iOS Apprentice中文版-从0开始学iOS开发-第十七课

编辑已有的待办事项名称

在app中添加新的待办项目到列表中对你是一个巨大的进步,但是通常伴随着新增还有其他两个操作需要实现,那就是:

1、删除待办项目(通过轻扫某一行删除,我们已经在前面实现了)

2、编辑已存在的待办项目

编辑已存在的项目是非常有用的,比如你想对待办项目重命名或者你打错字了需要修改,都需要用到编辑功能。

你也许可以做一个全新的界面来完成这个功能,但是你会发现这个功能和新增待办事项界面没有什么区别,唯一的不同就是一开始文本框中存在字符,而新增时一开始文本框是空的。

所以我们来重用新增待办事项界面,让它具备修改已存在项目的名称的能力。

实现后的预览

当用户点击Done按钮后你做的并不是创建一个新的CheclistItem对象,而是简单的修改已存在的CheclistItem对象的文本。

你同时也要通知委托这里做出了修改,这样委托就会更新列表中相应的table view cell。

练习:为了实现这个功能,我们都需要做哪些改造,你能做个列表出来吗?

答案:1、编辑项目时界面需要被重命名为Edit Item(现在是Add Item)
2、你必须能读取到一条存在的ChecklistItem对象。
3、你必须把这个ChecklistItem对象的文本放入文本框。
4、当用户点击Done按钮时,你不应该添加新的ChecklistItem对象,而是更新被编辑的那个。

这里有一些用户界面问题需要处理,比如用户如何打开编辑项目的界面?多数app都是通过直接点击某一行完成这个动作,但是在我们的这个app中,点击动作已经被占用了,它用来控制对勾符号的开关。

为了解决这个问题,你需要先修改一下UI设计。

当一行具备两个功能的时候,标准的做法是使用‘详细信息按钮(detail disclosure button)’来完成第二个功能。

详细信息按钮

点击某一行时,还是执行原来的动作,在我们的app中就是控制对勾符号的开关,而当点击详细信息按钮时,打开编辑项目界面。

⚠️:还有一种可选择的方法是,只有点击左边对勾符号的区域时,用于开关对勾符号,而点击这一行的其他地方时,打开编辑项目界面。
还有一些app是这样做的,你可以把整个屏幕触发为可编辑状态,然后可以逐条编辑项目。具体使用哪种方法依赖于哪种方法最有利于你的数据模型。

打开故事模版选定table view cell,然后进入到属性检查器,找到Accessory选项并且选择Detail Disclosure(注意:是Detail Disclosure,不是Detail)选项。

这时对勾符号将被一个蓝色感叹号+一个大于号到按钮图标替代掉。这就意味着你要重新选择一个地方放置对勾符号。

拖拽一个新的label到cell中去,并且做出如下设置:

文本 :√(option+v可以输入这个符号)

字体:Helvetica Neue,bold,大小22

Tag:1001

如果option+v没有这个符号,你可以从Xcode顶部菜单中选择Edit->Emoji & Symbols。然后在弹出窗口的搜索栏中输入“check”,选择一个对勾符号,或者选择任何你喜欢的图形。(注意,在进行此操作时,先双击标签,处于可以输入文本的状态下再进行,另外,这些特殊符号也许在部分手机上无法正确显示)

插入Emoji符号

重新调整两个标签的位置和大小,不要互相覆盖,也不要覆盖到右边的蓝色感叹号按钮上去。

重新设计后的prototype cell应该是这种样子的:

新的cell设计

打开ChecklistViewController.swift,将configureCheckmark(for:with:)改变为:

func configureCheckmark(for cell: UITableViewCell,with item: ChecklistItem) {
        let label = cell.viewWithTag(1001) as! UILabel
        
        if item.checked {
            label.text = "√"
        } else {
            label.text = ""
        }
    }

现在我们不修改cell的accessory属性了,而是修改新增的label的文本。

运行app,你会看到对勾符号从右边转移到左边了,同时这里多了一个蓝色感叹号的详细信息按钮在右边。点击某一行会开关对勾符号,而点击详细信息按钮则不会。

运行后的效果

现在,我们开始着手处理点击详细信息按钮后打开编辑界面的事情。这非常简单,因为界面建造器允许你为详细信息按钮添加一个转场。

打开故事模版。选定table view cell并且按住ctrl拖拽到旁边的Navigation Controller上,在弹出窗口中选择Accessory Action分节下的Present Modally。

