CloudKit框架详细解析(二) —— CloudKit一个基本使用示例(一)

版本记录

版本号 时间
V1.0 2019.10.21 星期一

前言

将结构化app和用户数据存储在可由应用程序的所有用户共享的iCloud容器中。感兴趣的可以看下面几篇文章。
1. CloudKit框架详细解析(一) —— 基本概览(一)

开始

题外话:徐S我想你了,虽然我们不会再相逢,你也不会再看见,但是很想你,你定你的亲,我单我的身,互不打扰~~

接着看下主要内容:

主要内容:在本CloudKit教程中,您将学习如何在应用程序中的iCloud中添加和查询数据,以及如何使用CloudKit仪表板管理数据。翻译文章来自下面地址

接着就是写作环境

Swift 5, iOS 13, Xcode 11

下面进入正题

CloudKit是Apple的远程数据存储服务。它基于iCloud,提供了一种低成本的选项,可以使用用户的iCloud帐户作为后端存储服务来存储和共享应用程序数据。

CloudKit有两个主要组件:

  • web dashboard,用于管理记录类型和任何公共数据。
  • 一组用于在iCloud和设备之间传输数据的API。

使用CloudKit,由于用户只能访问自己的私有数据库,而无法查看其他任何用户的私有数据,因此可以完全保护用户的私有数据。

对于使用大量数据但不需要大量服务器端逻辑的仅限iOS的应用程序,CloudKit是一个不错的选择。此外,您可以将CloudKit用于Webserver应用程序。

在本CloudKit教程中,您将通过创建一个名为BabiFüd的餐厅评分应用来获得使用CloudKit的动手经验。

注意:要使用此CloudKit教程中的示例应用程序,您需要一个有效的iOS开发人员帐户。没有一个,您将无法启用iCloud权利或访问CloudKit dashboard


Why CloudKit?

您可能想知道为什么应该选择CloudKit而不是其他商业BaaS(Backend as a Service)产品,甚至推出自己的服务器。

有以下三个原因:Simplicity, trust and cost

1. Simplicity

与其他后端解决方案不同,CloudKit不需要任何设置。您无需选择,配置或安装服务器。苹果处理安全性和扩展性。

只需注册iOS开发者计划,您就有资格使用CloudKit。您无需注册其他服务或创建新帐户。在您的应用中启用CloudKit功能后,所有必要的服务器设置过程都会自动发生。

无需下载其他库并进行配置;您可以像其他任何iOS框架一样导入CloudKitCloudKit框架本身还通过提供用于常见操作的便捷API来提供一定程度的简化。

对用户来说也很容易。由于CloudKit使用用户在设置设备时输入的iCloud凭据(或在设置后通过Settings应用程序输入的iCloud凭据),因此无需构建复杂的登录屏幕。只要他们登录,用户就可以无缝使用您的应用程序。那应该使您进入Cloud 9

2. Trust

CloudKit的另一个好处是,用户可以依靠Apple而不是应用程序开发人员来信任其数据的隐私和安全性。 CloudKit隔离了您(开发人员)的用户数据。

尽管这种缺乏访问权限的情况在调试时可能会令人沮丧,但由于您不必担心安全性或说服用户其数据是安全的,因此这是一个不错的选择。 如果应用程序用户信任iCloud,那么他们也可以信任您。

3. Cost

最后,对于任何开发人员而言,运行服务的成本都是一笔不小的数目。 甚至最便宜的服务器主机也无法为小型,免费或廉价应用提供低成本解决方案。 因此,始终会有与运行应用程序相关的成本。

借助CloudKit,您可以免费获得合理数量的公共数据存储和数据传输。 Apple的CloudKit site网站上有一个方便的计算器。

这些优势使CloudKit服务成为Mac和iOS应用程序的低麻烦解决方案。


Introducing BabiFüd

CloudKit教程的示例应用程序BabiFüd是对标准“评价餐馆”应用程序的最新尝试。 用户没有根据食物质量,服务速度或价格来评论餐厅,而是对儿童友善性进行了评分。 这包括更衣设施,加高座椅和健康食品的可用性。

该应用程序包含两个选项卡:附近餐厅列表和用户生成的注释。 您可以在下面查看运行中的应用程序。

模型类支持这些视图,并包装对CloudKit的调用。 CloudKit对象称为records。 模型中的主要记录类型是Establishment,它代表您应用中的各种餐馆。

打开入门项目。

