美文网首页 移动 前端 Python Android Java
MMKV(四) 对前面的知识复盘

MMKV(四) 对前面的知识复盘

作者: zcwfeng | 来源:发表于2020-10-14 00:17 被阅读0次

    前言:由于知识点多,分了多个记录。

    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 这个目录是加上文件锁的

    相关文章

      网友评论

        本文标题:MMKV(四) 对前面的知识复盘

        本文链接:https://www.haomeiwen.com/subject/bndmpktx.html