1. SharedPreferences 痛点拆解:性能瓶颈、类型安全缺失、异步不足、测试困难
从 Android 早期版本起,SharedPreferences(简称 SP)一直是轻量键值存储的首选方案。然而随着应用复杂度攀升、多线程场景增加以及测试要求提升,SP 在设计层面的缺陷逐渐暴露。本章系统拆解 SP 的四大核心痛点,并为后续章节引入 DataStore 提供对比基准。
1.1 痛点全景
| 痛点维度 | 核心表现 | 影响范围 |
|---|---|---|
| 性能瓶颈 | 提交操作直接写磁盘、全量加载、apply() 与 commit() 行为不一致 | UI 掉帧、启动变慢、ANR 风险 |
| 类型安全缺失 | 运行时类型检查、手动 getXxx(key, defaultVal) 易混 | 运行时崩溃、逻辑错误难以发现 |
| 异步不足 | commit() 阻塞主线程、apply() 结果不可观察 | 任务并行困难、数据一致性风险 |
| 测试困难 | 紧耦合 Android 环境、无法轻松注入 Mock | 单元测试覆盖低、回归成本高 |
1.2 性能瓶颈 —— 全量加载与同步写盘
SP 在首次访问时会将整个 XML 文件全量加载到内存的 Map 中。即使只读取一个 key,也会阻塞 IO 线程反序列化全部数据。对于存储上百条配置的应用,第一次 getXxx() 可能耗时 10–50ms,累积在冷启动阶段造成明显的启动延迟。
写操作同样存在隐患:commit() 同步写入磁盘,直接占用调用线程(常为主线程);apply() 虽为异步,但磁盘写入仍在校验队列中排队,且写入错误不抛出异常,开发人员无法感知。
// 传统 SP 使用模式 — 性能隐患示例
val prefs = context.getSharedPreferences("user_config", Context.MODE_PRIVATE)
// 读取:全量加载发生在此处
val name = prefs.getString("name", "default") // 可能阻塞主线程
// 写入:commit() 直接写磁盘
prefs.edit().putString("name", "Alice").commit() // 应在工作线程中执行
1.3 类型安全缺失 —— 运行时崩溃的源头
SP 所有键值对都以 Object 的方式存储,读取时只能通过 getString()、getInt() 等按已知类型手动强转。一旦读写类型不匹配(例如存储 Int 却用 getString 读取),不会出现编译错误,只会在运行时抛出 ClassCastException。
// 写入:value 是 Int 3
prefs.edit().putInt("interval", 3).apply()
// 另一处读取:错误地使用 getString
val interval = prefs.getString("interval", "0") // 运行时崩溃!
此外,SP 不提供结构化的类型映射 —— 一个配置项可以是 Int、String、Boolean 甚至 Set<String>,但无法定义“这个 key 必须是 Int”的契约。大型团队协作中,不同模块对同一 key 的读写类型分歧极易引发生产环境崩溃。
1.4 异步不足 —— 缺乏数据流式响应
SP 的 apply() 虽非阻塞,但调用方无法观察写入结果—— 比如写入完成后需要自动刷新 UI 或同步到远程,必须手动注册 OnSharedPreferenceChangeListener。该监听器在回调中提供 key,但无法直接拿到新旧值,且容易忘记注销导致内存泄漏。
commit() 返回 boolean 表示是否成功,但强制在调用线程执行磁盘写入。对于依赖数据一致性的场景(如保存用户登录状态),开发人员往往被迫使用 commit() 阻塞主线程,造成界面卡顿。
1.5 测试困难 —— 紧耦合与不可控
SP 实例的获取依赖 Context,单元测试中要么使用 Robolectric 模拟 Android 环境,要么手动创建 Context Mock。即使使用 Robolectric,SP 的行为也是真实文件 I/O,测试用例之间容易相互污染,需要额外清理。此外,无法轻松替换 SP 的实现为内存模拟,导致测试慢、脆、难以并行。
// 测试中模拟 Context 的典型样板 — 冗长且脆弱
@RunWith(RobolectricTestRunner::class)
class UserPrefsTest {
@Test
fun testReadName() {
val context = ApplicationProvider.getApplicationContext<Context>()
val prefs = context.getSharedPreferences("test", Context.MODE_PRIVATE)
prefs.edit().putString("name", "Bob").apply()
assertEquals("Bob", prefs.getString("name", ""))
// 测试后需手动清理文件
prefs.edit().clear().apply()
}
}