CoreLocation框架详细解析(十四) —— 仿Runkeeper的简单实现(三)

版本记录

版本号 时间
V1.0 2018.10.18 星期四

前言

很多的app都有定位功能,比如说滴滴,美团等,他们都需要获取客户所在的位置,并且根据位置推送不同的模块数据以及服务,可以说,定位方便了我们的生活,接下来这几篇我们就说一下定位框架CoreLocation。感兴趣的可以看我写的上面几篇。
1. CoreLocation框架详细解析 —— 基本概览(一)
2. CoreLocation框架详细解析 —— 选择定位服务的授权级别(二)
3. CoreLocation框架详细解析 —— 确定定位服务的可用性(三)
4. CoreLocation框架详细解析 —— 获取用户位置(四)
5. CoreLocation框架详细解析 —— 监控用户与地理区域的距离(五)
6. CoreLocation框架详细解析 —— 确定接近iBeacon(六)
7. CoreLocation框架详细解析 —— 将iOS设备转换为iBeacon(七)
8. CoreLocation框架详细解析 —— 获取指向和路线信息(八)
9. CoreLocation框架详细解析 —— 在坐标和用户友好的地名之间转换(九)
10. CoreLocation框架详细解析(十) —— 跟踪访问位置简单示例(一)
11. CoreLocation框架详细解析(十一) —— 跟踪访问位置简单示例(二)
12. CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)
13. CoreLocation框架详细解析(十三) —— 仿Runkeeper的简单实现(二)

开始

首先看一下写作环境

Swift 4, iOS 11, Xcode 9

该应用程序在当前状态下非常适合记录和显示数据,但它需要更多的火花才能为用户提供额外的动力。

在本节中,您将通过实施徽章系统来完成演示MoonRunner应用程序,该系统体现了健身是一种有趣且基于进步的成就的概念。 以下是它的工作原理:

  • 列表列出了增加距离的检查点以激励用户。
  • 当用户跑步时,该应用程序会显示即将到来的徽章的缩略图以及获得它的剩余距离。
  • 当用户第一次到达检查点时,应用程序会颁发徽章并记录运行的平均速度。从那里开始,徽章的银色和金色版本将以相对更快的速度再次到达该检查站。
  • 跑步后的地图在路径上的每个检查点显示一个点,其中自定义标注显示徽章名称和图像。

无论您使用哪个文件,您都会注意到您的项目包含资产目录中的许多图像以及名为badges.txt的文件。 现在打开badges.txt。 您可以看到它包含一个大型JSON徽章对象数组。 每个对象包含:

  • 一个名字name
  • 有关徽章的一些有趣信息information
  • 以米为单位实现徽章的距离distance
  • 资产目录(imageName)中相应图像的名称。

徽章从0米开始 - 嘿,你必须从某个地方开始 - 直到完整马拉松的长度。

第一项任务是将JSON文本解析为徽章数组。 将新的Swift文件添加到项目中,将其命名为Badge.swift,并将以下实现添加到其中:

struct Badge {
  let name: String
  let imageName: String
  let information: String
  let distance: Double
  
  init?(from dictionary: [String: String]) {
    guard
      let name = dictionary["name"],
      let imageName = dictionary["imageName"],
      let information = dictionary["information"],
      let distanceString = dictionary["distance"],
      let distance = Double(distanceString)
    else {
      return nil
    }
    self.name = name
    self.imageName = imageName
    self.information = information
    self.distance = distance
  }
}

这定义了Badge结构,并提供了一个可用的初始化程序来从JSON对象中提取信息。

将以下属性添加到结构以读取和解析JSON:

static let allBadges: [Badge] = {
  guard let fileURL = Bundle.main.url(forResource: "badges", withExtension: "txt") else {
    fatalError("No badges.txt file found")
  }
  do {
    let jsonData = try Data(contentsOf: fileURL, options: .mappedIfSafe)
    let jsonResult = try JSONSerialization.jsonObject(with: jsonData) as! [[String: String]]
    return jsonResult.flatMap(Badge.init)
  } catch {
    fatalError("Cannot decode badges.txt")
  }
}()

