Flutter 基础体系 · 第 13/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 本地存储:Preferences、文件、SQLite、加密和迁移

Flutter 应用中的本地存储不是一个单一 API,而是几种具有不同数据模型、持久性保证、并发行为和安全边界的机制组合:

  • Preferences:保存少量、简单、按键访问的配置;
  • 文件:保存 JSON、图片、日志、导出包等完整内容;
  • SQLite:保存具有查询、约束、事务和迁移需求的结构化数据;
  • 加密:保护数据机密性,但不能自动解决密钥管理、完整性和查询问题;
  • 迁移:让旧版本已经存在的数据能够安全地适应新版本的结构。

正确的设计不是“所有数据都放 SQLite”,而是先根据数据的访问方式、规模、关系、敏感性和恢复要求选择存储介质。


一、先区分“数据存在”与“数据可用”

移动端本地数据至少涉及三个不同概念:

  1. 进程存活期间存在:例如 Dart 内存中的对象;
  2. 应用未被卸载时存在:例如应用沙盒中的数据库和文件;
  3. 设备或备份体系中可恢复:例如系统备份、云同步或用户导出的文件。

“写入成功”通常只表示底层 API 接受了写入请求,不一定表示数据已经完成跨设备同步,也不表示数据不会被系统清理,更不表示数据没有被备份或提取。

可以把一次本地写入抽象为:

应用对象序列化存储 API操作系统文件系统持久介质\text{应用对象} \rightarrow \text{序列化} \rightarrow \text{存储 API} \rightarrow \text{操作系统文件系统} \rightarrow \text{持久介质}

每一层都可能失败:

  • 对象不能被序列化;
  • 路径不存在或没有权限;
  • 磁盘空间不足;
  • 数据库事务失败;
  • 应用在写入过程中被终止;
  • 文件已写完,但索引或元数据没有同步更新。

因此,存储层接口不应只返回“成功或失败”,还应明确:

  • 数据的主键是什么;
  • 写入是否幂等;
  • 是否允许丢失;
  • 失败后能否重试;
  • 读取到损坏数据时如何恢复;
  • 数据是否参与备份和迁移。

二、按数据特征选择存储方式

1. Preferences:少量标量配置

Preferences 适合:

  • 是否首次启动;
  • 当前主题模式;
  • 用户选择的语言;
  • 最近一次同步时间;
  • 简单的布尔、整数、浮点数和字符串;
  • 少量字符串列表。

它不适合:

  • 大量记录;
  • 复杂查询;
  • 多个键必须同时更新的业务状态;
  • 密码、令牌等高敏感秘密;
  • 需要关系约束的数据。

Preferences 的典型模型是:

keyscalar value\text{key} \rightarrow \text{scalar value}

它没有数据库中的表、索引、事务和外键约束。即使某个实现使用了底层数据库,也不能因此把它当作通用数据库使用。

2. 文件:完整对象或二进制内容

文件适合:

  • JSON 快照;
  • 图片、音频、视频;
  • 用户导出的数据;
  • 大型缓存;
  • 日志;
  • 需要按完整内容读取的文档。

文件通常用路径定位,而不是用条件查询:

pathbytes\text{path} \rightarrow \text{bytes}

如果需求变成“查找所有未同步的记录”“按时间分页”“统计每种状态的数量”,文件就不再是自然的数据模型。

3. SQLite:结构化、可查询、需要事务的数据

SQLite 适合:

  • 离线数据;
  • 多张有关联的表;
  • 分页、排序、过滤;
  • 唯一约束;
  • 增量同步队列;
  • 需要多个写入操作原子完成的状态。

SQLite 的核心价值不是“能存数据”,而是:

  • SQL 查询;
  • B-tree 索引;
  • 事务;
  • 唯一约束和外键;
  • 数据库级别的 schema version;
  • 崩溃恢复机制。

4. 加密:一个横向属性,而不是第四种数据模型

加密可以应用于 Preferences、文件或数据库,但“使用加密”必须回答三个问题:

  1. 谁拥有密钥;
  2. 数据是否需要完整性保护;
  3. 是否仍然需要查询或索引。

例如,把整个 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 形态包括 SharedPreferencesAsyncSharedPreferencesWithCache。下面使用异步接口,避免把缓存值误认为已经与磁盘同步:

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

更安全的基本流程是:

  1. 序列化完整内容;
  2. 写入同目录临时文件;
  3. 刷新临时文件;
  4. 将临时文件替换为目标文件;
  5. 读取失败时保留旧版本或恢复备份。

