Android系统中的SharedPreference深度解析

86 阅读11分钟

核心概念

  • 是什么? 一个轻量级的键值对(Key-Value)持久化存储机制,用于存储应用的简单配置数据用户偏好设置
  • 存储格式: XML 文件(位于 /data/data/<package_name>/shared_prefs/ 目录下)。
  • 数据类型: 支持基本类型:boolean, int, float, long, String 以及 Set<String> (API 11+)。不支持复杂对象
  • 接口: 通过 Context.getSharedPreferences(String name, int mode) 获取实例。主要操作接口是 SharedPreferences.Editor

深度分析维度:

  1. 架构与设计哲学:

    • “轻量级”的代价: 设计初衷是极其简单易用,适合存储少量、结构简单的数据(如用户设置开关、上次登录用户名、简单的计数器)。这种简单性牺牲了:
      • 类型安全: 所有值都以 Object 形式存储在内存 Map 中,类型转换由调用者负责,易出错。
      • 强一致性模型: 提供了 commit() (同步) 和 apply() (异步) 两种写入方式,但缺乏事务性。
      • 查询能力: 只有基于 Key 的读取,没有范围查询、排序等。
    • 进程内单例: 每个 SharedPreferences 文件在单个进程内通常由一个单例对象管理(通过静态 Map 缓存)。这是性能和内存优化的关键,但也带来了线程安全问题。
    • 全量加载与内存驻留: 这是最关键的设计决策,也是主要性能瓶颈的来源。 当第一次获取某个 SharedPreferences 实例时,系统会同步地解析整个对应的 XML 文件,将其内容完全加载到一个内存中的 Map<String, Object> 对象里。后续所有的 get 操作都直接访问这个内存 Map,速度极快。所有的 put 操作(通过 Editor)也是先修改这个内存 Map,然后通过 commit/apply 将整个 Map 序列化回 XML 文件。这意味着:
      • 初始化/首次加载慢: 文件越大,首次获取实例阻塞主线程的风险越高(可能导致 ANR)。
      • 写入成本高: 即使只修改一个键值对,也需要将整个内存 Map 序列化成 XML 并写入文件。文件越大,写入越慢。
      • 内存占用: 整个数据集常驻内存。如果存储了大量数据(这是误用!),会浪费宝贵内存。
    • “最终一致性”模型: 使用 apply() 时,内存 Map 立即更新,但文件写入是异步的(通过 QueuedWork 机制排队到后台线程)。这提供了很好的响应性,但带来了风险:
      • 写入丢失风险: 如果在 apply() 后异步写入完成前进程被杀死(特别是后台进程),修改可能丢失。
      • 状态不一致窗口期: 内存状态已更新,但文件尚未更新。如果此时另一个组件读取文件(如备份工具),看到的是旧数据。commit() 通过同步写入避免了这两个问题,但阻塞了调用线程。
  2. 并发与线程安全:

    • 内存 Map 的可见性: SharedPreferences 类内部使用一个 finalMap 字段 (mMap) 存储数据。这个 Map 在加载文件后就不会被替换(final 保证引用不变),但其内容会被 Editor 修改。Editor 的实现类 (EditorImpl) 使用 synchronized 块来保护对 mMap 的修改操作 (put, remove, clear) 和提交过程 (commitToMemory())。这保证了单个 Editor 实例内的操作是原子的
    • 经典的 get-put 问题:
      int counter = sp.getInt("counter", 0);
      sp.edit().putInt("counter", counter + 1).apply(); // 线程不安全!
      
      即使 getput 各自内部是线程安全的,但整个 get-increment-put 操作序列不是原子的。如果多个线程同时执行此代码,会导致更新丢失。SharedPreferences 本身不提供任何事务或原子更新机制。 解决方案:
      • 外部同步: 使用 synchronized 块或其它锁机制包裹整个操作序列。
      • Atomic 类 (仅限数值): 结合 commit() 或小心处理 apply() 的丢失问题。
      • 避免需要原子更新的场景 / 使用更适合的存储。
    • apply() 的异步提交队列 (QueuedWork): 所有通过 apply() 发起的写入请求会添加到一个全局的 QueuedWork 队列中,由后台线程顺序执行。这保证了多个 apply() 的写入顺序性,但 QueuedWork 本身在添加/移除任务时也有同步开销。
  3. 性能瓶颈深度剖析:

    • 文件 I/O:
      • 全量读写: 如前所述,无论修改大小,每次提交(commit/apply)都涉及序列化整个内存 Map 为 XML 字符串,然后写入文件(通常还会先写到一个临时文件 .bak,再原子替换原文件)。文件大小是 O(N) 操作成本的决定因素。
      • XML 解析/序列化: 虽然 Android 的 XmlUtils 做了优化,但解析和生成 XML 相比二进制格式(如 Protocol Buffers, FlatBuffers)或简单的自定义格式,开销仍然显著,尤其是字符串处理。
    • 主线程阻塞风险:
      • 首次加载:getSharedPreferences() 时,如果文件存在且未被加载,会触发同步的文件解析和内存 Map 构建。大文件在主线程执行此操作是 ANR 的经典诱因。
      • commit() 同步文件写入,在主线程调用必然阻塞。
    • 内存压力: 整个数据集常驻内存。存储大量数据(如缓存、复杂配置)是严重的资源浪费和性能反模式。
  4. 可靠性、数据一致性与崩溃恢复:

    • 写入机制: 典型的写入流程:
      1. 将新的 XML 内容写入一个临时文件(通常是原文件名加 .bak 后缀)。
      2. 删除旧的 .bak 文件(如果存在)。
      3. 将新的 XML 文件原子重命名为目标文件名。
      4. 删除旧的 XML 文件。 这个流程旨在保证在写入过程中发生崩溃(如断电)时,要么旧文件完整,要么 .bak 文件存在可以用于恢复上次成功提交的状态。SharedPreferences 在加载时会优先检查 .bak 文件,如果存在且目标文件损坏或不存在,会用 .bak 文件恢复。
    • apply() 的丢失风险: 如前所述,是 apply() 异步特性带来的固有风险。
    • 进程被杀的影响: 内存 Map 会丢失。下次加载时会从文件重新读取。如果 apply() 的写入尚未完成,那次修改就丢失了。commit() 则保证写入文件后才返回。
  5. 安全性与访问模式 (MODE_* 的演变与弃用):

    • 历史模式 (MODE_WORLD_READABLE, MODE_WORLD_WRITEABLE): 极度危险!允许其他应用直接读写文件。已弃用且强烈禁止使用。 违反了应用沙盒原则。
    • MODE_PRIVATE (唯一推荐模式): 只有创建该文件的应用本身可以访问。这是最安全也是唯一应该使用的模式。
    • MODE_MULTI_PROCESS (已弃用): 设计初衷是让多进程应用能“看到”彼此的最新修改。实现方式非常粗糙:每次 getSharedPreferences() 时都会检查文件修改时间戳,如果自上次加载后被修改过,就重新加载整个文件。这带来了巨大的性能开销(频繁的 I/O 和解析)和严重的线程安全问题(内存 Map 可能被突然替换,导致正在读取的线程看到不一致状态)。绝对不要使用! 多进程共享数据请使用 ContentProvider, Broadcast, Messenger, AIDL 或进程安全的数据库/Socket。
  6. 现代替代方案与演进 (PreferenceDataStore, Jetpack DataStore):

    • PreferenceDataStore (API 26+): 一个抽象接口。允许开发者自定义 SharedPreferences 的存储后端(例如存到数据库、加密存储、网络)。主要用于替换 PreferenceFragment 使用的默认 SharedPreferences 后端,提供了更大的灵活性,但底层如果还是基于 XML/全量加载,则性能瓶颈仍在。
    • Jetpack DataStore: Google 官方推荐的现代替代品。 有两种实现:
      • Preferences DataStore: 类似 SharedPreferences 的键值对存储,但:
        • 基于 Protocol Buffers (二进制): 序列化/反序列化更快,文件更小。
        • 异步 API (Coroutines Flow): 强制异步操作,避免主线程阻塞。
        • 事务性支持: 提供 dataStore.edit { preferences -> ... } 块,块内的操作是原子的。
        • 无全量加载问题? 严格来说,DataStore 在启动时也会读取整个文件到内存(反序列化成 Protobuf 对象)。但是:
          • 它使用了一个初始化的异步流 (dataStore.data),不会在主线程同步阻塞加载。
          • Protobuf 的解析和内存表示通常比 XML Map 更高效。
          • 写入时,DataStore 会进行增量合并(在内存中的 Protobuf 对象上修改),然后异步序列化整个 Protobuf 对象写入文件。所以它本质上还是全量写入。 对于非常大的数据集,写入性能瓶颈依然存在(虽然比 XML 好)。DataStore 的真正优势在于其强类型、异步安全、事务支持和现代化的 API。
      • Proto DataStore: 使用 Protobuf 定义强类型的数据结构 Schema。提供最强的类型安全和结构化能力,适合存储比简单键值对更复杂的配置数据。同样具有异步、事务特性。
  7. 最佳实践与适用场景:

    • 适用场景 (严格限制):
      • 存储非常少量(几十条以内)的简单类型数据。
      • 存储用户偏好设置(主题、字体大小、通知开关等)。
      • 存储非关键的、可以容忍偶尔丢失(如 apply() 丢失)的应用状态。
      • 对性能要求不苛刻的场景。
    • 最佳实践:
      • 只存小数据! 这是铁律。避免存储任何可能增长的大数据(列表、JSON 字符串、缓存)。
      • 始终使用 MODE_PRIVATE
      • 优先使用 apply() 除非你需要立即确认写入成功(如在下个 Activity 马上要用),否则都用 apply() 避免主线程阻塞。
      • 警惕首次加载 ANR: 不要在应用启动的主线程路径上首次加载非常大的 SharedPreferences 文件。考虑懒加载或异步预加载(但需处理异步读取)。
      • 处理并发更新: 如果需要原子更新(如计数器),必须自行加锁。
      • 考虑迁移: 如果数据量或复杂性开始增长,或需要更好的安全性/一致性,尽早迁移到更合适的存储方案(如 Room, DataStore)。
    • 禁止/不适用场景:
      • 存储结构化数据(用 SQLite/Room)。
      • 存储大型数据或二进制数据(用文件系统或 Blob in Room)。
      • 需要高效查询、排序、聚合的数据(用 SQLite/Room)。
      • 需要严格事务保证的数据(用 SQLite/Room/DataStore 事务)。
      • 多进程共享数据(用 ContentProvider, AIDL 等)。
      • 存储敏感信息(密码、令牌)不加密。即使 MODE_PRIVATE,设备 Root 后文件可读。敏感信息应使用 AndroidKeyStore 加密后存储。
  8. 源码级洞察 (Android Framework):

    • 核心类: SharedPreferencesImpl (真正的实现类,通过 ContextImpl 创建)。
    • 加载过程 (loadFromDisk()): 在构造器或 startLoadFromDisk() 中启动。同步执行:加锁 -> 检查 .bak 文件 -> 解析 XML (XmlUtils.readMapXml()) -> 填充内存 Map -> 通知等待线程。解析在 XmlUtils 中通过 XmlPullParser 完成。
    • Editor 实现 (EditorImpl):
      • mModified: 一个 Map 记录本次编辑的修改。
      • commitToMemory(): 同步方法。将 mModified 的修改合并到 SharedPreferencesImpl.mMap 中,并计算需要写入的监听器。返回一个 MemoryCommitResult
    • commit(): 同步调用 commitToMemory() -> 将 MemoryCommitResult 入队到写入队列 -> 同步执行写入任务 (writeToFile()) -> 通知监听器 -> 返回结果。
    • apply(): 同步调用 commitToMemory() -> 将 MemoryCommitResult 封装为 Runnable -> 添加到全局 QueuedWork 队列 (QueuedWork.addFinisher()) -> 立即返回QueuedWork 的后台线程 (QueuedWork.sHandler) 会执行这个 Runnable,其内部调用 writeToFile() -> 完成后移除 Finisher 并通知监听器。
    • writeToFile(): 核心写入方法。加锁 -> 创建临时文件 (.bak) -> 序列化内存 Map 到 XML (XmlUtils.writeMapXml()) 写入临时文件 -> fsync() 强制刷盘 -> 原子重命名临时文件为目标文件 -> 删除旧 .bak (如果有) -> 更新内存状态标记 -> 释放锁。异常时会尝试恢复 .bak
    • QueuedWork: 管理 apply() 异步写入任务的后台队列。还涉及 ActivityThread 处理 handleStopActivity() / handleServiceArgs() 等生命周期时调用 QueuedWork.waitToFinish() 等待所有挂起的 apply() 写入完成,这可能导致 ANR 如果写入队列积压过多(另一个 apply() 的潜在风险)。

