一、前言
APP 的性能优化之路是永无止境的, 这里学习一个腾讯开源用于提升本地存储效率的轻量级存储框架 MMKV
目前项目中在轻量级存储上使用的是 SharedPreferences, 虽然 SP 兼容性极好, 但 SP 的低性能一直被诟病, 线上也出现了一些因为 SP 导致的 ANR
网上有很多针对 SP 的优化方案, 这里笔者使用的是通过 Hook SP 在 Application 中的创建, 将其替换成自定义的 SP 的方式来增强性能, 但 SDK 28 以后禁止反射 QueuedWork.getHandler 接口, 这个方式就失效了
因此需要一种替代的轻量级存储方案, MMKV 便是这样的一个框架
二、集成与测试
以下介绍简单的使用方式, 更多详情请查看 Wiki
2.1 依赖注入
在 App 模块的 build.gradle 文件里添加:
1 | dependencies { |
2.2 初始化
1 | // 设置初始化的根目录 |
2.3 获取实例
1 | // 获取默认的全局实例 |
2.4 CURD
1 | // 添加/更新数据 |
2.5 SP 的迁移
1 | private void testImportSharedPreferences() { |
2.6 数据测试
以下是 MMKV、SharedPreferences 和 SQLite 同步写入 1000 条数据的测试结果
1 | // MMKV |
可以看到 MMKV 无论是对比 SP 还是 SQLite, 在性能上都有非常大的优势, 官方提供的数据测试结果如下
更详细的性能测试见 wiki
了解 MMKV 的使用方式和测试结果, 让我对其实现原理产生了很大的好奇心, 接下来便看看它是如何将性能做到这个地步的, 这里对主要对 MMKV 的基本操作进行剖析
- 初始化
- 实例化
- encode
- decode
- 进程读写的同步
我们从初始化的流程开始分析
三、初始化
1 | public class MMKV implements SharedPreferences, SharedPreferences.Editor { |
MMKV 的初始化, 主要是将根目录通过 jniInitialize 传入了 Native 层, 接下来看看 Native 的初始化操作
1 | // native-bridge.cpp |
可以看到 initializeMMKV 中主要任务是初始化数据, 以及创建根目录
- pthread_once_t: 类似于 Java 的单例, 其 initialize 方法在进程内只会执行一次
- 创建 MMKV 对象的缓存散列表 g_instanceDic
- 创建一个线程锁 g_instanceLock
- mkPath: 根据字符串创建文件目录
接下来我们看看这个目录创建的过程
3.1 目录的创建
1 | // MmapedFile.cpp |
以上是 Native 层创建文件路径的通用代码, 逻辑很清晰
好的, 文件目录创建好了之后, Native 层的初始化操作便结束了, 接下来看看 MMKV 实例构建的过程
四、实例化
1 | public class MMKV implements SharedPreferences, SharedPreferences.Editor { |
可以看到 MMKV 实例构建的主要逻辑通过 getMMKVWithID 方法实现, 看它内部做了什么
1 | // native-bridge.cpp |
可以看到最终通过 MMKV::mmkvWithID 函数获取到 MMKV 的对象
1 | // MMKV.cpp |
mmkvWithID 函数的实现流程非常的清晰, 这里我们主要关注一下实例对象的创建流程
1 | // MMKV.cpp |
可以从 MMKV 的构造函数中看到很多有趣的信息, MMKV 是支持 Ashmem 共享内存的, 这意味着即使是跨进程大数据的传输, 它也能够提供很好的性能支持
不过这里我们主要关注两个关键点
- m_metaFile 文件的映射
- loadFromFile 数据的载入
接下来我们先看看, 文件的映射
4.1 文件映射到内存
1 | // MmapedFile.cpp |
MmapedFile 的构造函数处理的事务如下
- 打开指定的文件
- 创建这个文件锁
- 修正文件大小, 最小为 4kb
- 前 4kb 用于统计数据总大小
- 通过 mmap 将文件映射到内存
好的, 通过 MmapedFile 的构造函数, 我们便能够获取到映射后的内存首地址了, 操作这块内存时 Linux 内核会负责将内存中的数据同步到文件中
比起 SP 的数据同步, mmap 显然是要优雅的多, 即使进程意外死亡, 也能够通过 Linux 内核的保护机制, 将进行了文件映射的内存数据刷入到文件中, 提升了数据写入的可靠性
结下来看看数据的载入
4.2 数据的载入
1 | // MMKV.cpp |
好的, 可以看到 loadFromFile 中对于 CRC 验证通过的文件, 会将文件中的数据读入到 m_dic 中缓存, 否则则会清空文件
因此用户恶意修改文件之后, 会破坏 CRC 的值, 这个存储数据便会被作废, 这一点要尤为注意
从文件中读取数据到 m_dic 之后, 会将 mdic 回写到文件中
, 其重写的目的是为了剔除重复的数据
- 关于为什么会出现重复的数据, 在后面 encode 操作中再分析
4.3 回顾
到这里 MMKV 实例的构建就完成了, 有了 m_dic 这个内存缓存, 我们进行数据查询的效率就大大提升了
从最终的结果来看它与 SP 是一致的, 都是初次加载时会将文件中所有的数据加载到散列表中, 不过 MMKV 多了一步数据回写的操作, 因此当数据量比较大时, 对实例构建的速度有一定的影响
1 | // 写入 1000 条数据之后, MMVK 和 SharedPreferences 实例化的时间对比 |
从结果上来看, MMVK 的确在实例构造速度上有一定的劣势, 不过得益于是将 m_dic 中的数据写入到 mmap 的内存, 其真正进行文件写入的时机由 Linux 内核决定, 再加上文件的页缓存机制, 所以速度上虽有劣势, 但不至于无法接受
五、encode
关于 encode 即数据的添加与更新的流程, 这里以 encodeString 为例
1 | public class MMKV implements SharedPreferences, SharedPreferences.Editor { |
看看 native 层的实现
1 | // native-bridge.cpp |
这里我们主要分析一下 setStringForKey 这个函数
1 | // MMKV.cpp |
这里主要分为两步操作
- 数据编码
- 更新键值对
5.1 数据的编码
MMKV 采用的是 ProtocolBuffer 编码方式, 这里就不做过多介绍了, 具体请查看 Google 官方文档
1 | // MiniPBCoder.cpp |
可以看到, 再未进行编码操作之前, 编码后的数据大小就已经确定好了, 并且将它保存在了 encodeItem->compiledSize 中, 接下来我们看看执行数据编码并输出到缓冲区的操作流程
1 | // MiniPBCoder.cpp |
可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 进行了字符串的编码操作
其中 m_ptr 是上面开辟的内存缓冲区的地址, 也就是说 writeString 执行结束之后, 数据就已经被写入缓冲区了
有了编码好的数据缓冲区, 接下来看看更新键值对的操作
5.2键值对的更新
1 | // MMKV.cpp |
好的, 可以看到更新键值对的操作还是比较复杂的, 首先将键值对数据写入到文件映射的内存中, 写入成功之后更新散列数据
关于写入到文件映射的过程, 上面代码中的注释也非常的清晰, 接下来我们 ensureMemorySize 是如何进行数据的重整与扩容的
5.2.1 数据的重整与扩容
1 | // MMKV.cpp |
从上面的代码我们可以了解到
- 数据的重写时机
- 文件剩余空间少于新的键值对大小
- 散列为空
- 文件扩容时机
- 所需空间的 1.5 倍超过了当前文件的总大小时, 扩容为之前的两倍
5.3回顾
至此 encode 的流程我们就走完了, 回顾一下整个 encode 的流程
使用 ProtocolBuffer 编码 value
将
key
和
编码后的 value
使用 ProtocolBuffer 的格式 append 到文件映射区内存的尾部
- 文件空间不足
- 判断是否需要扩容
- 进行数据的回写
- 即在文件后进行追加
- 文件空间不足
对这个键值对区域进行统一的加密
更新 CRC 的值
将 key 和 value 对应的 ProtocolBuffer 编码内存区域, 更新到散列表 m_dic 中
通过 encode 的分析, 我们得知 MMKV 文件的存储方式如下
接下来看看 decode 的流程
六、decode
decode 的过程同样以 decodeString 为例
1 | // native-bridge.cpp |
好的可以看到 decode 的流程比较简单, 先从内存缓存中获取 key 对应的 value 的 ProtocolBuffer 内存区域, 再解析这块内存区域, 从中获取真正的 value 值
6.1 思考
看到这里可能会有一个疑问, 为什么 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 加解锁的操作
7.1 文件共享锁
1 | MMKV::MMKV( |
可以看到在我们前面分析过的构造函数中, MMKV 对文件锁进行了初始化, 并且创建了共享锁和排它锁, 并在跨进程操作时开启, 当进行读操作时, 启动了共享锁
7.2 文件排它锁
1 | bool MMKV::fullWriteback() { |
在进行数据回写的函数中, 启动了排它锁
7.3 读写效率表现
其进程同步读写的性能表现如下
可以看到进程同步读写的效率也是非常 nice 的
关于跨进程同步就介绍到这里, 当然 MMKV 的文件锁并没有表面上那么简单, 因为文件锁为状态锁, 无论加了多少次锁, 一个解锁操作就全解除, 显然无法应对子函数嵌套调用的问题, MMKV 内部通过了自行实现计数器来实现锁的可重入性, 更多的细节可以查看 wiki
总结
通过上面的分析, 我们对 MMKV 有了一个整体上的把控, 其具体的表现如下所示
项目 | 评价 | 描述 |
---|---|---|
正确性 | 优 | 支持多进程安全, 使用 mmap, 由操作系统保证数据回写的正确性 |
时间开销 | 优 | 使用 mmap 实现, 减少了用户空间数据到内核空间的拷贝 |
空间开销 | 中 | 使用 protocl buffer 存储数据, 同样的数据会比 xml 和 json 消耗空间小 使用的是数据追加到末尾的方式, 只有到达一定阈值之后才会触发键值合并, 不合并之前会导致同一个 key 存在多份 |
安全 | 中 | 使用 crc 校验, 甄别文件系统和操作系统不稳定导致的异常数据 |
开发成本 | 优 | 使用方式较为简单 |
兼容性 | 优 | 各个安卓版本都前后兼容 |
虽然 MMKV 一些场景下比 SP 稍慢(如: 首次实例化会进行数据的复写剔除重复数据, 比 SP 稍慢, 查询数据时存在 ProtocolBuffer 解码, 比 SP 稍慢), 但其逆天的数据写入速度、mmap Linux 内核保证数据的同步, 以及 ProtocolBuffer 编码带来的更小的本地存储空间占用等都是非常棒的闪光点
在分析 MMKV 的代码的过程中, 从中学习到了很多知识, 非常感谢 Tencent 为开源社区做出的贡献