iOS的CoreData技术详解

为什么写这篇文章

最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。

但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。

上网查了一堆资料后,发现很多代码都已经是陈旧的了。甚至苹果官方文档提供的代码样例都未必是最新的Swift版本。于是萌生了自己写一篇文章来整理一遍思路的想法。尽可能让新人快速的上手,不但要知道其然,还要知道其设计的所以然,这样用起来才更得心应手。

什么是Core Data

我们写app肯定要用到数据持久化,说白了,就是把数据保存起来,app不删除的话可以继续读写。

iOS提供数据持久化的方案有很多,各自有其特定用途。

比如很多人熟知的UserDefaults,大部分时候是用来保存简单的应用配置信息;而NSKeyedArchiver可以把代码中的对象保存为文件,方便后来重新读取。

另外还有个常用的保存方式就是自己创建文件,直接在磁盘文件中进行读写。

而对于稍微复杂的业务数据,比如收藏夹,用户填写的多项表格等,SQLite就是更合适的方案了。关于数据库的知识,我这里就不赘述了,稍微有点技术基础的童鞋都懂。

Core DataSQLite做了更进一步的封装,SQLite提供了数据的存储模型,并提供了一系列API,你可以通过API读写数据库,去处理想要处理的数据。但是SQLite存储的数据和你编写代码中的数据(比如一个类的对象)并没有内置的联系,必须你自己编写代码去一一对应。

Core Data却可以解决一个数据在持久化层和代码层的一一对应关系。也就是说,你处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要你去实现额外的代码。

这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM)。

除了这个最重要的特性,Core Data还提供了很多有用的特性,比如回滚机制,数据校验等。

数据模型文件 - Data Model

当我们用Core Data时,我们需要一个用来存放数据模型的地方,数据模型文件就是我们要创建的文件类型。它的后缀是.xcdatamodeld。只要在项目中选 新建文件→Data Model 即可创建。

默认系统提供的命名为 Model.xcdatamodeld 。下面我依然以 Model.xcdatamodeld 作为举例的文件名。

这个文件就相当于数据库中的“库”。通过编辑这个文件,就可以去添加定义自己想要处理的数据类型。

数据模型中的“表格” - Entity

当在xcode中点击Model.xcdatamodeld时,会看到苹果提供的编辑视图,其中有个醒目的按钮Add Entity

什么是Entity呢?中文翻译叫“实体”,但是我这里就不打算用各种翻译名词来提高理解难度了。

如果把数据模型文件比作数据库中的“库”,那么Entity就相当于库里的“表格”。这么理解就简单了。Entity就是让你定义数据表格类型的名词。

假设我这个数据模型是用来存放图书馆信息的,那么很自然的,我会想建立一个叫BookEntity

“属性” - Attributes

当建立一个名为BookEntity时,会看到视图中有栏写着Attributes,我们知道,当我们定义一本书时,自然要定义书名,书的编码等信息。这部分信息叫Attributes,即书的属性。

Book的Entity

属性名 类型
name String
isbm String
page Integer32

其中,类型部分大部分是大家熟知的元数据类型,可以自行查阅。

同理,也可以再添加一个读者:Reader的Entity描述。

Reader的Entity

属性名 类型
name String
idCard String


“关系” - Relationship

在我们使用Entity编辑时,除了看到了Attributes一栏,还看到下面有Relationships一栏,这栏是做什么的?

回到例子中来,当定义图书馆信息时,刚书籍和读者的信息,但这两个信息彼此是孤立的,而事实上他们存在着联系。

比如一本书,它被某个读者借走了,这样的数据该怎么存储?

直观的做法是再定义一张表格来处理这类关系。但是Core Data提供了更有效的办法 - Relationship

Relationship的思路来思考,当一本书A被某个读者B借走,我们可以理解为这本书A当前的“借阅者”是该读者B,而读者B的“持有书”是A。

从以上描述可以看出,Relationship所描述的关系是双向的,即A和B互相以某种方式形成了联系,而这个方式是我们来定义的。

