Android 儲存優化 —— MMKV 整合與原理

SharryChoo發表於2019-08-15

前言

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 編碼帶來的更小的本地儲存空間佔用等都是非常棒的閃光點

在分析 MMKV 的程式碼的過程中, 從中學習到了很多知識, 非常感謝 Tencent 為開源社群做出的貢獻

參考文獻

相關文章