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

目录

MMKV源码分析

初始化 / MMKV.initialize(this);

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

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的开源库源码查看比较合适。android

若是你并不太熟悉JNI的方法调度,也不要紧,我会慢慢的经过方式来教你入门。git

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

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

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;
}
复制代码

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

  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();post

在刚刚的猜测中,我说起了关于文件读写的问题,由于对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官方文档

相关文章
相关标签/搜索