Android平台MMKV的原理及实现

前言

Protobuf协议

什么是Protobuf

  • protobuf 是google开源的一个序列化框架,类似xml,json。但是它存储的方式是二进制
  • MMKV就是基于protobuf协议进行数据存储

数据结构

image.png

  • 上面的图片我们可以知道,ProtoBuf存储结构是总长度->key的长度->key的内容->value的长度->value的内容...
  • 总长度我们可以用int来存储,也就是4个字节进行存储
  • key的长度实际就是字符串的长度(我们定义key只能是字符串)

PortoBuf写入方式

  • 一个字节有8位,我们将后7位用来保存数据,第一位用来判断是否还有字节,如果没有则为0,如果有则为1。

  • 如何判断当前是否还有字节?
    因为我们只保存后7位字节,而7位字节全是1的是7F,所以当我们的数大于7F则表示我们还有字节


    image.png
  • 当我们的数据大于7F,如何存储?
    我们以5201314数据进行分析,首先将5201314转成字节码0100 1111 0101 1101 1010 0010
    1、当前数据大于7F,我们先取最低的七位,也就是010 0010,第一位补1,则数据是1010 0010写入文件
    2、5201314右移动7F,左边不足补0,原数据则变成000 0000 1001 1110 1011 1011(大于7F)
    3、取出最低七位,1011 1011写入文件。
    4、5201314右移动7F,左边不足补0,原数据则变成000 0000 0000 0001 0011 1101
    5、取出最低七位,1011 1101写入文件。原数据则变成000 0000 0000 0000 0000 0010
    6、这时候数据小于07F,则直接将直接将0000 0010写入文件,结束

上述步骤结束之后拿到数据

1、1010 0010
2、1011 1011
3、1011 1101
4、0000 0010
  • 既然已经将数据存储了,那如何取出数据?
    1、我们将0000 0010拼接到1011 1101之前,因为1011 1101中之后后七位是有效数据,所以第一位需要去掉首位,此时的原数据就是0000 0010 011 1101
    2、依次推论,将上面拼好的数据放到1011 1011之前,得到数据0000 0010 011 1101 011 1011
    3、再将上面拼好的数据放到1010 0010之前,得到数据0 0000 0100 1111 0101 1101 1010 0010
    4、去除无效位数0,也就还原了原来的数据0100 1111 0101 1101 1010 0010

代码实现
上面写入方式了解之后,看起来还是挺简单,但是代码怎么写呢?

  • 1、我们现在写入一个int的数据,怎么获取它的大小?
    7F的字节码是0111 1111,也就是说第一位是1就代表需要两个字节来存。因此我们可以让我们当前的value&(0xFFFFFFFF<<7),判断是否等于0,如果等于0则表示需要一个字节就可以
    1111 1111 1111 1111 1111 1111 1000 0000            (0xffffffff<<7)
&   0000 0000 0000 0000 0000 0000 0110 1110            (110)
=   0000 0000 0000 0000 0000 0000 0000 0000            0

假设我们的value现在是150,因为值已经大于0x7F(也就是上述不成立),这时候我们需要将value&(0xFFFFFFFF<<14),如果等于0则表示需要2个字节

    1111 1111 1111 1111 1100 0000 0000 0000            (0xffffffff<<14)
&   0000 0000 0000 0000 0000 0000 1001 0110            (150)
=   0000 0000 0000 0000 0000 0000 1000 0000            0

以此推论,最终我们可以写出如下代码

int32_t ProtoBuf::computeInt32Size(int32_t value) {
    //0xffffffff 表示 uint 最大值
    //<< 7 则低7位变成0 与上value
    //如果value只要7位就够了则=0,编码只需要一个字节,否则进入其他判断
    if ((value & (0xffffffff << 7)) == 0) {
        return 1;
    } else if ((value & (0xffffffff << 14)) == 0) {
        return 2;
    } else if ((value & (0xffffffff << 21)) == 0) {
        return 3;
    } else if ((value & (0xffffffff << 28)) == 0) {
        return 4;
    }
    return 5;
}
  • 2、我们现在存一个key和value的数据,应该怎么计算它的大小(也就是下图的红框区域的大小)


    image.png

