iOS架构设计:揭秘MVC, MVP, MVVM以及VIPER

不要错过最新的iOS开发技能树 —— github地址

更新:在这里可以看到幻灯片
在iOS中使用MVC时感觉怪怪的?对切换到MVVM有疑虑?听说过VIPER,但不知道是否值得?

往下看,你将会找到这些问题的答案,如果还有疑问,请在评论区留言。

你将了解到在iOS环境下如何进行系统架构设计。我们将简单回顾一些流行的框架,并通过实践一些小例子来比较它们的理论。如果需要更多详细信息,请参考文章中出现的链接。

掌握设计模式可能会让人上瘾,所以要小心:你可能在阅读这篇文章之前已经问过自己一些问题,比如说:
谁应该拥有联网请求:Model还是Controller?
如何将Model传递到新View的View Model中?
谁创建了一个新的VIPER模块:Router还是Presenter?

为什么要纠结选择什么架构呢?

假如有一天,你在调试一个实现了几十种功能的庞大的类时,你会发现自己很难找到并修复你的类中的任何错误。并且,很难把这个类作为一个整体来考虑,因此,你总会忽略一些重要的细节。如果你的应用程序中已经出现了这种情况,那么很有可能:

  • 这类是UIViewController类。
  • UIViewController直接存储和处理你的数据
  • 你的UIView中几乎没有做任何事情
  • Model仅仅是一个数据结构
  • 单元测试覆盖不了任何内容

即使你遵循了苹果的指导方针并实现了苹果的MVC模式,这种情况还是会发生的,所以不要难过。苹果的MVC有点问题,这个我们稍后再谈。

让我们定义一个优秀系统结构的特征:
1.角色间职责的清晰分配(分布式)。
2.可测试性通常来自第一个特性(不必担心:使用适当的系统结构是很容易的)。
3.使用方便,维护成本低。

为什么要采用分布式

当我们想弄清楚某些事情是如何运作时,采用分布式能让我们的大脑思路清晰。如果你认为你开发越多,你的大脑就越能理解复杂性,那么你是对的。但这种能力不是线性的,很快就会达到上限。因此,克服复杂性的最简单方法是按照单一职责原则在多个实体之间划分职责。

为什么要可测试

对于那些已经习惯了单元测试的人来说,这通常不是问题,因为在添加了新的特性或者要增加一些类的复杂性之后通常会失败。这意味着测试能够降低应用程序在用户的设备上发生问题的概率,那时修复也许需要一个星期(审核)才能到达用户。

为什么要易用性

这并不需要回答,但值得一提的是,最好的代码是从未编写过的代码。因此,你拥有的代码越少,你拥有的bug就越少。这意味着编写更少代码的愿望决不能仅仅由开发人员的懒惰来解释,你不应该偏爱看起来更聪明的解决方案而忽视它的维护成本。

MV(X) 简介

现在我们在架构设计模式上有很多选择:

他们中的三个假设将应用程序的实体分成3类:

  • Models — 负责保存数据或数据访问层,操纵数据,例如“人”或“提供数据的人”。
  • Views  —  负责表示层(GUI),iOS环境下通常以“UI”前缀。
  • Controller/Presenter/ViewModel  —  Model和View之间的中介,一般负责在用户操作View时更新Model,以及当Model变化时更新View。

这种划分能让我们:

  • 更好地理解它们(如我们所知)
  • 重用它们(尤其是View和Model)
  • 独立地进行测试(单元测试)

让我们从MV(X)开始,稍后在回到VIPER:

MVC

曾经

在讨论苹果对MVC的看法之前,让我们先看看传统的MVC。

传统的MVC

在上图的情况下,View是无状态的。一旦Model被改变,Controller就会简单地渲染它。例如:网页完全加载后,一旦你按下链接,就导航到其他地方。
虽然在iOS应用用传统的MVC架构也可以实现,但这并没有多大意义,由于架构问题 ——三个实体是紧耦合的,每个实体和其他两个通信。这大大降低了可重用性——这可不是你希望在你的应用程序看到的。出于这个原因,我们甚至不想编写规范的MVC示例。