您使用基本的JSON反序列化从文件和flatMap中提取数据,以丢弃任何无法初始化的结构。 allBadges被声明为static,因此昂贵的解析操作只发生一次。

您需要以后能够匹配Badge,因此请将以下扩展添加到文件末尾:

extension Badge: Equatable {
  static func ==(lhs: Badge, rhs: Badge) -> Bool {
    return lhs.name == rhs.name
  }
}

Earning The Badge - 赢得徽章

现在您已经创建了Badge结构,在获得徽章时,您需要一个存储结构。 此结构将徽章与用户获得此Badge版本的各种Run对象(如果有)相关联。

将新的Swift文件添加到项目中,将其命名为BadgeStatus.swift,并将以下实现添加到其中:

struct BadgeStatus {
  let badge: Badge
  let earned: Run?
  let silver: Run?
  let gold: Run?
  let best: Run?
  
  static let silverMultiplier = 1.05
  static let goldMultiplier = 1.1
}

这定义了BadgeStatus结构和乘数,它们决定了用户获得银牌或金牌徽章所需的时间。 现在将以下方法添加到结构中:

static func badgesEarned(runs: [Run]) -> [BadgeStatus] {
  return Badge.allBadges.map { badge in
    var earned: Run?
    var silver: Run?
    var gold: Run?
    var best: Run?
    
    for run in runs where run.distance > badge.distance {
      if earned == nil {
        earned = run
      }
      
      let earnedSpeed = earned!.distance / Double(earned!.duration)
      let runSpeed = run.distance / Double(run.duration)
      
      if silver == nil && runSpeed > earnedSpeed * silverMultiplier {
        silver = run
      }
      
      if gold == nil && runSpeed > earnedSpeed * goldMultiplier {
        gold = run
      }
      
      if let existingBest = best {
        let bestSpeed = existingBest.distance / Double(existingBest.duration)
        if runSpeed > bestSpeed {
          best = run
        }
      } else {
        best = run
      }
    }
    
    return BadgeStatus(badge: badge, earned: earned, silver: silver, gold: gold, best: best)
  }
}

此方法将每个用户的跑步与每个徽章的距离要求进行比较,进行关联并为每个获得的徽章返回一组BadgeStatus值。

用户第一次获得徽章时,该跑步速度成为用于确定后续跑步是否已经足够改进以获得银色或金色版本的参考。

最后,该方法跟踪用户对每个徽章距离的最快跑步。


Displaying the Badges - 显示徽章

现在您已经将所有逻辑写入授予徽章,现在是时候向用户显示它们了。 初始项目已经定义了必要的UI。 您将在UITableViewController中显示徽章列表。 为此,首先需要定义显示徽章的自定义table view cell

将新的Swift文件添加到项目中并将其命名为BadgeCell.swift。 用以下内容替换文件的内容:

import UIKit

class BadgeCell: UITableViewCell {
  
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!
  
  var status: BadgeStatus! {
    didSet {
      configure()
    }
  }
}

这些是显示徽章信息所需的outlets。 您还声明了一个status变量,它是单元格的模型。

接下来,在status变量下面的单元格中添加configure()方法:

private let redLabel = #colorLiteral(red: 1, green: 0.07843137255, blue: 0.1725490196, alpha: 1)
private let greenLabel = #colorLiteral(red: 0, green: 0.5725490196, blue: 0.3058823529, alpha: 1)
private let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)
  
private func configure() {
  silverImageView.isHidden = status.silver == nil
  goldImageView.isHidden = status.gold == nil
  if let earned = status.earned {
    nameLabel.text = status.badge.name
    nameLabel.textColor = greenLabel
    let dateEarned = FormatDisplay.date(earned.timestamp)
    earnedLabel.text = "Earned: \(dateEarned)"
    earnedLabel.textColor = greenLabel
    badgeImageView.image = UIImage(named: status.badge.imageName)
    silverImageView.transform = badgeRotation
    goldImageView.transform = badgeRotation
    isUserInteractionEnabled = true
    accessoryType = .disclosureIndicator
  } else {
    nameLabel.text = "?????"
    nameLabel.textColor = redLabel
    let formattedDistance = FormatDisplay.distance(status.badge.distance)
    earnedLabel.text = "Run \(formattedDistance) to earn"
    earnedLabel.textColor = redLabel
    badgeImageView.image = nil
    isUserInteractionEnabled = false
    accessoryType = .none
    selectionStyle = .none
  }
}

