赞
踩
谈到轻量级的数据持久化,在 Android 开发过程中,大家首先想到的应该就是 SharedPreferences(以下简称 SP),其存储数据是以 key-value 键值对的形式保存在 data/data/<package name>/shared_prefs 路径下的 xml 文件中,使用 I/O 流 进行文件的读写。通常用来保存应用中的一些简单的配置信息,如用户名、密码、自定义参数的设置等。
需要注意的是:SP 中的 value 值只能是 int、boolean、float、long、String、StringSet 这些类型的数据。
作为 Android 原生库中自带的轻量级存储类,SP 在使用方式上还是很便捷的,但是也存在以下的一些问题:
为了解决上述问题,腾讯的微信团队基于 MMAP 研发了 MMKV 来代替 SP。
MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。支持通过 AES 算法对 protobuf 文件进行加密,并且引入 循环冗余校验码(CRC) 对文件的完整性进行校验。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。近期也已移植到 Android / macOS / Windows/ POSIX 平台,并且开源。
mmap 是 memory map 的缩写,也就是内存映射或地址映射,是 Linux 操作系统中的一种系统调用,它的作用是将一个文件或者其它对象映射到进程的地址空间,实现磁盘地址和进程虚拟地址空间一段虚拟地址的一一对应关系。通过 mmap 这个系统调用我们可以让进程之间通过映射到同一个普通文件实现共享内存,普通文件被映射到进程地址空间当中后,进程可以像访问普通内存一样对文件进行一系列操作,而不需要通过 I/O 系统调用来读取或写入。
mmap 函数 声明如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
函数各个参数的含义如下:
mmap 函数会将一个文件或其他对象映射到进程的地址空间中,并返回一个指向映射区域的指针,进程可以使用指针来访问映射区域的数据,就像访问内存一样。关于 mmap 的映射原理及源码分析,有兴趣的同学可看一下这篇文章。
Protocol Buffers 简称:Protobuf,是 Google 提供的一个具有高效的协议数据交换格式工具库,用于高效地序列化和反序列化结构化数据,通常用于网络通信、数据存储等场景。
Protobuf 和 Xml、Json 序列化的方式不同,采用了二进制字节的序列化方式,用字段索引和字段类型通过算法计算得到字段之间的关系映射,从而达到更高的时间效率和空间效率,特别适合对数据大小和传输速率比较敏感的场合使用。
Protobuf 采用了一种 TLV (Tag-Length-Value) 的格式进行编码,其格式如下:
由图可知,每条字段都由 Tag、Length、Value 三部分组成,其中 Length 是可选的。Tag 字段又由 field_number 和 wire_type 两部分组成,其中:
并且 Tag 采用了 Varints 编码,这是一种可变长的 int 编码(类似 dex 文件的 LEB128),其编码规则如下:
Protobuf 的主要优点包括:
在 MMKV 中通过 MiniPBCoder 完成了 Protobuf 的序列化及反序列化。可以通过 MiniPBCoder::decodeMap 将 MMKV 存储的 Protobuf 文件反序列化为对应的 map,也可以通过 MiniPBCoder::encodeDataWithObject 将 map 序列化为对应存储的字节流。
通过 MMKV.initialize 方法可以实现 MMKV 的初始化:
public class MMKV implements SharedPreferences, SharedPreferences.Editor { // call on program start 程序启动时调用 public static String initialize(Context context) { // 使用内部存储空间下的 mmkv 文件夹作为根目录 String root = context.getFilesDir().getAbsolutePath() + "/mmkv"; // 继续调用 initialize 方法传入根目录 root 进行初始化 return initialize(root, null); } // 记录 mmkv 存储使用的根目录 static private String rootDir = null; public static String initialize(String rootDir, LibLoader loader) { ...... // 省略 MMKV.rootDir = rootDir; // 保存根目录 // Native 层初始化 jniInitialize(MMKV.rootDir); return rootDir; } // JNI 调用到 Native 层继续初始化 private static native void jniInitialize(String rootDir); }
MMKV 的初始化,主要是将存储根目录通过 jniInitialize 传入到 Native 层,接下来看看 Native 层的初始化操作:
// native-bridge.cpp namespace mmkv { // mmkv 命名空间 MMKV_JNI void jniInitialize(JNIEnv *env, jobject obj, jstring rootDir) { if (!rootDir) { // 如果根目录为空则直接返回 return; } // 将 jstring 类型转化为 c 中的 const char * 类型 const char *kstr = env->GetStringUTFChars(rootDir, nullptr); if (kstr) { // 调用 MMKV::initializeMMKV 对 MMKV 进行初始化 MMKV::initializeMMKV(kstr); // c 和 c++ 与 Java 不同,用完需主动释放掉 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() { // 获取一个 unordered_map, 类似于 Java 中的 HashMap g_instanceDic = new unordered_map<std::string, MMKV *>; // 初始化线程锁 g_instanceLock = ThreadLock(); g_instanceLock->initialize(); ...... } void MMKV::initializeMMKV(const std::string &rootDir) { // 由 Linux Thread 互斥锁和条件变量保证 initialize 函数在一个进程内只会执行一次 static pthread_once_t once_control = PTHREAD_ONCE_INIT; // 回调 initialize() 方法进行初始化操作 pthread_once(&once_control, initialize); // 将根目录保存到全局变量 g_rootDir = rootDir; // 字符串拷贝库函数,这里是防止根目录被修改字符串的内容,因此拷贝副本使用 char *path = strdup(g_rootDir.c_str()); if (path) { // 根据路径, 生成目标地址的目录 mkPath(path); free(path); // 释放内存 } }
可以看到 initializeMMKV 的主要任务是初始化数据,以及创建根目录:
pthread_once_t: 类似于 Java 的单例,其 initialize 方法在进程内只会执行一次。
// 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; }
mkPath 根据字符串创建好文件目录之后,Native 层的初始化操作便结束了,接下来看看 MMKV 实例构建的过程。
通过 mmkvWithID 方法可以获取 MMKV 对象,传入的 mmapID 就对应了 SharedPreferences 中的 name,代表了一个文件对应的 name,而 relativePath 则对应了一个相对根目录的相对路径:
public class MMKV implements SharedPreferences, SharedPreferences.Editor { @Nullable public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) { if (rootDir == null) { throw new IllegalStateException("You should Call MMKV.initialize() first."); } // Native 层 getMMKVWithID 方法,执行完 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; // Java 层持有 Native 层对象的地址从而与 Native 对象通信 private MMKV(long handle) { nativeHandle = handle; // 并不是真正的 new 出 Java 层的一个实例对象 } }
调用到 Native 层的 getMMKVWithId 方法,并获取到了一个 handle 构造了 Java 层的 MMKV 对象返回,Java 层通过持有 Native 层对象的地址从而与 Native 对象通信。
// 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) { // mmapID 为 null 返回空指针 return (jlong) kv; } // 获取独立存储 mmapID string str = jstring2string(env, mmapID); // jstring类型的值转化为c++中的string类型 bool done = false; if (cryptKey) { // 如果需要进行加密,获取用于加密的 key,最后调用 MMKV::mmkvWithID 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; } } // 如果不需要加密,则调用 mmkvWithID 不传入加密 key,表示不进行加密 if (!done) { if (relativePath) { string path = jstring2string(env, relativePath); kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, &path); } else { kv = MMKV::mmkvWithID(str, DEFAULT_MMAP_SIZE, (MMKVMode) mode, nullptr, nullptr); } } // 强转成句柄, 返回到 Java return (jlong) kv; } }
其内部继续调用了 MMKV::mmkvWithID 方法,根据是否传入用于加密的 key 以及是否使用相对路径调用了不同的方法来获取到 MMKV 的对象。
// MMKV.cpp MMKV *MMKV::mmkvWithID( const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) { if (mmapID.empty()) { // mmapID 为 null 返回空指针 return nullptr; } SCOPEDLOCK(g_instanceLock); // 加锁 // 通过 mmapID 和 relativePath, 组成最终的 mmap 文件路径的 mmapKey auto mmapKey = mmapedKVKey(mmapID, relativePath); // 通过 mmapKey 在全局缓存中查找 map 中对应的 MMKV 对象并返回 auto itr = g_instanceDic->find(mmapKey); if (itr != g_instanceDic->end()) { MMKV *kv = itr->second; return kv; } // 如果找不到,构建路径后构建 MMKV 对象并加入 map if (relativePath) { // 根据 mappedKVPathWithID 获取 mmap 的最终文件路径 // mmapID 使用 md5 加密 auto filePath = mappedKVPathWithID(mmapID, mode, relativePath); if (!isFileExist(filePath)) { // 不存在则创建一个文件 if (!createFile(filePath)) { return nullptr; // 创建不成功则返回空指针 } } ... } // 创建实例对象 auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath); // 缓存这个 mmapKey (*g_instanceDic)[mmapKey] = kv; return kv; }
MMKV::mmkvWithID 方法的执行流程如下:
接下来重点关注 MMKV 的构造函数:
// MMKV.cpp MMKV::MMKV( const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) : m_mmapID(mmapedKVKey(mmapID, relativePath)) // 通过 mmapID 和 relativePath 组成最终的 mmap 文件路径的 mmapKey 赋值给 m_mmapID // 拼装文件的路径 , m_path(mappedKVPathWithID(m_mmapID, mode, relativePath)) // 拼装 .crc 文件路径 , m_crcPath(crcPathWithID(m_mmapID, mode, relativePath)) // 将文件摘要信息映射到内存, 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 加解密的引擎 AESCrypt if (cryptKey && cryptKey->length() > 0) { m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length()); } ...... // sensitive zone { // 加锁后调用 loadFromFile 根据 m_mmapID 来加载文件中的数据 SCOPEDLOCK(m_sharedProcessLock); loadFromFile(); } }
MMKV 构造函数:
MMKV 的构造函数可以看出,MMKV 是支持 Ashmem 共享内存的,当我们不想将文件写入磁盘,但是又想进行跨进程通信,就可以使用 MMKV 提供的 MMAP_ASHMEM。
// 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) { // 用于内存映射的文件 // open 方法打开文件 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 { 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); // 获取文件大小 } // 验证文件的大小是否小于一个内存页, 一般为 4kb if (m_segmentSize < DEFAULT_MMAP_SIZE) { m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE); // 通过 ftruncate 将文件大小对其到内存页 // 通过 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; } } // 通过 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 构造函数:
通过 MmapedFile 的构造函数, 我们便能够获取到映射后的内存首地址,操作这块内存时 Linux 内核会负责将内存中的数据同步到文件中。 即使进程意外死亡,也能够通过 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 { m_size = 0; // 获取文件大小 struct stat st = {0}; if (fstat(m_fd, &st) != -1) { m_size = static_cast<size_t>(st.st_size); } // 将文件大小对齐到内存页大小的整数倍,用 0 填充不足的部分 if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) { ...... } // 通过 mmap 将文件映射到内存,获取映射后的内存地址 m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0); if (m_ptr == MAP_FAILED) { ...... } else { // 读取内存文件的前 32 位, 获取存储数据的真实大小 memcpy(&m_actualSize, m_ptr, Fixed32Size); ...... bool loadFromFile = false, needFullWriteback = false; if (m_actualSize > 0) { // 验证文件的长度 if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) { // 对文件进行 CRC 校验,如果失败根据策略进行不同对处理 if (checkFileCRCValid()) { loadFromFile = true; } else { // CRC 校验失败,则回调 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; } } } // 需要从文件获取数据 if (loadFromFile) { ...... // 构建输入缓存 MMBuffer MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy); if (m_crypter) { // 如果需要解密,对文件进行解密 decryptBuffer(*m_crypter, inputBuffer); } // 通过 MiniPBCoder 将 MMBuffer 转换为 map m_dic.clear(); MiniPBCoder::decodeMap(m_dic, inputBuffer); // 构建输出数据的 CodedOutputData m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize, m_size - Fixed32Size - m_actualSize); // 进行重整回写, 剔除重复的数据 if (needFullWriteback) { fullWriteback(); } } // 说明文件中没有数据, 或者校验失败了 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 函数的执行流程如下:
Java 层的 MMKV 类继承了 SharedPreferences 及 SharedPreferences.Editor 接口并实现了一系列如 putInt、putLong、putString 等方法,同时也有 encode 的很多重载方法,用于对存储的数据进行修改,下面以 putInt 为例:
public class MMKV implements SharedPreferences, SharedPreferences.Editor { ...// 省略部分代码 public boolean encode(String key, int value) { // 调用 Native 层的 encodeInt 方法对数据进行写入操作 return encodeInt(nativeHandle, key, value); } @Override public Editor putInt(String key, int value) { // 调用 Native 层的 encodeInt 方法对数据进行写入操作 encodeInt(nativeHandle, key, value); return this; } private native boolean encodeInt(long handle, String key, int value); }
putInt 方法和 encode 方法,都是调用 Native 层的 encodeInt 方法对数据进行写入操作。
// native-bridge.cpp
namespace mmkv {
MMKV_JNI jboolean encodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint value) {
// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
return (jboolean) kv->setInt32(value, key);
}
return (jboolean) false;
}
}
继续调用 MMKV::setInt32 函数将数据写入:
// MMKV.cpp
bool MMKV::setInt32(int32_t value, const std::string &key) {
if (key.empty()) {
return false;
}
// 构造值对应的 MMBuffer,通过 CodedOutputData 将其写入 Buffer
size_t size = pbInt32Size(value);
MMBuffer data(size);
CodedOutputData output(data.getPtr(), size);
output.writeInt32(value);
return setDataForKey(std::move(data), key);
}
获取准备写入的 value 值在 Protobuf 中所占据的大小 size,之后为其构造了对应的 MMBuffer 并将数据写入了这段 Buffer,最后调用 setDataForKey 方法;
CodedOutputData:是与 Buffer 交互的桥梁,可以通过它实现向 MMBuffer 中写入数据。
// MMKV.cpp bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) { if (data.length() == 0 || key.empty()) { return false; } SCOPEDLOCK(m_lock); // 获取写锁 SCOPEDLOCK(m_exclusiveProcessLock); checkLoadData(); // 确保数据已正确读入内存 // 将键值对写入 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) { size_t keyLength = key.length(); // size needed to encode the key -- 计算 key 的 ProtocolBuffer 编码后的长度 size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength); // size needed to encode the value -- 计算 key + value 的 ProtocolBuffer 编码后的长度 size += data.length() + pbRawVarint32Size((int32_t) data.length()); // 要写入,获取写锁 SCOPEDLOCK(m_exclusiveProcessLock); // 验证是否有足够的空间, 不足则进行数据重整与扩容操作 bool hasEnoughSize = ensureMemorySize(size); if (!hasEnoughSize || !isFileValid()) { return false; } // m_actualSize 是位于文件的首部,保存当前有效内存的大小 // 由于新增数据,需调用 writeAcutalSize 函数更新 m_actualSize 值 writeAcutalSize(m_actualSize + size); // 将编码后的 key 和 value 写入到文件映射的内存 m_output->writeString(key); m_output->writeData(data); // note: write size of data // 获取文件映射内存当前 <key, value> 的起始位置 auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size; if (m_crypter) { // 需加密,则加密这块区域 m_crypter->encrypt(ptr, ptr, size); } updateCRCDigest(ptr, size, KeepSequence); // 更新 CRC 校验码 return true; }
注意:由于 Protobuf 不支持增量更新,为了避免全量写入带来的性能问题,MMKV 在文件中的写入并不是通过修改文件对应的位置,而是直接在后面 append 新增一条新的数据,即使是修改了已存在的 key。而读取时只记录最后一条对应 key 的数据,这样会导致在文件中存在冗余的数据。这样设计的原因我认为是出于性能的考量,MMKV 中存在着一套内存重整机制用于对冗余的 key-value 数据进行处理,其正是在确保内存充足时实现的。
// MMKV.cpp bool MMKV::ensureMemorySize(size_t newSize) { if (!isFileValid()) { MMKVWarning("[%s] file not valid", m_mmapID.c_str()); return false; } // make some room for placeholder -- 为占位符留出一些空间 constexpr size_t ItemSizeHolderSize = 4; if (m_dic.empty()) { newSize += ItemSizeHolderSize; } // 如果文件剩余空闲空间少于新的键值对,或存储的散列表为空 if (newSize >= m_output->spaceLeft() || m_dic.empty()) { // try a full rewrite to make space --- 尝试完全重写以腾出空间 static const int offset = pbFixed32Size(0); // 通过 MiniPBCoder::encodeDataWithObject 将整个 map 转换为对应的 MMBuffer MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic); // 计算所需的空间大小 size_t lenNeeded = data.length() + offset + newSize; if (m_isAshmem) { if (lenNeeded > m_size) { MMKVError("ashmem %s reach size limit:%zu, consider configure with larger size", m_mmapID.c_str(), m_size); return false; } } else { // 计算每个键值对的平均大小 size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size()); // 计算未来可能会使用的空间大小 size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2); // 1. no space for a full rewrite, double it -- 所需空间大小 >= 当前可映射文件总大小 // 2. or space is not large enough for future usage, double it to avoid frequently full rewrite // 如果内存重整后仍不足以写入,则将大小不断乘2直至足够写入,最后通过 mmap 重新映射文件 if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) { size_t oldSize = m_size; do { m_size *= 2; // double 空间直至足够 } while (lenNeeded + futureUsage >= m_size); MMKVInfo( "extending [%s] file size from %zu to %zu, incoming size:%zu, future usage:%zu", m_mmapID.c_str(), oldSize, m_size, newSize, futureUsage); // if we can't extend size, rollback to old state -- 如果扩展后大小还是不够,则回到之前的状态 if (ftruncate(m_fd, m_size) != 0) { MMKVError("fail to truncate [%s] to size %zu, %s", m_mmapID.c_str(), m_size, strerror(errno)); m_size = oldSize; return false; } if (!zeroFillFile(m_fd, oldSize, m_size - oldSize)) { // 用零填充不足部分 MMKVError("fail to zeroFile [%s] to size %zu, %s", m_mmapID.c_str(), m_size, strerror(errno)); m_size = oldSize; return false; } if (munmap(m_ptr, oldSize) != 0) { // 如果扩容后满足所需,则先 munmap 解除之前的映射 MMKVError("fail to munmap [%s], %s", m_mmapID.c_str(), strerror(errno)); } // 重新通过 mmap 映射新的空间大小 m_ptr = (char *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0); if (m_ptr == MAP_FAILED) { MMKVError("fail to mmap [%s], %s", m_mmapID.c_str(), strerror(errno)); } // check if we fail to make more space if (!isFileValid()) { MMKVWarning("[%s] file not valid", m_mmapID.c_str()); return false; } } } if (m_crypter) { // 加密数据 m_crypter->reset(); auto ptr = (unsigned char *) data.getPtr(); m_crypter->encrypt(ptr, ptr, data.length()); } // 重新构建并写入数据 writeAcutalSize(data.length()); delete m_output; m_output = new CodedOutputData(m_ptr + offset, m_size - offset); m_output->writeRawData(data); recaculateCRCDigest(); m_hasFullWriteback = true; } return true; }
类似上面数据存入,这里通过 getInt 函数对数据进行读取:
public class MMKV implements SharedPreferences, SharedPreferences.Editor {
...// 省略部分代码
public int decodeInt(String key, int defaultValue) {
// 调用 Native 层的 decodeInt 方法对数据进行读取操作
return decodeInt(nativeHandle, key, defaultValue);
}
@Override
public int getInt(String key, int defValue) {
// 调用 Native 层的 decodeInt 方法对数据进行读取操作
return decodeInt(nativeHandle, key, defValue);
}
private native int decodeInt(long handle, String key, int defaultValue);
}
getInt 方法和 decodeInt 方法,都是调用 Native 层的 decodeInt 方法来读取数据。
// native-bridge.cpp
namespace mmkv {
MMKV_JNI jint decodeInt(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jint defaultValue) {
// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
return (jint) kv->getInt32ForKey(key, defaultValue);
}
return defaultValue;
}
}
继续调用 MMKV::getInt32ForKey 函数读取数据:
// MMKV.cpp int32_t MMKV::getInt32ForKey(const std::string &key, int32_t defaultValue) { if (key.empty()) { return defaultValue; } SCOPEDLOCK(m_lock); auto &data = getDataForKey(key); if (data.length() > 0) { CodedInputData input(data.getPtr(), data.length()); return input.readInt32(); } return defaultValue; } const MMBuffer &MMKV::getDataForKey(const std::string &key) { checkLoadData(); // 确保数据已正确读入内存 auto itr = m_dic.find(key); // 从散列表 map 中寻找获取 key 对应的 value if (itr != m_dic.end()) { return itr->second; } static MMBuffer nan(0); return nan; }
MMKV::getInt32ForKey 函数继续调用 getDataForKey 方法获取 key 对应的 MMBuffer,如读取到数据,则调用 CodedInputData 函数将数据读出并返回,如长度为 0 则视为不存在对应的数据,返回默认值。
getDataForKey 函数通过在散列表 map 中寻找获取 key 对应的 value,找不到会返回 size 为 0 的 MMBuffer。
注意:MMKV 读写是直接读写到 mmap 文件映射的内存上,绕开了普通读写 io 需要进入内核,再写到磁盘的过程。
通过 Java 层 MMKV 的 remove、removeValueForKey 方法传入指定的 key 值来实现删除操作:
public class MMKV implements SharedPreferences, SharedPreferences.Editor { ...// 省略部分代码 public void removeValueForKey(String key) { // 调用 Native 层的 removeValueForKey 方法对数据进行删除操作 removeValueForKey(nativeHandle, key); } @Override public Editor remove(String key) { // 调用 Native 层的 removeValueForKey 方法对数据进行删除操作 removeValueForKey(key); return this; } private native void removeValueForKey(long handle, String key); }
remove 方法和 removeValueForKey 方法,都是调用 Native 层的 removeValueForKey 方法来删除数据。
// native-bridge.cpp
namespace mmkv {
MMKV_JNI void removeValueForKey(JNIEnv *env, jobject instance, jlong handle, jstring oKey) {
// 将 Java 层持有的 NativeHandle 转为对应的 MMKV 对象
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
kv->removeValueForKey(key);
}
}
}
继续调用 MMKV::removeValueForKey 函数删除数据:
// MMKV.cpp void MMKV::removeValueForKey(const std::string &key) { if (key.empty()) { return; } SCOPEDLOCK(m_lock); SCOPEDLOCK(m_exclusiveProcessLock); checkLoadData(); // 确保数据已正确读入内存 removeDataForKey(key); } bool MMKV::removeDataForKey(const std::string &key) { if (key.empty()) { return false; } // 从散列表 map 中删除 key 对应的 value auto deleteCount = m_dic.erase(key); if (deleteCount > 0) { m_hasFullWriteback = false; static MMBuffer nan(0); return appendDataWithKey(nan, key); } // 读取时发现它的 size 为 0,则会认为这条数据已经删除; return false; }
removeDataForKey 函数从散列表 map 中删除 key 对应的 value,然后构造一条 size 为 0 的 MMBuffer 并调用 appendDataWithKey 函数将其 append 到 Protobuf 文件中。
MMKV 中,在一些特定的情景下,会通过 fullWriteback 方法立即将散列表 map 中的数据回写到文件。如:
// MMKV.cpp bool MMKV::fullWriteback() { if (m_hasFullWriteback) { // 如果已经回写完毕,则返回 true return true; } if (m_needLoadFromFile) { // 需要从文件中加载数据是,则返回 true,即此时暂时不回写 return true; } if (!isFileValid()) { // 文件不可用,即没有文件可供回写,则返回 false MMKVWarning("[%s] file not valid", m_mmapID.c_str()); return false; } if (m_dic.empty()) { // 如果存储所用的散列表 m_dic 是空的,直接清空文件 clearAll(); return true; } // 将 m_dic 转换为对应的 MMBuffer 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; // 通过 CodedOutputData 写入编码后的数据 m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size); m_output->writeRawData(allData); // note: don't write size of data recaculateCRCDigest(); // 更新 CRC 校验码 m_hasFullWriteback = true; // 回写完毕,标记 m_hasFullWriteback 为 true return true; } else { // 如果剩余空间不够写入,则调用 ensureMemorySize 函数进行内存重整与扩容 return ensureMemorySize(allData.length() + Fixed32Size - m_size); } } return false; }
在 MMKV 中通过 MiniPBCoder 完成 Protobuf 的序列化及反序列化。通过 MiniPBCoder::decodeMap 将 MMKV 存储的 Protobuf 文件反序列化为对应的散列表 map,通过 MiniPBCoder::encodeDataWithObject 将 map 序列化为对应存储的字节流 MMBuffer。
// MiniPBCoder.h class MiniPBCoder { const MMBuffer *m_inputBuffer; CodedInputData *m_inputData; MMBuffer *m_outputBuffer; CodedOutputData *m_outputData; std::vector<PBEncodeItem> *m_encodeItems; private: MiniPBCoder(); MiniPBCoder(const MMBuffer *inputBuffer); ...... public: template <typename T> static MMBuffer encodeDataWithObject(const T &obj) { MiniPBCoder pbcoder; return pbcoder.getEncodeData(obj); } };
继续调用到 MiniPBCoder::getEncodeData 方法,并传入待序列化的 map:
// MiniPBCoder.cpp MMBuffer MiniPBCoder::getEncodeData(const unordered_map<string, MMBuffer> &map) { m_encodeItems = new vector<PBEncodeItem>(); // 新建 PBEncodeItem 数组 // 调用 prepareObjectForEncode 方法将 map 中的键值对分别构建成 PBEncodeItem 并添加到 m_encodeItems 中 size_t index = prepareObjectForEncode(map); PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr; if (oItem && oItem->compiledSize > 0) { // 新建输出缓存 MMBuffer,然后通过新建的 CodedOutputData 将数据写到 MMBuffer 中 m_outputBuffer = new MMBuffer(oItem->compiledSize); m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length()); writeRootObject(); } return std::move(*m_outputBuffer); }
首先来看 MiniPBCoder::prepareObjectForEncode 方法是如何序列化 map 的:
// MiniPBCoder.cpp size_t MiniPBCoder::prepareObjectForEncode(const unordered_map<string, MMBuffer> &map) { // 新建 PBEncodeItem 放到 m_encodeItems 的尾部 m_encodeItems->push_back(PBEncodeItem()); // 获取 m_encodeItems 尾部刚刚新建的 PBEncodeItem 的引用,以及其对应的 index PBEncodeItem *encodeItem = &(m_encodeItems->back()); size_t index = m_encodeItems->size() - 1; { // 将该 PBEncodeItem 作为一个 Container encodeItem->type = PBEncodeItemType_Container; encodeItem->value.strValue = nullptr; for (const auto &itr : map) { // 遍历 map const auto &key = itr.first; const auto &value = itr.second; if (key.length() <= 0) { continue; // 如果 key 为空,则跳过本次循环 } // 将 key 作为一个 String 类型的 EncodeItem 放入数组 size_t keyIndex = prepareObjectForEncode(key); if (keyIndex < m_encodeItems->size()) { // 将 value 作为一个 Data 类型存储 MMBuffer 的 EncodeItem 放入数组 size_t valueIndex = prepareObjectForEncode(value); if (valueIndex < m_encodeItems->size()) { // 计算 Container 添加 key 和 value 后的 size (*m_encodeItems)[index].valueSize += (*m_encodeItems)[keyIndex].compiledSize; (*m_encodeItems)[index].valueSize += (*m_encodeItems)[valueIndex].compiledSize; } else { // 移除 m_encodeItems 中的最后一个元素 m_encodeItems->pop_back(); // pop key } } } encodeItem = &(*m_encodeItems)[index]; } encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize; return index; }
首先在 m_encodeItems 的尾部添加一个作为 Container 的 PBEncodeItem,随后遍历 map 并对每个 key 和 value 分别构建对应的 PBEncodeItem 并添加到 m_encodeItems 的尾部,并且将它们的 size 计算入 Container 的 valueSize,最后返回该 Container 在 m_encodeItems 中的 index。
接下来看 MiniPBCoder::writeRootObject 方法是如何将数据写入到缓存 MMBuffer 的:
// 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) { case PBEncodeItemType_String: { m_outputData->writeString(*(encodeItem->value.strValue)); break; } case PBEncodeItemType_Data: { m_outputData->writeData(*(encodeItem->value.bufferValue)); break; } case PBEncodeItemType_Container: { m_outputData->writeRawVarint32(encodeItem->valueSize); break; } case PBEncodeItemType_None: { MMKVError("%d", encodeItem->type); break; } } } }
根据 MiniPBCoder::prepareObjectForEncode 构建的不同类型的 PBEncodeItem,分别调用 CodedOutputData 对应的写入函数进行写入操作。其中 PBEncodeItemType_Container 类型写入的就是后面数据的长度 size。
数据写入到文件后,最终的格式如下:
// MiniPBCoder.cpp void MiniPBCoder::decodeMap(unordered_map<string, MMBuffer> &dic, const MMBuffer &oData, size_t size) { MiniPBCoder oCoder(&oData); // 使用 MMBuffer 缓存构建 MiniPBCoder oCoder.decodeOneMap(dic, size); // 调用 decodeOneMap 方法进行反序列化 } void MiniPBCoder::decodeOneMap(unordered_map<string, MMBuffer> &dic, size_t size) { if (size == 0) { // 通过 CodedInputData 读取 Varint32 的 valueSize 值 auto length = m_inputData->readInt32(); } while (!m_inputData->isAtEnd()) { // 通过 CodedInputData 读取 key 值 const auto &key = m_inputData->readString(); if (key.length() > 0) { // 通过 CodedInputData 读取 value 值 auto value = m_inputData->readData(); if (value.length() > 0) { dic[key] = move(value); } else { // 如果 value 值为0,删除 key 对应的项 dic.erase(key); } } } }
相比于序列化来说,反序列化的逻辑相对简单,通过 CodedInputData 读取 Varint32 的 valueSize 值,随后不断循环通过 CodedInputData 分别读取 key 值和 value 值。
SharedPreferences 在 Android 7.0 之后便不再对跨进程模式进行支持,原因是跨进程无法保证线程安全,而 MMKV 则通过文件锁解决了这个问题。
其实本来是可以采用在共享内存中创建 pthread_mutex 互斥锁来实现两端线程的读写同步,但由于 Android 对 Linux 的部分同步互斥机制进行了阉割,使得它无法保证当持有锁的进程意外死亡时,并不会释放其拥有的锁,此时若多进程之间存在竞争,那么阻塞的进程将不会被唤醒,导致等待锁的进程饿死。
文件锁是一种用来保证多个进程对同一个文件的安全访问的机制。文件锁可以分为两种类型:建议性锁和强制性锁:
flock 函数是一种使用文件描述符来实现文件锁的方法。该函数的功能是对一个已打开的文件描述符 fd 进行锁定或解锁操作,它的函数原型如下:
#include <sys/file.h>
int flock(int fd, int operation);
函数的参数如下:
锁类型如下:
函数的用法:
文件锁存在着一定缺点:
MMKV 采用了文件锁的设计,并对文件锁的递归锁和锁升级/降级机制进行了实现。
MMKV 中调用 FileLock.lock 或 FileLock.try_lock 方法进行文件加锁,他们两者的区别是前者是阻塞式获取锁,会等待到锁的释放,后者则是非阻塞式获取锁,其最终都会调用 FileLock.doLock 方法完成锁的获取:
// InterProcessLock.cpp bool FileLock::lock(LockType lockType) { // 阻塞式,需等待 return doLock(lockType, true); } bool FileLock::try_lock(LockType lockType) { // 非阻塞式,不需等待 return doLock(lockType, false); } bool FileLock::doLock(LockType lockType, bool wait) { if (!isFileLockValid()) { return false; // 文件锁不可用,返回 false } bool unLockFirstIfNeeded = false; // 是否需要先解锁 if (lockType == SharedLockType) { // 如果是共享锁,加读锁(共享锁) m_sharedLockCount++; // 读锁数量++ // 不希望共享锁破坏任何现有的锁,即有其他锁的情况下,不需要真正再加一次锁 if (m_sharedLockCount > 1 || m_exclusiveLockCount > 0) { return true; } } else { m_exclusiveLockCount++; // 不希望排他锁破坏现有的排他锁,即之前加过写锁,则不需要再重新加锁 if (m_exclusiveLockCount > 1) { return true; } // 避免死锁:要加写锁,如果已经存在读锁,可能是其他进程获取的,如果是则需要先将自己的读锁释放掉,再加写锁 if (m_sharedLockCount > 0) { unLockFirstIfNeeded = true; } } // 加读锁或写锁获取到的锁类型:LOCK_SH 或 LOCK_EX int realLockType = LockType2FlockType(lockType); int cmd = wait ? realLockType : (realLockType | LOCK_NB); if (unLockFirstIfNeeded) { // 如果已经存在读锁,先看看能否获取写锁,成功直接返回,否者需先解读锁,再加写锁 auto ret = flock(m_fd, realLockType | LOCK_NB); if (ret == 0) { return true; } // 解除共享锁以防止死锁 ret = flock(m_fd, LOCK_UN); if (ret != 0) { MMKVError("fail to try unlock first fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno)); } } // 执行对应的加锁(读锁或写锁) auto ret = flock(m_fd, cmd); if (ret != 0) { MMKVError("fail to lock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno)); return false; } else { return true; } }
通过分析可知,对于写锁而言,在加写锁时,如果当前进程持有了读锁,那我们需要尝试加写锁。如果加写锁失败说明其他线程持有了读锁,则需要将目前的读锁释放掉,再加写锁,从而避免死锁(这种情况说明两个进程的读锁都想升级为写锁)。
MMKV 中通过维护 m_sharedLockCount 以及 m_exclusiveLockCount 从而实现递归加锁,如果存在其他锁时,就不再需要真正第二次加锁。
MMKV 通过 FileLock.unlock 方法完成文件解锁:
// InterProcessLock.cpp bool FileLock::unlock(LockType lockType) { if (!isFileLockValid()) { return false; // 文件锁不可用,返回 false } bool unlockToSharedLock = false; // 是否解锁到共享锁(读锁) if (lockType == SharedLockType) { if (m_sharedLockCount == 0) { // 共享锁,只是还未加锁,无须解锁 return false; } m_sharedLockCount--; // 解读锁,只需减少 m_sharedLockCount 即可 // 此时,如果存在其它的锁,则不需要真正的解锁 if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) { return true; } } else { if (m_exclusiveLockCount == 0) { return false; // 如果是写锁,只是还未加锁,无须解锁 } m_exclusiveLockCount--; // 解读锁,只需减少 m_exclusiveLockCount 即可 if (m_exclusiveLockCount > 0) { return true; } // 当所有排他锁,都解锁后,恢复共享锁 // 如果之前存在写锁,则只是降级为读锁,因为之前是将读锁升级为了写锁,此时只需降回即可 if (m_sharedLockCount > 0) { unlockToSharedLock = true; } } int cmd = unlockToSharedLock ? LOCK_SH : LOCK_UN; // 执行对应的操作(加读锁或解锁) auto ret = flock(m_fd, cmd); if (ret != 0) { MMKVError("fail to unlock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno)); return false; } else { return true; } }
在解锁时,对于解写锁时,如果我们的写锁是由读锁升级而来,则不会真的进行解锁,而是改为加读锁,从而实现将写锁降级为读锁(因为读锁还没解除)。
MMKV 既然支持跨进程共享文件,那就必然面临状态同步问题,有以下几种:
MMKV 中的状态同步通过 checkLoadData 方法实现:
void MMKV::checkLoadData() { if (m_needLoadFromFile) { SCOPEDLOCK(m_sharedProcessLock); m_needLoadFromFile = false; // 需重新加载文件数据 loadFromFile(); return; } if (!m_isInterProcess) { return; } if (!m_metaFile.isFileValid()) { return; // 文件不可用直接返回 } // TODO: atomic lock m_metaFile? 原子锁 m_metaFile MMKVMetaInfo metaInfo; metaInfo.read(m_metaFile.getMemory()); if (m_metaInfo.m_sequence != metaInfo.m_sequence) { MMKVInfo("[%s] oldSeq %u, newSeq %u", m_mmapID.c_str(), m_metaInfo.m_sequence, metaInfo.m_sequence); SCOPEDLOCK(m_sharedProcessLock); // 序列号 m_sequence 不同,说明发生了内存重整,清除后重新加载 clearMemoryState(); loadFromFile(); } else if (m_metaInfo.m_crcDigest != metaInfo.m_crcDigest) { // CRC 不同,说明发生了改变 MMKVDebug("[%s] oldCrc %u, newCrc %u", m_mmapID.c_str(), m_metaInfo.m_crcDigest, metaInfo.m_crcDigest); SCOPEDLOCK(m_sharedProcessLock); size_t fileSize = 0; if (m_isAshmem) { fileSize = m_size; } else { struct stat st = {0}; if (fstat(m_fd, &st) != -1) { fileSize = (size_t) st.st_size; } } if (m_size != fileSize) { // 如果 size 不同,说明发生了文件增长 MMKVInfo("file size has changed [%s] from %zu to %zu", m_mmapID.c_str(), m_size, fileSize); clearMemoryState(); loadFromFile(); } else { // size 相同,说明需要进行写指针同步,只需要部分进行 loadFile partialLoadFromFile(); } } }
除了写指针同步的情况,其余情况都是清除后重新读取文件实现同步。
MMKV 是一个基于 mmap 实现的 K-V 存储工具,它的序列化基于 Protobuf 实现,并引入了 CRC 冗余校验从而对文件完整性进行校验,并且它支持通过 AES 算法对 Protobuf 文件进行加密。
MMKV使用时的注意事项:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。