1. 为什么App总有这么多小数据要存键值对存储的价值与选型做Flutter开发这几年我发现自己写了最多的代码不是复杂的业务逻辑而是各种不太起眼的数据存取。用户登录后要不要记住账号、主题颜色选了深色还是浅色、引导页是不是已经看过、购物车里的商品ID临时存一下、首页tab上次停在哪个位置……这些东西单个看都不值一提但没处理好用户的体验能直接崩掉——换肤设置一重启就没了用户会以为App坏了每次启动都重看引导页用户会暴躁到想卸载。这类数据有个共同特征量不大、结构简单、以键值对为主、需要持久化。拿数据库去管是大炮打蚊子拿文件去存又要自己处理序列化、路径、并发读写纯属自找麻烦。这时候SharedPreferences就是最顺手的那把螺丝刀。SharedPreferences是Flutter官方维护的一个轻量级键值对存储插件底层在Android上基于SharedPreferences、在iOS上基于NSUserDefaultsmacOS上也有对应实现。它把写入一个键值对这件事简化成了调用一个异步方法几秒钟就能跑通不需要配置任何东西也不需要处理数据库版本迁移。不过我得先泼一盆冷水很多新手把它当成万能存储什么数据都往里塞最后卡顿、崩溃、数据丢失全来了。这篇文章我会从原理讲到实操再从封装讲到踩坑把这个插件真正吃透。先说结论SharedPreferences适合存偏好设置类数据也就是改了要记住、丢了会不爽但不会致命的东西。需要强一致、需要复杂查询、需要多表关联的数据通通不要往这里放。2. SharedPreferences核心机制为什么它是异步的很多第一次接触SharedPreferences的人会产生同一个困惑API明明很简单为什么获取实例要async为什么写入也要async我写个int count prefs.getInt(count)不行吗这要从它的实现机制说起。SharedPreferences的原理是在App启动时把整个键值对文件一次性加载进内存后续所有读写都基于这份内存数据。Android的原生SharedPreferences启动时会同步读取XML文件首次访问可能会阻塞主线程。Flutter插件封装之后改成了异步加载避免阻塞UI但底层思路是一样的——先加载、再操作。SharedPreferences.getInstance()返回的是一个Future内部就是在做这个加载到内存的动作。加载完成之后你拿到的实例实际上是内存数据的一份代理后续的getString、getInt这些读取操作都是纯内存操作速度极快不需要再走IO。写入操作setString这些返回Future是因为它要异步落盘——先把新值更新到内存再安排写盘。这里有个很关键的点**Flutter版的SharedPreferences在读操作上是内存级的但写操作是异步落盘的。**如果你连续写入两个值第二个写入并不保证在第一个落盘后才执行它们会被依次排队写入。绝大多数情况下这没有问题但如果你在写入后马上杀进程或App被系统强制回收极端情况下最后几个键值对可能来不及写入磁盘。我在项目里还碰到过另一种场景某次用户反馈设置里的昵称改完重启就没了排查半天发现是await写漏了。代码如下SharedPreferences prefs await SharedPreferences.getInstance(); prefs.setString(nickname, _nicknameController.text);注意这里没有对setString的结果做await。页面当时看起来是正常返回了用户也没注意任何异常但恰好在这之后App被系统杀掉这次的写入并没有成功落盘。虽然不是每次都会触发但这种隐患就是海上冰山——平时看不见出事就是事故。提示任何写入操作请务必await。不要因为API看起来简单就轻视它。写入之后如果需要立刻读回来做校验或UI更新也一定要等Future完成。还有一点要留意SharedPreferences.getInstance()每次调用都会返回同一个实例的代理插件内部有缓存所以不必担心频繁调用导致重复加载文件。后面我会专门讲封装把这一层隔离好业务代码就再也不用关心这些细节了。3. 从Set到Remove再到ClearFlutter侧完整API实操这一节把核心API过一遍不是照着文档念我把每个方法在实际项目里最常用到的写法、参数细节、返回值陷阱统一列出来方便直接抄。3.1 获取实例与支持的数据类型final SharedPreferences prefs await SharedPreferences.getInstance();SharedPreferences支持的数据类型不多官方就这五种数据类型读取方法写入方法说明StringgetString(String key)setString(String key, String value)最常用intgetInt(String key)setInt(String key, int value)注意平台差异doublegetDouble(String key)setDouble(String key, double value)精度有限boolgetBool(String key)setBool(String key, bool value)开关设置ListStringgetStringList(String key)setStringList(String key, List\String\ value)注意不可变需要特别注意的是**StringList读出来之后是ListString?而且这个List不能直接修改。**因为插件底层把它固定成了不可变列表你拿到之后想list.add()会直接抛UnsupportedError。常见的做法是复制一份ListString? tags prefs.getStringList(user_tags); if (tags ! null) { tags ListString.from(tags); // 复制成可修改的List tags.add(new_tag); await prefs.setStringList(user_tags, tags); }这个坑特别隐蔽因为它不是编译期报错而是运行期才炸。我印象很深第一次踩的时候查了好一会儿才意识到是Flutter插件层固定了List的不可变性当时还以为是自己的代码写错了。3.2 读取时的默认值陷阱所有读取方法都支持传入默认值int count prefs.getInt(launch_count) ?? 0; // 或者 int count prefs.getInt(launch_count, defaultValue: 0);但这里藏着一个新手最容易踩的陷阱如果你混合使用这两种写法或者某次存了其他类型读取时会直接抛类型转换异常。比如先存了setString(age, 18)再prefs.getInt(age)运行期直接崩溃。我的建议是**键名要有统一的类型管理一个key只绑定一种类型从命名到使用都要做成规范。**后面讲工程化封装的时候我再展开。3.3 删除、清空与判断是否存在// 删除单个键 await prefs.remove(temp_token); // 清空所有键值 await prefs.clear(); // 判断是否存在 bool hasKey prefs.containsKey(launch_count);这两个操作也有各自的门道。remove只会删掉指定的键别的数据不受影响。clear是一锅端整个SharedPreferences文件会被清空当前这个App的所有SharedPreferences键值对都会没掉不分命名空间。我实际开发中一般不会调用clear而是用remove逐个清理或者干脆给每个业务模块加一个前缀例如settings_theme、settings_font_size这样后续如果需要局部清理还可以用getKeys()过滤final SetString keys prefs.getKeys(); for (String key in keys) { if (key.startsWith(settings_)) { await prefs.remove(key); } }3.4 批量写入与更新时机SharedPreferences没有专门的批量写入API但你可以连续调用多个set方法它们内部会排队。更好的一种思路是把相关的键打包成一个JSON字符串只存一个键这在第5节里会详细讲。写入后需要立刻读取的场景务必用await等待写盘完成然后再做后续操作。比如用户修改昵称后跳回上一页上一页要展示最新昵称。如果你不等待写入完成就pop理论上页面的展示值可能是旧值——虽然内存里立刻更新了但这样依赖恰好来得及的写法迟早出事。4. 工程化封装键名常量、Service层与防呆设计直接裸用SharedPreferences刚开始没什么问题代码量一多麻烦就来了。我见过不少项目的代码里到处散落着prefs.getString(username)这种写法key是野字符串类型靠记忆项目几个月后谁也说不清某个key存的是String还是int。我现在的做法是分三层键名常量层、Service封装层、业务调用层。下面把每一层怎么设计讲清楚。4.1 键名常量层一个key一个类建立键名常量类推荐用abstract class不可实例化或者直接每天建一个AppKeys类集中管理class PrefKeys { PrefKeys._(); // 用户相关 static const String userToken user_token; static const String userNickname user_nickname; static const String userAvatar user_avatar; // 设置相关 static const String themeMode settings_theme_mode; static const String fontSize settings_font_size; static const String locale settings_locale; // 业务相关 static const String launchCount biz_launch_count; static const String lastTabIndex biz_last_tab_index; }键名的命名规范建议是前缀标识模块 语义化名称。前缀的好处是便于过滤和排查语义化则让读代码的人不需要去找谁存过它一看就懂。配合上getKeys()做批量清理会非常方便。4.2 Service封装层把SharedPreferences藏起来建立一个StorageService对外提供按语义命名的方法内部才操作SharedPreferencesclass StorageService { StorageService._(); static final StorageService instance StorageService._(); SharedPreferences? _prefs; Futurevoid init() async { _prefs await SharedPreferences.getInstance(); } Futurevoid setUserToken(String token) async { await _prefs?.setString(PrefKeys.userToken, token); } String? getUserToken() { return _prefs?.getString(PrefKeys.userToken); } Futurevoid setThemeMode(String mode) async { await _prefs?.setString(PrefKeys.themeMode, mode); } String getThemeMode() { return _prefs?.getString(PrefKeys.themeMode) ?? light; } }这个封装带来的好处是第一业务层完全不知道SharedPreferences的存在以后如果要换存储方案只需要改Service内部实现业务代码一行不用动第二可以对所有读写做统一处理比如埋点、日志、异常捕获第三方法名是有业务语义的代码即文档。我通常在App启动阶段会调用一次StorageService.instance.init()在main函数里等待初始化完成再跑runApp。虽然SharedPreferences的getInstance内部有缓存多次调用代价不高但集中初始化之后后续业务代码可以同步调用get方法不用到处写async代码会清爽很多。4.3 防呆设计带类型的读取方法更进阶一点的封装可以做带类型安全检查的读取。因为SharedPreferences的类型错误是运行期异常我封装的时候会对返回值做运行时类型校验避免外部脏数据引发崩溃String safeGetString(SharedPreferences prefs, String key, {String defaultValue }) { final Object? value prefs.get(key); if (value is String) return value; return defaultValue; } int safeGetInt(SharedPreferences prefs, String key, {int defaultValue 0}) { final Object? value prefs.get(key); if (value is int) return value; return defaultValue; }注意这里的prefs.get(key)返回的是Object?内部会根据实际存储类型返回。我自己用这套防呆逻辑后再没因为历史数据类型混乱导致过崩溃。5. 复杂数据存取JSON序列化与类型安全SharedPreferences只支持五种基本类型遇到复杂对象怎么办最实用的方案是序列化成JSON字符串存成String读取时再反序列化。这是Flutter社区最普遍的做法也是我推荐的做法。5.1 手动序列化适合简单模型class UserProfile { final String nickname; final int age; final ListString tags; UserProfile({ required this.nickname, required this.age, required this.tags, }); MapString, dynamic toJson() { nickname: nickname, age: age, tags: tags, }; factory UserProfile.fromJson(MapString, dynamic json) UserProfile( nickname: json[nickname] as String, age: json[age] as int, tags: ListString.from(json[tags] as List), ); }存取的时候final UserProfile profile UserProfile(nickname: 张三, age: 18, tags: [flutter, dart]); final String jsonStr jsonEncode(profile.toJson()); await prefs.setString(user_profile, jsonStr); final String? stored prefs.getString(user_profile); if (stored ! null) { final UserProfile restored UserProfile.fromJson(jsonDecode(stored) as MapString, dynamic); }这套流程没多难但有几个细节值得注意jsonDecode出来的Map是MapString, dynamic但如果你直接把它塞给fromJson里面的字段类型可能和你预期不一致——比如数字可能解析成int也可能解析成double需要自己做类型校验。存储的JSON字符串如果有中文写入后实际体积会变大一点因为编码方式的问题不过对于SharedPreferences这种轻量存储来说影响可以忽略。如果字段包含可空类型务必在fromJson里判空否则一旦某天字段被服务端移除本地解析直接NPE崩溃。5.2 用json_serializable自动生成对于字段比较多的模型手写toJson/fromJson容易漏我建议引入json_serializable和build_runner自动生成序列化代码。这一点对SharedPreferences本身没有影响纯粹是减少样板代码import package:json_annotation/json_annotation.dart; part user_profile.g.dart; JsonSerializable() class UserProfile { final String nickname; final int age; final ListString tags; UserProfile({ required this.nickname, required this.age, required this.tags, }); factory UserProfile.fromJson(MapString, dynamic json) _$UserProfileFromJson(json); MapString, dynamic toJson() _$UserProfileToJson(this); }然后运行flutter pub run build_runner build生成.g.dart文件。用的时候和手写版本一模一样但生成的代码更严谨对类型和空值处理也更周到。5.3 列表数据怎么存最常见的场景是存一组对象比如用户最近搜索记录ListString或者购物车里的商品ID列表Listint。如果是简单类型列表直接用setStringList即可但要注意读取后不可变的问题。如果是对象列表就需要序列化成JSON数组字符串ListMapString, dynamic itemList [ {id: 1, name: 苹果}, {id: 2, name: 香蕉}, ]; // 存 await prefs.setString(cart_items, jsonEncode(itemList)); // 取 final String? stored prefs.getString(cart_items); if (stored ! null) { final Listdynamic decoded jsonDecode(stored) as Listdynamic; final ListMapString, dynamic restored decoded.castMapString, dynamic(); }jsonDecode如果JSON字符串不合法会抛FormatException所以在读取时最好包一层try-catch或者先把字符串做合法性校验。我在实际项目中的做法是写一个通用safeJsonDecode方法解析失败返回null再让调用方决定怎么兜底。提示SharedPreferences的定位是轻量存储单个value的体量建议控制在几十KB以内。不要拿它存大JSON、缓存接口数据或任何体积会无限增长的内容。存超过几百KB的字符串性能会急剧下降而且跨平台行为还不一致。6. 安全边界与实战禁忌哪些场景千万别用SharedPreferences这一节是整篇文章里我最想让你认真看的部分。很多线上事故不是代码逻辑写错而是工具选型错了。6.1 严禁存敏感数据明文存储的SharedPreferences毫无安全性可言。在Android上SharedPreferences的XML文件默认存放在App私有目录普通用户拿不到但root过的设备或开启备份的情况下数据是可以被导出的。iOS的NSUserDefaults同样不加密且会出现在iTunes备份中。所以**登录口令、Token、支付密钥、身份证信息等敏感数据一律不许直接塞进SharedPreferences。**Token需要加密存储或者迁移到更安全的存储方案如flutter_secure_storage后者在Android上使用Keystore加密在iOS上使用Keychain安全等级完全不同。有小伙伴可能觉得我App的用户量不大不至于被盯上。真实世界的威胁很多时候不是黑客盯上你而是用户自己换了台设备、恢复了个备份、装了个辅助工具数据就裸奔了。不要心存侥幸。6.2 不适合存高频变化的数值比如用户的位置坐标每秒更新一次你往SharedPreferences里写磁盘IO频率过高App会被拖慢甚至卡顿。这类数据应该留在内存里或者存到数据库/文件中配合合理的写入策略。简单判断原则写入频率超过每秒一次、且对实时性有一定要求的数据不要用SharedPreferences。6.3 不适合做大对象的CRUD如果你有几百条记录需要按条件筛选、分页加载SharedPreferences根本不支持查询语法。你只能每次全量读出来在内存里自己过滤数据量一大读出来耗时和内存占用都很可观。这种场景该上数据库sqflite、drift、Hive任选一个都比硬扛强。6.4 跨进程、多入口场景下的坑如果你的App开启了多进程或者部分功能跑在后台的isolate里SharedPreferences的并发写存在竞争风险。Flutter插件本身有锁机制但多isolate场景下我遇到过数据丢失。如果你的业务必须多isolate同时写建议用一个统一的Service做写入队列串行化避免并发竞争。6.5 清除数据与版本升级的兼容问题有次升级版本后台要求强制清除本地缓存我用clear()全清了。结果老用户升级后因为某个键原来存的是int现在变成了null读取时走了默认值逻辑功能倒是没崩但部分用户的个性化设置全部失效客服被问爆了。正确做法是给存储数据加一个version键升级时做迁移判断const String storageVersion storage_version; Futurevoid migrateIfNeeded(SharedPreferences prefs) async { final int currentVersion prefs.getInt(storageVersion) ?? 1; if (currentVersion 2) { // 做v1到v2的迁移比如把旧的key迁移到新的key、清理废弃key await prefs.remove(legacy_key); await prefs.setInt(storageVersion, 2); } }这套迁移思路和数据库的schema migration是一个道理只不过简单得多。建议每个存储键值对都有明确的主人谁负责写入就谁负责迁移别让历史债堆积到没法下手。7. 一步一步排查我的一次线上崩溃定位过程空谈禁忌没意思分享一个我真实遇到的线上崩溃排查过程完整链路走一遍相信你能把这章当排查模板用。背景App上线了夜间模式用户切换主题后状态存到SharedPreferences。某天线上监控发现一撮用户的崩溃日志指向getInt(theme_mode)异常类型是TypeError。第一步我先看崩溃堆栈定位到是读取代码int mode prefs.getInt(PrefKeys.themeMode) ?? 0;这行代码本身看不出问题getInt按文档用应该没问题?? 0也兜底了默认值。但崩溃信息显示cast失败说明这里实际存的内容不是int。第二步我猜测某个历史版本可能用setString存过这个key于是查git提交记录。果然三个月前有个同事在另一处业务里用了prefs.setString(theme_mode, dark)来存这个键后来主逻辑改成setInt存0/1但旧数据残留在本地没有清理升级后读取旧数据直接类型冲突。第三步修复方案不是直接改读取代码因为你不确定用户本地到底残留了多少种类型的脏数据。我采用了safeGet的方式int getThemeMode(SharedPreferences prefs) { final Object? value prefs.get(PrefKeys.themeMode); if (value is int) return value; if (value is String) { return value dark ? 1 : 0; } return 0; }第四步同时清掉历史脏数据if (prefs.get(PrefKeys.themeMode) is! int) { await prefs.remove(PrefKeys.themeMode); }这样老用户升级后最多丢一次主题设置但不会崩溃新用户完全不受影响。这个案例里学到的经验有两条同一个key不要在不同业务线里重复使用键名要有归属和规范不然类型迟早被搞混。读取数据永远要考虑这个key可能被老版本存成了别的类型做好容错再谈逻辑。8. 性能实测与优化思路读写耗时、体积控制与缓存策略最后聊一下性能毕竟做开发不能只管功能不管效率。我在一台中端Android设备上对SharedPreferences做了个简单压测数据量不大但能看出规律。8.1 写入耗时与数据量增长单次写入一个几KB的String耗时在几毫秒到十几毫秒之间这个量级对绝大多数场景没影响。但当数据累积到几百KB写入耗时开始明显上升极端情况能摸到几百毫秒。原因很简单每次写盘都要对整个文件做序列化和IO体量越大代价越高。所以应对策略就是给每个键控体量。可以定期检查所有键值对的总字节数设个阈值比如100KB超了就触发清理或迁移。对大多数App来说SharedPreferences的总量控制在50KB以内是最稳妥的。8.2 读取是内存级的但首次加载有开销getInstance()首次加载时会读整个文件文件越大加载越慢。市面上有极端案例是有人把几MB的数据塞进SharedPreferencesApp冷启动直接卡死。所以文件初始加载体的控制要从源头上管住而不是等出问题再优化。8.3 把频繁访问的值做内存缓存如果你的业务有高频读取某个SharedPreferences值的场景比如每个Page都要读一次主题设置可以像我这样在Service层做一层内存缓存减少重复查询class StorageService { String? _cachedThemeMode; Futurevoid setThemeMode(String mode) async { _cachedThemeMode mode; await _prefs?.setString(PrefKeys.themeMode, mode); } String getThemeModeCached() { if (_cachedThemeMode ! null) return _cachedThemeMode!; final String? stored _prefs?.getString(PrefKeys.themeMode); _cachedThemeMode stored ?? light; return _cachedThemeMode!; } }注意缓存的失效时机写入的时候同步更新缓存其他模块如果绕过Service直接改 SharedPreferences缓存可能过期。所以缓存一定要建立在所有读写都走Service的前提上。8.4 多键合并写入对于需要同时更新多个键的场景与其逐个写入多次落盘不如合并成一个JSON字符串存一个键。比如用户资料里的昵称、头像、签名三个字段可以打包成一个user_profile键。这样既减少落盘次数读的时候也只需要解析一次。但合并的缺点是哪怕只想改其中一个字段也要全量反序列化再重新序列化。所以要注意粒度控制不要把经常单独修改的字段和几乎不动的字段捆在一起。这也是工程取舍经验大家可以根据自己业务权衡。写在最后的一点经验Flutter里的SharedPreferences确实简单上手五分钟就能写但这不代表可以随意用。在我看来它最理想的定位是App的偏好白板——放主题、语言、引导页状态、登录标记这类小而低频的数据刚刚好。一旦你发现自己在往里面塞大对象、频繁更新的值或者敏感信息就应该停下来重新做选型。我踩过最深的一个坑是早期做项目时为了一时方便把整个用户缓存数据都序列化塞进SharedPreferences结果App每次启动加载那几百KB的JSON字符串都要卡一下用户评分掉得厉害。后来花了一个晚上把数据迁移到数据库启动速度立竿见影。从那之后我就定了一条规矩所有存储选型先问三个问题——数据多大、多频繁读写、安全性要求多高三个问题想清楚方案自然就出来了。如果你刚开始用SharedPreferences建议先把键名常量、Service封装和safeGet这套习惯建立起来成本很低但后续省心程度是几何级数提升的。如果你已经在线上项目里大量裸用也不用慌按文中的迁移思路逐步收编即可一次改一个模块风险可控。希望这篇文章能让你少走我走过的弯路。关于Flutter的数据持久化后面有机会我还会写写文件存储、数据库方案和它们的选型对比如果你有其他问题也欢迎在评论区交流。