iOS与多线程(六) —— GCD之一个简单应用示例(二)

版本记录

版本号 时间
V1.0 2018.08.20

前言

信号量机制是多线程通信中的比较重要的一部分,对于NSOperation可以设置并发数,但是对于GCD就不能设置并发数了,那么就只能靠信号量机制了。接下来这几篇就会详细的说一下并发机制。感兴趣的可以看这几篇文章。
1. iOS与多线程(一) —— GCD中的信号量及几个重要函数
2. iOS与多线程(二) —— NSOperation实现多并发之创建任务
3. iOS与多线程(三) —— NSOperation实现多并发之创建队列和开启线程
4. iOS与多线程(四) —— NSOperation的串并行和操作依赖
5. iOS与多线程(五) —— GCD之一个简单应用示例(一)

开始

本篇主要将深入研究高级GCD概念,包括调度组,取消调度块,异步测试技术和调度源。

如果您继续学习,您可以从上一篇的示例项目中选择您上次停下的地方。

运行应用程序,点击+,然后选择Le Internet以添加互联网照片。 您可能会注意到在图像下载完成之前会弹出下载完成alert消息:

这个就是首先你需要解决的问题。


Dispatch Groups - 调度组

打开PhotoManager.swift并查看downloadPhotos(withCompletion :)

func downloadPhotos(
    withCompletion completion: BatchPhotoDownloadingCompletionClosure?) {
  var storedError: NSError?
  for address in [PhotoURLString.overlyAttachedGirlfriend,
                  PhotoURLString.successKid,
                  PhotoURLString.lotsOfFaces] {
                    let url = URL(string: address)
                    let photo = DownloadPhoto(url: url!) { _, error in
                      if error != nil {
                        storedError = error
                      }
                    }
                    PhotoManager.shared.addPhoto(photo)
    }
    
    completion?(storedError)
}

传递给方法的completion闭包触发alert弹窗。您可以在下载照片的for循环后调用此方法。在调用闭包之前,您错误地认为下载已完成。

您通过调用DownloadPhoto(url :)开始下载照片。此调用立即返回,但实际下载是异步发生的。因此,当完成运行时,无法保证所有下载都已完成。

你想要的是downloadPhotos(withCompletion :)在所有照片下载任务完成后调用它的completion闭包。如何监控这些并发异步事件来实现这一目标?使用当前的方法,您不知道任务何时完成,并且可以按任何顺序完成。

好消息!这正是调度组(dispatch groups )存在的原因。使用调度组,您可以将多个任务组合在一起,并等待它们完成,或者在完成后收到通知。任务可以是异步的或同步的,甚至可以在不同的队列上运行。

DispatchGroup管理调度组。你将首先看看它的wait方法。这将阻塞当前线程,直到所有组的排队任务完成。

PhotoManager.swift中,用以下代码替换downloadPhotos(withCompletion :)中的代码:

// 1
DispatchQueue.global(qos: .userInitiated).async {
  var storedError: NSError?

  // 2
  let downloadGroup = DispatchGroup()
  for address in [PhotoURLString.overlyAttachedGirlfriend, 
                  PhotoURLString.successKid,
                  PhotoURLString.lotsOfFaces] {
    let url = URL(string: address)

    // 3
    downloadGroup.enter()
    let photo = DownloadPhoto(url: url!) { _, error in
      if error != nil {
        storedError = error
      }   

      // 4
      downloadGroup.leave()
    }   
    PhotoManager.shared.addPhoto(photo)
  }   

  // 5      
  downloadGroup.wait()

  // 6
  DispatchQueue.main.async {
    completion?(storedError)
  }   
}

以下是代码逐步执行的操作:

  • 1)由于您正在使用阻塞当前线程的同步wait方法,因此使用async将整个方法放入后台队列以确保不阻止主线程。
  • 2)创建一个新的调度组。
  • 3)调用enter()手动通知组任务已启动。您必须使用和leave()数量相等进行调用enter(),否则您的应用程序将崩溃。
  • 4)在这里,您通知小组这项工作已完成。
  • 5)在等待任务完成时调用wait()来阻塞当前线程。这等待会持续,因为照片创建任务总是完成。您可以使用wait(timeout :)来指定超时,并在指定时间后等待挽救。
  • 6)此时,您可以保证所有图像任务都已完成或超时。然后,您回调主队列以运行完成关闭。

