Android 存儲優(yōu)化 —— MMKV 集成與原理

前言

APP 的性能優(yōu)化之路是永無止境的, 這里學(xué)習(xí)一個騰訊開源用于提升本地存儲效率的輕量級存儲框架 MMKV

目前項目中在輕量級存儲上使用的是 SharedPreferences, 雖然 SP 兼容性極好, 但 SP 的低性能一直被詬病, 線上也出現(xiàn)了一些因為 SP 導(dǎo)致的 ANR

網(wǎng)上有很多針對 SP 的優(yōu)化方案, 這里筆者使用的是通過 Hook SP 在 Application 中的創(chuàng)建, 將其替換成自定義的 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
}

初始化

// 設(shè)置初始化的根目錄
String dir = getFilesDir().getAbsolutePath() + "/mmkv_2";
String rootDir = MMKV.initialize(dir);
Log.i("MMKV", "mmkv root: " + rootDir);

獲取實例

// 獲取默認(rèn)的全局實例
MMKV kv = MMKV.defaultMMKV();

// 根據(jù)業(yè)務(wù)區(qū)別存儲, 附帶一個自己的 ID
MMKV kv = MMKV.mmkvWithID("MyID");

// 多進程同步支持
MMKV kv = MMKV.mmkvWithID("MyID", MMKV.MULTI_PROCESS_MODE);

CURD

// 添加/更新數(shù)據(jù)
kv.encode(key, value);

// 獲取數(shù)據(jù)
int tmp = kv.decodeInt(key);

// 刪除數(shù)據(jù)
kv.removeValueForKey(key);

SP 的遷移

private void testImportSharedPreferences() {
    MMKV mmkv = MMKV.mmkvWithID("myData");
    SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
    // 遷移舊數(shù)據(jù)
    mmkv.importFromSharedPreferences(old_man);
    // 清空舊數(shù)據(jù)
    old_man.edit().clear().commit();
    ......
}

數(shù)據(jù)測試

以下是 MMKV仔涩、SharedPreferences 和 SQLite 同步寫入 1000 條數(shù)據(jù)的測試結(jié)果

// 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, 在性能上都有非常大的優(yōu)勢, 官方提供的數(shù)據(jù)測試結(jié)果如下

單進程讀寫性能對比

更詳細(xì)的性能測試見 wiki

了解 MMKV 的使用方式和測試結(jié)果, 讓我對其實現(xiàn)原理產(chǎn)生了很大的好奇心, 接下來便看看它是如何將性能做到這個地步的, 這里對主要對 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) {
        ...... // 省略庫文件加載器相關(guān)代碼
        // 保存根目錄
        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 函數(shù)在一個進程內(nèi)只會執(zhí)行一次
    // 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. 根據(jù)路徑, 生成目標(biāo)地址的目錄
        mkPath(path);
        // 釋放內(nèi)存
        free(path);
    }
}

可以看到 initializeMMKV 中主要任務(wù)是初始化數(shù)據(jù), 以及創(chuàng)建根目錄

  • pthread_once_t: 類似于 Java 的單例, 其 initialize 方法在進程內(nèi)只會執(zhí)行一次
    • 創(chuàng)建 MMKV 對象的緩存散列表 g_instanceDic
    • 創(chuàng)建一個線程鎖 g_instanceLock
  • mkPath: 根據(jù)字符串創(chuàng)建文件目錄

接下來我們看看這個目錄創(chuàng)建的過程

目錄的創(chuàng)建

