iOS 底层原理38:自动化打包(三)Fastlane

iOS 底层原理 文章汇总

除了Jenkins可以自动打包,还有另一个方式:Fastlane,下面来了解下。

Fastlane是一个完全开源的项目,是一款为iOS和Android开发者提供的自动化构建工具。它可以帮助开发者将App打包、签名、测试、发布等流程串联起来,实现完全自动化的工作流。其官网地址为:https://fastlane.tools/

Fastlane安装

参考配置文档,安装步骤如下:

  • 检查ruby版本,需要2.0及以上,并且将gem的source改为gems.ruby-china.com/
// 查看 Ruby 版本
ruby -v

// 查看 gem 的source
gem sources
  • 检查Xcode命令行工具是否安装,如果没有安装则会自动开始安装
xcode-select --install
  • 安装Fastlane
sudo gem install fastlane --verbose

//如果安装出错,则使用以下命令
sudo gem install -n /usr/local/bin fastlane

//查看版本验证是否安装成功
fastlane --version
查看ruby版本图示

Fastlane相关block、lanes、actions

在完善打包逻辑之前,首先介绍Fastlane中提供的常用的blocks、lanes和actions

  • blocks:fastlane中除了lane的其他模块

  • lanes:Lanes的相关用法介绍

  • actions:fastlane内置好的常用操作

Blocks

block主要分为三类:

  • before_all & before_each

  • after_all & after_each

  • error

before_all & before_each

其中before_all会先于before_each执行,且仅执行一次,
所以许多通用操作流入git代码拉取等,可以放在before_all或者befoe_each中

  • before_all:在lane执行前执行一次,
before_all do |lane| 
   # ...
end

<!--举例-->
before_all do |lane| 
   cocoapods #这里会执行pod install
end
  • before_each:会在任意lane执行前执行,与before_all只执行一次不同,如果在一个 lane 中多次调用了其他的 lane,则其他的 lane 执行前都会执行 before_each。
before_each do |lane, options|
  # ...
end

after_all & after_each

after_all 会在 after_each 执行完后执行,且仅执行一次。
许多通用操作例如发送打包成功的通知等,可以放在 after_all 或 after_each 中。

  • after_all:会在lane执行完后执行一次
after_all do |lane|
  # ...
end


<!--举例-->
after_all do |lane|
  say("Successfully finished release (#{lane})!")
  slack(
    message: "Successfully submitted new App Update"
  )
  sh("./send_screenshots_to_team.sh") # Example
end
  • after_each:会在任意lane执行完后执行,如果在一个lane中多次调用了其他的lane,则其他的lane执行完后都会执行after_each
after_each do |lane, options|
  # ...
end

例如下面的例子,before_each将after_each被调用 4 次

  • before_each:调用deploy之前、切换到archive之前、切换到sign之前、切换到upload之前
  • after_each:执行deploy之后、切换到archive执行之后、切换到sign执行之后、切换到upload执行之后
before_each do |lane, options|
  # ...
end

lane :deploy do
  archive
  sign
  upload
end

lane :archive do
  # ...
end

lane :sign do
  # ...
end

lane :upload do
  # ...
end

after_each do |lane, options|
  # ...
end

error

在任何流程中发生错误时,都会退出并执行error,错误后的after_all和after_each将不会执行

error do |lane, exception|
  slack(
    message: "Something went wrong with the release.",
    success: false,
    payload: { "Error Info" => exception.error_info.to_s } 
  )
end

Actions

一个命名的lane代表一个持续集成的任务,每个任务由多个步骤组成,步骤组成是已经定义好的action工具。在终端可以通过fastlane action actionName查看某个具体的action,也可以通过fastlane action查看fastlane中定义好的action及说明。

除了使用ruby代码在lane中实现的各种功能,fastlane也内置了许多写好的独立方法库即action,每一个action都是一个独立Ruby脚本,是fastlane的最小执行单位,下面介绍几个常用的action

  • 1、cocoapods:执行pod install

  • 2、gym:项目编译、打包等

  • 3、increment_build_number:build号自增

  • 4、match:管理证书和配置文件

  • 5、app_store_connect_api_key:为其他 action 生成 App Store Connect API token

1、cocoapods

调用cocoapods action会执行pod install,如果工程中使用了cocoapods管理三方库,需要在Gemfile中添加以下命令

gem "cocoapods"