传统的MVC似乎不适用于现代IOS开发。

苹果的MVC

愿景:
Cocoa MVC

Controller是View和Model之间的中介,这样他们就解耦了。最小的可重用单元是Controller,这对我们来说是个好消息,因为我们必须有一个来放那些不适合放入Model的复杂业务逻辑的地方。
从理论上讲,它看起来很简单,但你觉得有些地方不对,对吧?你甚至听到有人说MVC全称应该改为Massive View Controller(大量的视图控制器)。此外,为View controller减负也成为iOS开发者面临的一个重要话题。
如果苹果只接受传统的MVC并改进了它,为什么会出现这种情况呢?

实际情况:
事实上的Cocoa MVC

Cocoa MVC鼓励人们编写大规模的视图控制器,而且由于它们涉及View的生命周期,所以很难说它们(View和Controller)是分离的。
虽然你仍有能力将一些业务逻辑和数据转换成Model,但你没办法将View从Controller中分离。在大多数时候所有View的责任是把事件传递给Controller。
ViewController最终演变成一个其他人的delegate和data source,通常负责分派和取消网络请求…你明白的。
你见过多少这样的代码?:

var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell
userCell.configureWithUser(user)

Cell(一个View)跟一个Model直接绑定了!所以MVC准则被违反了,但是这种情况总是发生,通常人们不会觉得它是错误的。如果你严格遵循MVC,那么你应该从Controller配置cell,而不是将Model传递到cell中,这将增大Controller。

Cocoa MVC 的全称应该是 Massive View Controller.

在单元测试之前,这个问题可能并不明显(希望在你的项目中是这样)。
由于视图控制器与视图紧密耦合,因此很难测试——因为在编写视图控制器的代码时,你必须模拟View的生命周期,从而使你的业务逻辑尽可能地与View层的代码分隔开来。
让我们看一看简单的操场例子:

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

class GreetingViewController : UIViewController { // View + Controller
    var person: Person!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
        self.greetingLabel.text = greeting
        
    }
    // 这里写布局代码
}
// 组装MVC
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
view.person = model;

MVC在可见的ViewController中进行组装

这似乎不太容易测试,对吗?
我们可以将greeting移动到新的GreetingModel类中并分别进行测试,但我们不能在不调用GreetingViewController的有关方法(viewDidLoad, didTapButton,这将会加载所有的View) 的情况下测试UIView中的显示逻辑(虽然在上面的例子中没有太多这样的逻辑)。这不利于单元测试。
事实上,在一个模拟器(如iPhone 4S)中测试UIViews并不能保证它会在其他设备良好的工作(例如iPad),所以我建议从你的单元测试Target中删除“Host Application”选项,然后脱离应用程序运行你的测试。

View和Controller之间的交互在单元测试中是不可测试的。

如此看来,Cocoa MVC 模式 似乎是一个很糟糕的选择。但是让我们根据文章开头定义的特性来评估它:

  • 职责拆分 — View和Model实现了分离,但是View与Controller仍是紧耦合。
  • 可测性 — 由于模式的原因,你只能测试你的Model。
  • 易用性 — 相比于其他模式代码量最少。此外,每个人都熟悉它,即使经验不太丰富的开发人员也能够维护它。

如果你不愿意在项目的架构上投入太多的时间,那么Cocoa MVC 就是你应该选择的模式。而且你会发现用其他维护成本较高的模式开发小的应用是一个致命的错误。

Cocoa MVC是开发速度最快的架构模式。

MVP

MVP 实现了Cocoa的MVC的愿景
Passive View 变体 — MVP

这看起来不正是苹果的MVC吗?是的,它的名字是MVP(Passive View variant,被动视图变体)。等等...这是不是意味着苹果的MVC实际上是MVP?不,不是这样。如果你仔细回忆一下,View是和Controller紧密耦合的,但是MVP的中介Presenter并没有对ViewController的生命周期做任何改变,因此View可以很容易的被模拟出来。在Presenter中根本没有和布局有关的代码,但是它却负责更新View的数据和状态。

假如告诉你,UIViewController就是View呢?

