034-Android存储(9):腾讯MMKV 高性能键值存储组件详解

1,552 阅读7分钟

【代码是最好得老师】

1.概要

Github

官方文档

  1. MMKV 是基于 mmap 内存映射的 key-value 组件
  2. 性能高,稳定性强(底层序列化/反序列化使用 protobuf 实现)
  3. 支持加密
  4. 支持多进程共享
  5. 支持匿名内存,内存悬浮不落地文件,安全性极高
  6. 效率极高
  7. 支持SharedPreferences直接迁移
  8. 支持类型:boolean、int、long、float、double、byte[]、String、Set、Parcelable

2.对比&原理

数据来源腾讯官方测试数据

2.1 单进程性能

可以直观看出MMKV性能秒杀SP、SQLite

(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)

2.2 多进程性能

亦可看出,多进程中,MMKV都远远超越 MultiProcessSharedPreferences & SQLite & SQLite. MMKV 在 Android 多进程 key-value 存储组件上是不二之选

(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)

2.3 原理

  • 内存准备

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。

  • 数据组织

数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。

  • 写入优化

考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力。我们考虑将增量 kv 对象序列化后,append 到内存末尾。

  • 空间增长

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。我们需要在性能和空间上做个折中。

更多请移步MMKV原理

3.简单使用

1.module的build.gradle导包

dependencies {
    implementation 'com.tencent:mmkv-static:1.2.7'
}

2.app的Application中初始化,也可选择自定义自定义路径初始化MMKV,见Demo封装的工具类

public void onCreate() {
    super.onCreate();
    //最简单方式
    String rootDir = MMKV.initialize(this);
}

3.简单的使用:存取数据

MMKV kv = MMKV.defaultMMKV();

kv.encode("bool", true);
boolean bValue = kv.decodeBool("bool");

kv.encode("int", Integer.MIN_VALUE);
int iValue = kv.decodeInt("int");

kv.encode("string", "Hello from mmkv");
String str = kv.decodeString("string");

4.高阶用法

同理,先导包

dependencies {
    implementation 'com.tencent:mmkv-static:1.2.7'
}

4.1.初始化(日志、多进程、加密、分组存储)

在app的Application中初始化,也可选择自定义自定义路径初始化MMKV,见Demo封装的工具类

public void onCreate() {
    super.onCreate();
    //进阶方式1
    MMKVHelper.init(this);
    //进阶方式2
    String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
    String rootDir = MMKVHelper.init(dir);
}

MMKVHelper封装类.方法,涵盖加密、多进程使用情形

public static  String ENCRPT_KEY = BuildConfig.LIBRARY_PACKAGE_NAME;//修改默认加密key
public static  String ENCRPT_KEY_MULTI_PROGRESS = BuildConfig.LIBRARY_PACKAGE_NAME;//修改默认加密key2

public static String init(Application context) {
    return init(context ,"",true ,ENCRPT_KEY);
}

public static String init(String mmkvFilePath) {
    return init(null,mmkvFilePath ,true ,ENCRPT_KEY);
}

public static String init(Application context, String path ,boolean openLog ,String encryptKey) {
    String rootDir ;
    if (TextUtils.isEmpty(path)){
        rootDir = MMKV.initialize(context);
    }else {
        rootDir = MMKV.initialize(path);
    }
    if (openLog) {
        Log.d("MMKV root dir:", rootDir);
        MMKV.setLogLevel(MMKVLogLevel.LevelInfo);
    }else {
        //除非有非常强烈的证据表明MMKV的日志拖慢了App的速度,否则不应该关掉日志
        MMKV.setLogLevel(MMKVLogLevel.LevelNone);
    }
    if (!TextUtils.isEmpty(encryptKey)){
        ENCRPT_KEY = encryptKey;
        ENCRPT_KEY_MULTI_PROGRESS = encryptKey;
    }
    return rootDir;
}

4.2.增删改查

简单使用MMKV,默认配置都是单进程、不加密,封装类MMKVHelper进阶使用,开放便捷设置情况,完整代码见Demo封装类代码。

example:

4.2.1 存储(增、改):

public static boolean put(String key, String value) {
    return MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE , ENCRPT_KEY).encode(key, value);
}

public static boolean put(String key, int value) {
    return MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY).encode(key, value);
}

//其他类型同理......

public static Boolean put2Group(String GroupId, String key, Object value) {
    return put2Group(GroupId, key, value, false);
}

