目标
了解MMKV
MMKV的基本应用
MMKV的原理概念
多进程设计思想
性能对比
源码解读
简介
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。
官方文档:https://github.com/Tencent/MMKV/blob/master/README_CN.md
项目地址:https://github.com/Tencent/MMKV
mmap
简单解释(仅供参考)
把文件描述符fd(部分硬件资源外存统一描述符)映射到虚拟空间中,所以能够实现进程间的通信、数据存取。
映射流程(仅供参考)
1、用户进程调用内存映射函数库mmap,当前进程在虚拟地址空间中,寻找一段空闲的满足要求的虚拟地址。
2、此时内核收到相关请求后会调用内核的mmap函数,注意,不同于用户空间库函数。内核mmap函数通过虚拟文件系统定位到文件磁盘物理地址,既实现了文件地址和虚拟地址区域的映射关系。 此时,这片虚拟地址并没有任何数据关联到主存中。
注意,前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
3、进程的读或写操作访问虚拟地址空间这一段映射地址,现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页中断。
4、由于引发了缺页中断,内核则调用nopage函数把所缺的页从磁盘装入到主存中。
5、之后用户进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
应用
Linux进程的创建
Android Binder
微信MMKV组件
美团Logan
参考文章
ProtoBuf
简介
protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
更多内容、实际应用可参考官方文档。
官方文档:https://developers.google.com/protocol-buffers/docs/overview
特性
语言无关、平台无关:即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台
高效:即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单
扩展性、兼容性好:你可以更新数据结构,而不影响和破坏原有的旧程序
数据结构
时间效率对比:
数据格式 | 1000条数据 | 5000条数据 |
---|---|---|
Protobuf | 195ms | 647ms |
Json | 515ms | 2293ms |
空间效率对比:
数据格式 | 5000条数据 |
---|---|
Protobuf | 22MB |
Json | 29MB |
参考文章
简单使用
MMKV 的使用非常简单,所有变更立马生效,无需调用 sync
、apply
。
依赖
1 2 3 4 |
dependencies { implementation 'com.tencent:mmkv:1.0.10' // replace "1.0.10" with any available version } |
初始化
配置 MMKV 根目录
在 App 启动时初始化 MMKV,设定 MMKV 的根目录(files/mmkv/),例如在 Application
里:
1 2 3 4 5 6 7 |
public void onCreate() { super.onCreate(); String rootDir = MMKV.initialize(this); System.out.println("mmkv root: " + rootDir); //data/user/0包名/files/mmkv } |
其他初始化的方法
1 2 3 4 5 6 7 8 9 10 |
//指定日志级别 initialize(Context context, MMKVLogLevel logLevel) //指定存储地址和日志级别 initialize(String rootDir) initialize(String rootDir, MMKVLogLevel logLevel) //MMKV.LibLoader用来解决Android 设备(API level 19)在安装/更新 APK 时出错问题 initialize(String rootDir, MMKV.LibLoader loader) initialize(String rootDir, MMKV.LibLoader loader, MMKVLogLevel logLevel) |
CRUD 操作
MMKV 提供一个全局的实例,可以直接使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import com.tencent.mmkv.MMKV; //…… 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"); |
删除 & 查询
1 2 3 4 5 6 7 8 9 |
MMKV kv = MMKV.defaultMMKV(); kv.removeValueForKey("bool"); System.out.println("bool: " + kv.decodeBool("bool")); kv.removeValuesForKeys(new String[]{"int", "long"}); System.out.println("allKeys: " + Arrays.toString(kv.allKeys())); boolean hasBool = kv.containsKey("bool"); |
区分存储
使用MMKV.mmkvWithID即可创建不同的存储区域的MMKV实例。
1 2 3 |
MMKV kv = MMKV.mmkvWithID("MyID"); kv.encode("bool", true); |
支持的数据类型
-
支持以下 Java 语言基础类型:
boolean、int、long、float、double、byte[]
-
支持以下 Java 类和容器:
String、Set<String>
- 任何实现了
Parcelable
的类型
SharedPreferences 迁移
- MMKV 提供了
importFromSharedPreferences()
函数,可以比较方便地迁移数据过来
1 2 3 4 5 |
/** * An highly efficient, reliable, multi-process key-value storage framework. * THE PERFECT drop-in replacement for SharedPreferences and MultiProcessSharedPreferences. */ public class MMKV implements SharedPreferences, SharedPreferences.Editor { |
- MKV 还额外实现了一遍
SharedPreferences
、SharedPreferences.Editor
这两个 interface,在迁移的时候只需两三行代码即可,其他 CRUD 操作代码都不用改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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(); } |
进阶使用
日志
日志切面AOP思想
MMKV 默认将日志打印到 logcat,不便于对线上问题进行定位和解决。你可以在 App 启动时接收转发 MMKV 的日志。实现MMKVHandler
接口,添加类似下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
@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; } } |
如果你不希望 MMKV 打印日志,你可以关掉它(虽然我们强烈不建议你这么做)。
注意:除非有非常强烈的证据表明MMKV的日志拖慢了App的速度,你不应该关掉日志。没有日志,日后万一用户有问题,将无法跟进。
1 |
MMKV.setLogLevel(MMKVLogLevel.LevelNone); |
加密
MMKV 默认明文存储所有 key-value,依赖 Android 系统的沙盒机制保证文件加密。如果你担心信息泄露,你可以选择加密 MMKV。
1 2 3 |
String cryptKey = "My-Encrypt-Key"; MMKV kv = MMKV.mmkvWithID("MyID", MMKV.SINGLE_PROCESS_MODE, cryptKey); |
你可以更改密钥,也可以将一个加密 MMKV 改成明文,或者反过来。
1 2 3 4 5 6 7 8 9 10 11 12 |
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); |
自定义 library loader
一些 Android 设备(API level 19)在安装/更新 APK 时可能出错, 导致 libmmkv.so 找不到。然后就会遇到 java.lang.UnsatisfiedLinkError
之类的 crash。有个开源库 ReLinker 专门解决这个问题,你可以用它来加载 MMKV:
1 2 3 4 5 6 7 8 |
String dir = getFilesDir().getAbsolutePath() + "/mmkv"; MMKV.initialize(dir, new MMKV.LibLoader() { @Override public void loadLibrary(String libName) { ReLinker.loadLibrary(MyApplication.this, libName); } }); |
Relinker简介:
本地库加载框架,github1000+的star
原理:
尝试使用系统原生方式去加载so,如果加载失败,Relinker会尝试从apk中拷贝so到App沙箱目录下,然后再去尝试加载so。最终,我们可以使用 ReLinker.loadLibrary(context, “mylibrary”) 来加载本地库。
Native Buffer
当从 MMKV 取一个 String
or byte[]
的时候,会有一次从 native 到 JVM 的内存拷贝。如果这个值立即传递到另一个 native 库(JNI),又会有一次从 JVM 到 native 的内存拷贝。当这个值比较大的时候,整个过程会非常浪费。Native Buffer 就是为了解决这个问题。
Native Buffer 是由 native 创建的内存缓冲区,在 Java 里封装成 NativeBuffer
类型,可以透明传递到另一个 native 库进行访问处理。整个过程避免了先拷内存到 JVM 又从 JVM 拷回来导致的浪费。示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
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); } |
跨进程通信的实现
本质:共享MMKV实例化信息完成对象的伪复制
-
通信的数据对象
该类MMKV内部已经实现,传递进程A的mmkv信息给B进程,B进程新建MMKV实例,B就可以通过MMKV实例来完成数据的操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public final class ParcelableMMKV implements Parcelable { private final String mmapID; private int ashmemFD = -1; private int ashmemMetaFD = -1; private String cryptKey = null; public ParcelableMMKV(MMKV mmkv) { mmapID = mmkv.mmapID(); ashmemFD = mmkv.ashmemFD(); ashmemMetaFD = mmkv.ashmemMetaFD(); cryptKey = mmkv.cryptKey(); } private ParcelableMMKV(String id, int fd, int metaFD, String key) { mmapID = id; ashmemFD = fd; ashmemMetaFD = metaFD; cryptKey = key; } public MMKV toMMKV() { if (ashmemFD >= 0 && ashmemMetaFD >= 0) { return MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey); } return null; } } |
-
Aidl文件,需要手动创建该文件
12345import com.tencent.mmkv.ParcelableMMKV;interface IAshmemMMKV {ParcelableMMKV GetAshmemMMKV();}Aidl定义了跨进程通信的方法细则,这里只需要一个get方法,返回ParcelableMMKV通信实体。
-
服务端
服务端Service
12345678910111213141516171819202122232425262728public class UserServer extends Service {@Nullable@Overridepublic IBinder onBind(Intent intent) {Log.i(TAG, "onBind, intent=" + intent);return new AshmemMMKVGetter();}}public class AshmemMMKVGetter extends IAshmemMMKV.Stub {private AshmemMMKVGetter() {// 1M, ashmem cannot change size after openedfinal String id = "tetAshmemMMKV";try {m_ashmemMMKV = MMKV.mmkvWithAshmemID(BenchMarkBaseService.this, id, AshmemMMKV_Size,MMKV.MULTI_PROCESS_MODE, CryptKey);m_ashmemMMKV.encode("bool", true);} catch (Exception e) {Log.e("MMKV", e.getMessage());}}public ParcelableMMKV GetAshmemMMKV() {return new ParcelableMMKV(m_ashmemMMKV);}} -
客户端
onServiceConnected连接之后
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
Intent intent = new Intent(); intent.setAction("***.***.***"); intent.setPackage("***.***.***"); bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); private ServiceConnection serviceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { IAshmemMMKV ashmemMMKV = IAshmemMMKV.Stub.asInterface(service); try { ParcelableMMKV parcelableMMKV = ashmemMMKV.GetAshmemMMKV(); if (parcelableMMKV != null) { m_ashmemMMKV = parcelableMMKV.toMMKV(); if (m_ashmemMMKV != null) { Log.i("MMKV", "ashmem bool: " + m_ashmemMMKV.decodeBool("bool")); } } } catch (RemoteException e) { e.printStackTrace(); } } @Override public void onServiceDisconnected(ComponentName name) { isBind = false; } }; |
原理
内存准备
通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。
数据组织
数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到我们要提供的是通用 kv 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,考虑将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
message KV { string key = 1; buffer value = 2; } -(BOOL)setInt32:(int32_t)value forKey:(NSString*)key { auto data = PBEncode(value); return [self setData:data forKey:key]; } -(BOOL)setData:(NSData*)data forKey:(NSString*)key { auto kv = KV { key, data }; auto buf = PBEncode(kv); return [self write:buf]; } |
写入优化
标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。
空间增长
使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 kv 文件就这一个 key,不到 1k 空间就存得下。这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
-(BOOL)append:(NSData*)data { if (space >= data.length) { append(fd, data); } else { newData = unique(m_allKV); if (total_space >= newData.length) { write(fd, newData); } else { while (total_space < newData.length) { total_space *= 2; } ftruncate(fd, total_space); write(fd, newData); } } } |
数据有效性
考虑到文件系统、操作系统都有一定的不稳定性,我们另外增加了 crc 校验,对无效数据进行甄别。在 iOS 微信现网环境上,我们观察到有平均约 70万日次的数据校验不通过。
多进程设计思想
官网地址:https://github.com/Tencent/MMKV/wiki/android_ipc
官网有详细的说明,这里主要分享思想:
CS架构:
IPC CS架构有Binder、Socket等,特点是一个单独进程管理数据,数据同步不易出错,简单好用易上手,缺点是慢。
去中心化:
只需要将文件 mmap 到每个访问进程的内存空间,加上合适的进程锁,再处理好数据的同步,就能够实现多进程并发访问。
性能对比
单进程
读写效率
mmkv | SharedPreferences | sqlite | |
---|---|---|---|
write int 1000 | 6.5 | 693.1 | 774.4 |
write String 1000 | 18.9 | 1003.9 | 857.3 |
read int 1000 | 4.3 | 1.5 | 302.9 |
read String 1000 | 8.3 | 1.3 | 320.7 |
单进程性能
可见,MMKV 在写入性能上远远超越 SharedPreferences & SQLite,在读取性能上也有相近或超越的表现。
(测试机器是 华为 Mate 20 Pro 128G,Android 10,每组操作重复 1k 次,时间单位是 ms。)
多进程性能
可见,MMKV 无论是在写入性能还是在读取性能,都远远超越 MultiProcessSharedPreferences & SQLite & SQLite。
性能对比: https://github.com/Tencent/MMKV/wiki/android_benchmark_cn
原理上和SharedPreference区别
SharedPreference原理
本质是在本地磁盘记录了一个xml文件,在构造方法中开启一个子线程加载磁盘中的xml文件
1 2 3 4 5 6 7 8 9 10 11 |
@UnsupportedAppUsage private void startLoadFromDisk() { synchronized (mLock) { mLoaded = false; } new Thread("SharedPreferencesImpl-load") { public void run() { loadFromDisk(); } }.start(); } |
SharedPreferencesImpl内部维护Map缓存,所以SharedPreference读的效率很高,但是写得时候都是通过FileOutputStreame文件IO得方式完成数据更新操作。
MMKV
利用mmap完成数据的读写,读写高效。
SharedPreference | MMKV | |
---|---|---|
读写方式 | IO | mmap |
数据格式 | XML | 总体结构、整型编码、二进制 |
更新方式 | 全量更新 | 增量与全量写入 |
SharedPreferences注意点
- 只要file name相同,拿到的就是同一个SharedPreferencesImpl对象,内部有缓存机制,首次获取才会创建对象。
- 在SharedPreferencesImpl构造方法中,会开启子线程把对应的文件key-value全部加载进内存,加载结束后,mLoaded被设置为true。
- 调用getXXX方法时,会阻塞等待直到mLoaded为true,也就是getXXX方法是有可能阻塞UI线程的,另外,调用contains和 edit等方法也是。
- 写数据时,会先拿到一个EditorImpl对象,然后putXXX,这时只是把数据写入到内存中,最后调用commit或者apply方法,才会真正写入文件。
- 不管是commit还是apply方法,第一步都是调用commitToMemory方法生成一个MemoryCommitResult对象,注意这里会先处理clear旧的key-value,再处理新添加的key-value,另外value为this或者null都表示需要被remove掉。
- 调用commit方法,就会同步执行写入文件的操作,该方法是耗时操作,不能在主线程中调用,该方法最后会返回成功或失败结果。
- 调用apply方法,就会把任务放到QueuedWork的队列中,然后在HandlerThread中执行,然后apply方法会立即返回。但如果是Android8.0之前,这里就是放到QueuedWork的一个单线程中执行了。
- 最后是写入文件,会先把原有的文件命名为bak备份文件,然后创建新的文件全量写入,写入成功后,把bak备份文件删除掉。
安全
基于Android的沙盒模式,在内存读写的方式上做了改变,所以不存在应用程序之前的安全问题。
MMKV使用ProtoBuf 编码,另外增加了内部实现的加密模式(AES CFB),相比SharedPrefrence,在文件暴露的情况下MMKV的数据不具有可读性。
在TV中的应用
配置参数较多、需要频繁读写修改参数的场景
可以提高读写耗时,减少SP带来的耗时成本和操作不当引发的ANR
源码解读
初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public static String initialize(Context context) { String root = context.getFilesDir().getAbsolutePath() + "/mmkv"; MMKVLogLevel logLevel = MMKVLogLevel.LevelInfo; return initialize(root, (MMKV.LibLoader)null, logLevel); } public static String initialize(String rootDir, LibLoader loader, MMKVLogLevel logLevel) { if (loader != null) { if (BuildConfig.FLAVOR.equals("SharedCpp")) { loader.loadLibrary("c++_shared"); } loader.loadLibrary("mmkv"); } else { if (BuildConfig.FLAVOR.equals("SharedCpp")) { System.loadLibrary("c++_shared"); } System.loadLibrary("mmkv"); } MMKV.rootDir = rootDir; jniInitialize(MMKV.rootDir, logLevel2Int(logLevel)); return rootDir; } |
1.当不指定目录的时候,会创建一个app内的/data/data/包名/files/mmkv的目录。所有的文件都保存在里面;
2.加载两个so库,c++_shared以及mmkv, 根据打包配置来选择是否要加载c++_shared
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
native_bridge.cpp MMKV_JNI void jniInitialize(JNIEnv *env, jobject obj, jstring rootDir, jint logLevel) { if (!rootDir) { return; } const char *kstr = env->GetStringUTFChars(rootDir, nullptr); if (kstr) { //获取rootDir的url char指针数组字符串,调用MMKV::initializeMMKV进一步初始化。 MMKV::initializeMMKV(kstr, (MMKVLogLevel) logLevel); env->ReleaseStringUTFChars(rootDir, kstr); } } MMKV.cpp void initialize() { //创建了MMKV实例的散列表 g_instanceDic = new unordered_map<string, MMKV *>; g_instanceLock = new ThreadLock(); g_instanceLock->initialize(); mmkv::DEFAULT_MMAP_SIZE = mmkv::getPageSize(); MMKVInfo("page size:%d", DEFAULT_MMAP_SIZE); } ThreadOnceToken_t once_control = ThreadOnceUninitialized; void MMKV::initializeMMKV(const MMKVPath_t &rootDir, MMKVLogLevel logLevel) { g_currentLogLevel = logLevel; //初始化全局的线程锁ThreadLock ThreadLock::ThreadOnce(&once_control, initialize); g_rootDir = rootDir; //创建文件夹 mkPath(g_rootDir); MMKVInfo("root dir: " MMKV_PATH_FORMAT, g_rootDir.c_str()); } |
MMKV 的实例化
java层的实例化
defaultMMKV
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public static MMKV defaultMMKV() { if (rootDir == null) { throw new IllegalStateException("You should Call MMKV.initialize() first."); } long handle = getDefaultMMKV(SINGLE_PROCESS_MODE, null); return new MMKV(handle); } //构造函数 private MMKV(long handle) { nativeHandle = handle; } |
getDefaultMMKV Native层做好实例化工作返回一个long类型的handle,以这个handler作为Java层MMKV的构造参数
mmkvWithID
与defaultMMKV区别就是多了参数设置
1 2 3 4 5 6 7 8 |
public static MMKV mmkvWithID(String mmapID) { if (rootDir == null) { throw new IllegalStateException("You should Call MMKV.initialize() first."); } long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, null); return new MMKV(handle); } |
native层实例化
native-bridge.cpp==>getDefaultMMKV
MMKV.cpp==>mmkvWithID 默认的ID为mmkv.default
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
native-bridge.cpp MMKV_JNI jlong getDefaultMMKV(JNIEnv *env, jobject obj, jint mode, jstring cryptKey) { MMKV *kv = nullptr; if (cryptKey) { string crypt = jstring2string(env, cryptKey); if (crypt.length() > 0) { kv = MMKV::defaultMMKV((MMKVMode) mode, &crypt); } } if (!kv) { kv = MMKV::defaultMMKV((MMKVMode) mode, nullptr); } return (jlong) kv; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
MMKV.cpp #define DEFAULT_MMAP_ID "mmkv.default" MMKV *MMKV::defaultMMKV(MMKVMode mode, string *cryptKey) { #ifndef MMKV_ANDROID return mmkvWithID(DEFAULT_MMAP_ID, mode, cryptKey); #else return mmkvWithID(DEFAULT_MMAP_ID, DEFAULT_MMAP_SIZE, mode, cryptKey); #endif } MMKV.h static MMKV *mmkvWithID(const std::string &mmapID, int size = mmkv::DEFAULT_MMAP_SIZE, MMKVMode mode = MMKV_SINGLE_PROCESS, std::string *cryptKey = nullptr, MMKVPath_t *relativePath = nullptr); |
只要是实例化,最后都是调用mmkvWithID进行实例化。默认的mmkv的id就是mmkv.default
mmkvWithID
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
MMKV.cpp MMKV *MMKV::mmkvWithID(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) { if (mmapID.empty()) { return nullptr; } SCOPED_LOCK(g_instanceLock); //取 mmapID relativePath MMKV_PATH_SLASH 的 md5值作为key auto mmapKey = mmapedKVKey(mmapID, relativePath); auto itr = g_instanceDic->find(mmapKey); if (itr != g_instanceDic->end()) { MMKV *kv = itr->second; return kv; } if (relativePath) { if (!isFileExist(*relativePath)) { if (!mkPath(*relativePath)) { return nullptr; } } MMKVInfo("prepare to load %s (id %s) from relativePath %s", mmapID.c_str(), mmapKey.c_str(), relativePath->c_str()); } //实例化 auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath); (*g_instanceDic)[mmapKey] = kv; return kv; } |
将所有的MMKV实例都会保存在之前实例化的g_instanceDic散列表中。其中mmkv每一个id对应一个文件的路径:
- 相对路径(android中是 data/data/包名/files/mmkv) + / + mmkvID
如果发现对应路径下的mmkv在散列表中已经缓存了,则直接返回。否则就会把相对路径保存下来,传递给MMKV进行实例化,并保存在g_instanceDic散列表中。
MMKV 的构造函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
MMKV::MMKV(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) : m_mmapID(mmapedKVKey(mmapID, relativePath)) // historically Android mistakenly use mmapKey as mmapID , m_path(mappedKVPathWithID(m_mmapID, mode, relativePath)) , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath)) , m_file(new MemoryFile(m_path, size, (mode & MMKV_ASHMEM) ? MMFILE_TYPE_ASHMEM : MMFILE_TYPE_FILE)) , m_metaFile(new MemoryFile(m_crcPath, DEFAULT_MMAP_SIZE, m_file->m_fileType)) , m_metaInfo(new MMKVMetaInfo()) , m_crypter(nullptr) , m_lock(new ThreadLock()) , m_fileLock(new FileLock(m_metaFile->getFd(), (mode & MMKV_ASHMEM))) , m_sharedProcessLock(new InterProcessLock(m_fileLock, SharedLockType)) , m_exclusiveProcessLock(new InterProcessLock(m_fileLock, ExclusiveLockType)) , m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0) { m_actualSize = 0; m_output = nullptr; if (cryptKey && cryptKey->length() > 0) { m_crypter = new AESCrypt(cryptKey->data(), cryptKey->length()); } m_needLoadFromFile = true; m_hasFullWriteback = false; m_crcDigest = 0; m_sharedProcessLock->m_enable = m_isInterProcess; m_exclusiveProcessLock->m_enable = m_isInterProcess; // sensitive zone { SCOPED_LOCK(m_sharedProcessLock); loadFromFile(); } } |
- 1.m_mmapID MMKV的ID通过mmapedKVKey创建:
1 2 3 4 5 6 |
string mmapedKVKey(const string &mmapID, MMKVPath_t *relativePath) { if (relativePath && g_rootDir != (*relativePath)) { return md5(*relativePath + MMKV_PATH_SLASH + string2MMKVPath_t(mmapID)); } return mmapID; } |
mmkvID就是经过md5后对应缓存文件对应的路径。
- 2.m_path mmkv 缓存的路径通过mappedKVPathWithID生成
1 2 3 4 5 6 7 8 9 10 11 12 |
MMKVPath_t mappedKVPathWithID(const string &mmapID, MMKVMode mode, MMKVPath_t *relativePath) { #ifndef MMKV_ANDROID ... #else if (mode & MMKV_ASHMEM) { return ashmemMMKVPathWithID(encodeFilePath(mmapID)); } else if (relativePath) { #endif return *relativePath + MMKV_PATH_SLASH + encodeFilePath(mmapID); } return g_rootDir + MMKV_PATH_SLASH + encodeFilePath(mmapID); } |
能看到这里是根据当前的mode初始化id,如果不是ashmem匿名共享内存模式进行创建,则会和上面的处理类似。id就是经过md5后对应缓存文件对应的路径。
注意这里mode设置的是MMKV_ASHMEM,也就是ashmem匿名共享内存模式则是如下创建方法:
1 2 3 4 5 |
constexpr char ASHMEM_NAME_DEF[] = "/dev/ashmem"; MMKVPath_t ashmemMMKVPathWithID(const MMKVPath_t &mmapID) { return MMKVPath_t(ASHMEM_NAME_DEF) + MMKV_PATH_SLASH + mmapID; } |
实际上就是在驱动目录下的一个内存文件地址。
- 3.m_crcPath 一个.crc文件的路径。这个crc文件实际上用于保存crc数据校验key,避免出现传输异常的数据进行保存了。
- 4.m_file 一个依据m_path构建的内存文件MemoryFile对象。
- 5.m_metaFile 一个依据m_crcPath构建的内存文件MemoryFile对象。
- 6.m_metaInfo 一个MMKVMetaInfo结构体,这个结构体一般是读写的时候,带上的MMKV的版本信息,映射的内存大小,加密crc的key等。
- 7.m_crypter 默认是一个AESCrypt 对称加密器
- 8.m_lock ThreadLock线程锁
- 9.m_fileLock 一个以m_metaFile的fd 文件锁
- 10.m_sharedProcessLock 类型是InterProcessLock,这是一种文件共享锁
- 11.m_exclusiveProcessLock 类型是InterProcessLock,这是一种排他锁
- 12.m_isInterProcess 判断是否打开了多进程模式的标志位,一旦关闭了,所有进程锁都会失效。
Ashmem匿名共享内存
Anonymous Shared Memory-Ashmem
简单理解:
共享内存是Linux自带的一种IPC机制,Android直接使用了该模型,不过做出了自己的改进,进而形成了Android的匿名共享内存(Anonymous Shared Memory-Ashmem)
应用:
APP进程同SurfaceFlinger共用一块内存,如此,就不需要进行数据拷贝,APP端绘制完毕,通知SurfaceFlinger端合成,再输出到硬件进行显示即可
更多文章
多进程MMKV实例化
多进程通信的过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
服务端创建MMKV实例 m_ashmemMMKV = MMKV.mmkvWithAshmemID(BenchMarkBaseService.this, id, AshmemMMKV_Size,MMKV.MULTI_PROCESS_MODE, CryptKey); Aidl传递实体 ParcelableMMKV(m_ashmemMMKV); Aidl传递实体 ParcelableMMKV字段 mmapID = mmkv.mmapID(); ashmemFD = mmkv.ashmemFD(); ashmemMetaFD = mmkv.ashmemMetaFD(); cryptKey = mmkv.cryptKey(); 客户端获取传递实体ParcelableMMKV AshmemMMKV ashmemMMKV = IAshmemMMKV.Stub.asInterface(service); ParcelableMMKV parcelableMMKV = ashmemMMKV.GetAshmemMMKV(); 客户端获取真正的操作数据的MMKV实例 parcelableMMKV.toMMKV() public MMKV toMMKV() { if (ashmemFD >= 0 && ashmemMetaFD >= 0) { return MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey); } return null; } 看一下mmkvWithAshmemFD MMKV.mmkvWithAshmemFD(mmapID, ashmemFD, ashmemMetaFD, cryptKey); |
mmkvWithAshmemFD
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
MMKV *MMKV::mmkvWithAshmemFD(const string &mmapID, int fd, int metaFD, string *cryptKey) { if (fd < 0) { return nullptr; } SCOPED_LOCK(g_instanceLock); auto itr = g_instanceDic->find(mmapID); if (itr != g_instanceDic->end()) { MMKV *kv = itr->second; # ifndef MMKV_DISABLE_CRYPT kv->checkReSetCryptKey(fd, metaFD, cryptKey); # endif return kv; } auto kv = new MMKV(mmapID, fd, metaFD, cryptKey); (*g_instanceDic)[mmapID] = kv; return kv; } MMKV::MMKV(const string &mmapID, int ashmemFD, int ashmemMetaFD, string *cryptKey) : m_mmapID(mmapID) , m_path(mappedKVPathWithID(m_mmapID, MMKV_ASHMEM, nullptr)) , m_crcPath(crcPathWithID(m_mmapID, MMKV_ASHMEM, nullptr)) , m_dic(nullptr) , m_dicCrypt(nullptr) , m_file(new MemoryFile(ashmemFD)) , m_metaFile(new MemoryFile(ashmemMetaFD)) , m_metaInfo(new MMKVMetaInfo()) , m_crypter(nullptr) , m_lock(new ThreadLock()) , m_fileLock(new FileLock(m_metaFile->getFd(), true)) , m_sharedProcessLock(new InterProcessLock(m_fileLock, SharedLockType)) , m_exclusiveProcessLock(new InterProcessLock(m_fileLock, ExclusiveLockType)) , m_isInterProcess(true) { |
encode 写入数据
encodeString
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
MMKV_JNI jboolean encodeString(JNIEnv *env, jobject, jlong handle, jstring oKey, jstring oValue) { MMKV *kv = reinterpret_cast<MMKV *>(handle); if (kv && oKey) { string key = jstring2string(env, oKey); if (oValue) { string value = jstring2string(env, oValue); return (jboolean) kv->set(value, key); } else { kv->removeValueForKey(key); return (jboolean) true; } } return (jboolean) false; } bool MMKV::set(const string &value, MMKVKey_t key) { if (isKeyEmpty(key)) { return false; } auto data = MiniPBCoder::encodeDataWithObject(value); return setDataForKey(std::move(data), key); } |
- 1.encodeDataWithObject 编码压缩内容
- 2.setDataForKey 保存数据
setDataForKey
保存数据到映射的文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
bool MMKV::setDataForKey(MMBuffer &&data, MMKVKey_t key) { if (data.length() == 0 || isKeyEmpty(key)) { return false; } SCOPED_LOCK(m_lock); SCOPED_LOCK(m_exclusiveProcessLock); checkLoadData(); auto ret = appendDataWithKey(data, key); if (ret) { m_dic[key] = std::move(data); m_hasFullWriteback = false; } return ret; } |
设置了互斥锁,和线程锁。整个步骤分为两步骤:
- 1.checkLoadData 保存数据之前,校验已经存储的数据
- 2.appendDataWithKey 进行数据的保存
appendDataWithKey
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
bool MMKV::appendDataWithKey(const MMBuffer &data, MMKVKey_t key) { size_t keyLength = key.length(); // size needed to encode the key size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // size needed to encode the value size += data.length() + pbRawVarint32Size((int32_t) data.length()); SCOPED_LOCK(m_exclusiveProcessLock); bool hasEnoughSize = ensureMemorySize(size); if (!hasEnoughSize || !isFileValid()) { return false; } m_output->writeString(key); m_output->writeData(data); // note: write size of data auto ptr = (uint8_t *) m_file->getMemory() + Fixed32Size + m_actualSize; if (m_crypter) { m_crypter->encrypt(ptr, ptr, size); } m_actualSize += size; updateCRCDigest(ptr, size); return true; } |
判断是否有足够的空间,没有则调用ensureMemorySize进行扩容,实在无法从内存中映射出来,那说明系统没空间了就返回异常。
正常情况下,是往全局缓冲区CodedOutputData 先后在文件内存的末尾写入key和value的数据。并对这部分的数据进行一次加密,最后更新这个存储区域的crc校验码。
这里实际上是调用了CodedOutputData的writeString把数据保存到映射的内存中。
1 2 3 4 5 6 7 8 9 10 11 |
void CodedOutputData::writeString(const string &value) { size_t numberOfBytes = value.size(); this->writeRawVarint32((int32_t) numberOfBytes); if (m_position + numberOfBytes > m_size) { auto msg = "m_position: " + to_string(m_position) + ", numberOfBytes: " + to_string(numberOfBytes) + ", m_size: " + to_string(m_size); throw out_of_range(msg); } memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes); m_position += numberOfBytes; } |
decode MMKV读取数据
MMKV读取数据
1 2 3 4 5 6 7 8 9 10 11 12 |
MMKV_JNI jstring decodeString(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jstring oDefaultValue) { MMKV *kv = reinterpret_cast<MMKV *>(handle); if (kv && oKey) { string key = jstring2string(env, oKey); string value; bool hasValue = kv->getString(key, value); if (hasValue) { return string2jstring(env, value); } } return oDefaultValue; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
bool MMKV::getString(MMKVKey_t key, string &result) { if (isKeyEmpty(key)) { return false; } SCOPED_LOCK(m_lock); auto &data = getDataForKey(key); if (data.length() > 0) { try { result = MiniPBCoder::decodeString(data); return true; } catch (std::exception &exception) { MMKVError("%s", exception.what()); } } return false; } |
大致可以分分为两步:
- 1.getDataForKey 通过key找缓存的数据
- 2.decodeString 对获取到的数据进行解码
getDataForKey
1 2 3 4 5 6 7 8 9 |
const MMBuffer &MMKV::getDataForKey(MMKVKey_t key) { checkLoadData(); auto itr = m_dic.find(key); if (itr != m_dic.end()) { return itr->second; } static MMBuffer nan; return nan; } |
由于是一个多进程的组件,因此每一次进行读写之前都需要进行一次checkLoadData的校验。而这个方法从上文可知,通过crc校验码,写回计数,文件长度来判断文件是否发生了变更,是否追加删除数据,从而是否需要重新充内存文件中获取数据缓存到m_dic。
也因此,在getDataForKey方法中,可以直接从m_dic中通过key找value。
decodeString
1 2 3 4 |
string MiniPBCoder::decodeString(const MMBuffer &oData) { MiniPBCoder oCoder(&oData); return oCoder.decodeOneString(); } |
1 2 3 |
string MiniPBCoder::decodeOneString() { return m_inputData->readString(); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
string CodedInputData::readString() { int32_t size = readRawVarint32(); if (size < 0) { throw length_error("InvalidProtocolBuffer negativeSize"); } auto s_size = static_cast<size_t>(size); if (s_size <= m_size - m_position) { string result((char *) (m_ptr + m_position), s_size); m_position += s_size; return result; } else { throw out_of_range("InvalidProtocolBuffer truncatedMessage"); } } |
能看到实际上很简单就是从m_dic找到对应的MMBuffer数据,此时的可以通过CodedInputData对MMBuffer对应的内存块(已经知道内存起始地址,长度)进行解析数据。
总结
MMKV读写是直接读写到mmap文件映射的内存上,绕开了普通读写io需要进入内核,写到磁盘的过程。光是这种级别优化,都可以拉开三个数量级的性能差距。但是也诞生了一个很大的问题,一个进程在32位的机子中有4g的虚拟内存限制,而我们把文件映射到虚拟内存中,如果文件过大虚拟内存就会出现大量的消耗最后出现异常,对于不熟悉Linux的朋友就无法理解这种现象。
有几个关于MMKV使用的注意事项:
- 1.保证每一个文件存储的数据都比较小,也就说需要把数据根据业务线存储分散。这要就不会把虚拟内存消耗过快。
- 2.还需要在适当的时候释放一部分内存数据,比如在App中监听onTrimMemory方法,在Java内存吃紧的情况下进行MMKV的trim操作(不准确,我们暂时以此为信号,最好自己监听进程中内存使用情况)。
- 2.在不需要使用的时候,最好把MMKV给close掉。甚至调用exit方法。
参考文章:https://www.jianshu.com/p/c12290a9a3f7
官方Demo:https://github.com/Tencent/MMKV/tree/master/Android/MMKV