阅读 1187

锦囊篇|一文摸懂SharedPreferences和MMKV(二)

目录

MMKV源码分析

初始化 / MMKV.initialize(this);

MMKV的整套流程中,MMKV的初始化起着承上启下的作用。

public static String initialize(Context context) {
        // 获取根路径
        String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
        MMKVLogLevel logLevel = MMKVLogLevel.LevelInfo;
        return initialize(root, (MMKV.LibLoader)null, logLevel); // 进行加载 1 -->
    }
// 1 -->
public static String initialize(String rootDir, MMKV.LibLoader loader, MMKVLogLevel logLevel) {
        // 加载必要的so文件
        // 。。。。。
        // 通过JNI来对底层c的实现进行初始化调度
        jniInitialize(MMKV.rootDir, logLevel2Int(logLevel));
        return rootDir;
    }
复制代码

因为到这里的话直接通过三方库的导入已经不能满足查看了,所以直接去下载MMKV的开源库源码查看比较合适。

如果你并不太熟悉JNI的方法调度,也没关系,我会慢慢的通过方式来教你入门。

你能够发现是爆红的JNI方法,那如何定位呢? 摁两下Shift的全局搜索,然后直接输入initializeMMKV,就会得到搜索结果了。

能够发现这里存在两个方法,进去看看就知道像C写的,那目标群体就已经被你锁定了。

void MMKV::initializeMMKV(const MMKVPath_t &rootDir, MMKVLogLevel logLevel) {
    // ThreadOnce说明初始化过程只会进行一次
    ThreadLock::ThreadOnce(&once_control, initialize);

    g_rootDir = rootDir;
    // 对目标路径进行设置,层级递推如果不存在就创建。
    mkPath(g_rootDir);
}
复制代码

对象实例获取 / MMKV.defaultMMKV()

public static MMKV defaultMMKV() {
        // 可以设置为多进程模式
        // 重点所在
        long handle = getDefaultMMKV(SINGLE_PROCESS_MODE, null); // *** -->
        return new MMKV(handle); // 1 -->
    }
// 1 -->
// 就是一个为long类型的handle变量设置
private MMKV(long handle) {
        nativeHandle = handle;
    }
复制代码

你能看到***注释位置是代码中一个迷惑性行为,通过数据类型定义能够知道最后得到的数据是一个数据类型为long的数据,我们可以猜测这个数据的用处对应着最后能够用于寻找到对应的MMKV,通过深层次调用后可以发现他调用了一个mmkvWithID()的方法,其中DEFAULT_MMAP_IDmmkv.default

MMKV *MMKV::mmkvWithID(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {
    // 。。。。。。
    auto mmapKey = mmapedKVKey(mmapID, relativePath); // 1 -->
    auto itr = g_instanceDic->find(mmapKey);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second; // 2 -->
        return kv;
    }
    if (relativePath) {
        if (!isFileExist(*relativePath)) {
            if (!mkPath(*relativePath)) {
                return nullptr;
            }
        }
    }
    auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath); // 3 -->
    (*g_instanceDic)[mmapKey] = kv;
    return kv;
}
复制代码

在这个代码中总共有两个核心部分:

  1. mmapKey的值的计算: 通过mmapIDrelativePath两个值进行一定的运算操作,具体关系就是mmapIDrelativePath的重合关系,具体还是要见于代码实现。
  2. MMKV的生成: 这里的解释对应注释2注释3,就是通过一个Map的形式来对数据进行存储,如果在g_instanceDic这个变量中进行数据查询。

MMKV的内部结构

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)) // 1 -->
    , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
    , m_dic(nullptr)
    , m_dicCrypt(nullptr)
    , 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_dicCrypt = new MMKVMapCrypt();
        m_crypter = new AESCrypt(cryptKey->data(), cryptKey->length());
    } else {
        m_dic = new MMKVMap();
    }
    // 。。。。。。一些赋值操作

    // sensitive zone
    {
        SCOPED_LOCK(m_sharedProcessLock);
        loadFromFile();
    }
}
复制代码