//进阶
public static Boolean put2Group(String GroupId, String key, Object value, boolean multiProgress) {
    MMKV mmkv;
    if (multiProgress) {
        //如果业务需要多进程访问,那么在初始化的时候加上标志位 MMKV.MULTI_PROCESS_MODE
        mmkv = MMKV.mmkvWithID(GroupId, MMKV.MULTI_PROCESS_MODE, ENCRPT_KEY_MULTI_PROGRESS);
    } else {
        //默认单进程
        mmkv = MMKV.mmkvWithID(GroupId, MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY);
    }

    boolean flag = false;
    if (value instanceof Boolean) {
        flag = mmkv.encode(key, (Boolean) value);
    }
    if (value instanceof Integer) {
        flag = mmkv.encode(key, (int) value);
    }
    if (value instanceof Float) {
        flag = mmkv.encode(key, (Float) value);
    }
    if (value instanceof Double) {
        flag = mmkv.encode(key, (Double) value);
    }
    if (value instanceof Long) {
        flag = mmkv.encode(key, (Long) value);
    }
    if (value instanceof String) {
        flag = mmkv.encode(key, (String) value);
    }
    if (value instanceof Parcelable) {
        flag = mmkv.encode(key, (Parcelable) value);
    }
    return flag;
}

4.2.2 删除

 //delete simple
public static boolean delete(String deleteItemKey) {
    MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY).remove(deleteItemKey);
    return MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY).contains(deleteItemKey);
}

public static boolean delete(String groupId, String deleteItemKey) {
    return delete(groupId, deleteItemKey, false);
}

public static boolean delete(String groupId, String deleteItemKey, boolean multiProgress) {
    int mode = multiProgress ? MMKV.MULTI_PROCESS_MODE : MMKV.SINGLE_PROCESS_MODE;
    MMKV mmkv;
    if (mode == MMKV.MULTI_PROCESS_MODE) {
        mmkv = MMKV.mmkvWithID(groupId, MMKV.MULTI_PROCESS_MODE, ENCRPT_KEY_MULTI_PROGRESS);
    } else {
        mmkv = MMKV.mmkvWithID(groupId, mode, ENCRPT_KEY);
    }
    mmkv.remove(deleteItemKey);
    return mmkv.contains(deleteItemKey);
}

4.2.3 获取(查询)

基础获取、多进程获取、分组获取

public static String get(String key, String defValue) {
    return MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE , ENCRPT_KEY).decodeString(key, defValue);
}

public static int get(String key, int defValue) {
    return MMKV.defaultMMKV(MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY).decodeInt(key, defValue);
}

public static <T> T getByGroup(String GroupId, String key, Object defValue) {
    return (T) getByGroup(GroupId, key, defValue, false);
}

public static Object getByGroup(String GroupId, String key, Object defValue, boolean multiProgress) {
    MMKV mmkv;
    if (multiProgress) {
        //如果业务需要多进程访问,那么在初始化的时候加上标志位 MMKV.MULTI_PROCESS_MODE
        mmkv = MMKV.mmkvWithID(GroupId, MMKV.MULTI_PROCESS_MODE, ENCRPT_KEY_MULTI_PROGRESS);
    } else {
        //默认单进程
        mmkv = MMKV.mmkvWithID(GroupId, MMKV.SINGLE_PROCESS_MODE, ENCRPT_KEY);
    }

    if (defValue instanceof Boolean) {
        return mmkv.decodeBool(key, (Boolean) defValue);
    }
    if (defValue instanceof Integer) {
        return mmkv.decodeInt(key, (int) defValue);
    }
    if (defValue instanceof Float) {
        return mmkv.decodeFloat(key, (Float) defValue);
    }
    if (defValue instanceof Double) {
        return mmkv.decodeDouble(key, (Double) defValue);
    }
    if (defValue instanceof Long) {
        return mmkv.decodeLong(key, (Long) defValue);
    }
    if (defValue instanceof String) {
        return mmkv.decodeString(key, (String) defValue);
    }
    if (defValue instanceof Parcelable) {
        return mmkv.decodeParcelable(key, (Class<Parcelable>) defValue);
    }
    return null;
}

5.其他设置

5.1 SP迁移

SharedPreferences 迁移 MMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。

MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface,在迁移的时候只需两三行代码即可,其他 CRUD 操作代码都不用改。

private void testImportSharedPreferences() {
    //SharedPreferences preferences = getSharedPreferences("myData", MODE_PRIVATE);
    MMKV preferences = MMKV.mmkvWithID("myData");
    // 迁移旧数据
    {
        SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
        preferences.importFromSharedPreferences(old_man);
        old_man.edit().clear().commit();
    }
    // 跟以前用法一样
    SharedPreferences.Editor editor = preferences.edit();
    editor.putBoolean("bool", true);
    editor.putInt("int", Integer.MIN_VALUE);
    editor.putLong("long", Long.MAX_VALUE);
    editor.putFloat("float", -3.14f);
    editor.putString("string", "hello, imported");
    HashSet<String> set = new HashSet<String>();
    set.add("W"); set.add("e"); set.add("C"); set.add("h"); set.add("a"); set.add("t");
    editor.putStringSet("string-set", set);
    // 无需调用 commit()
    //editor.commit();
}

5.2 日志

MMKV 默认将日志打印到 logcat,不便于对线上问题进行定位和解决。你可以在 App 启动时接收转发 MMKV 的日志。实现MMKVHandler接口,添加类似下面的代码:

