跳转至正文

离线优先支持

为应用中的某一功能实现离线优先支持。

离线优先应用是指在断网时仍能提供大部分或全部功能的应用。

离线优先应用通常依赖已存储的数据,让用户临时访问原本仅在联网时才可用的数据。

有些离线优先应用会无缝融合本地与远程数据,另一些则会在使用缓存数据时告知用户。同样地,有些应用在后台同步数据,另一些则要求用户显式触发同步。这完全取决于应用的需求及其提供的功能,由开发者自行决定哪种实现方式最契合自身需要。

本指南将介绍如何在 Flutter 中按 Flutter 架构指南 实现离线优先应用的不同方案。

离线优先架构

#

正如通用架构概念指南所述,Repository 充当单一数据源。它们负责呈现本地或远程数据,且应是唯一能修改数据的地方。在离线优先应用中,Repository 会合并不同的本地与远程数据源,在单一访问点呈现数据,与设备的联网状态无关。

本示例使用 UserProfileRepository,它是一个支持以离线优先方式获取并存储 UserProfile 对象的 Repository。

UserProfileRepository 使用两个不同的数据 service:一个处理远程数据,另一个处理本地数据库。

API 客户端 ApiClientService 通过 HTTP REST 调用连接远程服务。

dart
class ApiClientService {
  /// performs GET network request to obtain a UserProfile
  Future<UserProfile> getUserProfile() async {
    // ···
  }

  /// performs PUT network request to update a UserProfile
  Future<void> putUserProfile(UserProfile userProfile) async {
    // ···
  }
}

数据库 service DatabaseService 使用 SQL 存储数据,与 持久化存储架构:SQL 教程中的类似。

dart
class DatabaseService {
  /// Fetches the UserProfile from the database.
  /// Returns null if the user profile is not found.
  Future<UserProfile?> fetchUserProfile() async {
    // ···
  }

  /// Update UserProfile in the database.
  Future<void> updateUserProfile(UserProfile userProfile) async {
    // ···
  }
}

本示例还使用了通过 freezed package 创建的 UserProfile 数据类。

dart
@freezed
abstract class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
  }) = _UserProfile;
}

在数据较复杂的应用中,例如远程数据包含的字段多于 UI 所需时,你可能希望为 API 与数据库 service 准备一个数据类,再为 UI 准备另一个。例如,用 UserProfileLocal 表示数据库实体、用 UserProfileRemote 表示 API 响应对象,再用 UserProfile 作为 UI 数据模型类。 UserProfileRepository 会在必要时负责在它们之间相互转换。

本示例还包含 UserProfileViewModel,它是一个使用 UserProfileRepository 在 widget 上展示 UserProfile 的 view model。

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···
  final UserProfileRepository _userProfileRepository;

  UserProfile? get userProfile => _userProfile;
  // ···

  /// Load the user profile from the database or the network
  Future<void> load() async {
    // ···
  }

  /// Save the user profile with the new name
  Future<void> save(String newName) async {
    // ···
  }
}

读取数据

#

对于任何依赖远程 API 服务的应用而言,读取数据都是基础环节。

在离线优先应用中,你希望尽可能快地访问这些数据,并且无需依赖设备联网即可向用户提供数据。这与 乐观状态设计模式 类似。

本节将介绍两种不同方案:一种将数据库用作后备,另一种用 Stream 合并本地与远程数据。

将本地数据用作后备

#

第一种方案是通过后备机制实现离线支持,用于用户离线或网络调用失败的情形。

此时,UserProfileRepository 先尝试用 ApiClientService 从远程 API 服务器获取 UserProfile;若该请求失败,则返回 DatabaseService 中本地存储的 UserProfile

dart
Future<UserProfile> getUserProfile() async {
  try {
    // Fetch the user profile from the API
    final apiUserProfile = await _apiClientService.getUserProfile();
    //Update the database with the API result
    await _databaseService.updateUserProfile(apiUserProfile);

    return apiUserProfile;
  } catch (e) {
    // If the network call failed,
    // fetch the user profile from the database
    final databaseUserProfile = await _databaseService.fetchUserProfile();

    // If the user profile was never fetched from the API
    // it will be null, so throw an  error
    if (databaseUserProfile != null) {
      return databaseUserProfile;
    } else {
      // Handle the error
      throw Exception('User profile not found');
    }
  }
}

使用 Stream

#

一种更好的替代方案是用 Stream 呈现数据。在最理想的情况下,Stream 会发出两个值:本地存储的数据和来自服务器的数据。

首先,stream 通过 DatabaseService 发出本地存储的数据。该调用通常比网络调用更快、更不易出错,先执行它可以让 view model 尽早向用户展示数据。

如果数据库中没有任何缓存数据,则 Stream 完全依赖网络调用,只发出一个值。

随后,该方法用 ApiClientService 发起网络调用以获取最新数据。若请求成功,则用新获取的数据更新数据库,再将该值 yield 给 view model,以便展示给用户。

dart
Stream<UserProfile> getUserProfile() async* {
  // Fetch the user profile from the database
  final userProfile = await _databaseService.fetchUserProfile();
  // Returns the database result if it exists
  if (userProfile != null) {
    yield userProfile;
  }

  // Fetch the user profile from the API
  try {
    final apiUserProfile = await _apiClientService.getUserProfile();
    //Update the database with the API result
    await _databaseService.updateUserProfile(apiUserProfile);
    // Return the API result
    yield apiUserProfile;
  } catch (e) {
    // Handle the error
  }
}

view model 必须订阅该 Stream 并等待其完成。为此,对 Subscription 对象调用 asFuture() 并 await 其结果。

每获取到一个值,就更新 view model 的数据并调用 notifyListeners(),使 UI 展示最新数据。