Build并运行应用程序。通过Le Internet选项下载照片,并验证在下载所有图像之前alert弹窗未显示。

注意:如果网络活动发生得太快以至于无法识别何时应该调用完成闭包并且您在设备上运行应用程序,则可以通过在iOS的Settings应用程序的Developer部分中切换某些网络设置来确保这确实有效。 只需转到Network Link Conditioner部分,启用它,然后选择配置文件。Very Bad Network是一个不错的选择。如果您在模拟器上运行,则可以使用 Network Link Conditioner included in the Advanced Tools for Xcode来更改网络速度。 这是一个很好的工具,因为它会让你在连接速度低于最佳状态时意识到你的应用程序会发生什么。

Dispatch groups是所有类型队列的很好的选择。 如果您因为不想保留主线程而同步等待完成所有工作,那么您应该注意在主队列上使用调度组。 但是,异步模型是一种在多个长时间运行的任务完成后更新UI的一个不错的方法,例如网络调用。

您当前的解决方案是好的,但一般来说,最好尽可能避免阻塞线程。 您的下一个任务是重写相同的方法,以便在完成所有下载后异步通知您。


Dispatch Groups, Take 2 - 调度组(2)

异步调度到另一个队列然后使用wait阻塞工作是不可取的。 幸运的是,有一种更好的方法。 DispatchGroup可以在所有组的任务完成时通知您。

仍然在PhotoManager.swift中,用以下内容替换downloadPhotos(withCompletion :)中的代码:

// 1
var storedError: NSError?
let downloadGroup = DispatchGroup()
for address in [PhotoURLString.overlyAttachedGirlfriend,
                PhotoURLString.successKid,
                PhotoURLString.lotsOfFaces] {
  let url = URL(string: address)
  downloadGroup.enter()
  let photo = DownloadPhoto(url: url!) { _, error in
    if error != nil {
      storedError = error
    }   
    downloadGroup.leave()
  }   
  PhotoManager.shared.addPhoto(photo)
}   

// 2    
downloadGroup.notify(queue: DispatchQueue.main) {
  completion?(storedError)
}

下面进行详细拆分:

  • 1)在这个新实现中,您不需要在异步(async)调用中包围该方法,因为您没有阻塞主线程。
  • 2)notify(queue:work :)用作异步完成闭包。 它在组中没有剩余项目时运行。 您还指定要计划在主队列上调度完成工作。

这是一种更清洁的方式来处理这个特定的工作,因为它不会阻止任何线程。

Build并运行应用程序。 确认在下载所有互联网照片后仍然显示下载完成alert弹窗:


Concurrency Looping - 并发循环

有了所有这些新工具,你可能应该解决所有问题,对吧!?

看看PhotoManager中的downloadPhotos(withCompletion :)。 您可能会注意到那里有一个for循环,循环三次迭代并下载三个单独的图像。 你的工作是看你是否可以同时运行这个for循环来尝试加快速度。

这是DispatchQueue.concurrentPerform(iterations:execute:)的工作。 它与for循环的工作方式类似,因为它同时执行不同的迭代。 它是同步的,只有在完成所有工作后才返回。

在确定给定工作量的最佳迭代次数时,您必须小心。 每次迭代的许多迭代和少量工作都会产生如此多的开销,从而抵消了使调用并发的任何好处或者优势。 称为跨步(striding)的技术可以帮助你。 Striding允许您为每次迭代执行多项工作。

什么时候适合使用DispatchQueue.concurrentPerform(iterations:execute :)? 您可以排除串行队列,因为那里没有任何好处 - 您也可以使用普通的for循环。 对于包含循环的并发队列来说,这是一个不错的选择,特别是如果您需要跟踪进度。

PhotoManager.swift中使用以下代码替换downloadPhotos(withCompletion :)中的代码:

var storedError: NSError?
let downloadGroup = DispatchGroup()
let addresses = [PhotoURLString.overlyAttachedGirlfriend,
                 PhotoURLString.successKid,
                 PhotoURLString.lotsOfFaces]
let _ = DispatchQueue.global(qos: .userInitiated)
DispatchQueue.concurrentPerform(iterations: addresses.count) { index in
  let address = addresses[index]
  let url = URL(string: address)
  downloadGroup.enter()
  let photo = DownloadPhoto(url: url!) { _, error in
    if error != nil {
      storedError = error
    }
    downloadGroup.leave()
  }
  PhotoManager.shared.addPhoto(photo)
}
downloadGroup.notify(queue: DispatchQueue.main) {
  completion?(storedError)
}

