Android 存储优化 —— MMKV 集成与原理

2023-05-16

前言

APP 的性能优化之路是永无止境的, 这里学习一个腾讯开源用于提升本地存储效率的轻量级存储框架 MMKV

目前项目中在轻量级存储上使用的是 SharedPreferences, 虽然 SP 兼容性极好, 但 SP 的低性能一直被诟病, 线上也出现了一些因为 SP 导致的 ANR

网上有很多针对 SP 的优化方案, 这里笔者使用的是通过 Hook SP 在 Application 中的创建, 将其替换成自定义的 SP 的方式来增强性能, 但 SDK 28 以后禁止反射 QueuedWork.getHandler 接口, 这个方式就失效了

因此需要一种替代的轻量级存储方案, MMKV 便是这样的一个框架

一. 集成与测试

以下介绍简单的使用方式, 更多详情请查看 Wiki

依赖注入

在 App 模块的 build.gradle 文件里添加:

dependencies {
    implementation 'com.tencent:mmkv:1.0.22'
    // replace "1.0.22" with any available version
}
复制代码

初始化

// 设置初始化的根目录
String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
String rootDir = MMKV.initialize(dir);
Log.i("MMKV", "mmkv root: " + rootDir);
复制代码

获取实例

// 获取默认的全局实例
MMKV kv = MMKV.defaultMMKV();

// 根据业务区别存储, 附带一个自己的 ID
MMKV kv = MMKV.mmkvWithID("MyID");

// 多进程同步支持
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.MULTI_PROCESS_MODE);
复制代码

CURD

// 添加/更新数据
kv.encode(key, value);

// 获取数据
int tmp = kv.decodeInt(key);

// 删除数据
kv.removeValueForKey(key);
复制代码

SP 的迁移

private void testImportSharedPreferences() {
    MMKV mmkv = MMKV.mmkvWithID("myData");
    SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
    // 迁移旧数据
    mmkv.importFromSharedPreferences(old_man);
    // 清空旧数据
    old_man.edit().clear().commit();
    ......
}
复制代码

数据测试

以下是 MMKV、SharedPreferences 和 SQLite 同步写入 1000 条数据的测试结果

// MMKV
MMKV: MMKV write int: loop[1000]: 12 ms
MMKV: MMKV read int: loop[1000]: 3 ms

MMKV: MMKV write String: loop[1000]: 7 ms
MMKV: MMKV read String: loop[1000]: 4 ms

// SharedPreferences
MMKV: SharedPreferences write int: loop[1000]: 119 ms
MMKV: SharedPreferences read int: loop[1000]: 3 ms

MMKV: SharedPreferences write String: loop[1000]: 187
MMKV: SharedPreferences read String: loop[1000]: 2 ms

// SQLite
MMKV: sqlite write int: loop[1000]: 101 ms
MMKV: sqlite read int: loop[1000]: 136 ms

MMKV: sqlite write String: loop[1000]: 29 ms
MMKV: sqlite read String: loop[1000]: 93 ms
复制代码

可以看到 MMKV 无论是对比 SP 还是 SQLite, 在性能上都有非常大的优势, 官方提供的数据测试结果如下

单进程读写性能对比

 

更详细的性能测试见 wiki

了解 MMKV 的使用方式和测试结果, 让我对其实现原理产生了很大的好奇心, 接下来便看看它是如何将性能做到这个地步的, 这里对主要对 MMKV 的基本操作进行剖析

  • 初始化
  • 实例化
  • encode
  • decode
  • 进程读写的同步

我们从初始化的流程开始分析

二. 初始化

public class MMKV implements SharedPreferences, SharedPreferences.Editor {
    
    // call on program start
    public static String initialize(Context context) {
        String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
        return initialize(root, null);
    }

    static private String rootDir = null;

    public static String initialize(String rootDir, LibLoader loader) {
        ...... // 省略库文件加载器相关代码
        // 保存根目录
        MMKV.rootDir = rootDir;
        // Native 层初始化
        jniInitialize(MMKV.rootDir);
        return rootDir;
    }
    
    private static native void jniInitialize(String rootDir);
    
}
复制代码

MMKV 的初始化, 主要是将根目录通过 jniInitialize 传入了 Native 层, 接下来看看 Native 的初始化操作

// native-bridge.cpp
namespace mmkv {
    
MMKV_JNI void jniInitialize(JNIEnv *env, jobject obj, jstring rootDir) {
    if (!rootDir) {
        return;
    }
    const char *kstr = env->GetStringUTFChars(rootDir, nullptr);
    if (kstr) {
        MMKV::initializeMMKV(kstr);
        env->ReleaseStringUTFChars(rootDir, kstr);
    }
}
    
}

// MMKV.cpp

static unordered_map<std::string, MMKV *> *g_instanceDic;
static ThreadLock g_instanceLock;
static std::string g_rootDir;

void initialize() {
    // 1.1 获取一个 unordered_map, 类似于 Java 中的 HashMap
    g_instanceDic = new unordered_map<std::string, MMKV *>;
    // 1.2 初始化线程锁
    g_instanceLock = ThreadLock();
    ......
}

void MMKV::initializeMMKV(const std::string &rootDir) {
    // 由 Linux Thread 互斥锁和条件变量保证 initialize 函数在一个进程内只会执行一次
    // https://blog.csdn.net/zhangxiao93/article/details/51910043
    static pthread_once_t once_control = PTHREAD_ONCE_INIT;
    // 1. 进行初始化操作
    pthread_once(&once_control, initialize);
    // 2. 将根目录保存到全局变量
    g_rootDir = rootDir;
    // 拷贝字符串
    char *path = strdup(g_rootDir.c_str());
    if (path) {
        // 3. 根据路径, 生成目标地址的目录
        mkPath(path);
        // 释放内存
        free(path);
    }
}
复制代码

