本文已收录到 AndroidFamily,技能和职场问题,请重视大众号 [彭旭锐] 发问。
前语
大家好,我是小彭。
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)。
- Android 初代 K-V 存储结构 SharedPreferences,旧时代的余晖?(上)
- Android 初代 K-V 存储结构 SharedPreferences,旧时代的余晖?(下)
思想导图:
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 操作,就会出现反常的作用:如以下示例程序,按照直观的预期作用,终究写回磁盘的键值对应该只要 ,但事实上终究 和 两个键值对都会被写回磁盘。
出现这个 “现象” 的原因是: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 兼并后,需求全量掩盖写回磁盘的数据。
后续源码分析,见下一篇文章:Android 初代 K-V 存储结构 SharedPreferences,旧时代的余晖?(下)
版权声明
本文为稀土技能社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!
参考资料
- Android SharedPreferences 的了解与运用 —— ghroosk 著
- 一文读懂 SharedPreferences 的缺陷及一点点考虑 —— 业志陈 著
- 反思|官方也无力回天?Android SharedPreferences 的规划与完结 —— 却把青梅嗅 著
- 分析 SharedPreference apply 引起的 ANR 问题 —— 字节跳动技能团队
- 今天头条 ANR 优化实践系列 – 告别 SharedPreference 等候 —— 字节跳动技能团队