您使用DispatchQueue.concurrentPerform(iterations:execute :)替换前者for循环以处理并发循环。

这个实现包括一段奇怪的代码:let _ = DispatchQueue.global(qos:.userInitiated)。 调用此方法会导致GCD使用具有.userInitiated服务质量的队列进行并发调用。

Build并运行应用程序。 验证Internet下载功能是否仍然正常运行:

在设备上运行此新代码有时会产生稍微快一些的结果。但这一切都值得吗?

实际上,在这种情况下,它是不值得的。原因如下:

  • 与首先运行for循环相比,您可能创建了更多的并行运行线程的开销,您应该使用DispatchQueue.concurrentPerform(iterations:execute :)来迭代非常大的集合以及适当的跨度长度。
  • 您创建应用程序的时间有限 - 不要浪费时间预先优化您不知道的代码。如果你要优化某些东西,那么优化一些值得注意的东西,值得花时间。通过在Instruments中分析您的应用程序,找到执行时间最长的方法。
  • 通常,优化代码会使您的代码对您自己以及其他开发人员变得更加复杂,确保增加的复杂度是值得的。

请记住,不要因为优化而疯狂。你只会让自己和那些不得不研究你的代码的人变得更难。


Canceling Dispatch Blocks - 取消调度块

到目前为止,您还没有看到允许您取消排队任务的代码。 这是DispatchWorkItem表示的dispatch block objects成为焦点的地方。 请注意,您只能在DispatchWorkItem到达队列头部并开始执行之前取消它。

让我们通过从Le Internet开始几个图像的下载任务然后取消其中一些来证明这一点。

仍在PhotoManager.swift中,将downloadPhotos(withCompletion :)中的代码替换为以下代码:

var storedError: NSError?
let downloadGroup = DispatchGroup()
var addresses = [PhotoURLString.overlyAttachedGirlfriend,
                 PhotoURLString.successKid,
                 PhotoURLString.lotsOfFaces]

// 1
addresses += addresses + addresses

// 2
var blocks: [DispatchWorkItem] = []

for index in 0..<addresses.count {
  downloadGroup.enter()

  // 3
  let block = DispatchWorkItem(flags: .inheritQoS) {
    let address = addresses[index]
    let url = URL(string: address)
    let photo = DownloadPhoto(url: url!) { _, error in
      if error != nil {
        storedError = error
      }
      downloadGroup.leave()
    }
    PhotoManager.shared.addPhoto(photo)
  }
  blocks.append(block)

  // 4
  DispatchQueue.main.async(execute: block)
}

// 5
for block in blocks[3..<blocks.count] {

  // 6
  let cancel = Bool.random()
  if cancel {

    // 7
    block.cancel()

    // 8
    downloadGroup.leave()
  }
}

downloadGroup.notify(queue: DispatchQueue.main) {
  completion?(storedError)
}

以下是逐步完成上述代码的步骤:

  • 1)您展开地址(addresses)数组以保存每个图像的三个副本。
  • 2)初始化块(blocks)数组以保存调度块对象供以后使用。
  • 3)您创建一个新的DispatchWorkItem。传入flags参数以指定块应从您将其分派到的队列中继承其Quality of Service类。然后,定义要在闭包中执行的工作。
  • 4)您将块异步调度到主队列。对于此示例,使用主队列可以更轻松地取消选择块,因为它是一个串行队列。设置调度块的代码已在主队列上执行,因此可以保证下载块将在以后执行。
  • 5)通过切片块blocks数组跳过前三个下载块。
  • 6)在这里,您使用Bool.random()在true和false之间随机选择。这就像掷硬币一样。
  • 7)如果随机值为true,则取消该块。这只能取消仍在队列中但尚未开始执行的块。您无法在执行过程中取消块。
  • 8)在这里,您需要记住从调度组中删除已取消的块。

Build并运行应用程序,然后从Le Internet添加图像。你会看到该应用程序现在下载了三个以上的图像。每次重新运行应用程序时,额外图像的数量都会发生变化。您在开始之前取消队列中的一些其他图像下载。

这是一个特意做的例子,但它很好地说明了如何使用和取消调度块。

调度块可以执行更多操作,因此请务必查看Apple's documentation


Miscellaneous GCD Fun - 其他GCD乐趣

