news 2026/9/29 15:40:26

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
客户端首选项读写全攻略:从 SharedPreferences 到 DataStore

你肯定遇到过这种情况:用户在设置页里选好了深色模式,退出去再进来,设置居然还原了;或者明明存了一个登录状态,杀掉进程重启后却要重新登录。说白了,这就是首选项读写没做好。

首选项这个词,在客户端开发里基本等同于“本地轻量配置存储”。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,原生支持异步和事务。

我用一个表格对比一下常见的方案,这样你在团队里做技术选型时可以直接抄作业:

方案底层实现读写速度异步支持事务性适合场景风险/缺点
SharedPreferencesXML 文件,启动整文件加载读快,写需序列化apply 异步落盘,commit 同步无小型配置、状态标记大文件卡顿、无加密、无类型安全
DataStore(Preferences)基于协程 + 文件,事务性更新全异步,读写走 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 支持的数据类型其实非常有限,就下面这几种:

  • boolean
  • int
  • long
  • float
  • String
  • String的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 明明写入成功了,重启后数据却丢了

这是我在项目群里被问得最多的问题。大部分情况下,都不是真的丢了,而是写入和读取用的不是同一个实例。

排查步骤按顺序来:

  1. 确认是不是用了getDefaultSharedPreferences和getSharedPreferences("自定义名")混用。这两者指向不同的 XML 文件,一个写一个读,必然读不到。
  2. 确认是不是不同 Context。getSharedPreferences用 Activity 的 Context 和应用级 Context 获取的实例是同一个,因为最终都指向同一个文件路径,这个一般不是问题。
  3. 确认有没有在写入后调用apply()或commit()。只edit().putXxx()不提交,等于白写。
  4. 确认清理缓存时有没有误删。有些团队在“清除缓存”功能里粗暴地删了整个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,想改都改不动。这个教训,希望你不要再踩一次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 15:39:34

随机过程先验+Transformer:期权波动率曲面预测的实战指南

简介&#xff1a;本资源是一份聚焦随机过程Transformer模型在期权波动率曲面预测中应用的专题文档&#xff0c;共27页&#xff0c;适合金融工程、量化研究及对机器学习在衍生品定价领域感兴趣的读者。文档依据完整目录展开&#xff0c;从期权与波动率曲面基础、随机过程与Trans…

作者头像 李华
网站建设 2026/9/29 15:38:31

毕业论文AI率检测与降AI工具实测:从40%到15%的完整链路

2026年毕业季&#xff0c;我身边已经不止一个研究生在问同一个问题&#xff1a;毕业论文AI率要求20%以下&#xff0c;到底用什么工具能降下来&#xff1f;我去年也是这么过来的&#xff0c;初稿自测AI率40%&#xff0c;查重却只有12%&#xff0c;当时整个人有点懵。后来一边实测…

作者头像 李华
网站建设 2026/9/29 15:38:05

AI智能体落地物流运营:顺丰案例拆解大模型工作流与人工兜底

简介&#xff1a;顺丰运营环节的智能体应用&#xff0c;是物流行业数字化转型的重要样本。这份PPT以顺丰科技实践为蓝本&#xff0c;系统梳理智慧供应链的底层能力&#xff0c;涵盖覆盖全国的航空、陆运、铁运及多式联运网络&#xff1b;并结合天网、地网等资源布局&#xff0c…

作者头像 李华
网站建设 2026/9/29 15:37:51

进口阀门选型指南:品牌分类、工况匹配与避坑策略

进口阀门这四个字&#xff0c;在设备采购和工程项目圈子里一直是既让人安心又让人头疼的话题。安心的地方在于&#xff0c;大家都默认进口件更稳——同样的介质和工况&#xff0c;国产阀用两三年开始内漏&#xff0c;进口阀五年后还能保持接近出厂时的流量曲线&#xff1b;头疼…

作者头像 李华
网站建设 2026/9/29 15:37:40

OnlyOffice在线预览Word/Excel/PPT/PDF:Docker部署与前端接入全复盘

用OnlyOffice在网页里预览Word、Excel、PPT、PDF&#xff0c;听起来像是一件很常规的事&#xff0c;但真要把这四类文件统一跑通&#xff0c;并且做到只需要双击一个index.html就能看到预览效果&#xff0c;我前前后后折腾了两个晚上。最后成型的demo不复杂&#xff1a;前端就是…

作者头像 李华
网站建设 2026/9/29 15:37:27

Linux匿名管道与进程池:实现细节与排坑实战

很多人在 Linux 下做多进程开发&#xff0c;第一个接触的 IPC 机制基本都是匿名管道&#xff0c;面试时也经常被追问“进程池怎么实现”。我自己的感觉是&#xff1a;匿名管道看起来简单——一个 pipe() 调用&#xff0c;两个文件描述符&#xff0c;一端读一端写——但真把它和…

作者头像 李华