您必须先更改应用的包标识符和团队,然后才能开始编码。 您需要设置团队以从Apple获得必要的权限,同时拥有唯一的包标识符使整个过程变得更加容易。

Xcode中打开BabiFud.xcodeproj。 在项目导航器中选择BabiFud项目,然后选择BabiFud target。 选择General选项卡后,将Bundle Identifier替换为唯一的东西。 标准做法是使用反向域名表示法并包括项目名称。

这将考虑Bundle Identifier。 现在,您需要为CloudKit设置应用,并创建一些容器来保存数据。


Entitlements and Containers

您需要一个容器来保存应用程序的记录,然后才能通过应用程序添加任何数据。 容器是服务器上所有应用程序数据的概念位置的术语。 它是一组公共和私有数据库。

要创建容器,您首先需要为您的应用启用iCloud权利:

  • 1) 在目标编辑器中选择Signing & Capabilities选项卡。
  • 2) 单击+ Capability
  • 3) 选择iCloud
  • 4) 从下拉列表中选择您的团队。

此时,Xcode可能会提示您输入与您的iOS开发者帐户关联的Apple ID。 如果是这样,则按要求输入。

接下来,通过选中Services组中的CloudKit复选框来启用CloudKit

最后,如果CloudKit不会自动为您创建一个容器,请单击Containers下的+以添加新容器。

在弹出窗口中,添加您的bundle identifier

这将创建一个名为iCloud.<your app’s bundle ID>的默认容器。

注意:在撰写本文时,无法删除CloudKit容器。请记住,如果为本教程创建一个新容器,它将永久存在于CloudKit Dashboard中。

1. Troubleshooting iCloud Setup in Xcode

如果在创建entitlements,构建项目或运行应用程序时看到与容器ID相关的任何警告或错误,则以下是一些故障排除提示:

  • 如果iCloud部分的Steps组中显示了任何警告或错误,请尝试按Fix Issue按钮。您可能需要执行几次。
  • 该应用程序的bundle IDiCloud容器必须匹配,并且它们必须存在于开发者帐户中。例如,如果bundle identifiercom.<your domain>.BabiFud,则iCloud容器名称应为iCloud。加上bundle idiCloud.com.<your domain>.BabiFud
  • iCloud容器名称必须唯一,因为这是CloudKit用于访问数据的全局标识符。由于iCloud容器名称包含bundle ID,因此bundle ID也必须是唯一的。这就是为什么您不能使用com.raywenderlich.BabiFud并且必须更早对其进行更改的原因。
  • 为了entitlements起作用,必须在Certificates, Identifiers and Profiles门户的App IDs部分中显示app/bundle ID。这意味着用于签署应用程序的证书必须来自设置的team ID,并且必须列出app ID,这也意味着iCloud container ID

通常,如果您登录到有效的开发人员帐户,Xcode会自动执行所有这些操作。不幸的是,这有时会不同步。

它可以帮助以新的ID开头,并使用iCloud功能窗格更改CloudKit container ID以匹配。否则,您可能必须编辑Info.plistBabiFud.entitlements文件,以确保其中的ID值反映您为bundle ID设置的内容。


Introducing the CloudKit Dashboard

下一步是创建一些记录类型,这些记录类型定义应用程序将使用的数据。 您可以使用CloudKit Dashboard执行此操作。 单击CloudKit Dashboard,您可以在目标的Signing & Capabilities窗格中的iCloud下,或通过在浏览器中打开https://icloud.developer.apple.com/dashboard/来找到它。

注意:有时,新应用程序的容器可能需要一段时间才能显示在信息中心中。 如果您没有立即看到容器,请等待几分钟,然后重试。

仪表盘的外观如下:

CloudKit dashboard包含六个部分:

  • Data
  • Schema
  • Telemetry
  • Usage
  • Logs
  • API Access

Schema部分表示CloudKit容器的高级对象:Record Types, Indexes, Security Roles and Subscription Types。在本教程中,您将只处理Record Types

Record Types是定义单个记录的一组字段。在面向对象编程方面,Record Types就像一个类。您可以将一条记录视为特定Record Types的实例。它表示容器中的结构化数据,非常类似于数据库中的典型行,并且封装了一系列键/值对。

Data下的Private DatabasePublic Database部分中,您可以向有权访问的数据库中的数据添加数据或在其中搜索数据。请记住,作为开发人员,您可以访问所有公共数据,但只能访问自己的私有数据。

User Records存储有关当前iCloud用户的数据,例如名称和电子邮件。