ReaderRelationship下点击+号键。然后在Relationship栏的名字上填borrow,表示读者和书的关系是“借阅”,在Destination栏选择Book,这样,读者和书籍的关系就确立了。

对于第三栏,Inverse,却没有东西可以填,这是为什么?

因为我们现在定义了读者和书的关系,却没有定义书和读者的关系。记住,关系是双向的。

就好比你定义了A是B的父亲,那也要同时去定义B是A的儿子一个道理。计算机不会帮我们打理另一边的联系。

理解了这点,我们开始选择Book的一栏,在Relationship下添加新的borrowByDestinationReader,这时候点击Inverse一栏,会发现弹出了borrow,直接点上。

这是因为我们在定义BookRelationship之前,我们已经定义了ReaderRelationship了,所以电脑已经知道了读者和书籍的关系,可以直接选上。而一旦选好了,那么在ReaderRelationship中,我们会发现Inverse一栏会自动补齐为borrowBy。因为电脑这时候已经完全理解了双方的关系,自动做了补齐。

“一对一”和“一对多” - to one和to many

我们建立ReaderBook之间的联系的时候,发现他们的联系逻辑之间还漏了一个环节。

假设一本书被一个读者借走了,它就不能被另一个读者借走,而当一个读者借书时,却可以借很多本书。

也就是说,一本书只能对应一个读者,而一个读者却可以对应多本书。

这就是 一对一→to one 和 一对多→to many

Core Data允许我们配置这种联系,具体做法就是在RelationShip栏点击对应的关系栏,它将会出现在右侧的栏目中。(栏目如果没出现可以在xcode右上角的按钮调出,如果点击后栏目没出现Relationship配置项,可以多点击几下,这是xcode的小bug)。

Relationship的配置项里,有一项项名为Type,点击后有两个选项,一个是To One(默认值),另一个就是To Many了。


Core Data框架的主仓库 - NSPersistentContainer

当我们配置完Core Data的数据类型信息后,我们并没有产生任何数据,就好比图书馆已经制定了图书的规范 - 一本书应该有名字、isbm、页数等信息,规范虽然制定了,却没有真的引进书进来。

那么怎么才能产生和处理数据呢,这就需要通过代码真刀真枪的和Core Data打交道了。

由于Core Data的功能较为强大,必须分成多个类来处理各种逻辑,一次性学习多个类是不容易的,还容易混淆,所以后续我会分别一一列出。

要和这些各司其职的类打交道,我们不得不提第一个要介绍的类,叫NSPersistentContainer,因为它就是存放这多个类成员的“仓库类”。

这个NSPersistentContainer,就是我们通过代码和Core Data打交道的第一个目标。它存放着几种让我们和Core Data进行业务处理的工具,当我们拿到这些工具之后,就可以自由的访问数据了。所以它的名字 - Container 蕴含着的意思,就是 仓库、容器、集装箱。

进入正式的代码编写的第一步,我们先要在使用Core Data框架的swift文件开头引入这个框架:

import CoreData

早期,在iOS 10之前,还没有NSPersistentContainer这个类,所以Core Data提供的几种各司其职的工具,我们都要写代码一一获得,写出来的代码较为繁琐,所以NSPersistentContainer并不是一开始就有的,而是苹果框架设计者逐步优化出来的较优设计。

p4

NSPersistentContainer的初始化

在新建的UIKIT项目中,找到我们的AppDelegate类,写一个成员函数(即方法,后面我直接用函数这个术语替代):

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
}

这样,NSPersistentContainer类的建立就完成了,其中"Model"字符串就是我们建立的Model.xcdatamodeld文件。但是输入参数的时候,我们不需要(也不应该)输入.xcdatamodeld后缀。

当我们创建了NSPersistentContainer对象时,仅仅完成了基础的初始化,而对于一些性能开销较大的初始化,比如本地持久化资源的加载等,都还没有完成,我们必须调用NSPersistentContainer的成员函数loadPersistentStores来完成它。

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }
        print("Load stores success")
    }
}