SharedPreferences相同最后还是需要经历一场和文件读写的殊死搏斗,那问题就来了,同样是文件读写,为什么MMKV能够以百倍的速度碾压各类已成熟的产品呢?从我的思路出发可以分为这样的几种情况:

  1. 不够健壮的错误数据处理。 这如果你做一个简易版的FastJson就能够发现,数据的处理速度基本上能够有非常高的提升。但是这对于相对成熟的产品而言一般不会有这种方案。
  2. 底层进行数据处理。 这个方案的推行在一定程度上也是对应现在的两者对比有一定的道理,因为能够发现MMKV的实现方案基本都是依靠JNI来调度完成,而C的处理速度和Java相比想来我们也是有目共睹的。
  3. 更优化的文件读取方案。 这就是对当前方案的分析了,因为还没有看到后面的代码,所以这里是一种方案的猜测。因为SharedPreferencesMMKV两者都是我们有目共睹需要对数据进行读写操作的,而数据的最后来源就是本地的文件,一个更易于读写的文件方案势必是一个最关键的突破点。
  4. 。。。。。接下来由你开始进行更多的思考。

回归正题:loadFromFile();

在刚刚的猜想中,我提及了关于文件读写的问题,因为对MMKV而言,文件读写这一关肯定是躲不过去的,但是如何更高效就是我们应该去思考的点了。

void MMKV::loadFromFile() {
    // 文件不合法就重新加载
    if (!m_file->isFileValid()) {
        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();
        // loading
        if (loadFromFile && m_actualSize > 0) {
            MMBuffer inputBuffer(ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
            if (m_crypter) {
                clearDictionary(m_dicCrypt);
            } else {
                clearDictionary(m_dic);
            }
            // 1 -->
            if (needFullWriteback) {
                if (m_crypter) {
                    MiniPBCoder::greedyDecodeMap(*m_dicCrypt, inputBuffer, m_crypter); // 2 -->
                } else {
                    MiniPBCoder::greedyDecodeMap(*m_dic, inputBuffer); // 2 -->
                }
            } else {
            // 1 -->
                if (m_crypter) {
                    MiniPBCoder::decodeMap(*m_dicCrypt, inputBuffer, m_crypter); // 2 -->
                } else {
                    MiniPBCoder::decodeMap(*m_dic, inputBuffer); // 2 -->
                }
            }
            m_output = new CodedOutputData(ptr + Fixed32Size, m_file->getFileSize() - Fixed32Size);
            m_output->seek(m_actualSize); // 计算出数据量的实际大小
            if (needFullWriteback) {
                fullWriteback();
            }
        } else {
            // 如果数据是不合法或者空的就直接丢弃。
            // 。。。。。。
        }
    }

    m_needLoadFromFile = false;
}
复制代码

在代码段中我标注出了注释1注释2,也是我认为至关重要的代码了,分别做了两大操作:

  1. 数据的写回方案制作: 这是要一个非常有特色的地方,为什么这么说呢?其实你能够从一个判断的变量名能够看出会对数据的写回方式有一个选择,也就是部分写回和全部写回的策略之选,那这就是第一个原因为什么MMKV的综合性能能够强过SharedPreferences
  2. 文件格式的选择: 其实这是解析时候的事情了,这一段的论证来源于 MMKV 原理protobuf作为MMKV最后的选择方案在性能和空间占用上都有不错的表现。

数据更新 / kv.encodeXXX("string", XXX);

这里的代码分析只拿一个作为样例即可

MMKV_JNI jboolean encodeBool(JNIEnv *env, jobject, jlong handle, jstring oKey, jboolean value) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle); // 1-->
    if (kv && oKey) {
        string key = jstring2string(env, oKey); // 将key再进行特殊的加工处理
        return (jboolean) kv->set((bool) value, key); // 2 -->
    }
    return (jboolean) false;
}
复制代码

关注几个注释点:

  1. 注释1: 这就是之前在上面的时候已经提到过的在Java这一层中进行的操作只是一个数据类型为longhandle变量进行赋值操作,而这个handle中在后期可以被解析转化为已经初始化完成的MMKV对象。
  2. 注释2: 完成相对应的数据放置操作,那这里就要观察代码的深层调度是一个怎么样的过程了。