在MVP中,UIViewController的子类实际上是Views而不是Presenters。这种模式的可测试性得到了极大的提高,付出的代价是开发速度的一些降低,因为必须要做一些手动的数据和事件绑定,从下例中可以看出:

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

protocol GreetingView: class {
    func setGreeting(greeting: String)
}

protocol GreetingViewPresenter {
    init(view: GreetingView, person: Person)
    func showGreeting()
}

class GreetingPresenter : GreetingViewPresenter {
    unowned let view: GreetingView
    let person: Person
    required init(view: GreetingView, person: Person) {
        self.view = view
        self.person = person
    }
    func showGreeting() {
        let greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
        self.view.setGreeting(greeting)
    }
}

class GreetingViewController : UIViewController, GreetingView {
    var presenter: GreetingViewPresenter!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        self.presenter.showGreeting()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    // 布局代码
}
// 装配 MVP
let model = Person(firstName: "David", lastName: "Blaine")
let view = GreetingViewController()
let presenter = GreetingPresenter(view: view, person: model)
view.presenter = presenter
装配问题的重要说明

MVP是第一个揭示装配问题的模式,因为它有三个独立的层。既然我们不希望View和Model耦合,那么在显示的View Controller(其实就是View)中处理这种协调的逻辑就是不正确的,因此我们需要在其他地方来做这些事情。例如,我们可以做基于整个App范围内的路由服务,由它来负责执行协调任务,以及View到View的展示。这不仅仅是在MVP模式中必须处理的问题,同时也存在于以下集中方案中。

让我们看看MVP的特点:

  • 职责拆分 — 我们将最主要的任务划分到Presenter和Model,而View的功能较少(虽然上述例子中Model的任务也并不多)。
  • 可测性 — 非常好,基于一个功能简单的View层,可以测试大多数业务逻辑
  • 易用性 — 在我们上边不切实际的简单的例子中,代码量是MVC模式的2倍,但同时MVP的概念却非常清晰。

iOS 中的MVP意味着可测试性强、代码量大。

MVP

关于Bindings和Hooters

还有一些其他形态的MVP —— Supervising Controller MVP(监听Controller的MVP)。这个变体的变化包括View和Model之间的直接绑定,但是Presenter(Supervising Controller)仍然来管理来自View的动作事件,同时也能胜任对View的更新。

监听Controller的MVP

但是我们之前就了解到,模糊的职责划分是非常糟糕的,更何况将View和Model紧密的联系起来。这和Cocoa的桌面开发的原理有些相似。

和传统的MVC一样,写这样的例子没有什么价值,故不再给出。

MVVM

最新且是最伟大的MV(X)系列的一员

MVVM架构是MV(X)系列最新的成员,我们希望它已经考虑到MV(X)系列中之前已经出现的问题。
从理论层面来讲Model-View-ViewModel看起来不错,我们已经非常熟悉View和Model,以及Meditor(中介),在这里它叫做View Model。

MVVM

它和MVP模式看起来很像:

  • MVVM也将ViewController视作View
  • 在View和Model之间没有耦合

此外,它还有像Supervising版本的MVP那样的绑定功能,但这个绑定不是在View和Model之间而是在View和ViewModel之间。

那么在iOS中ViewModel到底代表了什么?它基本上就是UIKit下的独立控件以及控件的状态。ViewModel调用会改变Model同时会将Model的改变更新到自身并且因为我们绑定了View和ViewModel,第一步就是相应的更新状态。

绑定

我在MVP部分已经提到这点了,但是在这里我们来继续讨论。
绑定是从OS X开发中衍生出来的,但是我们没有在iOS开发中使用它们。当然我们有KVO通知,但它们没有绑定方便。
如果我们自己不想自己实现,那么我们有两种选择:

事实上,尤其是最近,你听到MVVM就会想到ReactiveCoca,反之亦然。尽管通过简单的绑定来使用MVVM是可实现的,但是ReactiveCocoa(或其变体)却能更好的发挥MVVM的特点。