从代码设计的角度看,为什么NSPersistentContainer不直接在构造函数里完成数据库的加载?这就涉及到一个面向对象的开发原则,即构造函数的初始化应该是(原则上)倾向于原子级别,即简单的、低开销内存操作,而对于性能开销大的,内存之外的存储空间处理(比如磁盘,网络),应尽量单独提供成员函数来完成。这样做是为了避免在构造函数中出错时错误难以捕捉的问题。

表格属性信息的提供者 - NSManagedObjectModel

现在我们已经持有并成功初始化了Core Data的仓库管理者NSPersistentContainer了,接下去我们可以使用向这个管理者索取信息了,我们已经在模型文件里存放了读者和书籍这两个Entity了,如何获取这两个Entity的信息?

这就需要用到NSPersistentContainer的成员,即managedObjectModel,该成员就是标题所说的NSManagedObjectModel类型。

为了讲解NSManagedObjectModel能提供什么,我通过以下函数来提供说明:

private func parseEntities(container: NSPersistentContainer) {
    let entities = container.managedObjectModel.entities
    print("Entity count = \(entities.count)\n")
    for entity in entities {
        print("Entity: \(entity.name!)")
        for property in entity.properties {
            print("Property: \(property.name)")
        }
        print("")
    }
}

为了执行上面这个函数,需要修改createPersistentContainer,在里面调用parseEntities

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }
        
        self.parseEntities(container: container)
    }
}

在这个函数里,我们通过NSPersistentContainer获得了NSManagedObjectModel类型的成员managedObjectModel,并通过它获得了文件Model.xcdatamodeld中我们配置好的Entity信息,即图书和读者。

由于我们配置了两个Entity信息,所以运行正确的话,打印出来的第一行应该是Entity count = 2

container的成员managedObjectModel有一个成员叫entities,它是一个数组,这个数组成员的类型叫NSEntityDescription,这个类名一看就知道是专门用来处理Entity相关操作的,这里就没必要多赘述了。

示例代码里,获得了entity数组后,打印entity的数量,然后遍历数组,逐个获得entity实例,接着遍历entity实例的properties数组,该数组成员是由类型NSPropertyDescription的对象组成。

关于名词Property,不得不单独说明下,学习一门技术最烦人的事情之一就是理解各种名词,毕竟不同技术之间名词往往不一定统一,所以要单独理解一下。

Core Data的术语环境下,一个Entity由若干信息部分组成,之前已经提过的EntityRelationship就是了。而这些信息用术语统称为propertyNSPropertyDescription看名字就能知道,就是处理property用的。

只要将这一些知识点梳理清楚了,接下去打印的内容就不难懂了:

Entity count = 2

Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy

Entity: Reader
Property: idCard
Property: name
Property: borrow

我们看到,打印出来我们配置的图书有4个property,最后一个是borrowedBy,明显这是个Relationship,而前面三个都是Attribute,这和我刚刚对property的说明是一致的。

Entity对应的类

开篇我们就讲过,Core Data是一个 对象-关系映射 持久化方案,现在我们在Model.xcdatamodeld已经建立了两个Entity,那么如果在代码里要操作他们,是不是会有对应的类?

答案是确实如此,而且你还不需要自己去定义这个类。

如果你点击Model.xcdatamodeld编辑窗口中的Book这个Entity,打开右侧的属性面板,属性面板会给出允许你编辑的关于这个Entity的信息,其中Entity部分的Name就是我们起的名字Book,而下方还有一个Class栏,这一栏就是跟Entity绑定的类信息,栏目中的Name就是我们要定义的类名,默认它和Entity的名字相同,也就是说,类名也是Book。所以改与不改,看个人思路以及团队的规范。

所有Entity对应的类,都继承自NSManagedObject

为了检验这一点,我们可以在代码中编写这一行作为测试:

var book: Book! // 纯测验代码,无业务价值

如果写下这一行编译通过了,那说明开发环境已经给我们生成了Book这个类,不然它就不可能编译通过。

测试结果,完美编译通过。说明不需要我们自己编写,就可以直接使用这个类了。

