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()),键名作为私有常量集中管理。这样既避免了键名散落各处导致的拼写错误,也方便后续替换存储实现。
把上面这张图记牢,就能避开大多数误用:读取走内存、写入先内存后磁盘、监听负责把变化推回 UI、首次初始化只做一次。掌握这四点,SharedPreferences 就足以稳定支撑配置与偏好类需求。