开篇
开局一张图,说明一切问题。
MMKV优势
可以看出MMKV相比SP的优势还是比较大的,除了需要引入库,有一些修改上的成本以外,就没有什么能够阻挡MMKV了。当然了,MMKV也有着不广为人知的缺点,放在最后。
MMKV还直接支持了将SharedPreferences的历史数据转换为MMKV进行存储,只不过需要注意一点,不可回退。
且听我慢慢道来
SP具体存在哪些问题
- 容易anr,无论是commit、apply、getxxx都可能导致ANR。
SharedPreferences 本身是一个接口,其具体的实现类是 SharedPreferencesImpl,而 Context 的各个和 SharedPreferences 相关的方法则是由 ContextImpl 来实现的。而每当我们获取到一个 SharedPreferences 对象时,这个对象将一直被保存在内存当中,如果SP文件过大,那么会对内存的占用是有很大的影响的。
如果SP文件过大的话,在App启动的时候也会造成启动慢,甚至ANR的。
class ContextImpl extends Context {
//根据应用包名缓存所有 SharedPreferences,根据 xmlFile 和具体的 SharedPreferencesImpl 对应上
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
//根据 fileName 拿到对应的 xmlFile
private ArrayMap<String, File> mSharedPrefsPaths;
}
如果我们在初始化 SharedPreferencesImpl 后紧接着就去 getValue 的话,势必也需要确保子线程已经加载完成后才去进行取值操作。SharedPreferencesImpl 就通过在每个 getValue 方法中调用 awaitLoadedLocked()方法来判断是否需要阻塞外部线程,确保取值操作一定会在子线程执行完毕后才执行。loadFromDisk()方法会在任务执行完毕后调用 mLock.notifyAll()唤醒所有被阻塞的线程。所以说,如果 SharedPreferences 存储的数据量很大的话,那么就有可能导致外部的调用者线程被阻塞,严重时甚至可能导致 ANR。当然,这种可能性也只是发生在加载磁盘文件完成之前,当加载完成后 awaitLoadedLocked()方法自然不会阻塞线程。这也是为什么第一次写入或者读取sp相比mmkv慢十多倍最主要的原因。
@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() {
if (!mLoaded) {
// Raise an explicit StrictMode onReadFromDisk for this
// thread, since the real read will be in a different
// thread and otherwise ignored by StrictMode.
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
//还未加载线程,让外部线程暂停等待
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
private void loadFromDisk() {
···
synchronized (mLock) {
mLoaded = true;
mThrowable = thrown;
// It's important that we always signal waiters, even if we'll make
// them fail with an exception. The try-finally is pretty wide, but
// better safe than sorry.
try {
if (thrown == null) {
if (map != null) {
mMap = map;
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
} else {
mMap = new HashMap<>();
}
}
// In case of a thrown exception, we retain the old map. That allows
// any open editors to commit and store updates.
} catch (Throwable t) {
mThrowable = t;
} finally {
//唤醒所有被阻塞的线程
mLock.notifyAll();
}
}
}
- SP数据保存的格式为xml。相比ProtoBuffer来说,性能较弱。
之前也是做过ProtoBuffer的原理,首先我们知道ProtoBuffer体积非常小,所以在存储上就占据了很大的优势。MMKV底层序列化和反序列化是ProtoBuffer实现的,所以在存储速度上也有着很大的优势。 - 每次写入数据的时候是全量写入。假如xml有100条数据,当插入一条新的数据或者更新一条数据,SP会将全部的数据全部重新写入文件,这是造成SP写入慢的原因。
- 当保存的数据较多时,会在进程中占用过多的内存。
commit() 和 apply() 两个方法都会通过调用 commitToMemory() 方法拿到修改后的全量数据commitToMemory(),SharedPreferences 包含的所有键值对数据都存储在 mapToWriteToDisk 中,Editor 改动到的所有键值对数据都存储在 mModified 中。如果 mClear 为 true,则会先清空 mapToWriteToDisk,然后再遍历 mModified,将 mModified 中的所有改动都同步给 mapToWriteToDisk。最终 mapToWriteToDisk 就保存了要重新写入到磁盘文件中的全量数据,SharedPreferences 会根据 mapToWriteToDisk 完全覆盖掉旧的 xml 文件。
// Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
long memoryStateGeneration;
boolean keysCleared = false;
List<String> keysModified = null;
Set<OnSharedPreferenceChangeListener> listeners = null;
Map<String, Object> mapToWriteToDisk;
synchronized (SharedPreferencesImpl.this.mLock) {
// We optimistically don't make a deep copy until
// a memory commit comes in when we're already
// writing to disk.
if (mDiskWritesInFlight > 0) {
// We can't modify our mMap as a currently
// in-flight write owns it. Clone it before
// modifying it.
// noinspection unchecked
mMap = new HashMap<String, Object>(mMap);
}
//拿到内存中的全量数据
mapToWriteToDisk = mMap;
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
//用于标记最终是否改动到了 mapToWriteToDisk
boolean changesMade = false;
if (mClear) {
if (!mapToWriteToDisk.isEmpty()) {
changesMade = true;
//清空所有在内存中的数据
mapToWriteToDisk.clear();
}
keysCleared = true;
//恢复状态,避免二次修改时状态错位
mClear = false;
}
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// "this" is the magic value for a removal mutation. In addition,
// setting a value to "null" for a given key is specified to be
// equivalent to calling remove on that key.
if (v == 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();
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}
- 不支持多进程模式,想实现需要配合跨进程通讯。
如果想要实现多进程共享数据,就需要自己去实现跨进程通讯,比如ContentProvider、AIDL、或者自己直接实现Binder等方式。
MMKV的优点
- MMKV实现了SharedPreferences接口,基本可以无缝切换。
MMKV提供了API可以直接将SP存储的内容直接转向MMKV存储,不可回退。
SharedPreferences sources = context.getSharedPreferences(name, mode);
mmkv.importFromSharedPreferences(sources);
- 通过mmap映射文件,通过一次拷贝。
通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。通过内存映射实现了文件到用户空间只需要一次拷贝,而SP则需要两次拷贝。
mmap 是 linux 提供的一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系;实现这样的映射关系后,进程就可以采用指针的方式读写操作这一块内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必调用read,write等系统调用函数。
Binder 的底层也是通过了 mmap 来实现一次内存拷贝的多进程通讯,所以MMKV也不用担心多进程下的数据持久化。 - MMKV数据存储序列化方面选用 protobuf 协议。
该协议类比xml有如下几个有点:- 语言无关、平台无关。即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台
- 高效。即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单
- 扩展性、兼容性好。你可以更新数据结构,而不影响和破坏原有的旧程序
- MMKV是增量更新,有性能优势。
增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。所以需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。
MMKV的缺点
- 由上可知,Linux 采用了分页来管理内存,存入数据先要创建一个文件,并要给这个文件分配一个固定的大小。如果存入了一个很小的数据,那么这个文件其余的内存就会被浪费。相反如果存入的数据比文件大,就需要动态扩容。
- 还有一点就是 SP 转 MMKV 简单,如果想要再将 MMKV 转换为其它方式的话,现在是不支持的。如果哪一天 Jetpack DataStore 崛起了,迁移起来可能会比较麻烦。
如何替换并且兼容
如何替换才能更好的兼容之前的代码呢?直接上代码,代码很简单,一看就懂。
dependencies {
implementation 'com.tencent:mmkv:1.2.7'
implementation 'com.getkeepsafe.relinker:relinker:1.4.4'
}
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.getkeepsafe.relinker.ReLinker;
import com.tencent.mmkv.MMKV;
import com.tencent.mmkv.MMKVLogLevel;
import java.util.Set;
/**
* 替换SharedPreferences为MMKV
*/
public class MySharedPreferences {
public static MySharedPreferences getDefaultSharedPreferences() {
Context context = MyApplication.getAppContext();
String defaultName = context.getPackageName() + "_preferences";
return new MySharedPreferences(context, defaultName, Context.MODE_PRIVATE);
}
public static MySharedPreferences getSharedPreferences(String name) {
return new MySharedPreferences(MyApplication.getAppContext(), name, Context.MODE_PRIVATE);
}
public static MySharedPreferences getSharedPreferences(String name, int mode) {
return new MySharedPreferences(null, name, mode);
}
public static MySharedPreferences getSharedPreferences(Context context, String name, int mode) {
return new MySharedPreferences(context, name, mode);
}
/**
* WRITE_TO_MMKV 为ture表示数据写入MMKV,为false,表示数据从MMKV写入SharedPreferences
*/
private static boolean mMMKVEnabled = true;
public static void setMMKVEnable(boolean enable) {
mMMKVEnabled = enable;
}
public static boolean isMMKVEnable() {
return mMMKVEnabled;
}
private MMKV mmkv, defaultMMKV;
private SharedPreferences spData;
private SharedPreferences.Editor spEditor;
private static boolean mmkvInited = false;
public static void initMMKV(Application app) {
if (mmkvInited) {
return;
}
mmkvInited = true;
if (MySharedPreferences.isMMKVEnable()) {
String root = app.getFilesDir().getAbsolutePath() + "/mmkv";
MMKVLogLevel logLevel = MyApplication.isDebuging() ? MMKVLogLevel.LevelDebug : MMKVLogLevel.LevelError;
try {
MMKV.initialize(root, new MMKV.LibLoader() {
@Override
public void loadLibrary(String libName) {
try {
ReLinker.loadLibrary(app, libName);
} catch (Throwable ex) {
MySharedPreferences.setMMKVEnable(false);
}
}
}, logLevel);
} catch (Throwable ex) {
MySharedPreferences.setMMKVEnable(false);
}
}
}
private MySharedPreferences(Context context, String name, int mode) {
if (mMMKVEnabled) {
try {
MMKV.initialize(MyApplication.getAppContext());
this.mmkv = MMKV.mmkvWithID(name);
this.defaultMMKV = MMKV.defaultMMKV();
} catch (IllegalArgumentException iae) {
String message = iae.getMessage();
if (!TextUtils.isEmpty(message) && message.contains("Opening a multi-process MMKV")) {
try {
this.mmkv = MMKV.mmkvWithID(name, MMKV.MULTI_PROCESS_MODE);
this.defaultMMKV = MMKV.defaultMMKV(MMKV.MULTI_PROCESS_MODE, null);
} catch (Throwable ex) {
//如果出现异常抛埋点给服务端
MyStatistics.getEvent().eventNormal("MMKV", 0, 102, name);
return;
}
}
} catch (Throwable ex) {
//如果出现异常抛埋点给服务端
MyStatistics.getEvent().eventNormal("MMKV", 0, 101, name);
return;
}
}
if (null == context) {
context = MyApplication.getAppContext();
}
if (null != context) {
if (mMMKVEnabled) {
if (null != defaultMMKV && !defaultMMKV.contains(name)) {
SharedPreferences sources = context.getSharedPreferences(name, mode);
mmkv.importFromSharedPreferences(sources);
defaultMMKV.encode(name, true);
Logger.i("MySharedPreferences", "transform SP-" + name + " to MMKV");
}
} else {
spData = context.getSharedPreferences(name, mode);
}
}
}
public final class Editor {
public Editor putString(String key, @Nullable String value) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, value);
}
} else {
if (null != spEditor) {
spEditor.putString(key, value);
}
}
return this;
}
public Editor putStringSet(String key, @Nullable Set<String> values) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, values);
}
} else {
if (null != spEditor) {
spEditor.putStringSet(key, values);
}
}
return this;
}
public Editor putInt(String key, int value) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, value);
}
} else {
if (null != spEditor) {
spEditor.putInt(key, value);
}
}
return this;
}
public Editor putLong(String key, long value) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, value);
}
} else {
if (null != spEditor) {
spEditor.putLong(key, value);
}
}
return this;
}
public Editor putFloat(String key, float value) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, value);
}
} else {
if (null != spEditor) {
spEditor.putFloat(key, value);
}
}
return this;
}
public Editor putBoolean(String key, boolean value) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.encode(key, value);
}
} else {
if (null != spEditor) {
spEditor.putBoolean(key, value);
}
}
return this;
}
public Editor remove(String key) {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.removeValueForKey(key);
}
} else {
if (null != spEditor) {
spEditor.remove(key);
}
}
return this;
}
public Editor clear() {
if (mMMKVEnabled) {
if (null != mmkv) {
mmkv.clearAll();
}
} else {
if (null != spEditor) {
spEditor.clear();
}
}
return this;
}
/**
* 无实际意义,只是为了适配以前已经调用了commit的旧的方式
*/
public boolean commit() {
if (!mMMKVEnabled) {
if (null != spEditor) {
return spEditor.commit();
}
}
return true;
}
/**
* 无实际意义,只是为了适配以前已经调用了apply的旧的方式
*/
public void apply() {
if (!mMMKVEnabled) {
if (null != spEditor) {
spEditor.apply();
}
}
}
}
public MySharedPreferences.Editor edit() {
if (!mMMKVEnabled) {
spEditor = spData.edit();
}
return new Editor();
}
@Nullable
public String getString(String key, @Nullable String defValue) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getString(key, defValue);
}
} else {
if (null != spData) {
return spData.getString(key, defValue);
}
}
return defValue;
}
@Nullable
Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getStringSet(key, defValues);
}
} else {
if (null != spData) {
return spData.getStringSet(key, defValues);
}
}
return defValues;
}
public int getInt(String key, int defValue) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getInt(key, defValue);
}
} else {
if (null != spData) {
return spData.getInt(key, defValue);
}
}
return defValue;
}
public long getLong(String key, long defValue) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getLong(key, defValue);
}
} else {
if (null != spData) {
return spData.getLong(key, defValue);
}
}
return defValue;
}
public float getFloat(String key, float defValue) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getFloat(key, defValue);
}
} else {
if (null != spData) {
return spData.getFloat(key, defValue);
}
}
return defValue;
}
public boolean getBoolean(String key, boolean defValue) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.getBoolean(key, defValue);
}
} else {
if (null != spData) {
return spData.getBoolean(key, defValue);
}
}
return defValue;
}
public boolean contains(String key) {
if (mMMKVEnabled) {
if (null != mmkv) {
return mmkv.containsKey(key);
}
} else {
if (null != spData) {
return spData.contains(key);
}
}
return false;
}
}
写到最后
最后,最重要的就是MMKV的缺点,迁移到MMKV是不可逆操作,一定要慎重。