关于类名,官方教程里一般会把类名更改为Entity名 + MO,比如我们这个Entity名为Book,那么如果是按照官方教程的做法,可以在面板中编辑Class的名字为BookMO,这里MO大概就是Model Object的简称吧。

但是我这里为简洁起见,就不做任何更改了,Entity名为Book,那么类名也一样为Book

另外,你也可以自己去定义Entity对应的类,这样有个好处是可以给类添加一些额外的功能支持,这部分Core Data提供了编写的规范,但是大部分时候这个做法反而会增加代码量,不属于常规操作。

数据业务的操作员 - NSManagedObjectContext

接下来我们要隆重介绍NSPersistentContainer麾下的一名工作任务最繁重的大将,成员viewContext,接下去我们和实际数据打交道,处理增删查改这四大操作,都要通过这个成员才能进行。

viewContext成员的类型是NSManagedObjectContext

NSManagedObjectContext,顾名思义,它的任务就是管理对象的上下文。从创建数据,对修改后数据的保存,删除数据,修改,五一不是以它为入口。

从介绍这个成员开始,我们就正式从 定义数据 的阶段,正式进入到 产生和操作数据 的阶段。

数据的插入 - NSEntityDescription.insertNewObject

梳理完前面的知识,就可以正式踏入数据创建的学习了。

这里,我们先尝试创建一本图书,用一个createBook函数来进行。示例代码如下:

private func createBook(container: NSPersistentContainer,
                        name: String, isbm: String, pageCount: Int) {
    let context = container.viewContext
    let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
                                                    into: context) as! Book
    book.name = name
    book.isbm = isbm
    book.page = Int32(pageCount)
    if context.hasChanges {
        do {
            try context.save()
            print("Insert new book(\(name)) successful.")
        } catch {
            print("\(error)")
        }
    }
}

在这个代码里,最值得关注的部分就是NSEntityDescription的静态成员函数insertNewObject了,我们就是通过这个函数来进行所要插入数据的创建工作。

insertNewObject对应的参数forEntityName就是我们要输入的Entity名,这个名字当然必须是我们之前创建好的Entity有的名字才行,否则就出错了。因为我们要创建的是书,所以输入的名字就是Book

into参数就是我们的处理增删查改的大将NSManagedObjectContext类型。

insertNewObject返回的类型是NSManagedObject,如前所述,这是所有Entity对应类的父类。因为我们要创建的EntityBook,我们已经知道对应的类名是Book了,所以我们可以放心大胆的把它转换为Book类型。

接下来我们就可以对Book实例进行成员赋值,我们可以惊喜的发现Book类的成员都是我们在Entity表格中编辑好的,真是方便极了。

那么问题来了,当我们把Book编辑完成后,是不是这个数据就完成了持久化了,其实不是的。

这里要提一下Core Data的设计理念:懒原则。Core Data框架之下,任何原则操作都是内存级的操作,不会自动同步到磁盘或者其他媒介里,只有开发者主动发出存储命令,才会做出存储操作。这么做自然不是因为真的很懒,而是出于性能考虑。

为了真的把数据保存起来,首先我们通过context(即NSManagedObjectContext成员)的hasChanges成员询问是否数据有改动,如果有改动,就执行contextsave函数。(该函数是个会抛异常的函数,所以用do→catch包裹起来)。

至此,添加书本的操作代码就写完了。接下来我们把它放到合适的地方运行。

我们对createPersistentContainer稍作修改:

private func createPersistentContainer() {
    let container = NSPersistentContainer(name: "Model")
    container.loadPersistentStores { (description, error) in
        if let error = error {
            fatalError("Error: \(error)")
        }
        
        //self.parseEntities(container: container)
        self.createBook(container: container,
                        name: "算法(第4版)",
                        isbm: "9787115293800",
                        pageCount: 636)
    }
}

运行项目,会看到如下打印输出:

Insert new book(算法(第4版)) successful.

至此,书本的插入工作顺利完成!

因为这个示例没有去重判定,如果程序运行两次,那么将会插入两条书名都为"算法(第4版)"的book记录。

数据的获取

有了前面基础知识的铺垫,接下去的例子只要 记函数 就成了,读取的示例代码:

private func readBooks(container: NSPersistentContainer) {
    let context = container.viewContext
    let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
    do {
        let books = try context.fetch(fetchBooks)
        print("Books count = \(books.count)")
        for book in books {
            print("Book name = \(book.name!)")
        }
    } catch {
        
    }
}

处理数据处理依然是我们的数据操作主力context,而处理读取请求配置细节则是交给一个专门的类,NSFetchRequest来完成,因为我们处理读取数据有各种各样的类型,所以Core Data设计了一个泛型模式,你只要对NSFetchRequest传入对应的类型,比如Book,它就知道应该传回什么类型的对应数组,其结果是,我们可以通过Entity名为Book的请求直接拿到Book类型的数组,真是很方便。

打印结果:

Books count = 1
Book name = 算法(第4版)

数据获取的条件筛选 - NSPredicate

通过NSFetchRequest我们可以获取所有的数据,但是我们很多时候需要的是获得我们想要的特定的数据,通过条件筛选功能,可以实现获取出我们想要的数据,这时候需要用到NSFetchRequest的成员predicate来完成筛选,如下所示,我们要找书名叫 算法(第4版) 的书。

在新的代码示例里,我们在之前实现的readBooks函数代码里略作修改:

private func readBooks(container: NSPersistentContainer) {
    let context = container.viewContext
    let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
    fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
    do {
        let books = try context.fetch(fetchBooks)
        print("Books count = \(books.count)")
        for book in books {
            print("Book name = \(book.name!)")
        }
    } catch {
        print("\(error)")
    }
}

通过代码:

fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")

我们从书籍中筛选出书名为 算法(第4版) 的书,因为我们之前已经保存过这本书,所以可以正确筛选出来。

筛选方案还支持大小对比,如

fetchBooks.predicate = NSPredicate(format: "page > 100")

这样将筛选出page数量大于100的书籍。

数据的修改

当我们要修改数据时,比如说我们要把 isbm = "9787115293800" 这本书书名修改为 算法(第5版) ,可以按照如下代码示例:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
    let books = try context.fetch(fetchBooks)
    if !books.isEmpty {
        books[0].name = "算法(第5版)"
        if context.hasChanges {
            try context.save()
            print("Update success.")
        }
    }
} catch {
    print("\(error)")
}

在这个例子里,我们遵循了 读取→修改→保存 的思路,先拿到筛选的书本,然后修改书本的名字,当名字被修改后,context将会知道数据被修改了,这时候判断数据是否被修改(实际上不需要判断我们也知道被修改了,只是出于编码规范加入了这个判断),如果被修改,就保存数据,通过这个方式,成功更改了书名。

数据的删除

数据的删除依然遵循 读取→修改→保存 的思路,找到我们想要的思路,并且删除它。删除的方法是通过contextdelete函数。

以下例子中,我们删除了所有 isbm="9787115293800" 的书籍:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
    let books = try context.fetch(fetchBooks)
    for book in books {
        context.delete(books[0])
    }
    if context.hasChanges {
        try context.save()
    }
} catch {
    print("\(error)")
}

扩展和进阶主题的介绍

如果跟我一步步走到这里,那么关于Core Data的基础知识可以说已经掌握的差不多了。
当然了,这部分基础对于日常开发已经基本够用了。

关于Core Data开发的进阶部分,我在这里简单列举一下:

  1. Relationship部分的开发,事实上通过之前的知识可以独立完成。
  2. 回滚操作,相关类:UndoManager
  3. EntityFetched Property属性。
  4. 多个context一起操作数据的冲突问题。
  5. 持久化层的管理,包括迁移文件地址,设置多个存储源等。

以上诸个主题都可以自己进一步探索,不在这篇文章的讲解范围。不过后续不排除会单独出文探索。

结语

Core Data在圈内是比较出了名的“不好用”的框架,主要是因为其抽象的功能和机制较为不容易理解。本文已经以最大限度的努力试图从设计的角度去阐述该框架,希望对你有所帮助。



引用:https://developer.apple.com/documentation/coredata

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容