你肯定遇到过这种情况:用户在设置页里选好了深色模式,退出去再进来,设置居然还原了;或者明明存了一个登录状态,杀掉进程重启后却要重新登录。说白了,这就是首选项读写没做好。
首选项这个词,在客户端开发里基本等同于“本地轻量配置存储”。Android 里最常见的就是 SharedPreferences,iOS 里有 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,原生支持异步和事务。
我用一个表格对比一下常见的方案,这样你在团队里做技术选型时可以直接抄作业:
| 方案 | 底层实现 | 读写速度 | 异步支持 | 事务性 | 适合场景 | 风险/缺点 |
|---|---|---|---|---|---|---|
| SharedPreferences | XML 文件,启动整文件加载 | 读快,写需序列化 | apply 异步落盘,commit 同步 | 无 | 小型配置、状态标记 | 大文件卡顿、无加密、无类型安全 |
| DataStore(Preferences) | 基于协程 + 文件,事务性更新 | 全异步,读写走 Flow | 原生支持 | 有 | 新项目、需要响应式监听 | 粒度删除操作复杂、迁移需额外处理 |
| MMKV | mmap 内存映射 | 极高 | 与进程内使用一致 | 有 | 高频率读写、多进程共享 | 引入第三方依赖、需要掌握原理 |
| SQLite / Room | B+ 树数据库 | 中等,索引查询快 | 需要自行封装 | 强 | 结构化数据、列表、关系查询 | 配置型小数据用它是过度设计 |
我的倾向很明确:新项目如果团队已经上了协程,直接上 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(),性能好、不卡顿 |
| 写入结果需要立刻反馈给 UI | apply(),因为内存已更新 |
| 批量写入大量键值 | 先多个 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 LiveData<Boolean> observeDarkMode(Context context) { MutableLiveData<Boolean> 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 平移到 DataStore
5.1 DataStore 引入:为什么官方要推它
Jetpack DataStore 是官方用来替代 SharedPreferences 的方案,底层基于协程的 Flow 实现,核心优势有三点:
- 全异步:读写都发生在协程上下文里,不再有主线程 IO 风险
- 事务性:所有操作建立在事务上,要么全部成功,要么全部回滚,避免 SP 部分写入的问题
- 响应式:数据以 Flow 形式对外暴露,天然支持监听数据变化
接入方式是在build.gradle里加依赖:
implementation "androidx.datastore:datastore-preferences:1.0.0"通过扩展属性创建 DataStore:
val 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: Flow<Boolean> = 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: DataStore<Preferences> 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,想改都改不动。这个教训,希望你不要再踩一次。