可以看到 initializeMMKV 中主要任务是初始化数据, 以及创建根目录

  • pthread_once_t: 类似于 Java 的单例, 其 initialize 方法在进程内只会执行一次
    • 创建 MMKV 对象的缓存散列表 g_instanceDic
    • 创建一个线程锁 g_instanceLock
  • mkPath: 根据字符串创建文件目录

接下来我们看看这个目录创建的过程

目录的创建

// MmapedFile.cpp
bool mkPath(char *path) {
    // 定义 stat 结构体用于描述文件的属性
    struct stat sb = {};
    bool done = false;
    // 指向字符串起始地址
    char *slash = path;
    while (!done) {
        // 移动到第一个非 "/" 的下标处
        slash += strspn(slash, "/");
        // 移动到第一个 "/" 下标出处
        slash += strcspn(slash, "/");

        done = (*slash == '\0');
        *slash = '\0';

        if (stat(path, &sb) != 0) {
            // 执行创建文件夹的操作, C 中无 mkdirs 的操作, 需要一个一个文件夹的创建
            if (errno != ENOENT || mkdir(path, 0777) != 0) {
                MMKVWarning("%s : %s", path, strerror(errno));
                return false;
            }
        }
        // 若非文件夹, 则说明为非法路径
        else if (!S_ISDIR(sb.st_mode)) {
            MMKVWarning("%s: %s", path, strerror(ENOTDIR));
            return false;
        }

        *slash = '/';
    }
    return true;
}
复制代码

以上是 Native 层创建文件路径的通用代码, 逻辑很清晰

好的, 文件目录创建好了之后, Native 层的初始化操作便结束了, 接下来看看 MMKV 实例构建的过程

三. 实例化

public class MMKV implements SharedPreferences, SharedPreferences.Editor {

    @Nullable
    public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) {
        ......
        // 执行 Native 初始化, 获取句柄值
        long handle = getMMKVWithID(mmapID, mode, cryptKey, relativePath);
        if (handle == 0) {
            return null;
        }
        // 构建一个 Java 的壳对象
        return new MMKV(handle);
    }
    
    private native static long
    getMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);
    
    // jni
    private long nativeHandle;

    private MMKV(long handle) {
        nativeHandle = handle;
    }
}
复制代码

可以看到 MMKV 实例构建的主要逻辑通过 getMMKVWithID 方法实现, 看它内部做了什么

// native-bridge.cpp
namespace mmkv {

MMKV_JNI jlong getMMKVWithID(
    JNIEnv *env, jobject, jstring mmapID, jint mode, jstring cryptKey, jstring relativePath) {
    MMKV *kv = nullptr;
    if (!mmapID) {
        return (jlong) kv;
    }
    // 获取独立存储 id
    string str = jstring2string(env, mmapID);

    bool done = false;
    if (cryptKey) {
        // 获取秘钥
        string crypt = jstring2string(env, cryptKey);
        if (crypt.length() > 0) {
            if (relativePath) {
                // 获取相对路径
                string path = jstring2string(env, relativePath);
                // 通过 mmkvWithID 函数获取一个 MMKV 的对象
                kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, &path);
            } else {
                kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, &crypt, nullptr);
            }
            done = true;
        }
    }
    ......
    // 强转成句柄, 返回到 Java
    return (jlong) kv;
}

}
复制代码

可以看到最终通过 MMKV::mmkvWithID 函数获取到 MMKV 的对象

// MMKV.cpp
MMKV *MMKV::mmkvWithID(
    const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {

    if (mmapID.empty()) {
        return nullptr;
    }
    SCOPEDLOCK(g_instanceLock);
    // 1. 通过 mmapID 和 relativePath, 组成最终的 mmap 文件路径的 key
    auto mmapKey = mmapedKVKey(mmapID, relativePath);
    // 2. 从全局缓存中查找
    auto itr = g_instanceDic->find(mmapKey);
    if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
        return kv;
    }
    // 3. 创建缓存文件
    if (relativePath) {
        // 根据 mappedKVPathWithID 获取 mmap 的最终文件路径
        // mmapID 使用 md5 加密
        auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
        // 不存在则创建一个文件
        if (!isFileExist(filePath)) {
            if (!createFile(filePath)) {
                return nullptr;
            }
        }
        ......
    }
    // 4. 创建实例对象
    auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
    // 5. 缓存这个 mmapKey
    (*g_instanceDic)[mmapKey] = kv;
    return kv;
}
复制代码

mmkvWithID 函数的实现流程非常的清晰, 这里我们主要关注一下实例对象的创建流程

// MMKV.cpp
MMKV::MMKV(
    const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
    : m_mmapID(mmapedKVKey(mmapID, relativePath)) 
    // 拼装文件的路径
    , m_path(mappedKVPathWithID(m_mmapID, mode, relativePath))
    // 拼装 .crc 文件路径
    , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
    // 1. 将文件摘要信息映射到内存, 4 kb 大小
    , m_metaFile(m_crcPath, DEFAULT_MMAP_SIZE, (mode & MMKV_ASHMEM) ? MMAP_ASHMEM : MMAP_FILE)
    ......
    , m_sharedProcessLock(&m_fileLock, SharedLockType)
    ......
    , m_isAshmem((mode & MMKV_ASHMEM) != 0) {
    ......
    // 判断是否为 Ashmem 跨进程匿名共享内存
    if (m_isAshmem) {
        // 创共享内存的文件
        m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
        m_fd = m_ashmemFile->getFd();
    } else {
        m_ashmemFile = nullptr;
    }
    // 根据 cryptKey 创建 AES 加解密的引擎
    if (cryptKey && cryptKey->length() > 0) {
        m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
    }
    ......
    // sensitive zone
    {
        SCOPEDLOCK(m_sharedProcessLock);
        // 2. 根据 m_mmapID 来加载文件中的数据
        loadFromFile();
    }
}
复制代码

可以从 MMKV 的构造函数中看到很多有趣的信息, MMKV 是支持 Ashmem 共享内存的, 当我们不想将文件写入磁盘,但是又想进行跨进程通信,就可以使用 MMKV 提供的 MMAP_ASHMEM

不过这里我们主要关注两个关键点

  • m_metaFile 文件摘要的映射
  • loadFromFile 数据的载入

接下来我们先看看, 文件摘要信息的映射

一) 文件摘要的映射