// MmapedFile.cpp
bool mkPath(char *path) {
    // 定義 stat 結(jié)構(gòu)體用于描述文件的屬性
    struct stat sb = {};
    bool done = false;
    // 指向字符串起始地址
    char *slash = path;
    while (!done) {
        // 移動到第一個非 "/" 的下標(biāo)處
        slash += strspn(slash, "/");
        // 移動到第一個 "/" 下標(biāo)出處
        slash += strcspn(slash, "/");

        done = (*slash == '\0');
        *slash = '\0';

        if (stat(path, &sb) != 0) {
            // 執(zhí)行創(chuàng)建文件夾的操作, C 中無 mkdirs 的操作, 需要一個一個文件夾的創(chuàng)建
            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 層創(chuàng)建文件路徑的通用代碼, 邏輯很清晰

好的, 文件目錄創(chuàng)建好了之后, Native 層的初始化操作便結(jié)束了, 接下來看看 MMKV 實例構(gòu)建的過程

三. 實例化

public class MMKV implements SharedPreferences, SharedPreferences.Editor {

    @Nullable
    public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey, String relativePath) {
        ......
        // 執(zhí)行 Native 初始化, 獲取句柄值
        long handle = getMMKVWithID(mmapID, mode, cryptKey, relativePath);
        if (handle == 0) {
            return null;
        }
        // 構(gòu)建一個 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 實例構(gòu)建的主要邏輯通過 getMMKVWithID 方法實現(xiàn), 看它內(nèi)部做了什么

// 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 函數(shù)獲取一個 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;
        }
    }
    ......
    // 強轉(zhuǎn)成句柄, 返回到 Java
    return (jlong) kv;
}

}

可以看到最終通過 MMKV::mmkvWithID 函數(shù)獲取到 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. 創(chuàng)建緩存文件
    if (relativePath) {
        // 根據(jù) mappedKVPathWithID 獲取 mmap 的最終文件路徑
        // mmapID 使用 md5 加密
        auto filePath = mappedKVPathWithID(mmapID, mode, relativePath);
        // 不存在則創(chuàng)建一個文件
        if (!isFileExist(filePath)) {
            if (!createFile(filePath)) {
                return nullptr;
            }
        }
        ......
    }
    // 4. 創(chuàng)建實例對象
    auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
    // 5. 緩存這個 mmapKey
    (*g_instanceDic)[mmapKey] = kv;
    return kv;
}

mmkvWithID 函數(shù)的實現(xiàn)流程非常的清晰, 這里我們主要關(guān)注一下實例對象的創(chuàng)建流程

// 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. 將文件映射到內(nèi)存
    , 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 跨進程匿名共享內(nèi)存
    if (m_isAshmem) {
        // 創(chuàng)共享內(nèi)存的文件
        m_ashmemFile = new MmapedFile(m_mmapID, static_cast<size_t>(size), MMAP_ASHMEM);
        m_fd = m_ashmemFile->getFd();
    } else {
        m_ashmemFile = nullptr;
    }
    // 根據(jù) cryptKey 創(chuàng)建 AES 加解密的引擎
    if (cryptKey && cryptKey->length() > 0) {
        m_crypter = new AESCrypt((const unsigned char *) cryptKey->data(), cryptKey->length());
    }
    ......
    // sensitive zone
    {
        SCOPEDLOCK(m_sharedProcessLock);
        // 2. 根據(jù) m_mmapID 來加載文件中的數(shù)據(jù)
        loadFromFile();
    }
}

可以從 MMKV 的構(gòu)造函數(shù)中看到很多有趣的信息, MMKV 是支持 Ashmem 共享內(nèi)存的, 這意味著即使是跨進程大數(shù)據(jù)的傳輸, 它也能夠提供很好的性能支持

不過這里我們主要關(guān)注兩個關(guān)鍵點

  • m_metaFile 文件的映射
  • loadFromFile 數(shù)據(jù)的載入

接下來我們先看看, 文件的映射

一) 文件映射到內(nèi)存

// 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) {
    // 用于內(nèi)存映射的文件
    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. 創(chuàng)建文件鎖
            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. 驗證文件的大小是否小于一個內(nèi)存頁, 一般為 4kb
            if (m_segmentSize < DEFAULT_MMAP_SIZE) {
                m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);
                // 3.1 通過 ftruncate 將文件大小對其到內(nèi)存頁
                // 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 將文件映射到內(nèi)存, 獲取內(nèi)存首地址
            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;
            }
        }
    }
    // 用于共享內(nèi)存的文件
    else {
        ......
    }
}

