2. SharedPreferences 原理与源码分析:提交模式、apply 与 commit 区别、跨进程问题、ANR 根因、替代方案
SharedPreferences(SP)是 Android 最早期、使用最广泛的轻量级键值对持久化方案。其核心设计为:以 XML 文件存储于应用私有目录,首次加载时全量读入内存,后续读写操作均基于内存缓存,最终将变更同步回磁盘文件。本章深入分析 SP 的内部架构、两种提交方式的本质差异、跨进程缺陷、引发 ANR 的底层原因以及官方推荐的替代方案。
2.1 核心架构与工作流程
- 文件定位:默认存储路径为
/data/data/<package>/shared_prefs/<name>.xml。 - 加载阶段:首次调用
getSharedPreferences(name, mode)时,通过ContextImpl获取SharedPreferencesImpl实例;构造函数中启动子线程读取 XML 文件,解析为Map<String, Object>并存入内存。 - 读取阶段:所有 getXxx() 方法直接从内存缓存返回,不涉及磁盘 I/O。
- 写入阶段:所有修改必须先通过
Editor提交,最终调用apply()或commit()同步到内存缓存,再按不同策略持久化至磁盘。
关键事实:SP 并非进程安全、非线程安全,其内部通过
ReentrantReadWriteLock 保证单进程多线程下的读写互斥。跨进程时完全没有同步机制。
2.2 apply 与 commit 核心区别
二者最终都会将修改写入 XML,但同步策略、返回值、ANR 风险存在根本差异。以下是基于 Android 源码(API 30)的对比:
| 对比维度 | commit() | apply() |
|---|---|---|
| 调用 API | SharedPreferences.Editor.commit() | SharedPreferences.Editor.apply() |
| 线程模型 | 同步:当前调用线程直接执行磁盘写 | 异步:将写任务提交到 QueuedWork 单线程池,立即返回 |
| 返回值 | boolean —— 写盘成功返回 true,失败返回 false | void —— 不返回结果 |
| 错误处理 | 可捕获异常、检查返回值 | 无法直接得知写盘结果,异常仅在内部日志 |
| ANR 风险 | 极高:在主线程调用时,磁盘 I/O 阻塞主线程超过 5s 即触发 ANR | 较低:异步写不回阻塞主线程;但混合 Service.onStop() / Activity.onPause() 等场景有延迟等待问题 |
| 写入原子性 | 原子:写盘结束后其他线程才可见新值 | 写盘时机不确定,但内存缓存立刻更新 |
| 推荐场景 | 子线程写入,需要确认写入结果 | 主线程写入,不关心写盘结果,或批量写入 |
2.3 源码分析:apply 的异步机制与 QueuedWork
apply() 的异步写盘依赖一个名为 QueuedWork 的内部类。核心流程如下:
- 调用
apply()时,先更新内存缓存Map,保证后续读取立即可见。 - 创建一个
Runnable(即awaitCommit),其中包含CountDownLatch用于同步等待。 - 将该 Runnable 提交到
QueuedWork.singleThreadExecutor中执行。 - 在
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 常见错误与避坑指南
- 过度使用 commit():尤其在循环或高频事件(如 onSensorChanged、onDraw)中调用 commit(),会频繁触发磁盘写,导致性能骤降和 ANR。
- 期望 apply() 立即落盘:apply() 仅保证内存缓存立即可读,磁盘写入时机不确定。需确认落盘时请改用 commit() 在子线程调用。
- 跨进程直接使用 SP:即便设置了 MODE_MULTI_PROCESS,也无法避免并发写入导致的数据损坏,必须换用 MMKV 或 ContentProvider。
- 忽略首次加载耗时:在启动流程(如 Application.onCreate)中执行大量 getXxx(),会因等待文件加载而阻塞主线程,建议使用
sp.getAll()预加载或延迟读取。 - 混合 apply 与 commit 导致逻辑混乱:同一 SP 文件中交错调用 apply 和 commit 会导致不一致的时间窗口,推荐统一使用 apply(主线程)或 commit(子线程)。