// MmapedFile.cpp
MmapedFile::MmapedFile(const std::string &path, size_t size, bool fileType)
    : m_name(path), m_fd(-1), m_segmentPtr(nullptr), m_segmentSize(0), m_fileType(fileType) {
    // 用于内存映射的文件
    if (m_fileType == MMAP_FILE) {
        // 1. 打开文件
        m_fd = open(m_name.c_str(), O_RDWR | O_CREAT, S_IRWXU);
        if (m_fd < 0) {
            MMKVError("fail to open:%s, %s", m_name.c_str(), strerror(errno));
        } else {
            // 2. 创建文件锁
            FileLock fileLock(m_fd);
            InterProcessLock lock(&fileLock, ExclusiveLockType);
            SCOPEDLOCK(lock);
            // 获取文件的信息
            struct stat st = {};
            if (fstat(m_fd, &st) != -1) {
                // 获取文件大小
                m_segmentSize = static_cast<size_t>(st.st_size);
            }
            // 3. 验证文件的大小是否小于一个内存页, 一般为 4kb
            if (m_segmentSize < DEFAULT_MMAP_SIZE) {
                m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);
                // 3.1 通过 ftruncate 将文件大小对其到内存页
                // 3.2 通过 zeroFillFile 将文件对其后的空白部分用 0 填充
                if (ftruncate(m_fd, m_segmentSize) != 0 || !zeroFillFile(m_fd, 0, m_segmentSize)) {
                    // 说明文件拓展失败了, 移除这个文件
                    close(m_fd);
                    m_fd = -1;
                    removeFile(m_name);
                    return;
                }
            }
            // 4. 通过 mmap 将文件映射到内存, 获取内存首地址
            m_segmentPtr =
                (char *) mmap(nullptr, m_segmentSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
            if (m_segmentPtr == MAP_FAILED) {
                MMKVError("fail to mmap [%s], %s", m_name.c_str(), strerror(errno));
                close(m_fd);
                m_fd = -1;
                m_segmentPtr = nullptr;
            }
        }
    }
    // 用于共享内存的文件
    else {
        ......
    }
}
复制代码

MmapedFile 的构造函数处理的事务如下

  • 打开指定的文件
  • 创建这个文件锁
  • 修正文件大小, 最小为 4kb
    • 前 4kb 用于统计数据总大小
  • 通过 mmap 将文件映射到内存

好的, 通过 MmapedFile 的构造函数, 我们便能够获取到映射后的内存首地址了, 操作这块内存时 Linux 内核会负责将内存中的数据同步到文件中

比起 SP 的数据同步, mmap 显然是要优雅的多, 即使进程意外死亡, 也能够通过 Linux 内核的保护机制, 将进行了文件映射的内存数据刷入到文件中, 提升了数据写入的可靠性

结下来看看数据的载入

二) 数据的载入

// MMKV.cpp
void MMKV::loadFromFile() {
    
    ......// 忽略匿名共享内存相关代码
    
    // 若已经进行了文件映射
    if (m_metaFile.isFileValid()) {
        // 则获取相关数据
        m_metaInfo.read(m_metaFile.getMemory());
    }
    // 获取文件描述符
    m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    if (m_fd < 0) {
        MMKVError("fail to open:%s, %s", m_path.c_str(), strerror(errno));
    } else {
        // 1. 获取文件大小
        m_size = 0;
        struct stat st = {0};
        if (fstat(m_fd, &st) != -1) {
            m_size = static_cast<size_t>(st.st_size);
        }
        // 1.1 将文件大小对其到内存页的整数倍
        if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
            ......
        }
        // 2. 获取文件映射后的内存地址
        m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
        if (m_ptr == MAP_FAILED) {
            ......
        } else {
            // 3. 读取内存文件的前 32 位, 获取存储数据的真实大小
            memcpy(&m_actualSize, m_ptr, Fixed32Size);
            ......
            bool loadFromFile = false, needFullWriteback = false;
            if (m_actualSize > 0) {
                // 4. 验证文件的长度
                if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
                    // 5. 验证文件 CRC 的正确性
                    if (checkFileCRCValid()) {
                        loadFromFile = true;
                    } else {
                        // 若不正确, 则回调异常 CRC 异常
                        auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);
                        if (strategic == OnErrorRecover) {
                            loadFromFile = true;
                            needFullWriteback = true;
                        }
                    }
                } else {
                    // 回调文件长度异常
                    auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);
                    if (strategic == OnErrorRecover) {
                        writeAcutalSize(m_size - Fixed32Size);
                        loadFromFile = true;
                        needFullWriteback = true;
                    }
                }
            }
            // 6. 需要从文件获取数据
            if (loadFromFile) {
                ......
                // 构建输入缓存
                MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
                if (m_crypter) {
                    // 解密输入缓冲中的数据
                    decryptBuffer(*m_crypter, inputBuffer);
                }
                // 从输入缓冲中将数据读入 m_dic
                m_dic.clear();
                MiniPBCoder::decodeMap(m_dic, inputBuffer);
                // 构建输出数据
                m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
                                               m_size - Fixed32Size - m_actualSize);
                // 进行重整回写, 剔除重复的数据
                if (needFullWriteback) {
                    fullWriteback();
                }
            } 
            // 7. 说明文件中没有数据, 或者校验失败了
            else {
                SCOPEDLOCK(m_exclusiveProcessLock);
                // 清空文件中的数据
                if (m_actualSize > 0) {
                    writeAcutalSize(0);
                }
                m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
                // 重新计算 CRC
                recaculateCRCDigest();
            }
            ......
        }
    }
    
    ......

    m_needLoadFromFile = false;
}
复制代码

