SharedPreferences.commit() 和 apply() 的区别是什么?为什么推荐使用 apply()?

解读

国内面试中,这道题几乎属于“必背题”,但面试官真正想听的是:

  1. 你是否知道二者在“线程模型、磁盘写入时机、异常反馈”三方面的本质差异;
  2. 能否结合主线程卡顿、ANR、崩溃率等线上痛点,解释为什么 apply() 更适合生产环境;
  3. 是否了解 Android 8.0 之后“QueuedWork”机制对 apply() 的兜底策略,以及国内 ROM 后台管控对写入造成的影响。

答出“apply 异步、commit 同步”只能拿 60 分,把“ANR 触发点、awaitCommit 阻塞、SP 文件过大导致卡顿”讲清楚才能拿到 90 分以上。

知识点

  1. 线程模型

    • commit(): 调用线程立即执行 I/O,属于同步阻塞;主线程调用直接造成卡顿,SP 文件过大(>50 KB)时尤为明显。
    • apply(): 把修改先同步更新到内存,再把写磁盘任务 enqueue 到单线程线程池(HandlerThread/QueuedWork),调用立即返回。
  2. 异常与返回值

    • commit(): boolean 返回值,可感知写盘失败(无空间、权限、内核 I/O 错误)。
    • apply(): 无返回值,失败信息通过 logcat 打印,调用方无感知。
  3. 崩溃与 ANR

    • Activity.onPause()/Service.onStop() 阶段,系统会等待所有 apply() 的写盘任务完成(QueuedWork.waitToFinish()),若此时线程池积压任务过多或磁盘慢,主线程被阻塞 5 s 以上即触发 ANR。
    • commit() 在主线程度调用时,若磁盘写操作耗时 >5 s,同样会 ANR;但 commit() 次数一般远少于 apply(),线上 ANR 占比反而低。
  4. 一致性

    • 二者均先原子更新内存,再异步/同步写磁盘;进程内读取始终强一致,跨进程通过 FileObserver 触发 reload,仍无法做到“实时一致”。
  5. 国内 ROM 特殊点

    • 部分国产 ROM 在应用退到后台 5 ~ 30 s 后冻结进程线程,导致 apply() 的线程池任务被挂起,再次冷启动时可能丢失“上次退出前最后一次 apply()”的修改;commit() 因同步写完,反而不会丢。
  6. 官方演进

    • Android 9 引入 EncryptedSharedPreferences,内部仍复用 commit/apply 模型;
    • Jetpack DataStore 基于 Kotlin Coroutines 和 Protobuf,彻底替换 SP,官方已明确为“SP 替代方案”。

答案

  1. 区别
    ① 线程:commit() 在当前线程同步写磁盘,apply() 异步写盘立即返回;
    ② 返回值:commit() 返回 boolean 表示写盘成败,apply() 无返回;
    ③ 卡顿:主线程 commit() 会阻塞,容易 ANR;apply() 把任务放到后台线程,正常情况下不卡主线程;
    ④ 崩溃一致性:二者内存层立即生效,磁盘层 commit() 保证写完返回,apply() 不保证时间点。

  2. 推荐使用 apply() 的原因

    • UI 线程不阻塞,帧率稳定,消除因 SP 写盘导致的卡顿与 ANR;
    • 实际测试中,apply() 的写盘延迟中位数 <10 ms,99% 场景在 100 ms 内完成,对业务无感知;
    • 即使 Activity 生命周期末尾系统会等待写盘,只要控制单次写入数据量(<20 KB)并避免集中批量写入,就能把 ANR 率控制在万分之一以下;
    • 对于需要确保“成功或失败”立即感知的场景(如支付标记),可降级为 commit(),但务必放子线程。

一句话总结:apply() 把“同步阻塞”变成“异步延迟”,在 99% 场景下用微不足道的延迟换来主线程零卡顿,因此官方与一线大厂均强制使用 apply()。

拓展思考

  1. 线上 ANR 日志中出现 “QueuedWork.waitToFinish” 应如何定位?
    打开 trace 文件,查看主线程阻塞栈是否卡在 QueuedWork.awaitCommit,再结合 SP 文件大小、写入次数,利用 BlockCanary 或 Matrix 插件定位是哪个业务在 onPause 前高频 apply()。

  2. 超大 SP 文件拆分策略
    国内主流 App 把 SP 按“账号维度+模块维度”拆成多文件,单文件不超过 50 KB;对读写频繁、但无需持久化的缓存,改用 MMKV 或 DataStore,彻底绕开 SP 的 XML 全量重写缺陷。

  3. 多进程场景
    SP 默认不支持多进程安全,MODE_MULTI_PROCESS 已被废弃。正确姿势是使用 ContentProvider + SP 或直接使用 MMKV / DataStore,后者基于 mmap 实现跨进程并发读写。

  4. 未来迁移
    Google 官方已停止对 SP 的新功能投入,Jetpack DataStore 提供 Proto 与 Preferences 两种实现,支持事务、协程背压、异常回滚,建议新模块直接采用 DataStore,老模块逐步重构,减少后续技术债。