MmapedFile 的構(gòu)造函數(shù)處理的事務(wù)如下

  • 打開指定的文件
  • 創(chuàng)建這個文件鎖
  • 修正文件大小, 最小為 4kb
    • 前 4kb 用于統(tǒng)計數(shù)據(jù)總大小
  • 通過 mmap 將文件映射到內(nèi)存

好的, 通過 MmapedFile 的構(gòu)造函數(shù), 我們便能夠獲取到映射后的內(nèi)存首地址了, 操作這塊內(nèi)存時 Linux 內(nèi)核會負(fù)責(zé)將內(nèi)存中的數(shù)據(jù)同步到文件中

比起 SP 的數(shù)據(jù)同步, mmap 顯然是要優(yōu)雅的多, 即使進程意外死亡, 也能夠通過 Linux 內(nèi)核的保護機制, 將進行了文件映射的內(nèi)存數(shù)據(jù)刷入到文件中, 提升了數(shù)據(jù)寫入的可靠性

結(jié)下來看看數(shù)據(jù)的載入

二) 數(shù)據(jù)的載入

// MMKV.cpp
void MMKV::loadFromFile() {
    
    ......// 忽略匿名共享內(nèi)存相關(guān)代碼
    
    // 若已經(jīng)進行了文件映射
    if (m_metaFile.isFileValid()) {
        // 則獲取相關(guān)數(shù)據(jù)
        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 將文件大小對其到內(nèi)存頁的整數(shù)倍
        if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
            ......
        }
        // 2. 獲取文件映射后的內(nèi)存地址
        m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
        if (m_ptr == MAP_FAILED) {
            ......
        } else {
            // 3. 讀取內(nèi)存文件的前 32 位, 獲取存儲數(shù)據(jù)的真實大小
            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 {
                        // 若不正確, 則回調(diào)異常 CRC 異常
                        auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);
                        if (strategic == OnErrorRecover) {
                            loadFromFile = true;
                            needFullWriteback = true;
                        }
                    }
                } else {
                    // 回調(diào)文件長度異常
                    auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);
                    if (strategic == OnErrorRecover) {
                        writeAcutalSize(m_size - Fixed32Size);
                        loadFromFile = true;
                        needFullWriteback = true;
                    }
                }
            }
            // 6. 需要從文件獲取數(shù)據(jù)
            if (loadFromFile) {
                ......
                // 構(gòu)建輸入緩存
                MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
                if (m_crypter) {
                    // 解密輸入緩沖中的數(shù)據(jù)
                    decryptBuffer(*m_crypter, inputBuffer);
                }
                // 從輸入緩沖中將數(shù)據(jù)讀入 m_dic
                m_dic.clear();
                MiniPBCoder::decodeMap(m_dic, inputBuffer);
                // 構(gòu)建輸出數(shù)據(jù)
                m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
                                               m_size - Fixed32Size - m_actualSize);
                // 進行重整回寫, 剔除重復(fù)的數(shù)據(jù)
                if (needFullWriteback) {
                    fullWriteback();
                }
            } 
            // 7. 說明文件中沒有數(shù)據(jù), 或者校驗失敗了
            else {
                SCOPEDLOCK(m_exclusiveProcessLock);
                // 清空文件中的數(shù)據(jù)
                if (m_actualSize > 0) {
                    writeAcutalSize(0);
                }
                m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
                // 重新計算 CRC
                recaculateCRCDigest();
            }
            ......
        }
    }
    
    ......

    m_needLoadFromFile = false;
}

好的, 可以看到 loadFromFile 中對于 CRC 驗證通過的文件, 會將文件中的數(shù)據(jù)讀入到 m_dic 中緩存, 否則則會清空文件

  • 因此用戶惡意修改文件之后, 會破壞 CRC 的值, 這個存儲數(shù)據(jù)便會被作廢, 這一點要尤為注意
  • 從文件中讀取數(shù)據(jù)到 m_dic 之后, 會將 mdic 回寫到文件中, 其重寫的目的是為了剔除重復(fù)的數(shù)據(jù)
    • 關(guān)于為什么會出現(xiàn)重復(fù)的數(shù)據(jù), 在后面 encode 操作中再分析

