离线优先应用是指在断开互联网连接时,仍能提供其大部分或全部功能的应用程序。离线优先应用通常依赖于存储的数据,以便为用户提供对数据的临时访问,否则这些数据只能在线访问。

一些离线优先应用无缝地结合了本地和远程数据,而另一些应用则会在应用使用缓存数据时通知用户。同样地,一些应用在后台同步数据,而另一些应用则需要用户显式地同步数据。这一切都取决于应用程序的需求及其提供的功能,开发者需要决定哪种实现最适合他们的需求。

在本指南中,你将学习如何在 Flutter 中实现不同的离线优先应用方法,遵循 Flutter 架构指南

离线优先架构

#

正如通用架构概念指南中所解释的,存储库充当单一数据源。它们负责呈现本地或远程数据,并且应该是唯一可以修改数据的地方。在离线优先应用中,存储库结合了不同的本地和远程数据源,以在单个访问点中呈现数据,而与设备的连接状态无关。

此示例使用 UserProfileRepository,该存储库允许你获取和存储具有离线优先支持的 UserProfile 对象。

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

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 {
    // ···
  }
}

数据库服务 DatabaseService 使用 SQL 存储数据,类似于 持久化存储架构: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
class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
  }) = _UserProfile;
}

在具有复杂数据的应用中,例如当远程数据包含比 UI 需要的更多字段时,你可能希望为 API 和数据库服务使用一个数据类,并为 UI 使用另一个数据类。例如,数据库实体的 UserProfileLocal, API 响应对象的 UserProfileRemote,以及 UI 数据模型类的 UserProfileUserProfileRepository 将负责在必要时从一个转换为另一个。

此示例还包括 UserProfileViewModel,一个使用 UserProfileRepository 在 widget 上显示 UserProfile 的视图模型。

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 发出本地存储的数据。此调用通常比网络调用更快且不易出错,并且通过首先执行此操作,视图模型已经可以向用户显示数据。

如果数据库不包含任何缓存数据,则 Stream 完全依赖于网络调用,仅发出一个值。

然后,该方法使用 ApiClientService 执行网络调用以获取最新数据。如果请求成功,它会使用新获取的数据更新数据库,然后将该值传递给视图模型,以便可以将其显示给用户。

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
  }
}

视图模型必须订阅此 Stream 并等待其完成。为此,请使用 Subscription 对象调用 asFuture() 并等待结果。

对于每个获得的值,更新视图模型数据并调用 notifyListeners(),以便 UI 显示最新数据。

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

仅使用本地数据

#

另一种可能的方法是使用本地存储的数据进行读取操作。此方法要求数据已在某个时间点预加载到数据库中,并且需要一种可以使数据保持最新的同步机制。

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 服务,如果请求成功,则将数据存储在数据库中。

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 服务。

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 服务不再同步。在下一节中,你将学习处理本地和远程数据之间同步的不同方法。

同步状态

#

使本地和远程数据保持同步是离线优先应用程序的重要组成部分,因为已在本地完成的更改需要复制到远程服务。该应用还必须确保,当用户返回到应用程序时,本地存储的数据与远程服务中的数据相同。

编写同步任务

#

在后台任务中实现同步有不同的方法。

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

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

然后,sync() 方法从数据库中获取 UserProfile,如果需要同步,则将其发送到 API 服务。

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
class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
    @Default(false) bool synchronized,
  }) = _UserProfile;
}

你的同步逻辑应尝试仅当 synchronized 标志为 false 时,才将其发送到 API 服务。如果请求成功,则将其更改为 true

从服务器推送数据

#

另一种同步方法是使用推送服务向应用程序提供最新数据。在这种情况下,服务器会在数据更改时通知应用程序,而不是由应用程序请求更新。

例如,你可以使用 Firebase messaging,将少量数据有效负载推送到设备,以及使用后台消息远程触发同步任务。

服务器不是在后台运行同步任务,而是在需要使用推送通知更新存储的数据时通知应用程序。

你可以将这两种方法结合在一起,使用后台同步任务和使用后台推送消息,以使应用程序数据库与服务器同步。

整合在一起

#

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

关键要点是:

  • 读取数据时,你可以使用 Stream 将本地存储的数据与远程数据组合在一起。

  • 写入数据时,请确定你需要在线还是离线,以及是否需要稍后同步数据。

  • 在实现后台同步任务时,请考虑设备状态和应用程序需求,因为不同的应用程序可能具有不同的要求。