做Flutter开发,你的第一个本地存储十有八九是 shared_preferences。登录态存token、引导页标记、主题模式、用户偏好,这些零碎但必须有持久化的键值对,用官方维护的这个插件几乎是业内默认答案。但很多人用得很随意:这个页面存一个key,那个页面换一个拼写,退出登录时还不敢调clear,怕把别的东西也清了。这篇文章不准备复述官方README,而是从实际项目视角聊聊:它到底怎么工作、怎么封装不容易出问题、容易踩的坑有哪些,以及什么情况下你就该换掉它。
1. 为什么Flutter项目里绕不开shared_preferences
1.1 先从“记住登录状态”这件事说起
一个典型App首次启动要做的流程:请求权限、初始化配置、判断是否已登录,如果已有登录态直接进首页,否则进登录页。这个“是否已登录”和登录后拿到的token,不能每次打开App都让用户重新输入。你可能想到写文件,但每次写文件要处理路径、格式、异常,只为了存两个字段,太啰嗦;你也会想到上数据库,但为了两个key启动一个SQLite服务,更是小题大做。shared_preferences就是为这种“零散键值对”设计的,它的设计哲学不是数据库,而是移动平台运行时的配置中心。
原生开发同学看到这里应该会心一笑:它对应的就是Android的SharedPreferences、iOS的NSUserDefaults。只不过Flutter官方把这个能力用一套Dart API统一封装了起来,业务侧不用关心底层是XML还是plist,不用关心文件路径,更不用自己写序列化。
1.2 它本质上是移动平台“偏好设置”的跨平台壳
shared_preferences是Flutter官方维护的插件,在pub.dev上长期位居最受欢迎榜单前列。它的底层不是一个统一的自研数据库,而是逐平台接入系统自带的本地键值存储:
| Flutter侧API | Android底层 | iOS/macOS底层 | Web底层 | Windows/Linux底层 |
|---|---|---|---|---|
| SharedPreferences | SharedPreferences XML文件 | NSUserDefaults plist文件 | localStorage | shared_preferences.json |
这个设计最大的好处是业务侧只需要学会一套API,就能在所有平台持久化数据;代价是你必须理解每个平台的存储容量和清理时机不一样。比如Web的localStorage有5MB左右的配额限制,用户清浏览器缓存会把登录态一起清掉;Android的SharedPreferences在卸载应用时删除,iOS的NSUserDefaults在系统备份恢复时可能带回旧值。这些都不是插件能替你解决的,得在业务设计阶段就心里有数。
1.3 什么场景适合、什么场景别碰
适合放进shared_preferences的数据,我通常归纳为三类:
- 用户偏好:主题色、深色模式、字体大小、语言选择
- 轻量状态:登录token、用户ID、记住账号、引导页是否看过
- 功能标记:远程配置的缓存、A/B分组ID、评分弹窗时间戳
不适合的场景也很明确:几十KB以上的结构化数据(聊天记录、离线缓存、草稿箱)、需要索引和条件查询的数据、敏感信息(明文密码、支付凭证)、多端强一致要求的数据。一句话总结:单条数据字段级别、小体量、低频次读写,才是它的主场。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础用法:API细节与底层落盘机制
2.1 添加依赖与首次读写
想用shared_preferences,先在pubspec.yaml里加依赖:
yaml复制dependencies:
shared_preferences: ^2.2.3
如果你是新项目,也可以直接用2.3.0及以上版本,官方引入了新的SharedPreferencesAsync API,后面会单独说。先看经典用法:
dart复制final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', 'abc123');
await prefs.setBool('isLogin', true);
await prefs.setInt('launchCount', 3);
await prefs.setDouble('brightness', 0.8);
final token = prefs.getString('token');
final bool isLogin = prefs.getBool('isLogin') ?? false;
final int launchCount = prefs.getInt('launchCount') ?? 0;
这里有几个细节值得注意:
- getInstance()首次调用会去原生平台读取一次文件,虽然文件小通常感觉不到,但理论上在UI线程有毫秒级IO开销。
- 实例拿到之后,所有get方法都是同步的,用起来像操作内存Map,这是它和别的异步存储方案最大的区别。
- key不存在时getString返回null,getBool返回null,类型化方法不会抛异常,但你需要用??给默认值。
- set方法不接受null为value,要删除某个key请用remove()。
2.2 支持的数据类型与边界
官方支持的原生类型只有5种:
- int
- double
- bool
- String
- List
这个限制非常关键。你不能直接存Map,不能存List
dart复制final json = jsonEncode(userProfile.toJson());
await prefs.setString('user_profile', json);
读取时再解码:
dart复制final json = prefs.getString('user_profile');
if (json != null) {
final userProfile = UserProfile.fromJson(jsonDecode(json));
}
这样做的缺点是String字段无法在原生层被当作结构化数据读取,但绝大多数业务不关心这个。
2.3 写入后到底什么时候落盘
这是很多人忽略的一点。你执行await prefs.setString(...)返回了true,就以为数据已经写到磁盘了。实际上在Android上,这个插件用的是SharedPreferences.Editor.apply(),它只保证内存立即更新,异步落盘。iOS的NSUserDefaults也一样,系统会在合适时机把内存里的dictionary写入plist文件。
这意味着什么?如果你写完立刻强杀进程,极端情况下最后几次写入可能没持久化。正式环境极少遇到,因为系统几秒内就会完成落盘;但如果你有“写入后立刻同步给另一个进程”的需求,就不能只依赖await完成,需要额外的跨进程同步机制。理解这一点,对后面排查“数据闪现”“数据丢失”很有帮助。
2.4 Android、iOS、Web的存储位置与差异
线上问题排查时,知道数据存在哪会快很多:
- Android:/data/data/
/shared_prefs/FlutterSharedPreferences.xml。Flutter插件写进去的key默认会加前缀“flutter.”,所以如果你在Android原生代码里用SharedPreferences读取,要用getString("flutter.token")这种方式。新版本提供了配置项可以自定义文件名和前缀。 - iOS/macOS:NSUserDefaults标准区,实际存到Library/Preferences/
.plist,键不带“flutter.”前缀。 - Web:window.localStorage,注意配额限制。
- Windows:%APPDATA%目录下的shared_preferences.json。
- Linux:~/.local/share目录下的shared_preferences.json。
曾经有用户反馈设置“随机丢失”,我通过adb连接真机去看XML文件还在不在,发现是Android系统在存储空间不足时清理了shared_prefs目录下部分文件。这属于平台行为,不是插件bug,但如果你不知道文件位置,很容易在错误的方向上排查半天。
3. 项目级封装:告别散落的魔法key与重复代码
3.1 魔法字符串的治理
我见过最糟糕的写法:页面A存"user_name",页面B存"username",页面C存"userName",三个key其实想干同一件事,结果读的时候谁也读不到谁。这类问题不在shared_preferences本身,而在缺乏统一管理。
简单有效的做法有几点:
- 所有key收敛到一个类,用静态常量定义
- key命名带上模块前缀,比如auth.token、settings.theme、onboarding.viewed
- key一旦上线,不要轻易改,真要改要考虑老用户旧key的迁移
- 每个key旁边加注释,标明类型、用途、谁在用
3.2 用户偏好Repository实战:AuthPreferences示例
业务代码里直接布满SharedPreferences.getInstance()和setString,不仅重复,而且难以维护。更好的做法是做一个领域层的Repository,让业务完全不感知存储细节。下面这个类可以直接抄走:
dart复制class AuthPreferences {
AuthPreferences._();
static const _kTokenKey = 'auth.token';
static const _kUserKey = 'auth.user';
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_kTokenKey);
}
static Future<bool> setToken(String token) async {
final prefs = await SharedPreferences.getInstance();
return prefs.setString(_kTokenKey, token);
}
static Future<void> clearToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kTokenKey);
}
static Future<UserProfile?> getUserProfile() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_kUserKey);
if (json == null) return null;
try {
return UserProfile.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
static Future<void> setUserProfile(UserProfile profile) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kUserKey, jsonEncode(profile.toJson()));
}
static Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kTokenKey);
await prefs.remove(_kUserKey);
}
}
调用方就变成了:
dart复制final token = await AuthPreferences.getToken();
if (token == null) {
// 跳登录页
} else {
// 跳首页
}
这套封装的好处:
- 调用方不知道也根本不用管key长什么样
- 序列化和反序列化逻辑集中在一个文件
- 将来某些字段要迁移到数据库,只改AuthPreferences,不用全项目找
- logout时精确删除auth模块的key,不会误伤settings模块
3.3 偏好变更后的跨页面通知
shared_preferences本身没有“变更通知”机制,你没法监听某个key变了然后自动刷新UI。实际项目中常见做法是配合ValueNotifier或ChangeNotifier,把“本地缓存”和“内存状态”结合起来。
拿主题模式举例:
dart复制class SettingsController extends ChangeNotifier {
SettingsController(this._prefs);
final SharedPreferences _prefs;
bool _darkMode = false;
bool get darkMode => _darkMode;
Future<void> load() async {
_darkMode = _prefs.getBool('settings.darkMode') ?? false;
notifyListeners();
}
Future<void> setDarkMode(bool value) async {
_darkMode = value;
await _prefs.setBool('settings.darkMode', value);
notifyListeners();
}
}
为什么搞这么一层?因为频繁调用SharedPreferences的get方法虽然也是内存读取,但涉及平台通道,能少调用就少调用。启动时读一次,后续以内存变量为准,只要在修改时同步写盘,业务层感知到的永远是最新值。这也是Flutter项目里处理偏好的标准姿势。
3.4 新API:SharedPreferencesAsync与SharedPreferencesWithCache
从shared_preferences 2.3.0开始,官方引入了两套新API:
dart复制final prefs = SharedPreferencesAsync();
await prefs.setString('auth.token', 'abc');
final token = await prefs.getString('auth.token');
新API的主要变化:
- 不再维护一个全局缓存单例,每次创建实例都能感知底层文件变化
- 支持配置缓存key前缀,默认行为还是兼容老数据的“flutter.”前缀
- set方法支持nullable值,传入null等于删除,语义更清晰
如果你正在处理“原生改数据Flutter读不到”的问题,可以试试SharedPreferencesAsync,它不走老的静态缓存,能够读到最新值。老项目要是跑得稳定,不必为了升级而升级,但新项目用新API会更省心。
4. 我踩过的坑:缓存不一致、并发写入与测试环境
4.1 最典型的缓存不一致:原生改值,Flutter读不到
老版本的SharedPreferences.getInstance()在同一个isolate里始终返回同一个缓存实例。只要App进程没重启,Flutter侧读到的都是第一次getInstance时从原生加载的那份内存副本。如果你在Android原生代码里往同一个SharedPreferences文件写入了新key,回到Flutter再调用getInstance(),拿到的还是旧内存。
我遇到过一次具体场景:App从推送通知栏点击跳转,原生代码在通知栏按钮点击事件里改了登录状态并写入了SharedPreferences,然后通过deep link唤起Flutter页面。Flutter侧读登录状态时仍然是旧值,用户被反复弹回登录页。后来排查发现就是缓存副本问题。
解决方案有三类:
- 尽量把写入操作集中到一端,别一部分从原生写,一部分从Flutter写
- 必须两端共享时,写入后让Flutter侧用SharedPreferencesAsync新建实例读取
- 实在不行,重启App是最朴素也最可靠的选择
4.2 并发写入同一key,后写覆盖前写
shared_preferences没有事务和锁的概念。两个地方几乎同时执行:
dart复制await prefs.setString('count', '1');
await prefs.setString('count', '2');
结果大概率是2,这符合键值存储的预期。但如果你的业务是“计数器累加”或者“先读旧值再写新值”,就会丢更新。
我遇到过一个真实case:首页接口返回后把最新的数据版本号写进一个map,另一个页面同时在写同一个map的另一个字段,两边都是先get整个map、修改、再set整个map,结果后写完的把先写字段覆盖了。这个问题的根子在于“整读整写”没有原子性,shared_preferences给不了。解决办法有两条路:避免用一个大key存多个字段,每个字段独立key;或者对同一个key的写操作做队列串行化。
4.3 clear()一锅端,所有配置被清空
clear()会清空当前SharedPreferences文件里所有键。如果App把token、用户偏好、引导标记全放在同一个默认文件里,一次clear()全没了。很多新人写“退出登录”时习惯性加一句“顺便把配置清了”,结果把暗黑模式、语言设置也清了。
我的建议很明确:
- 少用clear(),多用remove(key)
- 需要批量清理时,也在封装层提供logout()这种精确删除方法
- 实在需要用clear(),给key加统一前缀,并在执行前评估影响范围
4.4 单元测试里的MissingPluginException
写测试的时候经常会遇到这个报错:
code复制MissingPluginException(No implementation found for method getAll on channel plugins.flutter.io/shared_preferences)
原因很简单:单元测试环境跑在Dart虚拟机上,没有原生平台实现。官方提供了mock方案,在setUp里加一行:
dart复制SharedPreferences.setMockInitialValues({});
这样插件会在内存里用一个map模拟底层存储。同一组测试用例之间如果互相影响,就在setUp里重新调用一次,保证每个用例从空数据开始。
4.5 排查问题的方法论
本地存储出问题,先别急着改代码。我通常按这个顺序排查:
- 确认写入有没有返回true,写完之后立刻读一遍
- 确认读的是哪个key,有没有大小写、前后缀拼错
- 确认App进程是不是已经被系统杀死重建,有时不是数据丢了,是写入流程依赖了跨异步的状态没恢复
- 上真机验证,模拟器对本地存储的行为不完全真实
- 用日志把整个SharedPreferences的内容dump出来
你可以放一个方便排查的方法:
dart复制static void dumpPreferences() async {
final prefs = await SharedPreferences.getInstance();
for (final key in prefs.getKeys()) {
debugPrint('$key = ${prefs.get(key)}');
}
}
这一招在高版本Flutter里因为Logger冲突会有些噪音,但配合filter只看自己的tag,定位效率能高很多。
5. 存储选型边界:shared_preferences不是百宝箱
5.1 容量与性能边界
虽然官方没有明确的硬性容量限制,但底层是XML/plist文件,每次set一个key,系统本质上是在把整个文件重写一遍。文件越大,写入越慢。几十条配置完全感觉不到,如果你往里面塞了一两MB的字符串,就能在每次读取和写入时感受到明显卡顿。Web端更直接,localStorage通常有5MB上限。
所以,凡是可能超过几十KB的数据,或者需要高频写入的字段,别放进shared_preferences。
5.2 敏感数据该用什么
明文密码、支付token、私钥这类数据,不能放shared_preferences。Android的XML文件虽然是应用私有目录,但设备root后就能看到;iOS的NSUserDefaults在沙箱里相对安全,但也是明文存储。真要安全存储,移动端的选择是系统级安全存储区:
- Flutter侧用flutter_secure_storage插件,它在Android上封装Keystore,在iOS上封装Keychain
- 关键token如果坚持用shared_preferences,至少要做签名或加密后再存,但加密密钥的托管又是个新问题,所以直接用安全存储插件更省心
5.3 一张决策表搞定选型
| 数据特征 | 推荐方案 | 理由 |
|---|---|---|
| 应用设置、布尔标记、小段token | shared_preferences | 简单、跨平台、API成熟 |
| 用户资料、草稿、结构化对象 | 序列化JSON存入文件,或用Hive/Isar | 支持对象模型和更灵活的读写 |
| 聊天记录、消息列表、大量列表 | sqflite/Drift(SQLite) | 支持查询、事务、索引 |
| 需要全文搜索的数据 | 数据库 | shared_preferences完全无法满足 |
| 密码、私钥、支付凭证 | flutter_secure_storage | 系统级安全边界 |
| 服务端缓存数据 | 内存缓存+文件/数据库 | shared_preferences不适合承载过期策略 |
5.4 数据迁移与原生衔接的注意点
做混合开发或原生App迁移到Flutter时,最大注意点是键前缀。Flutter老版本写入Android的key默认带“flutter.”前缀,如果要在原生代码里读Flutter写的数据,记得带前缀;如果原生代码之前已经有一份不同文件的SharedPreferences,Flutter侧默认读不到,需要在初始化时配置对应的SharedPreferencesName。
另一个经验:如果打算长期使用shared_preferences,把版本固定好,升级插件后跑一遍存储读写回归。这个插件更新频率不算高,但每次大版本可能调整默认文件名或前缀,2.3.0引入新API就是一个例子。升级后重点确认老数据能否被新代码读到,别让老用户数据悄悄“消失”。
我在自己项目里的体会是:把shared_preferences当成一个极轻量的配置中心来用,而不是数据库。所有读写都集中在Repository层,key统一带模块前缀,退出登录只清auth模块,绝不碰settings模块。做到这几点,一年多下来没有遇到一次线上数据丢失或覆盖事故。如果你正打算在项目里引入本地存储,先花半天时间把存储层设计好,比将来在几十个页面里改key要省太多事。