在lane中使用直接调用即可

lane :debug do
  cocoapods  # 执行 pod install
end

2、gym

gym是fastlane提供的用于构建、打包的action,是build_app的别名。可以根据配置参数编译iOS应用,并生成ipa包。以下是常用的参数

  • workspace:workspace 文件路径,使用 cocoapods 后需要使用

  • project:project 文件路径,若有 workspace 此项可忽略

  • scheme: schema名

  • clean:是否在编译前清理工程

  • configuration:编译环境设置,Relese、Debug、自定义

  • export_method:包输出类型,app-store、ad-hoc、package、enterprise、development

  • archive_path : 读取xarchive的路径

  • output_directory:ipa包输出路径

  • output_name:ipa包名称

  • include_symbols:是否集成调试符号,若为 false 则会单独生成符号文件

  • include_bitcode: 是否开启bitcode

其他参数如下:

  • export_options: 可以指定更详细的打包配置,可以是配置文件路径

  • skip_build_archive: 跳过构建,打包阶段,直接签名;使用archive_path 作为输入

  • skip_archive:仅构建

  • skip_codesigning: 仅构建,打包,不签名

以下是CI中具体的例子

lane :release do
  # ...
  gym(
      workspace: "app.xcworkspace",
      scheme: "app",
      # 打包前clean
      clean: true,
      # Release、Debug、自定义
      configuration: "Release",
      # app-store, ad-hoc, package, enterprise, development
      export_method: "ad-hoc",
      # 文件输出路径
      output_directory: "/Users/user/Desktop/",
      # ipa名称
      output_name: "app.ipa",
      # 是否包含调试符号
      include_symbols: true,
      # 是否开启bitcode
      include_bitcode: false,
    )
    # ...
end

3、increment_build_number

  • 通过app_store_build_number获取最新版本的build_number
currentBuildNumber = app_store_build_number(
  api_key: api_key,        # 使用app_store_connect_api_key认证
  live: false,             #  live 为 true 查询已发售的版本,false 查询测试的版本
  app_identifier: "com.zm.ZLGithubClient"
)
  • 通过increment_build_number设置项目的build_number
increment_build_number(
  build_number: currentBuildNumber + 1
)

4、match

match主要用于证书和配置文件的管理,用于开发成员之间共享,具体的过程可参考代码签名指南

match配置步骤

  • 在gitLab上创建一个仓库用于保存证书

  • 在项目根目录下执行 fastlane match init,输入仓库的git地址,会在fastlane目录下生成Matchfile文件,包含match的配置信息

match参数说明

  • type: 同步的配置文件类型: appstore,adhoc,development,enterprise,默认 development

  • readonly: 默认false,如果是true,不会生成新的证书和描述配置文件

  • app_identifier: 指定描述配置文件的bundle id;如果不指定则使用 AppFile 中的 app_identifier

  • git_url: 证书保存的git地址

  • keychain_name : 保存证书的keychain,默认login.keychain

以下是在CI中使用match的例子

# match 中需要配置参数
# 证书类型   appstore , adhoc 还是 development
# app id  
# kaychain 证书保存的keychain
# git_url  证书保存的github远端库
lane :github_action_testFlight do

match(type: "appstore",       
      readonly: true,
      app_identifier: "com.used.id",
      keychain_name: ENV['MATCH_KEYCHAIN_NAME'],
      keychain_password: ENV['MATCH_PASSWORD'],
      git_url: ENV['MATCH_GIT_URL'])

end

# ENV['MATCH_GIT_URL'] 是加密保存的参数

match说明

  • 执行这些命令fastlane match development, fastlane match adhoc, fastlane match enterprisefastlane match appstore,首次执行自动在apple store connect中创建provisioning file,证书并下载加密保存在git仓库,并上传.

  • 其他开发者就可以使用fastlane match命令共享github中的证书和配置文件。

5、App Store Connect API

在早先访问App Store Connect信息时需要双因素认证。而在持续集成的过程中一般无法人机交互(例如github-action),会导致持续集成无法完成。在WWDC18中,苹果提出了App Store Connect API,提供另外的认证方式。Fastlane也对App Store Connect API提供了支持,具体查看Using App Store Connect API文档

Using App Store Connect API是一个官方公共API,用于管理应用元数据、定价、配置等。但是在使用之前,需要获取AppStore Connect的访问权限,即获取issuer ID和apiKey