还有更多! 这里有一些额外的功能。 虽然您几乎不会频繁使用这些工具,但它们在正确的情况下可以提供极大的帮助。

1. Testing Asynchronous Code - 测试异步代码

这可能听起来像一个疯狂的想法,但你知道Xcode有测试功能吗? 我知道,有时我喜欢假装它不存在,但在代码中构建复杂的关系时,编写和运行测试很重要。

Xcode测试都包含在XCTestCase的子类中,并且是签名以test开头的任何方法。 Tests在主线程上运行,因此您可以假设每个测试都以串行方式进行。

一旦给定的测试方法完成,Xcode就会认为测试已经完成并继续进行下一个测试。 这意味着在下一个测试运行时,前一个测试的任何异步代码都将继续运行。

网络代码通常是异步的,因为您不希望在执行网络提取时阻塞主线程。 这与测试方法完成后测试完成的事实相结合,可能使测试网络代码变得困难。

我们来简要介绍一下如何使用信号量(semaphores)来测试异步代码。

2. Semaphores - 信号量

信号量是一个老式的线程概念,由Edsger W. Dijkstra引入。 信号量是一个复杂的话题,因为它们建立在操作系统功能的复杂性之上。

如果您想了解有关信号量的更多信息,请查看有关信号量理论的detailed discussion。 如果你是学术类型,你可能想看看Dining Philosophers Problem,这是一个使用信号量的经典软件开发问题。

打开GooglyPuffTests.swift并使用以下代码替换downloadImageURL(withString :)中的代码:

let url = URL(string: urlString)

// 1
let semaphore = DispatchSemaphore(value: 0)
let _ = DownloadPhoto(url: url!) { _, error in
  if let error = error {
    XCTFail("\(urlString) failed. \(error.localizedDescription)")
  }

  // 2
  semaphore.signal()
}
let timeout = DispatchTime.now() + .seconds(defaultTimeoutLengthInSeconds)

// 3
if semaphore.wait(timeout: timeout) == .timedOut {
  XCTFail("\(urlString) timed out")
} 

以下是信号量在上面的代码中的工作原理:

  • 1)您创建一个信号量并设置其起始值。 这代表可以访问信号量而不需要增加信号量的事物数量(请注意,递增信号量称为发信号)。
  • 2)您在完成闭包中发出信号量的信号。 这会增加信号量计数,并表示信号量可供其他需要它的资源使用。
  • 3)你等待信号量,给定超时。 此调用会阻塞当前线程,直到信号量发出信号。 此函数的非零返回码表示超时时间已到期。 在这种情况下,测试失败,因为网络返回的时间不应超过10秒。

如果您具有默认键绑定,则从菜单中选择Product ▸ Test或使用Command-U运行测试。 他们都应该及时取得成功:

停止连接并再次运行测试。 如果您在设备上运行,请将其置于飞行(airplane)模式。 如果您在模拟器上运行,则只需关闭连接即可。 测试在10秒后完成,可以看见失败结果。 很棒,它有效!

这些都是相当简单的测试,但如果您正在与服务器团队合作,这些基本测试可以防止一轮指责谁应该为最新的网络问题负责。

注意:在代码中实现异步测试时,请先查看XCTWaiter,然后再转到这些低级API。 XCTWaiter的API更好,为异步测试提供了许多强大的技术。

3. Dispatch Sources

Dispatch sources是GCD特别有趣的功能。 您可以使用调度源来监视某种类型的事件。 事件可以包括Unix信号,文件描述符,Mach端口,VFS节点和其他模糊的东西。

在设置调度源时,您可以告诉它要监视的事件类型以及应在其上执行事件处理程序块的调度队列。 然后,您将事件处理程序分配给调度源。

创建后,调度源将以挂起状态启动。 这允许您执行所需的任何其他配置,例如设置事件处理程序。 配置调度源后,必须将其恢复以开始处理事件。

在本文中,您将以一种相当特殊的方式来尝试使用调度源:监视应用程序何时进入调试模式(debug mode)

打开PhotoCollectionViewController.swift并在backgroundImageOpacity全局属性声明下面添加以下内容:

// 1
#if DEBUG

  // 2
  var signal: DispatchSourceSignal?

  // 3
  private let setupSignalHandlerFor = { (_ object: AnyObject) in
    let queue = DispatchQueue.main

    // 4
    signal =
      DispatchSource.makeSignalSource(signal: SIGSTOP, queue: queue)
        
    // 5
    signal?.setEventHandler {
      print("Hi, I am: \(object.description!)")
    }

    // 6
    signal?.resume()
  }
