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

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

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

离线优先架构

#

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

此示例使用 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 {
    // ···
  }
}

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

dart
@freezed
abstract 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,使其定期运行,例如每五分钟一次。

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
abstract 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 将本地存储的数据与远程数据结合起来。
  • 写入数据时,决定你是否需要在线或离线,以及以后是否需要同步数据。
  • 实现后台同步任务时,请考虑设备状态和你的应用程序需求,因为不同的应用程序可能有不同的要求。