首先key的长度其实也就是

int32_t keyLength = key.length();

然后保存key的长度+key内容的长度:

 int32_t size = keyLength + ProtoBuf::computeInt32Size(keyLength);

value的长度+value内容的长度

 size += value->length() + ProtoBuf::computeInt32Size(value->length());

所以获取key+value大小的完整代码

int32_t ProtoBuf::computeItemSize(std::string key, ProtoBuf *value) {
    int32_t keyLength = key.length();
    // 保存key的长度与key数据需要的字节
    int32_t size = keyLength + ProtoBuf::computeInt32Size(keyLength);
    // 加上保存value的长度与value数据需要的字节
    size += value->length() + ProtoBuf::computeInt32Size(value->length());
    return size;
}
  • 3、如何写入数据
    我们上面分析了写入方式,那么我们现在直接假设写入的key数据的长度是字符串110,因为110小于0x7F所以直接写入,则直接写入即可
 if (value <= 0x7f) {
            writeByte(value);
            return;
 }
void ProtoBuf::writeByte(int8_t value) {
    if (m_position == m_size) {
        //满啦,出错啦
        return;
    }
    //将byte放入数组
    m_buf[m_position++] = value;
}

如果key数据的长度是字符串150,因为此时大于0x7f,将150转成字符串 1001 1000 ,首先记录低七位,

(value & 0x7F)

将第一位的数据变成1,再移除低7位

            writeByte((value & 0x7F) | 0x80);
            //7位已经写完了,处理更高位的数据
            value >>= 7;

原理如下

         0111 1111            (0x7F)
&        1001 1000             (150)
=        0001 1000           
|        1000 0000             (0X80)
=        1001 1000

此时key的长度已经全部写完,那key的内容怎么写呢,其实也很简单,直接将key的内容拷贝到数组就可以了

  memcpy(m_buf + m_position, data->getBuf(), numberOfBytes);

因此写入string数据的完整代码可以写成如下

void ProtoBuf::writeByte(int8_t value) {
    if (m_position == m_size) {
        //满啦,出错啦
        return;
    }
    //将byte放入数组
    m_buf[m_position++] = value;
}

void ProtoBuf::writeRawInt(int32_t value) {
    while (true) {
        //每次处理7位数据,如果写入的数据 <= 0x7f(7位都是1)那么使用7位就可以表示了
        if (value <= 0x7f) {
            writeByte(value);
            return;
        } else {
            //大于7位,则先记录低7位,并且将最高位置为1
            //1、& 0x7F 获得低7位数据
            //2、| 0x80 让最高位变成1,表示超过1个字节记录整个数据
            writeByte((value & 0x7F) | 0x80);
            //7位已经写完了,处理更高位的数据
            value >>= 7;
        }
    }
}
void ProtoBuf::writeString(std::string value) {
    size_t numberOfBytes = value.size();
    writeRawInt(numberOfBytes);
    memcpy(m_buf + m_position, value.data(), numberOfBytes);
    m_position += numberOfBytes;
}
  • 4、如何读取数据?
    如果当前的最高位,也就是第一位是0,则表示是一个字节,直接返回就可以
    if ((tmp >> 7) == 0) {
        return tmp;
    }

如果最高位1代表还有数据,我们首先读取低7位的数据

 int32_t result = tmp & 0x7f;

再读取一个字节,将后面的读取到字节左移7位拼接到上一个数据的低7位

