SharedPreferences

136 阅读6分钟

一、基础篇

1. SharedPreferences 是什么?

  • Android 提供的轻量级键值对存储方案,适合存储少量简单数据(基本类型、StringSet<String>)。
  • 数据保存在 /data/data/<package>/shared_prefs/xxx.xml 中,底层是 XML 文件,解析/写入用 XmlUtils
  • 内部维护一个 Map<String, Object> 作为缓存(mMap),读写都先走内存 Map。

2. 线程安全性

  • 读取:线程安全(mMapvolatile 保证可见性)。

  • 写入

    • commit():同步写,整个过程加锁,线程安全。
    • apply():更新内存 Map 后异步落盘,可能存在多线程覆盖风险(最后一次提交覆盖前面的)。

3. commit() vs apply()

方法磁盘写入方式返回值是否阻塞调用线程
commit()同步(当前线程直接写磁盘)boolean(是否成功)会阻塞(主线程调用可能卡 UI)
apply()异步(单线程后台顺序执行)void不阻塞(但数据可能未及时落盘)

4. 为什么适合小数据?

  • 每次写入都会全量重写 XML 文件(O(n) + I/O)。
  • 数据大 → 解析和写入都会变慢,可能引发卡顿/ANR。

5. 多进程问题

  • 默认不支持多进程数据同步(每个进程有独立缓存 Map)。

  • 解决方案:

    • ContentProvider/AIDL/IPC 同步访问。
    • 使用 MMKV(mmap 支持多进程)或 Jetpack DataStore。

6. 优化建议

  1. 合并写入:一次性 apply() 批量写,减少磁盘 I/O。
  2. 拆分文件:按数据类型/业务拆分,避免大文件。
  3. 延迟写入:非关键数据延迟到空闲时落盘。
  4. 预估替代方案:频繁/大数据用 SQLite/Room/MMKV。

二、深挖篇(面试高频追问)

1) commit 和 apply 底层实现区别

  • 相同

    • 都先更新内存 Map。
    • 磁盘写入用 AtomicFile(写临时文件再 rename 原子替换)。
  • 不同

    • commit():同步写入,返回写入结果。
    • apply():异步写入(QueuedWork 单线程),无返回值。

2) 为什么更推荐 apply?风险是什么?

  • 推荐理由

    • 不阻塞调用线程,主线程写入不卡 UI。
    • 内存立即更新,后续读取能拿到新值。
  • 风险

    • 落盘前进程被杀 → 数据丢失。
    • 多线程并发 apply() 可能有覆盖。
    • 多进程场景不做同步。

3) 多进程如何保持一致性?

  • MODE_MULTI_PROCESS 已废弃。

  • 可选方案:

    • 统一走一个进程 + IPC。
    • 用支持多进程的存储方案(MMKV/DataStore)。
    • 自行加文件锁并在写后主动 reload(复杂且性能差)。

4) 为什么频繁写会卡顿?

  • 每次写是全量序列化 + 磁盘 I/O + fsync
  • 主线程 commit() → 明显卡 UI。
  • 异步 apply() 频繁写 → 队列积压、刷盘压力大。

5) 为什么不适合存大量数据?

  • 加载慢:首次 getSP 需解析全文件进内存。
  • 写入慢:全量重写 XML。
  • 类型限制:不支持复杂结构。

6) getStringSet 为什么要复制?

  • 返回的可能是内部引用,直接改会:

    • 改坏缓存 Map。
    • 导致并发修改异常。
  • 正确做法:

    java
    复制编辑
    Set<String> copy = new HashSet<>(sp.getStringSet("key", Collections.emptySet()));
    copy.add("newValue");
    sp.edit().putStringSet("key", copy).apply();
    

7) 能否保证写入顺序?

  • 单进程

    • commit:顺序一致。
    • apply:一般保持队列顺序,但落盘时间不确定。
  • 多进程:不保证顺序与可见性。


8) 初始化会阻塞 UI 吗?

  • 可能会:首次加载时从磁盘解析 XML → 大文件或存储慢时卡顿。

  • 优化

    • 冷启动尽量延迟访问 SP。
    • 在后台线程预加载。
    • 大数据迁移到更高效存储方案。

9) SharedPreferences 是线程安全的吗?

结论

  • :线程安全(内存中有 volatile Map 缓存,读操作直接从内存取)。
  • SharedPreferences.Editor 的写是串行到磁盘的,但并发多次 apply() 可能出现最后写覆盖前面写的“逻辑冲突”。单进程下不会写坏文件(用 AtomicFile 原子替换)。

