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

6.5 KiB
Raw Blame History

06. 本地存储方案

决策

按数据类型分三档存储,全部封装在 core_storage 包内,feature_* 不直接依赖底层存储库:

数据类型 方案 版本(2026-08 快照)
结构化/关系型数据(门店列表缓存、订单历史等) Drift ^2.34.3
敏感数据(token、refresh token flutter_secure_storage ^11.0.0
简单非敏感 KV(是否看过引导页、用户偏好设置) shared_preferences ^2.5.5

依赖

dependencies:
  drift: ^2.34.3
  sqlite3_flutter_libs: ^0.5.0
  flutter_secure_storage: ^11.0.0
  shared_preferences: ^2.5.5

dev_dependencies:
  drift_dev: ^2.34.3
  build_runner: ^2.4.0

使用规则

  • 全仓库只有一个 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 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。

参考链接

附录: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, PaymentHistory], daos: [StoreDao, PaymentHistoryDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onUpgrade: (m, from, to) async {
      if (from < 2) {
        await m.addColumn(storeCache, storeCache.cachedAt);
      }
    },
  );
}
// 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。