MMKV的原理與實現(xiàn)

MMKV的原理與實現(xiàn)

MMKV源碼

1 初始化/文件準備

在 Java MMKV 類中有兩個靜態(tài)的 initialize() 方法:public static String initialize(Context context) 和 public static String initialize(String rootDir)。

1.1 public static String initialize(Context context)

//Java
public static String initialize(Context context) {
    String rootDir = context.getFilesDir().getAbsolutePath() + "/mmkv"; 
  return initialize(rootDir);
}

當傳入上下文 Context 時,文件將存儲在 App 私有的絕對目錄下。然后調(diào)用該方法的重載,將路徑字符 串傳入。

1.2 public static String initialize(String rootDir)

//Java
public static String initialize(String rootDir) { 
  MMKV.rootDir = rootDir; 
  jniInitialize(MMKV.rootDir);
    return rootDir;
}

無論是否直接傳入路徑字符串,最終都會調(diào)用該方法。在該方法內(nèi),MMKV 的靜態(tài)屬性 rootDir 被賦值為 傳入路徑,并開始調(diào)用 JNI native 方法 jniInitialize( MMKV.rootDir ) ,并返回 rootDir 。該 native 方法聲 明在

注意!當自選路徑為 SD 卡等外部存儲設(shè)備時,需要開啟外部設(shè)備讀寫權(quán)限!

uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"

1.3 jniInitialize( MMKV.rootDir )

下面是 jniInitialize(MMKV.rootDir) 在 Java 側(cè)的 native 聲明:

 //Java
private static native void jniInitialize(String rootDir);

下面是 JNI 側(cè)對 jniInitialize( MMKV.rootDir ) 的實現(xiàn),執(zhí)行了 c++ 代碼中 MMKV 類的靜態(tài)方法 initializeMMKV( path ) 。

//C++
extern "C"
JNIEXPORT void JNICALL
Java_com_mmkv_MMKV_jniInitialize(JNIEnv *env, jclass thiz, jstring _path) {
    const char *path = env->GetStringUTFChars(_path, 0); 
  MMKV::initializeMMKV(path);//執(zhí)行c++代碼中MMKV類的靜態(tài)方法initializeMMKV() 
  env->ReleaseStringUTFChars(_path, path);
}

1.4 MMKV::initializeMMKV(path)

下面是 MMKV.h 文件中對 MMKV::initializeMMKV(path) 的聲明,在 public 分區(qū),是一個靜態(tài)方法:

 //C++
static void initializeMMKV(const char *path);

下面是 MMKV.cpp 文件中對 initializeMMKV(const char *path) 的實現(xiàn):

//C++
void MMKV::initializeMMKV(const char *path) { 
  g_instanceDic = new unordered_map<string, MMKV *>; 
  g_rootDir = path;
  //創(chuàng)建目錄
  mkdir(g_rootDir.c_str(), 0777);
}

這里重點解釋一下第一句

 //C++
g_instanceDic = new unordered_map<string, MMKV *>;

這一句新建了一個無序 map ,map 的 K 是 string,V 是 MMKV 指針。這里的 string 類型的 K 在之后充當 mmapID ,這樣做的目的是什么?要解釋這一點,我們需要先看一看 MMKV Java 側(cè)的代碼:

//Java
public static MMKV defaultMMKV() { 
  if (rootDir == null) {
        throw new IllegalStateException("You should Call MMKV.initialize() first."); 
  }
    long handle = getDefaultMMKV();//>>>下面1.5詳細講<<<
    return new MMKV(handle);//非單例 
}

每一次調(diào)用 Java 側(cè)的 MMKV.defaultMMKV() 方法,都會 new 一個 MMKV 的實例對象,非單例。為了保證 同一個應(yīng)用所訪問的映射文件是同一個,我們在 C++ 側(cè)處理,讓該應(yīng)用創(chuàng)建的所有 MMKV 實例在 C++ 看 來都是同一個,反正我 C++ 只認 mmapID 。

無論 Java 側(cè)產(chǎn)生多少個 MMKV 實例對象,在 C++ 側(cè)都會先調(diào)用 MMKV::mmkvWithID(const string &mmapID) 方法先查找 g_instanceDic 中是否存在該 mmapID ,若存在,直接返回該 MMKV 指針;若不 存在,則創(chuàng)建,再返回,實現(xiàn)單例。

