Skip to content

18 · 本地存储与持久化

目标:给 Flutter App 补上"数据落地"这一课——登录态、用户设置、离线缓存、结构化数据各用什么方案,以及每个方案的最小可用代码

💡 本篇是通用知识篇:代码可直接粘进 lib/main_playground.dart 运行(见 README · 配套练习场)。


开篇:前端概念对照

前端(Web/React/Vue)Flutter 对应说明
localStorageshared_preferences小型 KV,字符串/数字/布尔
sessionStorage(无直接对应)Flutter 无会话级存储概念,App 重启即全部消失
IndexedDBHive / 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_storageiOS 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% 的"设置类"需求

bash
flutter pub add shared_preferences
dart
import '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');

三个注意点

  1. 所有读写是 Future,但首次 getInstance 后有内存缓存,读操作实际上很快
  2. 只能存 String/int/double/bool/List<String>——存对象要先 JSON 编码:
dart
// 对象序列化模板
await prefs.setString('user_profile', jsonEncode(user.toJson()));
final user = userFromJson(jsonDecode(prefs.getString('user_profile') ?? '{}'));
  1. 不要用它存 Token(安卓上是明文 XML、iOS 是普通 plist),下一节的 secure storage 才对。

三、flutter_secure_storage:Token 等敏感数据

bash
flutter pub add flutter_secure_storage
dart
import '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:对象级离线缓存

bash
flutter pub add hive hive_flutter
flutter pub add --dev build_runner hive_generator

4.1 用 TypeAdapter 存类型化对象(推荐)

dart
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});
}
bash
dart run build_runner build     # 生成 .g.dart(工作流见 16 篇)
dart
// 初始化(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。

bash
flutter pub add drift drift_flutter
flutter pub add --dev drift_dev build_runner
dart
// 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)));
}
bash
dart run build_runner build

与前端最像的一点watch() 返回 Stream,配合 Riverpod 的 StreamProvider 实现"数据库变了列表自动刷"——相当于 Room + Flow 或 IndexedDB observer 的体验:

dart
final unhandledAlarms = StreamProvider((ref) {
  final db = ref.watch(appDatabaseProvider);
  return db.watchUnhandled();
});

迁移(版本升级必做)

dart
@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 把"已就绪"的实例注入:

dart
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 · 测试实战