您可以使用Record Zone(在此处称为Default Zone)通过将记录分组在一起为私有数据库提供逻辑组织。

自定义区域允许您在处理其他操作之前同时保存多个记录,从而支持原子事务。自定义区域不在本教程的讨论范围之内。

API Access部分提供了为团队成员配置仪表板权限的功能。如果您有多个开发团队成员,则可以在此处限制他们编辑数据的能力。这也超出了本教程的范围。


Adding the Establishment Record Type

暂时考虑一下应用程序的设计。 您跟踪的每个establishments都有大量数据:便于儿童使用的选项的名称,位置和可用性。 记录类型使用字段来定义每个记录包含的各种数据。

Schema下,选择Record Types,然后选择New Type以添加新的记录类型。

将新记录类型命名为Establishment,然后按Enter

您会看到一行系统字段,它们是为每种记录类型自动创建的。 每个字段都有一个Field Name,一个Field Type和一个Index

当然,您可以添加自己的字段。 首先选择Add Field,将其命名为name,然后将Field Type设置为String。 重复直到添加了所有这些字段:

单击页面底部的Save以保存新的记录类型。

接下来,单击Edit Indexes。 您将添加两个索引,以便可以查询记录:

  • 1) 索引类型为QUERYABLErecordName
  • 2) 以索引类型QUERYABLE命名name

默认情况下,您需要使recordName可查询。 您将name设置为可查询,以便您可以基于特定名称查询记录。

单击页面底部的Save Changes以保存索引。

完成后,您的字段列表应如下所示:

现在,您可以将一些样本establishment记录添加到数据库中。 为此,请在信息中心顶部的下拉菜单中切换到Data

在左侧导航窗格的Public Database部分下,选择_defaultZone。 该区域将包含您应用的公共记录。

如果尚未选择Establishment记录类型,则从中央窗格的下拉列表中选择它。 然后单击底部详细信息窗格中的New Record按钮,如下面的屏幕快照所示:

这将创建一个新的空的Establishment记录。

此时,您就可以为您的应用输入一些测试数据了。

以下示例establishment数据是虚构的。 这些establishment位于Apple总部附近,因此可以在模拟器中轻松找到它们。

输入每个记录,如下所述:

您可以在包含先前入门项目的材料文件中找到图像。 查找Images文件夹。

保存所有记录后,dashboard应如下所示。 您可能需要单击Query Records按钮以使记录出现:

对于每个记录,输入的值是数据的数据库表示形式。

在应用程序方面,数据类型不同。 例如,ChangeingTable是一个枚举。 因此,为changetable指定的Int值可能对应于男女房间中可用的一个变化表。

对于HealthyOptionkidsMenuInt值表示布尔类型:0表示establishment不具有该选项,而1表示它具有。

要运行该应用程序,您需要拥有一个可用于开发的iCloud帐户。 您将在此处找到说明: Creating an iCloud Account for Development

您还需要在iOS模拟器中输入与此帐户关联的iCloud credentialsEnter iCloud Credentials Before Running Your App

返回到Xcode。 现在该开始将这些数据集成到您的应用程序中了!

1. Querying Establishment Records

要从数据库中选择记录,您将使用CKQueryCKQuery描述了如何查找与某些条件匹配的特定类型的所有记录。这些条件可以是“具有“ M”开头的“名称字段”的所有记录”,“具有加高座位的所有记录”或“ 3公里内的所有记录”。

iOS使用NSPredicate处理此类表达式。 NSPredicate评估对象以查看它们是否符合条件。Predicates也用在Core Data中;它们自然适合CloudKit,因为谓词的定义只是对字段的比较。

CloudKit仅支持可用NSPredicate函数的子集。其中包括数学比较,一些字符串和设置操作,例如“字段匹配列表中的一项”,以及特殊的距离函数。 CKQuery Class Reference包含CloudKit支持的函数的详细列表以及如何使用它们的描述。

在Xcode中,打开Model.swift。用以下实现替换refresh(_ :)

@objc func refresh(_ completion: @escaping (Error?) -> Void) {
  // 1.
  let predicate = NSPredicate(value: true)
  // 2.
  let query = CKQuery(recordType: "Establishment", predicate: predicate)
  establishments(forQuery: query, completion)
}

这是您添加的内容:

  • 1) 您创建一个值为true的谓词。 NSPredicate确定如何获取或过滤数据。 在这种情况下,您要指定一个应该刚刚存在的值。
  • 2) 您添加查询以指定所需的记录类型和谓词。

