Files
conti-docs/flutter-app/06-local-storage.md
T

324 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 06. 本地存储方案
## 决策
按数据类型分三档存储,`feature_*` 不直接依赖底层存储库:
| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 |
|---|---|---|---|
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` |
| 敏感数据(token、refresh token | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** |
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` |
> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。
## 依赖
```yaml
# core_storage
dependencies:
drift: ^2.34.3
drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包
path_provider: ^2.1.6
shared_preferences: ^2.5.5
dev_dependencies:
drift_dev: ^2.34.5
build_runner: ^2.15.2
# core_auth
dependencies:
flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文
```
> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider``driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。
## 使用规则
- 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。
- 每个 feature 拥有自己的表(`Table` 类)和 DAO`DriftAccessor`),表名加 feature 前缀(如 `store_cache``payment_history`)避免命名冲突,但都注册进同一个 `AppDatabase`
- feature 的 `data``local_datasource` 只依赖自己的 DAO 类型,不直接操作 `AppDatabase` 或访问其他 feature 的表。
- token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。
- 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。
## 所有业务缓存表必须带 `storeId`
PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存——购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且
- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询;
- `storeId` 建索引;
- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。
```dart
class PurchaseOrderCache extends Table {
IntColumn get storeId => integer()();
TextColumn get orderId => text()();
TextColumn get payload => text()();
DateTimeColumn get cachedAt => dateTime()();
@override
Set<Column> get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离
}
```
不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。
## 登出 / 切换门店的清理策略
| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 |
|---|---|---|---|---|
| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md) |
| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage |
| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 |
**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。
```dart
// packages/core_storage/lib/src/app_database.dart
extension StoreScopedCleanup on AppDatabase {
/// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态
Future<void> clearBusinessCache() => transaction(() async {
for (final table in allTables.where(_isBusinessCache)) {
await delete(table).go();
}
});
}
```
**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。
**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。
## 缓存 TTL
Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。
| 数据 | TTL | 过期后行为 |
|---|---|---|
| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" |
| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading |
| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 |
| 经营/财务分析数据 | 不缓存 | — |
每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。
**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。
## Migration 必须被验证,不能只靠"写了"
"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。
Drift 官方提供了 schema 快照 + 验证工具链,纳入流程:
```bash
# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库)
fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/
# 2. 生成迁移测试的辅助代码
fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/
```
```dart
// packages/core_storage/test/migration_test.dart
void main() {
late SchemaVerifier verifier;
setUpAll(() => verifier = SchemaVerifier(GeneratedHelper()));
test('从 v1 到最新版本的迁移都能跑通', () async {
for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) {
final connection = await verifier.startAt(from);
final db = AppDatabase.forTesting(connection);
await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion);
await db.close();
}
});
}
```
规则:
- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。
- 迁移测试进 `melos run test`CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。
## flutter_secure_storage 11.0.0 的升级风险
`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**RSA OAEP + AES-GCM)。这意味着:
- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。
- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。
- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。
读取失败的兜底必须写:
```dart
Future<String?> readAccessToken() async {
try {
return await _storage.read(key: _kAccessToken);
} catch (e, st) {
// 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页
_logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st);
await clear();
return null;
}
}
```
**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。
## 数据库在后台 isolate 打开
大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter``driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可:
```dart
// packages/core_storage/lib/src/connection.dart
QueryExecutor openConnection() => driftDatabase(
name: 'conti_app',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份
),
);
```
iOS 上数据库文件放 `Application Support` 而不是 `Documents``Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。
## 参考链接
- [Drift 官方文档](https://drift.simonbinder.eu/)
- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter)
- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/)
- [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage)
- [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences)
## 附录:Drift 是什么,日常怎么用
给还没接触过这套本地数据库封装方式的同学看的入门说明。
### 要解决的问题
Flutter 生态里直接操作本地 SQLite 最常见的是 [`sqflite`](https://pub.dev/packages/sqflite),但它是纯 SQL 字符串拼接:
```dart
// sqflite 写法,容易手滑打错字段名/表名,编译期完全发现不了
await db.rawQuery('SELECT * FROM stroe WHERE nmae = ?', [name]);
```
字段名、表名全靠字符串,拼错了只有运行时才报错;查询结果是 `Map<String, Object?>`,还得手动转成业务对象;数据变化了想让 UI 自动刷新,也得自己手写一套通知机制。
**Drift**`sqflite`(或更底层的 `sqlite3`)之上加了一层代码生成:用 Dart 类定义表结构,`build_runner` 生成类型安全的查询代码,写错字段名/类型在编译期就会报错;查询结果直接是强类型的 Dart 对象;还内置了 `.watch()` 方法,数据变化时自动推送新结果,天然适合配合 Riverpod 的 `StreamProvider`/`AsyncNotifier` 做响应式 UI。
### 核心概念
1. **`Table` 类**:用 Dart 代码声明表结构(字段名、类型、约束),而不是手写 `CREATE TABLE` 语句。
2. **`DriftAccessor`(DAO)**:给一组相关表写查询/增删改方法的地方,业务代码只调用 DAO 方法,不直接写 SQL。
3. **`.watch()` vs `.get()`**`.get()` 是一次性查询,`.watch()` 返回一个 `Stream`,只要底层数据变化(哪怕是另一个页面改的)就会自动推送新结果——不需要手动刷新。
4. **`schemaVersion` + `onUpgrade`**:数据库版本号和迁移回调,改表结构时递增版本号并在 `onUpgrade` 里写迁移逻辑(加字段、建索引等),保证已安装用户的本地数据不会因为升级直接报错或丢失。
### 使用示例(`feature_store`:门店列表本地缓存)
```dart
// packages/core_storage/lib/src/tables/store_table.dart
class StoreCache extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
RealColumn get lat => real()();
RealColumn get lng => real()();
DateTimeColumn get cachedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
```
```dart
// packages/core_storage/lib/src/daos/store_dao.dart
part 'store_dao.g.dart';
@DriftAccessor(tables: [StoreCache])
class StoreDao extends DatabaseAccessor<AppDatabase> with _$StoreDaoMixin {
StoreDao(super.db);
Future<void> upsertAll(List<StoreCacheCompanion> stores) =>
batch((b) => b.insertAllOnConflictUpdate(storeCache, stores));
Stream<List<StoreCacheData>> watchAll() => select(storeCache).watch();
}
```
```dart
// packages/core_storage/lib/src/app_database.dart
@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(openConnection());
AppDatabase.forTesting(super.connection); // 迁移测试用
static const latestSchemaVersion = 2;
@override
int get schemaVersion => latestSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(storeCache, storeCache.cachedAt);
}
},
);
}
```
> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。
```dart
// feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase
class StoreLocalDataSource {
final StoreDao _dao;
StoreLocalDataSource(this._dao);
Stream<List<Store>> watchCachedStores() =>
_dao.watchAll().map((rows) => rows.map(Store.fromCacheRow).toList());
}
```
配合 Riverpod 做响应式 UI(离线也能展示上次缓存的门店列表,等网络数据回来再刷新):
```dart
@riverpod
Stream<List<Store>> cachedStores(Ref ref) {
final localDataSource = ref.watch(storeLocalDataSourceProvider);
return localDataSource.watchCachedStores();
}
```
### secure storage 使用示例(token 存取)
```dart
// packages/core_auth/lib/src/token_storage.dart
class TokenStorage {
final FlutterSecureStorage _storage;
TokenStorage(this._storage);
Future<void> saveTokens({required String accessToken, required String refreshToken}) =>
Future.wait([
_storage.write(key: 'access_token', value: accessToken),
_storage.write(key: 'refresh_token', value: refreshToken),
]);
Future<String?> readAccessToken() => _storage.read(key: 'access_token');
Future<void> clear() => _storage.deleteAll();
}
```
`TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。
## 待确认项
- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。
- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。