原理

  • SP 内部持有一个 Map 作为缓存;commit()/apply() 都是先合并到内存 Map,再写磁盘。
  • 磁盘写使用临时文件 + rename,保证掉电时文件不损坏。

实战

  • 同一份配置建议集中在一个线程更新;避免多处同时改同一个 key。
  • 多进程不保证一致(见 Q107 注意事项)。

10) applycommit 的区别?commit 一定会在主线程操作吗?

结论

  • commit()同步写磁盘,返回 boolean在哪个线程调用就在哪个线程写,所以不一定是主线程——但不要在主线程调用,容易卡顿。
  • apply()异步写磁盘(排队到单线程执行),不阻塞当前线程,无返回值;内存 Map 立即可见。

选择建议

  • 绝大多数情况用 apply()(不卡 UI)。
  • 关键数据(必须确保已落盘),在后台线程commit()

示例(后台线程安全落盘):

kotlin
复制编辑
withContext(Dispatchers.IO) {
    val ok = sp.edit().putBoolean("onboarded", true).commit()
}

11) SharedPreferences 是如何初始化的,它会阻塞主线程吗?

结论

  • 第一次访问某个 xxx.xml 时,需要从磁盘解析 XML → 构建内存 Map,这一步可能阻塞调用线程;之后再取同名 SP 对象走缓存,基本不阻塞。
  • 如果你在冷启动流程主线程立刻大量访问 SP,可能卡顿

原理

  • ContextImpl#getSharedPreferences() 内部有个 缓存 Map(同名返回同一个实例)。第一次 miss 会读文件解析到内存,并放入缓存。

实战

  • 冷启动避免立即读“大 SP 文件”;可以后台预热
kotlin
复制编辑
// App 启动后台线程预热,避免首读阻塞主线程
withContext(Dispatchers.IO) { context.getSharedPreferences("config", MODE_PRIVATE).all }

12) 每次获取 SP 对象真的会很慢吗?

结论

  • 不会。同名 SP 对象在进程内有缓存,二次获取基本是 O(1)。
  • 的是第一次加载磁盘 + 解析 XML;或者 SP 文件很大/频繁写入导致 I/O 压力。

建议

  • 复用同一个 SP 实例引用即可(但不是必须);
  • 拆分超大的 SP 文件,避免一次解析太多键值。

13) 使用注意事项 & 优化点

1) 写入策略

  • 合并写入:把多次 putXXX 放到一次 apply()/commit() 里,减少 I/O。
  • 主线程避免 commit() ;关键标志用后台 commit(),其他用 apply()
  • 高频写的大 key,考虑放到更合适的存储(Room/MMKV/DataStore)。

2) 文件与数据量

  • SP 适合小量配置。文件过大(上百 KB 甚至 MB)会导致首次解析慢、写入是全量重写(慢、耗电)。
  • 按业务拆分 xxx.xml,避免“巨无霸”配置文件。

3) 多进程一致性

  • 默认不支持多进程实时一致;MODE_MULTI_PROCESS 已废弃且不可靠。
  • 多进程场景用 ContentProvider/AIDL 统一代理,或改用 MMKV(多进程模式)/DataStore
  • 跨进程若仍用 SP,需要文件锁 + 主动 reload(复杂且不推荐)。

4) getStringSet() 的坑

  • 返回的集合可能是内部引用,不要直接改。正确做法:拷贝后再写回:
java
复制编辑
Set<String> copy = new HashSet<>(sp.getStringSet("k", Collections.emptySet()));
copy.add("x");
sp.edit().putStringSet("k", copy).apply();

5) 冷启动/卡顿优化

  • 避免在 Application 主线程立刻读大 SP;必要时后台预热或延迟读取。
  • 写入放后台;apply() 会排队异步刷盘。

6) 可靠性

  • apply() 有“小概率未落盘”风险(进程在写入前崩溃/被杀);关键路径调用 sync 行为需用后台 commit()
  • 设备存储空间不足会导致写失败,要有兜底(返回值/容错)。

7) 迁移与替代

  • 频繁/大数据/多进程:优先 MMKV(mmap + 多进程)或 Jetpack DataStore(单进程、类型安全、可观察流)。

三、总结口诀

“读快写慢,小量配置信,单进程优先,跨进程换方案。”
commit 同步落盘可控但可能卡,apply 异步不卡但有丢失风险。大数据别用 SP,多进程别硬用 SP。