159 lines
6.5 KiB
Markdown
159 lines
6.5 KiB
Markdown
# 06. 本地存储方案
|
||||
|
|
|
|||
|
|
## 决策
|
|||
|
|
|
|||
|
|
按数据类型分三档存储,全部封装在 `core_storage` 包内,`feature_*` 不直接依赖底层存储库:
|
|||
|
|
|
|||
|
|
| 数据类型 | 方案 | 版本(2026-08 快照) |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` |
|
|||
|
|
| 敏感数据(token、refresh token) | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `^11.0.0` |
|
|||
|
|
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` |
|
|||
|
|
|
|||
|
|
## 依赖
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
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` 类)和 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` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。
|
|||
|
|
|
|||
|
|
## 参考链接
|
|||
|
|
|
|||
|
|
- [Drift 官方文档](https://drift.simonbinder.eu/)
|
|||
|
|
- [drift | Dart package](https://pub.dev/packages/drift)
|
|||
|
|
- [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, 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);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```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。
|