资讯动态

客户端首选项读写全攻略:从 SharedPreferences 到 DataStore

发布时间:2026/9/29 15:40:30 来源:尧图企业网站定制
你肯定遇到过这种情况用户在设置页里选好了深色模式退出去再进来设置居然还原了或者明明存了一个登录状态杀掉进程重启后却要重新登录。说白了这就是首选项读写没做好。首选项这个词在客户端开发里基本等同于“本地轻量配置存储”。Android 里最常见的就是 SharedPreferencesiOS 里有 UserDefaults桌面端还有各种配置文件方案。它的核心任务非常简单把用户的偏好设置、状态标记、少量业务数据以键值对的形式持久化到本地下次启动时读回来。虽然看起来只是一读一写但这里面的门道比大多数人想的多选型错了、API 用错了、线程处理不当都会留下线上事故。这篇文章我不会给你讲太虚的架构理论而是直接围绕首选项读写的完整链路从方案选型、核心 API 细节、完整实操到问题排查和迁移升级把这条路走一遍。适合刚入手客户端开发、被设置页存储折磨过的初级开发者也适合写了好一阵业务代码、想系统梳理存储方案的人。内容以 Android SharedPreferences 为主线但思路和坑点对 iOS、桌面端一样有参考价值。1. 首选项的定位与方案取舍为什么不能用数据库硬扛1.1 首选项到底是什么它解决什么问题首选项Preference本质上是一套“极简键值对存储约定”。所谓键值对就是类似key value的结构key 是字符串标识value 可以是基本类型或字符串。客户端应用里这套机制被广泛用来保存用户设置和轻量状态比如用户偏好主题模式、字体大小、通知开关、语言选择业务状态是否已看过引导页、当前登录账号ID、上次启动版本号临时标记某个弹窗本次启动是否已经展示过、新功能红点是否已消除这类数据的共同特征是量小、结构简单、不需要复杂查询而且需要跨启动保留。用户改了设置你总不能用内存变量吧进程一重启就没了用数据库吧杀鸡焉用牛刀一个存放 20 个键值对的 SQLite 表徒增维护成本连迁移版本都得单独设计。首选项就是为这种“小而频繁”的读写场景设计的。它在 Android 上的实现是 SharedPreferences底层是一个 XML 文件存放在应用的私有目录里。你调用putString(dark_mode, true)本质上是准备修改内存中的一份 Map然后通过apply()或commit()把整个 Map 序列化成 XML写回到磁盘。读的时候启动时一次性把整个 XML 文件加载进内存后续的getString都是纯内存操作速度极快。生活化类比一下首选项就像你玄关柜子里的一个固定抽屉里面放着钥匙、门卡和零钱。你不会为了放一串钥匙单独装修一个衣帽间也不会把所有家当都塞进这个抽屉它的定位就是“随取随放、放最常用的东西”。1.2 什么时候不该用首选项这个我必须放在前面讲因为见过太多人把首选项当成万能存储。首选项不适合存放以下内容大体积数据。SharedPreferences 的读写是整文件序列化你存一个几百 KB 的 JSON 字符串每次apply()都会把整个文件重新写一遍。早期版本没有异步落盘机制时主线程写大文件直接造成界面卡顿。需要事务性保证的业务数据。SP 没有事务概念一组关联数据比如订单信息 支付状态写到一半失败了程序是感知不到的。需要加密存储的敏感信息。SharedPreferences 的 XML 明文躺在应用私有目录里虽然后台应用不能访问但 root 设备、备份文件、安全审计场景下都有泄露风险。需要跨设备同步的数据。SP 是纯本地机制没有云同步能力你强行用它做账号体系存储后面迁移时成本极高。如果你发现自己正要往 SP 里塞一个几百条数据的列表停一下老老实实回去用数据库或者像 DataStore、MMKV 这类更现代的键值存储组件。选型这件事一开始想清楚比后面重构省心十倍。1.3 主流键值存储方案怎么选现在的客户端开发键值存储早就不是 SharedPreferences 一家独大了。尤其是腾讯出品的 MMKV基于 mmap 内存映射实现性能和安全性都做了很大改善Jetpack DataStore 则是 Google 官方推荐的 SharedPreferences 替代品基于协程和 Flow原生支持异步和事务。我用一个表格对比一下常见的方案这样你在团队里做技术选型时可以直接抄作业方案底层实现读写速度异步支持事务性适合场景风险/缺点SharedPreferencesXML 文件启动整文件加载读快写需序列化apply 异步落盘commit 同步无小型配置、状态标记大文件卡顿、无加密、无类型安全DataStorePreferences基于协程 文件事务性更新全异步读写走 Flow原生支持有新项目、需要响应式监听粒度删除操作复杂、迁移需额外处理MMKVmmap 内存映射极高与进程内使用一致有高频率读写、多进程共享引入第三方依赖、需要掌握原理SQLite / RoomB 树数据库中等索引查询快需要自行封装强结构化数据、列表、关系查询配置型小数据用它是过度设计我的倾向很明确新项目如果团队已经上了协程直接上 DataStore如果追求极致性能、有大量高频键值写入选 MMKV还在维护老项目或者改动范围极小继续用 SharedPreferences 也没问题它没那么不堪只要知道它的边界在哪。2. SharedPreferences 读写核心 API五个必知细节2.1 获取实例与基础读写操作Android 里获取 SharedPreferences 实例有三种方式// 方式一默认文件以包名命名 SharedPreferences sp PreferenceManager.getDefaultSharedPreferences(context); // 方式二自定义文件推荐这种方式职责清晰 SharedPreferences sp getSharedPreferences(user_settings, Context.MODE_PRIVATE); // 方式三如果 App 有多个模块推荐按模块拆文件 SharedPreferences sp getSharedPreferences(module_player, Context.MODE_PRIVATE);实操中我强烈建议用第二种并且按业务模块拆分成不同的文件比如login_prefs、theme_prefs、guide_prefs。原因很简单SP 读取时是整文件加载文件里的键越多启动加载的开销越大一次崩溃排查时定位也更方便。一个文件塞几百个键谁看了都头大。写入操作的基本流程是这样的SharedPreferences.Editor editor sp.edit(); editor.putBoolean(dark_mode, true); editor.putString(user_name, 老王); editor.apply();读取就简单了boolean darkMode sp.getBoolean(dark_mode, false); // 第二个参数是默认值 String userName sp.getString(user_name, 默认用户);这里getString的第二个参数——默认值——非常重要。新装的 App用户什么都没设置过文件里根本没有dark_mode这个键如果你不传默认值返回的就是 null后面一用直接空指针。凡是做读取操作永远养成都传一个合理的默认值这是首选项读写的第一条军规。2.2 apply 与 commit到底是同步还是异步这是面试里出现频率极高、实际项目里也最容易踩坑的细节。commit()是同步提交它会立刻把内存中的 ModifiableMap 同步写入磁盘文件然后返回布尔值表示是否成功。因为涉及磁盘 IO在主线程调用时极端情况下会造成卡顿但好处是你能立刻知道写入结果。apply()是异步提交它先原子更新内存中的值然后异步落盘。注意它的行为有点特殊它不阻塞当前线程会先返回成功然后在后台线程执行写入期间如果有其他线程要读读到的也是内存中的最新值不会出现读到旧数据的情况。从 Android 4.0 的源码看apply()通过一个QueuedWork机制异步写入同时会通过CountDownLatch在 Activity 的onStop等阶段等待落盘完成。这里有个著名的坑apply()本身是异步但如果你紧接着杀进程理论上数据有一定概率还没落盘不过这个概率在实际项目中非常低Google 也在文档里表示“apply 足以保证常用场景”。我个人的实践结论很简单场景推荐方式用户点击“保存设置后立即退出 App”commit()确保落盘完成常规记录、标记、非关键设置apply()性能好、不卡顿写入结果需要立刻反馈给 UIapply()因为内存已更新批量写入大量键值先多个 put 再一次性 apply/commit批量写的场景要特别强调多个putXX之后只调用一次apply()不要每个 put 后面都跟一个 apply。每调用一次 apply都是一次文件写操作哪怕内部有优化也是不必要的开销。2.3 支持的数据类型与边界SharedPreferences 支持的数据类型其实非常有限就下面这几种booleanintlongfloatStringString的Set注意不是任意 Set是 HashSet 之类的集合这里面有几个容易被忽略的点。第一getStringSet返回的对象你不能直接往里 add 元素。源码里返回的是Collections.unmodifiableSet包装过或者直接从底层 Map 抛出来的不可变集合你往里面写数据会抛UnsupportedOperationException。要修改先复制一份再操作。第二SP 没有专门存 long 之外的高精度数值。你存一个BigDecimal要么转成 String要么就算了。类似的double类型也只能转成String或者float存储精度损失问题必须自己注意。第三虽然 key 和 value 都限制了类型但 key 的命名规范很少有人维护。我见过项目里同一个标识被写成dark_mode和DarkMode两套工作正常但语义混乱。建议约定一个规则全部小写 下划线或者统一驼峰并且在文件头写清楚每个 key 的用途、类型、写入位置。2.4 监听变更SharedPreferences.OnSharedPreferenceChangeListener监听首选项变化是一个很实用的能力比如用户切换主题后全局 UI 要立刻响应登录状态变化后多个模块要同步刷新。注册监听是在实例上不是 Editor 上SharedPreferences.OnSharedPreferenceChangeListener listener new SharedPreferences.OnSharedPreferenceChangeListener() { Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (dark_mode.equals(key)) { boolean current sharedPreferences.getBoolean(key, false); // 这里做 UI 切换 } } }; sp.registerOnSharedPreferenceChangeListener(listener);注意两个细节。细节一监听器必须先注册再调用 apply()。因为apply()会同步更新内存监听回调是在内存更新后立刻触发的注册晚了就会错过通知。细节二registerOnSharedPreferenceChangeListener持有的是强引用必须在合适的生命周期里反注册。比如在 Activity 里注册了onDestroy里一定要unregisterOnSharedPreferenceChangeListener否则 Activity 销毁不了直接内存泄漏。在项目里我更喜欢用 LiveData 或者 Flow 把 SP 监听包装一层对外暴露响应式数据源这样业务方只需要订阅数据变化不用关心生命周期和反注册。这种封装本质上就是把 SP 从一个“命令式读取”升级成“响应式数据源”代码维护体验差了一个量级。3. 完整实操从设置页到持久化的落地3.1 场景设计一个需要首选项的典型功能写代码之前先设计一个足够有代表性的实战场景。我做了一个“新手引导 主题设置”的组合需求这是绝大多数 App 都会遇到的功能用户首次安装进入 App需要连续展示 3 页引导页之后再次打开就不再展示设置页提供深色模式开关用户切换后全局生效设置页提供推送通知开关用户退出登录后再登录需要更新本地标识这个场景涵盖了首选项最常见的四类使用状态标记引导是否完成、布尔设置深色模式、通知开关、字符串标识登录账号、跨页面共享设置页改了主界面立刻生效。3.2 写入流程用 Editor 批量提交的正确姿势首先建立一个管理类按我前面说的“模块拆分 常量集中”的规范来public class AppPrefs { // 每个 key 都用常量定义避免魔法值散落各处 public static final String KEY_GUIDE_FINISHED guide_finished; public static final String KEY_DARK_MODE dark_mode; public static final String KEY_NOTIFICATION_ENABLED notification_enabled; public static final String KEY_USER_ID user_id; private static final String FILE_NAME app_config; private static SharedPreferences getPrefs(Context context) { return context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE); } // 写入方法每次都返回 Editor 或包装类便于链式调用 public static void saveGuideFinished(Context context, boolean finished) { getPrefs(context).edit() .putBoolean(KEY_GUIDE_FINISHED, finished) .apply(); } public static void initUserConfig(Context context, String userId, boolean darkMode) { // 多次写入合并成一次提交减少不必要的 IO getPrefs(context).edit() .putString(KEY_USER_ID, userId) .putBoolean(KEY_DARK_MODE, darkMode) .apply(); } }具体到引导页的逻辑在引导页最后一个页面的“立即体验”按钮点击时写入// GuideActivity.java btnStart.setOnClickListener(v - { AppPrefs.saveGuideFinished(this, true); startActivity(new Intent(this, MainActivity.class)); finish(); });主题切换的写入稍微复杂一点因为要即时生效// SettingActivity.java SwitchCompat darkModeSwitch findViewById(R.id.sw_dark_mode); darkModeSwitch.setOnCheckedChangeListener((buttonView, isChecked) - { // 写入首选项 AppPrefs.saveDarkMode(this, isChecked); // 即时通知全局主题变化可以走 EventBus也可以走自定义回调 ThemeManager.getInstance().applyDarkMode(isChecked); });这里我要额外说一个主题切换的细节写入和刷新要分两层处理。写入是数据层的事刷新是 UI 层的事。不要耦合到设置页的回调里正确做法是让主题相关的页面都监听KEY_DARK_MODE的变化而不是靠设置页手动去遍历所有页面改。不然你新增一个页面还得回来改设置页的代码这就是坏味道。3.3 读取流程默认值策略与启动加载读取的核心是“永远不要赌用户一定会写入某个键”首次启动时文件是空的所有读取都要有兜底策略。以引导页判断为例在 MainActivity 的onCreate里// MainActivity.java boolean guideFinished AppPrefs.isGuideFinished(this); if (!guideFinished) { startActivity(new Intent(this, GuideActivity.class)); finish(); }读取方法isGuideFinished里一定要传默认值public static boolean isGuideFinished(Context context) { return getPrefs(context).getBoolean(KEY_GUIDE_FINISHED, false); }深色模式的取值逻辑要注意“跟随系统”这种三态需求。单纯存布尔是搞不定的建议还是存 String取值“light”、“dark”、“system”再映射成布尔状态。很多项目一开始只做布尔开关产品后来要求加了“跟随系统”被迫改数据格式迁移又麻烦。我的经验是设置类字段一开始就按“可能扩展”的姿态设计文本型存储优于布尔型存储。读取时机方面SP 文件是在getSharedPreferences第一次调用时才加载到内存的。如果 App 启动后马上要读一个关键值那首次读会有一次磁盘 IO 开销在低端机上有几十毫秒的耗时一般无感。但如果你有极致的启动性能要求可以考虑在 Application 里预加载要用的 SP 文件把 IO 前置到闪屏页阶段分散卡顿压力。3.4 多页面共享与数据一致性首选项在客户端是多页面共享的同一个文件可以被多个 Activity 同时读写这带来一个数据一致性的问题。我先说一个常见场景用户在 SettingsActivity 里打开了深色模式MainActivity 里还亮着白屏用户返回后发现主界面没有变化。出现这个问题十有八九是 MainActivity 没有监听变化只在onCreate里读了一次值。正确做法有两种。方案一在 MainActivity 里注册监听收到KEY_DARK_MODE变化后重新应用主题并刷新 UI// MainActivity.java private SharedPreferences.OnSharedPreferenceChangeListener listener (prefs, key) - { if (AppPrefs.KEY_DARK_MODE.equals(key)) { recreate(); // 简单粗暴重绘整个 Activity } }; Override protected void onResume() { super.onResume(); prefs.registerOnSharedPreferenceChangeListener(listener); } Override protected void onPause() { super.onPause(); prefs.unregisterOnSharedPreferenceChangeListener(listener); }方案二如果项目里有 ViewModel LiveData把 SP 包成一个 LiveData 数据源统一驱动所有页面// ThemeViewModel.java public LiveDataBoolean observeDarkMode(Context context) { MutableLiveDataBoolean liveData new MutableLiveData(); SharedPreferences prefs AppPrefs.getPrefs(context); liveData.setValue(prefs.getBoolean(AppPrefs.KEY_DARK_MODE, false)); prefs.registerOnSharedPreferenceChangeListener((prefs1, key) - { if (AppPrefs.KEY_DARK_MODE.equals(key)) { liveData.setValue(prefs1.getBoolean(key, false)); } }); return liveData; }数据一致性还有一个易被忽略的点不要在不同的 SP 文件里存储同一语义的数据。比如登录状态你在login_prefs里存了一个boolean is_login又在app_config里存了一个String user_id登出时要是第二个没清后面就会在“已登出但 userId 还在”的矛盾状态下出 bug。同一个语义的数据必须收拢到一个文件、一个管理类里。4. 首选项读写常见问题速查与排查思路4.1 明明写入成功了重启后数据却丢了这是我在项目群里被问得最多的问题。大部分情况下都不是真的丢了而是写入和读取用的不是同一个实例。排查步骤按顺序来确认是不是用了getDefaultSharedPreferences和getSharedPreferences(自定义名)混用。这两者指向不同的 XML 文件一个写一个读必然读不到。确认是不是不同 Context。getSharedPreferences用 Activity 的 Context 和应用级 Context 获取的实例是同一个因为最终都指向同一个文件路径这个一般不是问题。确认有没有在写入后调用apply()或commit()。只edit().putXxx()不提交等于白写。确认清理缓存时有没有误删。有些团队在“清除缓存”功能里粗暴地删了整个shared_prefs目录需要检查 App 的存储清理逻辑。如果真的确认是极端场景下apply()异步落盘没完成导致的丢失那就在“用户改完设置马上杀进程”这种场景改用commit()尤其是设置完成后用户紧接着退出的操作流。4.2 多进程模式下数据错乱用 Android 多进程组件android:process属性时SP 的读写会立刻出问题。SharedPreferences 的内存数据是跟着进程走的进程 A 写入后进程 B 的内存里还是旧值而且 B 后写入会覆盖 A 的结果。网上有人说MODE_MULTI_PROCESS能解决其实它只是在每次 get 时重新读一遍文件保证拿到磁盘最新值但跨进程的并发写入冲突靠它是解决不了的。官方对MODE_MULTI_PROCESS的注释直接写着“deprecated and not really supported”。问题的根源在于多个进程同时改一个文件本质上是一个分布式并发问题。真正靠谱的解决方案是方案一首选项读写统一收口到主进程子进程通过 AIDL 或接口转发请求穿透到主进程执行方案二改用 MMKV它天生支持多进程访问内部用文件锁保证一致性方案三从设计上避免多进程共享同一个 SP 文件每个进程拆开使用不同的文件我的建议是尽量避免把核心配置放在子进程里读写进程隔离本身就包含了“数据不共享”的设计意图非要跨进程共享请走方案一或方案二。4.3 大字符串与频繁写入引发的性能问题SharedPreferences 最怕两种人一种是把大 JSON 塞进去的人一种是把高频数据用 apply 疯狂提交的人。前者的问题在于SP 的每次写入都是整文件重写文件里有 500 KB 的字符串时每次apply()都要付出写 500 KB 的代价。后者的问题在于频繁提交会产生大量磁盘 IO虽然后台线程执行但在低内存设备上会挤压其他任务的时间。我见过一个真实案例某个 App 把一个商品列表的完整 JSON 缓存进 SP商品数据更新时每分钟提交一次用户在列表页滑动时明显感到卡顿。排查后发现是 SP 写入线程阻塞了 IO 队列。对于这类场景处理办法很简单大 JSON 挪到文件缓存或 Room 存储高频写入做节流至少保证两次提交之间有几百毫秒间隔写入前比较新旧值值没变就不提交4.4 安全与隐私首选项里的敏感信息要加保护很多人以为数据放在应用私有目录就安全了。实际上在 Android 上 SP 文件面临几类风险root 设备上应用数据可以被直接查看系统备份抓取时shared_prefs目录会被打包到备份文件里部分机型开发者选项中开启“不锁定应用数据加密”后备份是明文。所以敏感数据一律不允许明文写入 SP。常见的加固手段有Token、密码等用 Android Keystore 结合 AES 加密后存入 SP使用 EncryptedSharedPreferences 组件做透明加解密规避法敏感凭证只保存在内存或 KeyStore不落盘EncryptedSharedPreferences 是 AndroidX Security 库的一部分使用很简单但要注意它对 apply() 的性能影响比明文大因为每次写入都要做一次加解密适合低频敏感数据的保存不适合高频计数类字段。5. 迁移升级从 SharedPreferences 平移到 DataStore5.1 DataStore 引入为什么官方要推它Jetpack DataStore 是官方用来替代 SharedPreferences 的方案底层基于协程的 Flow 实现核心优势有三点全异步读写都发生在协程上下文里不再有主线程 IO 风险事务性所有操作建立在事务上要么全部成功要么全部回滚避免 SP 部分写入的问题响应式数据以 Flow 形式对外暴露天然支持监听数据变化接入方式是在build.gradle里加依赖implementation androidx.datastore:datastore-preferences:1.0.0通过扩展属性创建 DataStoreval Context.dataStore by preferencesDataStore( name app_config )读写方式变成了这样// 写入 suspend fun saveDarkMode(context: Context, enabled: Boolean) { context.dataStore.edit { settings - settings[PreferenceKeys.KEY_DARK_MODE] enabled } } // 读取 val darkMode: FlowBoolean context.dataStore.data.map { settings - settings[PreferenceKeys.KEY_DARK_MODE] ?: false }DataStore 会把数据以DataStore文件形式存储在files/datastore/目录注意它和 SharedPreferences 文件并不兼容不能直接把旧文件拷贝过来用需要走官方提供的迁移机制。5.2 迁移的实操步骤与避坑指南官方提供了一个SharedPreferencesMigration来平滑迁移。简单说在 DataStore 创建时注册迁移对象DataStore 首次访问时会自动把 SharedPreferences 里的数据搬进 DataStore然后标记 SP 数据已迁移。核心配置代码如下val Context.dataStore: DataStorePreferences by preferencesDataStore( name app_config, produceMigrations { context - listOf(SharedPreferencesMigration(context, app_config)) } )迁移有几个必须注意的坑坑一迁移是自动的但时机不确定。DataStore 只在首次读取时检测是否需要迁移。如果你的 App 在别的地方还在用 SharedPreferences 读旧数据而 DataStore 已经迁移完成旧文件里的数据就不动了两边会各读各的导致短暂的不一致。迁移期间最安全的做法是全面切换 API不要留旧的读取路径。坑二默认值迁移问题。SharedPreferencesMigration默认迁移所有键。如果旧 SP 里某个键你只是写入过默认值迁移过去后 DataStore 里也会有这个键后续读取时用?: 默认值才不会出问题但建议迁移逻辑尽量收敛。坑三迁移是单进程的。多个进程同时访问 DataStore 文件会导致崩溃这是 DataStore 目前最大的槽点。如果你项目里有跨进程需求别硬迁先解决进程结构问题再动手。如果旧的数据结构不复杂而且项目已经线上稳定我更倾向于写一次性迁移脚本读取旧 SP 文件手动写入 DataStore然后删除旧文件。操作可控回滚也方便不需要依赖官方 Migration 的黑盒行为。写在最后的一点体会首选项读写这个功能看着不起眼但它承载的是用户对 App 最直接的“记忆感”。用户设了深色模式重启后还是深色模式他觉得这个 App 智能设了通知开关明天打开还是该开开该关关他觉得你的 App 靠谱。反过来每次打开都要重新选一次用户只会觉得你的 App 有健忘症体验一下归零。我做了这么多年客户端最深的一个体会就是首选项这东西没有多难但要把边界意识刻进骨子里。知道什么时候用它更要知道什么时候不用它写 API 之前先想想要不要加密存数据之前先想想会不会涨成巨无霸选型的时候多想一步迁移的时候多想一个兼容版本。最后给一个小技巧不管你最终选 SharedPreferences、DataStore 还是 MMKV都建议把 key 常量集中管理把读写方法收拢到同一个管理类里。这样以后换存储方案只改一个文件全项目无感。很多团队不敢动技术债就是一开始姿势太随意代码里几百个prefs.getString(user_name, )散落在各个 Activity想改都改不动。这个教训希望你不要再踩一次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