函数响应式框架有一个残酷的事实:强大的能力来自于巨大的责任。当你开始使用Reactive的时候有很大的可能就会把事情搞砸。换句话来说就是,如果发现了一些错误,调试出这个bug可能会花费大量的时间,看下函数调用栈:


Reactive Debugging

在我们简单的例子中,FRF框架和KVO被禁用,取而代之地我们直接去调用showGreeting方法更新ViewModel,以及通过greetingDidChange 回调函数使用属性。

import UIKit

struct Person { // Model
    let firstName: String
    let lastName: String
}

protocol GreetingViewModelProtocol: class {
    var greeting: String? { get }
    var greetingDidChange: ((GreetingViewModelProtocol) -> ())? { get set } // function to call when greeting did change
    init(person: Person)
    func showGreeting()
}

class GreetingViewModel : GreetingViewModelProtocol {
    let person: Person
    var greeting: String? {
        didSet {
            self.greetingDidChange?(self)
        }
    }
    var greetingDidChange: ((GreetingViewModelProtocol) -> ())?
    required init(person: Person) {
        self.person = person
    }
    func showGreeting() {
        self.greeting = "Hello" + " " + self.person.firstName + " " + self.person.lastName
    }
}

class GreetingViewController : UIViewController {
    var viewModel: GreetingViewModelProtocol! {
        didSet {
            self.viewModel.greetingDidChange = { [unowned self] viewModel in
                self.greetingLabel.text = viewModel.greeting
            }
        }
    }
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self.viewModel, action: "showGreeting", forControlEvents: .TouchUpInside)
    }
    // layout code goes here
}
// 装配 MVVM
let model = Person(firstName: "David", lastName: "Blaine")
let viewModel = GreetingViewModel(person: model)
let view = GreetingViewController()
view.viewModel = viewModel

让我们再来看看关于三个特性的评估:

  • 职责拆分 — 在例子中并不是很清晰,但是事实上,MVVM的View要比MVP中的View承担的责任多。因为前者通过ViewModel的设置绑定来更新状态,而后者只监听Presenter的事件但并不会对自己有什么更新。
  • 可测性 — ViewModel不知道关于View的任何事情,这允许我们可以轻易的测试ViewModel。同时View也可以被测试,但是由于属于UIKit的范畴,对他们的测试通常会被忽略。
  • 易用性 — 在我们例子中的代码量和MVP的差不多,但是在实际开发中,我们必须把View中的事件指向Presenter并且手动的来更新View,如果使用绑定的话,MVVM代码量将会小的多。

MVVM是非常有吸引力的,因为它集合了上述方法的优点,并且由于在View层的绑定,它并不需要其他附加的代码来更新View,尽管这样,可测试性依然很强。

VIPER

把LEGO架构经验迁移到iOS app的设计

VIPER是我们最后要介绍的,由于不是来自于MV(X)系列,它具备一定的趣味性。

到目前为止,你必须同意划分责任的粒度是很好的选择。VIPER在责任划分层面进行了迭代,VIPER分为五个层次:

VIPER
  • 交互器(Interactor) — 包括关于数据和网络请求的业务逻辑,例如创建一个实体(Entities),或者从服务器中获取一些数据。为了实现这些功能,需要使用服务、管理器,但是他们并不被认为是VIPER架构内的模块,而是外部依赖。
  • 展示器(Presenter) — 包含UI层面(但UIKit独立)的业务逻辑以及在交互器(Interactor)层面的方法调用。
  • 实体(Entities) — 普通的数据对象,不属于数据访问层,因为数据访问属于交互器(Interactor)的职责。
  • 路由器(Router) — 用来连接VIPER的各个模块。

基本上,VIPER的模块可以是一个屏幕或者用户使用应用的整个过程 —— 例如认证过程,可以由一屏完成或者需要几步才能完成。你想让模块多大,这取决于你。

当我们把VIPER和MV(X)系列作比较时,我们会在职责划分方面发现一些不同:

  • Model(数据交互)逻辑以实体(Entities)为单位拆分到交互器(Interactor)中。
  • Controller/Presenter/ViewModel 的UI展示方面的职责移到了Presenter中,但是并没有数据转换相关的操作。
  • VIPER 是第一个通过路由器(Router)实现明确的地址导航的模式。