三) 回顧

到這里 MMKV 實例的構(gòu)建就完成了, 有了 m_dic 這個內(nèi)存緩存, 我們進行數(shù)據(jù)查詢的效率就大大提升了

從最終的結(jié)果來看它與 SP 是一致的, 都是初次加載時會將文件中所有的數(shù)據(jù)加載到散列表中, 不過 MMKV 多了一步數(shù)據(jù)回寫的操作, 因此當(dāng)數(shù)據(jù)量比較大時, 對實例構(gòu)建的速度有一定的影響

// 寫入 1000 條數(shù)據(jù)之后, MMVK 和 SharedPreferences 實例化的時間對比
E/TAG: create MMKV instance time is 4 ms
E/TAG: create SharedPreferences instance time is 1 ms

從結(jié)果上來看, MMVK 的確在實例構(gòu)造速度上有一定的劣勢, 不過得益于是將 m_dic 中的數(shù)據(jù)寫入到 mmap 的內(nèi)存, 其真正進行文件寫入的時機由 Linux 內(nèi)核決定, 再加上文件的頁緩存機制, 所以速度上雖有劣勢, 但不至于無法接受

四. encode

關(guān)于 encode 即數(shù)據(jù)的添加與更新的流程, 這里以 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 層的實現(xiàn)

// 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 函數(shù), 將數(shù)據(jù)存入
            string value = jstring2string(env, oValue);
            return (jboolean) kv->setStringForKey(value, key);
        } 
        // 若是 value 為 NULL, 則移除 key 對應(yīng)的 value 值
        else {
            kv->removeValueForKey(key);
            return (jboolean) true;
        }
    }
    return (jboolean) false;
}

}

這里我們主要分析一下 setStringForKey 這個函數(shù)

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
    if (key.empty()) {
        return false;
    }
    // 1. 將數(shù)據(jù)編碼成 ProtocolBuffer
    auto data = MiniPBCoder::encodeDataWithObject(value);
    // 2. 更新鍵值對
    return setDataForKey(std::move(data), key);
}

這里主要分為兩步操作

  • 數(shù)據(jù)編碼
  • 更新鍵值對

一) 數(shù)據(jù)的編碼

MMKV 采用的是 ProtocolBuffer 編碼方式, 這里就不做過多介紹了, 具體請查看 Google 官方文檔

// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const string &str) {
    // 1. 創(chuàng)建編碼條目的集合
    m_encodeItems = new vector<PBEncodeItem>();
    // 2. 為集合填充數(shù)據(jù)
    size_t index = prepareObjectForEncode(str);
    PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
    if (oItem && oItem->compiledSize > 0) {
        // 3. 開辟一個內(nèi)存緩沖區(qū), 用于存放編碼后的數(shù)據(jù)
        m_outputBuffer = new MMBuffer(oItem->compiledSize);
        // 4. 創(chuàng)建一個編碼操作對象
        m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
        // 執(zhí)行 protocolbuffer 編碼, 并輸出到緩沖區(qū)
        writeRootObject();
    }
    // 調(diào)用移動構(gòu)造函數(shù), 重新創(chuàng)建實例返回
    return move(*m_outputBuffer);
}

size_t MiniPBCoder::prepareObjectForEncode(const string &str) {
    // 2.1 創(chuàng)建 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 填充要編碼的數(shù)據(jù)
        encodeItem->value.strValue = &str;
        // 2.6 填充數(shù)據(jù)大小
        encodeItem->valueSize = static_cast<int32_t>(str.size());
    }
    // 2.7 計算編碼后的大小
    encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
    return index;
}

可以看到, 再未進行編碼操作之前, 編碼后的數(shù)據(jù)大小就已經(jīng)確定好了, 并且將它保存在了 encodeItem->compiledSize 中, 接下來我們看看執(zhí)行數(shù)據(jù)編碼并輸出到緩沖區(qū)的操作流程

