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

一些离线优先应用程序可以无缝地组合本地和远程数据,而另一些应用程序则会在应用程序使用缓存数据时通知用户。同样,一些应用程序会在后台同步数据,而另一些则需要用户显式同步。这完全取决于应用程序的需求和它提供的功能,由开发人员决定哪种实现最适合他们的需求。

在本指南中,您将学习如何在 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 {
    // ···
  }
}

本示例还使用了使用 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 执行网络调用以获取最新数据。如果请求成功,它会用新获取的数据更新数据库,然后将该值yield 给视图模型,以便显示给用户。

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

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