#endif

下面进行详细说明:

  • 1)您只能在DEBUG模式下编译此代码,以防止“interested parties”获得对您的应用程序的大量洞察。DEBUG是通过在Project Settings -> Build Settings -> Swift Compiler - Custom Flags -> Other Swift Flags -> Debug下添加-D DEBUG来定义的。它应该已经在启动项目中设置。
  • 2)您声明一个DispatchSourceSignal类型的signal变量,用于监视Unix信号。
  • 3)您创建一个分配给setupSignalHandlerFor全局变量的块,您将用于一次性设置调度源。
  • 4)在这里设置signal。您表示您有兴趣监视SIGSTOP Unix信号并处理主队列上收到的事件 - 您很快就会发现原因。
  • 5)如果成功创建了调度源,则会注册每当收到SIGSTOP信号时调用的事件处理程序闭包。您的处理程序打印包含类描述的消息。
  • 6)默认情况下,所有源都以挂起状态启动。在这里,您告诉调度源恢复,以便它可以开始监视事件。

将以下代码添加到对super.viewDidLoad()调用正下方的viewDidLoad()中:

#if DEBUG
  setupSignalHandlerFor(self)
#endif

此代码调用调度源的初始化代码。

Build并运行应用程序。 通过点击Xcode调试器中的暂停然后播放按钮,暂停程序执行并立即恢复应用程序:

看一下控制台,你会看到这些输出:

Hi, I am: <GooglyPuff.PhotoCollectionViewController: 0x7fbf0af08a10>

你的app现在可以调试了! 这真是太棒了,但是你会在现实生活中如何使用它?

您可以使用它来调试对象并在您恢复应用程序时显示数据。 当恶意攻击者将调试器附加到您的应用程序时,您还可以为应用程序提供自定义安全逻辑以保护自身(或用户的数据)。

一个有趣的想法是使用此方法作为堆栈跟踪工具来查找要在调试器中操作的对象。

想想这种情况一秒钟。 当你突然停止调试器时,你几乎从不在所需的堆栈帧上。 现在,您可以随时停止调试器,并在所需位置执行代码。 如果您想在应用程序中从调试器访问繁琐的点执行代码,这非常有用。 试试看!

在刚刚添加的setupSignalHandlerFor块内的print()语句上放置一个断点。

在调试器中暂停,然后重新开始。 该应用程序将达到您添加的断点。 您现在深入了解PhotoCollectionViewController方法的深度。 现在,您可以访问PhotoCollectionViewController的实例到您的内容。 非常方便!

注意:如果您还没有注意到调试器中有哪些线程,请立即查看它们。 主线程将始终是第一个线程,然后是libdispatch,GCD的协调器,作为第二个线程。 之后,线程计数和剩余线程取决于应用程序到达断点时硬件正在执行的操作。

在控制台输入下面内容:

expr object.navigationItem.prompt = "WOOT!"

Xcode调试器有时可能不合作。 如果你收到消息:

error: use of unresolved identifier 'self'

然后你必须以艰难的方式解决LLDB中的错误。 首先记下调试区域中object的地址:

po object

然后通过在调试器中运行以下命令手动将值转换为所需的类型,将0xHEXADDRESS替换为输出的地址:

expr let $vc = unsafeBitCast(0xHEXADDRESS, to: GooglyPuff.PhotoCollectionViewController.self)
expr $vc.navigationItem.prompt = "WOOT!"

如果这不起作用,幸运的是 - 你在LLDB中遇到了另一个错误! 在这种情况下,您可能需要再次尝试构建和运行应用程序。

成功运行此命令后,请继续执行应用程序。 你会看到以下内容:

使用此方法,您可以对UI进行更新,查询类的属性,甚至执行方法 - 所有这些都可以在不必重新启动应用程序的情况下进入特殊的工作流状态。 很简约。

除了GCD,通常,如果您使用简单的fire-and-forget任务,最好使用GCD。 Operation提供了更好的控制,处理最大并发操作的实现,以及以速度为代价的更加面向对象的范例。

请记住,除非您有特定的理由要求降低,否则请始终尝试使用更高级别的API。

后记

本篇主要讲述了,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容