MMKV的原理与实现
MMKV源码
1 初始化/文件准备
在 Java MMKV 类中有两个静态的 initialize() 方法:public static String initialize(Context context) 和 public static String initialize(String rootDir)。
1.1 public static String initialize(Context context)
//Java
public static String initialize(Context context) {
String rootDir = context.getFilesDir().getAbsolutePath() + "/mmkv";
return initialize(rootDir);
}
当传入上下文 Context 时,文件将存储在 App 私有的绝对目录下。然后调用该方法的重载,将路径字符 串传入。
1.2 public static String initialize(String rootDir)
//Java
public static String initialize(String rootDir) {
MMKV.rootDir = rootDir;
jniInitialize(MMKV.rootDir);
return rootDir;
}
无论是否直接传入路径字符串,最终都会调用该方法。在该方法内,MMKV 的静态属性 rootDir 被赋值为 传入路径,并开始调用 JNI native 方法 jniInitialize( MMKV.rootDir ) ,并返回 rootDir 。该 native 方法声 明在
注意!当自选路径为 SD 卡等外部存储设备时,需要开启外部设备读写权限!
uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
1.3 jniInitialize( MMKV.rootDir )
下面是 jniInitialize(MMKV.rootDir) 在 Java 侧的 native 声明:
//Java
private static native void jniInitialize(String rootDir);
下面是 JNI 侧对 jniInitialize( MMKV.rootDir ) 的实现,执行了 c++ 代码中 MMKV 类的静态方法 initializeMMKV( path ) 。
//C++
extern "C"
JNIEXPORT void JNICALL
Java_com_mmkv_MMKV_jniInitialize(JNIEnv *env, jclass thiz, jstring _path) {
const char *path = env->GetStringUTFChars(_path, 0);
MMKV::initializeMMKV(path);//执行c++代码中MMKV类的静态方法initializeMMKV()
env->ReleaseStringUTFChars(_path, path);
}
1.4 MMKV::initializeMMKV(path)
下面是 MMKV.h 文件中对 MMKV::initializeMMKV(path) 的声明,在 public 分区,是一个静态方法:
//C++
static void initializeMMKV(const char *path);
下面是 MMKV.cpp 文件中对 initializeMMKV(const char *path) 的实现:
//C++
void MMKV::initializeMMKV(const char *path) {
g_instanceDic = new unordered_map<string, MMKV *>;
g_rootDir = path;
//创建目录
mkdir(g_rootDir.c_str(), 0777);
}
这里重点解释一下第一句
//C++
g_instanceDic = new unordered_map<string, MMKV *>;
这一句新建了一个无序 map ,map 的 K 是 string,V 是 MMKV 指针。这里的 string 类型的 K 在之后充当 mmapID ,这样做的目的是什么?要解释这一点,我们需要先看一看 MMKV Java 侧的代码:
//Java
public static MMKV defaultMMKV() {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}
long handle = getDefaultMMKV();//>>>下面1.5详细讲<<<
return new MMKV(handle);//非单例
}
每一次调用 Java 侧的 MMKV.defaultMMKV() 方法,都会 new 一个 MMKV 的实例对象,非单例。为了保证 同一个应用所访问的映射文件是同一个,我们在 C++ 侧处理,让该应用创建的所有 MMKV 实例在 C++ 看 来都是同一个,反正我 C++ 只认 mmapID 。
无论 Java 侧产生多少个 MMKV 实例对象,在 C++ 侧都会先调用 MMKV::mmkvWithID(const string &mmapID) 方法先查找 g_instanceDic 中是否存在该 mmapID ,若存在,直接返回该 MMKV 指针;若不 存在,则创建,再返回,实现单例。
//C++
MMKV *MMKV::mmkvWithID(const string &mmapID) {
auto itr = g_instanceDic->find(mmapID);//auto是C++的新特性,自动推导变量类型
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
//创建并放入集合
auto kv = new MMKV(mmapID);
(*g_instanceDic)[mmapID] = kv;
return kv;
}
1.5 getDefaultMMKV()
上面 Java 调用 defaultMMKV() 时,里面有另外一个 JNI native 方法被调用:getDefaultMMKV() 。
它在 Java 侧的声明是在 MMKV 类中:
//Java
private static native long getDefaultMMKV();
它在 JNI侧的 C++ 实现是:
//C++
extern "C"
JNIEXPORT jlong JNICALL Java_com_mmkv_MMKV_getDefaultMMKV(JNIEnv *env, jclass clazz) {
MMKV *kv = MMKV::defaultMMKV();
return reinterpret_cast<jlong>(kv);
}
显然,会调用 C++ 代码中 MMKV 类的静态方法 defaultMMKV() 。该方法会给该 defaultMMKV 实例分配一 个默认的 mmapID DEFAULT_MMAP_ID ,并调用我们上面提到的通过 map 查找 mmapID 实现单例的 mmkvWithID(const string &mmapID) 方法,创建或者直接返回一个指向 MMKV 对象的指针。
Q:为什么 native long getDefaultMMKV() 方法返回值是一个 long 类型?
A:结合 JNI 的 C++ 实现可以看出,JNI 侧返回的是一个 MMKV 指针强转之后的 jlong ,也就对应 Java 侧的返回值 long 。换句话说,我们调用 native long getDefaultMMKV() 方法,JNI 将被创建的 MMKV 实例对象的指针返回给 Java 侧,Java 侧今后若希望调用该 MMKV 实例对象的其他方法,需 要将指向该实例对象的指针(也就是这个 long 类型的 handle 句柄)传递给 native 方法,由 JNI 侧转换为指针,再调用 C++ 侧的 MMKV 实例的相关方法。
//C++
MMKV *MMKV::defaultMMKV() {
return mmkvWithID(DEFAULT_MMAP_ID);
}
MMKV *MMKV::mmkvWithID(const string &mmapID) {
auto itr = g_instanceDic->find(mmapID);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
//创建并放入集合
auto kv = new MMKV(mmapID);
(*g_instanceDic)[mmapID] = kv;
return kv;
}
当然,MMKV 源码中还有可以自己手动指定 mmapID 的方法。
1.6 new MMKV(mmapID)
接下来我们详细看看这个构造方法做了什么事情。
我们上面说到,创建一个 MMKV 实例对象最终会调用到 MMKV *MMKV::mmkvWithID(const string &mmapID) 方法来查询 map 看是否对应 mmapID 的 MMKV 实例已经存在,若不存在,再创建。现在详细 看创建过程,下面是调用:
//C++
MMKV::MMKV(const string &mmapID) :m_mmapID(mmapID), m_path(g_rootDir + "/" + mmapID) {
loadFromFile();
//···省略了一些方法
}
在该方法中,最重要的是这个方法 loadFromFile() ,从文件加载。在深入学习这个方法的源码之前,我 们思考这个问题:
Q:为什么创建 MMKV 实例的过程是从文件加载?
A: 因为文件不同于内存中的对象,文件是持久存在的,而内存中的实例对象是会被回收的。 当 我创建一个实例对象的时候,先要检查是否已经存在以往的映射文件, 若存在,需要先建立映射 关系,然后解析出以往的数据;若不存在,才是直接创建空文件来建立映射关系。
接下来我们在数据编解码中详细看 loadFrromFile() 方法的文件解析过程。
2 数据编解码
2.1 loadFromFile()
//C++
void MMKV::loadFromFile() {
/*----------- PART 1 : 打开文件 -----------*/
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU); if (m_fd < 0) {
//打开失败
LOGI("打开文件:%s 失败!", m_path.c_str()); }
//读取文件大小
struct stat st = {0};
if (fstat(m_fd, &st) != -1) {
m_size = st.st_size;
}
LOGI("打开文件:%s [%d]", m_path.c_str(), m_size);
/**
* 健壮性。 文件是否已存在,容量是否满足页大小倍数,此处省略 */
/*----------- PART 2 : 读取有效数据长度 -----------*/
m_ptr = static_cast<int8_t *>(mmap(0, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0));
//文件头4个字节写了数据有效区长度
memcpy(&m_actualSize, m_ptr, Fixed32Size);
bool loadFromFile = false;
//有数据
if (m_actualSize > 0) {
//数据长度有效:不能比文件还大
if (m_actualSize + Fixed32Size <= m_size) {
loadFromFile = true;
}
//其他情况,MMKV是交给用户选择
// 1、OnErrorDiscard 忽略错误,MMKV会忽略文件中原来的内容
// 2、OnErrorRecover 还原,MMKV尝试按照自己的方式解析文件,并修正长度
}
/**
* 解析 mmkv 文件中的数据 保存到 map 集合中 */
/*----------- PART 3 : 解析K-V -----------*/
if (loadFromFile) {
// 封装的protobuf解析器
InputBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize);
while (!inputBuffer.isAtEnd()) {
//
string key = inputBuffer.readString();
if (key.length() > 0) {
//读取value(包含value长度+value数据)
InputBuffer *value = inputBuffer.readData(); //unordered_map<string,InputBuffer*>::iterator iter;
auto iter = m_dic.find(key);
// 集合中找到了老数据
if (iter != m_dic.end()){
//清理老数据
delete iter->second; // java-> map.remove
m_dic.erase(key);
}
//本次数据有效,加入集合
if (value && value->length() > 0) {
// java-> map.insert
m_dic.emplace(key, value); }
}
}
//创建输出
m_output = new OutputBuffer(m_ptr + Fixed32Size + m_actualSize, m_size - Fixed32Size - m_actualSize);
} else{
//todo 文件有问题,忽略文件已存在的数据
}
}
loadFromFile() 方法的代码实现总体分为三个部分:
- 第一部分,打开文件,读取文件大小,判断是否是整数页等等;
- 第二部分,读取有效数据长度,我们知道 MMKV 进行内存映射时,文件内容的前 4 个字节是有效数 据的长度,我们读取出来,判断是否存在有效数据,若存在有效数据,则进行第三部分的数据解 析;
- 第三部分,解析 K-V 数据,按照 K1长--->K1值--->V1长--->V1值--->...---> 格式解析,并解析 protobuf 编码。
2.2 InputBuer类/OutputBuer类
因为用户可以 put/get 多种数据类型的数据进来,为了同意管理数据,我们将从文件中解析出来的数据 封装成 InputBuffer ,将从内存中写进映射文件的数据封装成 OutputBuffer ,根据用户调用的不同方法 来处理不同的字节。是用户和映射文件之间的一个“ Buffer ”,这两个 Buffer 类都有一下几个属性:
1. int8_t *m_buf ;//一个字节指针,进行字节操作
2. size_t m_size ;//整个文件的大小
3. size_t m_position ;//当前游标
这里的 Input/Output 是站在程序的角度(或者内存的角度)而言的,当我需要从文件中解析数据 进入内存,即为 Input ;反之即为 Output 。所以 InputBuer 类中的文件操作相关方法都是 readXxx() ,比如 readInt32() 等。对应的,OutputBuer 类中的文件操作相关方法都是 writeXxx() , 比如 writeInt32() 等。
2.3 向映射文件写入
我们需要实现 OutputBuer() 类中的方法,以 writeInt32 和 writeInt64 为例,遵从 protobuf 编码规则,在实现 writeInt32/writeInt64 之前我们先定义写入单个字节的方法 writeByte() 。
//C++
void OutputBuffer::writeByte(int8_t value) {
if (m_position == m_size) {
//满啦,出错啦
return;
}
//将byte放入数组
m_buf[m_position++] = value;
}
在该方法中,传入一个 int8_t 即 byte 类型的 value 。若当前游标已经在文件的结尾,说明需要扩容,此 方法暂时无法写入,直接 return ;否则将该数据写入游标指向的字节,并将游标往后移动一位。
//C++
void OutputBuffer::writeInt32(int32_t value) { if (value < 0) {
writeInt64(value);
} else {
while (true){
if (value <= 0x7f){
writeByte(value); break;
} else{
// 取低7位,再最高位赋1
writeByte(value & 0x7f | 0x80);
value >>= 7;
}
}
}
该方法中,传入了一个 32 位(即 4 字节)的 value 。若 value 是负数,则直接调用 writeInt64() 方法。
Q:为什么负数直接调用 writeInt64() 方法?
A:在 Protobuf 为了让 int32 和 int64 在编码格式上兼容,对负数的编码将 int32 视为 int64 处理, 因此负数使用 Varint (变长)编码一定是 10 字节。说白了就是 protobuf 编码的规则。
若 value 为正数,则进入 else 代码块,进行熟悉的 protobuf 编码:如果数据小于等于 127 则直接 writeByte() 写入一个字节;否则取出低 7 位,字节最高位置 1 ,写入这个字节,最后右移 7 位,循环执 行该操作直到 value 小于等于 127 ,写入最后一个字节,break 跳出循环,结束。
//C++
void OutputBuffer::writeInt64(int64_t value) {
uint64_t i = value;//转为无符号64位整型
while (true){
if (i & ~0x7f == 0){
writeByte(i);
break;
} else {
// 取低7位,再最高位赋1
writeByte(i & 0x7f | 0x80);
i >>= 7;
}
}
}
该方法中,传入的是一个有符号 64 位整型数据 value 。从上面的 writeInt32() 方法的实现知道,由于 protobuf 的规则,负数的编码一定是调用 writeInt64() 方法的,所以这里涉及到带符号右位移操作使用 前的处理,对于操作符“ >> ”,左边是调用该操作符的操作数,右边是具体右移的位数,需要注意的 是:当操作数是正数时,右移时高位补 0 ;当操作数是负数时,右移时高位补 1 。若不做处理直接按照 前面的 protobuf 编码实现来做,将陷入死循环。我们以 -1 的 protobuf 编码为例:
- -1 在计算机内以补码形式存放,64 位,8 字节:
1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111
- 取低 7 位,字节最高位补 1 ,并右移 7 位得到:
1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111
得到的还是 64 个 1,陷入死循环。
所以,此处首先将有符号 64 位整型转换为无符号 64 位整型,避免这个问题。也因为负数需要按照 int64 编码,负数经过 protobuf 编码之后一定是 10 个字节。因为 64 / 7 = 9 ··· ··· 1 。
2.4 从映射文件读出
实现 InputBuer 类中的 readInt32() ,readInt64()方法。
与写入类似,我们先定义 readByte() 方法,处理单个字节。
readByte()
//C++
int8_t InputBuffer::readByte() { //不能越界
if (m_position == m_size) {
return 0;
}
return m_buf[m_position++];
}
若当前游标已经指向文件最后一个字节 之后 的一个字节,则拒绝读取,不能越界;否则返回当前游标所 指向的字节的数据,并将游标向后移动。
//C++
int32_t InputBuffer::readInt32() {
int32_t res = 0;
int moveBits = 0; while (true){
int32_t tmp = readByte();
if(tmp <= 0x7f){
tmp <<= moveBits; res += tmp; break;
} else {
tmp &= 0x7f;//最高位置0
tmp <<= moveBits;
moveBits += 7;
res += tmp;
}
}
return res;
}
该方法没有传入参数,需要返回一个 int32_t 类型的整型数据。
- 首先,我们声明一个 int32_t 类型的变量 res 用作数据拼接和最后返回。然后定义一个 int 类型的 变量 moveBits 用于表示当前读出来的字节需要右移多少位;
- 进入循环,每一个循环调用一次 readByte() 方法,读出一个字节,并用一个 int32_t 类型的 tmp 接 收;
- 若读出的一个字节小于 0x7f 即 127 ,则说明该字节是最后一个字节,左移 moveBits 位后,加到 res 上,break跳出循环;
- 若读出的一个字节大于 0x7f 即 127 ,则说明该字节的最高位是 1 ,需要继续读取并拼接。对于当 前读出的字节,与上 0x7f 将最高位置 0 ,左移 moveBits 位,并将 moveBits += 7 ,供下一次读出的 字节左移使用,res 加上处理结束的 tmp ,进入下一个循环。
//C++
int32_t readInt64(){
int32_t res = 0;
uint64_t uint64 = 0;
int moveBits = 0;
while (true){
uint64_t tmp = readByte();
if(tmp <= 0x7f){
tmp <<= moveBits; uint64 += tmp; break;
} else {
tmp &= 0x7f;
tmp <<= moveBits;
moveBits += 7;
uint64 += tmp;
}
}
bool isMinus = (uint64>>63 != 0);//保留正负号
int32_t tmp = uint64 & 0x7fffffff;//保留低31位
if(isMinus){
tmp |= 0x80000000;//是负数,最高位符号位置1
}
res = tmp;
return res;
}
该方法没有传入参数,需要返回一个 int32_t 类型的整型数据。转换过程与 readInt32() 有一些相似但又 区别。
- 首先,我们定义一个 int32_t 类型的 res ,用作返回结果;定义一个 uint64_t 类型的 uint64 用作数 据拼接,定义一个 moveBits 表示读出来的字节需要左移多少位。注意!必须用一个 64 位的整数类 型作拼接;
- 拼接完成后,保留正负号,保留低 31 位,再设置符号位;
- 返回结果。