Flutter 基础体系 · 第 13/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 本地存储:Preferences、文件、SQLite、加密和迁移
Flutter 应用中的本地存储不是一个单一 API,而是几种具有不同数据模型、持久性保证、并发行为和安全边界的机制组合:
- Preferences:保存少量、简单、按键访问的配置;
- 文件:保存 JSON、图片、日志、导出包等完整内容;
- SQLite:保存具有查询、约束、事务和迁移需求的结构化数据;
- 加密:保护数据机密性,但不能自动解决密钥管理、完整性和查询问题;
- 迁移:让旧版本已经存在的数据能够安全地适应新版本的结构。
正确的设计不是“所有数据都放 SQLite”,而是先根据数据的访问方式、规模、关系、敏感性和恢复要求选择存储介质。
一、先区分“数据存在”与“数据可用”
移动端本地数据至少涉及三个不同概念:
- 进程存活期间存在:例如 Dart 内存中的对象;
- 应用未被卸载时存在:例如应用沙盒中的数据库和文件;
- 设备或备份体系中可恢复:例如系统备份、云同步或用户导出的文件。
“写入成功”通常只表示底层 API 接受了写入请求,不一定表示数据已经完成跨设备同步,也不表示数据不会被系统清理,更不表示数据没有被备份或提取。
可以把一次本地写入抽象为:
每一层都可能失败:
- 对象不能被序列化;
- 路径不存在或没有权限;
- 磁盘空间不足;
- 数据库事务失败;
- 应用在写入过程中被终止;
- 文件已写完,但索引或元数据没有同步更新。
因此,存储层接口不应只返回“成功或失败”,还应明确:
- 数据的主键是什么;
- 写入是否幂等;
- 是否允许丢失;
- 失败后能否重试;
- 读取到损坏数据时如何恢复;
- 数据是否参与备份和迁移。
二、按数据特征选择存储方式
1. Preferences:少量标量配置
Preferences 适合:
- 是否首次启动;
- 当前主题模式;
- 用户选择的语言;
- 最近一次同步时间;
- 简单的布尔、整数、浮点数和字符串;
- 少量字符串列表。
它不适合:
- 大量记录;
- 复杂查询;
- 多个键必须同时更新的业务状态;
- 密码、令牌等高敏感秘密;
- 需要关系约束的数据。
Preferences 的典型模型是:
它没有数据库中的表、索引、事务和外键约束。即使某个实现使用了底层数据库,也不能因此把它当作通用数据库使用。
2. 文件:完整对象或二进制内容
文件适合:
- JSON 快照;
- 图片、音频、视频;
- 用户导出的数据;
- 大型缓存;
- 日志;
- 需要按完整内容读取的文档。
文件通常用路径定位,而不是用条件查询:
如果需求变成“查找所有未同步的记录”“按时间分页”“统计每种状态的数量”,文件就不再是自然的数据模型。
3. SQLite:结构化、可查询、需要事务的数据
SQLite 适合:
- 离线数据;
- 多张有关联的表;
- 分页、排序、过滤;
- 唯一约束;
- 增量同步队列;
- 需要多个写入操作原子完成的状态。
SQLite 的核心价值不是“能存数据”,而是:
- SQL 查询;
- B-tree 索引;
- 事务;
- 唯一约束和外键;
- 数据库级别的 schema version;
- 崩溃恢复机制。
4. 加密:一个横向属性,而不是第四种数据模型
加密可以应用于 Preferences、文件或数据库,但“使用加密”必须回答三个问题:
- 谁拥有密钥;
- 数据是否需要完整性保护;
- 是否仍然需要查询或索引。
例如,把整个 JSON 文件加密后,可以保护文件内容,但无法直接执行:
SELECT * FROM messages WHERE sender_id = 42;
如果把整个 SQLite 文件加密,通常需要支持加密 SQLite 的数据库实现;普通 sqflite 并不会因为数据库文件后缀改变就自动加密。
三、Flutter 中的异步存储边界
Flutter UI 运行在 Dart isolate 中。文件和数据库操作通常涉及平台通道或磁盘 I/O,因此接口普遍是异步的。
应用启动时不要把异步初始化塞入 build:
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final repository = await AppRepository.open();
runApp(MyApp(repository: repository));
}
WidgetsFlutterBinding.ensureInitialized() 的作用是确保 Flutter 绑定已经初始化,使 main 中可以安全调用插件和平台相关 API。
错误的生命周期模式是:
@override
Widget build(BuildContext context) {
final future = loadFromDatabase(); // 每次 build 都可能重复执行
return FutureBuilder(
future: future,
builder: ...,
);
}
build 可能因为主题、尺寸或父组件状态变化而反复执行。应该在状态初始化、路由加载器或状态管理层中创建并缓存 Future,而不是把带副作用的存储调用直接放入 build。
存储层还应防止“组件已经销毁,但异步读取后来完成”的问题:
class ExampleState extends State<Example> {
bool loading = true;
String? value;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final result = await widget.repository.readValue();
if (!mounted) {
return;
}
setState(() {
value = result;
loading = false;
});
}
}
mounted 只能避免向已经销毁的 State 调用 setState,不能取消底层数据库或网络操作。需要真正取消时,应使用支持取消的 API、请求令牌或 isolate 生命周期控制。
四、Preferences:简单,但不等于可靠数据库
Flutter 项目通常通过 shared_preferences 包访问 Preferences。它是插件包,不是 Dart 标准库的一部分;具体 API 和平台支持应以项目实际锁定的包版本为准。
较新的 API 形态包括 SharedPreferencesAsync 和 SharedPreferencesWithCache。下面使用异步接口,避免把缓存值误认为已经与磁盘同步:
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
final SharedPreferencesAsync _preferences =
SharedPreferencesAsync();
Future<bool> isDarkMode() async {
return await _preferences.getBool('settings.dark_mode') ?? false;
}
Future<void> setDarkMode(bool enabled) async {
final ok = await _preferences.setBool(
'settings.dark_mode',
enabled,
);
if (!ok) {
throw StateError('无法保存主题设置');
}
}
Future<String?> selectedLanguage() {
return _preferences.getString('settings.language');
}
Future<void> setSelectedLanguage(String language) async {
final ok = await _preferences.setString(
'settings.language',
language,
);
if (!ok) {
throw StateError('无法保存语言设置');
}
}
}
这里的 bool 返回值表示底层实现是否接受了设置操作,但不应把它解释为“设备已经完成物理持久化”。应用仍可能在极端情况下被终止、磁盘损坏或数据被系统清理。
1. Preferences 的原子性边界
假设应用要同时保存:
account_id = "u100"
is_logged_in = true
连续执行两个 set 操作并不自动形成跨键事务:
await preferences.setString('account_id', 'u100');
await preferences.setBool('is_logged_in', true);
如果进程在两次写入之间终止,可能得到:
account_id = "u100"
is_logged_in = null
因此,多个值必须保持一致时,可以把它们序列化为一个值:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class SessionStore {
final SharedPreferencesAsync _preferences =
SharedPreferencesAsync();
Future<void> saveSession({
required String accountId,
required String accessToken,
}) async {
final payload = jsonEncode({
'accountId': accountId,
'accessToken': accessToken,
});
final ok = await _preferences.setString(
'session.snapshot',
payload,
);
if (!ok) {
throw StateError('无法保存会话快照');
}
}
Future<({String accountId, String accessToken})?> readSession() async {
final raw = await _preferences.getString('session.snapshot');
if (raw == null) {
return null;
}
try {
final map = jsonDecode(raw);
if (map is! Map<String, dynamic> ||
map['accountId'] is! String ||
map['accessToken'] is! String) {
throw const FormatException('会话格式错误');
}
return (
accountId: map['accountId'] as String,
accessToken: map['accessToken'] as String,
);
} on FormatException {
await _preferences.remove('session.snapshot');
return null;
}
}
}
这个例子解决了“多个字段分散写入”的问题,但没有提供真正的事务日志,也没有解决访问令牌的保密问题。访问令牌应进一步考虑平台安全存储,而不是仅因为 JSON 写入是单键操作就认为安全。
2. Preferences 的常见误用
用 Preferences 存列表数据
把几千条对象编码成一个 JSON 字符串会导致:
- 每次修改都重写整个列表;
- 查询需要完整反序列化;
- 并发修改容易发生最后写入覆盖;
- 单个坏字节可能导致整个列表无法解析;
- 数据迁移难以局部进行。
用 Preferences 存秘密
Preferences 不是“安全存储”。在移动端,它通常依赖平台偏好设置机制;在桌面和 Web 上,底层实现与安全边界不同。调试备份、越狱或 root 环境、桌面文件访问、浏览器开发者工具都可能暴露内容。
多个 isolate 或多个进程同时写入
Dart isolate 不共享普通内存。多个 isolate 通过平台存储读写同一份数据时,不应假设 Dart 层面的顺序或互斥自动存在。关键状态应集中由一个仓储层串行写入,或者改用具备明确事务语义的数据库。
五、文件存储:目录、原子替换和损坏恢复
1. 选择应用目录
path_provider 提供跨平台目录路径,但目录语义由平台定义,不能只凭名字假设所有系统行为一致。
常见目录包括:
getApplicationDocumentsDirectory():用户生成、应用文档类数据;getApplicationSupportDirectory():应用内部支持数据;getTemporaryDirectory():可被系统清理的临时数据;getApplicationCacheDirectory():缓存数据。
示例:
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
Future<File> userProfileFile() async {
final directory = await getApplicationSupportDirectory();
final filePath = p.join(directory.path, 'profile.json');
return File(filePath);
}
不要手工拼接路径:
final path = '${directory.path}/profile.json';
不同平台的路径分隔符、特殊字符和规范化规则不同,应该使用 package:path。
Web 没有应用沙盒中的普通 dart:io 文件路径。Web 端的持久化通常依赖浏览器存储,例如 IndexedDB 或由具体插件提供的抽象,不能直接复用移动端文件代码。
2. 为什么要使用临时文件再替换
直接执行:
await file.writeAsString(json);
如果进程在写入过程中终止,目标文件可能只包含部分 JSON。下次读取时会得到:
FormatException: Unexpected end of input
更安全的基本流程是:
- 序列化完整内容;
- 写入同目录临时文件;
- 刷新临时文件;
- 将临时文件替换为目标文件;
- 读取失败时保留旧版本或恢复备份。
示例:
import 'dart:convert';
import 'dart:io';
Future<void> writeJsonAtomically(
File target,
Object value,
) async {
final json = jsonEncode(value);
final temp = File('${target.path}.tmp');
await temp.writeAsString(
json,
flush: true,
);
if (await target.exists()) {
await target.delete();
}
await temp.rename(target.path);
}
这个示例改善了“目标文件被部分覆盖”的风险,但它不是所有平台上的严格原子提交保证:
rename是否覆盖已有文件具有平台差异;- 跨文件系统移动通常不是原子的;
- 目录元数据刷新不一定由高层 Dart API 完整暴露;
- 应用终止可能发生在删除旧文件之后、重命名之前。
更稳健的恢复策略是保留两个版本:
profile.json
profile.json.bak
profile.json.tmp
写入时先生成新内容。启动读取时按以下顺序验证:
profile.json存在且 JSON、版本号和校验和均有效,使用它;- 否则检查
profile.json.bak; - 如果临时文件完整,也可以按应用协议恢复;
- 全部失败时回退到默认值并上报诊断信息。
3. 文件格式必须有版本
不要只保存业务字段:
{"name":"Ada","age":37}
应包含格式版本:
{
"schemaVersion": 2,
"profile": {
"name": "Ada",
"birthYear": 1987
}
}
读取时先检查 schemaVersion,再根据版本迁移。版本号解决的是结构变化,校验和解决的是内容完整性检测;两者不是同一个概念。
例如:
Map<String, dynamic> migrateProfile(Map<String, dynamic> input) {
final version = input['schemaVersion'];
if (version is! int) {
throw const FormatException('缺少 schemaVersion');
}
var current = input;
if (version == 1) {
final profile = Map<String, dynamic>.from(
current['profile'] as Map,
);
final age = profile.remove('age');
if (age is! int) {
throw const FormatException('旧 profile.age 无效');
}
final now = DateTime.now().year;
profile['birthYear'] = now - age;
current = {
'schemaVersion': 2,
'profile': profile,
};
}
if (current['schemaVersion'] != 2) {
throw FormatException(
'不支持的 profile schema: ${current['schemaVersion']}',
);
}
return current;
}
这个迁移只是示例,且由年龄反推出生年份会丢失生日月份和日期,因此在真实业务中不能把这种转换当作精确历史数据。迁移代码必须考虑旧字段的信息是否足以推导新字段。
六、SQLite:从表结构到事务
SQLite 是嵌入式关系数据库。它通常以一个数据库文件保存表、索引、触发器和元数据。Flutter 不直接在框架核心中内置统一 SQLite ORM;项目需要选择插件或数据库封装。
常见方案包括:
sqflite:移动端常用的 SQLite 插件;sqflite_common_ffi:桌面等支持 SQLite FFI 的环境;drift:在 SQLite 之上提供类型安全查询、代码生成和迁移工具;- 其他数据库插件:具体能力和平台范围需按包文档确认。
下面使用 sqflite 展示核心机制。依赖可以通过命令添加:
flutter pub add sqflite path
桌面端不能因为代码使用了 sqflite 就自动获得所有平台支持;通常需要平台对应的 SQLite 实现,例如 FFI,并在入口初始化相应工厂。Web 端则不能使用 dart:io 文件数据库方式。
1. 数据库打开与 schema version
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
class AppDatabase {
static Future<Database> open() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, 'app.db');
return openDatabase(
path,
version: 2,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
updated_at INTEGER NOT NULL,
sync_state INTEGER NOT NULL DEFAULT 0
)
''');
await db.execute('''
CREATE INDEX idx_notes_updated_at
ON notes(updated_at DESC)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('''
ALTER TABLE notes
ADD COLUMN sync_state INTEGER NOT NULL DEFAULT 0
''');
}
},
);
}
}
这里的 version: 2 是数据库 schema 版本,不是应用版本。SQLite 打开时会比较旧版本与新版本:
oldVersion == 0:通常表示数据库首次创建,执行onCreate;oldVersion < newVersion:执行对应的onUpgrade;oldVersion > newVersion:说明当前应用代码比数据库旧,需要明确处理降级,而不能静默覆盖。
迁移条件使用区间判断:
if (oldVersion < 2) {
// 迁移到版本 2
}
if (oldVersion < 3) {
// 迁移到版本 3
}
这样一个从版本 1 直接升级到版本 3 的用户会依次执行 1→2 和 2→3,而不是只执行最后一步。
2. CRUD 与参数绑定
class NoteRepository {
final Database db;
NoteRepository(this.db);
Future<int> insertNote({
required String title,
required String body,
}) {
return db.insert('notes', {
'title': title,
'body': body,
'updated_at': DateTime.now().millisecondsSinceEpoch,
'sync_state': 0,
});
}
Future<List<Map<String, Object?>>> listNotes({
int limit = 20,
int offset = 0,
}) {
return db.query(
'notes',
orderBy: 'updated_at DESC, id DESC',
limit: limit,
offset: offset,
);
}
Future<int> updateNote({
required int id,
required String title,
required String body,
}) {
return db.update(
'notes',
{
'title': title,
'body': body,
'updated_at': DateTime.now().millisecondsSinceEpoch,
'sync_state': 0,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> deleteNote(int id) {
return db.delete(
'notes',
where: 'id = ?',
whereArgs: [id],
);
}
}
whereArgs 是参数绑定。不要通过字符串插值拼接用户输入:
// 不要这样做
final sql = "SELECT * FROM notes WHERE title = '$input'";
参数绑定可以避免 SQL 注入和引号转义错误:
final rows = await db.query(
'notes',
where: 'title = ?',
whereArgs: [input],
);
表名和列名通常不能像值一样使用 ? 绑定。如果表名来自用户输入,需要使用固定白名单映射,而不是直接拼接。
3. 事务:多个变化必须一起提交
假设发送一条消息时需要同时:
- 插入消息;
- 插入同步队列;
- 更新会话的最后消息时间。
这三个操作不能只依赖 Dart 中连续的 await:
await db.insert('messages', message);
await db.insert('outbox', outbox);
await db.update('conversations', conversation);
中间任何一步失败,数据库可能处于半完成状态。应使用事务:
Future<void> saveMessage(Database db) async {
await db.transaction((txn) async {
final messageId = await txn.insert('messages', {
'conversation_id': 7,
'body': 'hello',
'created_at': DateTime.now().millisecondsSinceEpoch,
});
await txn.insert('outbox', {
'entity_type': 'message',
'entity_id': messageId,
'operation': 'create',
'created_at': DateTime.now().millisecondsSinceEpoch,
});
await txn.update(
'conversations',
{
'last_message_id': messageId,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [7],
);
});
}
事务的关键性质是:
在事务回调内部,应使用 txn,不要重新使用外部的 db 对象,否则可能产生嵌套事务或锁等待问题。
4. 唯一约束解决并发重复
假设同步接口返回同一个远程 ID 两次,应用不能只靠“先查询再插入”防止重复:
先 SELECT,不存在
另一个任务也 SELECT,不存在
两个任务都 INSERT
应把唯一性放在数据库中:
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_id TEXT NOT NULL UNIQUE,
body TEXT NOT NULL
);
然后根据业务选择冲突策略:
await db.insert(
'messages',
{
'remote_id': remoteId,
'body': body,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
“先检查再写入”是应用层逻辑;UNIQUE 约束才是数据库层最终保证。两者的因果关系不能倒置。
七、SQLite 查询、索引和分页边界
1. 索引不是越多越好
如果查询是:
SELECT *
FROM notes
WHERE sync_state = 0
ORDER BY updated_at ASC, id ASC
LIMIT 50;
可以考虑复合索引:
CREATE INDEX idx_notes_sync_updated
ON notes(sync_state, updated_at, id);
索引的代价是:
- 占用额外磁盘空间;
- 插入、更新、删除时需要维护索引;
- 过多索引会降低写入性能;
- 索引顺序必须匹配常用过滤和排序模式。
应使用 EXPLAIN QUERY PLAN 检查实际计划:
EXPLAIN QUERY PLAN
SELECT *
FROM notes
WHERE sync_state = 0
ORDER BY updated_at ASC, id ASC
LIMIT 50;
输出中的具体文字依 SQLite 版本和查询计划变化,不能把某一条文本当作永久 API 保证。诊断时重点观察是否使用预期索引,以及是否出现全表扫描。
2. OFFSET 分页的边界
SELECT *
FROM notes
ORDER BY updated_at DESC
LIMIT 20 OFFSET 40;
当数据量增长或前面的记录发生插入、删除时,OFFSET 分页可能出现:
- 同一条记录重复出现;
- 某条记录被跳过;
- 深页扫描成本变高。
更稳定的是基于游标的分页。若排序键为 (updated_at DESC, id DESC),下一页条件可以写成:
SELECT *
FROM notes
WHERE updated_at < ?
OR (updated_at = ? AND id < ?)
ORDER BY updated_at DESC, id DESC
LIMIT 20;
假设上一页最后一条是:
updated_at = 1700000000000
id = 42
下一页只读取:
updated_at < 1700000000000
或在时间相同的情况下:
updated_at = 1700000000000 且 id < 42
id 是第二排序键,用来打破时间相同导致的不确定顺序。
八、加密:机密性、完整性和密钥管理
1. 加密保护什么
加密的目标通常是机密性:
其中:
- 是明文;
- 是密钥;
- 是随机 nonce 或 IV;
- 是密文。
现代应用应优先使用带认证的加密模式,例如 AES-GCM 或 ChaCha20-Poly1305。带认证加密不仅隐藏明文,还能检测密文是否被篡改。
解密时:
如果认证标签 T 校验失败,应把它视为数据损坏或篡改,而不是返回一段可能错误的明文。
2. 不要硬编码密钥
以下方式不能保护密钥:
const encryptionKey = 'my-secret-key';
因为 APK、IPA、桌面二进制和 Web JavaScript 都可能被分析。把字符串拆成多个常量、Base64 编码或放入混淆代码,也只是提高搜索成本,不能改变攻击者最终能获得密钥的事实。
更合理的移动端流程是:
- 第一次启动时生成随机数据密钥;
- 使用 Android Keystore 或 iOS Keychain 等平台安全能力保护该密钥;
- 业务数据使用数据密钥加密;
- 读取时从安全存储取出密钥;
- 密文认证失败时拒绝使用,并进入恢复流程。
Flutter 项目常使用 flutter_secure_storage 等插件访问平台安全存储。它不是 Flutter 框架核心 API,平台实现、备份行为、Web 支持和硬件保护能力必须以实际插件版本和平台配置为准。
3. 安全存储也不是无条件安全
平台安全存储通常更适合保存:
- 刷新令牌;
- 数据加密密钥;
- 短小的设备绑定秘密。
它不适合保存大型数据库或大量业务记录。还存在以下边界:
- 设备已解锁时,应用本身通常可以读取自己的秘密;
- root、越狱、调试、恶意备份可能削弱保护;
- Android 卸载、密钥恢复和备份策略存在系统版本差异;
- iOS Keychain 的可访问性配置会影响锁屏时是否可读;
- Web 的“安全存储”不能等价于硬件支持的 Keychain 或 Keystore。
因此,安全存储解决的是“密钥放在哪里”,而不是“应用被完全攻破后数据仍不可读”。
4. 加密文件示意
下面的代码展示加密文件应具备的结构,不依赖某个特定加密包的 API:
magic/version | nonce | ciphertext | authentication_tag
其中:
magic/version用于识别格式;nonce可以明文保存,但必须保证同一密钥下不重复;ciphertext是加密后的内容;authentication_tag用于检测篡改。
不要复用固定 nonce。对 GCM 这类模式,重复 nonce 可能严重破坏安全性。不要只使用 AES-CBC 加密而不附带 MAC;否则攻击者可能篡改密文而不被发现。
5. 加密 SQLite 的现实限制
普通 SQLite 数据库文件包含:
- 表数据;
- 索引;
- WAL 或 journal 文件;
- 可能的临时文件。
只把主数据库文件加密,不能自动保护 WAL、journal 或导出的备份。若需求是“数据库整体透明加密并保持 SQL 查询能力”,需要使用支持加密 SQLite 的实现,并验证:
- Android、iOS、macOS、Windows、Linux 是否都支持;
- WAL 和备份是否同样受保护;
- 密钥注入方式;
- 数据库升级和迁移兼容性;
- 是否能在目标平台上稳定构建。
把数据库导出为 JSON 后再加密,只能保护导出文件,不能替代运行时数据库加密。
九、迁移:数据结构变化的可验证流程
迁移是把旧数据 转换成新结构 的函数:
理想情况下,迁移应满足:
- 可执行:所有支持的旧版本都能完成;
- 可验证:迁移后满足新 schema 的约束;
- 幂等或可识别:不会重复执行同一步造成破坏;
- 可恢复:失败后旧数据或备份仍可用;
- 可观测:能记录失败版本和错误原因。
1. SQLite 迁移示例
版本 1:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
版本 2 要求增加非空邮箱:
ALTER TABLE users ADD COLUMN email TEXT NOT NULL;
这通常会失败,因为旧行没有 email。正确做法取决于业务语义。
如果邮箱可以暂时为空:
ALTER TABLE users ADD COLUMN email TEXT;
如果邮箱必须非空,可分阶段迁移:
BEGIN;
ALTER TABLE users ADD COLUMN email TEXT;
UPDATE users
SET email = 'unknown-' || id || '@invalid.local'
WHERE email IS NULL;
-- 之后再根据 SQLite 版本和表重建策略建立 NOT NULL 约束
COMMIT;
但这里的默认邮箱是业务决策,不是技术自动推导。若无法可靠生成,应让迁移完成结构变化,再把用户标记为需要补全,而不是伪造真实数据。
SQLite 对部分 ALTER TABLE 操作有限制。复杂变更经常采用“重建表”流程:
BEGIN;
CREATE TABLE users_new (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);
INSERT INTO users_new (id, name, email)
SELECT id, name, email
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
COMMIT;
生产环境中还需同步重建索引、触发器、外键关系,并在事务内验证行数和关键约束。
2. 文件迁移示例
对于 JSON 文件,迁移通常是显式的纯函数链:
Map<String, dynamic> migrate(Map<String, dynamic> source) {
var version = source['schemaVersion'];
if (version is! int) {
throw const FormatException('版本字段缺失');
}
var result = source;
if (version == 1) {
result = migrateV1ToV2(result);
version = 2;
}
if (version == 2) {
result = migrateV2ToV3(result);
version = 3;
}
if (version != 3) {
throw FormatException('不支持的版本: $version');
}
return result;
}
每一个迁移函数都应有测试:
给定合法的 v1 输入
→ 迁移后 schemaVersion 为 3
→ 必填字段存在
→ 字段类型正确
→ 原始业务信息没有无依据地丢失
反例是只修改读取代码:
final name = map['displayName'] ?? map['name'];
这可以兼容读取,却没有真正升级数据。之后任何写入仍可能产生混合格式,最终让迁移责任散落在整个应用中。
3. 应用版本与数据版本分离
应用版本 2.5.0 不一定对应数据库版本 5。一个应用版本可能包含多个数据迁移,也可能不改变数据结构。
应分别记录:
应用版本:面向用户发布
数据库 schema 版本:面向 SQLite 结构
文件格式版本:面向某种序列化协议
同步协议版本:面向服务端交互
混淆这些版本会导致错误迁移,例如把服务端 API 版本变化误当成本地表结构变化。
十、迁移失败、崩溃和恢复路径
一个实际的数据库升级流程如下:
flowchart TD
A[打开本地数据库] --> B{当前 schema 是否最新}
B -->|是| C[执行查询]
B -->|否| D[创建备份或保留恢复副本]
D --> E[开始迁移事务]
E --> F{迁移成功}
F -->|是| G[校验表、索引和关键数据]
G --> H[提交事务]
H --> C
F -->|否| I[回滚事务]
I --> J{旧数据库仍可打开}
J -->|是| K[记录错误并进入兼容/升级失败状态]
J -->|否| L[从备份恢复或清理可重建缓存]
关键点是:
- 迁移前要知道哪些数据可重建,哪些数据不可丢失;
- 迁移脚本不能依赖网络,因为离线升级必须可执行;
- 迁移期间不要启动依赖新表结构的并发查询;
- 成功后要验证,而不是仅以“没有抛异常”为成功;
- 失败后不能直接删除数据库,除非数据库明确被定义为可重建缓存。
删除数据库看似简单:
await deleteDatabase(path);
但这会丢失未同步数据、草稿、用户本地记录和同步队列。只有在数据确实可从服务端完整重建,并且用户明确接受时,才适合作为恢复策略。
十一、并发、缓存和单一写入入口
本地存储常见的并发来源包括:
- 用户快速点击产生多个写入;
- 页面销毁后旧请求仍在完成;
- 后台同步任务与前台编辑同时修改;
- 多个 isolate 访问同一数据库;
- 应用恢复时同时触发多个初始化流程。
一个简单的仓储层可以用串行队列保护写入:
import 'dart:async';
class SerialWriter {
Future<void> _tail = Future.value();
Future<T> run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>(
(_) {},
onError: (_, __) {},
);
return result;
}
}
使用时:
final writer = SerialWriter();
Future<void> saveSettings() {
return writer.run(() async {
// 这里的写操作按提交顺序执行
});
}
这只能串行化当前 Dart 进程内的调用,不能替代数据库事务,也不能协调其他进程或其他应用实例。
缓存还需要区分:
- 内存缓存:速度快,进程退出即丢失;
- 持久缓存:可在重启后读取,但可能过期;
- 权威数据:决定业务最终状态的数据来源。
例如网络列表缓存中,缓存命中不等于数据最新。应保存:
payload
fetchedAt
etag 或版本标识
schemaVersion
读取时还要定义过期条件:
其中 TTL 是允许的数据新鲜度窗口。离线模式可以使用过期缓存,但 UI 应区分“实时数据”和“离线快照”。
十二、平台差异
Android 与 iOS
- 应用沙盒路径不能硬编码;
- 应用卸载通常会删除应用私有文件和数据库;
- 系统备份是否包含某类数据由平台、目录和配置共同决定;
- Android 多进程访问同一数据库需要额外验证;
- iOS 的文件保护级别会影响设备锁定时的数据可读性;
- 平台安全存储的可访问性、迁移和备份行为不同。
macOS、Windows 和 Linux
桌面端的文件通常更容易被用户、备份工具或其他进程访问。不能把“桌面应用沙盒”当作移动端同等级别的隔离。
SQLite 支持也依赖具体 Flutter 插件和原生构建配置。使用 FFI 时需要确认:
- SQLite 动态库或静态库是否可用;
- 发布包是否包含所需库;
- 文件锁在目标文件系统上是否正常;
- 多实例打开时的锁竞争行为。
Web
Web 端没有 dart:io 文件系统,也不能使用移动端的 getDatabasesPath() 语义。浏览器存储包括:
localStorage:容量小、同步 API、适合极少量简单值;- IndexedDB:异步、适合结构化数据;
- Cache Storage:主要用于请求和响应缓存。
Web 存储受源、浏览器策略、隐私模式、用户清理和容量回收影响。浏览器开发者工具可以直接查看许多本地数据,因此不能把 Web 本地存储当作秘密保存位置。
如果同一仓储要支持 Web 和移动端,应在接口层抽象:
abstract interface class NotesStore {
Future<void> save(Note note);
Future<List<Note>> page({required int limit, String? cursor});
}
然后为移动端提供 SQLite 实现,为 Web 提供 IndexedDB 或其他浏览器存储实现,而不是在业务代码中到处判断 Platform.isAndroid。
十三、诊断与验证
1. 启动时检查
生产环境可以记录非敏感的诊断字段:
storage backend = sqlite
database schema = 5
last migration = 4 -> 5
last migration result = success
database file size = ...
pending outbox rows = ...
不要记录:
- 访问令牌;
- 加密密钥;
- 完整用户隐私内容;
- 未脱敏的 SQL 参数。
2. 验证数据库状态
可以在调试或诊断命令中执行:
PRAGMA user_version;
PRAGMA integrity_check;
integrity_check 返回 ok 表示 SQLite 完成了相应完整性检查,但它不能证明业务数据正确,也不能证明迁移没有丢失语义。
业务验证还应包括:
SELECT COUNT(*) FROM notes;
SELECT COUNT(*) FROM outbox WHERE sync_state = 0;
以及关键外键、唯一键和状态分布检查。
3. 必须测试的故障
本地存储测试不应只覆盖“正常写入后正常读取”,还应覆盖:
- 写入过程中进程终止;
- JSON 文件部分损坏;
- 数据库迁移从每个历史版本直升最新版本;
- 迁移中途抛异常;
- 磁盘空间不足;
- 数据库被锁定;
- 重复同步事件;
- 时间相同的分页游标;
- 加密密文被修改;
- 密钥丢失或安全存储读取失败;
- Web 端用户清理站点数据;
- 应用卸载后重新安装。
尤其要测试“旧版本真实数据”,而不是只手工构造理想输入。真实旧数据往往包含空值、未知字段、旧 bug 写入的非法值和未完成的迁移状态。
十四、一个可执行的选型算例
假设一个笔记应用有以下数据:
| 数据 | 特征 | 选择 |
|---|---|---|
| 是否深色模式 | 单个布尔值 | Preferences |
| 用户头像 | 二进制、大对象 | 文件 |
| 笔记标题和正文 | 需要搜索、排序、分页 | SQLite |
| 刷新令牌 | 高敏感、短文本 | 平台安全存储 |
| 导出备份 | 用户主动生成、可迁移 | 加密文件 |
| 未同步变更 | 需要重试、去重、事务 | SQLite outbox 表 |
一次保存笔记的完整数据流可以是:
用户编辑
↓
业务层校验标题和正文
↓
SQLite 事务
├─ 写入 notes
├─ 写入 outbox
└─ 更新会话摘要
↓
事务提交
↓
后台同步读取 outbox
↓
HTTP 成功后在另一个事务中标记已同步
如果正文中包含敏感内容,可以在进入 SQLite 前加密字段,但要接受查询能力下降。例如对正文进行随机 nonce 的认证加密后,无法直接执行全文搜索;若必须搜索,就需要设计可接受泄露风险的搜索索引,或只对部分字段加密。
十五、最终边界
可以用下面的判断顺序决定存储方案:
- 数据是否只是少量标量配置?是,则考虑 Preferences;
- 数据是否按完整对象或二进制读取?是,则考虑文件;
- 是否需要查询、排序、分页、唯一性或事务?是,则考虑 SQLite;
- 数据是否敏感?是,则额外设计密钥管理和认证加密;
- 数据结构是否会演进?是,则从第一版就加入 schema version 和迁移;
- 是否支持 Web 或桌面?是,则为平台差异设计存储实现,而不是假设移动端 API 全部通用;
- 数据是否可以重建?不可重建的数据必须有失败恢复和备份策略。
Preferences、文件和 SQLite 解决的是不同的数据组织问题;加密解决的是机密性和完整性问题;迁移解决的是时间演进问题。把它们混为一谈,通常会表现为:用 Preferences 模拟数据库、用字符串覆盖文件、把密钥硬编码在应用中,或者升级时直接删除旧数据。
可靠的本地存储层应当明确数据模型、生命周期、事务边界、平台实现、故障恢复和版本迁移,而不是只在页面中调用一个 set、write 或 insert。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 网络与数据层:HTTP、序列化、取消、缓存、分页和离线
- 下一篇:Flutter 平台集成:Plugin、Platform Channel、原生生命周期和权限
- 延伸:Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论