本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 发问。
前言
大家好,我是小彭。
SharedPreferences 是 Android 平台上轻量级的 K-V 存储框架,亦是初代 K-V 存储框架,至今被很多利用沿用。
有的小伙伴会说,SharedPreferences 是旧时代的产物,当初曾经有 DataStore 或 MMKV 等新时代的 K-V 框架,没有学习意义。但我认为,尽管 SharedPreference 这个计划曾经过期,然而并不意味着 SharedPreference 中应用的技术过期。做技术要知其然,更要知其所以然,而不是随声附和,如果要你解释为什么 SharedPreferences 会过期,你能说到什么水平?
不晓得你最近有没有读到一本在技术圈十分火爆的一本新书 《安卓传奇 · Android 缔造团队回忆录》,其中就讲了很多 Android 架构演进中设计者的思考。如果你平时也有从设计者的角度思考过 “为什么”,那么很多内容会感觉想到一块去了,反之就会感觉无感。
小彭的 Android 交换群 02 群曾经建设啦,公众号回复 “加群” 退出咱们~
—— 图片援用自电商平台
明天,咱们就来剖析 SharedPreference 源码,在过程中仍然能够学习到十分丰盛的设计技巧。在后续的文章中,咱们会持续剖析其余 K-V 存储框架,请关注。
本文源码剖析基于 Android 10(API 31),并关联剖析局部 Android 7.1(API 25)。
思维导图:
1. 实现 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 框架是否良好解决了这些问题?
2. 从 Sample 开始
SharedPreferences 采纳 XML 文件格式长久化键值对数据,文件的存储地位位于利用沙盒的外部存储 /data/data/<packageName>/shared_prefs/
地位,每个 XML 文件对应于一个 SharedPreferences 对象。
在 Activity、Context 和 PreferenceManager 中都存在获取 SharedPreferences 对象的 API,它们最终都会走到 ContextImpl 中:
ContextImpl.java
class ContextImpl extends Context { // 获取 SharedPreferences 对象 @Override public SharedPreferences getSharedPreferences(String name, int mode) { // 后文详细分析... }}
示例代码
SharedPreferences sp = getSharedPreferences("prefs", Context.MODE_PRIVATE);// 创立事务Editor editor = sp.edit();editor.putString("name", "XIAO PENG");// 同步提交事务boolean result = editor.commit(); // 异步提交事务// editor.apply()// 读取数据String blog = sp.getString("name", "PENG");
prefs.xml 文件内容
<?xml version='1.0' encoding='utf-8' standalone='yes' ?><map> <string name="name">XIAO PENG</string></map>
3. 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
class ContextImpl extends Context { // SharedPreferences 文件根目录 private File mPreferencesDir; // <文件名 - 文件> @GuardedBy("ContextImpl.class") private ArrayMap<String, File> mSharedPrefsPaths; // 获取 SharedPreferences 对象 @Override public SharedPreferences getSharedPreferences(String name, int mode) { // 1、文件名转文件对象 File file; synchronized (ContextImpl.class) { // 1.1 查问映射表 if (mSharedPrefsPaths == null) { mSharedPrefsPaths = new ArrayMap<>(); } file = mSharedPrefsPaths.get(name); // 1.2 缓存未命中,创立 File 对象 if (file == null) { file = getSharedPreferencesPath(name); mSharedPrefsPaths.put(name, file); } } // 2、获取 SharedPreferences 对象 return getSharedPreferences(file, mode); } // -> 1.2 缓存未命中,创立 File 对象 @Override public File getSharedPreferencesPath(String name) { return makeFilename(getPreferencesDir(), name + ".xml"); } private File getPreferencesDir() { synchronized (mSync) { // 文件目录:data/data/[package_name]/shared_prefs/ if (mPreferencesDir == null) { mPreferencesDir = new File(getDataDir(), "shared_prefs"); } return ensurePrivateDirExists(mPreferencesDir); } }}
文件对象 to SP 对象:
ContextImpl.java
class ContextImpl extends Context { // <包名 - Map> // <文件 - SharedPreferencesImpl> @GuardedBy("ContextImpl.class") private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache; // -> 2、获取 SharedPreferences 对象 @Override public SharedPreferences getSharedPreferences(File file, int mode) { SharedPreferencesImpl sp; synchronized (ContextImpl.class) { // 2.1 查问缓存 final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked(); sp = cache.get(file); // 2.2 未命中缓存(首次获取) if (sp == null) { // 2.2.1 查看 mode 标记 checkMode(mode); // 2.2.2 创立 SharedPreferencesImpl 对象 sp = new SharedPreferencesImpl(file, mode); // 2.2.3 缓存 cache.put(file, sp); return sp; } } // 3、命中缓存(二次获取) if ((mode & Context.MODE_MULTI_PROCESS) != 0 || getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) { // 判断以后磁盘文件绝对于最初一次内存批改是否变动,如果时则从新加载文件 sp.startReloadIfChangedUnexpectedly(); } return sp; } // 依据包名获取 <文件 - SharedPreferencesImpl> 映射表 @GuardedBy("ContextImpl.class") private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() { if (sSharedPrefsCache == null) { sSharedPrefsCache = new ArrayMap<>(); } final String packageName = getPackageName(); ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName); if (packagePrefs == null) { packagePrefs = new ArrayMap<>(); sSharedPrefsCache.put(packageName, packagePrefs); } return packagePrefs; } ...}
4. 读取和解析磁盘文件
在创立 SharedPreferencesImpl 对象时,构造函数会启动一个子线程去读取本地磁盘文件,一次性将文件中所有的 XML 数据转化为 Map 散列表。
须要留神的是: 如果在执行 loadFromDisk()
解析文件数据的过程中,其余线程调用 getValue 查问数据,那么就必须期待 mLock
锁直到解析完结。
如果单个 SharedPreferences 的 .xml
文件很大的话,就有可能导致查问数据的线程被长时间被阻塞,甚至导致主线程查问时产生 ANR。这也辅证了 SharedPreferences 只适宜保留大量数据,文件过大在解析时会有性能问题。
读取示意图
SharedPreferencesImpl.java
// 指标文件private final File mFile;// 备份文件(后文详细分析)private final File mBackupFile;// 模式private final int mMode;// 锁private final Object mLock = new Object();// 读取文件标记位@GuardedBy("mLock")private boolean mLoaded = false;SharedPreferencesImpl(File file, int mode) { mFile = file; mBackupFile = makeBackupFile(file); mMode = mode; mLoaded = false; mMap = null; mThrowable = null; // 读取并解析文件数据 startLoadFromDisk();}private void startLoadFromDisk() { synchronized (mLock) { mLoaded = false; } // 子线程 new Thread("SharedPreferencesImpl-load") { public void run() { loadFromDisk(); } }.start();}// -> 读取并解析文件数据(子线程)private void loadFromDisk() { synchronized (mLock) { if (mLoaded) { return; } // 1、如果存在备份文件,则复原备份数据(后文详细分析) if (mBackupFile.exists()) { mFile.delete(); mBackupFile.renameTo(mFile); } } Map<String, Object> map = null; if (mFile.canRead()) { // 2、读取文件 BufferedInputStream str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024); // 3、将 XML 数据解析为 Map 映射表 map = (Map<String, Object>) XmlUtils.readMapXml(str); IoUtils.closeQuietly(str); } synchronized (mLock) { mLoaded = true; if (map != null) { // 应用解析的映射表 mMap = map; } else { // 创立空的映射表 mMap = new HashMap<>(); } // 4、唤醒期待 mLock 锁的线程 mLock.notifyAll(); }}static File makeBackupFile(File prefsFile) { return new File(prefsFile.getPath() + ".bak");}
查问数据可能会阻塞期待:
SharedPreferencesImpl.java
public String getString(String key, @Nullable String defValue) { synchronized (mLock) { // 期待 mLoaded 标记位 awaitLoadedLocked(); // 查问数据 String v = (String)mMap.get(key); return v != null ? v : defValue; }}private void awaitLoadedLocked() { // “查看 - 期待” 模式 while (!mLoaded) { try { mLock.wait(); } catch (InterruptedException unused) { } }}
5. 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
final class SharedPreferencesImpl implements SharedPreferences { // 创立修改器对象 @Override public Editor edit() { // 期待磁盘文件加载实现 synchronized (mLock) { awaitLoadedLocked(); } // 创立修改器对象 return new EditorImpl(); } // 修改器 // 非动态外部类(会持有外部类 SharedPreferencesImpl 的援用) public final class EditorImpl implements Editor { // 锁对象 private final Object mEditorLock = new Object(); // 批改记录(将以事务形式写回磁盘) @GuardedBy("mEditorLock") private final Map<String, Object> mModified = new HashMap<>(); // 革除全副数据的标记位 @GuardedBy("mEditorLock") private boolean mClear = false; // 批改 String 类型键值对 @Override public Editor putString(String key, @Nullable String value) { synchronized (mEditorLock) { mModified.put(key, value); return this; } } // 批改 int 类型键值对 @Override public Editor putInt(String key, int value) { synchronized (mEditorLock) { mModified.put(key, value); return this; } } // 移除键值对 @Override public Editor remove(String key) { synchronized (mEditorLock) { // 将 this 指针作为非凡的移除标记位 mModified.put(key, this); return this; } } // 清空键值对 @Override public Editor clear() { synchronized (mEditorLock) { // 革除全副数据的标记位 mClear = true; return this; } } ... @Override public void apply() { // commitToMemory():写回磁盘的数据并封装事务对象 MemoryCommitResult mcr = commitToMemory(); // 同步写回,下文详细分析 } @Override public boolean commit() { // commitToMemory():写回磁盘的数据并封装事务对象 final MemoryCommitResult mcr = commitToMemory(); // 异步写回,下文详细分析 } }}
MemoryCommitResult 事务对象外围的字段只有 2 个:
- memoryStateGeneration: 以后的内存版本(在
writeToFile()
中会过滤低于最新的内存版本的有效事务); - mapToWriteToDisk: 最终全量笼罩写回磁盘的数据。
SharedPreferencesImpl.java
private static class MemoryCommitResult { // 内存版本 final long memoryStateGeneration; // 须要全量笼罩写回磁盘的数据 final Map<String, Object> mapToWriteToDisk; // 同步计数器 final CountDownLatch writtenToDiskLatch = new CountDownLatch(1); @GuardedBy("mWritingToDiskLock") volatile boolean writeToDiskResult = false; boolean wasWritten = false; // 后文写回完结后调用 void setDiskWriteResult(boolean wasWritten, boolean result) { this.wasWritten = wasWritten; // writeToDiskResult 会作为 commit 同步写回的返回值 writeToDiskResult = result; // 唤醒期待锁 writtenToDiskLatch.countDown(); }}
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
final class SharedPreferencesImpl implements SharedPreferences { // 进行中事务计数(在提交事务是自增 1,在写回完结时自减 1) @GuardedBy("mLock") private int mDiskWritesInFlight = 0; // 内存版本 @GuardedBy("this") private long mCurrentMemoryStateGeneration; // 磁盘版本 @GuardedBy("mWritingToDiskLock") private long mDiskStateGeneration; // 修改器 public final class EditorImpl implements Editor { // 锁对象 private final Object mEditorLock = new Object(); // 批改记录(将以事务形式写回磁盘) @GuardedBy("mEditorLock") private final Map<String, Object> mModified = new HashMap<>(); // 革除全副数据的标记位 @GuardedBy("mEditorLock") private boolean mClear = false; // 获取须要写回磁盘的事务 private MemoryCommitResult commitToMemory() { long memoryStateGeneration; boolean keysCleared = false; List<String> keysModified = null; Set<OnSharedPreferenceChangeListener> listeners = null; Map<String, Object> mapToWriteToDisk; synchronized (SharedPreferencesImpl.this.mLock) { // 如果同时存在多个写回事务,则应用深拷贝 if (mDiskWritesInFlight > 0) { mMap = new HashMap<String, Object>(mMap); } // mapToWriteToDisk:须要写回的数据 mapToWriteToDisk = mMap; // mDiskWritesInFlight:进行中事务自增 1 mDiskWritesInFlight++; synchronized (mEditorLock) { // changesMade:标记是否产生无效批改 boolean changesMade = false; // 革除全副键值对 if (mClear) { // 革除 mapToWriteToDisk 映射表(上面的 mModified 有可能从新减少键值对) if (!mapToWriteToDisk.isEmpty()) { changesMade = true; mapToWriteToDisk.clear(); } keysCleared = true; mClear = false; } // 将 Editor 中的 mModified 批改记录合并到 mapToWriteToDisk // mapToWriteToDisk 指向 SharedPreferencesImpl 中的 mMap,所以内存缓存越会被批改 for (Map.Entry<String, Object> e : mModified.entrySet()) { String k = e.getKey(); Object v = e.getValue(); if (v == this /*应用 this 指针作为魔数*/|| v == null) { // 移除键值对 if (!mapToWriteToDisk.containsKey(k)) { continue; } mapToWriteToDisk.remove(k); } else { // 新增或更新键值对 if (mapToWriteToDisk.containsKey(k)) { Object existingValue = mapToWriteToDisk.get(k); if (existingValue != null && existingValue.equals(v)) { continue; } } mapToWriteToDisk.put(k, v); } // 标记产生无效批改 changesMade = true; // 记录变更的键值对 if (hasListeners) { keysModified.add(k); } } // 重置批改记录 mModified.clear(); // 如果产生无效批改,内存版本自增 1 if (changesMade) { mCurrentMemoryStateGeneration++; } // 记录以后的内存版本 memoryStateGeneration = mCurrentMemoryStateGeneration; } } return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified, listeners, mapToWriteToDisk); } }}
步骤 2 - 合并变更记录中,存在一种 “反直觉” 的 clear() 操作:
如果在 Editor 中存在 clear() 操作,并且 clear 前后都有 putValue 操作,就会呈现反常的成果:如以下示例程序,依照直观的预期成果,最终写回磁盘的键值对应该只有 <age>,但事实上最终 <name> 和 <age> 两个键值对都会被写回磁盘。
呈现这个 “景象” 的起因是:SharedPreferences 事务中没有放弃 clear 变更记录和 putValue 变更记录的程序,所以 clear 操作之前的 putValue 操作仍然会失效。
示例程序
getSharedPreferences("user", Context.MODE_PRIVATE).let { it.edit().putString("name", "XIAOP PENG") .clear() .putString("age", "18") .apply()}
小结一下 3 个映射表的区别:
- 1、mMap 是 SharedPreferencesImpl 对象中记录的键值对数据,代表 SharedPreferences 的内存缓存;
- 2、mModified 是 Editor 修改器中记录的键值对变更记录;
- 3、mapToWriteToDisk 是 mMap 与 mModified 合并后,须要全量笼罩写回磁盘的数据。
6. 两种写回策略
在取得事务对象后,咱们持续剖析 Editor 接口中的 commit 同步写回策略和 apply 异步写回策略。
6.1 commit 同步写回策略
Editor#commit 同步写回绝对简略,外围步骤分为 4 步:
- 1、调用
commitToMemory()
创立MemoryCommitResult
事务对象; - 2、调用
enqueueDiskWrite(mrc, null)
提交磁盘写回工作(在以后线程执行); - 3、调用 CountDownLatch#await() 阻塞期待磁盘写回实现;
- 4、调用 notifyListeners() 触发回调监听。
commit 同步写回示意图
其实严格来说,commit 同步写回也不相对是在以后线程同步写回,也有可能在后盾 HandlerThread 线程写回。但不论怎么样,对于 commit 同步写回来说,都会调用 CountDownLatch#await() 阻塞期待磁盘写回实现,所以在逻辑上也等价于在以后线程同步写回。
SharedPreferencesImpl.java
public final class EditorImpl implements Editor { @Override public boolean commit() { // 1、获取事务对象(前文已剖析) MemoryCommitResult mcr = commitToMemory(); // 2、提交磁盘写回工作 SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* 写回胜利回调 */); // 3、阻塞期待写回实现 mcr.writtenToDiskLatch.await(); // 4、触发回调监听器 notifyListeners(mcr); return mcr.writeToDiskResult; }}
6.2 apply 异步写回策略
Editor#apply 异步写回绝对简单,外围步骤分为 5 步:
- 1、调用
commitToMemory()
创立MemoryCommitResult
事务对象; - 2、创立
awaitCommit
Ruunnable 并提交到 QueuedWork 中。awaitCommit 中会调用 CountDownLatch#await() 阻塞期待磁盘写回实现; - 3、创立
postWriteRunnable
Runnable,在 run() 中会执行 awaitCommit 工作并将其从 QueuedWork 中移除; - 4、调用
enqueueDiskWrite(mcr, postWriteRunnable)
提交磁盘写回工作(在子线程执行); - 5、调用 notifyListeners() 触发回调监听。
能够看到不论是调用 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite()
提交磁盘写回工作。
区别在于:
- 在 commit 中 enqueueDiskWrite() 的第 2 个参数是 null;
- 在 apply 中 enqueueDiskWrite() 的第 2 个参数是一个
postWriteRunnable
写回完结的回调对象,enqueueDiskWrite() 外部就是依据第 2 个参数来辨别 commit 和 apply 策略。
apply 异步写回示意图
SharedPreferencesImpl.java
@Overridepublic void apply() { // 1、获取事务对象(前文已剖析) final MemoryCommitResult mcr = commitToMemory(); // 2、提交 aWait 工作 // 疑难:postWriteRunnable 能够了解,awaitCommit 是什么? final Runnable awaitCommit = new Runnable() { @Override public void run() { // 阻塞线程直到磁盘工作执行结束 mcr.writtenToDiskLatch.await(); } }; QueuedWork.addFinisher(awaitCommit); // 3、创立写回胜利回调 Runnable postWriteRunnable = new Runnable() { @Override public void run() { // 执行 aWait 工作 awaitCommit.run(); // 移除 aWait 工作 QueuedWork.removeFinisher(awaitCommit); } }; // 4、提交磁盘写回工作,并绑定写回胜利回调 SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable /* 写回胜利回调 */); // 5、触发回调监听器 notifyListeners(mcr);}
QueuedWork.java
// 提交 aWait 工作(后文详细分析)private static final LinkedList<Runnable> sFinishers = new LinkedList<>();public static void addFinisher(Runnable finisher) { synchronized (sLock) { sFinishers.add(finisher); }}public static void removeFinisher(Runnable finisher) { synchronized (sLock) { sFinishers.remove(finisher); }}
这里有一个疑难:
在 apply() 办法中,在执行 enqueueDiskWrite() 前创立了 awaitCommit 工作并退出到 QueudWork 期待队列,直到磁盘写回完结才将 awaitCommit 移除。这个 awaitCommit 工作是做什么的呢?
咱们略微再答复,先持续往下走。
6.3 enqueueDiskWrite() 提交磁盘写回事务
能够看到,不论是 commit 还是 apply,最终都会调用 SharedPreferencesImpl#enqueueDiskWrite() 提交写回磁盘工作。尽管 enqueueDiskWrite() 还没到真正调用磁盘写回操作的中央,但的确创立了与磁盘 IO 相干的 Runnable 工作,外围步骤分为 4 步:
- 步骤 1:依据是否有 postWriteRunnable 回调辨别是 commit 和 apply;
步骤 2:创立磁盘写回工作(真正执行磁盘 IO 的中央):
- 2.1 调用 writeToFile() 执行写回磁盘 IO 操作;
- 2.2 在写回完结后对前文提到的 mDiskWritesInFlight 计数自减 1;
- 2.3 执行 postWriteRunnable 写回胜利回调;
- 步骤 3:如果是异步写回,则提交到 QueuedWork 工作队列;
- 步骤 4:如果是同步写回,则查看 mDiskWritesInFlight 变量。如果存在并发写回的事务,则也要提交到 QueuedWork 工作队列,否则就间接在以后线程执行。
其中步骤 2 是真正执行磁盘 IO 的中央,逻辑也很好了解。不好了解的是,咱们发现除了 “同步写回而且不存在并发写回事务” 这种非凡状况,其余状况都会交给 QueuedWork
再调度一次。
在通过 QueuedWork#queue
提交工作时,会将 writeToDiskRunnable 工作追加到 sWork 工作队列中。如果是首次提交工作,QueuedWork 外部还会创立一个 HandlerThread
线程,通过这个子线程实现异步的写回工作。这阐明 SharedPreference 的异步写回相当于应用了一个单线程的线程池,事实上在 Android 8.0 以前的版本中就是应用一个 singleThreadExecutor 线程池实现的。
提交工作示意图
SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { // 1、依据是否有 postWriteRunnable 回调辨别是 commit 和 apply final boolean isFromSyncCommit = (postWriteRunnable == null); // 2、创立磁盘写回工作 final Runnable writeToDiskRunnable = new Runnable() { @Override public void run() { synchronized (mWritingToDiskLock) { // 2.1 写入磁盘文件 writeToFile(mcr, isFromSyncCommit); } synchronized (mLock) { // 2.2 mDiskWritesInFlight:进行中事务自减 1 mDiskWritesInFlight--; } if (postWriteRunnable != null) { // 2.3 触发写回胜利回调 postWriteRunnable.run(); } } }; // 3、同步写回且不存在并发写回,则间接在以后线程 // 这就是前文提到 “commit 也不是相对在以后线程同步写回” 的源码出处 if (isFromSyncCommit) { boolean wasEmpty = false; synchronized (mLock) { // 如果存在并发写回的事务,则此处 wasEmpty = false wasEmpty = mDiskWritesInFlight == 1; } // wasEmpty 为 true 阐明以后只有一个线程在执行提交操作,那么就间接在此线程上实现工作 if (wasEmpty) { writeToDiskRunnable.run(); return; } } // 4、交给 QueuedWork 调度(同步工作不能够提早) QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit /*是否能够提早*/ );}@GuardedBy("mWritingToDiskLock")private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) { // 稍后剖析}
QueuedWork 调度:
QueuedWork.java
@GuardedBy("sLock")private static LinkedList<Runnable> sWork = new LinkedList<>();// 提交工作// shouldDelay:是否提早public static void queue(Runnable work, boolean shouldDelay) { Handler handler = getHandler(); synchronized (sLock) { // 入队 sWork.add(work); // 发送 Handler 音讯,触发 HandlerThread 执行工作 if (shouldDelay && sCanDelay) { handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY /* 100ms */); } else { handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN); } }}private static Handler getHandler() { synchronized (sLock) { if (sHandler == null) { // 创立 HandlerThread 后盾线程 HandlerThread handlerThread = new HandlerThread("queued-work-looper", Process.THREAD_PRIORITY_FOREGROUND); handlerThread.start(); sHandler = new QueuedWorkHandler(handlerThread.getLooper()); } return sHandler; }}private static class QueuedWorkHandler extends Handler { static final int MSG_RUN = 1; QueuedWorkHandler(Looper looper) { super(looper); } public void handleMessage(Message msg) { if (msg.what == MSG_RUN) { // 执行工作 processPendingWork(); } }}private static void processPendingWork() { synchronized (sProcessingWork) { LinkedList<Runnable> work; synchronized (sLock) { // 创立新的工作队列 // 这一步是必须的,否则会与 enqueueDiskWrite 抵触 work = sWork; sWork = new LinkedList<>(); // Remove all msg-s as all work will be processed now getHandler().removeMessages(QueuedWorkHandler.MSG_RUN); } // 遍历 ,按程序执行 sWork 工作队列 if (work.size() > 0) { for (Runnable w : work) { w.run(); } } }}
比拟不了解的是:
同一个文件的屡次写回串行化能够了解,对于多个文件的写回串行化意义是什么,是不是能够用多线程来写回多个不同的文件?或者这也是 SharedPreferences 是轻量级框架的起因之一,你感觉呢?
6.4 被动期待写回工作完结
当初咱们能够答复 6.1 中遗留的问题:
在 apply() 办法中,在执行 enqueueDiskWrite() 前创立了 awaitCommit 工作并退出到 QueudWork 期待队列,直到磁盘写回完结才将 awaitCommit 移除。这个 awaitCommit 工作是做什么的呢?
要了解这个问题须要治理剖析到 ActivityThread 中的主线程音讯循环:
能够看到,在主线程的 Activity#onPause、Activity#onStop、Service#onStop、Service#onStartCommand 等生命周期状态变更时,会调用 QueudeWork.waitToFinish():
ActivityThread.java
@Overridepublic void handlePauseActivity(...) { performPauseActivity(r, finished, reason, pendingActions); // Make sure any pending writes are now committed. if (r.isPreHoneycomb()) { QueuedWork.waitToFinish(); } ...}private void handleStopService(IBinder token) { ... QueuedWork.waitToFinish(); ActivityManager.getService().serviceDoneExecuting(token, SERVICE_DONE_EXECUTING_STOP, 0, 0); ...}
waitToFinish() 会执行所有 sFinishers 期待队列中的 aWaitCommit 工作,被动期待所有磁盘写回工作完结。在写回工作完结之前,主线程会阻塞在期待锁上,这里也有可能产生 ANR。
被动期待示意图
至于为什么 Google 要在 ActivityThread 中局部生命周期中被动期待所有磁盘写回工作完结呢?官网并没有明确示意,联合头条和抖音技术团队的文章,我比拟偏向于这 2 点解释:
- 解释 1 - 跨进程同步(次要): 为了保障跨过程的数据同步,要求在组件跳转前,确保以后组件的写回工作必须在以后生命周期内实现;
- 解释 2 - 数据完整性: 为了避免在组件跳转的过程中可能产生的 Crash 造成未写回的数据失落,要求以后组件的写回工作必须在以后生命周期内实现。
当然这两个解释并不全面,因为就算要求被动期待,也不能保障跨过程实时同步,也不能保障不产生 Crash。
抖音技术团队观点
QueuedWork.java
@GuardedBy("sLock")private static Handler sHandler = null;public static void waitToFinish() { boolean hadMessages = false; Handler handler = getHandler(); synchronized (sLock) { if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) { // Delayed work will be processed at processPendingWork() below handler.removeMessages(QueuedWorkHandler.MSG_RUN); } // We should not delay any work as this might delay the finishers sCanDelay = false; } // Android 8.0 优化:帮忙子线程执行磁盘写回 // 作用无限,因为 QueuedWork 应用了 sProcessingWork 锁保障同一时间最多只有一个线程在执行磁盘写回 // 所以这里应该是尝试在主线程执行,能够晋升线程优先级 processPendingWork(); // 执行 sFinshers 期待队列,期待所有写回工作完结 try { while (true) { Runnable finisher; synchronized (sLock) { finisher = sFinishers.poll(); } if (finisher == null) { break; } // 执行 mcr.writtenToDiskLatch.await(); // 阻塞线程直到磁盘工作执行结束 finisher.run(); } } finally { sCanDelay = true; }}
Android 7.1 QueuedWork 源码比照:
public static boolean hasPendingWork() { return !sPendingWorkFinishers.isEmpty();}
7. writeToFile() 捷足先登
最终走到具体调用磁盘 IO 操作的中央了!
7.1 写回步骤
writeToFile() 的逻辑绝对简单一些了。通过简化后,剩下的外围步骤只有 4 大步骤:
步骤 1:过滤有效写回事务:
- 1.1 事务的 memoryStateGeneration 内存版本小于 mDiskStateGeneration 磁盘版本,跳过;
- 1.2 同步写回必须写回;
- 1.3 异步写回事务的 memoryStateGeneration 内存版本版本小于 mCurrentMemoryStateGeneration 最新内存版本,跳过。
步骤 2:文件备份:
- 2.1 如果不存在备份文件,则将旧文件重命名为备份文件;
- 2.2 如果存在备份文件,则删除有效的旧文件(上一次写回出并且后处理没有胜利删除的状况)。
步骤 3:全量笼罩写回磁盘:
- 3.1 关上文件输入流;
- 3.2 将 mapToWriteToDisk 映射表全量写出;
- 3.3 调用 FileUtils.sync() 强制操作系统页缓存写回磁盘;
- 3.4 写入胜利,则删除被封文件(如果没有走到这一步,在未来读取文件时,会从新复原备份文件);
- 3.5 将磁盘版本记录为以后内存版本;
- 3.6 写回完结(胜利)。
- 步骤 4:后处理: 删除写至半途的有效文件。
7.2 写回优化
持续剖析发现,SharedPreference 的写回操作并不是简略的调用磁盘 IO,在保障 “可用性” 方面也做了一些优化设计:
- 优化 1 - 过滤有效的写回事务:
如前文所述,commit 和 apply 都可能呈现并发批改同一个文件的状况,此时在间断批改同一个文件的事务序列中,旧的事务是没有意义的。为了过滤这些无意义的事务,在创立 MemoryCommitResult
事务对象时会记录过后的 memoryStateGeneration
内存版本,而在 writeToFile() 中就会依据这个字段过滤有效事务,防止了有效的 I/O 操作。
- 优化 2 - 备份旧文件:
因为写回文件的过程存在不确定的异样(比方内核解体或者机器断电),为了保障文件的完整性,SharedPreferences 采纳了文件备份机制。在执行写回操作之前,会先将旧文件重命名为 .bak
备份文件,在全量笼罩写入新文件后再删除备份文件。
如果写回文件失败,那么在后处理过程中会删除写至半途的有效文件。此时磁盘中只有一个备份文件,而实在文件须要等到下次触发写回事务时再写回。
如果直到利用退出都没有触发下次写回,或者写回的过程中 Crash,那么在前文提到的创立 SharedPreferencesImpl 对象的构造方法中调用 loadFromDisk() 读取并解析文件数据时,会从备份文件复原数据。
- 优化 3 - 强制页缓存写回:
在写回文件胜利后,SharedPreference 会调用 FileUtils.sync()
强制操作系统将页缓存写回磁盘。
写回示意图
SharedPreferencesImpl.java
// 内存版本@GuardedBy("this")private long mCurrentMemoryStateGeneration;// 磁盘版本@GuardedBy("mWritingToDiskLock")private long mDiskStateGeneration;// 写回事务private static class MemoryCommitResult { // 内存版本 final long memoryStateGeneration; // 须要全量笼罩写回磁盘的数据 final Map<String, Object> mapToWriteToDisk; // 同步计数器final CountDownLatch writtenToDiskLatch = new CountDownLatch(1); // 后文写回完结后调用 // wasWritten:是否有执行写回 // result:是否胜利 void setDiskWriteResult(boolean wasWritten, boolean result) { this.wasWritten = wasWritten; writeToDiskResult = result; // 唤醒期待锁 writtenToDiskLatch.countDown(); }}// 提交写回事务private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { ... // 创立磁盘写回工作 final Runnable writeToDiskRunnable = new Runnable() { @Override public void run() { synchronized (mWritingToDiskLock) { // 2.1 写入磁盘文件 writeToFile(mcr, isFromSyncCommit); } synchronized (mLock) { // 2.2 mDiskWritesInFlight:进行中事务自减 1 mDiskWritesInFlight--; } if (postWriteRunnable != null) { // 2.3 触发写回胜利回调 postWriteRunnable.run(); } } }; ...}// 写回文件// isFromSyncCommit:是否同步写回@GuardedBy("mWritingToDiskLock")private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) { boolean fileExists = mFile.exists(); // 如果旧文件存在 if (fileExists) { // 1. 过滤有效写回事务 // 是否须要执行写回 boolean needsWrite = false; // 1.1 磁盘版本小于内存版本,才有可能须要写回 // (只有旧文件存在才会走到这个分支,然而旧文件不存在的时候也可能存在无意义的写回, // 猜想官网是心愿首次创立文件的写回可能及时尽快执行,毕竟只有一个后盾线程) if (mDiskStateGeneration < mcr.memoryStateGeneration) { if (isFromSyncCommit) { // 1.2 同步写回必须写回 needsWrite = true; } else { // 1.3 异步写回须要判断事务对象的内存版本,只有最新的内存版本才有必要执行写回 synchronized (mLock) { if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) { needsWrite = true; } } } } if (!needsWrite) { // 1.4 有效的异步写回,间接完结 mcr.setDiskWriteResult(false, true); return; } // 2. 文件备份 boolean backupFileExists = mBackupFile.exists(); if (!backupFileExists) { // 2.1 如果不存在备份文件,则将旧文件重命名为备份文件 if (!mFile.renameTo(mBackupFile)) { // 备份失败 mcr.setDiskWriteResult(false, false); return; } } else { // 2.2 如果存在备份文件,则删除有效的旧文件(上一次写回出并且后处理没有胜利删除的状况) mFile.delete(); } } try { // 3、全量笼罩写回磁盘 // 3.1 关上文件输入流 FileOutputStream str = createFileOutputStream(mFile); if (str == null) { // 关上输入流失败 mcr.setDiskWriteResult(false, false); return; } // 3.2 将 mapToWriteToDisk 映射表全量写出 XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str); // 3.3 FileUtils.sync:强制操作系统将页缓存写回磁盘 FileUtils.sync(str); // 敞开输入流 str.close(); ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0); // 3.4 写入胜利,则删除被封文件(如果没有走到这一步,在未来读取文件时,会从新复原备份文件) mBackupFile.delete(); // 3.5 将磁盘版本记录为以后内存版本 mDiskStateGeneration = mcr.memoryStateGeneration; // 3.6 写回完结(胜利) mcr.setDiskWriteResult(true, true); return; } catch (XmlPullParserException e) { Log.w(TAG, "writeToFile: Got exception:", e); } catch (IOException e) { Log.w(TAG, "writeToFile: Got exception:", e); } // 在 try 块中抛出异样,会走到这里 // 4、后处理:删除写至半途的有效文件 if (mFile.exists()) { if (!mFile.delete()) { Log.e(TAG, "Couldn't clean up partially-written file " + mFile); } } // 写回完结(失败) mcr.setDiskWriteResult(false, false);}// -> 读取并解析文件数据private void loadFromDisk() { synchronized (mLock) { if (mLoaded) { return; } // 1、如果存在备份文件,则复原备份数据(后文详细分析) if (mBackupFile.exists()) { mFile.delete(); mBackupFile.renameTo(mFile); } } ...}
至此,SharedPreferences 外围源码剖析完结。
8. SharedPreferences 的其余细节
SharedPreferences 还有其余细节值得学习。
8.1 SharedPreferences 锁总结
SharedPreferences 是线程平安的,但它的线程平安并不是间接应用一个全局的锁对象,而是采纳多种颗粒度的锁对象实现 “锁细化” ,而且还贴心地应用了 @GuardedBy
注解标记字段或办法所述的锁级别。
应用 @GuardedBy 注解标记锁级别
@GuardedBy("mLock")private Map<String, Object> mMap;
对象锁 | 性能呢 | 形容 |
---|---|---|
1、SharedPreferenceImpl#mLock | SharedPreferenceImpl 对象的全局锁 | 全局应用 |
2、EditorImpl#mEditorLock | EditorImpl 修改器的写锁 | 确保多线程拜访 Editor 的竞争平安 |
3、SharedPreferenceImpl#mWritingToDiskLock | SharedPreferenceImpl#writeToFile() 的互斥锁 | writeToFile() 中会批改内存状态,须要保障多线程竞争平安 |
4、QueuedWork.sLock | QueuedWork 的互斥锁 | 确保 sFinishers 和 sWork 的多线程资源竞争平安 |
5、QueuedWork.sProcessingWork | QueuedWork#processPendingWork() 的互斥锁 | 确保同一时间最多只有一个线程执行磁盘写回工作 |
8.2 应用 WeakHashMap 存储监听器
SharedPreference 提供了 OnSharedPreferenceChangeListener 回调监听器,能够在主线程监听键值对的变更(蕴含批改、新增和移除)。
SharedPreferencesImpl.java
@GuardedBy("mLock")private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners = new WeakHashMap<OnSharedPreferenceChangeListener, Object>();
SharedPreferences.java
public interface SharedPreferences { public interface OnSharedPreferenceChangeListener { void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key); }}
比拟意外的是: SharedPreference 应用了一个 WeakHashMap 弱键散列表存储监听器,并且将监听器对象作为 Key 对象。这是为什么呢?
这是一种避免内存透露的思考,因为 SharedPreferencesImpl 的生命周期是全局的(位于 ContextImpl 的内存缓存),所以有必要应用弱援用避免内存透露。想想也对,Java 规范库没有提供相似 WeakArrayList 或 WeakLinkedList 的容器,所以这里将监听器对象作为 WeakHashMap 的 Key,就很奇妙的复用了 WeakHashMap 主动清理有效数据的能力。
提醒: 对于 WeakHashMap 的详细分析,请浏览小彭说 · 数据结构与算法 专栏文章 《WeakHashMap 和 HashMap 的区别是什么,何时应用?》
8.3 如何查看文件被其余过程批改?
在读取和写入文件后记录 mStatTimestamp 工夫戳和 mStatSize 文件大小,在查看时查看这两个字段是否发生变化
SharedPreferencesImpl.java
// 文件工夫戳@GuardedBy("mLock")private StructTimespec mStatTimestamp;// 文件大小@GuardedBy("mLock")private long mStatSize;// 读取文件private void loadFromDisk() { ... mStatTimestamp = stat.st_mtim; mStatSize = stat.st_size; ...}// 写入文件private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) { ... mStatTimestamp = stat.st_mtim; mStatSize = stat.st_size; ...}// 查看文件private boolean hasFileChangedUnexpectedly() { synchronized (mLock) { if (mDiskWritesInFlight > 0) { // If we know we caused it, it's not unexpected. if (DEBUG) Log.d(TAG, "disk write in flight, not unexpected."); return false; } } // 读取文件 Stat 信息 final StructStat stat = Os.stat(mFile.getPath()); synchronized (mLock) { // 查看批改工夫和文件大小 return !stat.st_mtim.equals(mStatTimestamp) || mStatSize != stat.st_size; }}
至此,SharedPreferences 全副源码剖析完结。
9. 总结
能够看到,尽管 SharedPreferences 是一个轻量级的 K-V 存储框架,但确实是一个残缺的存储计划。从源码剖析中,咱们能够看到 SharedPreferences 在读写性能、可用性方面都有做一些优化,例如:锁细化、事务化、事务过滤、文件备份等,值得细细品味。
在下篇文章里,咱们来盘点 SharedPreferences 中存在的 “毛病”,为什么 SharedPreferences 没有乘上新时代的船只。请关注。
参考资料
- Android SharedPreferences 的了解与应用 —— ghroosk 著
- 一文读懂 SharedPreferences 的缺点及一点点思考 —— 业志陈 著
- 反思|官网也无力回天?Android SharedPreferences 的设计与实现 —— 却把青梅嗅 著
- 分析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技术团队
- 今日头条 ANR 优化实际系列 - 辞别 SharedPreference 期待 —— 字节跳动技术团队