//C++
MMKV *MMKV::mmkvWithID(const string &mmapID) {
    auto itr = g_instanceDic->find(mmapID);//auto是C++的新特性,自動推導變量類型 
  if (itr != g_instanceDic->end()) {
        MMKV *kv = itr->second;
        return kv; 
  }
    //創(chuàng)建并放入集合
    auto kv = new MMKV(mmapID); 
  (*g_instanceDic)[mmapID] = kv; 
  return kv;
}

1.5 getDefaultMMKV()

上面 Java 調(diào)用 defaultMMKV() 時,里面有另外一個 JNI native 方法被調(diào)用:getDefaultMMKV() 。

它在 Java 側(cè)的聲明是在 MMKV 類中:

 //Java
private static native long getDefaultMMKV();

它在 JNI側(cè)的 C++ 實現(xiàn)是:

//C++
extern "C"
  JNIEXPORT jlong JNICALL Java_com_mmkv_MMKV_getDefaultMMKV(JNIEnv *env, jclass clazz) {
  MMKV *kv = MMKV::defaultMMKV();
    return reinterpret_cast<jlong>(kv); 
}

顯然,會調(diào)用 C++ 代碼中 MMKV 類的靜態(tài)方法 defaultMMKV() 。該方法會給該 defaultMMKV 實例分配一 個默認的 mmapID DEFAULT_MMAP_ID ,并調(diào)用我們上面提到的通過 map 查找 mmapID 實現(xiàn)單例的 mmkvWithID(const string &mmapID) 方法,創(chuàng)建或者直接返回一個指向 MMKV 對象的指針。

Q:為什么 native long getDefaultMMKV() 方法返回值是一個 long 類型?

A:結(jié)合 JNI 的 C++ 實現(xiàn)可以看出,JNI 側(cè)返回的是一個 MMKV 指針強轉(zhuǎn)之后的 jlong ,也就對應(yīng) Java 側(cè)的返回值 long 。換句話說,我們調(diào)用 native long getDefaultMMKV() 方法,JNI 將被創(chuàng)建的 MMKV 實例對象的指針返回給 Java 側(cè),Java 側(cè)今后若希望調(diào)用該 MMKV 實例對象的其他方法,需 要將指向該實例對象的指針(也就是這個 long 類型的 handle 句柄)傳遞給 native 方法,由 JNI 側(cè)轉(zhuǎn)換為指針,再調(diào)用 C++ 側(cè)的 MMKV 實例的相關(guān)方法。

//C++
MMKV *MMKV::defaultMMKV() {
    return mmkvWithID(DEFAULT_MMAP_ID);
}
MMKV *MMKV::mmkvWithID(const string &mmapID) {
    auto itr = g_instanceDic->find(mmapID); 
  if (itr != g_instanceDic->end()) {
    MMKV *kv = itr->second;
    return kv; 
  }
  //創(chuàng)建并放入集合
  auto kv = new MMKV(mmapID); 
  (*g_instanceDic)[mmapID] = kv;
  return kv; 
}

當然,MMKV 源碼中還有可以自己手動指定 mmapID 的方法。

1.6 new MMKV(mmapID)

接下來我們詳細看看這個構(gòu)造方法做了什么事情。
我們上面說到,創(chuàng)建一個 MMKV 實例對象最終會調(diào)用到 MMKV *MMKV::mmkvWithID(const string &mmapID) 方法來查詢 map 看是否對應(yīng) mmapID 的 MMKV 實例已經(jīng)存在,若不存在,再創(chuàng)建?,F(xiàn)在詳細 看創(chuàng)建過程,下面是調(diào)用:

//C++
MMKV::MMKV(const string &mmapID) :m_mmapID(mmapID), m_path(g_rootDir + "/" + mmapID) { 
  loadFromFile();
    //···省略了一些方法 
}

在該方法中,最重要的是這個方法 loadFromFile() ,從文件加載。在深入學習這個方法的源碼之前,我 們思考這個問題:

Q:為什么創(chuàng)建 MMKV 實例的過程是從文件加載?