app_store_connect_api_key 是用来为其他 action 生成 App Store Connect API token 的 action; match,pilot以及 deliver等 action 都可以使用 App Store Connect API token,有如下参数:

  • key_id:密钥ID,需要在AppStore Connect -> 用户和访问 -> 密钥中创建并获取

  • issuer_id:标识创建认证令牌的发放者。也是在AppStore Connect -> 用户和访问 -> 密钥中获取

  • key_filePath: p8文件的路径

  • key_content: p8文件的内容,未编码直接提供需要将回车替换为\n

  • is_key_content_base64: 是否key的内容经过base64编码

  • in_house: 是app store还是 enterprise

以下是在CI中的使用例子

lane :release do
  api_key = app_store_connect_api_key(
    key_id: "xxxxxxxxx",
    issuer_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    key_filepath: "./private_keys/AuthKey_xxxxxxxxxx.p8",
    duration: 1200, # optional
    in_house: false, # optional but may be required if using match/sigh
  )
   
  # 在piolt中使用app_store_connect_api_key
  pilot(api_key: api_key)
end

Lanes

lanes的使用方式请参考文档,可以分为以下几种:

  • 命令行参数传递

  • lane之间的调用

  • 返回值

  • 如何停止执行中的lane

  • lane的上下文通信

  • 如果访问lane的属性

  • 私有lane

  • 如何配置多个lane的环境变量

命令行参数传递

1、传递:如果需要将参数从命令行传递到lane,语法如下

//格式
fastlane [lane] key:value key2:value2

//举例
fastlane deploy submit:false build_number:24

2、使用:在lanes中接收传入的值,是通过options实现的,格式为:options[:参数名]

lane :deploy do |options| 
    # 获取传入的submit
    if options[:submit] 
        # Only when submit is true 
    end 
    # build号增加,获取传入的build_number 
    increment_build_number(build_number: options[:build_number])  
 end

lane之间的调用

lane之间的调用,有点类似于函数的调用,通过lane的名字来调用

lane :deploy do |options|
  # deploy调用名为build的lanes
  build(release: true) # 传入打包的方式
end

lane :staging do |options|
  # deploy调用名为build的lanes
  build # 不传参也可以工作
end

lane :build do |options|
  build_config = (options[:release] ? "Release" : "Staging")
  build_ios_app(configuration: build_config)
end

返回值

除此之外,lane还可以检索返回值,在Ruby中,lane定义的最后一行是返回值,在其他lane中通过options进行使用

lane :deploy do |options|
  value = calculate(value: 3)
  puts value # => 5
end

lane :calculate do |options|
  # 返回值
  2 + options[:value] # the last line will always be the return value
end

如何停止执行中的lane

在lane中可以通过next关键字来停止执行中的lane,如下所示

lane :build do |options|
  if cached_build_available?
    UI.important 'Skipping build because a cached build is available!'
    next # skip doing the rest of this lane
  end
  match
  gym
end

private_lane :cached_build_available? do |options|
  # ...
  true
end

当next作用在切换lane时,会控制流程返回到前lane正在执行的位置,即当前lane的next以后的代码将不会执行

lane :first_lane do |options|
  puts "If you run: `fastlane first_lane`"
  puts "You'll see this!"
  second_lane
  puts "As well as this!"
end

private_lane :second_lane do |options|
  next
  puts "This won't be shown"
end

lane的上下文通信

lane的上下文通信,简单来说就是如何在不同的action间通信。不同的action可以通过分享哈希来进行通信,如下所示

lane_context[SharedValues::VARIABLE_NAME_HERE]

//举例
lane_context[SharedValues::BUILD_NUMBER]                # Generated by `increment_build_number`
lane_context[SharedValues::VERSION_NUMBER]              # Generated by `increment_version_number`
lane_context[SharedValues::SNAPSHOT_SCREENSHOTS_PATH]   # Generated by _snapshot_
lane_context[SharedValues::PRODUCE_APPLE_ID]            # The Apple ID of the newly created app
lane_context[SharedValues::IPA_OUTPUT_PATH]             # Generated by _gym_
lane_context[SharedValues::DSYM_OUTPUT_PATH]            # Generated by _gym_
lane_context[SharedValues::SIGH_PROFILE_PATH]           # Generated by _sigh_
lane_context[SharedValues::SIGH_UDID]                   # The UDID of the generated provisioning profile
lane_context[SharedValues::HOCKEY_DOWNLOAD_LINK]        # Generated by `hockey`
lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]      # Generated by `gradle`
lane_context[SharedValues::GRADLE_ALL_APK_OUTPUT_PATHS] # Generated by `gradle`
lane_context[SharedValues::GRADLE_FLAVOR]               # Generated by `gradle`
lane_context[SharedValues::GRADLE_BUILD_TYPE]           # Generated by `gradle`