为详细信息按钮添加转场

现在从Checklists界面到导航栏有两个转场了,一个是用于➕按钮,一个用于cell中的详细信息按钮。

两个转场

为了使app能够区分两个转场,我们必须给它们不同的身份id。

选择新的转场箭头,然后打开属性检查器,在identifier中输入EditItem。

如果你运行app的话,点击蓝色的感叹号按钮,会打开新增待办事项界面,但是此时你点击Cancel按钮的话,不会有任何作用。

练习:想一想为什么?

答案:你还没有配置委托。一定要记得你是在prepare(for:sender:)中设置委托的,但是只有➕号按钮的AddItem转场设置委托,你还需要对EditItem转场做同样的事情。

在你修复这个问题前,你需要先使新增待办事项界面拥有编辑ChecklistItem对象的能力。

打开AddItemViewController.swift,添加一个新的实例变量:

var itemToEdit: ChecklistItem?

这个变量用于包含用户准备编辑的ChecklistItem对象。但是当新增一个待办项目时,itemToEdit会是nil,这就是视图控制器如何区分新增和编辑的。

因为itemToEdit会为nil,所以它必须是可选型的,这就是问号的作用。

还是在AddItemViewController.swift中,添加viewDidLoad()方法:

override func viewDidLoad() {
        super.viewDidLoad()
        
        if let item = itemToEdit {
            title = "Edit Item"
            textField.text =  item.text
        }
    }

回忆一下viewDidLoad()是党视图控制器从故事模版中被加载时由UIKit调用,此时界面还没有展现在屏幕上。这就给了你时间配置用户界面。

在编辑模式下,也就是当itemToEdit不为nil时,你改变导航栏的名称为“Edit Item”。你通过改变导航栏的title属性来完成这一功能。

每个视图控制器都有几个内建的属性,title正是其中之一。导航控制器读取到title的值后自动改变导航栏的名称。

你同时也设置了文本框的值为item的text属性。

if let
你不能像使用普通变量那样使用可选型变量。例如,如果你直接这样写语句的话:

textField.text =  item.text

这样Xcode的编译器会给出一个报错,“Value of optional type ChecklistItem?not unwarped(类型为ChecklistItem的可选型变量的值没有解包)”
这是因为itemToEdit是ChecklistItem的可选型变量。
要使用它你首先要进行解包。通过一个特殊的语法完成这一功能:

if let temporaryConstant = optionalVariable {
  // temporaryConstant now contains the unwrapped value
  // of the optional variable
}

如果这个可选型不是nil,if的条件为真,if语句体内的代码才会被执行。
还有一些其他方法可以读取可选型变量的值,但是使用if let是最安全的:如果可选型没有值,或者为nil,那么if语句体内的代码会被自动略过。
可选型让你头晕脑胀了吗?多做练习,熟能生巧,每个人都是这样过来的。可选型是swift的特色,多数主流语言没有这个功能,许多开发者都会在这里付出大量精力去理解它。
尽管难以理解,但是可选型可以避免空指针错误并且防止你的app挂掉。

现在AddItemViewController有能力识别什么时候进入编辑界面了。如果itemToEdit拥有一个ChecklistItem对象,那么界面会魔法般的变成编辑界面。

但是你在哪里给itemToEdit赋值呢?当然是在转场的时候了!这里最理想的地方用于给变量赋值,在界面即将显示在屏幕上前把一切都配置好。

打开ChecklistViewController.swift修改prepare(for: sender:)为下面这个样子:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "AddItem" {...
} else if segue.identifier == "EditItem" {
            let navigationController = segue.destination as! UINavigationController
            let controller = navigationController.topViewController as! AddItemViewController
            controller.delegate = self
            if let indexPath = tableView.indexPath(for: sender as! UITableViewCell) {
                controller.itemToEdit = items[indexPath.row]
            }
        }
    }

和以前一样,你从故事模版中读取导航控制器,并且使用topViewController属性调用AddItemViewController。

你同时也设置了视图控制器的委托属性,这样用户点击Cancel或者Done按钮时,你就可以得到通知了,这里做的事和“AddItem”转场一模一样。

比较有意思的是新增加的这一部分:

if let indexPath = tableView.indexPath(for: sender as! UITableViewCell) {
                controller.itemToEdit = items[indexPath.row]

prepare(for:sender:)方法有一个叫做sender的参数。这个参数包含一个控制转场的引用,在我们的这个情况中,就是table view cell的详细信息按钮被点击时。

你设置了一个UITableViewCell对象用于定位被点击的那一行的行号相应的index-path,通过使用tableView.indexPath(for:)。

tableView.indexPath(for:)的返回类型为IndexPath?,是一个可选型,这就意味着它可能返回nil。这就是为什么在你使用它前需要用if let来解包的原因。

一旦你有了一个行号,你就可以获得需要被编辑的ChecklistItem对象,并且你同时将它分配给了AddItemViewController的itemToEdit属性。

视图控制器之间互相发送数据
我们讲过界面B(Add/Edit Item screen)回传数据给界面A(Checklists screen)是通过委托完成的。
但是这里你是从界面A向界面B传递数据,也就是说传递一个ChecklistItem对象用于编辑。
数据在视图控制器间传递有两种方法:
1、从A到B。当界面A打开界面B时,A可以给B需要的数据。你简单的在B的视图控制器中创建一个实例变量,然后A转场到B时给这个变量赋值就可以了,这一工作通常都是在prepare(for:sender:)中完成。
2、从B到A。B回传数据给A则需要使用委托。
下图说明了A发送数据给B时,是如何对B的变量赋值的,以及B是如何通过委托向A回传数据的:



我希望你能初步的了解了视图控制器间的数据传递方式。在我们的这个课程中还会出现几次这样的场景,请务必掌握这一知识。
制作iOS app的本质就是创建视图控制器并且在其中传递数据,你要把这件事变成自己的第二本能。

做完这些步骤后,你可以运行一下app,点击➕按钮会打开新增待办事项界面,而点击某一行上的蓝色感叹号按钮后会弹出编辑界面,界面上已经存在了待编辑的条目:

编辑待办事项

有个小问题:导航栏上的Done按钮一开始是被禁用的,这是因为你最初的时候在故事模版中设置禁用了它。

打开AddItemViewController.swift,添加一行代码进去:

override func viewDidLoad() {
  super.viewDidLoad()
  if let item = itemToEdit {
    title = "Edit Item"
    textField.text = item.text
    doneBarButton.isEnabled = true  //添加这一行
} 
}

在编辑模式下,可以安全的在一开始就将Done按钮置为可用状态,因为文本被删至没有时,Done会自动被禁用。

真正的问题不在这里,眼下你运行app,编辑某一行后点击Done按钮,你会发现原先的一行并没有被修改,取而代之是,新增了一行上去。

你还没有写代码来复制并且更新数据模型,所以委托会以为你的目的是新增一行。

为了解决这个问题,你需要在委托协议中新增一个方法。

打开AddItemViewController.swift,在协议中添加如下代码:

func addItemController(_ controller: AddItemViewController,didFinishEditing item: ChecklistItem)

现在整个协议内容看起来是这个样子的:

protocol AddItemViewControllerDelegate: class {
    func addItemControllerDidCancel(_ controller: AddItemViewController)
    func addItemController(_ controller: AddItemViewController,didFinishAdding item: ChecklistItem)
    func addItemController(_ controller: AddItemViewController,didFinishEditing item: ChecklistItem)
}

现在用户点击Done按钮后一共有两个方法用于响应。

当新增一行后调用didFinishAdding,而当编辑一行后调用didFinishEditing。

通过调用不同的委托方法就可以分别处理这两种不同的情景了。

打开AddItemViewController.swift,将done()方法修改为:

@IBAction func done() {
        if let item = itemToEdit {
            item.text = textField.text!
            delegate?.addItemController(self, didFinishEditing: item)
        } else {
        let item = ChecklistItem()
        item.text = textField.text!
        item.checked = false
        delegate?.addItemController(self, didFinishAdding: item)
        }
    }

首先我们检查itemToEdit属于是否包含一个对象,你应该明白if let的作用是解包可选型变量。

如果这个可选型变量不为空,你就在文本框中放入一条已存在的ChecklistItem对象并且调用新的didFinishEditing方法。

如果为空,则添加一条新的纪录进去,就像我们之前做的一样。

试着运行app,你会看到一个惊喜,app挂了。

Xcode说道:“Build Failed”,但是在AddItemViewController.swift中看不到任何报错,这是为什么?

你可以在Xcode的问题导航器(Issue navigator)中看到一个报错信息:

报错信息

这个报错信息是发生在ChecklistViewController中的,因为它没有执行协议中的方法。这并不奇怪,因为你刚把didFinishEditing方法添加到协议中,但是你还没有告诉视图控制器,谁来扮演委托的角色。

⚠️:在我的这个版本的Xcode中报错信息为:Method 'addItemController(_:didFinishAdding:)' has different argument names from those required by protocol...,这是个比较奇怪的报错信息。它并没有准确的描述什么地方出了问题,仅仅是反应了Swift对目前的情况困惑不解。
当你制作自己的app时,你也许会面对Swift不可描述的奇怪报错信息。随着经验的积累,你会变得熟悉这些情况。Swift的编译器刚问世不久,它也需要被完善。

在ChecklistViewController.swift中添加以下代码,就可以把刚才的报错化为历史了:

func addItemController(_ controller: AddItemViewController, didFinishEditing item: ChecklistItem) {
        if let index = items.index(of: item) {
        let indexPath = IndexPath(row: index, section: 0)
        if let cell = tableView.cellForRow(at: indexPath) {
            configureText(for: cell, with: item)
            }
        }
        dismiss(animated: true, completion: nil)
    }

这样ChecklistItem就有了新的文本,cell也是已经存在在table view中的,你只是需要更新table view cell中的标签。

这个新的方法就是你用来寻找cell对应的ChecklistItem对象,然后通过configureText方法来更新标签。

第一行语句对我们而言比较新鲜:

if let index = items.index(of: item)

你需要从cell中读取所需的IndexPath,首先你就需要寻找到ChecklistItem对象的行号。行号和ChecklistItem在items数组中的索引值是一致的,然后你通过index(of)方法来返回这个index。

虽然在我们的这个app中不会发生,但是理论上数组中的某个对象会不存在,这个时候index(of)会返回nil,所以它的返回值是可选型的,所以我们用if let对它进行解包。

试着运行app,噢,我想我太心急了。Xcode给出了另一个报错:Cannot invoke index with an argument list of type blah blah blah。这是怎么回事?

这是因为你不能在任意对象上使用index(of),只能在“相同”的对象上使用它。index(of)以某种方式对你在数组中寻找的对象进与调用它的对象行比较,看看它们是否相等。

你的ChecklistItem对象现在并不具备这个功能。这里有好几种方式处理这个问题,我们来用最简单的一种。

在ChecklistItem.swift的类声明的那一行添加一点东西:

class ChecklistItem: NSObject {

如果你之前使用过Object-C,那么你应该对NSObject非常熟悉。

几乎所有Object-C中的对象都是基于NSObject的。这是由iOS提供的最基本的代码块,它可以提供Swift对象所没有的大量有用的基础功能。

你以Swift写代码时候大多数时候并不需要搭理NSObject,但是眼下是必须的。

将ChecklistItem建立在NSObject之上,就可以使它安全的进行比较了。后面我们在学习如何存储checklist对象时,你也必须将它转换为NSObject。

运行app,试试功能,现在一切都正常了。

重构代码

现在你已经有了一个app,可以实现新增待办事项以及编辑事项的功能,非常不错。

但是我发现AddItemViewController这个名字起的不太恰当了,毕竟这个界面现在承载着新增待办事项和编辑已有项目两个功能,我们应该将它重命名为ItemDetailViewController。

现在我们有一个好消息和一个坏消息,你想先听哪个?

好消息是Xcode有一个特殊的菜单用于重构源代码,包含重命名的工具,你可以在Edit->Refactor中找到它。

坏消息是在Xcode 8.0中这个功能并不支持Swift语言,仅支持Object-C,所以你不能使用它,差评。

所以我们的唯一选择就是手工完成这件事,幸运的是,Xcode有一个非常便利的搜索和替换功能。我们接下来一步步的做完这个工作。

1、打开搜索导航起,工程导航界面上的第三个按钮。

就是放大镜图标的那个

2、点击Find,切换为Replace(替换)。

3、将上图中的Ignoring Case,切换为Matching Case。

4、在搜索框中输入AddItemViewController。重要:确保你的拼写无误。

5、在替换框中输入ItemDetailViewController

填写完毕的样子

6、点击键盘上的回车键开始搜索,这一操作不会进行替换。

搜索结果会返回所有相关的匹配项。你应该可以看到两个swfit源文件和Main.storyboard。

搜索结果

7、点击Preview按钮。Xcode会打开一个界面里面包含各个文件中将被替换掉部分。

替换结果预览

仔细的逐条对比每一对替换,确保没有替换掉不应该替换掉东西。这次替换仅仅是把名为AddItemViewController的字样替换为ItemDetailViewController,包含故事模版内的。

8、点击Replace按钮并且祈祷。如果Xcode需要你确认,点击Continue。

9、在工程导航器中,选定AddItemViewController.swift,然后再点击一次,两次点击不要太快,然后你就可以对这个文件进行重命名了。

新的名字是:ItemDetailViewController.swift

还没有结束,你还要为之前的委托协议进行重命名。

1、再次切换到搜索导航器

2、确定处于Replace模式下(如果不是点击Find切换到Replace)

3、确保处于Matching Case模式下(如果不是则点击Ignoring Case切换)

4、输入搜索文本addItemViewController,注意开头是小写的a

5、输入替换文本itemDetailViewController,注意开头是小写的i

6、敲击键盘上的回车键。

得到搜索结果

搜索结果应该只包含协议委托方法的名称,分别在ChecklistViewController.swift和ItemDetailViewController.swift两个文件中。

7、点击Replace All进行替换。

替换完毕后,可以再进行一次搜索确认替换结果,此时应该搜不到任何东西了,因为都被替换完了。

现在ItemDetailViewController.swift中的协议方法应该是下面这个样子:

protocol ItemDetailViewControllerDelegate: class {
    func itemDetailViewControllerDidCancel(_ controller: ItemDetailViewController)
    func itemDetailViewController(_ controller: ItemDetailViewController,didFinishAdding item: ChecklistItem)
    func itemDetailViewController(_ controller: ItemDetailViewController,didFinishEditing item: ChecklistItem)
}

用command+B键来重新编译以下代码,如果顺利,编译会成功通过。

⚠️:如果编译报错,那么一定是搜索和替换的关键字错了,重新检查一下,尤其注意大小写,在swift中ItemDetailViewController和itemDetailViewController是两个完全不同的对象,它们开头的字母大小写不一样。

如果你运行app崩溃的话,检查一下故事模版中Add Item这个界面的身份检查器中的Custom Class此时应该为ItemDetailViewController。有时在搜索替换时,Xcode会漏掉这个地方。

因为你做了很多改动,此时最好清理一下缓存先,通过Xcode的菜单Product->Clean来完成缓存清理。

如果没有问题,你就可以重新运行app,好好的测试下各种场景了。确保一切工作正常。(如果Xcode编译正常但是仍然显示有报错,那么就整个关掉Xcode再重新打开试试)

迭代开发(Iterative development)
如果你觉得我们这个课程里的开发过程太啰嗦了,为什么不一步到位的讲呢?非要弄出这么多弯路。这种想法是不正确的。
当你从一个设计开始实现具体功能的时候,你总会发现很多问题没有考虑清楚,所以你需要在实践的过程中不断的重构自己的代码,最终达到理想效果。
软件开发就是这样的一种工作。
一开始你只是完成一小块功能,并且看上去好像没有什么问题。然后你又添加另外一小部分进去,突然间意料之外的问题就出现了。这时候也许你就不得不推翻之前的设计一切从头开始了。直到最终完成为止你都不得的频繁的重复这个行为。
软件开发就是一个不断的细致化的过程。所以在我们的课程中,我不会给你最完美的结局方案,而是通过不断的修改至臻完善,并且详细的展示每一小块的细节。因为真实的软件开发就是这样一个工作。
而你则会从0出发直到完成一个完整的app,并且在这个过程中持续的解决问题,就像哪些专业人士做的一样。
在软件的领域不存在高屋建瓴的设计,像建筑图纸那样的东西,我并不相信这些设计。当然提前做好计划是没错的,但是不能把计划当成实现方式。在写这些教程的时候,我会画一些草图来想象这些app应该如何运作。这个工作的效果显著的,但是经常性的,在开发的过程中会遇到问题,使我不得不改变当初的想法,这还仅仅是这样一个小的app而已。
这并不是说你不要去做提前的规划,只是不要在这个上面浪费太多时间而已。
你要做的是简单的从某个步骤开始做起,直到遇到问题卡住,然后来回溯并且找到问题的解决办法。这就叫做迭代开发,这种方法比提前规划好蓝图的效果和效率要快的多了。

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

推荐阅读更多精彩内容