SharedPreferences.commit() 和 apply() 的区别是什么?为什么推荐使用 apply()?
解读
国内面试中,这道题几乎属于“必背题”,但面试官真正想听的是:
- 你是否知道二者在“线程模型、磁盘写入时机、异常反馈”三方面的本质差异;
- 能否结合主线程卡顿、ANR、崩溃率等线上痛点,解释为什么 apply() 更适合生产环境;
- 是否了解 Android 8.0 之后“QueuedWork”机制对 apply() 的兜底策略,以及国内 ROM 后台管控对写入造成的影响。
答出“apply 异步、commit 同步”只能拿 60 分,把“ANR 触发点、awaitCommit 阻塞、SP 文件过大导致卡顿”讲清楚才能拿到 90 分以上。
知识点
-
线程模型
- commit(): 调用线程立即执行 I/O,属于同步阻塞;主线程调用直接造成卡顿,SP 文件过大(>50 KB)时尤为明显。
- apply(): 把修改先同步更新到内存,再把写磁盘任务 enqueue 到单线程线程池(HandlerThread/QueuedWork),调用立即返回。
-
异常与返回值
- commit(): boolean 返回值,可感知写盘失败(无空间、权限、内核 I/O 错误)。
- apply(): 无返回值,失败信息通过 logcat 打印,调用方无感知。
-
崩溃与 ANR
- Activity.onPause()/Service.onStop() 阶段,系统会等待所有 apply() 的写盘任务完成(QueuedWork.waitToFinish()),若此时线程池积压任务过多或磁盘慢,主线程被阻塞 5 s 以上即触发 ANR。
- commit() 在主线程度调用时,若磁盘写操作耗时 >5 s,同样会 ANR;但 commit() 次数一般远少于 apply(),线上 ANR 占比反而低。
-
一致性
- 二者均先原子更新内存,再异步/同步写磁盘;进程内读取始终强一致,跨进程通过 FileObserver 触发 reload,仍无法做到“实时一致”。
-
国内 ROM 特殊点
- 部分国产 ROM 在应用退到后台 5 ~ 30 s 后冻结进程线程,导致 apply() 的线程池任务被挂起,再次冷启动时可能丢失“上次退出前最后一次 apply()”的修改;commit() 因同步写完,反而不会丢。
-
官方演进
- Android 9 引入 EncryptedSharedPreferences,内部仍复用 commit/apply 模型;
- Jetpack DataStore 基于 Kotlin Coroutines 和 Protobuf,彻底替换 SP,官方已明确为“SP 替代方案”。
答案
-
区别
① 线程:commit() 在当前线程同步写磁盘,apply() 异步写盘立即返回;
② 返回值:commit() 返回 boolean 表示写盘成败,apply() 无返回;
③ 卡顿:主线程 commit() 会阻塞,容易 ANR;apply() 把任务放到后台线程,正常情况下不卡主线程;
④ 崩溃一致性:二者内存层立即生效,磁盘层 commit() 保证写完返回,apply() 不保证时间点。 -
推荐使用 apply() 的原因
- UI 线程不阻塞,帧率稳定,消除因 SP 写盘导致的卡顿与 ANR;
- 实际测试中,apply() 的写盘延迟中位数 <10 ms,99% 场景在 100 ms 内完成,对业务无感知;
- 即使 Activity 生命周期末尾系统会等待写盘,只要控制单次写入数据量(<20 KB)并避免集中批量写入,就能把 ANR 率控制在万分之一以下;
- 对于需要确保“成功或失败”立即感知的场景(如支付标记),可降级为 commit(),但务必放子线程。
一句话总结:apply() 把“同步阻塞”变成“异步延迟”,在 99% 场景下用微不足道的延迟换来主线程零卡顿,因此官方与一线大厂均强制使用 apply()。
拓展思考
-
线上 ANR 日志中出现 “QueuedWork.waitToFinish” 应如何定位?
打开 trace 文件,查看主线程阻塞栈是否卡在 QueuedWork.awaitCommit,再结合 SP 文件大小、写入次数,利用 BlockCanary 或 Matrix 插件定位是哪个业务在 onPause 前高频 apply()。 -
超大 SP 文件拆分策略
国内主流 App 把 SP 按“账号维度+模块维度”拆成多文件,单文件不超过 50 KB;对读写频繁、但无需持久化的缓存,改用 MMKV 或 DataStore,彻底绕开 SP 的 XML 全量重写缺陷。 -
多进程场景
SP 默认不支持多进程安全,MODE_MULTI_PROCESS 已被废弃。正确姿势是使用 ContentProvider + SP 或直接使用 MMKV / DataStore,后者基于 mmap 实现跨进程并发读写。 -
未来迁移
Google 官方已停止对 SP 的新功能投入,Jetpack DataStore 提供 Proto 与 Preferences 两种实现,支持事务、协程背压、异常回滚,建议新模块直接采用 DataStore,老模块逐步重构,减少后续技术债。