
开头第一次接触到 Flutter 里的 SharedPreferences 键值对数据管理是在做一个需要记住用户登录状态的小应用。当时刚迁到 Flutter 不久第一直觉是直接上数据库后来被前辈提醒了一句“你这就存一个 token 和用户名开个 SQLite 是不是有点杀鸡用牛刀了” 这句话让我重新审视了轻量存储这件事。SharedPreferences 就是 Flutter 生态里最适合干这类活儿的组件它不是一个数据库而是一个基于键值对的数据管理方案专门搞定那些小体量、零散、需要持久化的状态数据。这篇内容想跟你聊聊它的定位、API 细节、封装套路以及我当时踩过的一些坑适合刚入门的 Flutter 开发者也适合想把自己项目里存储逻辑整理得更规范的进阶选手。1. 轻量存储方案选型为什么优先考虑 SharedPreferences1.1 键值对存储的场景定位先搞清楚一个基本问题键值对存储到底适合存什么我用一个生活类比来解释便利贴和笔记本的区别。笔记本适合记录长文、结构化内容、复杂的查询关系好比 SQLite 或者 Drift 这类数据库需要建表、写 SQL、做索引适合存放聊天记录、离线缓存的文章列表、复杂的业务对象。便利贴适合记录“冰箱里没牛奶了”“下午三点开会”“WiFi 密码是啥”这种单点信息不需要关系、不需要查询、不需要排序随写随贴撕下来就完事。SharedPreferences 就是便利贴。它在 Flutter 里用来存这类数据用户登录 token、用户昵称、语言偏好、主题模式浅色深色、首次启动标记、上次启动时间、简单的开关配置。这些数据的共同特征是体积小、字段少、读取频率高、不需要复杂查询。反过来如果你发现自己在用 SharedPreferences 存一个包含上百条记录的列表或者需要按条件筛选数据那就说明选型出了偏差。不是它不能存而是这种用法会让代码越来越别扭性能上也会开始出现肉眼可见的卡顿。我在项目里见过有人把完整对象 JSON 序列化后塞进 SharedPreferences 的偶尔存一个配置对象没问题但如果是频繁读写的列表数据后续维护会非常痛苦。1.2 常见存储方案横向对比Flutter 本地持久化的主流方案其实就那几类我整理了一个对比表格能帮你快速定位什么场景该选什么方案。存储方案适合场景数据格式复杂度典型代表SharedPreferences键值对、小体量配置、用户偏好String、bool、int、double、List极低token、主题、语言、开关SQLite / Drift结构化数据、复杂查询、大数据量表格行数据高聊天记录、离线缓存、商品列表文件存储大块数据、图片、日志、导出文件文件流中图片缓存、导出 JSON 文件内存态不持久化临时状态、进程内共享任意对象极低全局状态、临时缓存从表格里能看出一个核心结论SharedPreferences 的不可替代性在于“极低复杂度”。它不需要建表、不需要迁移、不需要写 SQL一行读一行写数据自动持久化到平台本地。作为 Flutter 开发者大多数应用 80% 的持久化需求其实就是“记住几个状态”这套方案完全兜得住。1.3 为什么不建议轻量数据直接上数据库数据库听起来更“专业”但对轻量键值对数据来说上数据库会引入三重成本。第一心智成本。数据库需要设计表结构、考虑字段类型、写增删改查本来一行代码能搞定的事硬要拆成一套数据层操作。第二性能成本。数据库启动时需要初始化连接、执行建表语句读取数据要经过 SQL 解析和结果集转换。SharedPreferences 则是把整个文件加载到内存读取 Key 相当于查一个内存 Map速度是纳秒级的。实测在界面上读取一个配置项SharedPreferences 的耗时几乎可以忽略不计而数据库首次查询往往有几十毫秒甚至更高的开销。第三维护成本。数据库引入后你还要处理版本迁移、索引优化、并发问题。这跟便利贴场景根本不匹配。我个人的选型标准很简单数据量小于几十条、字段固定、不需要查询和关联的数据一律用 SharedPreferences。只有在数据量增长明确可见、查询复杂度明显上升的情况下才考虑迁移到数据库而且迁移逻辑我会提前做好不是等代码写死了再重构。2. SharedPreferences 的核心原理与 API 细节2.1 底层机制它到底是怎么工作的SharedPreferences 不是 Flutter 自己实现的东西它是在 Android 和 iOS 原生能力之上封装出的插件。Android 端底层是 DataStore 的前身 SharedPreferencesiOS 端则是 NSUserDefaults。Flutter 的 shared_preferences 插件通过平台通道把 Dart API 映射到原生实现最终数据落在平台各自的本地存储文件中。关键机制有两点值得理解。第一是启动时全量加载应用启动后第一次访问 SharedPreferences 时插件会把整个存储文件读入内存构建一个 Map 结构。后续所有读操作都不再走磁盘直接读内存所以读起来飞快。第二是写操作的异步持久化调用 set 方法时数据先写入内存同时异步提交到磁盘。这意味着写方法返回的 Future 不等于磁盘写入已经完成只是表示“已经接受写入指令并同步到内存”。理解这点对排查“数据还没写进去就杀进程弄丢了”的问题很有帮助。2.2 常用 API 盘点与易错点我按使用频率给你盘一下这套 API基本覆盖日常开发 95% 的需求。// 获取实例注意是异步的 final prefs await SharedPreferences.getInstance(); // 读取数据 String? token prefs.getString(token); int loginCount prefs.getInt(loginCount) ?? 0; bool isDarkMode prefs.getBool(isDarkMode) ?? false; double volume prefs.getDouble(volume) ?? 0.5; ListString tags prefs.getStringList(tags) ?? []; // 写入数据 await prefs.setString(token, abc123); await prefs.setInt(loginCount, 10); await prefs.setBool(isDarkMode, true); await prefs.setDouble(volume, 0.8); await prefs.setStringList(tags, [A, B, C]); // 删除数据 await prefs.remove(token); // 清空全部 await prefs.clear(); // 检查是否存在 bool hasKey prefs.containsKey(token);这里有几个我一开始就踩过的易错点。读取方法在 Key 不存在时不会抛异常而是返回 null或类型对应的默认空值所以读取端要习惯给默认值否则拿到的可能是 null 或 0逻辑判断时容易踩空。另外setString等写方法的返回 Future 建议都await。如果不等待就立刻进行下一个操作极端情况下会出现写后读不一致。比如你写了一个配置马上读取另一个逻辑判断依赖这个配置内存层面同步没问题但如果你紧接着调clear()或者remove()因为操作顺序没控制好可能出现覆盖或误删。2.3 getInstance 的单例模式与缓存问题还有个容易忽略的细节是SharedPreferences.getInstance()本身是有缓存的。同一进程内多次调用返回的是同一个实例不会重复读取磁盘。这点对性能是好事但会带来一个困扰如果你在测试中或者热重载场景下修改了存储文件实例不会自动感知最新数据。我在写工具类封装时就遇到过一次非常诡异的 bug。测试里先写入一个 Key再调用getInstance()获取新实例发现读到的还是旧数据。排查半天才发现 getInstance 缓存了实例第二行拿到的还是同一个对象。解决办法是测试代码里用SharedPreferences.setMockInitialValues重置或者干脆重启进程。这套 API 虽然简单但理解缓存机制能帮你少走很多弯路。3. 完整实操从零搭建一个键值对数据管理模块3.1 环境准备与依赖引入先加依赖。在项目根目录的pubspec.yaml文件的 dependencies 区域加这一行dependencies: flutter: sdk: flutter shared_preferences: ^2.2.0也可以直接在终端跑命令让工具自动帮你加和安装flutter pub add shared_preferences装完以后别忘了确认一下 Flutter SDK 版本。shared_preferences 新版插件对 Flutter 的最低版本有要求旧项目如果长期没升级 SDK有概率遇到 “The current configured Flutter SDK is not known to be fully supported” 这类提示这时要么升级 Flutter 到稳定版要么锁定一个旧版本的 shared_preferences。3.2 封装一个 AppStorage 工具类直接裸用 SharedPreferences 的 API 在简单场景没问题但项目里十几个页面各自调 getInstance、各自定义 Key 字符串很快就会失控。我的习惯是封装一个工具类统一管理 Key 和读写方法。下面这套封装我从几个项目里整理出比较顺手的形态你可以直接抄也可以按项目情况调整。import package:shared_preferences/shared_preferences.dart; /// 全局唯一存储服务业务代码不要直接操作 SharedPreferences class AppStorage { AppStorage._(); static final AppStorage _instance AppStorage._(); static AppStorage get instance _instance; /// 预加载在 main 里调用一次避免后续异步等待 static late SharedPreferences _prefs; static Futurevoid init() async { _prefs await SharedPreferences.getInstance(); } // ---------- 登录态相关 ---------- static const _kToken login_token; static const _kUserId login_user_id; static const _kLastLoginTime login_last_time; String? get token _prefs.getString(_kToken); Futurebool setToken(String? token) async { if (token null) { return _prefs.remove(_kToken); } return _prefs.setString(_kToken, token); } int? get userId _prefs.getInt(_kUserId); Futurevoid setUserId(int id) _prefs.setInt(_kUserId, id); int lastLoginTime _prefs.getInt(_kLastLoginTime) ?? 0; Futurevoid setLastLoginTime(int time) _prefs.setInt(_kLastLoginTime, time); // ---------- 主题偏好 ---------- static const _kThemeMode theme_mode; String get themeMode _prefs.getString(_kThemeMode) ?? system; Futurebool setThemeMode(String mode) _prefs.setString(_kThemeMode, mode); // ---------- 通用清除 ---------- Futurevoid clearLoginInfo() async { await _prefs.remove(_kToken); await _prefs.remove(_kUserId); await _prefs.remove(_kLastLoginTime); } }注意几个设计点。第一这个类全程用静态方法和静态常量管理 Key业务代码根本看不到 Key 字符串不会因为到处写isDarkMode导致维护时改一处漏一处。第二初始化用late关键字 init()预加载在main()里调一次后续所有读取都是同步内存操作不需要每个页面再写await SharedPreferences.getInstance()的样板代码。第三token 的 setter 里做了一个特殊处理传入 null 时自动移除 Key这个设计在实现退出登录时非常顺手。3.3 在 main 里完成初始化封装完成后在入口文件里初始化保证全局所有页面都能直接使用。Futurevoid main() async { WidgetsFlutterBinding.ensureInitialized(); await AppStorage.init(); runApp(const MyApp()); }WidgetsFlutterBinding.ensureInitialized()这行是为了保证在 runApp 之前就允许执行异步平台通道调用去掉这行赶在某些平台上会出现 “Binding has not yet been initialized” 的异常。3.4 实战场景记住登录状态与主题设置有了 AppStorage实战写起来就非常丝滑了。登录页面在用户登录成功后写入 tokenFuturevoid _handleLogin() async { // 假设这里调了接口拿到 userId 和 token final userId 9527; final token mock_token_abc; AppStorage.instance.setUserId(userId); await AppStorage.instance.setToken(token); await AppStorage.instance.setLastLoginTime( DateTime.now().millisecondsSinceEpoch, ); // 跳转首页 }启动时判断是否已登录class SplashPage extends StatefulWidget { override StateSplashPage createState() _SplashPageState(); } class _SplashPageState extends StateSplashPage { override void initState() { super.initState(); _checkLogin(); } Futurevoid _checkLogin() async { final storage AppStorage.instance; if (storage.token ! null storage.token!.isNotEmpty) { // 已登录直接进主页 } else { // 未登录去登录页 } } }主题设置也走同一套逻辑。用户切到暗色模式后调AppStorage.instance.setThemeMode(dark)应用启动时在根组件读取themeMode转成ThemeMode.dark或ThemeMode.light就能实现“重启后记住主题”。整套流程不到 30 行代码没有数据库、没有状态管理框架依赖纯粹用键值对就把体验做完整了。4. 踩坑实录常见问题与排查技巧4.1 异步初始化顺序导致的数据覆盖这个坑我印象太深了。项目里有个引导页用户第一次进入时读一个is_first_launch标记如果是首次启动就展示引导页并且把它设成 false。当时我图省事没有统一走 AppStorage.init而是在引导页里自己final prefs await SharedPreferences.getInstance()结果遇到一个很隐蔽的问题启动流程里有个地方也读了这个标记因为两个 getInstance 返回的不是同一个实例插件版本较旧时出现了一边写 true 另一边读到 false 的情况导致引导页重复出现好几次。后来统一改成启动时预加载 全局静态实例访问再没出过这个问题。建议你在项目里严格约定SharedPreferences 实例只允许在初始化阶段获取业务层一律通过封装类访问。4.2 Key 命名混乱的教训最早的项目里Key 命名完全是野路子。token、TOKEN、user_token、isLogin在代码里混着用有的页面用下划线命名有的页面用驼峰命名还有的直接用中文当 KeyFlutter 其实支持但看起来非常不专业。后来排查一个 bug 时发现登录态存了三个不同 Key读写各用各的数据当然对不上。我花了一个下午把所有 Key 统一收敛到常量类里顺手做了命名规范小驼峰、按业务域前缀区分。这个工作不产生新功能但后续再也没出现过因为 Key 不一致导致的数据错乱。4.3 类型转换与默认值陷阱SharedPreferences 的读取方法是强类型的getInt拿到的只可能是 int 或 null不存在“拿到 String 然后自动转 int”的情况。不过实际开发中版本迭代很容易出现类型不一致的坑。举个例子第一版 app 存的是is_login值为 bool第二版某个开发手误写成了prefs.setString(is_login, yes)。老用户更新后读取时getBool(is_login)返回 null代码里如果没写默认值就会被判断成“未登录”出现莫名其妙被登出的 bug。解决办法有两个。第一所有读取都显式给默认值第二尽量别临时改某个 Key 的存储类型真要改换个新 Key 更干净老数据通过迁移逻辑读取后清除。4.4 已废弃 API 与插件版本兼容Flutter 社区迭代非常快shared_preferences 插件也在持续更新。老代码里常见的写法有// 旧版本写法可能已废弃 SharedPreferences? prefs await SharedPreferences.getInstance(); // 早期版本返回 nullable新版本已经改成非空返回。升级插件后编译器会提示你处理这些变化。另外新版本对 Android 端引入了 DataStore 迁移的概念如果你未来使用shared_preferences的替代品DataStore要注意原 SharedPreferences 数据不会自动迁移需要在迁移代码里手动转换。我的经验是升级插件前先看一眼官方 changelog升级后全局跑一遍静态编译看有没有废弃提示。不要盲目升级大版本尤其是涉及到原生存储层这种基础设施稳定比新功能重要。4.5 数据写入成功后崩溃丢失的问题还有一次碰到个很刁钻的问题App 切换后台后被杀掉等再打开上一次设置的某个开关值丢了。排查下来发现那段写入代码没有 await调用后用户立刻切后台系统在数据完全落盘前就把进程杀了。这个场景在 Android 上比较常见。修复方式就是所有写操作统一await并且减少“写入后马上退后台”的窗口。封装类里的方法始终返回 Future业务层必须等待完成。4.6 常见问题速查表问题现象可能原因解决方案读取返回 nullKey 不存在或类型不一致给默认值检查类型是否匹配广播数据没有持久化杀进程前写入未完成写操作全部 await引导页重复出现初始化顺序混乱实例不一致统一预加载 全局单例老用户登录态丢失Key 定义被修改/类型变更新 Key 迁移逻辑不覆盖原有 Key不同页面读到不同数据全局存在多个 SharedPreferences 实例收敛到工具类统一管理setStringList 存中文乱码极少见一般和终端编码有关确认数据来源本身是否合法编码5. 进阶思路把 SharedPreferences 用出“艺术感”5.1 与状态管理结合实现配置即时生效键值对存储负责的是持久化它本身不负责 UI 响应。想让用户切换主题后界面马上变化需要把存储和状态管理配合起来。一个常见做法是用 ValueNotifier 包装配置项。比如主题模式启动时从 AppStorage 读出当前值初始化一个ValueNotifierString每次切换时同时更新存储和 Notifier。界面层用ValueListenableBuilder监听修改存储后 UI 即时刷新刷新依赖的内存数据又是同步的不会有延迟。class ThemeController extends ValueNotifierString { ThemeController() : super(AppStorage.instance.themeMode); void update(String mode) { AppStorage.instance.setThemeMode(mode); value mode; // 通知所有监听者刷新 } }这种设计的精髓在于存储层负责“记住选择”状态层负责“响应变化”各管一段互不干扰。你可以在不改变存储实现的前提下把状态管理方案从 ValueNotifier 换成 Provider、Riverpod 或者 Bloc不会影响持久化逻辑。5.2 数据版本控制与一键迁移很多人忽略数据版本管理但它是让键值对存储可持续使用的关键。我在 AppStorage 里增加了一个kStorageVersion字段启动初始化时检查版本号如果发现需要迁移就执行一段一次性迁移代码。比如第一版 App 里用is_first_launch充当引导页开关后来产品改了需求把引导页改成“版本引导”逻辑需要记录last_guide_launch_version同时还保留首次启动标记。这时候最简单的做法是读旧 Key、写新 Key然后删掉旧 Key并把版本号从 1 升到 2。下次启动所有新逻辑都读新 Key完全无感。这个机制看起来多了一点点代码量但对线上版本迭代特别重要。没有版本控制时一旦你改了 Key 定义老用户就可能出现状态丢失、重复引导、登录失效等问题用户不会知道这是存储层的技术债只会觉得“这 App 怎么又把我数据清掉了”。5.3 加密与安全边界最后说一个很多人没考虑的问题SharedPreferences 存储的数据不是加密的。明文存放在应用私有目录下对于普通配置来说没问题但如果存了 token、用户敏感信息建议至少做一层混淆或加密处理。一个折中方案是只把 token 这类关键字段用 base64 编码后存储防君子不防小人。更严谨的方案是引入 flutter_secure_storage它借助系统级 Keychain / Keystore 存储敏感数据。这个库的 API 很接近 SharedPreferences迁移成本不高。我在涉及支付、账号体系的项目里会做一个约定非敏感配置走 AppStorage敏感凭证走 secure storage。两边各司其职安全边界清晰也不会因为过度加密拖慢读取速度。5.4 合理使用缓存避免高频读写SharedPreferences 虽然读写快但它毕竟不是设计来做高频数据的。像用户滑动位置实时保存、每秒更新一次计时器这种操作建议先在内存中做防抖/节流等用户停顿后再写入。具体做法是监听操作事件设置 300ms 到 1s 的防抖窗口。比如记录阅读进度用户滑动时只更新内存变量停止滑动 500ms 后才写一次存储。这样既保证了数据不丢失也不会让存储层背负无谓的写入压力。Android 端 SharedPreferences 的 apply 模式虽然是异步的但频繁 apply 同样会带来性能损耗在低端机上尤其明显。写在流量最后的一点体会我在几个项目里反复用过 SharedPreferences整体感受是它能解决 80% 的“记住点什么”的需求但非常考验开发者对数据边界和生命周期的理解。真正让我受益的不是它有多简单而是提前做好了封装、版本控制和权限边界——存储层一旦设计好后续业务的迭代非常省心。如果你也在用这个组件建议从今天开始做三件小事第一把所有 Key 收敛到一个常量文件第二统一初始化不要在业务页面里再 getInstance第三给存储数据加上一个版本号。做完这三步你会在后续维护里感受到质的变化。项目里那些“莫名其妙的数据丢了”“设置老是不生效”的诡异 bug大概率会少掉一大半。