3. SharedPreferences键值存储实战

在 Flutter 的本地存储方案里,SharedPreferences 是最轻的一层。它本质上是一个基于平台原生实现的键值对存储:Android 侧对应 SharedPreferences,iOS/macOS 侧对应 NSUserDefaults,Web 侧对应 localStorage,Windows/Linux 则落到各自平台的配置文件。它适合存放"小、少、简单"的数据——主题模式、语言、是否首次启动、上次登录账号、通知开关、引导页是否看过、列表排序方式等。理解它的边界,比记住 API 更重要:它不适合存大对象、列表、图片、敏感凭证,也不适合高频写入。

一、读写模型:异步 API 与内存缓存

SharedPreferences 的读写是"内存缓存 + 异步落盘"的模型。首次调用 getInstance() 时,插件会把整份偏好数据一次性读入内存;之后的 getString、getBool 等读取操作直接命中内存,是同步的、极快的。而写入操作(setString、setBool 等)会先更新内存,再异步写回磁盘,因此返回的是 Future<bool>。

// 初始化:整个 App 生命周期通常只做一次
final prefs = await SharedPreferences.getInstance();

// 写入:先改内存,再异步落盘
await prefs.setString('theme_mode', 'dark');
await prefs.setBool('first_launch', false);
await prefs.setInt('launch_count', 3);

// 读取:命中内存,同步返回
final theme = prefs.getString('theme_mode') ?? 'system';
final isFirst = prefs.getBool('first_launch') ?? true;
final count = prefs.getInt('launch_count') ?? 0;

// 删除与清空
await prefs.remove('theme_mode');
// await prefs.clear(); // 慎用,会清掉全部键

这里有两个关键点。第一,getXxx 系列都有 ?? 兜底的必要性:键不存在时返回 null,如果直接当非空使用会抛异常。第二,setXxx 返回的 Future 表示"落盘完成",但内存值在调用瞬间就已生效,所以同一帧内再读能立刻拿到新值。若业务要求"写成功后才继续",就必须 await;若只是更新 UI 状态,可以不等待,但要意识到进程被杀时可能丢失最后一次写入。

二、监听变化:让偏好驱动 UI

SharedPreferences 提供了 addListener 与 onSharedPreferenceChanged 两种监听方式。前者在任意键变化时触发,后者可以精确到某个键。典型场景是主题切换:设置页写入 theme_mode,根组件监听后重建 MaterialApp。

class ThemeController extends ChangeNotifier {
  static const _key = 'theme_mode';
  late SharedPreferences _prefs;
  ThemeMode _mode = ThemeMode.system;

  ThemeMode get mode => _mode;

  Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
    _mode = _parse(_prefs.getString(_key));
    _prefs.addListener(_onChanged);
  }

  void _onChanged() {
    final next = _parse(_prefs.getString(_key));
    if (next != _mode) {
      _mode = next;
      notifyListeners();
    }
  }

  Future<void> setMode(ThemeMode m) async {
    _mode = m;
    notifyListeners();
    await _prefs.setString(_key, m.name);
  }

  ThemeMode _parse(String? v) => switch (v) {
        'light' => ThemeMode.light,
        'dark' => ThemeMode.dark,
        _ => ThemeMode.system,
      };

  @override
  void dispose() {
    _prefs.removeListener(_onChanged);
    super.dispose();
  }
}

注意 dispose 中必须移除监听,否则控制器被回收后监听仍持有引用,会造成内存泄漏。另外,addListener 是全局的,如果多个模块都监听,一次写入会触发所有回调,因此回调里应做"值是否真的变了"的判断,避免无意义的重建。

三、平台差异与常见坑

虽然 API 统一,但底层行为有差异,工程上需要提前处理:

  • 数据类型限制:只支持 String、int、double、bool、List<String>。存对象要自己序列化成 JSON 字符串,存 List<int> 需转成 List<String> 或 JSON。
  • 键名前缀:Android 默认会加 flutter. 前缀,iOS 不会。若与原生代码共享同一份偏好,必须显式指定 SharedPreferences.setPrefix('') 或统一约定键名。
  • Web 端:底层是 localStorage,容量约 5MB,且是同步 API 包装成异步,写入不会真正"落盘失败"。
  • 测试环境:单元测试中需调用 SharedPreferences.setMockInitialValues({}),否则 getInstance() 会因缺少平台通道而挂起。
  • 敏感数据:偏好文件是明文存储,token、密码等应改用 flutter_secure_storage。
不要在 build() 里调用 getInstance()。它是异步的,每次 build 都触发会反复读取磁盘。正确做法是在 main() 或应用启动阶段初始化一次,再通过依赖注入或全局单例向下传递。

四、初始化时机与封装建议

推荐在 runApp 之前完成初始化,这样首帧就能拿到正确的主题和语言,避免"先亮后暗"的闪烁:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  runApp(MyApp(prefs: prefs));
}

对于中大型项目,建议把偏好访问收敛到一个 PreferencesService 中,对外只暴露语义化方法(如 isFirstLaunch()、setThemeMode()),键名作为私有常量集中管理。这样既避免了键名散落各处导致的拼写错误,也方便后续替换存储实现。

SharedPreferences 读写与监听数据流 应用层 设置页 / 主题控制器 PreferencesService 内存缓存 Map getXxx 同步命中 setXxx 立即更新 平台磁盘存储 Android / iOS / Web 异步落盘 读取 写入 异步持久化 首次 getInstance() 全量载入内存 addListener / onSharedPreferenceChanged 通知 UI 重建 典型键值示例(类型 → 用途) String theme_mode / language bool first_launch / notify_on int launch_count / sort_index List<String> recent_searches 不支持:对象、List<int>、图片、敏感凭证 → 需 JSON 序列化或改用 secure_storage / 数据库

把上面这张图记牢,就能避开大多数误用:读取走内存、写入先内存后磁盘、监听负责把变化推回 UI、首次初始化只做一次。掌握这四点,SharedPreferences 就足以稳定支撑配置与偏好类需求。