⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。
当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。
本文是 Android 开源库系列的第 1 篇文章,完整文章目录请移步到文章末尾~
前言
大家好,我是小彭。
SharedPreferences 是 Android 平台上轻量级的 K-V 存储框架,亦是初代 K-V 存储框架,至今被很多应用沿用。
有的小伙伴会说,SharedPreferences 是旧时代的产物,现在已经有 DataStore 或 MMKV 等新时代的 K-V 框架,没有学习意义。但我认为,虽然 SharedPreference 这个方案已经过时,但是并不意味着 SharedPreference 中使用的技术过时。做技术要知其然,更要知其所以然,而不是人云亦云,如果要你解释为什么 SharedPreferences 会过时,你能说到什么程度?
不知道你最近有没有读到一本在技术圈非常火爆的一本新书 《安卓传奇 · Android 缔造团队回忆录》,其中就讲了很多 Android 架构演进中设计者的思考。如果你平时也有从设计者的角度思考过 “为什么”,那么很多内容会觉得想到一块去了,反之就会觉得无感。
—— 图片引用自电商平台
今天,我们就来分析 SharedPreference 源码,在过程中依然可以学习到非常丰富的设计技巧。在后续的文章中,我们会继续分析其他 K-V 存储框架,请关注。
本文源码分析基于 Android 10(API 31),并关联分析部分 Android 7.1(API 25)。
思维导图:
- 实现 K-V 框架应该思考什么问题?
在阅读 SharedPreference 的源码之前,我们先思考一个 K-V 框架应该考虑哪些问题?
- 问题 1 - 线程安全: 由于程序一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;
- 问题 2 - 内存缓存: 由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;
- 问题 3 - 事务: 由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;
- 问题 4 - 事务串行化: 由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;
- 问题 5 - 异步写回: 由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;
- 问题 6 - 增量更新: 由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;
- 问题 7 - 变更回调: 由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;
- 问题 8 - 多进程: 由于程序可能有多进程需求,那么框架如何保证多进程数据同步?
- 问题 9 - 可用性: 由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;
- 问题 10 - 高效性: 性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;
- 问题 11 - 安全性: 如果程序需要存储敏感数据,如何保证数据完整性和保密性;
- 问题 12 - 数据迁移: 如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;
- 问题 13 - 研发体验: 是否模板代码冗长,是否容易出错。
提出这么多问题后:
你觉得学习 SharedPreferences 有没有价值呢?
如果让你自己写一个 K-V 框架,你会如何解决这些问题呢?
新时代的 MMKV 和 DataStore 框架是否良好处理了这些问题?
- 从 Sample 开始
SharedPreferences 采用 XML 文件格式持久化键值对数据,文件的存储位置位于应用沙盒的内部存储 /data/data/<packageName>/shared_prefs/
位置,每个 XML 文件对应于一个 SharedPreferences 对象。
在 Activity、Context 和 PreferenceManager 中都存在获取 SharedPreferences 对象的 API,它们最终都会走到 ContextImpl 中:
ContextImpl.java
1 | java复制代码class ContextImpl extends Context { |
示例代码
1 | java复制代码SharedPreferences sp = getSharedPreferences("prefs", Context.MODE_PRIVATE); |
prefs.xml 文件内容
1 | xml复制代码<?xml version='1.0' encoding='utf-8' standalone='yes' ?> |
- SharedPreferences 的内存缓存
由于磁盘 IO 操作是耗时操作,如果每一次访问 SharedPreferences 都执行一次 IO 操作就显得没有必要,所以 SharedPreferences 会在业务层和磁盘之间增加一层内存缓存。在 ContextImpl 类中,不仅支持获取 SharedPreferencesImpl 对象,还负责支持 SharedPreferencesImpl 对象的内存缓存。
ContextImpl 中的内存缓存逻辑是相对简单的:
- 步骤1:通过文件名 name 映射文件对应的 File 对象;
- 步骤 2:通过 File 对象映射文件对应的 SharedPreferencesImpl 对象。
两个映射表:
- mSharedPrefsPaths: 缓存 “文件名 to 文件对象” 的映射;
- sSharedPrefsCache: 这是一个二级映射表,第一级是包名到 Map 的映射,第二级是缓存 “文件对象 to SP 对象” 的映射。每个 XML 文件在内存中只会关联一个全局唯一的 SharedPreferencesImpl 对象
继续分析发现: 虽然 ContextImpl 实现了 SharedPreferencesImpl 对象的缓存复用,但没有实现缓存淘汰,也没有提供主动移除缓存的 API。因此,在 APP 运行过程中,随着访问的业务范围越来越多,这部分 SharedPreferences 内存缓存的空间也会逐渐膨胀。这是一个需要注意的问题。
在 getSharedPreferences() 中还有 MODE_MULTI_PROCESS 标记位的处理:
如果是首次获取 SharedPreferencesImpl 对象会直接读取磁盘文件,如果是二次获取 SharedPreferences 对象会复用内存缓存。但如果使用了 MODE_MULTI_PROCESS 多进程模式,则在返回前会检查磁盘文件相对于最后一次内存修改是否变化,如果变化则说明被其他进程修改,需要重新读取磁盘文件,以实现多进程下的 “数据同步”。
但是这种同步是非常弱的,因为每个进程本身对磁盘文件的写回是非实时的,再加上如果业务层缓存了 getSharedPreferences(…) 返回的对象,更感知不到最新的变化。所以严格来说,SharedPreferences 是不支持多进程的,官方也明确表示不要将 SharedPreferences 用于多进程环境。
SharedPreferences 内存缓存示意图
流程图
ContextImpl.java
1 | java复制代码class ContextImpl extends Context { |
文件对象 to SP 对象:
ContextImpl.java
1 | java复制代码class ContextImpl extends Context { |
- 读取和解析磁盘文件
在创建 SharedPreferencesImpl 对象时,构造函数会启动一个子线程去读取本地磁盘文件,一次性将文件中所有的 XML 数据转化为 Map 散列表。
需要注意的是: 如果在执行 loadFromDisk()
解析文件数据的过程中,其他线程调用 getValue 查询数据,那么就必须等待 mLock
锁直到解析结束。
如果单个 SharedPreferences 的 .xml
文件很大的话,就有可能导致查询数据的线程被长时间被阻塞,甚至导致主线程查询时产生 ANR。这也辅证了 SharedPreferences 只适合保存少量数据,文件过大在解析时会有性能问题。
读取示意图
SharedPreferencesImpl.java
1 | java复制代码// 目标文件 |
查询数据可能会阻塞等待:
SharedPreferencesImpl.java
1 | java复制代码public String getString(String key, @Nullable String defValue) { |
- SharedPreferences 的事务机制
是的,SharedPreferences 也有事务操作。
虽然 ContextImpl 中使用了内存缓存,但是最终数据还是需要执行磁盘 IO 持久化到磁盘文件中。如果每一次 “变更操作” 都对应一次磁盘 “写回操作” 的话,不仅效率低下,而且没有必要。
所以 SharedPreferences 会使用 “事务” 机制,将多次变更操作聚合为一个 “事务”,一次事务最多只会执行一次磁盘写回操作。虽然 SharedPreferences 源码中并没有直接体现出 “Transaction” 之类的命名,但是这就是一种 “事务” 设计,与命名无关。
5.1 MemoryCommitResult 事务对象
SharedPreferences 的事务操作由 Editor 接口实现。
SharedPreferences 对象本身只保留获取数据的 API,而变更数据的 API 全部集成在 Editor 接口中。Editor 中会将所有的 putValue 变更操作记录在 mModified
映射表中,但不会触发任何磁盘写回操作,直到调用 Editor#commit
或 Editor#apply
方法时,才会一次性以事务的方式发起磁盘写回任务。
比较特殊的是:
- 在 remove 方法中:会将
this
指针作为特殊的移除标记位,后续将通过这个 Value 来判断是移除键值对还是修改 / 新增键值对; - 在 clear 方法中:只是将
mClear
标记位置位。
可以看到: 在 Editor#commit 和 Editor#apply 方法中,首先都会调用 Editor#commitToMemery()
收集需要写回磁盘的数据,并封装为一个 MemoryCommitResult 事务对象,随后就是根据这个事务对象的信息写回磁盘。
SharedPreferencesImpl.java
1 | java复制代码final class SharedPreferencesImpl implements SharedPreferences { |
MemoryCommitResult 事务对象核心的字段只有 2 个:
- memoryStateGeneration: 当前的内存版本(在
writeToFile()
中会过滤低于最新的内存版本的无效事务); - mapToWriteToDisk: 最终全量覆盖写回磁盘的数据。
SharedPreferencesImpl.java
1 | java复制代码private static class MemoryCommitResult { |
5.2 创建 MemoryCommitResult 事务对象
下面,我们先来分析创建 Editor#commitToMemery() 中 MemoryCommitResult 事务对象的步骤,核心步骤分为 3 步:
- 步骤 1 - 准备映射表
首先,检查 SharedPreferencesImpl#mDiskWritesInFlight
变量,如果 mDiskWritesInFlight == 0 则说明不存在并发写回的事务,那么 mapToWriteToDisk 就只会直接指向 SharedPreferencesImpl 中的 mMap
映射表。如果存在并发写回,则会深拷贝一个新的映射表。
mDiskWritesInFlight
变量是记录进行中的写回事务数量记录,每执行一次 commitToMemory() 创建事务对象时,就会将 mDiskWritesInFlight 变量会自增 1,并在写回事务结束后 mDiskWritesInFlight 变量会自减 1。
- 步骤 2 - 合并变更记录
其次,遍历 mModified
映射表将所有的变更记录(新增、修改或删除)合并到 mapToWriteToDisk 中(此时,Editor 中的数据已经同步到内存缓存中)。
这一步中的关键点是:如果发生有效修改,则会将 SharedPreferencesImpl 对象中的 mCurrentMemoryStateGeneration
最新内存版本自增 1,比最新内存版本小的事务会被视为无效事务。
- 步骤 3 - 创建事务对象
最后,使用 mapToWriteToDisk 和 mCurrentMemoryStateGeneration 创建 MemoryCommitResult 事务对象。
事务示意图
SharedPreferencesImpl.java
1 | java复制代码final class SharedPreferencesImpl implements SharedPreferences { |
步骤 2 - 合并变更记录中,存在一种 “反直觉” 的 clear() 操作:
如果在 Editor 中存在 clear() 操作,并且 clear 前后都有 putValue 操作,就会出现反常的效果:如以下示例程序,按照直观的预期效果,最终写回磁盘的键值对应该只有 ,但事实上最终 和 两个键值对都会被写回磁盘。
出现这个 “现象” 的原因是:SharedPreferences 事务中没有保持 clear 变更记录和 putValue 变更记录的顺序,所以 clear 操作之前的 putValue 操作依然会生效。
示例程序
1 | java复制代码getSharedPreferences("user", Context.MODE_PRIVATE).let { |
小结一下 3 个映射表的区别:
- 1、mMap 是 SharedPreferencesImpl 对象中记录的键值对数据,代表 SharedPreferences 的内存缓存;
- 2、mModified 是 Editor 修改器中记录的键值对变更记录;
- 3、mapToWriteToDisk 是 mMap 与 mModified 合并后,需要全量覆盖写回磁盘的数据。
后续源码分析,见下一篇文章:Android 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
版权声明
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
参考资料
- Android SharedPreferences 的理解与使用 —— ghroosk 著
- 一文读懂 SharedPreferences 的缺陷及一点点思考 —— 业志陈 著
- 反思|官方也无力回天?Android SharedPreferences 的设计与实现 —— 却把青梅嗅 著
- 剖析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技术团队
- 今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待 —— 字节跳动技术团队
推荐阅读
Android 开源库系列完整目录如下(2023/07/12 更新):
- #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
- #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
- #3 IO 框架 Okio 的实现原理,到底哪里 OK?
- #4 IO 框架 Okio 的实现原理,如何检测超时?
- #5 序列化框架 Gson 原理分析,可以优化吗?
- #6 适可而止!看 Glide 如何把生命周期安排得明明白白
- #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
- #8 内存缓存框架 LruCache 的实现原理,手写试试?
- #9 这是一份详细的 EventBus 使用教程
⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~
本文转载自: 掘金