int32_t ProtoBuf::readInt() {
    uint8_t tmp = readByte();
    //最高1位为0  这个字节是一个有效int。
    if ((tmp >> 7) == 0) {
        return tmp;
    }
    //获得低7位数据
    int32_t result = tmp & 0x7f;
    int32_t i = 1;
    do {
        //再读一个字节
        tmp = readByte();
        if (tmp < 0x80) {
            //读取后一个字节左移7位再拼上前一个数据的低7位
            result |= tmp << (7 * i);
        } else {
            result |= (tmp & 0x7f) << (7 * i);
        }
        i++;
    } while (tmp >= 0x80);
    return result;
}
int8_t ProtoBuf::readByte() {
    if (m_position == m_size) {
        return 0;
    }
    return m_buf[m_position++];
}

至此ProtoBuf的读取和写入都已经基本差不多了,我们来看mmap

内存映射MMAP

SharedPreferences的弊端
  • SharedPreferences采用的是IO写入数据


    image.png
  • 通信的本质借助内核
    • 左边的进程把数据从用户空间copy到内核空间
    • 右边的进程把数据从内核空间copy到用户空间
页、页框、页表
  • 基本概念
    • CPU执行一个进程的时候,都会访问内存
    • 但是并不是直接访问物理内存地址,而是通过虚拟地址访问物理内存地址
  • 页:将进程分配的虚拟地址空间划分成的块,对应的大小叫做页面的大小
  • 页框:将物理地址划分的块
  • 页表:记录每一对页和页框的映射关系
  • 页面大小是4k,或者4k的整数倍


    image.png
mmap
  • 原理:通过mmap映射文件的一块到用户空间,那么现在通过操作mmap返回的指针,就可以操作mmap映射的用户空间,同时相当于操作文件

    image.png

  • 函数

 void * mmap(void *addr, size_t len, int prot, int flags, int fd, off_t of    fset); 
  • 参数
    • add:地址,当为NULL的时候,由系统分配
    • len:内存的大小
    • prot:
      • PROT_EXEC内容可以被执行;
      • PROT_READ:内容可以被读取;
      • PROT_WRITE:内容可以被写入;
      • PROT_NONE:内容不可访问
    • flags:MAP_SHARED:共享;MAP_PRIVATE:私用;MAP_ANONYMOUS:匿名映射(不基于文件),fd传入-1
    • fd:打开文件的句柄
    • fset:偏移大小,必须是4k的整数倍,一个物理页映射是4k
核心代码的实现

具体代码大家可以看我的github:https://github.com/Peakmain/Video_Audio/tree/master/app/src/main/cpp/src/mmkv/MMKV.cpp

  • 初始化,代码很简单,主要创建一个文件夹和创建文件名字为peakmain_mmkv


    image.png
int32_t DEFAULT_MMAP_SIZE =getpagesize();
void MMKV::initializeMMKV(const char *path) {
    g_rootDir = path;
    //创建文件夹
    mkdir(g_rootDir.c_str(), 0777);
}

MMKV::MMKV(const char *mmapID) {
    m_path = g_rootDir + "/" + mmapID;
    loadFromFile();
}

MMKV *MMKV::defaultMMKV() {
    MMKV *kv = new MMKV(DEFAULT_MMAP_ID);
    return kv;
}
  • 打开文件,并获取文件的大小
    m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
    //获取文件的具体大小
    struct stat st = {0};
    if (fstat(m_fd, &st) != -1) {
        m_size = st.st_size;
    }
  • 我们需要保证文件的大小是页的整数倍,也就是4k的整数倍,因为文件大小被增加了,那么增加的内容需要被设置为0
    if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
        //调整为4k整数倍
        int32_t oldSize = m_size;
        //新的4k整数倍
        m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
        if (ftruncate(m_fd, m_size) != 0) {
            m_size = st.st_size;
        }
        //如果文件大小被增加了, 让增加这些大小的内容变成空
        zeroFillFile(m_fd, oldSize, m_size - oldSize);
    }
  • mmap去映射文件
    m_ptr = static_cast<int8_t *>(mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0));
  • 获取到文件句柄之后,我们获取文件原来的大小(上面我们分析了,文件的前四个字节为内容的总长度)
  memcpy(&m_actualSize, m_ptr, 4);
  • 如果m_actualSize>0,代表原来的文件是有值得,那么就需要将原有的数据保存hashmap
    if (m_actualSize > 0) {
        ProtoBuf inputBuffer(m_ptr + 4, m_actualSize);
        //清空
        map.clear();
        //已有的数据添加到Map
        while (!inputBuffer.isAtEnd()) {
            std::string key = inputBuffer.readString();
            LOGE("key=%s ", key.c_str());
            if (key.length() > 0) {
                ProtoBuf *value = inputBuffer.readData();
                if (value && value->length() > 0) {
                     //相当于java的Hashmap的add
                    map.emplace(key, value);
                }
            }
        }
    }
    m_output = new ProtoBuf(m_ptr + 4 + m_actualSize,
                            m_size - 4 - m_actualSize);

