前言
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 檔案的儲存方式如下
接下來看看 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 為開源社群做出的貢獻