// 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) {
            // 主要關(guān)心編碼 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. 將字符串的數(shù)據(jù)拷貝到編碼好的長度后面
    memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
    // 更新 position 的值
    m_position += numberOfBytes;
}

可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 進行了字符串的編碼操作

其中 m_ptr 是上面開辟的內(nèi)存緩沖區(qū)的地址, 也就是說 writeString 執(zhí)行結(jié)束之后, 數(shù)據(jù)就已經(jīng)被寫入緩沖區(qū)了

有了編碼好的數(shù)據(jù)緩沖區(qū), 接下來看看更新鍵值對的操作

二) 鍵值對的更新

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
    // 編碼數(shù)據(jù)獲取存放數(shù)據(jù)的緩沖區(qū)
    auto data = MiniPBCoder::encodeDataWithObject(value);
    // 更新鍵值對
    return setDataForKey(std::move(data), key);
}

bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {
    ......
    // 將鍵值對寫入 mmap 文件映射的內(nèi)存中
    auto ret = appendDataWithKey(data, key);
    // 寫入成功, 更新散列數(shù)據(jù)
    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. 驗證是否有足夠的空間, 不足則進行數(shù)據(jù)重整與擴容操作
    bool hasEnoughSize = ensureMemorySize(size);
    if (!hasEnoughSize || !isFileValid()) {
        return false;
    }
    
    // 3. 更新文件頭的數(shù)據(jù)總大小
    writeAcutalSize(m_actualSize + size);
    
    // 4. 將 key 和編碼后的 value 寫入到文件映射的內(nèi)存
    m_output->writeString(key);
    m_output->writeData(data);
    
    // 5. 獲取文件映射內(nèi)存當(dāng)前 <key, value> 的起始位置
    auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
    if (m_crypter) {
        // 加密這塊區(qū)域
        m_crypter->encrypt(ptr, ptr, size);
    }
    
    // 6. 更新 CRC
    updateCRCDigest(ptr, size, KeepSequence);
    return true;
}

好的, 可以看到更新鍵值對的操作還是比較復(fù)雜的, 首先將鍵值對數(shù)據(jù)寫入到文件映射的內(nèi)存中, 寫入成功之后更新散列數(shù)據(jù)

關(guān)于寫入到文件映射的過程, 上面代碼中的注釋也非常的清晰, 接下來我們 ensureMemorySize 是如何進行數(shù)據(jù)的重整與擴容的

數(shù)據(jù)的重整與擴容

// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {
    ......
    // 計算新鍵值對的大小
    constexpr size_t ItemSizeHolderSize = 4;
    if (m_dic.empty()) {
        newSize += ItemSizeHolderSize;
    }
    // 數(shù)據(jù)重寫: 
    // 1. 文件剩余空閑空間少于新的鍵值對
    // 2. 散列為空
    if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
        // 計算所需的數(shù)據(jù)空間
        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. 所需空間 >= 當(dāng)前文件總大小
            // 2. 所需空間的 1.5 倍 >= 當(dāng)前文件總大小
            if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
                // 擴容為 2 倍
                size_t oldSize = m_size;
                do {
                    m_size *= 2;
                } while (lenNeeded + futureUsage >= m_size);
                .......
            }
        }
        ......
        // 進行數(shù)據(jù)的重寫
        writeAcutalSize(data.length());
        ......
    }
    return true;
}

從上面的代碼我們可以了解到

  • 數(shù)據(jù)的重寫時機
    • 文件剩余空間少于新的鍵值對大小
    • 散列為空
  • 文件擴容時機
    • 所需空間的 1.5 倍超過了當(dāng)前文件的總大小時, 擴容為之前的兩倍

三) 回顧