A: 因為文件不同于內(nèi)存中的對象,文件是持久存在的,而內(nèi)存中的實例對象是會被回收的。 當 我創(chuàng)建一個實例對象的時候,先要檢查是否已經(jīng)存在以往的映射文件, 若存在,需要先建立映射 關(guān)系,然后解析出以往的數(shù)據(jù);若不存在,才是直接創(chuàng)建空文件來建立映射關(guān)系。

接下來我們在數(shù)據(jù)編解碼中詳細看 loadFrromFile() 方法的文件解析過程。

2 數(shù)據(jù)編解碼

2.1 loadFromFile()

//C++
void MMKV::loadFromFile() {
  /*----------- PART 1 : 打開文件 -----------*/
  m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU); if (m_fd < 0) {
  //打開失敗
  LOGI("打開文件:%s 失敗!", m_path.c_str()); }
  //讀取文件大小
  struct stat st = {0};
  if (fstat(m_fd, &st) != -1) {
    m_size = st.st_size; 
  }
  LOGI("打開文件:%s [%d]", m_path.c_str(), m_size);
  /**
    * 健壯性。 文件是否已存在,容量是否滿足頁大小倍數(shù),此處省略 */
    /*----------- PART 2 : 讀取有效數(shù)據(jù)長度 -----------*/
  m_ptr = static_cast<int8_t *>(mmap(0, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0));
//文件頭4個字節(jié)寫了數(shù)據(jù)有效區(qū)長度 
  memcpy(&m_actualSize, m_ptr, Fixed32Size); 
  bool loadFromFile = false;
  //有數(shù)據(jù)
  if (m_actualSize > 0) {
    //數(shù)據(jù)長度有效:不能比文件還大
    if (m_actualSize + Fixed32Size <= m_size) {
      loadFromFile = true; 
    }
    //其他情況,MMKV是交給用戶選擇
    // 1、OnErrorDiscard 忽略錯誤,MMKV會忽略文件中原來的內(nèi)容
    // 2、OnErrorRecover 還原,MMKV嘗試按照自己的方式解析文件,并修正長度
    }
  /**
  * 解析 mmkv 文件中的數(shù)據(jù) 保存到 map 集合中 */
  /*----------- PART 3 : 解析K-V -----------*/
  if (loadFromFile) {
    // 封裝的protobuf解析器
    InputBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize); 
    while (!inputBuffer.isAtEnd()) {
    //
        string key = inputBuffer.readString(); 
      if (key.length() > 0) {
      //讀取value(包含value長度+value數(shù)據(jù))
        InputBuffer *value = inputBuffer.readData(); //unordered_map<string,InputBuffer*>::iterator iter; 
        auto iter = m_dic.find(key);
            // 集合中找到了老數(shù)據(jù)
        if (iter != m_dic.end()){
            //清理老數(shù)據(jù)
            delete iter->second; // java-> map.remove 
            m_dic.erase(key);
          }
          //本次數(shù)據(jù)有效,加入集合
          if (value && value->length() > 0) {
          // java-> map.insert
          m_dic.emplace(key, value); }
          } 
    }
    //創(chuàng)建輸出
    m_output = new OutputBuffer(m_ptr + Fixed32Size + m_actualSize, m_size - Fixed32Size - m_actualSize);
  } else{
//todo 文件有問題,忽略文件已存在的數(shù)據(jù)
  }
}

loadFromFile() 方法的代碼實現(xiàn)總體分為三個部分:

  • 第一部分,打開文件,讀取文件大小,判斷是否是整數(shù)頁等等;
  • 第二部分,讀取有效數(shù)據(jù)長度,我們知道 MMKV 進行內(nèi)存映射時,文件內(nèi)容的前 4 個字節(jié)是有效數(shù) 據(jù)的長度,我們讀取出來,判斷是否存在有效數(shù)據(jù),若存在有效數(shù)據(jù),則進行第三部分的數(shù)據(jù)解 析;
  • 第三部分,解析 K-V 數(shù)據(jù),按照 K1長--->K1值--->V1長--->V1值--->...---> 格式解析,并解析 protobuf 編碼。

2.2 InputBuer類/OutputBuer類