好的, 可以看到 loadFromFile 中对于 CRC 验证通过的文件, 会将文件中的数据读入到 m_dic 中缓存, 否则则会清空文件

  • 因此用户恶意修改文件之后, 会破坏 CRC 的值, 这个存储数据便会被作废, 这一点要尤为注意
  • 从文件中读取数据到 m_dic 之后, 会将 mdic 回写到文件中, 其重写的目的是为了剔除重复的数据
    • 关于为什么会出现重复的数据, 在后面 encode 操作中再分析

三) 回顾

到这里 MMKV 实例的构建就完成了, 有了 m_dic 这个内存缓存, 我们进行数据查询的效率就大大提升了

从最终的结果来看它与 SP 是一致的, 都是初次加载时会将文件中所有的数据加载到散列表中, 不过 MMKV 多了一步数据回写的操作, 因此当数据量比较大时, 对实例构建的速度有一定的影响

// 写入 1000 条数据之后, MMVK 和 SharedPreferences 实例化的时间对比
E/TAG: create MMKV instance time is 4 ms
E/TAG: create SharedPreferences instance time is 1 ms
复制代码

从结果上来看, MMVK 的确在实例构造速度上有一定的劣势, 不过得益于是将 m_dic 中的数据写入到 mmap 的内存, 其真正进行文件写入的时机由 Linux 内核决定, 再加上文件的页缓存机制, 所以速度上虽有劣势, 但不至于无法接受

四. encode

关于 encode 即数据的添加与更新的流程, 这里以 encodeString 为例

public class MMKV implements SharedPreferences, SharedPreferences.Editor {

    public boolean encode(String key, String value) {
        return encodeString(nativeHandle, key, value);
    }
    
    private native boolean encodeString(long handle, String key, String value);

}
复制代码

看看 native 层的实现

// native-bridge.cpp
namespace mmkv {

MMKV_JNI jboolean encodeString(JNIEnv *env, jobject, jlong handle, jstring oKey, jstring oValue) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle);
    if (kv && oKey) {
        string key = jstring2string(env, oKey);
        // 若是 value 非 NULL
        if (oValue) {
            // 通过 setStringForKey 函数, 将数据存入
            string value = jstring2string(env, oValue);
            return (jboolean) kv->setStringForKey(value, key);
        } 
        // 若是 value 为 NULL, 则移除 key 对应的 value 值
        else {
            kv->removeValueForKey(key);
            return (jboolean) true;
        }
    }
    return (jboolean) false;
}

}
复制代码

这里我们主要分析一下 setStringForKey 这个函数

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
    if (key.empty()) {
        return false;
    }
    // 1. 将数据编码成 ProtocolBuffer
    auto data = MiniPBCoder::encodeDataWithObject(value);
    // 2. 更新键值对
    return setDataForKey(std::move(data), key);
}
复制代码

这里主要分为两步操作

  • 数据编码
  • 更新键值对

一) 数据的编码

MMKV 采用的是 ProtocolBuffer 编码方式, 这里就不做过多介绍了, 具体请查看 Google 官方文档

// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const string &str) {
    // 1. 创建编码条目的集合
    m_encodeItems = new vector<PBEncodeItem>();
    // 2. 为集合填充数据
    size_t index = prepareObjectForEncode(str);
    PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
    if (oItem && oItem->compiledSize > 0) {
        // 3. 开辟一个内存缓冲区, 用于存放编码后的数据
        m_outputBuffer = new MMBuffer(oItem->compiledSize);
        // 4. 创建一个编码操作对象
        m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
        // 执行 protocolbuffer 编码, 并输出到缓冲区
        writeRootObject();
    }
    // 调用移动构造函数, 重新创建实例返回
    return move(*m_outputBuffer);
}

size_t MiniPBCoder::prepareObjectForEncode(const string &str) {
    // 2.1 创建 PBEncodeItem 对象用来描述待编码的条目, 并添加到 vector 集合
    m_encodeItems->push_back(PBEncodeItem());
    // 2.2 获取 PBEncodeItem 对象
    PBEncodeItem *encodeItem = &(m_encodeItems->back());
    // 2.3 记录索引位置
    size_t index = m_encodeItems->size() - 1;
    {   
        // 2.4 填充编码类型
        encodeItem->type = PBEncodeItemType_String;
        // 2.5 填充要编码的数据
        encodeItem->value.strValue = &str;
        // 2.6 填充数据大小
        encodeItem->valueSize = static_cast<int32_t>(str.size());
    }
    // 2.7 计算编码后的大小
    encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
    return index;
}
复制代码

可以看到, 再未进行编码操作之前, 编码后的数据大小就已经确定好了, 并且将它保存在了 encodeItem->compiledSize 中, 接下来我们看看执行数据编码并输出到缓冲区的操作流程

// MiniPBCoder.cpp
void MiniPBCoder::writeRootObject() {
    for (size_t index = 0, total = m_encodeItems->size(); index < total; index++) {
        PBEncodeItem *encodeItem = &(*m_encodeItems)[index];
        switch (encodeItem->type) {
            // 主要关心编码 String
            case PBEncodeItemType_String: {
                m_outputData->writeString(*(encodeItem->value.strValue));
                break;
            }
            ......
        }
    }
}

// CodedOutputData.cpp
void CodedOutputData::writeString(const string &value) {
    size_t numberOfBytes = value.size();
    ......
    // 1. 按照 varint 方式编码字符串长度, 会改变 m_position 的值
    this->writeRawVarint32((int32_t) numberOfBytes);
    // 2. 将字符串的数据拷贝到编码好的长度后面
    memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
    // 更新 position 的值
    m_position += numberOfBytes;
}
复制代码