接下来,用以下内容替换establishments(forQuery:_ :)

private func establishments(forQuery query: CKQuery,
    _ completion: @escaping (Error?) -> Void) {
  publicDB.perform(query,
      inZoneWith: CKRecordZone.default().zoneID) { [weak self] results, error in
    guard let self = self else { return }
    if let error = error {
      DispatchQueue.main.async {
        completion(error)
      }
      return
    }
    guard let results = results else { return }
    self.establishments = results.compactMap {
      Establishment(record: $0, database: self.publicDB)
    }
    DispatchQueue.main.async {
      completion(nil)
    }
  }
}

由于您在此处传递CKQuery对象,因此您的公共数据库publicDB可以执行查询。 如果回想一下在CloudKit仪表板中创建两个Establishment对象时,可以将它们放在默认容器中的公共数据库中。 这正是inZoneWith参数在此处指定的内容。 现在,数据库将查询公共数据库中存在的所有Establishment记录。

构建并运行。 您应该会看到附近的establishments列表。

事情看起来不太正确。 您在表中有两项,并且如果选择一项,则大多数详细信息将正确加载。

但是,详细信息屏幕缺少图像。 那是因为您上传的图像是CKAssets。 这些需要一些特殊的处理。


Working With Binary Assets

资源asset是与记录关联的二进制数据,例如图像。 就您而言,您应用的资源是在NearableTableViewController的表格视图中显示的establishment照片。

在本部分中,您将添加逻辑以加载在检索establishment记录时下载的资产。

打开Establishment.swift并将loadCoverPhoto(_ :)替换为以下代码:

  
func loadCoverPhoto(completion: @escaping (_ photo: UIImage?) -> ()) {
  // 1.
  DispatchQueue.global(qos: .utility).async {
    var image: UIImage?
    // 5.
    defer {
      DispatchQueue.main.async {
        completion(image)
      }
    }
    // 2.
    guard 
      let coverPhoto = self.coverPhoto,
      let fileURL = coverPhoto.fileURL 
      else {
        return
    }
    let imageData: Data
    do {
      // 3.
      imageData = try Data(contentsOf: fileURL)
    } catch {
      return
    }
    // 4.
    image = UIImage(data: imageData)
  }
}

此方法从asset attribute加载图像,如下所示:

  • 1) 尽管在检索记录的其余部分的同时下载了资源,但您还是要异步加载图像。 因此,将所有内容包装在DispatchQueue.async块中。
  • 2) 检查以确保资产CoverPhoto存在并且具有fileURL
  • 3) 下载图片的二进制数据。
  • 4) 使用图像数据创建UIImage的实例。
  • 5) 对检索到的图像执行完成回调。 请注意,无论执行哪个return,都会执行defer块。 例如,如果没有图像资产,那么return时就不会设置image,并且餐厅也不会显示图像。

构建并运行。 现在将显示establishment图像。 很好!

CloudKit资产有两个陷阱:

  • 1) 资产只能在CloudKit中作为记录中的属性存在; 您不能单独存储它们。 删除记录还将删除所有关联资产。
  • 2) 检索资产可能会对性能产生负面影响,因为您与其余记录数据同时下载了资产。 如果您的应用大量使用资产,则应存储对仅包含资产的另一种记录类型的引用。

Relationships

了解如何在CloudKit中创建不同记录类型之间的关系非常重要。 为此,您将添加一个新的记录类型Note,并创建不在公共数据库中的私人记录。 这些记录将属于一个Establishment

返回CloudKit仪表板,通过进入Schema并选择New Type来添加Note类型。 添加以下字段,然后保存:

单击Edit Indexes,然后单击Add Index以使recordName可查询。

接下来,将一个新字段添加到Establishment中:

通过在Establishment上创建字段notes,并在notes上创建Establishment,您现在具有一对多关系。 这意味着一个Establishment可以有多个注释notes,但是一个注释只能属于一个Establishment

在继续之前,您需要获取Establishment记录的Name值。 在CloudKit仪表板中,返回到Data,选择Public Database,然后从下拉列表中键入Establishment。 接下来,单击Query Records。 记下第一项的Name,如下所示:

接下来,就像在两个Establishment记录中一样,在CloudKit仪表板中创建一个Note记录。 仍在仪表板的Data部分中,从Type下拉列表中选择Note。 但是,将Public Database更改为Private Database,然后选择New Record。 现在,您的记录将仅在CloudKit数据库中可用。 然后更改以下值:

  • 对于Establishment,输入在名称字段中找到的值。
  • 输入您想要输入的文字。

