存储方案在Android开发中是一个非常重要的模块, 这里分享一篇大佬的Android 轻量级存储方案的前世今生。希望对大家的学习和工作有所帮助。
原文地址:https://juejin.cn/post/6934494768185475079
背景
对于 Android 轻量级存储方案,有大多数人都很熟悉的 SharedPreferences;也有基于 mmap 的高性能组件 MMKV,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强;还有 Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和流程(Flow)以异步、一致的事务方式存储数据。本文将一一分析这三个方案的来龙去脉,并深入源码进行分析。(本文基于Android 29 源码)
SharedPreferences
SharedPreferences 是 Android 中简单易用的轻量级存储方案,用来保存 App 的相关信息,其本质是一个键值对(key-value)的方式保存数据的 xml 文件,文件路径为 /data/data/应用程序包名/shared_prefs
,文件内容如下:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="pref.device.id">8207e635-bd88-4220-9fc6-59c5e367ad82</string>
<string name="pref.contact.chat">Let's chat(test)</string>
<boolean name="pref.user.birthday.modifiable" value="true" />
<int name="pref.user.birthday.day" value="0" />
<string name="pref.contact.phone">123-456-8888</string>
<string name="pref.user.phone"></string>
<int name="pref.user.birthday.year" value="0" />
<boolean name="pref.is.login" value="true" />
<boolean name="pref.first_launch" value="false" />
</map>
每次读取数据时,通过解析 xml 文件,得到指定 key 对应的 value。
SharedPreferences 的设计初衷是轻量级存储,如果我们存储了大量的数据,那会对内存造成什么影响?
源码初步分析
我们先来看看 SharedPreferences 的源码设计,首先从我们的常规调用 Context.getSharedPreferences(name, mode)
开始,最终都会调用到
//ContextImpl.java
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
···
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) { //用于记录所有的SP文件,文件名为key,file为value
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
再来看看方法 getSharedPreferences 方法
//ContextImpl.getSharedPreferences方法
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
checkMode(mode);
···
//SharedPreferences的真正实现类是SharedPreferencesImpl
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
···
return sp;
}
SharedPreferences 是个接口,其真正实现类是 SharedPreferencesImpl
final class SharedPreferencesImpl implements SharedPreferences {
@UnsupportedAppUsage
private final File mFile; //对应的xml文件
private final File mBackupFile;
private Map<String, Object> mMap; //map中缓存了xml文件中所有键值对
···
@UnsupportedAppUsage
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
mThrowable = null;
startLoadFromDisk(); //开启一个线程加载xml文件内容
}
}
每当调用 SharedPreferencesImpl 的构造器的时候,都会开始调用 startLoadFromDisk 方法,然后在该方法中开启一个子线程加载 xml 文件中的内容,最后将 xml 中的内容全部加载到 mMap中
map = (Map<String, Object>) XmlUtils.readMapXml(str);
内存占用
从上面的分析可以看出当 xml 中数据过大时,肯定会导致内存占用过高,虽然 Context.getSharedPreferences(name, mode)
调用时会将 xml 中的数据一股脑加载到 mMap 中导致内存占用过大,也就是空间换时间,同时 ContextImpl.getSharedPreferencesCacheLocked
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache; //静态
@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;
}
可以看到这个静态的 sSharedPrefsCache 保存了所有的 sp,然后 sSharedPrefsCache 的 value 值保存了所有键值对,也就是说用过的 sp 永远存在于内存中。
同时开发者也需要注意对每个 sp(xml)大小进行控制,毕竟对读写操作也会有一定的影响,具体的区分可以根据相应的业务进行区分。
但是 SharedPreferences 的设计初衷就是面向轻量级的数据存储,所以该设计没毛病,设计者应该自己注意使用场景,毕竟再好的设计也不能面对所有场景。
首次调用可能阻塞主线程
我们再来看看 SharedPreferencesImpl.getString()
方法
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
@GuardedBy("mLock")
private void awaitLoadedLocked() {
···
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
···
}
可以看到,当 sp 还没加载完毕主线程会一直阻塞在那里,直到加载 sp 的子线程加载完成。对于上面的问题,我们可以提前调用 getSharedPreferences 方法让子线程提前加载 sp 的内容。
防止连续多次edit/commit/apply
SharedPreferences sp = getSharedPreferences("jackie", MODE_PRIVATE);
sp.edit().putString("a", "ljc").commit();
sp.edit().putString("b", "cxy").commit();
sp.edit().putString("c", "lsm").apply();
sp.edit().putString("c", "dmn").apply();
每次调用 edit 方法都会创建一个 Editor 对象,造成额外的内存占用。很多设计者会对 SharedPreferences 进行封装,隐藏掉 edit()
和commit/apply()
调用流程,但往往同时也忽略了Editor.commit/apply()
的设计理念和使用场景。如果是复杂的场景,用户可以在多次 putXxx 方法之后再统一进行 commit/apply()
,也就是一次更新多个键值对,只进行一次 IO 操作。
commit/apply 引起的 ANR 问题
commit 是同步地提交到硬件磁盘,有返回值表明修改是否成功,如果在主线程中提交会阻塞线程,影响后续的操作,可能导致 ANR;而 apply 是将修改数据提交到内存,而后异步真正提交到硬件磁盘,没有返回值。我们着重研究一下 apply 为什么会导致 ANR 问题,先来看看 apply 的源码:
@Override
public void apply() {