离线优先支持
离线优先应用程序是一种即使断开互联网连接也能提供大部分或全部功能的应用程序。离线优先应用程序通常依赖于存储的数据,以便为用户提供临时访问那些本来只能在线访问的数据。
一些离线优先应用程序无缝地结合了本地和远程数据,而其他应用程序则会在使用缓存数据时通知用户。同样,一些应用程序在后台同步数据,而另一些则需要用户显式地同步数据。这一切都取决于应用程序的要求和它提供的功能,开发人员需要决定哪种实现最符合他们的需求。
在本指南中,您将学习如何在 Flutter 中实现离线优先应用程序的不同方法,遵循Flutter 架构指南。
离线优先架构
#如通用架构概念指南中所述,存储库充当单一事实来源。它们负责呈现本地或远程数据,并且应该是唯一可以修改数据的地方。在离线优先应用程序中,存储库结合不同的本地和远程数据源,以在单个访问点呈现数据,而与设备的连接状态无关。
此示例使用 UserProfileRepository
,这是一个允许您获得和存储具有离线优先支持的 UserProfile
对象的存储库。
UserProfileRepository
使用两种不同的数据服务:一种用于远程数据,另一种用于本地数据库。
API 客户端 ApiClientService
使用 HTTP REST 调用连接到远程服务。
class ApiClientService {
/// 执行 GET 网络请求以获取 UserProfile
Future<UserProfile> getUserProfile() async {
// ···
}
/// 执行 PUT 网络请求以更新 UserProfile
Future<void> putUserProfile(UserProfile userProfile) async {
// ···
}
}
数据库服务 DatabaseService
使用 SQL 存储数据,类似于持久性存储架构:SQL 食谱中找到的数据库服务。
class DatabaseService {
/// 从数据库中获取 UserProfile。
/// 如果未找到用户配置文件,则返回 null。
Future<UserProfile?> fetchUserProfile() async {
// ···
}
/// 更新数据库中的 UserProfile。
Future<void> updateUserProfile(UserProfile userProfile) async {
// ···
}
}
此示例还使用 UserProfile
数据类,该类是使用 freezed
包创建的。
@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
的视图模型。
class UserProfileViewModel extends ChangeNotifier {
// ···
final UserProfileRepository _userProfileRepository;
UserProfile? get userProfile => _userProfile;
// ···
/// 从数据库或网络加载用户配置文件
Future<void> load() async {
// ···
}
/// 使用新名称保存用户配置文件
Future<void> save(String newName) async {
// ···
}
}
读取数据
#读取数据是任何依赖于远程 API 服务的应用程序的基本部分。
在离线优先应用程序中,您希望确保访问此数据的速度尽可能快,并且它不依赖于设备在线才能向用户提供数据。这类似于乐观状态设计模式。
在本节中,您将学习两种不同的方法,一种使用方法作为后备,另一种使用方法组合本地和远程数据。
使用本地数据作为后备
#作为第一种方法,您可以通过为用户离线或网络调用失败时提供后备机制来实现离线支持。
在这种情况下,UserProfileRepository
尝试使用 ApiClientService
从远程 API 服务器获取 UserProfile
。如果此请求失败,则返回 DatabaseService
中本地存储的 UserProfile
。
Future<UserProfile> getUserProfile() async {
try {
// 从 API 获取用户配置文件
final apiUserProfile = await _apiClientService.getUserProfile();
// 使用 API 结果更新数据库
await _databaseService.updateUserProfile(apiUserProfile);
return apiUserProfile;
} catch (e) {
// 如果网络调用失败,
// 从数据库中获取用户配置文件
final databaseUserProfile = await _databaseService.fetchUserProfile();
// 如果从未从 API 获取用户配置文件
// 它将为 null,因此会抛出错误
if (databaseUserProfile != null) {
return databaseUserProfile;
} else {
// 处理错误
throw Exception('未找到用户配置文件');
}
}
}
使用流
#更好的替代方案是使用流呈现数据。在最佳情况下,流发出两个值,本地存储的数据和来自服务器的数据。
首先,流使用 DatabaseService
发出本地存储的数据。此调用通常比网络调用更快且错误更少,并且通过首先执行此操作,视图模型已经可以向用户显示数据。
如果数据库不包含任何缓存数据,则流完全依赖于网络调用,只发出一个值。
然后,该方法使用 ApiClientService
执行网络调用以获取最新的数据。如果请求成功,它将使用新获得的数据更新数据库,然后将值传递给视图模型,以便可以将其显示给用户。
Stream<UserProfile> getUserProfile() async* {
// 从数据库中获取用户配置文件
final userProfile = await _databaseService.fetchUserProfile();
// 如果存在,则返回数据库结果
if (userProfile != null) {
yield userProfile;
}
// 从 API 获取用户配置文件
try {
final apiUserProfile = await _apiClientService.getUserProfile();
// 使用 API 结果更新数据库
await _databaseService.updateUserProfile(apiUserProfile);
// 返回 API 结果
yield apiUserProfile;
} catch (e) {
// 处理错误
}
}
视图模型必须订阅此流并等待其完成。为此,使用 Subscription
对象调用 asFuture()
并等待结果。
对于每个获得的值,更新视图模型数据并调用 notifyListeners()
,以便 UI 显示最新数据。
Future<void> load() async {
await _userProfileRepository.getUserProfile().listen((userProfile) {
_userProfile = userProfile;
notifyListeners();
}, onError: (error) {
// 处理错误
}).asFuture();
}
只使用本地数据
#另一种可能的方法是使用本地存储的数据进行读取操作。这种方法要求数据在某个时间点已预加载到数据库中,并且需要一种可以保持数据最新的同步机制。
Future<UserProfile> getUserProfile() async {
// 从数据库中获取用户配置文件
final userProfile = await _databaseService.fetchUserProfile();
// 如果存在,则返回数据库结果
if (userProfile == null) {
throw Exception('数据未找到');
}
return userProfile;
}
Future<void> sync() async {
try {
// 从 API 获取用户配置文件
final userProfile = await _apiClientService.getUserProfile();
// 使用 API 结果更新数据库
await _databaseService.updateUserProfile(userProfile);
} catch (e) {
// 稍后重试
}
}
这种方法对于不需要数据始终与服务器同步的应用程序很有用。例如,天气应用程序,其中天气数据每天只更新一次。
同步可以由用户手动完成,例如,下拉刷新操作然后调用 sync()
方法,或者由 Timer
或后台进程定期完成。您可以在有关同步状态的部分中学习如何实现同步任务。
写入数据
#在离线优先应用程序中写入数据根本上取决于应用程序的用例。
某些应用程序可能需要用户输入的数据立即在服务器端可用,而其他应用程序可能更灵活,并允许数据暂时不同步。
本节说明在离线优先应用程序中实现数据写入的两种不同方法。
只在线写入
#在离线优先应用程序中写入数据的一种方法是强制在线写入数据。虽然这听起来可能违反直觉,但这确保了用户修改的数据已完全与服务器同步,并且应用程序的状态与服务器的状态没有差异。
在这种情况下,您首先尝试将数据发送到 API 服务,如果请求成功,则将数据存储在数据库中。
Future<void> updateUserProfile(UserProfile userProfile) async {
try {
// 使用用户配置文件更新 API
await _apiClientService.putUserProfile(userProfile);
// 只有在 API 调用成功后
// 使用用户配置文件更新数据库
await _databaseService.updateUserProfile(userProfile);
} catch (e) {
// 处理错误
}
}
在这种情况下,缺点是离线优先功能仅适用于读取操作,而不适用于写入操作,因为写入操作需要用户在线。
离线优先写入
#第二种方法则相反。应用程序不是首先执行网络调用,而是首先将新数据存储在数据库中,然后在本地存储后尝试将其发送到 API 服务。
Future<void> updateUserProfile(UserProfile userProfile) async {
// 使用用户配置文件更新数据库
await _databaseService.updateUserProfile(userProfile);
try {
// 使用用户配置文件更新 API
await _apiClientService.putUserProfile(userProfile);
} catch (e) {
// 处理错误
}
}
这种方法允许用户即使在应用程序离线时也能在本地存储数据,但是,如果网络调用失败,本地数据库和 API 服务将不再同步。在下一节中,您将学习处理本地和远程数据之间同步的不同方法。
同步状态
#保持本地和远程数据的同步是离线优先应用程序的重要组成部分,因为本地所做的更改需要复制到远程服务。应用程序还必须确保,当用户返回应用程序时,本地存储的数据与远程服务中的数据相同。
编写同步任务
#在后台任务中实现同步有不同的方法。
一个简单的解决方案是在 UserProfileRepository
中创建一个定期运行的 Timer
,例如每五分钟运行一次。
Timer.periodic(
const Duration(minutes: 5),
(timer) => sync(),
);
然后,sync()
方法从数据库中获取 UserProfile
,如果需要同步,则将其发送到 API 服务。
Future<void> sync() async {
try {
// 从数据库中获取用户配置文件
final userProfile = await _databaseService.fetchUserProfile();
// 检查用户配置文件是否需要同步
if (userProfile == null || userProfile.synchronized) {
return;
}
// 使用用户配置文件更新 API
await _apiClientService.putUserProfile(userProfile);
// 将用户配置文件设置为已同步
await _databaseService
.updateUserProfile(userProfile.copyWith(synchronized: true));
} catch (e) {
// 稍后重试
}
}
更复杂的解决方案使用后台进程,例如 workmanager
插件。这允许您的应用程序即使在应用程序未运行时也能在后台运行同步过程。
还建议仅在网络可用时执行同步任务。例如,您可以使用 connectivity_plus
插件来检查设备是否连接到 WiFi。您还可以使用 battery_plus
来验证设备电量是否不足。
在前面的示例中,同步任务每 5 分钟运行一次。在某些情况下,这可能过于频繁,而在其他情况下,这可能不够频繁。应用程序的实际同步周期时间取决于您的应用程序需求,这是您必须决定的。
存储同步标志
#要了解数据是否需要同步,请向数据类添加一个标志,指示更改是否需要同步。
例如,bool synchronized
:
@freezed
class UserProfile with _$UserProfile {
const factory UserProfile({
required String name,
required String photoUrl,
@Default(false) bool synchronized,
}) = _UserProfile;
}
您的同步逻辑应该只在 synchronized
标志为 false
时尝试将其发送到 API 服务。如果请求成功,则将其更改为 true
。
从服务器推送数据
#同步的另一种方法是使用推送服务向应用程序提供最新的数据。在这种情况下,服务器在数据更改时通知应用程序,而不是应用程序请求更新。
例如,您可以使用Firebase 消息传递 将少量数据有效负载推送到设备,并使用后台消息远程触发同步任务。
无需在后台运行同步任务,服务器会在存储的数据需要使用推送通知更新时通知应用程序。
您可以将这两种方法结合起来,使用后台同步任务和使用后台推送消息,以保持应用程序数据库与服务器同步。
集成所有内容
#编写离线优先应用程序需要做出关于读取、写入和同步操作实现方式的决策,这取决于您正在开发的应用程序的要求。
关键要点是:
- 读取数据时,您可以使用流将本地存储的数据与远程数据结合起来。
- 写入数据时,请决定您是否需要在线或离线,以及您是否需要稍后同步数据。
- 实现后台同步任务时,请考虑设备状态和您的应用程序需求,因为不同的应用程序可能具有不同的需求。
除非另有说明,否则本网站上的文档反映的是 Flutter 的最新稳定版本。页面最后更新于 2025-01-30。 查看源代码 或 报告问题。