因為用戶可以 put/get 多種數(shù)據(jù)類型的數(shù)據(jù)進來,為了同意管理數(shù)據(jù),我們將從文件中解析出來的數(shù)據(jù) 封裝成 InputBuffer ,將從內(nèi)存中寫進映射文件的數(shù)據(jù)封裝成 OutputBuffer ,根據(jù)用戶調(diào)用的不同方法 來處理不同的字節(jié)。是用戶和映射文件之間的一個“ Buffer ”,這兩個 Buffer 類都有一下幾個屬性:

1. int8_t *m_buf ;//一個字節(jié)指針,進行字節(jié)操作 
2. size_t m_size ;//整個文件的大小
3. size_t m_position ;//當前游標

這里的 Input/Output 是站在程序的角度(或者內(nèi)存的角度)而言的,當我需要從文件中解析數(shù)據(jù) 進入內(nèi)存,即為 Input ;反之即為 Output 。所以 InputBuer 類中的文件操作相關(guān)方法都是 readXxx() ,比如 readInt32() 等。對應(yīng)的,OutputBuer 類中的文件操作相關(guān)方法都是 writeXxx() , 比如 writeInt32() 等。

2.3 向映射文件寫入

我們需要實現(xiàn) OutputBuer() 類中的方法,以 writeInt32 和 writeInt64 為例,遵從 protobuf 編碼規(guī)則,在實現(xiàn) writeInt32/writeInt64 之前我們先定義寫入單個字節(jié)的方法 writeByte() 。

//C++
void OutputBuffer::writeByte(int8_t value) { 
  if (m_position == m_size) {
    //滿啦,出錯啦
    return; 
  }
  //將byte放入數(shù)組
  m_buf[m_position++] = value; 
 }

在該方法中,傳入一個 int8_t 即 byte 類型的 value 。若當前游標已經(jīng)在文件的結(jié)尾,說明需要擴容,此 方法暫時無法寫入,直接 return ;否則將該數(shù)據(jù)寫入游標指向的字節(jié),并將游標往后移動一位。

