2. SharedPreferences 原理与源码分析:提交模式、apply 与 commit 区别、跨进程问题、ANR 根因、替代方案

SharedPreferences(SP)是 Android 最早期、使用最广泛的轻量级键值对持久化方案。其核心设计为:以 XML 文件存储于应用私有目录,首次加载时全量读入内存,后续读写操作均基于内存缓存,最终将变更同步回磁盘文件。本章深入分析 SP 的内部架构、两种提交方式的本质差异、跨进程缺陷、引发 ANR 的底层原因以及官方推荐的替代方案。

2.1 核心架构与工作流程

  1. 文件定位:默认存储路径为 /data/data/<package>/shared_prefs/<name>.xml。
  2. 加载阶段:首次调用 getSharedPreferences(name, mode) 时,通过 ContextImpl 获取 SharedPreferencesImpl 实例;构造函数中启动子线程读取 XML 文件,解析为 Map<String, Object> 并存入内存。
  3. 读取阶段:所有 getXxx() 方法直接从内存缓存返回,不涉及磁盘 I/O。
  4. 写入阶段:所有修改必须先通过 Editor 提交,最终调用 apply() 或 commit() 同步到内存缓存,再按不同策略持久化至磁盘。
关键事实:SP 并非进程安全、非线程安全,其内部通过 ReentrantReadWriteLock 保证单进程多线程下的读写互斥。跨进程时完全没有同步机制。
图 2-1 SharedPreferences 完整工作流程(内存缓存 + 磁盘落盘) 应用层调用 ContextImpl SharedPreferencesImpl 内存缓存 Map Editor.edit() commit() / apply() commit() :同步写 apply() :异步写 内存 → 磁盘 XML ⚠ ANR 风险 commit() 在主线程调用 导致磁盘 I/O 阻塞超 5s

2.2 apply 与 commit 核心区别

二者最终都会将修改写入 XML,但同步策略、返回值、ANR 风险存在根本差异。以下是基于 Android 源码(API 30)的对比:

对比维度commit()apply()
调用 APISharedPreferences.Editor.commit()SharedPreferences.Editor.apply()
线程模型同步:当前调用线程直接执行磁盘写异步:将写任务提交到 QueuedWork 单线程池,立即返回
返回值boolean —— 写盘成功返回 true,失败返回 falsevoid —— 不返回结果
错误处理可捕获异常、检查返回值无法直接得知写盘结果,异常仅在内部日志
ANR 风险极高:在主线程调用时,磁盘 I/O 阻塞主线程超过 5s 即触发 ANR较低:异步写不回阻塞主线程;但混合 Service.onStop() / Activity.onPause() 等场景有延迟等待问题
写入原子性原子:写盘结束后其他线程才可见新值写盘时机不确定,但内存缓存立刻更新
推荐场景子线程写入,需要确认写入结果主线程写入,不关心写盘结果,或批量写入

2.3 源码分析:apply 的异步机制与 QueuedWork

apply() 的异步写盘依赖一个名为 QueuedWork 的内部类。核心流程如下:

  1. 调用 apply() 时,先更新内存缓存 Map,保证后续读取立即可见。
  2. 创建一个 Runnable(即 awaitCommit),其中包含 CountDownLatch 用于同步等待。
  3. 将该 Runnable 提交到 QueuedWork.singleThreadExecutor 中执行。
  4. 在 QueuedWork 内部,通过 Handler 在主线程空闲时(IdleHandler)触发写盘,或在 Activity.onPause() 中调用 QueuedWork.waitToFinish() 强制等待所有异步写完成,以保 Activity 切换前数据落盘。
源码路径:frameworks/base/core/java/android/app/SharedPreferencesImpl.java
关键方法:awaitCommit.run() → enqueueDiskWrite() → QueuedWork.addFinisher()
// SharedPreferencesImpl.java(精简)
public final class SharedPreferencesImpl implements SharedPreferences {
    private final Object mLock = new Object();
    private Map<String, Object> mMap;
    private boolean mDiskWritesInFlight = 0;

    public void apply() {
        // 1. 更新内存缓存
        final MemoryCommitResult mcr = commitToMemory();
        // 2. 创建异步写盘 Runnable
        final Runnable awaitCommit = new Runnable() {
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await(); // 等待写盘完成
                } catch (InterruptedException ignored) {}
            }
        };
        // 3. 添加到 QueuedWork 等待执行
        QueuedWork.addFinisher(awaitCommit);
        Runnable writeTask = new Runnable() {
            public void run() {
                try {
                    writeToFile(mcr); // 最终写 XML 文件
                } finally {
                    QueuedWork.removeFinisher(awaitCommit);
                    mcr.writtenToDiskLatch.countDown();
                }
            }
        };
        // 4. 提交到单线程池
        QueuedWork.singleThreadExecutor().execute(writeTask);
    }
}