如果访问lane的属性

我们也可以通过lane_context动态访问当前lane的属性,如下所示

lane_context[SharedValues::PLATFORM_NAME]        # Platform name, e.g. `:ios`, `:android` or empty (for root level lanes)

lane_context[SharedValues::LANE_NAME]            # The name of the current lane preceded by the platform name (stays the same when switching lanes)

lane_context[SharedValues::DEFAULT_PLATFORM]     # Default platform

同时这些属性也可用作.env文件的环境变量

ENV["FASTLANE_PLATFORM_NAME"]
ENV["FASTLANE_LANE_NAME"]

私有lane

当我们有不同lane调用同一个lane时,可以将这个lane定义为私有的lane,防止在外部通过fastlane laneName进行调用,如下所示,我们不能通过fastlane build来访问私有的lane

lane :production do
  # ...
  build(release: true)
  appstore # Deploy to the AppStore
  # ...
end

lane :beta do
  # ...
  build(release: false)
  crashlytics # Distribute to testers
  # ...
end

lane :build do |options|
  # ...
  ipa
  # ...
end

<!--更改为-->
lane :production do
  # ...
  build(release: true)
  appstore # Deploy to the AppStore
  # ...
end

lane :beta do
  # ...
  build(release: false)
  crashlytics # Distribute to testers
  # ...
end

private_lane :build do |options|
  # ...
  ipa
  # ...
end

如何配置多个lane的环境变量

通常Appfile只会使用配置项的第一个值,如下所示,app_identifier配置了两个值,我们一般只会取第一个com.used.id,而com.ignored.id将被忽略。

app_identifier "com.used.id"
app_identifier "com.ignored.id"

为了避免以上情况,fastlane提供了for_lanefor_platform来解决多个配置的情况,所有的配置文件中都可使用

  • for_lane:当调用的lane和指定的lane名称匹配时,会调用对应的block
locales ['en-US', 'fr-FR', 'ja-JP']

for_lane :screenshots_english_only do
  locales ['en-US']
end

for_lane :screenshots_french_only do
  locales ['fr-FR']
end
  • for_platform:根据当前的platform决定执行的block
app_identifier "com.default.id"

for_lane :enterprise do
  app_identifier "com.forlane.enterprise"
end

for_platform :mac do
  app_identifier "com.forplatform.mac"

  for_lane :release do
    app_identifier "com.forplatform.mac.forlane.release"
  end
end

Fastlane配置

Fastlane的配置主要分为三步:

  • fastlane初始化
  • 创建.env配置全局变量,并修改Appfile
  • fastfile完善打包逻辑

【第一步】fastlane的初始化配置

主要执行以下命令

cd [项目根目录,xcodeproj的同级目录]
fastlane init

init操作主要完成以下操作:

  • 1、会要求输入开发者账户和密码,会存储在钥匙串中,后续使用无需再输入密码

  • 2、会检测当前项目的App Identifier是否已经存在Developer中

  • 3、会检测App是否已经在AppStore Connect中,如果都满足,过程是比较顺利的

  • 4、会在项目工程的目录下生成一个fastlane文件夹,里面是Fastlane的一些配置文件。fastlane可以通过配置AppfileDeliverfileFastfile 来完成各种工作。

    fastlane文件夹

下面分别介绍下fastlane文件夹中常用的文件:

  • Appfile:存放 App 的基本信息包括 App_Identifier 、AppID 、Team_ID 等。这个文件的参数是已经定义好的,新增并没有用

  • Fastfile:最核心的用来控制流程走向的配置文件,是最重要的一个文件,在这个文件里面可以编写和定制我们打包脚本的一个文件,所有自定义的功能都写在这里

  • Deliverfile:可用于配置提交AppStore Connect的一些参数。(注:这个文件只有在选择由fastlane管理metadata为YES时,才会生成,如下所示)

    Deliverfile是否有的选择

  • .env或.env.default:为了更灵活的使用Appfile的内容,可以使用.env文件来进行环境配置,新增其他的变量在fastfile中使用

