美文网首页Android知多少
为什么要使用MMKV

为什么要使用MMKV

作者: 搬砖的兔子 | 来源:发表于2022-07-22 11:03 被阅读0次

    前言


    在app应用中往往要存储一些持久化的本地数据,比如"用户设置"等。这时候我们通常想到的是SharedPreferences,也许你也正在使用它,但随着互联网的发展,开发者们渐渐地弃用了它,转而自己实现,比较出名的是腾讯的开源框架-MMKV。
    那么为什么要放弃SharedPreferences,转而使用MMKV呢?

    从源码看SharedPreferences


    当我们调用context.getSharedPreferences()获取SharedPreferences时,实际上是调用类ContextImpl中的getSharedPreferences()。

    getSharedPreferences.png

    从上图不难看出,SharedPreferences的具体实现类是SharedPreferencesImpl,它是通过构造函数被创建的,其构造函数中调用startLoadFromDisk()开启子线程运行loadFromDisk()来从磁盘中加载数据。

    loadFromDisk中的部分代码1.png

    上图中是loadFromDisk中的部分代码,我们可以看到Sp是通过IO流的形式来读取XML文件到内存中,而sp的一系列get方法都是直接从内存中的map获取数据的。

    知道了Sp是如何在初始化的时候加载数据到内存,如何取数据,接下来我们看看Sp是如何存数据的。

    Sp是通过SharedPreferencesImpl的内部类EditImpl中的apply()和commit()来实现存储数据的。其中apply()是异步提交,commit()是同步提交。

    apply方法提交.png commit方法提交.png

    无论是是apply还是commit提交最终都会调用父类的enqueueDiskWrite()方法,最终执行其writeToDiskRunnable中的writeToFile()将内存中的数据写入到磁盘文件中。下图是writeToFile()中的部分代码,其是使用IO流将数据保存到XML中的。

    writeToFile方法中的部分代码.png

    总结一下Sp的工作原理:在初始化的时候从磁盘中通过IO流加载数据到内存,取数据的方式是直接从内存中维护的map集合中去取,存数据的方式是通过apply()或commit()提交,将数据先存到内存中,在从内存中通过IO流将数据全部覆盖到XML中去。

    综上我们总结Sp的特点如下:

    • 通过IO操作内存与磁盘中的文件同步。
    • 文件的保存格式为XML。
    • 更新方式为全量更新。

    Sp与MMKV的性能对比


    什么是MMKV?

    MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。

    MMKV原理:

    • 内存准备 通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心crash 导致数据丢失。
    • 数据组织 数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。
    • 写入优化 考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力。我们考虑将增量 kv 对象序列化后,append 到内存末尾。
    • 空间增长 使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。我们需要在性能和空间上做个折中。

    性能对比

    接下来我们来比较一下Sp和MMKV的存储数据时的性能。
    写一个测试Demo,分别使用Sp和MMKV两种方式存储1000个随机的Int值,并打印出方法耗时。

        private void testMmkvSave() {
            long oldTime = SystemClock.currentThreadTimeMillis();
            MMKV mmkv = MMKV.defaultMMKV();
            Random random = new Random();
            for (int i=1;i<= 1000;i++){
                int value = random.nextInt(1000);
                mmkv.encode("key"+i,value);
            }
            long time = SystemClock.currentThreadTimeMillis() - oldTime;
    
            Log.d(TAG, "testMmkvSave: 使用MMKV存储1000个数据需要耗时:"+time+"毫秒");
        }
    
        private void testSpSave() {
            long oldTime = SystemClock.currentThreadTimeMillis();
            Random random = new Random();
            for (int i=1;i<= 1000;i++){
                int value = random.nextInt(1000);
                SpUtils.putInt(getApplicationContext(),"key"+i,value);
            }
            long time = SystemClock.currentThreadTimeMillis() - oldTime;
    
            Log.d(TAG, "testSpSave: 使用Sp存储1000个数据需要耗时:"+time+"毫秒");
        }
    

    运行一下两个方法,打印结果如下:

    D/MainActivity: testSpSave: 使用Sp存储1000个数据需要耗时:939毫秒
    D/MainActivity: testSpSave: 使用Sp存储1000个数据需要耗时:839毫秒
    D/MainActivity: testSpSave: 使用Sp存储1000个数据需要耗时:843毫秒
    D/MainActivity: testSpSave: 使用Sp存储1000个数据需要耗时:890毫秒
    D/MainActivity: testSpSave: 使用Sp存储1000个数据需要耗时:840毫秒
    D/MainActivity: testMmkvSave: 使用MMKV存储1000个数据需要耗时:3毫秒
    D/MainActivity: testMmkvSave: 使用MMKV存储1000个数据需要耗时:5毫秒
    D/MainActivity: testMmkvSave: 使用MMKV存储1000个数据需要耗时:1毫秒
    D/MainActivity: testMmkvSave: 使用MMKV存储1000个数据需要耗时:2毫秒
    D/MainActivity: testMmkvSave: 使用MMKV存储1000个数据需要耗时:3毫秒
    

    我们可以很直观的看到,使用Sp的平均耗时大约1秒,而使用MMKV的平均耗时只有几毫秒,这是很大的性能差距了。
    那么为什么两者性能间差距这么大呢?我们接着看源码。

    从源码看MMKV


    MMKV的初始化

    通过MMKV的静态方法initialize()方法进行初始化,最终调用到MMKV中的doInitialize()方法。该方法根据是否传入自定义的加载器确定是用自定义还是系统加载器加载mmkv的so库。最后在调用native层的jniInitialize方法进行真正的初始化工作。

    doInitialize方法.png

    在jniInitialize方法中又是通过 MMKV::initializeMMKV 对 MMKV 类进行了初始化。

    initializeMMKV方法.png

    在该方法中全局记录了rootDir并创建了对应的目录。

    获取MMKV对象

    获取mmkv对象的方法会通过一系列的调用,最终调用native层的MMKV的构造方法。如下代码:

    #ifndef MMKV_ANDROID
    MMKV::MMKV(const string &mmapID, MMKVMode mode, string *cryptKey, 
    MMKVPath_t *rootPath)
        : m_mmapID(mmapID)
        // ...) {
        // ...
        //维护一个全局的map集合
        m_dic = new MMKVMap();
        // ...
        // 从文件中加载数据
        // 加锁后调用 loadFromFile 加载数据 
        {
            SCOPED_LOCK(m_sharedProcessLock);
            loadFromFile();
        }
    }
    #endif
    

    构造函数较为复杂,我们不必关心只看关键代码,首先它创建了一个全局的map集合,最后调用loadFromFile()从文件中读取数据。该方法在MMKV_IO.cpp中,同样我们只需要关心其中重要的部分。简化代码如下:

    void MMKV::loadFromFile() {
        // ...
        if (!m_file->isFileValid()) {   //判断是否有效
            //重新映射、加载文件
            //reloadFromFile方法中通过mmap进行内存映射
            m_file->reloadFromFile();   
        }
        if (!m_file->isFileValid()) {
            MMKVError("file [%s] not valid", m_path.c_str());
        } else {
            // error checking
            bool loadFromFile = false, needFullWriteback = false;
            // 检查数据有效性
            checkDataValid(loadFromFile, needFullWriteback);
            auto ptr = (uint8_t *) m_file->getMemory();
            // 开始读数据
            if (loadFromFile && m_actualSize > 0) {
                MMBuffer inputBuffer(ptr + Fixed32Size, 
                m_actualSize, MMBufferNoCopy);
                // ...
                // 由于 MMKV 使用了 protobuf 进行序列化,通过 MiniPBCoder::decodeMap 方法将 protobuf 转换成对应的 map;
                MiniPBCoder::decodeMap(*m_dic, inputBuffer);
                // ...
            } else {
                // 文件无效或为空,丢弃所有内容
                SCOPED_LOCK(m_exclusiveProcessLock);
                m_output = new CodedOutputData(ptr + Fixed32Size, 
                m_file->getFileSize() - Fixed32Size);
                if (m_actualSize > 0) {
                    writeActualSize(0, 0, nullptr, IncreaseSequence);
                    sync(MMKV_SYNC);
                } else {
                    writeActualSize(0, 0, nullptr, KeepSequence);
                }
            }
            auto count = m_crypter ? m_dicCrypt->size() : m_dic->size();
            MMKVInfo("loaded [%s] with %zu key-values", m_mmapID.c_str(), count);
        }
        m_needLoadFromFile = false;
    }
    

    判断文件的有效性,无效则调用reloadFromFile方法,其会执行自身封装的mmap()。

    bool MemoryFile::mmap() {
        //通过 mmap 函数将文件映射到内存中,得到指向该区域的指针 m_ptr
        m_ptr = (char *) ::mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_diskFile.m_fd, 0);
        if (m_ptr == MAP_FAILED) {
            m_ptr = nullptr;
            return false;
        }
        return true;
    }
    

    由于采用了内存映射数据的存储只需要直接将数据写入到内存中,再将内存中的数据直接写入map中即可,读数据亦是如此。

    为什么MMKV要优于SharedPreferences


    实际上mmkv源码中还有很多干货,比如是怎么数据是怎么存储的,又是怎么实现增量更新的、怎么扩容的等等,但是从代码上看很不直观,接下来我们就以图文的形式说明下MMKV原理中的几大优点。

    mmap

    mmap是一种内存映射文件的方法,它能将一个文件或者其他对象映射进内存。

    我们知道操作系统采用的是虚拟内存并将该区域分为用户空间和内核空间,我们的应用程序运行在用户空间,而文件系统运行在内核空间。传统的IO写操作是将用户空间的数据拷贝到内核空间,再将内核空间的数据拷贝的磁盘中,读操作反之。大概如下图所示:

    传统IO原理图.png

    而mmap是将用户空间的虚拟内存地址与内核空间的页缓存进行内存映射,用户可以直接在用户空间操作buffer从而达到操作页缓存区域,免去了页缓存区域与用户缓冲区之间copy的过程,同时免去了用户空间与内核空间来回切换的过程。

    mmap原理图.png

    Protobuf 协议

    Protobuf(全称 Google Protocol Buffer)是一种轻便高效的结构化数据存储格式,平台无关、语言无关、可扩展,可用于通讯协议和数据存储等领域。因为profobuf是二进制数据格式,需要编码和解码,数据本身不具有可读性,因此只能反序列化之后得到真正可读的数据。

    我们知道SharedPreferences采用的是xml,由于其需要成对的闭合标签,因此它的数据也更加冗余,而Protobuf是二进制数据格式,相比于xml体积更小、更适合高性能,同时支持跨平台多语言,且可以自己定义数据结构。

    下图是mmkv中的数据结构:

    mmkv中的数据结构.png
    • 总长度:4个字节,表示数据的有效长度,解析数据时只需要解析到最后一个有效数据即可。
    • key长度:通常为1个字节,可变,表示接下来有效长度的数据为key。
    • key:由key长度决定。
    • value长度:通常为1个字节,可变,表示接下来有效长度的数据为value。
    • value:由value长度决定。

    接下来我们写个程序看看mmkv数据到底是怎么存的。首先写个方法存两个数据。

            // 存入两个int值
            MMKV mmkv = MMKV.defaultMMKV();
            String rootDir = mmkv.getRootDir();
            Log.d(TAG, "rootDir = "+rootDir);
            boolean b1 = mmkv.encode("one",1);
            boolean b2 = mmkv.encode("two",2);
            Log.d(TAG, "b1 = "+b1);
            Log.d(TAG, "b2 = "+b2);
    
            //打印结果,表示成功存入
            D/MainActivity: rootDir = /data/user/0/com.code.ledding.test_mmkv/files/mmkv
            D/MainActivity: b1 = true
            D/MainActivity: b2 = true
    

    接下来运行程序,并在文件管理器中找到该mmkv文件,用二进制查看工具打开。

    mmkv二进制文件的解析.png

    增量更新

    SharedPreferences最蛋疼的地方是不支持增量更新,哪怕仅仅是新增一个数据,也需要将map中的所有数据重新写入到xml文件中,严重影响效率。

    MMKV的设计的数据结构是的MMKV支持增量更新,每次写数据只需要在有效数据的末尾追加数据即可。同时读数据时又不用担心数据重复,因为解析数据时是从前往后解析并存入map中去的,因此相同key值的数据永远是后面覆盖前面的。

    我们再次运行我们的测试程序,重新写入一遍数据,看看二进制文件是如何保存的。

    mmkv的增量更新.png

    去重与扩容

    MMKV的增量更新必然会导致数据不断变大,且存在许多重复数据,因此它不仅只支持增量更新,也支持全量更新。

    当二进制文件达到最大长度时,用户无法写入数据,这时mmkv就会进行全量更新,将map中的不重复的数据全部覆盖写入文件以达到去重的目的。
    若去重后剩余空间仍就不足以写入当前数据,则会对文件进行扩容。每次扩容的空间大小为内存的pagesize。

    参考文献


    1. MMKV框架原理解密
    2. MMKV源码解析

    结束语


    感谢各位小伙伴儿的观看,更多文章可以移步到我的CSDN中去了解。

    码子不易,学艺不精,难免出错,有问题的,请在下方评论区留言。

    最后别忘了留下你的足迹、点个赞在走哦!

    相关文章

      网友评论

        本文标题:为什么要使用MMKV

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