2.4 跨进程问题:MODE_MULTI_PROCESS 的真相

Android 提供了 Context.MODE_MULTI_PROCESS 标志,用来提示 SP 在文件被其他进程修改后重新加载。但这并不能解决真正意义的跨进程同步,原因如下:

  • 无锁竞争:SP 内部使用 ReentrantReadWriteLock,该锁仅作用于本进程,不同进程之间不会互斥。
  • 文件并发写入:两个进程同时调用 apply() 或 commit() 会同时写入同一个 XML 文件,导致数据覆盖、内容损坏。
  • MODE_MULTI_PROCESS 作用有限:该标记仅在 getSharedPreferences() 时检查一次文件时间戳,若被其他进程修改则重新加载;但 运行时增量写入仍无同步。
  • 官方已废弃:从 API 23 起,MODE_MULTI_PROCESS 已被标记为 @Deprecated,不推荐使用。
经验教训:严格禁止在多进程场景下共用同一 SP 文件。如需跨进程数据共享,应使用 ContentProvider、aidl 或 MMKV 等进程安全方案。

2.5 ANR 根因详细分析

SP 引发的 ANR 主要来源于以下两个路径:

触发场景根因链路阈值
commit() 在主线程commit() → SharedPreferencesImpl.writeToFile() → 文件 I/O 同步阻塞主线程主线程阻塞 > 5s
apply() 在 Activity.onPause() 或 Service.onStop()系统回调中调用 QueuedWork.waitToFinish(),强制等待所有异步写盘完成 → 若磁盘繁忙或文件较大,阻塞主线程同上,部分情况超过 5s
首次加载大文件getSharedPreferences() 时启动子线程加载 XML 到内存,但如果之前未完成加载则后续 getXxx() 将阻塞等待可能触发 ANR(配合 I/O 慢盘)

分析 ANR 日志时常见如下堆栈:

"main" prio=5 tid=1 Blocked
  at android.app.SharedPreferencesImpl.writeToFile(SharedPreferencesImpl.java:705)
  at android.app.SharedPreferencesImpl.access$800(SharedPreferencesImpl.java:53)
  at android.app.SharedPreferencesImpl$2.run(SharedPreferencesImpl.java:140)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
  at java.lang.Thread.run(Thread.java:745)

或:

"main" prio=5 tid=1 Blocked
  at android.app.QueuedWork.waitToFinish(QueuedWork.java:96)
  at android.app.ActivityThread.handlePauseActivity(ActivityThread.java:4219)
  at android.app.ActivityThread.-wrap0(ActivityThread.java:...)

2.6 替代方案对比

鉴于 SP 存在的诸多问题,业界和官方均推荐迁移至更现代、更可靠的持久化方案。以下为核心替代方案对比:

方案进程安全ANR 风险写入性能大数据量支持推荐场景
Jetpack DataStore (Preferences)否(单进程)极低(协程异步)高(Flow + 协程)一般偏好设置、轻量键值、响应式
Jetpack DataStore (Proto)否(单进程)极低高较高(强类型)需要类型安全的复杂配置
MMKV(腾讯开源)是(mmap + 锁)极低(无同步 I/O)极高(mmap 写)高多进程、高性能、大规模键值
SQLite / Room是(事务 + 锁)低(异步可配置)中(事务开销)极高(查询灵活)结构化数据、搜索、迁移
迁移建议:新项目优先选择 DataStore(单进程)或 MMKV(多进程);老项目中逐步将高频写入的 SP 替换为 MMKV,保留低频读取的 SP 以便平稳过渡。

2.7 常见错误与避坑指南

  1. 过度使用 commit():尤其在循环或高频事件(如 onSensorChanged、onDraw)中调用 commit(),会频繁触发磁盘写,导致性能骤降和 ANR。
  2. 期望 apply() 立即落盘:apply() 仅保证内存缓存立即可读,磁盘写入时机不确定。需确认落盘时请改用 commit() 在子线程调用。
  3. 跨进程直接使用 SP:即便设置了 MODE_MULTI_PROCESS,也无法避免并发写入导致的数据损坏,必须换用 MMKV 或 ContentProvider。
  4. 忽略首次加载耗时:在启动流程(如 Application.onCreate)中执行大量 getXxx(),会因等待文件加载而阻塞主线程,建议使用 sp.getAll() 预加载或延迟读取。
  5. 混合 apply 与 commit 导致逻辑混乱:同一 SP 文件中交错调用 apply 和 commit 会导致不一致的时间窗口,推荐统一使用 apply(主线程)或 commit(子线程)。