可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 进行了字符串的编码操作

其中 m_ptr 是上面开辟的内存缓冲区的地址, 也就是说 writeString 执行结束之后, 数据就已经被写入缓冲区了

有了编码好的数据缓冲区, 接下来看看更新键值对的操作

二) 键值对的更新

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
    // 编码数据获取存放数据的缓冲区
    auto data = MiniPBCoder::encodeDataWithObject(value);
    // 更新键值对
    return setDataForKey(std::move(data), key);
}

bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
    ......
    // 将键值对写入 mmap 文件映射的内存中
    auto ret = appendDataWithKey(data, key);
    // 写入成功, 更新散列数据
    if (ret) {
        m_dic[key] = std::move(data);
        m_hasFullWriteback = false;
    }
    return ret;
}

bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
    // 1. 计算 key + value 的 ProtocolBuffer 编码后的长度
    size_t keyLength = key.length();
    size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
    size += data.length() + pbRawVarint32Size((int32_t) data.length());
    SCOPEDLOCK(m_exclusiveProcessLock);
    
    // 2. 验证是否有足够的空间, 不足则进行数据重整与扩容操作
    bool hasEnoughSize = ensureMemorySize(size);
    if (!hasEnoughSize || !isFileValid()) {
        return false;
    }
    
    // 3. 更新文件头的数据总大小
    writeAcutalSize(m_actualSize + size);
    
    // 4. 将 key 和编码后的 value 写入到文件映射的内存
    m_output->writeString(key);
    m_output->writeData(data);
    
    // 5. 获取文件映射内存当前 <key, value> 的起始位置
    auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
    if (m_crypter) {
        // 加密这块区域
        m_crypter->encrypt(ptr, ptr, size);
    }
    
    // 6. 更新 CRC
    updateCRCDigest(ptr, size, KeepSequence);
    return true;
}
复制代码

好的, 可以看到更新键值对的操作还是比较复杂的, 首先将键值对数据写入到文件映射的内存中, 写入成功之后更新散列数据

关于写入到文件映射的过程, 上面代码中的注释也非常的清晰, 接下来我们 ensureMemorySize 是如何进行数据的重整与扩容的

数据的重整与扩容

// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {
    ......
    // 计算新键值对的大小
    constexpr size_t ItemSizeHolderSize = 4;
    if (m_dic.empty()) {
        newSize += ItemSizeHolderSize;
    }
    // 数据重写: 
    // 1. 文件剩余空闲空间少于新的键值对
    // 2. 散列为空
    if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
        // 计算所需的数据空间
        static const int offset = pbFixed32Size(0);
        MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
        size_t lenNeeded = data.length() + offset + newSize;
        if (m_isAshmem) {
            ......
        } else {
            // 
            // 计算每个键值对的平均大小
            size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
            // 计算未来可能会使用的大小(类似于 1.5 倍)
            size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
            // 1. 所需空间 >= 当前文件总大小
            // 2. 所需空间的 1.5 倍 >= 当前文件总大小
            if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
                // 扩容为 2 倍
                size_t oldSize = m_size;
                do {
                    m_size *= 2;
                } while (lenNeeded + futureUsage >= m_size);
                .......
            }
        }
        ......
        // 进行数据的重写
        writeAcutalSize(data.length());
        ......
    }
    return true;
}
复制代码

从上面的代码我们可以了解到

  • 数据的重写时机
    • 文件剩余空间少于新的键值对大小
    • 散列为空
  • 文件扩容时机
    • 所需空间的 1.5 倍超过了当前文件的总大小时, 扩容为之前的两倍

三) 回顾

至此 encode 的流程我们就走完了, 回顾一下整个 encode 的流程

  • 使用 ProtocolBuffer 编码 value
  • key编码后的 value 使用 ProtocolBuffer 的格式 append 到文件映射区内存的尾部
    • 文件空间不足
      • 判断是否需要扩容
      • 进行数据的回写
    • 即在文件后进行追加
  • 对这个键值对区域进行统一的加密
  • 更新 CRC 的值
  • 将 key 和 value 对应的 ProtocolBuffer 编码内存区域, 更新到散列表 m_dic 中

通过 encode 的分析, 我们得知 MMKV 文件的存储方式如下

MMKV 文件存储格式

 

接下来看看 decode 的流程

五. decode

decode 的过程同样以 decodeString 为例

// native-bridge.cpp
MMKV_JNI jstring
decodeString(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jstring oDefaultValue) {
    MMKV *kv = reinterpret_cast<MMKV *>(handle);
    if (kv && oKey) {
        string key = jstring2string(env, oKey);
        // 通过 getStringForKey, 将数据输出到传出参数中 value 中
        string value;
        bool hasValue = kv->getStringForKey(key, value);
        if (hasValue) {
            return string2jstring(env, value);
        }
    }
    return oDefaultValue;
}

// MMKV.cpp
bool MMKV::getStringForKey(const std::string &key, std::string &result) {
    if (key.empty()) {
        return false;
    }
    SCOPEDLOCK(m_lock);
    // 1. 从内存缓存中获取数据
    auto &data = getDataForKey(key);
    if (data.length() > 0) {
        // 2. 解析 data 对应的 ProtocolBuffer 数据
        result = MiniPBCoder::decodeString(data);
        return true;
    }
    return false;
}

const MMBuffer &MMKV::getDataForKey(const std::string &key) {
    // 从散列表中获取 key 对应的 value
    auto itr = m_dic.find(key);
    if (itr != m_dic.end()) {
        return itr->second;
    }
    static MMBuffer nan(0);
    return nan;
}
复制代码

好的可以看到 decode 的流程比较简单, 先从内存缓存中获取 key 对应的 value 的 ProtocolBuffer 内存区域, 再解析这块内存区域, 从中获取真正的 value 值

