前言:由于知识点多,分了多个记录。
MMKV( 一) 了解原理
MMKV (二)基础知识点和实现流程解析
MMKV (三) POSIX线程和文件锁
可以先预览基础知识点,然后在继续本文结合文末的代码,关与NDK搭建等到我的文集查看
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)
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 声明:
private static native void jniInitialize(String rootDir);
下面是 JNI 侧对 jniInitialize( MMKV.rootDir ) 的实现,执行了 c++ 代码中 MMKV 类的静态方法 initializeMMKV( path ) 。
extern "C"
JNIEXPORT void JNICALL
Java_com_enjoy_enjoymmkv_MMKV_jniInitialize(JNIEnv *env, jclass thiz, jstring _path) {
const char *path = env->GetStringUTFChars(_path, 0);
MMKV::initializeMMKV(path);
env->ReleaseStringUTFChars(_path, path);
}
1.4 MMKV::initializeMMKV(path)
下面是 MMKV.h 文件中对 MMKV::initializeMMKV(path) 的声明,在 public 分区,是一个静态方法:
static void initializeMMKV(const char *path);
下面是 MMKV.cpp 文件中对 initializeMMKV(const char *path) 的实现:
void MMKV::initializeMMKV(const char *path) {
g_instanceDic = new unordered_map<string, MMKV *>;
g_instanceLock = ThreadLock();
g_rootDir = path;
//创建目录
mkdir(g_rootDir.c_str(), 0777);
}
这里重点解释一下第一句
g_instanceDic = new unordered_map<string, MMKV *>;
这一句新建了一个无序 map ,map 的 K 是 string,V 是 MMKV 指针。这里的 string 类型的 K 在之后充当 mmapID ,这样做的目的是什么?要解释这一点,我们需要先看一看 MMKV Java 侧的代码:
public static MMKV defaultMMKV(int mode) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}
long handle = getDefaultMMKV(mode);//>>>下面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 指针;若不 存在,则创建,再返回,实现单例。
MMKV *MMKV::mmkvWithID(const string &mmapID, MMKVMode mode) {
SCOPEDLOCK(g_instanceLock); //__scopedLock0
auto itr = g_instanceDic->find(mmapID);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
//创建并放入集合
auto kv = new MMKV(mmapID, mode);
(*g_instanceDic)[mmapID] = kv;
return kv;
}
1.5 getDefaultMMKV()
上面 Java 调用 defaultMMKV() 时,里面有另外一个 JNI native 方法被调用:getDefaultMMKV() 。 它在 Java 侧的声明是在 MMKV 类中:
private static native long getDefaultMMKV(int mode);
它在 JNI侧的 C++ 实现是:
extern "C"
JNIEXPORT jlong JNICALL
Java_com_enjoy_enjoymmkv_MMKV_getDefaultMMKV(JNIEnv *env, jclass clazz,jint mode) {
MMKV *kv = MMKV::defaultMMKV(static_cast<MMKVMode>(mode));
return reinterpret_cast<jlong>(kv);
}
显然,会调用 C++ 代码中 MMKV 类的静态方法 defaultMMKV() 。该方法会给该 defaultMMKV 实例分配一 个默认的 mmapID DEFAULT_MMAP_ID ,并调用我们上面提到的通过 map 查找 mmapID 实现单例的 mmkvWithID(const string &mmapID) 方法,创建或者直接返回一个指向 MMKV 对象的指针。
显然,会调用 C++ 代码中 MMKV 类的静态方法 defaultMMKV() 。该方法会给该 defaultMMKV 实例分配一 个默认的 mmapID DEFAULT_MMAP_ID ,并调用我们上面提到的通过 map 查找 mmapID 实现单例的 mmkvWithID(const string &mmapID) 方法,创建或者直接返回一个指向 MMKV 对象的指针。
//C++
MMKV *MMKV::defaultMMKV(MMKVMode mode) {
return mmkvWithID(DEFAULT_MMAP_ID, mode);
}
MMKV *MMKV::mmkvWithID(const string &mmapID, MMKVMode mode) {
SCOPEDLOCK(g_instanceLock); //__scopedLock0
auto itr = g_instanceDic->find(mmapID);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
//创建并放入集合
auto kv = new MMKV(mmapID, mode);
(*g_instanceDic)[mmapID] = kv;
return kv;
}
当然,MMKV 源码中还有可以自己手动指定 mmapID 的方法。
1.6 new MMKV(mmapID)
接下来我们详细看看这个构造方法做了什么事情。
我们上面说到,创建一个 MMKV 实例对象最终会调用到 MMKV *MMKV::mmkvWithID(const string &mmapID) 方法来查询 map 看是否对应 mmapID 的 MMKV 实例已经存在,若不存在,再创建。现在详细 看创建过程,下面是调用:
//C++
//创建并放入集合
auto kv = new MMKV(mmapID, mode);
下面是该方法实现:
MMKV::MMKV(const string &mmapID, MMKVMode mode) :
m_mmapID(mmapID),
m_path(g_rootDir + "/" + mmapID),
m_crcPath(m_path + ".crc"),
m_metaFile(m_crcPath),
m_fileLock(m_metaFile.getFd()),
m_sharedProcessLock(&m_fileLock, SharedLockType),
m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType),
m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0) {
m_crcDigest = 0;
m_sharedProcessLock.m_enable = m_isInterProcess;
m_exclusiveProcessLock.m_enable = m_isInterProcess;
//crc文件读锁
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
在该方法中,最重要的是这个方法 loadFromFile() ,从文件加载。在深入学习这个方法的源码之前,我 们思考这个问题:
Q:为什么创建 MMKV 实例的过程是从文件加载?
A: 因为文件不同于内存中的对象,文件是持久存在的,而内存中的实例对象是会被回收的。 当 我创建一个实例对象的时候,先要检查是否已经存在以往的映射文件, 若存在,需要先建立映射 关系,然后解析出以往的数据;若不存在,才是直接创建空文件来建立映射关系。
接下来我们在数据编解码中详细看 loadFrromFile() 方法的文件解析过程。
2 数据编解码
2.1 loadFromFile()
void MMKV::loadFromFile() {
if (m_metaFile.isFileValid()) {
m_metaInfo.read(m_metaFile.getMemory());
}
/*----------- 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 文件有问题,忽略文件已存在的数据
//crc文件写锁
SCOPEDLOCK(m_exclusiveProcessLock);
if (m_actualSize > 0) {
writeAcutalSize(0);
}
//创建输出,忽略原数据
m_output = new OutputBuffer(m_ptr + Fixed32Size,
m_size - Fixed32Size);
//重新弄 crc文件
recaculateCRCDigest();
}
}
loadFromFile() 方法的代码实现总体分为三个部分:
- 第一部分,打开文件,读取文件大小,判断是否是整数页等等;
- 第二部分,读取有效数据长度,我们知道 MMKV 进行内存映射时,文件内容的前 4 个字节是有效数 据的长度,我们读取出来,判断是否存在有效数据,若存在有效数据,则进行第三部分的数据解 析;
- 第三部分,解析 K-V 数据,按照 K1长--->K1值--->V1长--->V1值--->...---> 格式解析,并解析 protobuf 编码。
2.2 InputBuffer类/OutputBuffer类
因为用户可以 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 。所以 InputBuffer 类中的文件操作相关方法都是 readXxx() ,比如 readInt32() 等。对应的,OutputBuffer 类中的文件操作相关方法都是 writeXxx() , 比如 writeInt32() 等。
2.3 向映射文件写入
我们需要实现 OutputBuffer() 类中的方法,以 writeInt32 和 writeInt64 为例,遵从 protobuf 编码规则,
在实现 writeInt32/writeInt64 之前我们先定义写入单个字节的方法 writeByte() 。
C++ /OutputBuffer /writeByte()
void OutputBuffer::writeByte(int8_t value) {
if (m_position == m_size) {
//满啦,出错啦
return;
}
//将byte放入数组
m_buf[m_position++] = value;
}
在该方法中,传入一个 int8_t 即 byte 类型的 value 。若当前游标已经在文件的结尾,说明需要扩容,此 方法暂时无法写入,直接 return ;否则将该数据写入游标指向的字节,并将游标往后移动一位。
writeInt32()
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 跳出循环,结束。
writeInt64()
void OutputBuffer::writeInt64(int64_t value) {
uint64_t i = value;
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 。
回顾总结一下,具体的基础细节看为什么是10个字节前三篇文章基础讲过
2.4 从映射文件读出
实现 InputBuffer 类中的 readInt32() ,readInt64()方法
与写入类似,我们先定义 readByte() 方法,处理单个字节。
readByte()
int8_t InputBuffer::readByte() {
//不能越界
if (m_position == m_size) {
return 0;
}
return m_buf[m_position++];
}
若当前游标已经指向文件最后一个字节 之后 的一个字节,则拒绝读取,不能越界;否则返回当前游标所 指向的字节的数据,并将游标向后移动。
readInt32()
int32_t InputBuffer::readInt32() {
//最高位不为1
int8_t tmp = readByte();
if (tmp >= 0) {
return tmp;
}
//获得低7位数据
int32_t result = tmp & 0x7f;
if ((tmp = readByte()) >= 0) {
//小端方式连接两个字节
result |= tmp << 7;
} else {
result |= (tmp & 0x7f) << 7;
if ((tmp = readByte()) >= 0) {
result |= tmp << 14;
} else {
result |= (tmp & 0x7f) << 14;
if ((tmp = readByte()) >= 0) {
result |= tmp << 21;
} else {
result |= (tmp & 0x7f) << 21;
//读第五个字节
result |= (tmp = readByte()) << 28;
//还有数据?
if (tmp < 0) {
//因为int32最大被编码5个字节
//如果是负数,被变成int64编码为10字节,
//但是这10字节只有剩下的最低5为才是有效,高位的都是补的1
for (int i = 0; i < 5; i++) {
//如果最高位是1(需要下个字节数据),丢弃
// 否则就不用再丢弃,直接返回结果
if (readByte() >= 0) {
return result;
}
}
}
}
};
}
return result;
}
该方法没有传入参数,需要返回一个 int32_t 类型的整型数据。
readInt64()
int32_t InputBuffer::readInt64() {
int32_t i = 0;
int64_t result = 0;
while (i < 64) {
int8_t tmp = readByte();
result |= (int64_t)(tmp & 0x7f) << i;
//最高位为0,就读完了
if ((tmp & 0x80) == 0) {
return result;
}
i += 7;
}
return 0;
}
还有readData string 类型,readFloat writeFloat
这里就是原理的实现方式
具体查看Tencent/MMKV 具体实现
本文对应的代码在 这里
android_MMKVtest 这个是mmkv 中mmap的一个测试
android_MMKV 简单的实现
android_EnjoyMMKV 单纯的简单demo
android_EnjoyMMKV_2 这个目录是加上文件锁的
网友评论