至此 encode 的流程我們就走完了, 回顧一下整個 encode 的流程

  • 使用 ProtocolBuffer 編碼 value
  • key編碼后的 value 使用 ProtocolBuffer 的格式 append 到文件映射區(qū)內(nèi)存的尾部
    • 文件空間不足
      • 判斷是否需要擴容
      • 進行數(shù)據(jù)的回寫
    • 即在文件后進行追加
  • 對這個鍵值對區(qū)域進行統(tǒng)一的加密
  • 更新 CRC 的值
  • 將 key 和 value 對應(yīng)的 ProtocolBuffer 編碼內(nèi)存區(qū)域, 更新到散列表 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, 將數(shù)據(jù)輸出到傳出參數(shù)中 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. 從內(nèi)存緩存中獲取數(shù)據(jù)
    auto &data = getDataForKey(key);
    if (data.length() > 0) {
        // 2. 解析 data 對應(yīng)的 ProtocolBuffer 數(shù)據(jù)
        result = MiniPBCoder::decodeString(data);
        return true;
    }
    return false;
}

const MMBuffer &MMKV::getDataForKey(const std::string &key) {
    // 從散列表中獲取 key 對應(yīng)的 value
    auto itr = m_dic.find(key);
    if (itr != m_dic.end()) {
        return itr->second;
    }
    static MMBuffer nan(0);
    return nan;
}

好的可以看到 decode 的流程比較簡單, 先從內(nèi)存緩存中獲取 key 對應(yīng)的 value 的 ProtocolBuffer 內(nèi)存區(qū)域, 再解析這塊內(nèi)存區(qū)域, 從中獲取真正的 value 值

思考

看到這里可能會有一個疑問, 為什么 m_dic 不直接存儲 key 和 value 原始數(shù)據(jù)呢, 這樣查詢效率不是更快嗎?

  • 如此一來查詢效率的確會更快, 因為少了 ProtocolBuffer 解碼的過程
讀取性能對比

從圖上的結(jié)果可以看出, MMKV 的讀取性能時略低于 SharedPreferences 的, 這里筆者給出自己的思考

  • m_dic 在數(shù)據(jù)重整中也起到了非常重要的作用, 需要依靠 m_dic 將數(shù)據(jù)寫入到 mmap 的文件映射區(qū), 這個過程是非常耗時的, 若是原始的 value, 則需要對所有的 value 再進行一次 ProtocolBuffer 編碼操作, 尤其是當(dāng)數(shù)據(jù)量比較龐大時, 其帶來的性能損耗更是無法忽略的

既然 m_dic 還承擔(dān)著方便數(shù)據(jù)復(fù)寫的功能, 那能否再添加一個內(nèi)存緩存專門用于存儲原始的 value 呢?

  • 當(dāng)然可以, 這樣 MMKV 的讀取定是能夠達(dá)到 SharedPreferences 的水平, 不過 value 的內(nèi)存消耗則會加倍, MMKV 作為一個輕量級緩存的框架, 查詢時時間的提升幅度還不足以用內(nèi)存加倍的代價去換取, 我想這是 Tencent 在進行多方面權(quán)衡之后, 得到的一個比較合理的解決方案

六. 進程讀寫的同步

說起進程間讀寫同步, 我們很自然的想到 Linux 的共享內(nèi)存配合信號量使用的案例, 但是這種方式有一個弊端, 那就是當(dāng)持有鎖的進程意外死亡的時候, 并不會釋放其擁有的信號量, 若多進程之間存在競爭, 那么阻塞的進程將不會被喚醒, 這是非常危險的

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))
    // 創(chuàng)建文件鎖的描述
    , 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) {
    ......
    // 根據(jù)是否跨進程操作判斷共享鎖和排它鎖的開關(guān)
    m_sharedProcessLock.m_enable = m_isInterProcess;
    m_exclusiveProcessLock.m_enable = m_isInterProcess;

    // sensitive zone
    {
        // 文件讀操作, 啟用了文件共享鎖
        SCOPEDLOCK(m_sharedProcessLock);
        loadFromFile();
    }
}

可以看到在我們前面分析過的構(gòu)造函數(shù)中, MMKV 對文件鎖進行了初始化, 并且創(chuàng)建了共享鎖和排它鎖, 并在跨進程操作時開啟, 當(dāng)進行讀操作時, 啟動了共享鎖