总结:SharedPreferences 的深层本质

SharedPreferences 是一个基于全量内存缓存和全量 XML 文件序列化的简单键值存储。其设计核心是用内存空间换取读取速度(所有 get 操作都是内存访问),代价是:

  1. 高昂的初始化成本(加载整个文件到内存)。
  2. 高昂的写入成本(序列化整个内存状态回文件)。
  3. 潜在的内存浪费(整个数据集常驻内存)。
  4. 有限的并发控制(仅保证单 Editor 原子性,无事务)。
  5. apply() 的异步写入丢失风险
  6. 糟糕的多进程支持MODE_MULTI_PROCESS 是性能灾难且不安全)。

结论:

  • 对于其设计目标(存储极少量的简单偏好设置),SharedPreferences 因其极简的 API 仍然可用,但需严格遵守最佳实践(尤其控制数据量)。
  • 对于任何超出最基本偏好的存储需求,或者对性能、可靠性、安全性、多进程有要求的场景,都应毫不犹豫地选择更现代的替代方案,特别是 Jetpack DataStore (Preferences 或 Proto) 或 Room。
  • 理解 SharedPreferences 的底层实现(全量加载/写入)是避免性能陷阱和正确使用它的关键。 其源码 (SharedPreferencesImpl, EditorImpl, QueuedWork) 是理解这些机制的最佳教材。