☰
riverpod_sqflite 实战指南:基于 SQLite 的 Riverpod 离线持久化完整实现
2026/9/29 3:15:59 网站建设 项目流程

导读

riverpod_sqflite是 Riverpod 生态中官方提供的离线持久化实现,它通过 SQLite(sqflite)为 Riverpod 的状态提供跨应用重启的持久化能力。本指南以packages/riverpod_sqflite/README.md为骨架,结合 存储实现源码、核心持久化抽象 与 单元测试,带你从零搭建storageProvider连接器、在AsyncNotifier中接入persist,并深入理解缓存时间、销毁键(destroyKey)与数据库表结构等底层机制。读完你将掌握一套可直接复制的「读库恢复状态 + 状态变更自动写库」的完整离线缓存方案。

什么是 riverpod_sqflite:官方离线持久化适配层

riverpod_sqflite(版本 0.4.7,见 pubspec.yaml)是 Riverpod 官方对「离线持久化」的 sqflite 落地实现。它并非一个独立的状态管理方案,而是一个Storage 适配器:负责把 Riverpod 的状态以 JSON 形式写入 SQLite 数据库,并在下次启动时读回。

它的角色可以在 Storage 抽象 的文档注释中得到印证——Riverpod 核心库把「如何与数据库交互」抽象为Storage<KeyT, EncodedT>接口,并明确说明「Storages are generally implemented by third-party packages. Riverpod provides an official implementation of [Storage] that stores data using SQLite, in theriverpod_sqflitepackage.」也就是说:

  • riverpod核心包只负责持久化流程编排(何时读、何时写、何时删);
  • riverpod_sqflite负责具体的数据库读写;
  • 如果你愿意,也可以实现自己的Storage(例如使用 Hive、SharedPreferences),只需满足read / write / delete / deleteOutOfDate四个方法即可。

从源码导出口 riverpod_sqflite.dart 可以看到,该包对外只暴露一个类:JsonSqFliteStorage。

第一步:创建数据库连接器 storageProvider

按照 README 的用法,首先需要创建一个通往数据库的连接器。官方推荐的做法是封装成一个FutureProvider<JsonSqFliteStorage>,让所有需要持久化的 Provider 共享同一个存储实例:

final storageProvider = FutureProvider<JsonSqFliteStorage>((ref) async { // Initialize SQFlite. We should share the Storage instance between providers. return JsonSqFliteStorage.open( join(await getDatabasesPath(), 'riverpod.db'), ); });

这段代码涉及两个关键点:

  1. JsonSqFliteStorage.open(path)是唯一的构造入口(构造函数本身是私有的)。它在内部做了三件事(见 riverpod_sqflite.dart):
    • 调用openDatabase(path, version: 1, ...)打开(或创建)指定路径的 SQLite 数据库;
    • 通过onCreate回调执行建表语句,确保riverpod表存在;
    • 调用deleteOutOfDate()清理所有已过期的数据。
  2. 共享单例:数据库连接应当全局唯一、跨 Provider 复用。注释 "We should share the Storage instance between providers" 明确强调了这一点——多个 Provider 各自 open 会产生多个数据库连接,浪费资源且容易引发竞争。

数据库文件的路径使用join(await getDatabasesPath(), 'riverpod.db'),其中getDatabasesPath()来自 sqflite,返回应用专属的数据库目录;join来自package:path,用于跨平台安全地拼接路径。完整示例可参考 example/lib/manual.dart。

底层的表结构设计

JsonSqFliteStorage在打开数据库时会创建一张名为riverpod的表(源码 riverpod_sqflite.dart):

CREATE TABLE IF NOT EXISTS riverpod( key TEXT PRIMARY KEY NOT NULL, json TEXT, expireAt INTEGER, destroyKey TEXT ) WITHOUT ROWID

四个字段的语义与核心库的PersistedData一一对应(见 persist.dart):

字段类型含义
keyTEXT(主键)持久化状态在数据库中的唯一标识,由persist(key: ...)指定
jsonTEXT经encode编码后的状态序列化内容
expireAtINTEGER过期时间戳(UTC 毫秒),由cacheTime计算而来;null表示永不过期
destroyKeyTEXT数据销毁键,用于强制作废旧数据(见下文 destroyKey 详解)

WITHOUT ROWID是 SQLite 的优化选项,由于key是主键且表结构紧凑,该表可以直接以主键作为行存储,减少一层索引开销。写入时使用ConflictAlgorithm.replace(源码),即「键已存在则整体覆盖」,天然支持 upsert 语义。

第二步:在 Notifier 中 mix-in Persistable 并调用 persist

数据库连接器就绪后,接下来就是把某个 Notifier 的状态接入持久化。README 给出的核心范式是:让AsyncNotifier在build方法开头调用persist。

class TodosNotifier extends AsyncNotifier<List<Todo>> { @override FutureOr<List<Todo>> build() async { // We call persist at the start of our `build` method. // This will: // - Read the DB and update the state with the persisted value the first // time this method executes. // - Listen to changes on this provider and write those changes to the DB. // We "await" for persist to complete to make sure that the decoding is done // before we return the state. // If you do not care about the decoded value, don't await the future. await persist( // We pass our JsonSqFliteStorage instance. No need to "await" the Future. // Riverpod will take care of that. ref.watch(storageProvider.future), // A unique key for this state. // No other provider should use the same key. key: 'todos', // By default, state is cached offline only for 2 days. // In this example, we tell Riverpod to cache the state forever. options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever), encode: jsonEncode, decode: (json) { final decoded = jsonDecode(json) as List; return decoded .map((e) => Todo.fromJson(e as Map<String, Object?>)) .toList(); }, ).future; // If a state is persisted, we return it. Otherwise we return an empty list. return state.value ?? []; } Future<void> add(Todo todo) async { // When modifying the state, no need for any extra logic to persist the change. // Riverpod will automatically cache the new state and write it to the DB. state = AsyncData([...await future, todo]); } }

这段代码是离线持久化的「最小完整闭环」,其核心机制需要拆解为四个层次:

1.persist的双向职责

persist(来自package:riverpod/experimental/persist.dart导出的NotifierPersistXmixin)在首次执行build时做两件事:

  • 读:从数据库读取key: 'todos'对应的历史状态,解码后写入当前AsyncNotifier的 state;
  • 写:订阅该 Provider 的状态变化,每次 state 更新时自动把新状态编码后写入数据库。

这正是 README 注释中 "Read the DB and update the state with the persisted value" + "Listen to changes on this provider and write those changes to the DB" 的完整含义。因此,add(Todo)方法里只写了一行state = AsyncData([...await future, todo]),没有任何额外持久化代码——状态更新与落库是自动绑定的。

2. 为什么persist传入的是.future

ref.watch(storageProvider.future)得到的是一个Future<JsonSqFliteStorage>而不是存储实例本身。README 注释说明 "No need to 'await' the Future. Riverpod will take care of that."——persist内部会自行等待存储就绪。这样storageProvider与其他 Provider 之间形成了自然的依赖图,存储初始化顺序由 Riverpod 保证。

3.await persist(...).future与return state.value ?? []的组合

  • persist返回的 future 表示「解码完成」这一时刻。await它确保从数据库读回的状态已经合并进当前 state,之后state.value才可靠;
  • README 注释特别提醒:如果你不关心读回的解码值,可以不 await(例如仅在启动时静默恢复缓存);
  • return state.value ?? []是兜底逻辑:数据库中有历史状态就返回它,否则返回空列表作为初始数据。这里state.value只可能来自两种来源——persist刚写入的恢复值,或上次 build 已计算的值。

4. 序列化契约

  • encode: jsonEncode:List<Todo>→ JSON 字符串;
  • decode:JSON 字符串 →List<Todo>,通过Todo.fromJson逐条还原;
  • key: 'todos'必须是全局唯一键(README 强调 "No other provider should use the same key"),因为数据库表以key为主键,撞键会导致状态互相覆盖。

深入 StorageOptions:cacheTime 与 destroyKey

persist的第三个参数options控制缓存生命周期策略,其完整定义位于 StorageOptions:

const StorageOptions({ this.destroyKey, this.cacheTime = const StorageCacheTime(Duration(days: 2)), });

cacheTime:默认缓存 2 天

默认值是Duration(days: 2),即状态只在数据库里保留 2 天。过期数据的清理时机有两个(见 persist.dart 的注释):

  • 应用重启时(JsonSqFliteStorage.open会先执行deleteOutOfDate);
  • 过期后再次读取该 Provider 时。

StorageCacheTime提供了两个构造形态(源码):

const StorageCacheTime(Duration this.duration); // 自定义有效期 static const unsafe_forever = StorageCacheTime._(null); // 永不过期
  • StorageCacheTime(Duration(days: 3)):自定义 3 天有效期;
  • StorageCacheTime.unsafe_forever:永不过期。

关于unsafe_forever,核心库源码给出了重要的警告(persist.dart):不推荐无条件永久持久化。因为如果某天你从应用源码中删除了该 Provider,旧用户的数据库里仍会残留它的数据,且 Riverpod 不会提供任何清理工具——届时你必须自己写数据库迁移来删除这些孤儿数据。这正是它名字里 "unsafe" 的由来。README 的示例为了演示「缓存永远有效」而刻意使用了它,实际项目请权衡取舍。

destroyKey:绕过复杂迁移的「状态作废开关」

destroyKey是 README 未展开、但源码明确支持的高价值特性(persist.dart):

当某个 Provider 的状态发生了破坏性变更(如数据结构重构),与其写复杂的数据库迁移,不如在发布前修改该 Provider 的destroyKey。一旦destroyKey变化,旧状态会被销毁,新状态从头重建。

使用要点:

  • 该值应在应用重启间保持稳定,强烈建议使用常量;
  • 变更它即触发「旧数据作废」,同时PersistedData会携带destroyKey元数据参与比较(persist.dart);
  • 在 SQLite 侧,destroyKey被单独存入一列(destroyKey TEXT),写入时仅在非空时才落库(riverpod_sqflite.dart)。

数据库读写与过期清理的底层实现

JsonSqFliteStorage的四个核心方法完整覆盖了Storage抽象接口(Storage 接口定义):

方法职责sqflite 实现要点(见 riverpod_sqflite.dart)
open(path)打开库、建表、清过期openDatabase+onCreate建表 + 启动即清理(L31-L48)
read(key)按主键读取事务内query加limit: 1,空结果返回null(L84-L96)
write(key, value, options)写入/更新insert配合ConflictAlgorithm.replace,按cacheTime计算expireAt(L99-L110)
delete(key)删除指定键delete按key = ?条件删除(L77-L79)
deleteOutOfDate()清理全部过期数据事务内先建表(容错)再delete where expireAt < 当前时间(L58-L74)

值得注意的实现细节:

  • 过期时间统一使用 UTC 毫秒时间戳(clock.now().toUtc().millisecondsSinceEpoch,见 riverpod_sqflite.dart),并依赖clock包取时间——这使测试可以借助 fake clock 模拟时间流逝;
  • deleteOutOfDate在事务里先执行建表语句再删除(L60-L66),这样即使表被外部意外删掉,调用也不至于抛错;
  • 读取时允许返回过期数据(Storage.read 契约),是否过滤由persist的上层逻辑决定,存储层只负责「存」与「取」。

测试如何验证:基于 fake clock 与内存数据库

仓库的 persist_test.dart 是理解上述行为的最佳佐证。它通过sqflite_common_ffi在桌面环境初始化 sqflite(sqfliteFfiInit()+databaseFactoryFfi),并使用inMemoryDatabasePath跑内存库:

  • Clears expired keys on creation:写入一个默认 2 天缓存的数据和一个 3 天缓存的数据,用fakeAsync拨快 3 天时钟后再open一次数据库,断言表内只剩maintained一条——精确验证了「过期数据在 open 时被清除」;
  • returns null on unknown keys/returns the value if it exists/returns null after a delete:分别验证read的三种分支:未命中返回null、命中返回PersistedData<String>、删除后返回null。

如果你想在真实设备/模拟器上跑这套测试,只需在项目里添加sqflite_common_ffi作为 dev dependency(pubspec.yaml 正是这么做的)。

进阶:用 @JsonPersist 注解配合代码生成

README 展示的是手写persist的方式,而仓库示例还提供了基于代码生成的等效写法(example/lib/generated.dart)。它与手写版的差别在于:

  • 使用@JsonPersist()注解标记 Notifier,其定义位于 riverpod_annotation/experimental/json_persist.dart,文档说明:被注解的状态对象必须是原始类型(int、String、bool、double、List、Map),或实现了fromJson/toJson方法对的对象;
  • 生成器会自动注入encode/decode逻辑,因此persist调用不再需要手写encode:与decode:参数:
@riverpod @JsonPersist() class TodosNotifier extends _$TodosNotifier { @override FutureOr<List<Todo>> build() async { persist( ref.watch(storageProvider.future), options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever), ); return state.value ?? []; } Future<void> add(Todo todo) async { state = AsyncData([...await future, todo]); } }

两版示例对照阅读效果最佳:手写版 manual.dart 展示了每个参数的显式含义,生成版 generated.dart 展示了生产环境中更简洁的写法(配合freezed定义Todo.fromJson)。注意两版在persist的 await 处理上有细微差异:README/手写版在build中await persist(...).future,而生成版示例未 await——两者都合法,区别仅在于「是否等到解码完成再返回 state」,可按 README 注释的指引按需选择。

依赖与集成清单

在 Flutter 项目中启用riverpod_sqflite,需要的最小依赖如下(对应 pubspec.yaml):

dependencies: riverpod: 3.4.3 # 提供 Storage 抽象与 persist mixin riverpod_sqflite: ^0.4.7 # 本适配包 sqflite: ^2.4.1 # SQLite 数据库驱动 path: ^1.8.0 # 路径拼接(join/getDatabasesPath 场景)

其中riverpod_sqflite还间接依赖clock(过期时间计算)与meta(注解支持)。若采用代码生成路线,还需追加riverpod_annotation、riverpod_generator与build_runner;若想在本机(桌面)运行仓库内的 persist_test.dart,则需在 dev dependencies 中加入sqflite_common_ffi。

集成后的完整数据流可以概括为一条闭环:

  1. 应用启动 →storageProvider执行 →JsonSqFliteStorage.open()建表并清理过期行;
  2. 首个依赖持久化的 Notifier 执行build→persist读库恢复历史状态(若有);
  3. 运行期state = ...更新 →persist自动把新状态编码写入数据库;
  4. 下次启动重复第 2 步,用户看到的是上次会话结束时的状态——即离线持久化的全部意义。
  • 前端
  • 移动开发

【免费下载链接】riverpod

A reactive caching and>项目地址:https://gitcode.com/gh_mirrors/ri/riverpod

点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询