Android平臺MMKV的原理及實(shí)現(xiàn)

前言

Protobuf協(xié)議

什么是Protobuf

  • protobuf 是google開源的一個(gè)序列化框架沙咏,類似xml胎源,json塘匣。但是它存儲的方式是二進(jìn)制
  • MMKV就是基于protobuf協(xié)議進(jìn)行數(shù)據(jù)存儲

數(shù)據(jù)結(jié)構(gòu)

image.png

  • 上面的圖片我們可以知道惭等,ProtoBuf存儲結(jié)構(gòu)是總長度->key的長度->key的內(nèi)容->value的長度->value的內(nèi)容...
  • 總長度我們可以用int來存儲,也就是4個(gè)字節(jié)進(jìn)行存儲
  • key的長度實(shí)際就是字符串的長度(我們定義key只能是字符串)

PortoBuf寫入方式

  • 一個(gè)字節(jié)有8位裸扶,我們將后7位用來保存數(shù)據(jù)咱旱,第一位用來判斷是否還有字節(jié),如果沒有則為0茫多,如果有則為1祈匙。

  • 如何判斷當(dāng)前是否還有字節(jié)?
    因?yàn)槲覀冎槐4婧?位字節(jié),而7位字節(jié)全是1的是7F夺欲,所以當(dāng)我們的數(shù)大于7F則表示我們還有字節(jié)


    image.png
  • 當(dāng)我們的數(shù)據(jù)大于7F,如何存儲跪帝?
    我們以5201314數(shù)據(jù)進(jìn)行分析,首先將5201314轉(zhuǎn)成字節(jié)碼0100 1111 0101 1101 1010 0010
    1、當(dāng)前數(shù)據(jù)大于7F,我們先取最低的七位些阅,也就是010 0010伞剑,第一位補(bǔ)1,則數(shù)據(jù)是1010 0010寫入文件
    2市埋、5201314右移動(dòng)7F黎泣,左邊不足補(bǔ)0,原數(shù)據(jù)則變成000 0000 1001 1110 1011 1011(大于7F)
    3缤谎、取出最低七位,1011 1011寫入文件抒倚。
    4、5201314右移動(dòng)7F坷澡,左邊不足補(bǔ)0托呕,原數(shù)據(jù)則變成000 0000 0000 0001 0011 1101
    5、取出最低七位,1011 1101寫入文件洋访。原數(shù)據(jù)則變成000 0000 0000 0000 0000 0010
    6镣陕、這時(shí)候數(shù)據(jù)小于07F,則直接將直接將0000 0010寫入文件姻政,結(jié)束

上述步驟結(jié)束之后拿到數(shù)據(jù)

1呆抑、1010 0010
2、1011 1011
3汁展、1011 1101
4鹊碍、0000 0010
  • 既然已經(jīng)將數(shù)據(jù)存儲了,那如何取出數(shù)據(jù)食绿?
    1侈咕、我們將0000 0010拼接到1011 1101之前,因?yàn)?011 1101中之后后七位是有效數(shù)據(jù)器紧,所以第一位需要去掉首位耀销,此時(shí)的原數(shù)據(jù)就是0000 0010 011 1101
    2、依次推論铲汪,將上面拼好的數(shù)據(jù)放到1011 1011之前熊尉,得到數(shù)據(jù)0000 0010 011 1101 011 1011
    3、再將上面拼好的數(shù)據(jù)放到1010 0010之前掌腰,得到數(shù)據(jù)0 0000 0100 1111 0101 1101 1010 0010
    4狰住、去除無效位數(shù)0,也就還原了原來的數(shù)據(jù)0100 1111 0101 1101 1010 0010

代碼實(shí)現(xiàn)
上面寫入方式了解之后齿梁,看起來還是挺簡單催植,但是代碼怎么寫呢肮蛹?

  • 1、我們現(xiàn)在寫入一個(gè)int的數(shù)據(jù)创南,怎么獲取它的大小?
    7F的字節(jié)碼是0111 1111伦忠,也就是說第一位是1就代表需要兩個(gè)字節(jié)來存。因此我們可以讓我們當(dāng)前的value&(0xFFFFFFFF<<7)扰藕,判斷是否等于0缓苛,如果等于0則表示需要一個(gè)字節(jié)就可以
    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