这个简单的方法根据设置的BadgeStatus配置表视图单元格。

如果复制并粘贴代码,您会注意到Xcode将#colorLiterals更改为样本。 如果您手动输入,请开始键入单词Color literal,选择Xcode完成并双击生成的样本。

这将显示一个简单的颜色选择器。 单击Other…按钮。

这将调出系统颜色选择器。 要匹配示例项目中使用的颜色,请使用Hex Color#字段,输入FF142C表示红色,输入00924E表示绿色。

打开Main.storyboard并将outlets连接到Badges Table View Controller Scene中的BadgeCell

  • badgeImageView
  • silverImageView
  • goldImageView
  • nameLabel
  • earnedLabel

现在已经定义了table cell,现在是时候创建table view controller了。 将新的Swift文件添加到项目中并将其命名为BadgesTableViewController.swift。 替换导入部分以导入UIKitCoreData

import UIKit
import CoreData

现在,添加类定义:

class BadgesTableViewController: UITableViewController {
  
  var statusList: [BadgeStatus]!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    statusList = BadgeStatus.badgesEarned(runs: getRuns())
  }
  
  private func getRuns() -> [Run] {
    let fetchRequest: NSFetchRequest<Run> = Run.fetchRequest()
    let sortDescriptor = NSSortDescriptor(key: #keyPath(Run.timestamp), ascending: true)
    fetchRequest.sortDescriptors = [sortDescriptor]
    do {
      return try CoreDataStack.context.fetch(fetchRequest)
    } catch {
      return []
    }
  }
}

加载视图时,您向Core Data询问所有已完成的跑步的列表,按日期排序,然后使用此列表构建所获得的徽章列表。

接下来,在扩展中添加UITableViewDataSource方法:

extension BadgesTableViewController {
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return statusList.count
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell: BadgeCell = tableView.dequeueReusableCell(for: indexPath)
    cell.status = statusList[indexPath.row]
    return cell
  }
}

这些是所有UITableViewControllers所需的标准UITableViewDataSource方法,将行数和已配置的单元格返回到表中。 正如在第1部分中一样,您通过在StoryboardSupport.swift中定义的泛型方法使单元格出列来减少“stringly typed”代码。

Build并运行以检查您的新徽章! 你应该看到这样的东西:


What Does a Runner Have to Do to Get a Gold Medal Around Here? - 跑步者必须做些什么来获得这里的金牌?

MoonRunner的最后一个视图控制器是显示徽章细节的控制器。 将新的Swift文件添加到项目中并将其命名为BadgeDetailsViewController.swift。 用以下内容替换文件的内容:

import UIKit

class BadgeDetailsViewController: UIViewController {
  
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var distanceLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!
  @IBOutlet weak var bestLabel: UILabel!
  @IBOutlet weak var silverLabel: UILabel!
  @IBOutlet weak var goldLabel: UILabel!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
  
  var status: BadgeStatus!
}

这将声明控制UI所需的所有outlets以及作为此视图模型的BadgeStatus

接下来,添加viewDidLoad()

override func viewDidLoad() {
  super.viewDidLoad()
  let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)
  
  badgeImageView.image = UIImage(named: status.badge.imageName)
  nameLabel.text = status.badge.name
  distanceLabel.text = FormatDisplay.distance(status.badge.distance)
  let earnedDate = FormatDisplay.date(status.earned?.timestamp)
  earnedLabel.text = "Reached on \(earnedDate)"
  
  let bestDistance = Measurement(value: status.best!.distance, unit: UnitLength.meters)
  let bestPace = FormatDisplay.pace(distance: bestDistance, 
                                    seconds: Int(status.best!.duration), 
                                    outputUnit: UnitSpeed.minutesPerMile)
  let bestDate = FormatDisplay.date(status.earned?.timestamp)
  bestLabel.text = "Best: \(bestPace), \(bestDate)"
  
  let earnedDistance = Measurement(value: status.earned!.distance, unit: UnitLength.meters)
  let earnedDuration = Int(status.earned!.duration)
}