除此之外,还需要关注fastlane同级的Gemfile和Gemfile.lock文件

  • Gemfile:私用bundler来安装和引入该App所需的gem,类似于cocoapods中的podfile

  • Gemfile.lock:gem的版本控制文件,类似于cocoapods的podfile.lock文件

【第二步】创建.env配置全局变量,并修改Appfile

注:因为是.env文件是.开头文件,默认是在finder中隐藏的,可以通过快捷键来显示隐藏文件:CMD + Shift + .

为了更加灵活的使用Appfile的内容,可以引入.env文件来进行环境配置,具体的请参考
.env环境配置文档。这样在执行命令时,可以在气候加上环境变量,以达到使用不同配置的目的。

其命名规则为:.env.<environment>,例如:.env.development、.env.release。

  • 在命令中如下使用
fastlane <lane-name> --env development
  • Appfile中使用.env,直接读取变量即可
//.env内容
WORKSPACE=YourApp.xcworkspace 
HOCKEYAPP_API_TOKEN=your-hockey-api-token

//Fastfile中使用.env文件
xcworkspace ENV['WORKSPACE']
hockey-api-token ENV['HOCKEYAPP_API_TOKEN'] 

【第三步】Fastfile完善打包逻辑

编辑fastlane的逻辑可参考fastlane自动部署iOS AppStore文档,构建步骤如下:

  • 更新pod,清理缓存

  • 版本号修改

  • 编译并打包

  • 上传AppStore

以下是具体的编译、打包代码

# 因为fastlane存在新老版本兼容问题,所以一般会指定fastlane版本
fastlane_version "2.205.1"
# 设置默认的平台
default_platform(:ios)

# ======================== .env文件配置获取相关参数 ========================
# 定义两个方便使用和修改的常量
scheme = ENV['Scheme'] #scheme名称
xcodeproj = ENV['Xcodeproj'] 
workspace = ENV['Workspace']
info_plist = ENV['Info_Plist']

# 定义指定平台的操作
platform :ios do

  # ======================== 执行lane前的操作 ========================
  # 所有lane执行之前,可以做如执行cocoapods的pod install
  before_all do |lane, options|
    # 更新pod
    cocoapods(use_bundle_exec: FALSE)
    # 清理缓存
    xcclean(scheme: scheme)
  end

  # 将正式应用打包并上传到App Store,release是自己取的名字,因为是发布正式版,所以取名叫 release
  desc "Push a new release build to the App Store"
  lane :release do

    # 打包之前,先将build号递增
    increment_build_number(xcodeproj: "#{xcodeproj}")

    # 对应用进行打包
    build_app(
      workspace: "#{workspace}",
      scheme: "#{scheme}",
      export_method: "app-store",
      clean: true,
      xcargs: "-allowProvisioningUpdates"
    )

    # 将打包完的应用上传到AppStore
    upload_to_app_store(
      skip_metadata: true,
      skip_screenshots: true
    )
  end
  
  # ======================== 执行lane成功后的操作 ========================
  # 所有lane完成之后,可以使用参数lane来区分
  after_all do |lane, options|
    puts "所有lane执行完毕"
  end

# ======================== 执行lane失败后的操作 ========================
  # 所有lane失败之后,可以使用参数lane来区分
  error do |lane, options|
    puts "lane执行失败"
  end
  
end

到此,fastlane就配置完成了

Fastlane使用

  • 跳转到项目的根目录:cd [项目根目录]
  • 自动打包并上传到AppStore Connect,在终端执行lane:fastlane lanename

除了可以在本地终端执行,还可以在Jenkins构建打包服务,具体步骤参考文档:Jenkins集成fastlane

最终,Fastlane整体流程汇总如下


Fastlane打包流程图示

iOS中打包方式汇总如下


iOS 打包-流程图示

参考文章

Fastlane一键自动化打包发布 iOS 项目
使用fastlane进行iOS打包
iOS 使用fastlane实现自动化打包
Fastlane
使用fastlane打包并发布iOS应用(一) - 新思路
bundler官网 - 局部使用fastlane
使用 Fastlane 自动打包应用 - block全面

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

推荐阅读更多精彩内容