假設(shè)我們的value現(xiàn)在是150,因?yàn)橹狄呀?jīng)大于0x7F(也就是上述不成立)邓深,這時(shí)候我們需要將value&(0xFFFFFFFF<<14),如果等于0則表示需要2個(gè)字節(jié)

    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,編碼只需要一個(gè)字節(jié),否則進(jìn)入其他判斷
    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芥备、我們現(xiàn)在存一個(gè)key和value的數(shù)據(jù)冬耿,應(yīng)該怎么計(jì)算它的大小(也就是下圖的紅框區(qū)域的大小)


    image.png

首先key的長度其實(shí)也就是

int32_t keyLength = key.length();

然后保存key的長度+key內(nèi)容的長度:

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

value的長度+value內(nèi)容的長度

 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數(shù)據(jù)需要的字節(jié)
    int32_t size = keyLength + ProtoBuf::computeInt32Size(keyLength);
    // 加上保存value的長度與value數(shù)據(jù)需要的字節(jié)
    size += value->length() + ProtoBuf::computeInt32Size(value->length());
    return size;
}
  • 3、如何寫入數(shù)據(jù)
    我們上面分析了寫入方式萌壳,那么我們現(xiàn)在直接假設(shè)寫入的key數(shù)據(jù)的長度是字符串110亦镶,因?yàn)?10小于0x7F所以直接寫入,則直接寫入即可
 if (value <= 0x7f) {
            writeByte(value);
            return;
 }
void ProtoBuf::writeByte(int8_t value) {
    if (m_position == m_size) {
        //滿啦袱瓮,出錯(cuò)啦
        return;
    }
    //將byte放入數(shù)組
    m_buf[m_position++] = value;
}

如果key數(shù)據(jù)的長度是字符串150缤骨,因?yàn)榇藭r(shí)大于0x7f,將150轉(zhuǎn)成字符串 1001 1000 尺借,首先記錄低七位绊起,

(value & 0x7F)

將第一位的數(shù)據(jù)變成1,再移除低7位

            writeByte((value & 0x7F) | 0x80);
            //7位已經(jīng)寫完了,處理更高位的數(shù)據(jù)
            value >>= 7;

原理如下

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

此時(shí)key的長度已經(jīng)全部寫完燎斩,那key的內(nèi)容怎么寫呢虱歪,其實(shí)也很簡單,直接將key的內(nèi)容拷貝到數(shù)組就可以了

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

因此寫入string數(shù)據(jù)的完整代碼可以寫成如下

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