思考

看到这里可能会有一个疑问, 为什么 m_dic 不直接存储 key 和 value 原始数据呢, 这样查询效率不是更快吗?

  • 如此一来查询效率的确会更快, 因为少了 ProtocolBuffer 解码的过程

读取性能对比

 

从图上的结果可以看出, MMKV 的读取性能时略低于 SharedPreferences 的, 这里笔者给出自己的思考

  • m_dic 在数据重整中也起到了非常重要的作用, 需要依靠 m_dic 将数据写入到 mmap 的文件映射区, 这个过程是非常耗时的, 若是原始的 value, 则需要对所有的 value 再进行一次 ProtocolBuffer 编码操作, 尤其是当数据量比较庞大时, 其带来的性能损耗更是无法忽略的

既然 m_dic 还承担着方便数据复写的功能, 那能否再添加一个内存缓存专门用于存储原始的 value 呢?

  • 当然可以, 这样 MMKV 的读取定是能够达到 SharedPreferences 的水平, 不过 value 的内存消耗则会加倍, MMKV 作为一个轻量级缓存的框架, 查询时时间的提升幅度还不足以用内存加倍的代价去换取, 我想这是 Tencent 在进行多方面权衡之后, 得到的一个比较合理的解决方案

六. 进程读写的同步

说起进程间读写同步, 我们很自然的想到 Linux 的信号量, 但是这种方式有一个弊端, 那就是当持有锁的进程意外死亡的时候, 并不会释放其拥有的信号量, 若多进程之间存在竞争, 那么阻塞的进程将不会被唤醒, 这是非常危险的

MMKV 是采用 文件锁 的方式来进行进程间的同步操作

  • LOCK_SH(共享锁): 多个进程可以使用同一把锁, 常被用作读共享锁
  • LOCK_EX(排他锁): 同时只允许一个进程使用, 常被用作写锁
  • LOCK_UN: 释放锁

接下来我看看 MMKV 加解锁的操作

一) 文件共享锁

MMKV::MMKV(
    const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
    : m_mmapID(mmapedKVKey(mmapID, relativePath))
    // 创建文件锁的描述
    , m_fileLock(m_metaFile.getFd())
    // 描述共享锁
    , m_sharedProcessLock(&m_fileLock, SharedLockType)
    // 描述排它锁
    , m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
    // 判读是否为进程间通信
    , m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0)
    , m_isAshmem((mode & MMKV_ASHMEM) != 0) {
    ......
    // 根据是否跨进程操作判断共享锁和排它锁的开关
    m_sharedProcessLock.m_enable = m_isInterProcess;
    m_exclusiveProcessLock.m_enable = m_isInterProcess;

    // sensitive zone
    {
        // 文件读操作, 启用了文件共享锁
        SCOPEDLOCK(m_sharedProcessLock);
        loadFromFile();
    }
}
复制代码

可以看到在我们前面分析过的构造函数中, MMKV 对文件锁进行了初始化, 并且创建了共享锁和排它锁, 并在跨进程操作时开启, 当进行读操作时, 启动了共享锁

二) 文件排它锁

bool MMKV::fullWriteback() {
    ......
    auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
    // 启动了排它锁
    SCOPEDLOCK(m_exclusiveProcessLock);
    if (allData.length() > 0) {
        if (allData.length() + Fixed32Size <= m_size) {
            if (m_crypter) {
                m_crypter->reset();
                auto ptr = (unsigned char *) allData.getPtr();
                m_crypter->encrypt(ptr, ptr, allData.length());
            }
            writeAcutalSize(allData.length());
            delete m_output;
            m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
            m_output->writeRawData(allData); // note: don't write size of data
            recaculateCRCDigest();
            m_hasFullWriteback = true;
            return true;
        } else {
            // ensureMemorySize will extend file & full rewrite, no need to write back again
            return ensureMemorySize(allData.length() + Fixed32Size - m_size);
        }
    }
    return false;
}
复制代码

在进行数据回写的函数中, 启动了排它锁

三) 读写效率表现

其进程同步读写的性能表现如下

进程同步读写表现

 

可以看到进程同步读写的效率也是非常 nice 的

关于跨进程同步就介绍到这里, 当然 MMKV 的文件锁并没有表面上那么简单, 因为文件锁为状态锁, 无论加了多少次锁, 一个解锁操作就全解除, 显然无法应对子函数嵌套调用的问题, MMKV 内部通过了自行实现计数器来实现锁的可重入性, 更多的细节可以查看 wiki

总结

通过上面的分析, 我们对 MMKV 有了一个整体上的把控, 其具体的表现如下所示

项目评价描述
正确性支持多进程安全, 使用 mmap, 由操作系统保证数据回写的正确性
时间开销使用 mmap 实现, 减少了用户空间数据到内核空间的拷贝
空间开销使用 protocl buffer 存储数据, 同样的数据会比 xml 和 json 消耗空间小
使用的是数据追加到末尾的方式, 只有到达一定阈值之后才会触发键值合并, 不合并之前会导致同一个 key 存在多份
安全使用 crc 校验, 甄别文件系统和操作系统不稳定导致的异常数据
开发成本使用方式较为简单
兼容性各个安卓版本都前后兼容