@Override
public boolean wantLogRedirecting() {
    return true;
}

@Override
public void mmkvLog(MMKVLogLevel level, String file, int line, String func, String message) {
    String log = "<" + file + ":" + line + "::" + func + "> " + message;
    switch (level) {
        case LevelDebug:
            //Log.d("redirect logging MMKV", log);
            break;
        case LevelInfo:
            //Log.i("redirect logging MMKV", log);
            break;
        case LevelWarning:
            //Log.w("redirect logging MMKV", log);
            break;
        case LevelError:
            //Log.e("redirect logging MMKV", log);
            break;
        case LevelNone:
            //Log.e("redirect logging MMKV", log);
            break;
    }
}

日志组件推荐使用 xlog,同样也是开源自微信团队。

关闭日志(不建议):

除非有非常强烈的证据表明MMKV的日志拖慢了App的速度,否则不应该关掉日志。没有日志,日后万一用户有问题,将无法跟进。

MMKV.setLogLevel(MMKVLogLevel.LevelNone);//关闭日志

5.3 加密

MMKV 默认明文存储所有 key-value,依赖 Android 系统的沙盒机制保证文件加密。如果你担心信息泄露,你可以选择加密 MMKV。

String cryptKey = "My-Encrypt-Key";
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.SINGLE_PROCESS_MODE, cryptKey);
你可以更改密钥,也可以将一个加密 MMKV 改成明文,或者反过来。

final String mmapID = "testAES_reKey1";
// an unencrypted MMKV instance
MMKV kv = MMKV.mmkvWithID(mmapID, MMKV.SINGLE_PROCESS_MODE, null);

// change from unencrypted to encrypted
kv.reKey("Key_seq_1");

// change encryption key
kv.reKey("Key_seq_2");

// change from encrypted to unencrypted
kv.reKey(null);

5.4 自定义根目录

MMKV 默认把文件存放在$(FilesDir)/mmkv/目录。你可以在 App 启动时自定义根目录:

String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
String rootDir = MMKV.initialize(dir);
Log.i("MMKV", "mmkv root: " + rootDir);
MMKV 甚至支持自定义某个文件的目录:

String relativePath = getFilesDir().getAbsolutePath() + "/mmkv_3";
MMKV kv = MMKV.mmkvWithID("testCustomDir", relativePath);

注意:官方推荐将 MMKV 文件存储在你 App 的私有路径内部,不要 存储在 external storage(也就是 SD card)。

5.5 自定义 library loader

一些 Android 设备(API level 19)在安装/更新 APK 时可能出错, 导致 libmmkv.so 找不到。然后就会遇到 java.lang.UnsatisfiedLinkError 之类的 crash。有个开源库 ReLinker 专门解决这个问题,你可以用它来加载 MMKV:

String dir = getFilesDir().getAbsolutePath() + "/mmkv";
MMKV.initialize(dir, new MMKV.LibLoader() {
    @Override
    public void loadLibrary(String libName) {
        ReLinker.loadLibrary(MyApplication.this, libName);
    }
});

5.6 Native Buffer

当从 MMKV 取一个 String or byte[]的时候,会有一次从 native 到 JVM 的内存拷贝。如果这个值立即传递到另一个 native 库(JNI),又会有一次从 JVM 到 native 的内存拷贝。当这个值比较大的时候,整个过程会非常浪费。Native Buffer 就是为了解决这个问题。 Native Buffer 是由 native 创建的内存缓冲区,在 Java 里封装成 NativeBuffer 类型,可以透明传递到另一个 native 库进行访问处理。整个过程避免了先拷内存到 JVM 又从 JVM 拷回来导致的浪费。示例代码:

int sizeNeeded = kv.getValueActualSize("bytes");
NativeBuffer nativeBuffer = MMKV.createNativeBuffer(sizeNeeded);
if (nativeBuffer != null) {
    int size = kv.writeValueToNativeBuffer("bytes", nativeBuffer);
    Log.i("MMKV", "size Needed = " + sizeNeeded + " written size = " + size);

    // pass nativeBuffer to another native library
    // ...

    // destroy when you're done
    MMKV.destroyNativeBuffer(nativeBuffer);
}

5.7 数据恢复

在 crc 校验失败,或者文件长度不对的时候,MMKV 默认会丢弃所有数据。你可以让 MMKV 恢复数据。要注意的是修复率无法保证,而且可能修复出奇怪的 key-value。同样地也是实现MMKVHandler接口,添加以下代码:

@Override
public MMKVRecoverStrategic onMMKVCRCCheckFail(String mmapID) {
    return MMKVRecoverStrategic.OnErrorRecover;
}

@Override
public MMKVRecoverStrategic onMMKVFileLengthError(String mmapID) {
    return MMKVRecoverStrategic.OnErrorRecover;
}

5.8 多进程与实现

多进程设计与实现