void ProtoBuf::writeRawInt(int32_t value) {
    while (true) {
        //每次處理7位數(shù)據(jù)笋鄙,如果寫入的數(shù)據(jù) <= 0x7f(7位都是1)那么使用7位就可以表示了
        if (value <= 0x7f) {
            writeByte(value);
            return;
        } else {
            //大于7位,則先記錄低7位怪瓶,并且將最高位置為1
            //1萧落、& 0x7F 獲得低7位數(shù)據(jù)
            //2、| 0x80 讓最高位變成1洗贰,表示超過1個(gè)字節(jié)記錄整個(gè)數(shù)據(jù)
            writeByte((value & 0x7F) | 0x80);
            //7位已經(jīng)寫完了找岖,處理更高位的數(shù)據(jù)
            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、如何讀取數(shù)據(jù)哆姻?
    如果當(dāng)前的最高位宣增,也就是第一位是0玫膀,則表示是一個(gè)字節(jié)矛缨,直接返回就可以
    if ((tmp >> 7) == 0) {
        return tmp;
    }

如果最高位1代表還有數(shù)據(jù),我們首先讀取低7位的數(shù)據(jù)

 int32_t result = tmp & 0x7f;

再讀取一個(gè)字節(jié),將后面的讀取到字節(jié)左移7位拼接到上一個(gè)數(shù)據(jù)的低7位

int32_t ProtoBuf::readInt() {
    uint8_t tmp = readByte();
    //最高1位為0  這個(gè)字節(jié)是一個(gè)有效int箕昭。
    if ((tmp >> 7) == 0) {
        return tmp;
    }
    //獲得低7位數(shù)據(jù)
    int32_t result = tmp & 0x7f;
    int32_t i = 1;
    do {
        //再讀一個(gè)字節(jié)
        tmp = readByte();
        if (tmp < 0x80) {
            //讀取后一個(gè)字節(jié)左移7位再拼上前一個(gè)數(shù)據(jù)的低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的讀取和寫入都已經(jīng)基本差不多了灵妨,我們來看mmap

內(nèi)存映射MMAP

SharedPreferences的弊端
  • SharedPreferences采用的是IO寫入數(shù)據(jù)


    image.png
  • 通信的本質(zhì)借助內(nèi)核
    • 左邊的進(jìn)程把數(shù)據(jù)從用戶空間copy到內(nèi)核空間
    • 右邊的進(jìn)程把數(shù)據(jù)從內(nèi)核空間copy到用戶空間
頁、頁框落竹、頁表
  • 基本概念
    • CPU執(zhí)行一個(gè)進(jìn)程的時(shí)候泌霍,都會訪問內(nèi)存
    • 但是并不是直接訪問物理內(nèi)存地址,而是通過虛擬地址訪問物理內(nèi)存地址
  • 頁:將進(jìn)程分配的虛擬地址空間劃分成的塊述召,對應(yīng)的大小叫做頁面的大小
  • 頁框:將物理地址劃分的塊
  • 頁表:記錄每一對頁和頁框的映射關(guān)系
  • 頁面大小是4k朱转,或者4k的整數(shù)倍


    image.png
mmap
  • 原理:通過mmap映射文件的一塊到用戶空間,那么現(xiàn)在通過操作mmap返回的指針积暖,就可以操作mmap映射的用戶空間藤为,同時(shí)相當(dāng)于操作文件

    image.png

  • 函數(shù)

 void * mmap(void *addr, size_t len, int prot, int flags, int fd, off_t of    fset); 
  • 參數(shù)
    • add:地址,當(dāng)為NULL的時(shí)候夺刑,由系統(tǒng)分配
    • len:內(nèi)存的大小
    • prot:
      • PROT_EXEC內(nèi)容可以被執(zhí)行缅疟;
      • PROT_READ:內(nèi)容可以被讀取遍愿;
      • PROT_WRITE:內(nèi)容可以被寫入;
      • PROT_NONE:內(nèi)容不可訪問
    • flags:MAP_SHARED:共享存淫;MAP_PRIVATE:私用;MAP_ANONYMOUS:匿名映射(不基于文件)沼填,fd傳入-1
    • fd:打開文件的句柄
    • fset:偏移大小,必須是4k的整數(shù)倍桅咆,一個(gè)物理頁映射是4k
核心代碼的實(shí)現(xiàn)

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

  • 初始化,代碼很簡單倾哺,主要?jiǎng)?chuàng)建一個(gè)文件夾和創(chuàng)建文件名字為peakmain_mmkv


    image.png
int32_t DEFAULT_MMAP_SIZE =getpagesize();
void MMKV::initializeMMKV(const char *path) {
    g_rootDir = path;
    //創(chuàng)建文件夾
    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;
    }
  • 我們需要保證文件的大小是頁的整數(shù)倍,也就是4k的整數(shù)倍,因?yàn)槲募笮”辉黾恿诵吆#敲丛黾拥膬?nèi)容需要被設(shè)置為0
    if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
        //調(diào)整為4k整數(shù)倍
        int32_t oldSize = m_size;
        //新的4k整數(shù)倍
        m_size = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
        if (ftruncate(m_fd, m_size) != 0) {
            m_size = st.st_size;
        }
        //如果文件大小被增加了忌愚, 讓增加這些大小的內(nèi)容變成空
        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));
  • 獲取到文件句柄之后,我們獲取文件原來的大小(上面我們分析了却邓,文件的前四個(gè)字節(jié)為內(nèi)容的總長度)
  memcpy(&m_actualSize, m_ptr, 4);
  • 如果m_actualSize>0硕糊,代表原來的文件是有值得,那么就需要將原有的數(shù)據(jù)保存hashmap
    if (m_actualSize > 0) {
        ProtoBuf inputBuffer(m_ptr + 4, m_actualSize);
        //清空
        map.clear();
        //已有的數(shù)據(jù)添加到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) {
                     //相當(dāng)于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寫入數(shù)據(jù)
    1简十、計(jì)算value需要多少個(gè)字節(jié),將寫入到ProtoBuf,再用一個(gè)key為string撬腾,value為ProtoBuf的類似java HashMap的unordered_map去存
void MMKV::putInt(const std::string &key, int32_t value) {
    //value需要幾個(gè)字節(jié)
    int32_t size = ProtoBuf::computeInt32Size(value);
    ProtoBuf *buf = new ProtoBuf(size);
    buf->writeRawInt(value);
    map.emplace(key, buf);
    appendDataWithKey(key, buf);
}

2螟蝙、計(jì)算待寫入數(shù)據(jù)的大小(也就是key+key的長度+value+value的長度),如果當(dāng)前待寫入數(shù)據(jù)的大小大于剩余的空間大小民傻,就需要擴(kuò)大內(nèi)存胰默。如果內(nèi)存足夠則直接放入數(shù)據(jù)即可

void MMKV::appendDataWithKey(std::string key, ProtoBuf *value) {
    //待寫入數(shù)據(jù)的大小
    int32_t itemSize = ProtoBuf::computeItemSize(key, value);
    if (itemSize > m_output->spaceLeft()) {
        //內(nèi)存不夠
        //計(jì)算map的大小
        int32_t needSize = ProtoBuf::computeMapSize(map);
        //加上總長度
        needSize += 4;
        //擴(kuò)容的大小
        //計(jì)算每個(gè)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;
            //如果在需要的與將來可能增加的加起來比擴(kuò)容后還要大场斑,繼續(xù)擴(kuò)容
            do {
                //擴(kuò)充一倍
                m_size *= 2;

            } while (needSize + futureUsage >= m_size);
            //重新設(shè)定文件大小
            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 {
        //內(nèi)存夠
        m_actualSize += itemSize;
        memcpy(m_ptr, &m_actualSize, 4);
        m_output->writeString(key);
        m_output->writeData(value);
    }
  • mmap取出數(shù)據(jù),只需要從map中取出數(shù)據(jù)就可以
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;
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末漏隐,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子奴迅,更是在濱河造成了極大的恐慌青责,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,576評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件取具,死亡現(xiàn)場離奇詭異脖隶,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)暇检,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評論 3 399
  • 文/潘曉璐 我一進(jìn)店門浩村,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人占哟,你說我怎么就攤上這事心墅。” “怎么了榨乎?”我有些...
    開封第一講書人閱讀 168,017評論 0 360
  • 文/不壞的土叔 我叫張陵怎燥,是天一觀的道長。 經(jīng)常有香客問我蜜暑,道長铐姚,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,626評論 1 296
  • 正文 為了忘掉前任肛捍,我火速辦了婚禮隐绵,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘拙毫。我一直安慰自己依许,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評論 6 397
  • 文/花漫 我一把揭開白布缀蹄。 她就那樣靜靜地躺著峭跳,像睡著了一般。 火紅的嫁衣襯著肌膚如雪缺前。 梳的紋絲不亂的頭發(fā)上蛀醉,一...
    開封第一講書人閱讀 52,255評論 1 308
  • 那天,我揣著相機(jī)與錄音衅码,去河邊找鬼拯刁。 笑死,一個(gè)胖子當(dāng)著我的面吹牛逝段,可吹牛的內(nèi)容都是我干的垛玻。 我是一名探鬼主播逸绎,決...
    沈念sama閱讀 40,825評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼夭谤!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起巫糙,我...
    開封第一講書人閱讀 39,729評論 0 276
  • 序言:老撾萬榮一對情侶失蹤朗儒,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后参淹,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體醉锄,經(jīng)...
    沈念sama閱讀 46,271評論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評論 3 340
  • 正文 我和宋清朗相戀三年浙值,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了恳不。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,498評論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡开呐,死狀恐怖烟勋,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情筐付,我是刑警寧澤卵惦,帶...
    沈念sama閱讀 36,183評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站瓦戚,受9級特大地震影響沮尿,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜较解,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評論 3 333
  • 文/蒙蒙 一畜疾、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧印衔,春花似錦啡捶、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,338評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至忿偷,卻和暖如春金顿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背鲤桥。 一陣腳步聲響...
    開封第一講書人閱讀 33,458評論 1 272
  • 我被黑心中介騙來泰國打工揍拆, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人茶凳。 一個(gè)月前我還...
    沈念sama閱讀 48,906評論 3 376
  • 正文 我出身青樓嫂拴,卻偏偏與公主長得像播揪,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子筒狠,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評論 2 359

推薦閱讀更多精彩內(nèi)容