这里有人可能不懂为什么这里是m_ptr + 4 + m_actualSize,这里的目的是将我们的buf指向没有被填写的位置

  • mmap写入数据
    1、计算value需要多少个字节,将写入到ProtoBuf,再用一个key为string,value为ProtoBuf的类似java HashMap的unordered_map去存
void MMKV::putInt(const std::string &key, int32_t value) {
    //value需要几个字节
    int32_t size = ProtoBuf::computeInt32Size(value);
    ProtoBuf *buf = new ProtoBuf(size);
    buf->writeRawInt(value);
    map.emplace(key, buf);
    appendDataWithKey(key, buf);
}

2、计算待写入数据的大小(也就是key+key的长度+value+value的长度),如果当前待写入数据的大小大于剩余的空间大小,就需要扩大内存。如果内存足够则直接放入数据即可

void MMKV::appendDataWithKey(std::string key, ProtoBuf *value) {
    //待写入数据的大小
    int32_t itemSize = ProtoBuf::computeItemSize(key, value);
    if (itemSize > m_output->spaceLeft()) {
        //内存不够
        //计算map的大小
        int32_t needSize = ProtoBuf::computeMapSize(map);
        //加上总长度
        needSize += 4;
        //扩容的大小
        //计算每个item的平均长度
        int32_t avgItemSize = needSize / std::max<int32_t>(1, map.size());
        int32_t futureUsage = avgItemSize * std::max<int32_t>(8, (map.size() + 1) / 2);
        if (needSize + futureUsage >= m_size) {
            int32_t oldSize = m_size;
            //如果在需要的与将来可能增加的加起来比扩容后还要大,继续扩容
            do {
                //扩充一倍
                m_size *= 2;

            } while (needSize + futureUsage >= m_size);
            //重新设定文件大小
            ftruncate(m_fd, m_size);
            zeroFillFile(m_fd, oldSize, m_size - oldSize);
            //解除映射
            munmap(m_ptr, oldSize);
            //重新映射
            m_ptr = (int8_t *) mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
        }
        m_actualSize = needSize - 4;
        memcpy(m_ptr, &m_actualSize, 4);
        LOGE("extending  full write");
        delete m_output;
        m_output = new ProtoBuf(m_ptr + 4,
                                m_size - 4);
        auto iter = map.begin();
        for (; iter != map.end(); iter++) {
            auto k = iter->first;
            auto v = iter->second;
            m_output->writeString(k);
            m_output->writeData(v);
        }
    } else {
        //内存够
        m_actualSize += itemSize;
        memcpy(m_ptr, &m_actualSize, 4);
        m_output->writeString(key);
        m_output->writeData(value);
    }
  • mmap取出数据,只需要从map中取出数据就可以
int32_t MMKV::getInt(std::string key, int32_t defaultValue) {
    auto itr = map.find(key);
    if (itr != map.end()) {
        ProtoBuf *buf = itr->second;
        int32_t returnValue = buf->readInt();
        //多次读取,将position还原为0
        buf->restore();
        return returnValue;
    }
    return defaultValue;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容