bool MMKV::set(bool value, MMKVKey_t key) {
    // 1. 进行数据的测量,并创建相同大小的区间
    size_t size = pbBoolSize();
    MMBuffer data(size);
    // 2. 转化为CodedOutputData对象用于写入
    CodedOutputData output(data.getPtr(), size);
    output.writeBool(value); // 3 -->
    // 从名字就能知道这其实是一个正式的数据替换操作
    // 追溯后可以发现会出现一个文件的写入。
    return setDataForKey(move(data), key); 
}
// 3-->
void CodedOutputData::writeBool(bool value) {
    // 用0和1来表示最后的数值
    this->writeRawByte(static_cast<uint8_t>(value ? 1 : 0));
}
复制代码

但是通过官方的文档中能够知道,关于这个文件格式下的数据是存在问题的,那就是他并不支持增量更新 ,这也就意味着复杂的操作会更加多了,那腾讯的解决方案是什么呢?

标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

一句话讲来就是,新的或更改过的就最后新增后面插入。

而新旧数据累加势必会造成文件的庞大,那这方面MMKV给出的解决方案又是怎么样的呢?

以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

同样的换成一句话来进行描述,有上限目标的文件重写。

这一段的代码实现就不贴出了,具体位置就在MMKV_IO中的ensureMemorySize()方法,通过已存在数据大小的总量来进行整理,因为很多时候数据量很大是因为大容量的数据的重复添加造成的。

数据获取 / kv.decodeXXX("string");

MMKV_JNI jboolean decodeBool(JNIEnv *env, jobject, jlong handle, jstring oKey, jboolean defaultValue) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle);
    if (kv && oKey) {
        string key = jstring2string(env, oKey);
        return (jboolean) kv->getBool(key, defaultValue);
    }
    return defaultValue;
}
复制代码

其实基本逻辑和写文件的差不多了,这个时候还是首先要获取一个对应的MMKV对象,然后完成数据的获取。

bool MMKV::getBool(MMKVKey_t key, bool defaultValue) {
    auto data = getDataForKey(key);
    if (data.length() > 0) {
        CodedInputData input(data.getPtr(), data.length());
        return input.readBool();
    }
    return defaultValue;
}
复制代码

转化为CodedInputData的对象来完成数据的读取,如果数据不存在,那就直接默认值返回。

删除对应的数据 / kv.removeValueForKey("string")

在看代码之前做一个思考,在已知的数据基础上,换成你会怎么做这样的操作呢?

我们要关注的点有以下几个:

  1. protobuf是一个不支持增量更新的文件格式,相对应MMKV给出的解决方案就是通过尾部增加,出现新旧数据叠加
  2. 问题1的引申,新旧数据叠加的一个查询和删除问题,因为新旧数据,那么做查询的时候势必要多次的查,如果每次的数据都有1G,那你的查询每次都要叠加到1G的程度,而不是查到即可开始删除。

对于以上问题思考清楚了的话,我们就可以给出MMKV的解决方案了。

auto itr = m_dic->find(key);
        if (itr != m_dic->end()) {
            m_hasFullWriteback = false;
            static MMBuffer nan; // ******
            auto ret = appendDataWithKey(nan, itr->second); // ******
            if (ret.first) {
#ifdef MMKV_APPLE
                [itr->first release];
#endif
                m_dic->erase(itr);
            }
            return ret.first;
        }
复制代码

将关注点全部放置于注释带*的代码段上,一个没有赋值的MMBuffer说明数据为空,然后直接调用appendDataWithKey()文件写入的方案,说明最后出现在protobuf的数据样式会是这样的。

message empty{
	
}
复制代码

其实就是往里面加一个新的空数据作为新的数据。

总结

从源码分析完之后,和SharedPreferences相比,重新整理后可以总结为以下几点的突破:

  1. mmap的使用: 内存映射的技术的使用,减少了 SharedPreferences 的拷贝和提交的时间消耗。
  2. 数据的更新方式: 局部更新的数据,通过尾部追加来进行完成,而不是像SharedPreferences一样的直接文件重构。同样要注意这样的方式会造成冗余数据的增加。
  3. 多进程访问安全的设计: 详细见于MMKV for Android 多进程设计与实现,主要还是以mmap作为突破口,来完成对其他进程对当前文件的操作的一个状态感知,主要就是分为三方面:写指针增长、内存重整、内存增长

参考资料

  1. MMKV官方文档