虽然 MMKV 一些场景下比 SP 稍慢(如: 首次实例化会进行数据的复写剔除重复数据, 比 SP 稍慢, 查询数据时存在 ProtocolBuffer 解码, 比 SP 稍慢), 但其逆天的数据写入速度、mmap Linux 内核保证数据的同步, 以及 ProtocolBuffer 编码带来的更小的本地存储空间占用等都是非常棒的闪光点

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Android 存储优化 —— MMKV 集成与原理 的相关文章

  • linux开启关闭端口(iptables 无法使用情况下)

    一 查看端口开启状态 查询已开放的端口 netstat anp root 64 localhost etc firewall cmd query port 61 8080 tcp 提示 yes xff0c 表示开启 xff1b no表示未开
  • 中介者模式

    模式的结构与实现 中介者模式实现的关键是找出 中介者 xff0c 下面对它的结构和实现进行分析 1 模式的结构 中介者模式包含以下主要角色 抽象中介者 xff08 Mediator xff09 角色 xff1a 它是中介者的接口 xff0c
  • YUV解析

    一般的视频采集芯片输出的码流一般都是 YUV 格式数据流 xff0c 后续视频处理也是对 YUV 数据流进行编码和解析 所以 xff0c 了解 YUV 数据流对做视频领域的人而言 xff0c 至关重要 在介绍 YUV 格式之前 xff0c
  • Android Camera旋转角度

    首先理解一下 info orientation 官方解释 官方定义 xff1a orientation 表示相机图像的方向 它的值是相机图像顺时针旋转到设备自然方向一致时的角度 例如假设设备是竖屏的 后置相机传感器是横屏安装的 当你面向屏幕
  • NV21 图像旋转处理 ( 后置摄像头顺时针旋转 90 度 | 前置摄像头顺时针旋转 90 度 )

    1 NV21 格式图像数据的排列 16 1616 个 Y 灰度数据在前 然后 4 44 组 8 88 个 VU 色彩值 饱和度 数据交替存放 2 NV21 格式的图像的 YUV 值顺时针旋转 90 度后的 YUV 矩阵为 3 灰度值 Y 数
  • enum 实现 Parcelable 接口

    enum 实现 Parcelable 接口 当你创建一个枚举 xff0c 想要使用上述插件时 xff0c 就会发现无法序列号 这个是因为 Parcel writeXXX 没有写入枚举的方法 xff0c 所以无法直接实现 Parcelable
  • Java暂停/挂起线程(suspend())和恢复线程(resume())

    暂停线程意味着此线程还可以恢复运行 在 Java 多线程中 xff0c 可以使用 suspend 方法暂停线程 xff0c 使用 resume 方法恢复线程的执行 suspend 与 resume 方法 本节通过一个案例来介绍 suspen
  • Java yieId()方法如何使用

    yieId 方法的作用是放弃当前的 CPU 资源 xff0c 将它让给其他的任务去占用 CPU 执行时间 但放弃的时间不确定 xff0c 有可能刚刚放弃 xff0c 马上又获得 CPU 时间片 例 1 创建一个线程实现从 1 开始 xff0
  • Gson源码解析

    Gson简介 Gson xff0c 就是帮助我们完成序列化和反序列化的工作的一个库 日常用法 UserInfo userInfo 61 getUserInfo Gson gson 61 new Gson String jsonStr 61
  • Git内部原理

    Git是怎么储存信息的 这里会用一个简单的例子让大家直观感受一下git是怎么储存信息的 首先我们先创建两个文件 git init echo 39 111 39 gt a txt echo 39 222 39 gt b txt git add
  • Java并发的AQS原理详解

    线程阻塞原语 Java 的线程阻塞和唤醒是通过 Unsafe 类的 park 和 unpark 方法做到的 public class Unsafe public native void park boolean isAbsolute lon
  • MySql前瞻,数据库管理技术的发展阶段

    文章目录 数据管理技术的3个发展阶段人工管理阶段文件系统阶段数据库系统阶段各个阶段背景及特点 数据管理技术的3个发展阶段 在目前阶段 xff0c 存储和管理数据都离不开数据库 当数据存储到数据库后 xff0c 数据库管理系统就会对这些数据进
  • git 拉取远程分支到本地

    1 把远程分支拉到本地 git fetch origin develop xff08 develop为远程仓库的分支名 xff09 2 在本地创建分支dev并切换到该分支 git checkout b dev 本地分支名称 origin d
  • 会话描述协议SDP

    什么是SDP SDP xff08 Session Description Protocol xff09 是一种通用的会话描述协议 xff0c 主要用来描述多媒体会话 xff0c 用途包括会话声明 会话邀请 会话初始化等 WebRTC主要在连
  • android studio引入本地外部项目的Module

    方法一 1 File gt New gt Import Module 2 Source directory 这里选择其它工程的module 点击Finish完成 方法二 1 File gt New gt New Module 或在工程上右键
  • git merge冲突解决

    1 git merge冲突了 xff0c 根据提示找到冲突的文件 xff0c 解决冲突 如果文件有冲突 xff0c 那么会有类似的标记 2 修改完之后 xff0c 执行git add 冲突文件名 3 git commit 注意 没有 m选项
  • Java并发之CAS原理分析

    CAS 底层原理 CAS 的思想很简单 xff1a 三个参数 xff0c 一个当前内存值 V 旧的预期值 A 即将更新的值 B xff0c 当且仅当预期值 A 和内存值 V 相同时 xff0c 将内存值修改为 B 并返回 true xff0
  • git 删除分支

    删除一个已被终止的分支 如果需要删除的分支不是当前正在打开的分支 xff0c 使用branch d直接删除 git branch d lt branch name gt 异常 error Cannot delete branch 39 xx
  • 异常 Caused by: java.lang.ClassNotFoundException: Didn‘t find class “...“on path: DexPathList

    解决方法 xff1a Android的项目目录里是有两个build文件夹的 xff0c 一个是 xff1a 项目目录 app build xff0c 另一个是 xff1a 项目目录 build Build gt Clean Project
  • java消息队列,业务应用场景概述

    1 异步处理 场景说明 xff1a 用户注册后 xff0c 需要发注册邮件和注册短信 传统的做法有两种 1 串行的方式 xff1b 2 并行方式 a 串行方式 xff1a 将注册信息写入数据库成功后 xff0c 发送注册邮件 xff0c 再

