第3章 SharedPreferences同步读写陷阱
3.1 原理:SharedPreferences的文件I/O本质
SharedPreferences(SP)底层的存储介质是XML文件。无论是读操作还是写操作,最终都会落到
磁盘文件I/O
上。每次调用getSharedPreferences() 时,SP实例会尝试加载并解析整个XML文件到内存中;每次调用 commit() 或 apply() 时,都会将全部数据序列化后写回磁盘。这种全量加载与全量写入的机制,决定了它在主线程高频调用时存在天然的ANR风险。
核心风险认知:SP的读写操作会持有一个内部锁。即使使用
apply()异步写入,如果写入任务在主线程之外的后台队列积压,当Activity或Service进入后台(例如按下Home键)时,系统会强制等待所有SP写入完成,此时主线程依然可能被阻塞导致ANR。
3.2 commit vs apply:差异与隐藏的ANR风险
| 对比维度 | commit() | apply() |
|---|---|---|
| 写入机制 | 同步写盘,直接写入文件系统。 | 异步写盘,先写入内存,再提交到QueuedWork队列。 |
| 返回值 | 返回 boolean,表示写入是否成功。 | 无返回值 (void)。 |
| 主线程阻塞 | 阻塞主线程,直到写入完成。 | 不阻塞主线程。 |
| ANR风险 | 高(直接阻塞,尤其在写入大文件时)。 | 中高(onPause()/onStop() 会等待QueuedWork完成)。 |
| 高频写入影响 | 每次调用都触发一次磁盘I/O,卡顿明显。 | 大量日志积压在队列中,导致生命周期回调超时ANR。 |
陷阱解析:很多开发者认为apply()是安全的,于是不加节制的在主线程高频调用apply()。虽然每个apply()本身不卡主线程,但大量任务堆积在QueuedWork中。当用户按下Home键,onPause()需要等待QueuedWork全部刷入磁盘,此时主线程被冻结,形成隐性ANR。
3.3 避免在主线程调用 getSharedPreferences
除了写入陷阱,getSharedPreferences()在首次调用时也会同步读取整个XML文件。如果该文件较大(例如超过100KB),或者存储在低速闪存上,主线程可能会被阻塞几十甚至几百毫秒,直接导致应用启动期ANR或界面跳转卡顿。
优化策略:
- 延迟初始化:不要在
Activity.onCreate()或Application.onCreate()的主线程中直接调用getSharedPreferences(),使用ListenableFuture或简单线程预加载。 - 数据分类:仅将小数据(<1KB)存放在SP中;复杂结构化数据请使用 DataStore、MMKV 或 Room。
- 预读取:在
Application的异步初始化阶段提前加载SP,待界面需要时直接使用缓存。
3.4 实战案例:高频写入导致ANR的复现与修复
复现代码(高风险写法):
// 模拟在主线程高频写入SharedPreferences
SharedPreferences sp = getSharedPreferences("cache", MODE_PRIVATE);
for (int i = 0; i < 500; i++) {
sp.edit().putString("key_" + i, String.valueOf(i)).apply();
// 每个apply都压入QueuedWork队列
}
// 用户立即按下Home键 -> onPause()等待队列刷完 -> ANR
修复代码(批量提交 + 避免主线程积压):
// 使用Editor批量提交,仅触发一次apply
SharedPreferences sp = getSharedPreferences("cache", MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit();
for (int i = 0; i < 500; i++) {
editor.putString("key_" + i, String.valueOf(i));
}
editor.apply(); // 只有一次提交,队列不会积压
关键原则:高频写入场景下,务必使用
Editor的批量化操作,将多次put合并为一次apply提交,从根本上避免QueuedWork队列堆积。
3.5 性能与稳定性建议
- 严格控制文件大小:SP文件应保持在 1KB以下,超过此阈值请选择DataStore、MMKV或数据库。
- 避免主线程首次加载:通过
ExecutorService或HandlerThread异步预读SP,保证主线程不被文件I/O阻塞。 - 用apply替代commit:绝大多数场景使用
apply(),且注意配合Editor批量调用。 - 生命周期意识:在
onPause()或onStop()中不要执行任何SP写入操作,以防止生命周期阻塞。