这将在BadgeStatus信息的详细视图中设置标签。 现在,你需要设置金色和银色徽章。

将以下代码添加到viewDidLoad()的末尾:

if let silver = status.silver {
  silverImageView.transform = badgeRotation
  silverImageView.alpha = 1
  let silverDate = FormatDisplay.date(silver.timestamp)
  silverLabel.text = "Earned on \(silverDate)"
} else {
  silverImageView.alpha = 0
  let silverDistance = earnedDistance * BadgeStatus.silverMultiplier
  let pace = FormatDisplay.pace(distance: silverDistance, 
                                seconds: earnedDuration, 
                                outputUnit: UnitSpeed.minutesPerMile)
  silverLabel.text = "Pace < \(pace) for silver!"
}

if let gold = status.gold {
  goldImageView.transform = badgeRotation
  goldImageView.alpha = 1
  let goldDate = FormatDisplay.date(gold.timestamp)
  goldLabel.text = "Earned on \(goldDate)"
} else {
  goldImageView.alpha = 0
  let goldDistance = earnedDistance * BadgeStatus.goldMultiplier
  let pace = FormatDisplay.pace(distance: goldDistance, 
                                seconds: earnedDuration, 
                                outputUnit: UnitSpeed.minutesPerMile)
  goldLabel.text = "Pace < \(pace) for gold!"
}

必要时通过将其alpha设置为0来隐藏金色和银色图像视图。这适用于嵌套的UIStackViews和自动布局之间的交互。

最后,添加以下方法:

@IBAction func infoButtonTapped() {
  let alert = UIAlertController(title: status.badge.name,
                                message: status.badge.information,
                                preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel))
  present(alert, animated: true)
}

按下信息按钮时将调用此选项,并显示带有徽章信息的弹出窗口。

打开Main.storyboard。 连接BadgeDetailsViewControlleroutlets

  • badgeImageView
  • nameLabel
  • distanceLabel
  • earnedLabel
  • bestLabel
  • silverLabel
  • goldLabel
  • silverImageLabel
  • goldImageLabel

将操作infoButtonTapped()连接到info button。 最后,在Badges Table View Controller Scene中选择Table View

选中Attributes Inspector中的User Interaction Enabled复选框:

打开BadgesTableViewController.swift并添加以下扩展:

extension BadgesTableViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "BadgeDetailsViewController"
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! BadgeDetailsViewController
      let indexPath = tableView.indexPathForSelectedRow!
      destination.status = statusList[indexPath.row]
    }
  }

  override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
    guard let segue = SegueIdentifier(rawValue: identifier) else { return false }
    switch segue {
    case .details:
      guard let cell = sender as? UITableViewCell else { return false }
      return cell.accessoryType == .disclosureIndicator
    }
  }
}

当用户点击表中的徽章时,这会将BadgeStatus传递给BadgeDetailsViewController

iOS 11注意:iOS 11的当前测试版在配置单元格之后和显示之前将表格单元格的属性isUserInteractionEnabled重置为true。 因此,您必须实现shouldPerformSegue(withIdentifier:sender :)以防止访问未获得的徽章的徽章详细信息。 如果更高版本的iOS 11更正此错误,则可以删除此方法。

建立并运行。 查看您的新徽章的详细信息!


Carrot Motivation - 激励

现在您已经拥有了一个很酷的新徽章系统,您需要更新现有应用程序的UI以将其合并。 在您这样做之前,您需要使用几种实用方法来确定最近获得的徽章以及为给定距离获得的下一个徽章。

打开Badge.swift并添加以下方法:

static func best(for distance: Double) -> Badge {
  return allBadges.filter { $0.distance < distance }.last ?? allBadges.first!
}

static func next(for distance: Double) -> Badge {
  return allBadges.filter { distance < $0.distance }.first ?? allBadges.last!
}

这些方法中的每一种都过滤徽章列表,具体取决于它们是否已获得或尚未获得。

现在,打开Main.storyboard。 在New Run View Controller Scene中找到Button Stack View。 将UIImageView和UILabel拖到Document Outline中。 确保它们位于Button Stack View的顶部:

选择这两个新视图,然后选择Editor \ Embed In \ Stack View。 更改生成的堆栈视图的属性,如下所示:

  • Axis: Horizontal
  • Distribution: Fill Equally
  • Spacing: 10
  • Hidden: checked

将图像视图的Content Mode设置为Aspect Fit

更改Label的属性如下:

  • Color: White Color
  • Font: System 14.0
  • Lines: 0
  • Line Break: Word Wrap
  • Autoshrink: Minimum Font Size
  • Tighten Letter Spacing: checked

使用您最喜欢的Assistant Editor连接新的Stack View, Image View and Label中的outlets,命名如下:

@IBOutlet weak var badgeStackView: UIStackView!
@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoLabel: UILabel!

Xcode 9注意:如果你看到一对警告说你的新UI项目的垂直位置不明确,请不要担心。 您的Xcode版本无法正确计算隐藏项目的子视图的布局。 要使警告消失,请取消选中Main.storyboard中徽章堆栈视图上的Hidden属性。 然后将以下行添加到NewRunViewController.swift中的viewDidLoad()

badgeStackView.isHidden = true // required to work around behavior change in Xcode 9 beta 1

打开NewRunViewController.swift并导入AVFoundation

import AVFoundation

现在,添加以下属性:

private var upcomingBadge: Badge!
private let successSound: AVAudioPlayer = {
  guard let successSound = NSDataAsset(name: "success") else {
    return AVAudioPlayer()
  }
  return try! AVAudioPlayer(data: successSound.data)
}()

successSound是作为“success sound”的音频播放器创建的,每次获得新徽章时都会播放该声音播放器。

接下来,找到updateDisplay()并添加:

let distanceRemaining = upcomingBadge.distance - distance.value
let formattedDistanceRemaining = FormatDisplay.distance(distanceRemaining)
badgeInfoLabel.text = "\(formattedDistanceRemaining) until \(upcomingBadge.name)"

这将使用户了解下一个要获得的徽章的最新信息。

startRun()中,在调用updateDisplay()之前,添加:

badgeStackView.isHidden = false
upcomingBadge = Badge.next(for: 0)
badgeImageView.image = UIImage(named: upcomingBadge.imageName)

这显示了要获得的初始徽章。

stopRun()中添加:

badgeStackView.isHidden = true

就像其他视图一样,所有徽章信息都需要在跑步之间隐藏。

添加以下新方法:

private func checkNextBadge() {
  let nextBadge = Badge.next(for: distance.value)
  if upcomingBadge != nextBadge {
    badgeImageView.image = UIImage(named: nextBadge.imageName)
    upcomingBadge = nextBadge
    successSound.play()
    AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
  }
}

这可以检测到徽章何时达到,更新UI以显示下一个徽章,并播放成功声音以庆祝完成徽章。

eachSecond()中,在调用updateDisplay()之前添加对checkNextBadge()的调用:

checkNextBadge()

构建并运行以在模拟器运行时观察标签更新。 通过新徽章时听取声音!

注意:在控制台中,一旦播放成功声音,您可能会看到一些如下所示的错误消息:

[aqme] 254: AQDefaultDevice (188): skipping input stream 0 0 0x0

在模拟器上,这是正常的。 消息来自AVFoundation,并不表示您的错误。

此外,如果您不想等待测试徽章,您可以随时在模拟器的Debug \ Location菜单中切换到其他位置模式。

后记

本篇主要讲述了仿Runkeeper的简单实现,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容