随机推荐

  • TCP三次握手,四次挥手

    三次握手 xff1a 第一次握手 SYN 61 1 seq 61 x xff0c 发送完毕后 xff0c 客户端进入 SYN SEND 状态 第二次握手 SYN 61 1 ACK 61 1 seq 61 y ACKnum 61 x 43 1
  • 206. 反转链表

    方法一 xff1a 迭代 假设链表为 1 2 3 xff0c 我们想要把它改成 1 2 3 在遍历链表时 xff0c 将当前节点的 next 指针改为指向前一个节点 由于节点没有引用其前一个节点 xff0c 因此必须事先存储其前一个节点 在
  • 启动不了docker怎么办?关于docker报错

    常常有时候电脑重启之后或者前一天正常关机第二天就启动不了的问题 xff1f 问题描述 从terminal启动ubuntu1804报错 参考的对象类型不支持尝试的操作 直接启动ubuntu1804也不行 解决方法 以左下角鼠标右键管理员身份打
  • Java 并发编程(一)

    多线程带来的风险 public class Unsafe private int chenmo public int add return chenmo 43 43 上面这段代码在单线程的环境中可以正确执行 xff0c 但在多线程的环境中则
  • Java并发编程(二):保证共享变量的原子性

    怎么让一个类在多线程的环境下是安全的 xff0c 有 3 条法则 xff0c 让我来告诉你 xff1a 1 不在线程之间共享状态变量 2 将状态变量改为不可变 3 访问状态变量时使用同步 那你可能要问 xff0c 状态变量是什么 xff1f
  • Java 并发编程(三):保证共享变量的可见性

    Java 内存模型 xff08 Java Memory Model xff0c 简称 JMM xff09 描述了 Java 程序中各种变量 xff08 线程之间的共享变量 xff09 的访问规则 xff0c 以及在 JVM 中将变量存储到内
  • Java 并发编程(四):保证对象的线程安全性

    02 线程安全类 设计一个线程安全类需要三个步骤 xff1a 1 xff09 找出表示对象状态的所有变量 2 xff09 对变量进行有效性约束 3 xff09 增加类的并发访问策略 我在作者说的基础上做了微调 xff0c 读起来更加容易理解
  • gradle依赖变量

    settings gradle对象生成早于app build gradle甚至早于根目录的build gradle 所以在build gradle里声明ext someVar 61 xxx 变量无效 settings无法访问 因此在grad
  • AudioTrack分析

    第一部分 AudioTrack分析 一 目的 本文的目的是通过从Audio系统来分析Android的代码 xff0c 包括Android自定义的那套机制和一些常见类的使用 xff0c 比如Thread xff0c MemoryBase等 分
  • git 报错信息:SSL certificate problem: certificate has expired 解决方案

    执行命令 git config global http sslVerify false 再次执行 git pull 成功拉取
  • 获取本机号码及sim卡信息

    一 SIM卡存储的数据可分为四类 xff0c 它们分别是 xff1a 第一类是固定存放的数据 这类数据在移动电话机被出售之前由SIM卡中心写入 xff0c 包括国际移动用户识别号 xff08 IMSI xff09 鉴权密钥 xff08 KI
  • Socket粘包问题的3种解决方案

    在 Java 语言中 xff0c 传统的 Socket 编程分为两种实现方式 xff0c 这两种实现方式也对应着两种不同的传输层协议 xff1a TCP 协议和 UDP 协议 xff0c 但作为互联网中最常用的传输层协议 TCP xff0c
  • Android aar包的使用 打包aar后报错ClassNotFoundException,aar中有dependencies依赖的情况;

    示例 xff1a 现有A xff0c B两个library module都是本地的 xff0c A引用B xff0c 我把A打成了aar库 xff0c 打出来之后却没有B的内容 xff0c 导致我的主工程引用A之后 xff0c 执行某个应用
  • MFC架构下的DirectX8

    MFC架构下的DirectX8 第一章 MFC框架 DX8MFC 这里的MFC框架指的是一个符合游戏开发应用的框架 xff0c 当然你也可以写一个符合你要求的MFC框架 如果你对MFC比较熟悉的话可以直接从第二章开始阅读 本框架是以后几个例
  • 解决:There is no tracking information for the current branch. Please specify which branch you want to

    报警信息 xff1a There is no tracking information for the current branch Please specify which branch you want to rebase agains
  • git删除远程分支和本地分支

    1 xff09 使用命令git branch a 查看所有分支 注 xff1a 其中 xff0c remote origin master表示的是远程分支 xff08 2 xff09 删除远程分支 注 xff1a 如上所示 xff0c 使用
  • Java 集合 List 与 Array 的转换

    List 转 Array 使用集合转数组的方法 xff0c 必须使用集合的 toArray T array xff0c 传入的是类型完全一样的数组 xff0c 大小就是 list size 反例 xff1a 直接使用 toArray 无参方
  • C语言中.h和.c文件解析

    简单的说其实要理解C文件与头文件 xff08 即 h xff09 有什么不同之处 xff0c 首先需要弄明白编译器的工作过程 xff0c 一般说来编译器会做以下几个过程 xff1a 1 预处理阶段 2 词法与语法分析阶段 3 编译阶段 xf
  • CMake 使用

    1 前言 当在做 Android NDK 开发时 xff0c 如果不熟悉用 CMake 来构建 xff0c 读不懂 CMakeLists txt 的配置脚本 xff0c 很容易就会踩坑 xff0c 遇到编译失败 xff0c 一个很小的配置问
  • Android 存储优化 —— MMKV 集成与原理

    前言 APP 的性能优化之路是永无止境的 这里学习一个腾讯开源用于提升本地存储效率的轻量级存储框架 MMKV 目前项目中在轻量级存储上使用的是 SharedPreferences 虽然 SP 兼容性极好 但 SP 的低性能一直被诟病 线上也