本文已收录到 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
@Override
public 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
@Override
public 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 期待 —— 字节跳动技术团队