找到一个适合的方法来实现路由对于iOS应用是一个挑战,MV(X)系列并未涉及这一问题。

例子中并不包含路由和模块之间的交互,所以和MV(X)系列部分架构一样不再给出例子。

import UIKit

struct Person { // Entity (usually more complex e.g. NSManagedObject)
    let firstName: String
    let lastName: String
}

struct GreetingData { // Transport data structure (not Entity)
    let greeting: String
    let subject: String
}

protocol GreetingProvider {
    func provideGreetingData()
}

protocol GreetingOutput: class {
    func receiveGreetingData(greetingData: GreetingData)
}

class GreetingInteractor : GreetingProvider {
    weak var output: GreetingOutput!
    
    func provideGreetingData() {
        let person = Person(firstName: "David", lastName: "Blaine") // usually comes from data access layer
        let subject = person.firstName + " " + person.lastName
        let greeting = GreetingData(greeting: "Hello", subject: subject)
        self.output.receiveGreetingData(greeting)
    }
}

protocol GreetingViewEventHandler {
    func didTapShowGreetingButton()
}

protocol GreetingView: class {
    func setGreeting(greeting: String)
}

class GreetingPresenter : GreetingOutput, GreetingViewEventHandler {
    weak var view: GreetingView!
    var greetingProvider: GreetingProvider!
    
    func didTapShowGreetingButton() {
        self.greetingProvider.provideGreetingData()
    }
    
    func receiveGreetingData(greetingData: GreetingData) {
        let greeting = greetingData.greeting + " " + greetingData.subject
        self.view.setGreeting(greeting)
    }
}

class GreetingViewController : UIViewController, GreetingView {
    var eventHandler: GreetingViewEventHandler!
    let showGreetingButton = UIButton()
    let greetingLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.showGreetingButton.addTarget(self, action: "didTapButton:", forControlEvents: .TouchUpInside)
    }
    
    func didTapButton(button: UIButton) {
        self.eventHandler.didTapShowGreetingButton()
    }
    
    func setGreeting(greeting: String) {
        self.greetingLabel.text = greeting
    }
    
    // 布局代码
}
// 装配 VIPER 模块(不包含路由)
let view = GreetingViewController()
let presenter = GreetingPresenter()
let interactor = GreetingInteractor()
view.eventHandler = presenter
presenter.view = view
presenter.greetingProvider = interactor
interactor.output = presenter

让我们再来评估一下特性:

  • 职责拆分 — 毫无疑问,VIPER是任务划分中的佼佼者。
  • 可测性 — 不出意外地,更好的分布性就有更好的可测试性。
  • 易用性 — 最后你可能已经猜到了维护成本方面的问题。你必须为很小功能的类写出大量的接口。
什么是LEGO

当使用VIPER时,你可能想像用乐高积木来搭建一个城堡,这个想法可能存在一些问题。也许,现在就应用VIPER架构还为时过早,考虑一些更为简单的模式反而会更好。一些人会忽略这些问题,大材小用。假定他们笃信VIPER架构会在未来给他们的应用带来一些好处,虽然现在维护起来确实是有些费劲。如果你也持这样的观点,我为你推荐 Generamba 这个用来搭建VIPER架构的工具。虽然我个人感觉这是在用高射炮打蚊子。

总结

我们研究了几种架构模式,希望你能找到一些困扰你的问题的答案。但毫无疑问通过阅读这篇文章你应该已经认识到了没有绝对的解决方案。所以架构模式的选择需要根据实际情况进行利弊分析。
因此,在同一应用程序中混合架构是很自然的。例如:你开始的时候使用MVC,然后突然意识到一个页面在MVC模式下的变得越来越难以维护,然后就切换到MVVM架构,但是仅仅针对这一个页面。并没有必要对哪些MVC模式下运转良好的页面进行重构,因为二者是可以并存的。

让一切尽可能简单,但不是愚蠢。  ——  阿尔伯特·爱因斯坦

译自:iOS Architecture Patterns

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

推荐阅读更多精彩内容