示例:

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

写入时先生成新内容。启动读取时按以下顺序验证:

  1. profile.json 存在且 JSON、版本号和校验和均有效,使用它;
  2. 否则检查 profile.json.bak
  3. 如果临时文件完整,也可以按应用协议恢复;
  4. 全部失败时回退到默认值并上报诊断信息。

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. 事务:多个变化必须一起提交

假设发送一条消息时需要同时:

  1. 插入消息;
  2. 插入同步队列;
  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],
    );
  });
}

事务的关键性质是:

提交成功三个变化都可见\text{提交成功} \Rightarrow \text{三个变化都可见}

事务失败三个变化都回滚\text{事务失败} \Rightarrow \text{三个变化都回滚}

在事务回调内部,应使用 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. 加密保护什么

加密的目标通常是机密性:

C=Encrypt(K,P,N)C = \operatorname{Encrypt}(K, P, N)

其中:

  • PP 是明文;
  • KK 是密钥;
  • NN 是随机 nonce 或 IV;
  • CC 是密文。

现代应用应优先使用带认证的加密模式,例如 AES-GCM 或 ChaCha20-Poly1305。带认证加密不仅隐藏明文,还能检测密文是否被篡改。

解密时:

Decrypt(K,C,N,T)\operatorname{Decrypt}(K, C, N, T)

如果认证标签 T 校验失败,应把它视为数据损坏或篡改,而不是返回一段可能错误的明文。

2. 不要硬编码密钥

以下方式不能保护密钥:

const encryptionKey = 'my-secret-key';

因为 APK、IPA、桌面二进制和 Web JavaScript 都可能被分析。把字符串拆成多个常量、Base64 编码或放入混淆代码,也只是提高搜索成本,不能改变攻击者最终能获得密钥的事实。

更合理的移动端流程是:

  1. 第一次启动时生成随机数据密钥;
  2. 使用 Android Keystore 或 iOS Keychain 等平台安全能力保护该密钥;
  3. 业务数据使用数据密钥加密;
  4. 读取时从安全存储取出密钥;
  5. 密文认证失败时拒绝使用,并进入恢复流程。

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 后再加密,只能保护导出文件,不能替代运行时数据库加密。


九、迁移:数据结构变化的可验证流程

迁移是把旧数据 DiD_i 转换成新结构 Di+1D_{i+1} 的函数:

Mi:DiDi+1M_i: D_i \rightarrow D_{i+1}

理想情况下,迁移应满足:

  1. 可执行:所有支持的旧版本都能完成;
  2. 可验证:迁移后满足新 schema 的约束;
  3. 幂等或可识别:不会重复执行同一步造成破坏;
  4. 可恢复:失败后旧数据或备份仍可用;
  5. 可观测:能记录失败版本和错误原因。

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

读取时还要定义过期条件:

可用缓存    当前时间fetchedAtTTL\text{可用缓存} \iff \text{当前时间} - \text{fetchedAt} \leq \text{TTL}

其中 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 的认证加密后,无法直接执行全文搜索;若必须搜索,就需要设计可接受泄露风险的搜索索引,或只对部分字段加密。


十五、最终边界

可以用下面的判断顺序决定存储方案:

  1. 数据是否只是少量标量配置?是,则考虑 Preferences;
  2. 数据是否按完整对象或二进制读取?是,则考虑文件;
  3. 是否需要查询、排序、分页、唯一性或事务?是,则考虑 SQLite;
  4. 数据是否敏感?是,则额外设计密钥管理和认证加密;
  5. 数据结构是否会演进?是,则从第一版就加入 schema version 和迁移;
  6. 是否支持 Web 或桌面?是,则为平台差异设计存储实现,而不是假设移动端 API 全部通用;
  7. 数据是否可以重建?不可重建的数据必须有失败恢复和备份策略。

Preferences、文件和 SQLite 解决的是不同的数据组织问题;加密解决的是机密性和完整性问题;迁移解决的是时间演进问题。把它们混为一谈,通常会表现为:用 Preferences 模拟数据库、用字符串覆盖文件、把密钥硬编码在应用中,或者升级时直接删除旧数据。

可靠的本地存储层应当明确数据模型、生命周期、事务边界、平台实现、故障恢复和版本迁移,而不是只在页面中调用一个 setwriteinsert


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。