跳至主要内容

离线优先支持

离线优先应用程序是指能够在断开互联网连接的情况下提供大部分或全部功能的应用程序。离线优先应用程序通常依赖于存储的数据,以便为用户提供对原本仅在线可用的数据的临时访问。

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

在本指南中,您将学习如何在 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 食谱中的数据库。

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

此示例还使用 UserProfile 数据类,该类是使用freezed 包创建的。

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

在具有复杂数据的应用程序中,例如当远程数据包含比 UI 所需更多的字段时,您可能希望为 API 和数据库服务创建一个数据类,为 UI 创建另一个数据类。例如,UserProfileLocal 用于数据库实体,UserProfileRemote 用于 API 响应对象,然后 UserProfile 用于 UI 数据模型类。UserProfileRepository 将在必要时负责在它们之间进行转换。

此示例还包括 UserProfileViewModel,这是一个使用 UserProfileRepository 在小部件上显示 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 会发出两个值,本地存储的数据和来自服务器的数据。

首先,流使用 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 消息传递将少量数据有效负载推送到设备,并使用后台消息远程触发同步任务。

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

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

整合所有内容

#

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

关键要点是

  • 读取数据时,可以使用Stream将本地存储的数据与远程数据组合。
  • 写入数据时,请确定您是否需要在线或离线,以及是否需要稍后同步数据。
  • 实现后台同步任务时,请考虑设备状态和应用程序需求,因为不同的应用程序可能具有不同的需求。