保存之前,请复制在新note’sName字段中找到的值,以简化下一步操作。

您的新记录应如下所示:

选择Save。 接下来,查询您的公共场所public Establishments并编辑您用于注释的Name的记录。 选择+按钮,输入您在上一步中保存的注释名称,然后Save。 它看起来应该像这样:

现在,您有一个public Establishment记录,该记录与一个private Note record有关系! 要加载注释,请打开Note.swift,并将fetchNotes(_ :)替换为以下内容:

static func fetchNotes(_ completion: @escaping (Result<[Note], Error>) -> Void) {
  let query = CKQuery(recordType: "Note",
                      predicate: NSPredicate(value: true))
  let container = CKContainer.default()
  container.privateCloudDatabase
    .perform(query, inZoneWith: nil) { results, error in
    
  }
}

这看起来类似于您查询和下载establishments的方式。 但是,请注意,该信息现在是从privateCloudDatabase而不是从公共数据库加载的。 只需简单地指定您要在应用中使用特定于用户的数据还是公开数据。

接下来,在闭包内部添加以下内容以获取记录的数据,就像您之前对Establishment所做的一样:

if let error = error {
  DispatchQueue.main.async {
    completion(.failure(error))
  }
  return
}
  
guard let results = results else {
  DispatchQueue.main.async {
    let error = NSError(
      domain: "com.babifud", code: -1,
      userInfo: [NSLocalizedDescriptionKey: "Could not download notes"])
    completion(.failure(error))
  }
  return
}

let notes = results.map(Note.init)
DispatchQueue.main.async {
  completion(.success(notes))
}

但是,此代码仅适用于加载所有用户注释。 要加载与场所相关的注释,请打开Establishment.swift,并将以下内容添加到init?(record:database :)的末尾:

if let noteRecords = record["notes"] as? [CKRecord.Reference] {
  Note.fetchNotes(for: noteRecords) { notes in
    self.notes = notes
  }
}

这将检查您的establishment是否具有引用数组,然后仅加载那些特定记录。 打开Note.swift并添加以下方法:

static func fetchNotes(for references: [CKRecord.Reference],
                       _ completion: @escaping ([Note]) -> Void) {
  let recordIDs = references.map { $0.recordID }
  let operation = CKFetchRecordsOperation(recordIDs: recordIDs)
  operation.qualityOfService = .utility
  
  operation.fetchRecordsCompletionBlock = { records, error in
    let notes = records?.values.map(Note.init) ?? []
    DispatchQueue.main.async {
      completion(notes)
    }
  }
  
  Model.currentModel.privateDB.add(operation)
}

使用CKFetchRecordsOperation可以轻松地一次加载多个记录。 您使用ID列表创建它,并设置其服务质量以确保其在后台运行。 然后,设置完成块以传递获取的注释,如果有错误,则传递一个空数组。 要运行该操作,请在私有数据库上调用add

构建并运行,然后转到Notes选项卡。 您应该看到notes已加载。

另外,转到设置noteestablishment,然后选择Notes。 您可以看到其他establishment未加载注释。

注意:到目前为止,您无需登录iCloud即可运行该应用程序。 如果您在加载注释时遇到问题,请在Settings/Sign in to your iPhone下登录。 请记住,您需要使用与登录CloudKit仪表板相同的Apple ID登录。 然后,尝试重新启动该应用程序以查看数据加载正确。

1. Troubleshooting Queries

如果数据显示不正确或根本不显示,请使用CloudKit仪表板检查样本数据。 确保所有记录都存在,已将它们添加到默认区域,并且它们具有正确的值。

如果需要重新输入数据,可以通过单击垃圾桶图标删除记录。

调试CloudKit错误可能很棘手。 CloudKit错误消息通常不包含大量信息。

要确定错误原因,您可以将错误代码与您尝试的特定数据库操作结合使用。 使用数字错误代码,查找匹配的CKErrorCode枚举。 文档中的名称和说明将有助于缩小问题的原因。

注意:有关CloudKit可以返回的错误代码的列表,请阅读CKError Reference

您也可以在 CloudKit上查看Apple的文档。

借助CloudKit,您可以通过Apple提供的出色后端API将您的应用程序提升到一个新的水平。

后记

本篇主要讲述了CloudKit一个基本使用示例,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容