APP 的性能優化之路是永無止境的, 這裏學習一個騰訊開源用於提高本地存儲效率的輕量級存儲框架 MMKVhtml
目前項目中在輕量級存儲上使用的是 SharedPreferences, 雖然 SP 兼容性極好, 但 SP 的低性能一直被詬病, 線上也出現了一些由於 SP 致使的 ANRandroid
網上有不少針對 SP 的優化方案, 這裏筆者使用的是經過 Hook SP 在 Application 中的建立, 將其替換成自定義的 SP 的方式來加強性能, 但 SDK 28 之後禁止反射 QueuedWork.getHandler 接口, 這個方式就失效了git
所以須要一種替代的輕量級存儲方案, MMKV 即是這樣的一個框架github
如下介紹簡單的使用方式, 更多詳情請查看 Wikisql
在 App 模塊的 build.gradle 文件裏添加:json
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);
複製代碼
// 添加/更新數據
kv.encode(key, value);
// 獲取數據
int tmp = kv.decodeInt(key);
// 刪除數據
kv.removeValueForKey(key);
複製代碼
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 的基本操做進行剖析bash
咱們從初始化的流程開始分析
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 中主要任務是初始化數據, 以及建立根目錄
接下來咱們看看這個目錄建立的過程
// 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. 將文件映射到內存
, 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 共享內存的, 這意味着即便是跨進程大數據的傳輸, 它也可以提供很好的性能支持
不過這裏咱們主要關注兩個關鍵點
接下來咱們先看看, 文件的映射
// 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 的構造函數處理的事務以下
好的, 經過 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 中緩存, 不然則會清空文件
到這裏 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 即數據的添加與更新的流程, 這裏以 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;
}
複製代碼
從上面的代碼咱們能夠了解到
至此 encode 的流程咱們就走完了, 回顧一下整個 encode 的流程
經過 encode 的分析, 咱們得知 MMKV 文件的存儲方式以下
接下來看看 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 原始數據呢, 這樣查詢效率不是更快嗎?
從圖上的結果能夠看出, MMKV 的讀取性能時略低於 SharedPreferences 的, 這裏筆者給出本身的思考
既然 m_dic 還承擔着方便數據複寫的功能, 那可否再添加一個內存緩存專門用於存儲原始的 value 呢?
提及進程間讀寫同步, 咱們很天然的想到 Linux 的共享內存配合信號量使用的案例, 可是這種方式有一個弊端, 那就是當持有鎖的進程意外死亡的時候, 並不會釋放其擁有的信號量, 若多進程之間存在競爭, 那麼阻塞的進程將不會被喚醒, 這是很是危險的
MMKV 是採用 文件鎖 的方式來進行進程間的同步操做
接下來我看看 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 編碼帶來的更小的本地存儲空間佔用等都是很是棒的閃光點
在分析 MMKV 的代碼的過程當中, 從中學習到了不少知識, 很是感謝 Tencent 爲開源社區作出的貢獻