Android 性能優化 -- MMKV使用與原理

Android 性能優化 – MMKV使用與原理

前言

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, 在性能上都有非常大的優勢, 官方提供的數據測試結果如下

img

單進程讀寫性能對比

更詳細的性能測試見 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. 將文件映射到內存
    , 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 共享內存的, 這意味着即使是跨進程大數據的傳輸, 它也能夠提供很好的性能支持

不過這裏我們主要關注兩個關鍵點

  • 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 文件的存儲方式如下

img

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 解碼的過程

img

讀取性能對比

從圖上的結果可以看出, 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;
}

在進行數據回寫的函數中, 啓動了排它鎖

三) 讀寫效率表現

其進程同步讀寫的性能表現如下

img

進程同步讀寫表現

可以看到進程同步讀寫的效率也是非常 nice 的

關於跨進程同步就介紹到這裏, 當然 MMKV 的文件鎖並沒有表面上那麼簡單, 因爲文件鎖爲狀態鎖, 無論加了多少次鎖, 一個解鎖操作就全解除, 顯然無法應對子函數嵌套調用的問題, MMKV 內部通過了自行實現計數器來實現鎖的可重入性, 更多的細節可以查看 wiki

總結

通過上面的分析, 我們對 MMKV 有了一個整體上的把控, 其具體的表現如下所示

項目 評價 描述
正確性 支持多進程安全, 使用 mmap, 由操作系統保證數據回寫的正確性
時間開銷 使用 mmap 實現, 減少了用戶空間數據到內核空間的拷貝
空間開銷 使用 protocl buffer 存儲數據, 同樣的數據會比 xml 和 json 消耗空間小 使用的是數據追加到末尾的方式, 只有到達一定閾值之後纔會觸發鍵值合併, 不合並之前會導致同一個 key 存在多份
安全 使用 crc 校驗, 甄別文件系統和操作系統不穩定導致的異常數據
開發成本 使用方式較爲簡單
兼容性 各個安卓版本都前後兼容

雖然 MMKV 一些場景下比 SP 稍慢(如: 首次實例化會進行數據的複寫剔除重複數據, 比 SP 稍慢, 查詢數據時存在 ProtocolBuffer 解碼, 比 SP 稍慢), 但其逆天的數據寫入速度、mmap Linux 內核保證數據的同步, 以及 ProtocolBuffer 編碼帶來的更小的本地存儲空間佔用等都是非常棒的閃光點

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

參考文獻

轉載自SharryChoo博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章