核心概念
- 是什么? 一个轻量级的键值对(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。
深度分析维度:
-
架构与设计哲学:
- “轻量级”的代价: 设计初衷是极其简单易用,适合存储少量、结构简单的数据(如用户设置开关、上次登录用户名、简单的计数器)。这种简单性牺牲了:
- 类型安全: 所有值都以
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()通过同步写入避免了这两个问题,但阻塞了调用线程。
- 写入丢失风险: 如果在
- “轻量级”的代价: 设计初衷是极其简单易用,适合存储少量、结构简单的数据(如用户设置开关、上次登录用户名、简单的计数器)。这种简单性牺牲了:
-
并发与线程安全:
- 内存 Map 的可见性:
SharedPreferences类内部使用一个final的Map字段 (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(); // 线程不安全!get和put各自内部是线程安全的,但整个get-increment-put操作序列不是原子的。如果多个线程同时执行此代码,会导致更新丢失。SharedPreferences本身不提供任何事务或原子更新机制。 解决方案:- 外部同步: 使用
synchronized块或其它锁机制包裹整个操作序列。 Atomic类 (仅限数值): 结合commit()或小心处理apply()的丢失问题。- 避免需要原子更新的场景 / 使用更适合的存储。
- 外部同步: 使用
apply()的异步提交队列 (QueuedWork): 所有通过apply()发起的写入请求会添加到一个全局的QueuedWork队列中,由后台线程顺序执行。这保证了多个apply()的写入顺序性,但QueuedWork本身在添加/移除任务时也有同步开销。
- 内存 Map 的可见性:
-
性能瓶颈深度剖析:
- 文件 I/O:
- 全量读写: 如前所述,无论修改大小,每次提交(
commit/apply)都涉及序列化整个内存 Map 为 XML 字符串,然后写入文件(通常还会先写到一个临时文件.bak,再原子替换原文件)。文件大小是 O(N) 操作成本的决定因素。 - XML 解析/序列化: 虽然 Android 的
XmlUtils做了优化,但解析和生成 XML 相比二进制格式(如 Protocol Buffers, FlatBuffers)或简单的自定义格式,开销仍然显著,尤其是字符串处理。
- 全量读写: 如前所述,无论修改大小,每次提交(
- 主线程阻塞风险:
- 首次加载: 在
getSharedPreferences()时,如果文件存在且未被加载,会触发同步的文件解析和内存 Map 构建。大文件在主线程执行此操作是 ANR 的经典诱因。 commit(): 同步文件写入,在主线程调用必然阻塞。
- 首次加载: 在
- 内存压力: 整个数据集常驻内存。存储大量数据(如缓存、复杂配置)是严重的资源浪费和性能反模式。
- 文件 I/O:
-
可靠性、数据一致性与崩溃恢复:
- 写入机制: 典型的写入流程:
- 将新的 XML 内容写入一个临时文件(通常是原文件名加
.bak后缀)。 - 删除旧的
.bak文件(如果存在)。 - 将新的 XML 文件原子重命名为目标文件名。
- 删除旧的 XML 文件。
这个流程旨在保证在写入过程中发生崩溃(如断电)时,要么旧文件完整,要么
.bak文件存在可以用于恢复上次成功提交的状态。SharedPreferences在加载时会优先检查.bak文件,如果存在且目标文件损坏或不存在,会用.bak文件恢复。
- 将新的 XML 内容写入一个临时文件(通常是原文件名加
apply()的丢失风险: 如前所述,是apply()异步特性带来的固有风险。- 进程被杀的影响: 内存 Map 会丢失。下次加载时会从文件重新读取。如果
apply()的写入尚未完成,那次修改就丢失了。commit()则保证写入文件后才返回。
- 写入机制: 典型的写入流程:
-
安全性与访问模式 (
MODE_*的演变与弃用):- 历史模式 (
MODE_WORLD_READABLE,MODE_WORLD_WRITEABLE): 极度危险!允许其他应用直接读写文件。已弃用且强烈禁止使用。 违反了应用沙盒原则。 MODE_PRIVATE(唯一推荐模式): 只有创建该文件的应用本身可以访问。这是最安全也是唯一应该使用的模式。MODE_MULTI_PROCESS(已弃用): 设计初衷是让多进程应用能“看到”彼此的最新修改。实现方式非常粗糙:每次getSharedPreferences()时都会检查文件修改时间戳,如果自上次加载后被修改过,就重新加载整个文件。这带来了巨大的性能开销(频繁的 I/O 和解析)和严重的线程安全问题(内存 Map 可能被突然替换,导致正在读取的线程看到不一致状态)。绝对不要使用! 多进程共享数据请使用ContentProvider,Broadcast,Messenger,AIDL或进程安全的数据库/Socket。
- 历史模式 (
-
现代替代方案与演进 (
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。提供最强的类型安全和结构化能力,适合存储比简单键值对更复杂的配置数据。同样具有异步、事务特性。
- Preferences DataStore: 类似
-
最佳实践与适用场景:
- 适用场景 (严格限制):
- 存储非常少量(几十条以内)的简单类型数据。
- 存储用户偏好设置(主题、字体大小、通知开关等)。
- 存储非关键的、可以容忍偶尔丢失(如
apply()丢失)的应用状态。 - 对性能要求不苛刻的场景。
- 最佳实践:
- 只存小数据! 这是铁律。避免存储任何可能增长的大数据(列表、JSON 字符串、缓存)。
- 始终使用
MODE_PRIVATE。 - 优先使用
apply(): 除非你需要立即确认写入成功(如在下个 Activity 马上要用),否则都用apply()避免主线程阻塞。 - 警惕首次加载 ANR: 不要在应用启动的主线程路径上首次加载非常大的
SharedPreferences文件。考虑懒加载或异步预加载(但需处理异步读取)。 - 处理并发更新: 如果需要原子更新(如计数器),必须自行加锁。
- 考虑迁移: 如果数据量或复杂性开始增长,或需要更好的安全性/一致性,尽早迁移到更合适的存储方案(如 Room, DataStore)。
- 禁止/不适用场景:
- 存储结构化数据(用 SQLite/Room)。
- 存储大型数据或二进制数据(用文件系统或
Blobin Room)。 - 需要高效查询、排序、聚合的数据(用 SQLite/Room)。
- 需要严格事务保证的数据(用 SQLite/Room/DataStore 事务)。
- 多进程共享数据(用
ContentProvider,AIDL等)。 - 存储敏感信息(密码、令牌)不加密。即使
MODE_PRIVATE,设备 Root 后文件可读。敏感信息应使用AndroidKeyStore加密后存储。
- 适用场景 (严格限制):
-
源码级洞察 (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 操作都是内存访问),代价是:
- 高昂的初始化成本(加载整个文件到内存)。
- 高昂的写入成本(序列化整个内存状态回文件)。
- 潜在的内存浪费(整个数据集常驻内存)。
- 有限的并发控制(仅保证单
Editor原子性,无事务)。 apply()的异步写入丢失风险。- 糟糕的多进程支持(
MODE_MULTI_PROCESS是性能灾难且不安全)。
结论:
- 对于其设计目标(存储极少量的简单偏好设置),
SharedPreferences因其极简的 API 仍然可用,但需严格遵守最佳实践(尤其控制数据量)。 - 对于任何超出最基本偏好的存储需求,或者对性能、可靠性、安全性、多进程有要求的场景,都应毫不犹豫地选择更现代的替代方案,特别是 Jetpack DataStore (Preferences 或 Proto) 或 Room。
- 理解
SharedPreferences的底层实现(全量加载/写入)是避免性能陷阱和正确使用它的关键。 其源码 (SharedPreferencesImpl,EditorImpl,QueuedWork) 是理解这些机制的最佳教材。