Files
conti-docs/06-local-storage.md
T
Guangfei.Zhao 444db49818 feat: add engineering conventions and CI gates documentation
- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks.
- Updated README to include the new conventions document.
- Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability.
- Adjusted global exception handling to return numeric error codes.
- Updated tests to reflect changes in error code handling.
2026-08-13 19:28:36 +08:00

16 KiB
Raw Blame History

06. 本地存储方案

决策

按数据类型分三档存储,feature_* 不直接依赖底层存储库:

数据类型 方案 版本(2026-08 快照) 归属包
结构化/关系型数据(门店列表缓存、订单历史等) Drift ^2.34.3 core_storage
敏感数据(token、refresh token flutter_secure_storage 11.0.0(锁死) core_auth
简单非敏感 KV(是否看过引导页、用户偏好设置) 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 的依赖例外表)。代价是 core_auth 自己要依赖 flutter_secure_storage,这比多一条包间依赖划算。

依赖

# 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_providerdriftDatabase() 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。

使用规则

  • 全仓库只有一个 Drift 数据库实例,定义在 core_storage 里,不允许每个 feature_* 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。
  • 每个 feature 拥有自己的表(Table 类)和 DAODriftAccessor),表名加 feature 前缀(如 store_cachepayment_history)避免命名冲突,但都注册进同一个 AppDatabase
  • feature 的 datalocal_datasource 只依赖自己的 DAO 类型,不直接操作 AppDatabase 或访问其他 feature 的表。
  • token / refresh token 只能经过 core_auth 包里封装的 secure storage 读写方法,不允许其他 core_*/feature_* 直接调用 FlutterSecureStorage 实例。
  • 数据库表结构变更必须写 migration(onUpgrade + schemaVersion 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。

所有业务缓存表必须带 storeId

PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。

硬性规则:任何缓存业务数据的表都必须有 storeId 列,并且

  • 所有查询都带 where(tbl.storeId.equals(currentStoreId)),不允许无门店条件的全表查询;
  • storeId 建索引;
  • 表的主键包含 storeId(或用 (storeId, businessId) 联合主键),避免不同门店的同 ID 记录互相覆盖。
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
登出 全部清空 只清与用户相关的键,保留 App 级偏好 全部清空 失效 + 清 Cookie/LocalStorage
切换账号 同登出 同登出 同登出 同登出

切换门店时是"只留当前门店"还是"全清":选全清。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。

// 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 快照 + 验证工具链,纳入流程:

# 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/
// 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 testCI 卡点(见 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,不存在历史数据,所以本次没有实际迁移风险;这条规则是为后续升级立的。

读取失败的兜底必须写:

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_flutterdriftDatabase() 默认就用后台 isolate,只要不手动关掉即可:

// packages/core_storage/lib/src/connection.dart
QueryExecutor openConnection() => driftDatabase(
      name: 'conti_app',
      native: const DriftNativeOptions(
        databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份
      ),
    );

iOS 上数据库文件放 Application Support 而不是 DocumentsDocuments 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。

参考链接

附录:Drift 是什么,日常怎么用

给还没接触过这套本地数据库封装方式的同学看的入门说明。

要解决的问题

Flutter 生态里直接操作本地 SQLite 最常见的是 sqflite,但它是纯 SQL 字符串拼接:

// sqflite 写法,容易手滑打错字段名/表名,编译期完全发现不了
await db.rawQuery('SELECT * FROM stroe WHERE nmae = ?', [name]);

字段名、表名全靠字符串,拼错了只有运行时才报错;查询结果是 Map<String, Object?>,还得手动转成业务对象;数据变化了想让 UI 自动刷新,也得自己手写一套通知机制。

Driftsqflite(或更底层的 sqlite3)之上加了一层代码生成:用 Dart 类定义表结构,build_runner 生成类型安全的查询代码,写错字段名/类型在编译期就会报错;查询结果直接是强类型的 Dart 对象;还内置了 .watch() 方法,数据变化时自动推送新结果,天然适合配合 Riverpod 的 StreamProvider/AsyncNotifier 做响应式 UI。

核心概念

  1. Table:用 Dart 代码声明表结构(字段名、类型、约束),而不是手写 CREATE TABLE 语句。
  2. DriftAccessorDAO:给一组相关表写查询/增删改方法的地方,业务代码只调用 DAO 方法,不直接写 SQL。
  3. .watch() vs .get().get() 是一次性查询,.watch() 返回一个 Stream,只要底层数据变化(哪怕是另一个页面改的)就会自动推送新结果——不需要手动刷新。
  4. schemaVersion + onUpgrade:数据库版本号和迁移回调,改表结构时递增版本号并在 onUpgrade 里写迁移逻辑(加字段、建索引等),保证已安装用户的本地数据不会因为升级直接报错或丢失。

使用示例(feature_store:门店列表本地缓存)

// 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};
}
// 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();
}
// 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 那种才是典型的门店级数据。

// 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(离线也能展示上次缓存的门店列表,等网络数据回来再刷新):

@riverpod
Stream<List<Store>> cachedStores(Ref ref) {
  final localDataSource = ref.watch(storeLocalDataSourceProvider);
  return localDataSource.watchCachedStores();
}

secure storage 使用示例(token 存取)

// 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)方案,涉及密钥保管和原生库体积。
  • 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。