dart
Future<void> load() async {
  await _userProfileRepository
      .getUserProfile()
      .listen(
        (userProfile) {
          _userProfile = userProfile;
          notifyListeners();
        },
        onError: (error) {
          // handle error
        },
      )
      .asFuture<void>();
}

仅使用本地数据

#

另一种可行方案是读取操作仅使用本地存储的数据。这种方案要求数据在某个时刻已预加载到数据库中,并需要一套能保持数据最新的同步机制。

dart
Future<UserProfile> getUserProfile() async {
  // Fetch the user profile from the database
  final userProfile = await _databaseService.fetchUserProfile();

  // Return the database result if it exists
  if (userProfile == null) {
    throw Exception('Data not found');
  }

  return userProfile;
}

Future<void> sync() async {
  try {
    // Fetch the user profile from the API
    final userProfile = await _apiClientService.getUserProfile();

    // Update the database with the API result
    await _databaseService.updateUserProfile(userProfile);
  } catch (e) {
    // Try again later
  }
}

这种方案适用于无需数据始终与服务器保持同步的应用。例如天气应用,其天气数据每天仅更新一次。

同步既可由用户手动触发,例如下拉刷新动作随后调用 sync() 方法,也可由 Timer 或后台进程定期执行。你可以在「同步状态」一节中了解如何实现同步任务。

写入数据

#

在离线优先应用中,写入数据的方式从根本上取决于应用的使用场景。

有些应用要求用户输入的数据立即在服务器端可用,另一些则更灵活,允许数据临时处于不同步状态。

本节介绍在离线优先应用中实现数据写入的两种不同方案。

仅在线写入

#

在离线优先应用中写入数据的一种方案是强制要求联网才能写入。这听起来或许有违直觉,但能确保用户修改的数据与服务器完全同步,使应用不会与服务器处于不同的状态。

此时,你先尝试将数据发送给 API service,若请求成功,再将数据存入数据库。

dart
Future<void> updateUserProfile(UserProfile userProfile) async {
  try {
    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);

    // Only if the API call was successful
    // update the database with the user profile
    await _databaseService.updateUserProfile(userProfile);
  } catch (e) {
    // Handle the error
  }
}

这种方案的缺点是:离线优先功能仅对读取操作可用,对写入操作不可用,因为写入要求用户处于在线状态。

离线优先写入

#

第二种方案恰好相反。应用不先发起网络调用,而是先将新数据存入数据库,待本地存储完成后再尝试将其发送给 API service。

dart
Future<void> updateUserProfile(UserProfile userProfile) async {
  // Update the database with the user profile
  await _databaseService.updateUserProfile(userProfile);

  try {
    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);
  } catch (e) {
    // Handle the error
  }
}

这种方案允许用户即使在应用离线时也能将数据存储到本地,但如果网络调用失败,本地数据库与 API service 便不再同步。在下一节中,你将学习处理本地与远程数据同步的不同方案。

同步状态

#

保持本地与远程数据同步是离线优先应用的重要部分,因为本地所做的更改需要复制到远程服务。应用还必须确保用户重新回到应用时,本地存储的数据与远程服务中的数据一致。

编写同步任务

#

在后台任务中实现同步有多种不同方案。

一种简单方案是在 UserProfileRepository 中创建一个定期运行的 Timer,例如每五分钟一次。

dart
Timer.periodic(const Duration(minutes: 5), (timer) => sync());

sync() 方法随后从数据库获取 UserProfile,若其需要同步,则将其发送给 API service。

dart
Future<void> sync() async {
  try {
    // Fetch the user profile from the database
    final userProfile = await _databaseService.fetchUserProfile();

    // Check if the user profile requires synchronization
    if (userProfile == null || userProfile.synchronized) {
      return;
    }

    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);

    // Set the user profile as synchronized
    await _databaseService.updateUserProfile(
      userProfile.copyWith(synchronized: true),
    );
  } catch (e) {
    // Try again later
  }
}

更复杂的方案则使用 workmanager 等插件提供的后台进程。这样即使应用未在运行,也能在后台执行同步过程。

还建议仅在网络可用时才执行同步任务。例如,你可以用 connectivity_plus 插件检查设备是否已连接 WiFi,也可以用 battery_plus 确认设备电量是否充足。

前述示例中,同步任务每 5 分钟运行一次。在某些场景下这可能过于频繁,而在另一些场景下又不够频繁。应用实际的同步周期取决于你的应用需求,需要由你自行决定。

存储同步标志

#

为判断数据是否需要同步,可在数据类中添加一个标志,表示更改是否需要同步。

例如 bool synchronized

dart
@freezed
abstract class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
    @Default(false) bool synchronized,
  }) = _UserProfile;
}

你的同步逻辑应仅在 synchronized 标志为 false 时才尝试将数据发送给 API service。若请求成功,再将其改为 true

从服务器推送数据

#

另一种同步方案是使用推送服务向应用提供最新数据。此时由服务器在数据变更时通知应用,而非由应用主动请求更新。

例如,你可以用 Firebase messaging 向设备推送小型数据载荷,并通过后台消息远程触发同步任务。

服务器通过推送通知在存储数据需要更新时通知应用,从而无需在后台持续运行同步任务。

你也可以将两种方案结合使用:既运行后台同步任务,又使用后台推送消息,从而保持应用数据库与服务器同步。

总结

#

编写离线优先应用需要就读取、写入与同步操作的实现方式做出决策,而这些决策取决于你所开发应用的需求。

要点:

  • 读取数据时,可用 Stream 合并本地存储的数据与远程数据。

  • 写入数据时,需决定是要求在线还是允许离线,以及是否需要稍后同步数据。

  • 实现后台同步任务时,需考虑设备状态与应用需求,因为不同应用的要求可能不同。