18 · 本地存储与持久化
目标:给 Flutter App 补上"数据落地"这一课——登录态、用户设置、离线缓存、结构化数据各用什么方案,以及每个方案的最小可用代码。
💡 本篇是通用知识篇:代码可直接粘进
lib/main_playground.dart运行(见 README · 配套练习场)。
开篇:前端概念对照
| 前端(Web/React/Vue) | Flutter 对应 | 说明 |
|---|---|---|
localStorage | shared_preferences | 小型 KV,字符串/数字/布尔 |
sessionStorage | (无直接对应) | Flutter 无会话级存储概念,App 重启即全部消失 |
IndexedDB | Hive / Isar / sqflite | 结构化/大量数据 |
| SQLite (sql.js) | drift / sqflite | 关系型查询 |
| Cookie(HttpOnly) | flutter_secure_storage | 敏感信息,加密存储 |
| 内存态(Zustand/Pinia) | Riverpod State | 运行时状态,不落盘(见 08 篇) |
核心心智模型:前端把"内存态"和"持久化"混在 localStorage 里用是坏习惯;Flutter 生态把它们分得很清——运行时状态归 Riverpod,持久化归本篇,两者通过 Riverpod 的初始化钩子衔接(第五节)。
一、先看选型:一张决策表
| 需求 | 方案 | 理由 |
|---|---|---|
| 用户设置(主题、语言、开关) | shared_preferences | 量小、纯 KV、同步语义简单 |
| Token / 密码等敏感信息 | flutter_secure_storage | iOS Keychain / Android Keystore 硬件级加密 |
| 离线缓存对象(列表、详情快照) | Hive(或 Isar) | 无 SQL、速度快、对象直接存取 |
| 复杂查询 / 关系 / 统计 | drift(SQLite) | 类型安全 SQL、支持迁移 |
| 大文件(图片、附件) | 文件系统 path_provider | 数据库不放 Blob |
Hive vs Isar vs drift:轻量缓存选 Hive(作者已停止大更新但生态稳定
[核对当前状态]);需要索引/复杂查询的 NoSQL 选 Isar;需要 SQL/事务/迁移选 drift。新项目二选一即可,不要三个都上。
二、shared_preferences:99% 的"设置类"需求
flutter pub add shared_preferencesimport 'package:shared_preferences/shared_preferences.dart';
// 写
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', 'dark');
await prefs.setBool('pushEnabled', true);
await prefs.setInt('unreadCount', 3);
// 读
final theme = prefs.getString('theme') ?? 'light'; // 记得给默认值
// 删
await prefs.remove('theme');三个注意点:
- 所有读写是
Future,但首次getInstance后有内存缓存,读操作实际上很快 - 只能存
String/int/double/bool/List<String>——存对象要先 JSON 编码:
// 对象序列化模板
await prefs.setString('user_profile', jsonEncode(user.toJson()));
final user = userFromJson(jsonDecode(prefs.getString('user_profile') ?? '{}'));- 不要用它存 Token(安卓上是明文 XML、iOS 是普通 plist),下一节的 secure storage 才对。
三、flutter_secure_storage:Token 等敏感数据
flutter pub add flutter_secure_storageimport 'package:flutter_secure_storage/flutter_secure_storage.dart';
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: 'access_token', value: token);
final token = await storage.get('access_token'); // 注意:这个是真异步,无内存缓存
await storage.delete(key: 'access_token');与 shared_preferences 的关键差异:每次读都走平台通道(Keychain/Keystore),没有内存缓存,高频读取要自己缓存到 Riverpod 状态里。本项目登录态的做法可对照 09 篇数据层链路的 Token 刷新链路——刷新令牌的存放用 secure storage,运行时使用放内存状态。
四、Hive:对象级离线缓存
flutter pub add hive hive_flutter
flutter pub add --dev build_runner hive_generator4.1 用 TypeAdapter 存类型化对象(推荐)
import 'package:hive/hive.dart';
part 'device_model.g.dart'; // build_runner 生成
@HiveType(typeId: 1)
class Device extends HiveObject {
@HiveField(0) String id;
@HiveField(1) String name;
@HiveField(2) bool online;
Device({required this.id, required this.name, required this.online});
}dart run build_runner build # 生成 .g.dart(工作流见 16 篇)// 初始化(main.dart 里,await WidgetsFlutterBinding.ensureInitialized() 之后)
await Hive.initFlutter();
Hive.registerAdapter(DeviceAdapter());
final deviceBox = await Hive.openBox<Device>('devices');
// 用:对象直接存取,无需 JSON
await deviceBox.put('d001', Device(id: 'd001', name: '空调', online: true));
final d = deviceBox.get('d001'); // 同步读!缓存命中后是内存速度
await deviceBox.delete('d001');
// 监听变化(类似前端 store 的 subscribe)
deviceBox.listenable().addListener(() => print('box 变了'));4.2 典型架构:离线优先(offline-first)
┌──────────────┐ 成功时写缓存
UI ◀──▶ Riverpod State ◀──▶ Repository ──▶ Retrofit/Dio ──▶ 服务端
▲ │
└── 启动时先读 ──▶ Hive Box先展示缓存(秒开),请求成功后刷新缓存和 UI,失败时兜底展示旧数据。Repository 分层见 09 篇,Hive 只是把那套"三件套"里的数据源多了一种。
五、drift:需要 SQL 的时候
判断标准:有 WHERE/JOIN/聚合/排序分页需求,或数据会超过几千条,就上 drift。
flutter pub add drift drift_flutter
flutter pub add --dev drift_dev build_runner// database.dart
import 'package:drift/drift.dart';
// 表定义
class AlarmRecords extends Table {
TextColumn get id => text()();
TextColumn get deviceName => text()();
DateTimeColumn get createdAt => dateTime()();
BoolColumn get handled => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [AlarmRecords])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(driftDatabase(name: 'app_db')); // drift_flutter 快捷方式
@override
int get schemaVersion => 1;
// 类型安全的查询
Stream<List<AlarmRecord>> watchUnhandled() {
return (select(alarmRecords)..where((t) => t.handled.equals(false)))
.watch(); // Stream:数据变了 UI 自动刷新
}
Future<void> markHandled(String id) =>
(update(alarmRecords)..where((t) => t.id.equals(id)))
.write(AlarmRecordsCompanion(handled: Value(true)));
}dart run build_runner build与前端最像的一点:watch() 返回 Stream,配合 Riverpod 的 StreamProvider 实现"数据库变了列表自动刷"——相当于 Room + Flow 或 IndexedDB observer 的体验:
final unhandledAlarms = StreamProvider((ref) {
final db = ref.watch(appDatabaseProvider);
return db.watchUnhandled();
});迁移(版本升级必做)
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(alarmRecords, alarmRecords.handled); // v1 → v2 加列
}
},
);规则:每次改表结构,schemaVersion +1 并在 onUpgrade 补一段迁移。上线后用户库里是旧版本,没有迁移就是白屏/崩溃。
六、与 Riverpod 衔接的标准姿势
持久化初始化是异步的,而 App 启动是同步的——用 ProviderScope.overrides 把"已就绪"的实例注入:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final deviceBox = await Hive.openBox<Device>('devices');
runApp(ProviderScope(
overrides: [
sharedPrefsProvider.overrideWithValue(prefs),
deviceBoxProvider.overrideWithValue(deviceBox),
],
child: const MyApp(),
));
}
final sharedPrefsProvider = Provider<SharedPreferences>((_) => throw UnimplementedError());
final deviceBoxProvider = Provider<Box<Device>>((_) => throw UnimplementedError());之后任何 Provider/页面里 ref.watch(sharedPrefsProvider) 都是安全同步的。这个模式与本项目 main.dart 的启动初始化思路一致(对照 07 篇启动三阶段)。
七、常见陷阱速查
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
Token 存 shared_preferences | 逆向/Root 后明文泄露 | 用 flutter_secure_storage |
| 大列表 JSON 塞 preferences | 读写卡顿(整个文件重写) | 换 Hive/drift |
Hive typeId 重复或改字段不递增 fieldId | 反序列化崩溃 | typeId 全局唯一;只加字段不改旧编号 |
| drift 改表忘迁移 | 升级后崩溃 | 版本+1 并写 onUpgrade |
每帧 await SharedPreferences.getInstance() | 无谓的异步开销 | main 里初始化一次,override 注入 |
| 缓存无限增长 | 磁盘膨胀 | 加过期策略/TTL,启动时清理 |
| UI 直接 await 存储再 build | 首帧慢 | 存储只在 Repository/Provider 层碰,UI 只读状态 |
八、动手练习
练习 8.1(设置页):做一个"深色模式开关 + 字号滑杆"设置页,用 shared_preferences 持久化,重启 App 后状态保留。(提示:主题状态用 StateNotifierProvider,初始化值从 prefs 读)
练习 8.2(离线缓存):给一个 mock 接口的设备列表加 Hive 缓存——有网时展示最新并写缓存,断网时读缓存兜底。(在 playground 里把 Dio 换成可失败的 mock 函数即可)
练习 8.3(结构化查询):用 drift 建"告警记录"表,实现"未处理告警数角标 + 按时间倒序的未处理列表",体验 watch() 驱动 UI。
九、自检清单
- [ ] 能说出四种存储方案各自的适用场景与边界
- [ ] 知道为什么 Token 不能放
shared_preferences - [ ] 能写出
main()里初始化持久化并通过ProviderScope.overrides注入的完整流程 - [ ] 知道 Hive 的
typeId/fieldId规则和 drift 的迁移规则 - [ ] 能描述"离线优先"架构中 缓存 / Repository / 状态 三者的协作顺序
导航:上一章 17 · 常见陷阱与速查表 | 下一章 19 · 测试实战