//C++
void OutputBuffer::writeInt32(int32_t value) { if (value < 0) {
        writeInt64(value);
    } else {
            while (true){
        if (value <= 0x7f){ 
          writeByte(value); break;
        } else{
            // 取低7位,再最高位賦1 
          writeByte(value & 0x7f | 0x80); 
          value >>= 7;
        }
      }
}

該方法中,傳入了一個 32 位(即 4 字節(jié))的 value 。若 value 是負數(shù),則直接調(diào)用 writeInt64() 方法。

Q:為什么負數(shù)直接調(diào)用 writeInt64() 方法?

A:在 Protobuf 為了讓 int32 和 int64 在編碼格式上兼容,對負數(shù)的編碼將 int32 視為 int64 處理, 因此負數(shù)使用 Varint (變長)編碼一定是 10 字節(jié)。說白了就是 protobuf 編碼的規(guī)則。

若 value 為正數(shù),則進入 else 代碼塊,進行熟悉的 protobuf 編碼:如果數(shù)據(jù)小于等于 127 則直接 writeByte() 寫入一個字節(jié);否則取出低 7 位,字節(jié)最高位置 1 ,寫入這個字節(jié),最后右移 7 位,循環(huán)執(zhí) 行該操作直到 value 小于等于 127 ,寫入最后一個字節(jié),break 跳出循環(huán),結(jié)束。

//C++
void OutputBuffer::writeInt64(int64_t value) { 
  uint64_t i = value;//轉(zhuǎn)為無符號64位整型 
  while (true){
    if (i & ~0x7f == 0){ 
                 writeByte(i);
                break;
     } else {
                // 取低7位,再最高位賦1 
       writeByte(i & 0x7f | 0x80); 
      i >>= 7;
    } 
  }
}

該方法中,傳入的是一個有符號 64 位整型數(shù)據(jù) value 。從上面的 writeInt32() 方法的實現(xiàn)知道,由于 protobuf 的規(guī)則,負數(shù)的編碼一定是調(diào)用 writeInt64() 方法的,所以這里涉及到帶符號右位移操作使用 前的處理,對于操作符“ >> ”,左邊是調(diào)用該操作符的操作數(shù),右邊是具體右移的位數(shù),需要注意的 是:當操作數(shù)是正數(shù)時,右移時高位補 0 ;當操作數(shù)是負數(shù)時,右移時高位補 1 。若不做處理直接按照 前面的 protobuf 編碼實現(xiàn)來做,將陷入死循環(huán)。我們以 -1 的 protobuf 編碼為例:

  • -1 在計算機內(nèi)以補碼形式存放,64 位,8 字節(jié):
1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111
  • 取低 7 位,字節(jié)最高位補 1 ,并右移 7 位得到:
1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111

得到的還是 64 個 1,陷入死循環(huán)。

所以,此處首先將有符號 64 位整型轉(zhuǎn)換為無符號 64 位整型,避免這個問題。也因為負數(shù)需要按照 int64 編碼,負數(shù)經(jīng)過 protobuf 編碼之后一定是 10 個字節(jié)。因為 64 / 7 = 9 ··· ··· 1 。

2.4 從映射文件讀出

實現(xiàn) InputBuer 類中的 readInt32() ,readInt64()方法。

與寫入類似,我們先定義 readByte() 方法,處理單個字節(jié)。

readByte()

//C++
int8_t InputBuffer::readByte() { //不能越界
  if (m_position == m_size) { 
    return 0;
  }
  return m_buf[m_position++]; 
}

若當前游標已經(jīng)指向文件最后一個字節(jié) 之后 的一個字節(jié),則拒絕讀取,不能越界;否則返回當前游標所 指向的字節(jié)的數(shù)據(jù),并將游標向后移動。

//C++
int32_t InputBuffer::readInt32() { 
  int32_t res = 0;
  int moveBits = 0; while (true){
  int32_t tmp = readByte(); 
    if(tmp <= 0x7f){
    tmp <<= moveBits; res += tmp; break;
    } else {
      tmp &= 0x7f;//最高位置0 
      tmp <<= moveBits; 
      moveBits += 7;
      res += tmp;
    } 
  }
  return res; 
}

該方法沒有傳入?yún)?shù),需要返回一個 int32_t 類型的整型數(shù)據(jù)。

  • 首先,我們聲明一個 int32_t 類型的變量 res 用作數(shù)據(jù)拼接和最后返回。然后定義一個 int 類型的 變量 moveBits 用于表示當前讀出來的字節(jié)需要右移多少位;
  • 進入循環(huán),每一個循環(huán)調(diào)用一次 readByte() 方法,讀出一個字節(jié),并用一個 int32_t 類型的 tmp 接 收;
  • 若讀出的一個字節(jié)小于 0x7f 即 127 ,則說明該字節(jié)是最后一個字節(jié),左移 moveBits 位后,加到 res 上,break跳出循環(huán);
  • 若讀出的一個字節(jié)大于 0x7f 即 127 ,則說明該字節(jié)的最高位是 1 ,需要繼續(xù)讀取并拼接。對于當 前讀出的字節(jié),與上 0x7f 將最高位置 0 ,左移 moveBits 位,并將 moveBits += 7 ,供下一次讀出的 字節(jié)左移使用,res 加上處理結(jié)束的 tmp ,進入下一個循環(huán)。
//C++
int32_t readInt64(){
  int32_t res = 0; 
  uint64_t uint64 = 0; 
  int moveBits = 0;
  while (true){
    uint64_t tmp = readByte(); 
    if(tmp <= 0x7f){
        tmp <<= moveBits; uint64 += tmp; break;
    } else {
        tmp &= 0x7f;
        tmp <<= moveBits; 
      moveBits += 7; 
      uint64 += tmp;
    } 
  }
  bool isMinus = (uint64>>63 != 0);//保留正負號 
  int32_t tmp = uint64 & 0x7fffffff;//保留低31位 
  if(isMinus){
        tmp |= 0x80000000;//是負數(shù),最高位符號位置1 
  }
    res = tmp;
    return res;
}

該方法沒有傳入?yún)?shù),需要返回一個 int32_t 類型的整型數(shù)據(jù)。轉(zhuǎn)換過程與 readInt32() 有一些相似但又 區(qū)別。

  • 首先,我們定義一個 int32_t 類型的 res ,用作返回結(jié)果;定義一個 uint64_t 類型的 uint64 用作數(shù) 據(jù)拼接,定義一個 moveBits 表示讀出來的字節(jié)需要左移多少位。注意!必須用一個 64 位的整數(shù)類 型作拼接;
  • 拼接完成后,保留正負號,保留低 31 位,再設(shè)置符號位;
  • 返回結(jié)果。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容