本地存储:偏好、文件与 SQLite

本地存储的选择取决于三件事:数据有多小、是什么结构、是否需要加密。键值对用 shared_preferences,结构化或大体积数据用文件(path_provider + JSON),需要查询和事务就用 SQLite(sqflitedrift),密码令牌一律进 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:键值对

只适合少量简单类型:boolintdoubleStringList<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();
}
维度sqflitedrift
写法手写 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');
  1. 先出旧数据再刷网络(stale-while-revalidate),用户不必盯着转圈;缓存必须带时间戳与版本号,后端结构变更时直接废弃旧缓存;同时提供清缓存入口,出问题时用户能自救。

小结:键值对用 shared_preferences,结构化缓存用 path_provider 写 JSON 文件并带过期时间,需要查询与事务用 sqflite(表多再考虑 drift),敏感数据一律进 flutter_secure_storage;读路径按"内存 → 本地 → 网络"逐级降级,写路径先更新 UI 再异步落盘。

笔记加载中…