本地存储:偏好、文件与 SQLite
本地存储的选择取决于三件事:数据有多小、是什么结构、是否需要加密。键值对用 shared_preferences,结构化或大体积数据用文件(path_provider + JSON),需要查询和事务就用 SQLite(sqflite 或 drift),密码令牌一律进 flutter_secure_storage。本章给出四种方案的最小可用代码与取舍。
先定策略:内存 → 文件 → 网络
读数据按"内存缓存(最快)→ 本地文件/DB(离线可用)→ 网络(最新但最慢)"逐级降级;写数据先乐观更新 UI,再写内存、异步落盘、后台同步。
| 数据 | 推荐方案 | 过期策略 |
|---|---|---|
| 开关、主题、上次登录账号 | shared_preferences | 不过期 |
| 列表页 JSON 缓存 | 文件 + 时间戳 | 5~30 分钟 |
| Token、支付凭据 | flutter_secure_storage | 随登录态失效 |
flutter pub add shared_preferences path_provider sqflite path flutter_secure_storage
shared_preferences:键值对
只适合少量简单类型:bool、int、double、String、List<String>。不支持自定义对象与嵌套结构,要存对象就自己 jsonEncode 成字符串。
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
static const _kThemeMode = 'theme_mode';
static const _kLastUser = 'last_user';
Future<void> saveThemeMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeMode, mode); // 写入是异步的,要 await
}
Future<Map<String, dynamic>?> readUser() async {
final raw = (await SharedPreferences.getInstance()).getString(_kLastUser);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null; // 旧结构不兼容时直接放弃
}
}
}
三个注意点:首次 getInstance() 有 IO 开销,可在 main() 里预取一次再注入;它没有原子更新,并发自增要先读再写、可能丢更新;Web 上落到 localStorage,容量约 5 MB。
path_provider:文件读写
| 目录方法 | 位置 | 用途 |
|---|---|---|
getApplicationDocumentsDirectory() | 应用私有,iOS 会被备份 | 用户数据、草稿 |
getApplicationSupportDirectory() | 应用私有,不备份 | 缓存、数据库 |
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
Future<File> cacheFile() async {
final dir = await getApplicationSupportDirectory(); // 缓存目录,不参与 iOS 备份
return File('${dir.path}/articles.json'); // 用 path 包拼路径更稳,跨平台安全
}
Future<void> saveCache(List<Map<String, dynamic>> list) async {
// 写入带时间戳的包装结构,读出时判断是否过期
await (await cacheFile()).writeAsString(
jsonEncode({'savedAt': DateTime.now().toIso8601String(), 'list': list}),
flush: true,
);
}
Future<List<Map<String, dynamic>>?> readCache() async {
final file = await cacheFile();
if (!await file.exists()) return null;
try {
final payload = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final savedAt = DateTime.tryParse(payload['savedAt'] as String? ?? '');
if (savedAt == null || DateTime.now().difference(savedAt) > const Duration(minutes: 10)) {
return null; // 已过期,交给上层重新请求
}
return (payload['list'] as List).cast<Map<String, dynamic>>();
} catch (_) {
await file.delete(); // 文件损坏或内容非法,删掉重建
return null;
}
}
sqflite:SQLite 最小例子
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
class TodoDb {
Database? _db;
Future<Database> get db async => _db ??= await openDatabase(
p.join(await getDatabasesPath(), 'todo.db'),
version: 1, // 改表结构时 +1,并在 onUpgrade 里做迁移
onCreate: (db, v) => db.execute('''CREATE TABLE todo(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL)'''),
);
Future<int> insert(String title) async => (await db).insert('todo',
{'title': title, 'done': 0, 'created_at': DateTime.now().millisecondsSinceEpoch});
Future<List<Map<String, Object?>>> queryAll() async =>
(await db).query('todo', orderBy: 'created_at DESC', limit: 100);
// 查改删同理:条件用 where + whereArgs 传参,不要拼字符串
Future<int> markDone(int id, bool done) async =>
(await db).update('todo', {'done': done ? 1 : 0}, where: 'id = ?', whereArgs: [id]);
Future<void> close() async => _db?.close();
}
| 维度 | sqflite | drift |
|---|---|---|
| 写法 | 手写 SQL 字符串 | 注解 + 代码生成 |
| 类型安全 | 弱,列名写错运行期才报 | 强,编译期校验 |
敏感数据与缓存纪律
Token、刷新令牌、支付凭据不要放 shared_preferences(Android 上 root 设备可读,iOS 上会进备份)。用 flutter_secure_storage,它底层走 Keychain / Keystore:
final storage = const FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
final token = await storage.read(key: 'access_token');
- 先出旧数据再刷网络(stale-while-revalidate),用户不必盯着转圈;缓存必须带时间戳与版本号,后端结构变更时直接废弃旧缓存;同时提供清缓存入口,出问题时用户能自救。
小结:键值对用 shared_preferences,结构化缓存用 path_provider 写 JSON 文件并带过期时间,需要查询与事务用 sqflite(表多再考虑 drift),敏感数据一律进 flutter_secure_storage;读路径按"内存 → 本地 → 网络"逐级降级,写路径先更新 UI 再异步落盘。