SharedPreferences 作为轻量级存储在 Android 应用中是必不可少的,但依旧存在较大的优化空间,小菜在做性能优化时尝试了新的利器 腾讯 MMKV,小菜今天按如下脑图顺序尝试学习和简单分析一下;
SharedPreferences
1. SharedPreferences 基本介绍
SharedPreferences 是一种轻量级存储方式,以 key-value 方式存储在本地 xml 文件中;其持久化的本质就是在在本地磁盘记录一个 xml 文件;
public interface SharedPreferences {
public interface OnSharedPreferenceChangeListener {
void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key);
}
void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener);
Editor edit();
public interface Editor {
Editor putString(String key, @Nullable String value);
Editor putStringSet(String key, @Nullable Set values);
...
Editor remove(String key);
Editor clear();
boolean commit();
void apply();
}
}
简单分析源码可得,SharedPreferences 只是一个接口,SharedPreferencesImpl 为具体的实现类,通过 ContextImpl 中 getSharedPreferences() 获取对象;
2. SharedPreferences 初始化
SharedPreferences sp = getSharedPreferences(Constants.SP_APP_CONFIG, MODE_PRIVATE);
SharedPreferences 的通过 getSharedPreferences() 初始化创建一个对象;其中 MODE 为文件操作类型;MODE_PRIVATE 为本应用私有的,其他 app 不可访问的;MODE_APPEND 也为应用私有,但是新保存的数据放置在文件最后,不会替换之前已有的 key-value;MODE_WORLD_READABLE/WRITEABLE 为其他文件是否可以支持读写操作;常用的还是 MODE_PRIVATE 方式;
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
if (mPackageInfo.getApplicationInfo().targetSdkVersion < Build.VERSION_CODES.KITKAT) {
if (name == null) { name = "null"; }
}
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
// TAG 01
mSharedPrefsPaths = new ArrayMap<>();
}
file = mSharedPrefsPaths.get(name);
if (file == null) {
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
// TAG 02
final ArrayMap cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
checkMode(mode);
if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {
if (isCredentialProtectedStorage() && !getSystemService(UserManager.class).isUserUnlockingOrUnlocked(UserHandle.myUserId())) {
throw new IllegalStateException("SharedPreferences in credential encrypted storage are not available until after user is unlocked");
}
}
// TAG 03
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
小菜在源码处注明了几个 TAG 需要注意的地方;
TAG 01: 在根据 name 查询文件时,SharedPreferences 使用了 ArrayMap,相较于 HashMap 更便捷,更节省空间;
TAG 02: 在创建生成 SharedPreferences 时,通过 cache 来防止同一个 SharedPreferences 被重复创建;
TAG 03: SharedPreferencesImapl 为具体的实现类,初始化时开启新的 I/O 线程读取整个文件 startLoadFromDisk(),进行 xml 解析,存入内存 Map 集合中;
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();
}
3. SharedPreferences 编辑提交
// 编辑数据
Editor editor = sp.edit();
editor.putString("name", "阿策小和尚");
// 提交数据
editor.apply();
// 获取数据
Editor editor = sp.edit();
editor.getString("name", "");
Editor 是用于编辑 SharedPreferences 内容的接口,EditorImpl 为具体的实现类;putXXX() 编辑后的数据保存在 Editor 中,commit()/apply() 后才会更新到 SharedPreferences;
@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() {
if (!mLoaded) {
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
getXXX() 获取数据时根据 mLoaded 文件是否读取完成判断,若未读取完成 awaitLoadedLocked() 会被阻塞,此时在 UI 主线程中进行使用时就可有可能会造成 ANR;
@Override
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
notifyListeners(mcr);
}
Editor 通过 commit() 和 apply() 提交更新到 SharedPrefenences;两者的区别很明显,apply() 通过线程进行异步处理,如果任务完成则从队列中移除 QueuedWork.removeFinisher,无法获取提交的结果;commit 是同步更新,使用时会阻塞主线程,因为是同步提交,可以获取 Boolean 状态的提交状态,进而判断是否提交成功;
4. SharedPreferences 问题与优化
SharedPreferences 虽因其便利性而应用广泛,但也存在一些弊端;
Q1: 编辑 get()/put() 时均会涉及到互斥锁和写入锁,并发操作时影响性能;
A1: 读写操作都是针对的 SharedPreferences 对象,可适当拆分文件或降低访问频率等;
Q2: 使用时出现卡顿引发 GC 或 ANR;
A2:
- 不要存放大数据类型的 key-value 避免导致一直在内存中无法释放;
- 尽量避免频繁读写操作;
- 尽量减少 apply() 次数,每次都会新建一个 EditorImpl 对象,可以批量处理统一提交;
Q3: 不能跨进程通信,不能保证更新本地数据后被另一个进程所知;
A3: 可以借助 ContentProvider 来在多进程中更新数据;
MMKV
1. MMKV 基本介绍
正因为 SharedPreferences 还有很大的优化空间,因为我们才会尝试其他存储框架;其中 腾讯 MMKV 得到很多人的支持;
MMKV 分别代表的是 Memory Mapping Key Value,是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强;官网 Wiki 介绍的优势很明显,是目前微信正在使用的轻量级存储框架;在 Android / macOS / Win32 / POSIX 多个平台一并开源;
2. MMKV 优势
小菜从如下几个角度简单分析一下 MMKV 的优势;
a. 数据格式及更新范围优化;
SharedPreferences 采用 xml 数据存储,每次读写操作都会全局更新;MMKV 采用 protobuf 数据存储,更紧密,支持局部更新
b. 文件耗时操作优化;
MMKV 采用 MMap 内存映射的方式取代 I/O 操作,使用 0拷贝技术提高更新速度;
c. 跨进程状态同步;
SharedPreferences 为了线程安全不支持跨进程状态同步;MMKV 通过 CRC 校验 和文件锁 flock 实现跨进程状态更新;
d. 应用便捷性,较好的兼容性;
MMKV 使用方式便捷,与 SharedPreferences 基本一致,迁移成本低;
2.1 Memory Mapping 内存映射
Memory Mapping 简称 MMap 是一种将磁盘上文件的一部分或整个文件映射到应用程序地址空间的一系列地址机制,从而应用程序可以用访问内存的方式访问磁盘文件;
由此可见,MMap 的优势很明显了,因为进行了内存映射,操作内存相当于操作文件,无需开启新的线程,相较于 I/O 对文件的读写操作只需要从磁盘到用户主存的一次数据拷贝过程,减少了数据的拷贝次数,提高了文件的操作效率;同时 MMap 只需要提供一段内存,只需要关注往内存文件中读写操作即可,在操作系统内存不足或进程退出时自动写入文件中;
当然,MMap 也有自身的劣势,因为 MMap 需要提供一度长度的内存块,其映射区的长度默认是一页,即 4kb,当存储的文件内容较少时可能会造成空间的浪费;
2.2 Protocol Buffers 编码结构
Protocol Buffers 简称 protobuf,是 Google 出品的一种可扩展的序列化数据的编码格式,主要用于通信协议和数据存储等;利用 varint 原理(一种变长的编码方式,值越小的数字,使用的字节越少)压缩数据以后,二进制数据非常紧凑;
protobuf 采用了 TLV(TAG-Length-Value) 的编码格式,减少了分隔符的使用,编码更为紧凑;
protobuf 在更新文件时,虽然也不方便局部更新,但是可以做增量更新,即不管之前是否有相同的 key,一旦有新的数据便添加到文件最后,待最终文件读取时,后面新的数据会覆盖之前老旧的数据;
当添加新的数据时文件大小不够了,需要全量更新,此时需要将 Map 中数据按照 MMKV 方式序列化,滤重后保存需要的字节数,根据获取的字节数与文件大小进行比较;若保存后的文件大小可以添加新的数据时直接添加在最后面,若保存后的文件大小还是不足以添加新的数据时,此时需要对 protobuf * 2 扩容;
protobuf 功能简单,作为二进制存储,可读性较差;同时无法表示复杂的概念,通用性相较于 xml 较差;这也是 protobuf 的不足之处;
2.3 flock 文件锁 + CRC 校验
SharedPreferences 因为线程安全不支持在多进程中进行数据更新;而 MMKV 通过 flock 文件锁和 CRC 校验支持多进程的读写操作;
小菜简单理解,MMKV 在进程 A 中更新了数据,在进程 B 中获取当前数据时会先通过 CRC 文件校验看文件是否有过更新,若没更新直接读取,若已更新则重新获取文件内容在进行读取;
而为了防止多个进程同时对文件进行写操作,MMKV 采用了文件锁 flock 方式来保证同一时间只有一个进程对文件进行写操作;
3. MMKV 应用与注意
MMKV 的应用非常简单,根据官网集成即可:
- Maven 仓库引入 mmkv;
implementation 'com.tencent:mmkv-static:1.2.2'
- 初始化;
MMKV.initialize(this);
- 根据文件名称创建对应存储文件;建议设置 MMKV 为全局实例,方便统一处理;
// 默认文件名
MMKV kv = MMKV.defaultMMKV();
// 指定文件名
MMKV kv = MMKV.mmkvWithID(Constants.SP_APP_CONFIG);
- 可以通过 encode() 方式存储数据也可以使用和 SharedPreferences 相同的 put() 方式存储数据;
kv.encode("name", "阿策小和尚");
kv.encode("age", 18);
kv.putString("address", "北京市海淀区");
kv.putInt("sex", 0);
- 同样可以采用 decodeXXX() 或 getXXX() 获取数据;
kv.decodeString("name", "");
kv.decodeInt("age", -1);
kv.getString("address", "");
kv.getInt("sex", -1);
- 与 SharedPreferences 一样,remove() 清除一条数据,clear() 清空全部数据;
kv.remove();
kv.clear();
- 对于应用中已存在 SharedPreferences 时,MMKV 提供了一键转换为 MMKV 方式;
MMKV mmkv = MMKV.mmkvWithID(Constants.SP_APP_CONFIG);
SharedPreferences sp = context.getSharedPreferences(mid, Context.MODE_PRIVATE);
mmkv.importFromSharedPreferences(sp);
sp.edit().clear().commit();
小菜对于 SharedPreferences 和 MMKV 的底层源码还不够深入,如有错误,请多多指导!
来源: 阿策小和尚