二) 文件排它鎖

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;
}

在進行數(shù)據(jù)回寫的函數(shù)中, 啟動了排它鎖

三) 讀寫效率表現(xiàn)

其進程同步讀寫的性能表現(xiàn)如下

進程同步讀寫表現(xiàn)

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

關(guān)于跨進程同步就介紹到這里, 當(dāng)然 MMKV 的文件鎖并沒有表面上那么簡單, 因為文件鎖為狀態(tài)鎖, 無論加了多少次鎖, 一個解鎖操作就全解除, 顯然無法應(yīng)對子函數(shù)嵌套調(diào)用的問題, MMKV 內(nèi)部通過了自行實現(xiàn)計數(shù)器來實現(xiàn)鎖的可重入性, 更多的細(xì)節(jié)可以查看 wiki

總結(jié)

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

項目 評價 描述
正確性 優(yōu) 支持多進程安全, 使用 mmap, 由操作系統(tǒng)保證數(shù)據(jù)回寫的正確性
時間開銷 優(yōu) 使用 mmap 實現(xiàn), 減少了用戶空間數(shù)據(jù)到內(nèi)核空間的拷貝
空間開銷 使用 protocl buffer 存儲數(shù)據(jù), 同樣的數(shù)據(jù)會比 xml 和 json 消耗空間小
使用的是數(shù)據(jù)追加到末尾的方式, 只有到達(dá)一定閾值之后才會觸發(fā)鍵值合并, 不合并之前會導(dǎo)致同一個 key 存在多份
安全 使用 crc 校驗, 甄別文件系統(tǒng)和操作系統(tǒng)不穩(wěn)定導(dǎo)致的異常數(shù)據(jù)
開發(fā)成本 優(yōu) 使用方式較為簡單
兼容性 優(yōu) 各個安卓版本都前后兼容

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

在分析 MMKV 的代碼的過程中, 從中學(xué)習(xí)到了很多知識, 非常感謝 Tencent 為開源社區(qū)做出的貢獻

參考文獻

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市沮峡,隨后出現(xiàn)的幾起案子乖杠,更是在濱河造成了極大的恐慌狭魂,老刑警劉巖阅茶,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件被廓,死亡現(xiàn)場離奇詭異坏晦,居然都是意外死亡,警方通過查閱死者的電腦和手機嫁乘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進店門昆婿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人亦渗,你說我怎么就攤上這事挖诸。” “怎么了法精?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長痴突。 經(jīng)常有香客問我搂蜓,道長,這世上最難降的妖魔是什么辽装? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任帮碰,我火速辦了婚禮,結(jié)果婚禮上拾积,老公的妹妹穿的比我還像新娘殉挽。我一直安慰自己,他們只是感情好拓巧,可當(dāng)我...
    茶點故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布斯碌。 她就那樣靜靜地躺著,像睡著了一般肛度。 火紅的嫁衣襯著肌膚如雪傻唾。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天承耿,我揣著相機與錄音冠骄,去河邊找鬼伪煤。 笑死,一個胖子當(dāng)著我的面吹牛凛辣,可吹牛的內(nèi)容都是我干的抱既。 我是一名探鬼主播,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼扁誓,長吁一口氣:“原來是場噩夢啊……” “哼蝙砌!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起跋理,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤择克,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后前普,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體肚邢,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年拭卿,在試婚紗的時候發(fā)現(xiàn)自己被綠了骡湖。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡峻厚,死狀恐怖响蕴,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情惠桃,我是刑警寧澤浦夷,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站辜王,受9級特大地震影響劈狐,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜呐馆,卻給世界環(huán)境...
    茶點故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一肥缔、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧汹来,春花似錦续膳、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至闺阱,卻和暖如春炮车,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工瘦穆, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留纪隙,地道東北人。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓扛或,卻偏偏與公主長得像绵咱,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子熙兔,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,925評論 2 344