这周处理了两条反馈,都不是崩溃。
第一条来自一个老用户:他手机上有一份没同步出去的草稿,升级到新版本之后打开草稿箱是空的。数据没被删——是我改了 drafts 表的字段,而本地那个库文件早就存在了,建表那段代码一次都没执行过。
第二条很常见:「在地铁里打开 app,是一片空白,过几秒内容才跳出来。」这条我这边所有页面都成立,因为我的读路径从头到尾只有一个数据源——网络。本地库我确实在写,但写完就没人在打开页面的时候读过它。
这两条让我意识到之前把「本地存储」想小了。用什么存只是第一层,剩下几层是:什么时候读它、什么时候写它、它和服务端不一致时以谁为准、发新版本时它怎么跟着走。第一层是工具,后面几层是设计,工具换一遍也补不上设计的缺。这一篇按这两层记我自己的做法和付出去的代价。
先按数据形态分,再谈工具
我一开始的习惯是先选库再想数据长什么样,结果就是把什么都往那一个库里塞。现在反过来,先看形态:
| 数据形态 | 典型例子 | 我用的东西 | 我付出去的东西 |
|---|---|---|---|
| 少量键值 | 深色模式开关、上次登录时间、引导页看过没有 | shared_preferences | 启动时要一次性读进来;没有查询能力 |
| 会过期的对象缓存 | 某个接口最近一次的返回列表 | 内存 LRU,需要跨启动就再落一份库 | 进程被回收就没了;要自己管失效 |
| 结构化、要按条件查、要事务 | 联系人、草稿、待同步队列 | SQLite(sqflite),或类型安全的 drift | 建表语句、版本号、SQL 或 DSL |
| 文件 | 头像缓存、导出的 PDF、离线资源包 | path_provider 拿目录,清理策略自己写 | 空间占用、系统备份语义、什么时候删 |
我的判断线索是两个问题:需不需要按条件取其中一部分,和需不需要一组写入要么全成要么全不成。两个都是「否」,就轮不到数据库;两个都是「是」,shared_preferences 就一定不够用。中间那档(对象缓存)最容易做错——它看着像键值,实际有失效语义,塞进 shared_preferences 之后每次读写都在搬整份数据。
shared_preferences:我为什么只放开关
最小可用形态就三段:
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
SettingsStore(this._prefs);
final SharedPreferences _prefs;
// 启动时 await 一次,把实例传下去(我挂在 provider 上注入给各界面)
static Future<SettingsStore> load() async =>
SettingsStore(await SharedPreferences.getInstance());
bool get darkMode => _prefs.getBool('setting.darkMode') ?? false;
Future<void> setDarkMode(bool value) => _prefs.setBool('setting.darkMode', value);
DateTime? get lastSignIn {
final raw = _prefs.getString('auth.lastSignIn');
// 存 ISO 字符串是因为这里只能存标量;解析失败我宁可返回 null 也不抛
if (raw == null) return null;
return DateTime.tryParse(raw);
}
Future<void> setLastSignIn(DateTime t) =>
_prefs.setString('auth.lastSignIn', t.toIso8601String());
}它的机制是这样:读是同步的,但同步的那份在 Dart 侧内存里。getInstance() 是个 Future,它做的事是把平台侧(Android 是 SharedPreferences,iOS 是 NSUserDefaults)的整份键值搬到内存缓存一遍;之后的 getString 之所以能不带 await,是因为它读的就是这个缓存,不是磁盘。写回去的那条路径反过来——setString 走平台通道,落盘是异步安排的,函数返回那一刻并不保证数据已经写进了磁盘。这个语义放开关没问题,放「用户刚填的表单」就不行。
我为什么给它划了只放标量的线:底层那些平台存储都是一整份文件。Android 侧 SharedPreferences 是一个 XML,改一个 key 落盘时重写的是整个文件;再叠加我这边的用法是把接口返回的 JSON 整串塞进一个 key,于是「读三页列表 → 缓存 → 下次启动全部读回来」这条路径变成启动时同步解一大坨字符串。我把联系人列表(大概一两百条)塞进去过一次,那次启动明显变慢,而且慢在 getInstance() 那一行上——这行是我的首帧前置依赖,慢就直接体现在冷启动上。我后来只放了一堆标量,getInstance() 的耗时回到了我注意不到的程度。要不要在意这个,取决于首帧预算;我这工程是首页要出列表,所以在意。
sqflite 最小可用:开库、建表、批量写
import 'package:sqflite/sqflite.dart';
late final Future<Database> _db;
Future<Database> openAppDb() async {
final dir = await getDatabasesPath();
final path = '$dir/contacts.db';
return openDatabase(
path,
// version 是整数,也是 onUpgrade 唯一的触发条件;我把它当发布检查项
version: 3,
onConfigure: (db) async {
// 外键默认是关的,开了才有级联删除
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, version) async {
// 只在库文件第一次被创建时跑
await db.execute('''
CREATE TABLE contacts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT,
updated_at INTEGER NOT NULL
)
''');
await db.execute('''
CREATE TABLE drafts (
id TEXT PRIMARY KEY,
body TEXT NOT NULL,
synced_at INTEGER
)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
// 只在 version 变大时跑,而且拿到的是「从几升到几」,
// 所以跨版本升级要写成一段一段补上来,不能只处理最后一档
if (oldVersion < 2) {
await db.execute('ALTER TABLE contacts ADD COLUMN remark TEXT');
}
if (oldVersion < 3) {
await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)');
}
},
);
}查询这边我一开始全用字符串,因为它直白:
final rows = await db.query(
'contacts',
columns: ['id', 'name'],
where: 'name LIKE ?',
whereArgs: ['%${keyword}%'], // 条件值走 args:手拼进 where 串就是注入的入口
orderBy: 'updated_at DESC',
limit: 50,
);批量写这块是我第一版最难看的地方。当时的列表导入是 for 循环里逐条 await db.insert(...),一万条在 Android 真机上跑到十几秒。换成一个事务包住的 batch:
final batch = db.batch();
for (final c in contacts) {
batch.insert('contacts', c.toRow(), conflictAlgorithm: ConflictAlgorithm.replace);
}
// noResult:每条的返回值一概不要,省掉一批跨 isolate 的数据搬运
await batch.commit(noResult: true);跑的是同一万条数据,时间掉到一秒以内,一到两个数量级的差。为什么会这样:逐条执行时每条语句都是一个独立事务,而事务提交要把日志落盘(fsync),慢的是那个同步点而不是我那个循环。包进一个事务就只剩一次。
batch 的两个性质我当时是分开撞到的。一是它在事务里时,提交前数据只对这个事务可见,事务里后续语句读得到自己刚写的那批,出事务之前外面看不到;二是不包事务时它是逐条提交的,遇到第一个错误就停,而前面已经提交的那些留在库里。continueOnError: true 改变的是「出错之后还继不继续」,不改变「已经进去的那些不会退回来」这件事,所以它给不了原子性。
我的取舍是:一批数据要么整批要么不带,一律 db.transaction((txn) { ... }) 包住,continueOnError 我一次都没用——因为它把失败变得不可见。
代价也摆在这儿:列名和表名全是字符串。where: 'name LIKE ?' 里的 name 拼错,编译期没有任何信号。这就是我后来试 drift 的原因。
版本升级:开头那次「草稿没了」的成因
先把结论说清楚:onCreate 里加字段,对已经装了老版本的用户等于没加。 SQLite 的建表语句我写的是 CREATE TABLE drafts (...),库文件存在时这张表已经在了,onCreate 根本不会被调用;我就算写成 CREATE TABLE IF NOT EXISTS,它的语义也是「有就整段跳过」,不是「有就把缺的列补上」。SQLite 没有「对齐 schema」这个概念,它只提供 ALTER TABLE ADD COLUMN 这种一条一条的动作,把新旧版本之间的差补齐是我的活儿。
那条用户反馈的现场是这样的:我加了 remark 列,只改了 onCreate 里的建表语句,version 忘了动。老用户装上新包,读列表的 SQL 里带着 remark,抛出来的是:
DatabaseException(no such column: remark
sqflite 会把 SQLite 的原始消息包进 DatabaseException,后面还带一段 error 码,尾巴在不同平台上不太一样。这个查询我包了一层 try,失败就返回空列表——这才是最难查的部分:崩溃没有,用户看到的就是「草稿没了」,而库里那些行一条都没少。我的判断方法是把 UI 那层先放下,直接从库文件看结构:
# Android:debug 包能走 run-as;把 <pkg> 换成 applicationId
adb shell "run-as <pkg> cat /data/data/<pkg>/databases/contacts.db" > /tmp/contacts.db
sqlite3 /tmp/contacts.db '.schema drafts'
sqlite3 /tmp/contacts.db 'PRAGMA table_info(contacts);'PRAGMA table_info 的输出直接对上「这一列到底在不在」,比在 app 里打日志快。
写迁移时有两处我自己的规矩。一是 onUpgrade 里只写「往前补」的判断(if (oldVersion < N)),因为从 1 直接升到 3 的用户要连续经过 2 那一步,只处理最后一档的话他们的中间那一层就丢了。二是加列尽量加可空或者带 DEFAULT 的,NOT NULL 无默认值的 ALTER TABLE ADD COLUMN 在 SQLite 上会直接被拒。
真正麻烦的是改列名和改类型。SQLite 的 ALTER TABLE 这么多年只给了很少的动作,RENAME COLUMN 要到 3.25 才有,而 Android 上跑的是系统自带的那份 SQLite,版本跟着 ROM 走——同一段迁移代码,我在老设备上见到的是:
DatabaseException(near "RENAME": syntax error
这种情况下标准做法是「建新表、搬数据、删旧表、改名」四步,包在一个事务里。这段 SQL 我写得很少,因为每写一次就要在真机上验一次升级路径(装老版本 → 造数据 → 覆盖安装新版本),而这条测试我在 CI 里跑不起来。drift 在这件事上帮到的就是这个部分,下面讲。
还有一条兜底是我给「草稿」这类数据单独加的:迁移代码里如果我判断这次改动风险大,会先把没同步出去的草稿导出成文件再改 schema,失败了至少用户的东西还在。要不要做到这一步,取决于本地数据里有没有「丢了就再也拿不回来」的东西——我这个有,所以做了。
drift 解决的是「拼错列名要等到运行」
drift(2.35.3)的定位是把 SQLite 变成一个有类型的 Dart API:表用 Dart 类定义(也可以写在 .drift 文件里用 SQL 定义),dart run build_runner build 生成一张表对应的行类型和查询 DSL。
// lib/data/db.dart
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'db.g.dart';
class Contacts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 64)();
TextColumn get phone => text().nullable()();
TextColumn get remark => text().nullable()(); // 加字段就改这里
IntColumn get updatedAt => integer()();
}
@DriftDatabase(tables: [Contacts])
class AppDb extends _$AppDb {
AppDb() : super(driftDatabase(name: 'contacts')); // drift_flutter 负责选目录
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async => m.createAll(),
onUpgrade: (m, from, to) async {
// m 是 MigrateSession,能拿到新旧 schema
if (from < 2) {
await m.addColumn(contacts, contacts.remark); // 生成的 addColumn,不用手写 SQL
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
// 类型化的查询:列名写错是编译错误
Future<List<Contact>> search(String keyword) =>
(select(contacts)
..where((t) => t.name.like('%$keyword%'))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
..limit(50))
.get();
// 响应式:表一变就重新发一份结果,读路径直接挂这个 Stream
Stream<List<Contact>> watchAll() => select(contacts).watch();
}它替我挡住的东西很具体。以前 where: 'name LIKE ?' 里的 name 拼错要等到运行,现在写成 t.titel 拿到的是分析器报错(大意是 The getter 'titel' isn't defined for the type 'Contacts'),编译不过去;orderBy 里指到不存在的列同理。m.addColumn(contacts, contacts.remark) 这一行也比手写 ALTER TABLE 少两个出错点——表名、列名都来自定义,不用我在字符串里再抄一遍。
watch() 对我更有用的地方在读路径:它给的是一个持续发结果的 Stream,本地库写完,UI 那一侧自己就刷新了。这样「缓存变了怎么通知界面」这件事我不用自己维护通知链,路由层和状态层也都不用为这一层存储专门加回调。
代价有三样,我在 sqflite 和 drift 之间选的时候掂量的就是这三样:
多一道代码生成。 part 'db.g.dart' 那个文件不在我手上,改了表定义忘了跑 build_runner,IDE 里红一片;CI 也要多一步。团队里第一次碰这个的人会问「为什么改了 .dart 还要跑命令」。
报错的栈变长了。 drift 底层是 package:sqlite3 加一层自己的连接管理,异常信息里出现的类型不再是直接的 SQLite 原语。路径这块我确实碰到过一次:在 Android 上自己拼 NativeDatabase(File(...)) 时目录要给对,默认的临时目录在那台机器上是访问不到的,报出来的是一句和 SQL 毫无关系的错。换用 drift_flutter 的 driftDatabase(name: 'contacts') 之后目录由它替我选定,这个便利的背面是——真出现空间占用问题,我得先去读它的实现才知道文件落在哪。
DSL 之上还得会 SQL。 复杂查询我最后仍要落回 customStatement / customSelect(迁移、PRAGMA、WITH 之类),drift 允许混用,但这意味着学它不等于避开 SQLite 那部分心智负担。
两条路我自己的立场是:表少、查询简单、不想引入生成链,sqflite 够用,代价是把列名当成字符串纪律来守;本地数据是核心资产、要跨版本演进、要给 UI 推流,drift 省下来的是我一年之后回来看 schema 的那部分痛苦。
离线优先的读路径:先给缓存,再问网络
开头第二条反馈的根因不是「没缓存」,是「缓存只在写的时候用,读的时候没人问它」。我现在把列表页的读路径固定成三段,顺序是有讲究的:
class ContactRepository {
ContactRepository(this._db, this._api);
final AppDb _db;
final ContactApi _api;
/// 界面订阅这一个流:先出本地(秒开),网络回来后如果有变化再出第二份。
Stream<List<Contact>> watch(String keyword) async* {
yield await _db.search(keyword); // 1. 本地那份,空数组也照样发出去
try {
final remote = await _api.list(keyword); // 2. 后台拉最新
final local = await _db.search(keyword);
if (!_sameSet(local, remote)) { // 3. 有差异才回写 + 再发一份
await _db.replaceAllForQuery(keyword, remote);
yield await _db.search(keyword);
}
} on Object {
// 网络失败这里什么都不发:第一段已经给过内容了,界面无感
}
}
bool _sameSet(List<Contact> a, List<Contact> b) {
if (a.length != b.length) return false;
// 用服务端给的 updatedAt 最大值 + 条数当指纹;比对全量字段太贵
for (var i = 0; i < a.length; i++) {
if (a[i].id != b[i].id || a[i].updatedAt != b[i].updatedAt) return false;
}
return true;
}
}界面上消费这个流,下拉刷新另走一条:
class ContactListPage extends ConsumerStatefulWidget { ... }
class _ContactListPageState extends ConsumerState<ContactListPage> {
@override
Widget build(BuildContext context) {
// ref.watch 的是一个 streamProvider,订阅的就是上面那条 watch()
final list = ref.watch(contactListProvider).valueOrNull ?? const <Contact>[];
return RefreshIndicator(
// onRefresh 里我只做一件事:强制走一次网络并回写库。
// 缓存那一层的 yield 已经在流里了,不需要在这里手动刷 UI
onRefresh: () => ref.read(contactRepositoryProvider).forceSync(),
child: ListView.builder(
// physics 保持默认:内容不足一屏时 AlwaysScrollable 才有下拉响应
itemCount: list.length,
itemBuilder: (context, i) => ContactTile(list[i]),
),
);
}
}这套写法有两条我不合并的原则。缓存负责快,服务端负责真:本地那份从来不作为「最终答案」呈现,它只是「我上次看到的样子」,所以它过期我不焦虑;_sameSet 那个指纹比对的作用是让「无变化时不再发第二份」,否则每次进页面都会多一次 setState,看起来像在闪。任何缓存都要能一行绕过:把 watch() 的第一段 yield 去掉,界面就退回纯网络模式,出问题时我能立刻分清是缓存的锅还是接口的锅。这个开关我做早了——有一次列表显示顺序不对,我先怀疑缓存,去掉第一段之后问题还在,才定位到是我在 orderBy 里用了 updated_at,而服务端排序用的是另一个字段。
这里我撞过一次 Flutter 侧的错。第一版我是这样写的:网络回来后 setState 之前先 await 了一个 Future.delayed(为了给转场留时间),用户在等待期间退出了页面:
Looking up a deactivated widget's ancestor is unsafe.
原因是 await 之后那个 context 可能已经不在树上了,ScaffoldMessenger.of(context) / ref.read 这类沿树找上层的调用都不可靠。判断方法:栈顶两帧通常就是我自己那个回调和 flutter/widgets/framework.dart。现在的写法是不依赖 context——数据侧的东西全部通过流交给界面,异步回来之后谁还在订阅就发给谁;真要碰 context,先读 mounted。
RefreshIndicator 那条还有个细节:列表内容不足一屏时它不响应下拉,我加了 AlwaysScrollableScrollPhysics 才在空列表上也能触发——而「空列表能不能下拉出网络重试」正好是离线体验里最需要的一条。
写路径:本地先落库,待同步队列,恢复之后重放
「用户点了保存」这件事我拆成两个动作:本地那半是必成的,服务端那半尽力而为。中间那段差值我放在一张表里:
class PendingOps extends Table {
IntColumn get seq => integer().autoIncrement()(); // 顺序就靠它,重放按 seq 升序
TextColumn get entity => text()(); // 'contact' / 'draft'
TextColumn get entityId => text()();
TextColumn get op => text()(); // upsert / delete
TextColumn get payload => text()(); // 序列化后的整份内容
IntColumn get createdAt => integer()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
}/// 保存:本地事务里一次写两样——业务数据 + 一条待同步记录。
/// 这两条必须在同一个事务里,否则会出现「界面看到改了,但没人负责同步」。
Future<void> saveContact(ContactDraft draft) => db.transaction((txn) async {
await txn.update(
contacts,
ContactCompanion.fromDraft(draft),
where: (t) => t.id.equals(draft.id),
);
await txn.into(pendingOps).insert(PendingOpsCompanion.insert(
entity: 'contact',
entityId: draft.id,
op: 'upsert',
payload: draft.encode(),
createdAt: DateTime.now().millisecondsSinceEpoch,
));
});
/// 重放:一条一条来,成功一条删一条。
/// 半途失败保留剩下的,下次继续;不做整批回滚。
Future<void> flushPending() async {
if (_flushing) return; // 同一时刻只允许一个发送者,两路并发跑同一队列会互相踩
_flushing = true;
try {
final ops = await (db.select(db.pendingOps)
..orderBy([(t) => OrderingTerm.asc(t.seq)]))
.get();
for (final op in ops) {
try {
await _api.apply(op); // 带上 idempotencyKey: op.entityId
await (db.delete(db.pendingOps)..where((t) => t.equalsKey(op))).go();
} on DioException catch (e) {
if (e.response?.statusCode != null && e.response!.statusCode! < 500) {
await _giveUp(op); // 4xx:重放不会变好,进冲突处理
} else {
await _bumpAttempts(op); // 超时 / 5xx:留在队列里,退避后重试
}
return; // 顺序不能乱,一条不通就整轮停在这里
}
}
} finally {
_flushing = false;
}
}断网重试我按三种失败分开处理,因为它们的下一步动作完全不一样。
网络根本不通(DioExceptionType.connectionError / receiveTimeout):请求有没有到达服务端是不确定的。这种情况我把 op 留在队列里,什么都不改,等下一次触发。这里的前提是服务端接口接受重复提交——同一个 entityId 带幂等键,重复的那次当成功返回。客户端没法知道自己那次是不是已经生效了,这是事实;我这边能改的只有让重复无害。
4xx:说明请求本身有问题(字段校验、权限、资源已经被别人删了)。重放一万次也不会变好,所以我让它离开队列进「待处理」状态,界面上给一条「这条没同步上去,点这里处理」的入口。这一档我最开始是错的——当时当成网络失败一起重试,结果一份不合法的草稿在里面转了几百次,attempts 涨到三位数,日志全被刷满。判断依据就是 HTTP 状态码这一档,attempts 只给 5xx 和超时用。
5xx 或者超时但服务端可能已经应用了:退避重试(我用固定几档:5 秒、30 秒、2 分钟、之后每次乘 2 封在 15 分钟)。这里有个本地 id 和服务端 id 不同步的问题:草稿的 id 我在本地生成(uuid),服务端创建完返回的是它自己的 id。这个映射我一开始是「拿到响应就地更新业务表」,结果重放时因为 entityId 还是旧的,第二次提交又建了一份重复数据。现在的做法是 payload 里带上客户端 uuid,服务端按 uuid 去重;本地 id 换成服务端 id 这件事我放在下一次成功拉列表时统一对齐,而不是在写路径里改。
触发重放的时机我挂了三个:app 回到前台(WidgetsBindingObserver 的 didChangeAppLifecycleState)、任意一次接口成功之后(说明链路通了)、用户下拉。这三处都会调同一个 flushPending(),靠里面那个 _flushing 标志去重——早期我没加,回前台和接口成功同时命中,两路并发跑同一个队列,出现过后一条把前一条已经删掉的行又更新一次的竞态(本地表现是一条待同步记录既没删也没加重试次数,界面上那条「未同步」的提示一直在)。
缓存失效:TTL、版本号、手动清
我自己在用的三条规矩,按主题给,不带通用性主张:
缓存只负责快。 判断依据是:这份数据丢了,用户看到的是「等一次网络」,不是「数据没了」。凡是我觉得丢了会有第二条反馈那种结果的,就不该走缓存那一栏,而是走「本地先落库」那一栏——两者在代码里是不同的表、不同的生命周期。
失效条件写进数据,不写进代码。 TTL 我存成行上的一个时间戳,WHERE fetched_at + ttl > now 这种判断在查询里,而不是在 Dart 里到处 if (DateTime.now().difference(...) > ...)。这样「某个页面的缓存改成一个星期」动的是那一行数据,不是某个函数。版本号(服务端给列表带一个 rev 或 updated_since 游标)我用它决定整片缓存作废,TTL 用它决定单条过期——两者解决的粒度不一样,只留 TTL 的话我那次改字段之后老缓存照样能用,然后界面显示的是过期字段。
手动清要按命名空间清。 我这台机器上留了一个 debug 菜单,「清掉接口缓存」和「清掉本地业务库」是两个按钮,中间那个不影响草稿。合在一起的后果我在自己手机上试过:一次排查里我想看看「重新拉接口」的表现,结果连草稿一起删了,那份没同步的东西就再没机会验证了。目录上也是分开的——库文件走 getDatabasesPath(),文件缓存走 getTemporaryDirectory(),我给自己定的规矩是离线包放 getApplicationSupportDirectory()、临时下载放 getTemporaryDirectory(),因为后者系统可以在空间紧张时清理,把它当持久层用等于给自己埋一条「过几天离线包没了」的反馈(iOS 那边还有个更直接的:Documents 是会出现在「文件」app 里的,那是给用户看的目录,不是给缓存用的)。
学习时要特别注意的几处
MissingPluginException(No implementation found for method getDatabasesPath on channel com.tekartik.sqflite)
症状:pub add sqflite 之后热重载,第一次调开库就抛这一句,path_provider 和 shared_preferences 也各有一个对应通道名的版本。原因:插件的平台侧实现是在构建阶段注册的,热重载只换 Dart 代码,不重跑原生侧;另外 sqflite 覆盖到的平台是 Android / iOS / macOS,在 Windows、Linux 上跑要换成 sqflite_common_ffi 那套后端。怎么判断:先做完整重启(flutter run 重新装一遍),错误还在就基本是平台不在覆盖范围里,而不是忘记 restart。
改完字段老用户看不到数据,而且没有崩溃。 症状就是开头那条反馈:列表空,但库文件里行还在。原因:onCreate 只在库第一次创建时跑,CREATE TABLE IF NOT EXISTS 也不会给已有表补列;我如果还写了「查询失败返回空列表」的兜底,异常就被吞成了一片空白。怎么判断:PRAGMA table_info(表名),把设备上的 .db 拉出来看,或者直接看 version 有没有跟着改——线上出这类问题时,version 十有八九和上一版一样。
DatabaseException(no such column: xxx
症状:只在部分设备上报,本地新装的模拟器一切正常。原因:迁移段没覆盖到某个起点版本(比如从 1 直升 3 少了中间那一步),或者我用的 SQL 动作在设备那份 SQLite 上还不存在(RENAME COLUMN 要 3.25+,Android 上 SQLite 版本跟 ROM 走)。怎么判断:报错里带列名或 near "…" syntax error 就说明 schema 和代码假设不一致;换一台系统版本低的真机复现一次,比自己猜快。顺带一条:本地库文件是「有历史」的,复现迁移问题的路径是先装旧版本、造数据、再覆盖安装新版本;删掉重装只会走 onCreate,永远走不到迁移那一段。
batch 没包事务时留下半批。 症状:导入到一半失败,重试之后出现重复行或者条数不对,而 await batch.commit() 看起来是正常返回的。原因:batch 在事务外是逐条提交的,遇到第一个错误就停,前面那些已经落库了。怎么判断:失败前后各做一次 count(*),条数变了就说明是部分提交。我后来的规矩是批量写一律 db.transaction,要么全成要么全无。
SharedPreferences 在使用之前没初始化。 症状:单测里或者某个静态调用点直接 SharedPreferences.getInstance() 之外的 getter 先跑了,抛的断言里带 has not been initialized. Make sure to call SharedPreferences.getInstance,前面那句在不同 Dart 版本上是 LateInitializationError 还是 Bad state 我两种都见过。原因:那份内存缓存是 getInstance() 建起来的,同步读的前提是它已经存在。怎么判断:看调用点在这行之前有没有 await 过 getInstance();我的做法是 app 启动阶段 await 一次、把实例注入下去,之后没有任何地方自己调 getInstance(),这样漏了初始化会在装配处响,而不是在某个页面的 build() 里。
Looking up a deactivated widget's ancestor is unsafe.
症状:数据回来后想弹一条 SnackBar,或者想在回调里 context.go,页面已经退掉时才炸。原因:await 之后原来那个 context 可能已经不在 widget 树上了,沿树找上层的操作没有对象可找。怎么判断:栈里出现 flutter/widgets/framework.dart 加上我自己那个异步回调,就是这条;把调用挪到不依赖 context 的位置(走流、走 notifier)之后错误消失,就能确认是生命周期而不是逻辑。
换完之后我付出去的几样
多了一个「有历史」的东西。 库文件在用户手机上活得好几年,我每次改 schema 都在给几年后写迁移代码。version 这个数字现在是我发布检查清单上的一项,而它是以前不存在的负担。
多了一条测试路径。 本地存储的 bug 没法靠「重装一次」复现,它要求的是「老版本用了半年之后的那个库」。我现在至少留一条手测:debug 包 → 造几条数据 → 改 version 和迁移 → 覆盖安装 → 看那几条还在不在。
离线优先要处理冲突。 一旦允许离线写,就会有「我在本地改了、别人在服务端也改了」这种情况。我现在的处理很初级——同一条记录以 updatedAt 新的为准,冲突时保留本地那份并给用户一条提示——这块我承认没做完,因为「以谁为准」是个产品决定而不是技术决定。
本地存了一份,就等于多了一份要维护的数据副本。 它带来的收益是首页秒开、没网也能看、写操作不转圈;它带来的成本是上面这些。哪些数据值得进这一层,我按「丢了能不能从服务端原样拿回来」来划:拿得回来的进缓存那一栏,拿不回来的进待同步那一栏,剩下那两类我没往数据库里放——一个键值或者一个文件就够了。
评论
暂未收到