使用 Xcode 制作 Framework 与 XCFramework

使用 Xcode 制作 Framework 与 XCFramework

最近公司有个项目外包,我就负责提供离在线语音识别 SDK 和数据埋点 SDK 封装,在制作 Framework 的过程中,遇到了很多问题。所以在这篇文章里我们会主要介绍下 如何制作 Frameworks ,以及如何解决遇到的一些问题。

编译过程简述

在制作 Framework 之前,我想简单阐述下编译器的工作原理,这有助于我们理解静态库动态库的制作。如果想了解编译器的详细设计,请点这里

我们都知道,计算机没法直接理解我们人类用高级语言写的程序,所以编译器可以帮助我们将高级语言写的程序转换成计算机能懂的二进制。下面我们结合一个简单的 C 程序来讲解下具体的编译过程。

使用 GCC 编译源程序

GCC(GNU Compiler Collection,GNU编译器套件)是由GNU开发的编程语言译器。GNU编译器套件包括C、C++、 Objective-C、 Fortran、Java、Ada和Go语言前端,也包括了这些语言的库(如libstdc++,libgcj等。)

GCC 的初衷是为 GNU 操作系统专门编写的一款编译器。GNU 系统是彻底的自由软件,此处“自由”的含义是它尊重用户的自由。

编写源程序

我们先编写一个简单的 C 程序,然后存储为 hello.c 源文件。

#include <stdio.h>
int main()
{
    printf("hello, world\n");
    return 0;
}

GCC 编译

  • 编写好源程序后,在控制终端输入以下命令,gcc 自动会完成所有编译过程,最后输出可执行文件 hello。

    gcc -o hello hello.c
    
  • 在终端运行可执行文件 hello,终端就会输出 hello, world

    ./hello
    

我们可以看到,一个 gcc 命令就将源程序转变成了可执行文件,它使用源程序编译变得非常简单。但我们还是得深究下,gcc 究竟做了些啥事。接下来,我们就简单介绍下编译器的四个阶段:预处理、编译、汇编,以及链接。

编译过程

预处理阶段

CPP,即 C Pre-Processor, 即 C 预处理器,是一个独立于C 编译器的小程序,预编译器并不理解 C 语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能。

hello.c 源程序为例,CPP 会根据 <font color=green>#</font> 开头的指令来修改源程序。比如 hello.c 中的第一行 <font color=green>#include <stdio.h></font> 就是告诉预处理器去把系统头文件 <font color=green>stdio.h</font> 内容读取出来,并直接插入到 hello.c 源程序中来,替换 <font color=green>#include <stdio.h></font>。

修改后的 C 程序一般另保存为 <font color=green>.i</font> 后缀的文本文件(本例为 hello.i),输出的 hello.i 将用于下一个阶段。

编译阶段

接下来编译器 cc1hello.i 编译成汇编程序,并保存为 <font color=green>.s</font> 后缀的汇编语言文本文件(本例为 hello.s)。

编译成汇编语言程序有个好处,就是对于不同的编译器,不同的高级语言,都会编译输出一样的汇编程序。

例如,C 编译器 和 Fortran 编译器,编译后都会输出同样的汇编程序。

汇编阶段

目标文件 (Object File)

在介绍汇编阶段前,我们先了了解下目标文件(Object File), 它其实有 3 种形式:

  • 可重定位目标文件 (Relocatable Object File)

    包含可与其它 relocatable object file 相结合的二进制代码和数据,由编译器和汇编器产生。

  • 可执行文件 (Executable Object File)

    包含可直接复制到内存并执行的二进制代码和数据,由链接器生成。

  • 共享目标文件 (Shared Object File)

    一种特殊的 relocatable object file,它可以被装载入内存,并且可以在装载或运行的时候动态地链接。

    由编译器和汇编器产生。

汇编阶段的工作内容

汇编阶段的工作就是将汇编文本程序翻译成机器指令,输出目标文件。

汇编器 (as) 将汇编程序 hello.s 翻译成机器指令,然后包装成可重定位目标程序(Relocatable Object Program),并将其结果保存在 hello.o 文件中。hello.o 为二进制文件。

链接阶段

在我们的 hello 程序中,它调用了C 标准库中 <font color=green>printf</font> 方法,而 <font color=green>printf</font> 位于事先编译好的独立目标文件 <font color=green>printf.o</font> 中。所以为了能让 hello 程序运行起来,我们需要采取某种手段将 <font color=green>printf.o</font> 合并入 <font color=green>hello.o</font>。幸运的是,链接器(ld) 就是做这一工作。

经过链接器的合并操作后,输出 <font color=green>hello</font> 可执行文件。在终端命令行中输入 ./hello 回车后, <font color=green>hello</font> 可执行文件被装载入内存(通过加载器Loader来完成),并由系统执行,程序就跑起来了。

Library

我们在开发过程中,会把一些通用的函数制作成一个库,或者将一个功能??橹谱鞒梢桓隹?,然后提供给 App 使用,可以达到共享复用的目的。

Library 可以理解成目标文件的集合,将相关的目标文件打包在一起,就成了一个 Library。我们按照 Library 是如何链接到 App 中的,可以把 Library 分成静态库和动态库。

静态库(Static Libraries)

下图为 App 用到了静态库的情况。App 自身的代码被编译成目标文件后,通过静态链接器将App的目标文件与静态库合并,并生成的可执行文件。这样,App 自身代码生成的目标文件与静态库都被拷贝到可执行文件中,从而静态库也成为了 App 可执行文件的一部分。这样的库呢,我们称之为 静态库,也称为 <I>static archive libraries</I>, 或 <I>static linked shared libraries</I>.

Link Static Libraries

存在形式

静态库主要以 .a.lib 的形式存在,在苹果生态系统中,还可以是 .framework.xcframework

特点

  • App 启动的时候就全部载入内存空间,所以在 App 运行过程中,需要使用依赖库的时候不需要额外从外部加载,速度快,但也增加了 App 的启动时间。

  • App 的可执行文件变大,占用内存也会相应增多。因为App依赖的所有静态库都会被静态链接器链接并拷贝到将要生成的 app 可执行文件中。

  • 当静态库需要修改时,必须得重新编译和发布静态库,所以不便于维护。

动态库(Dynamic Library)

动态库又 dynamic shared libraries, shared objects, or dynamically linked libraries。我们以 OS X 为例,当 App 启动时,操作系统内核会将 App 代码和数据载入新进程(也就是操作系统为 App 创建的新进程)的地址空间。与此同时呢,操作系统内核也会把动态加载器(Dynamic Loader) 载入进程,由动态加载器来完成加载 App 依赖的动态库。不过在启动阶段,动态加载器只会根据静态链接器中记录的 App 已链接的依赖库的名字,然后使用依赖库的 install name 来查找它们是否在文件系统中存在。如果不存在或不兼容,App 启动过程会中断。动态库被完全载入内存,是在代码里使用它的时候。所以相对静态库来说,使用动态库链接的 App 启动过程会更快。

Dynamic Link

存在形式

动态库主要以 .dylib,.so,dll 的形式存在,在苹果生态系统中,还可以是 .framework.xcframework 。

iOS App 的动态库存放在 .app bundle 下的 Frameworks 文件夹。

特点

  • App 按需装载,可以加速 App 的启动。

  • 动态库不会被拷贝到 App 的可执行文件中,所以可以动态按需加载。

  • 动态库的维护和更新很方便,只要 APIs 不变,依赖动态库的 App 就不用重新编译 。因为动态库并不是 App 可执行文件的一部分,是独立的,可动态加载的。

Apple FrameWorks

Framework

Framework 可以通俗的理解为封装了共享资源的具有层次结构的文件夹。共享资源可以是 nib文件、国际化字符串文件、头文件、库文件等等。它同时也是个 Bundle,里面的内容可以通过 Bundle 相关 API 来访问。Framework 可以是 static frameworkdynamic framework。<font color=red> 在 iOS App 打包完成后,如果 Framework 包含了模拟器指令集(x86_64 或 i386),那么用 Xcode 发布 App 的时候,会报 unsupported architectures 的错误,所以需要我们手动或脚本去移除。</font>

XCFramework

XCFramework 是由 Xcode 创建的一个可分发的二进制包,它包含了 framework 或 library 的一个或多个变体,因此可以在多个平台(iOS、macOS、tvOS、watchOS) 上使用,包括模拟器。XCFramework 可以是静态的,也可以是动态的。xcframework 的好处就是用 Xcode 发布的时候,Xcode 会自动选用正确的指令集 Frameworks,省去了手动移除动态库中的模拟器指令集的工作。<font color=red>不过值得注意的是,Xcode 11 才引入 XCFramework 。</font>

制作 Frameworks

关于如何用 Xcode 一步一步创建 Framework 工程的话,我就不多说了,网上一大把教程,您也可以参考 Building Cross Platform Universal FrameworksSwift Cross Platform Framework。我重点讲如何用脚本来制作各种类型的 frameworks。为什么要介绍脚本呢,网上不是很多脚本制作 frameworks 吗? 刚开始我也是直接用网上的脚本,可总会有这样那样的问题,各种错误,所以决定在参考大神们文章的同时,自己重新整理下。

到这里,我假设您的 framework 代码都已经写好了,打包的 Aggregation Target 也创建好了。接下来,我将直接讲用脚本来制作 frameworks 。至于是制作 static framework 还是 dynamic framework 可以在 framework targetBuild Settings 中的 Mach-O Type 选择 framework 的类型,一般选用 Dynamic Library 或者 Static Library 就行。

开发环境

本文中使用的开发环境为:

  • macOS Catalina 10.15.4
  • Xcode 11.5

.xcarchive 目录结构

在制作 universal frameworkxcframework,我们都会用到 .xcarchive 包,所以我们先来看下它的目录结构。

<img src="https://gitee.com/evanxlh/Resources/raw/master/blog/make-frameworks-xcode/xcarchive-contents.png" alt="xcarchive contents" style="zoom:67%;" />

制作 Universal Framework 脚本

你可以在这里直接获取制作 universal framwork 的完整脚本。

编译单个平台的函数

# 制作完 framework 后,是否在 Finder 中打开
REVEAL_FRAMEWORK_IN_FINDER=true 
# Framework 的名字
FREAMEWORK_NAME="${PROJECT_NAME}" 
# 制作好的 framework 会输出到这个文件夹下面
FREAMEWORK_OUTPUT_DIR="${PROJECT_DIR}/Distribution" 
# Device Archive 生成的 .xcarchive 存放路径。在工程的根目录下生成 Build 文件夹。
ARCHIVE_PATH_IOS_DEVICE="./Build/ios_device.xcarchive" 
# Simulator Archive 生成的 .xcarchive 存放路径。
ARCHIVE_PATH_IOS_SIMULATOR="./Build/ios_simulator.xcarchive"

# 我们可以编译更多平台的 xcarchive
# ARCHIVE_PATH_MACOS="./build/macos.xcarchive"

# 生成单个平台的 .xcarchive. 接收4个参数, scheme, destination, archivePath,指令集.
# xcpretty 可以删除,这里用来使 Xcode 输出的日志更加人性化。
function archiveOnePlatform {
    echo "? Starts archiving the scheme: ${1} for destination: ${2};\n? Archive path: ${3}"

    xcodebuild archive \
        -scheme "${1}" \
        -destination "${2}" \
        -archivePath "${3}" \
        VALID_ARCHS="${4}" \
        SKIP_INSTALL=NO \
    BUILD_LIBRARY_FOR_DISTRIBUTION=YES | xcpretty
    # BUILD_LIBRARY_FOR_DISTRIBUTION=YES

    # sudo gem install -n /usr/local/bin xcpretty
    # xcpretty makes xcode compile information much more readable.
}

编译所有平台的函数

以下方法可以编译并生成 iOS device, simulator 两个平台的 .xcarchive,此方法接收一个参数:scheme, 即 对应 app targetscheme。通常情况下,schemeframework name 是相同的。

function archiveAllPlatforms {

    # https://www.mokacoding.com/blog/xcodebuild-destination-options/

    # Platform              Destination
    # iOS                   generic/platform=iOS
    # iOS Simulator         generic/platform=iOS Simulator
    # iPadOS                generic/platform=iPadOS
    # iPadOS Simulator      generic/platform=iPadOS Simulator
    # macOS                 generic/platform=macOS
    # tvOS                  generic/platform=tvOS
    # watchOS               generic/platform=watchOS
    # watchOS Simulator     generic/platform=watchOS Simulator
    # carPlayOS             generic/platform=carPlayOS
    # carPlayOS Simulator   generic/platform=carPlayOS Simulator

    SCHEME=${1}

    archiveOnePlatform $SCHEME "generic/platform=iOS Simulator" ${ARCHIVE_PATH_IOS_SIMULATOR} "x86_64"
  archiveOnePlatform $SCHEME "generic/platform=iOS" ${ARCHIVE_PATH_IOS_DEVICE} "armv7 arm64"
  # archiveOnePlatform $SCHEME "generic/platform=macOS" ${ARCHIVE_PATH_MACOS}
}

这个方法执行完后,在本例中会得到以下 Build 文件夹内的内容:

<img src="https://gitee.com/evanxlh/Resources/raw/master/blog/make-frameworks-xcode/xcarchives.png" alt="Generated xcarchives" style="zoom:67%;" />

生成 Universal Framework的函数

function makeUniversalFramework {
    # xcarchive 包中的 Frameworks 目录相对路径
    FRAMEWORK_RELATIVE_PATH="Products/Library/Frameworks"
    
    # 接下来的三个路径分别是模拟器平台的framework路径,真机平台的framework路径,以及输出的universal framework路径
    SIMULATOR_FRAMEWORK="${ARCHIVE_PATH_IOS_SIMULATOR}/${FRAMEWORK_RELATIVE_PATH}/${FREAMEWORK_NAME}.framework"
    DEVICE_FRAMEWORK="${ARCHIVE_PATH_IOS_DEVICE}/${FRAMEWORK_RELATIVE_PATH}/${FREAMEWORK_NAME}.framework"
    OUTPUT_FRAMEWORK="${FREAMEWORK_OUTPUT_DIR}/${FREAMEWORK_NAME}.framework"

    mkdir -p "${OUTPUT_FRAMEWORK}"

    # Copy all the contents of iphoneos framework to output framework dir.
    cp -rf "${DEVICE_FRAMEWORK}/." "${OUTPUT_FRAMEWORK}"

    lipo "${SIMULATOR_FRAMEWORK}/${FREAMEWORK_NAME}" "${DEVICE_FRAMEWORK}/${FREAMEWORK_NAME}" \
        -create -output "${OUTPUT_FRAMEWORK}/${FREAMEWORK_NAME}"

    # For Swift framework, Swiftmodule needs to be copied in the universal framework
    if [ -d "${SIMULATOR_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/" ]; then
        cp -f "${SIMULATOR_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/*" "${OUTPUT_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/" | echo
    fi
    if [ -d "${DEVICE_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/" ]; then
        cp -f "${DEVICE_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/*" "${OUTPUT_FRAMEWORK}/Modules/${FRAMEWORK_NAME}.swiftmodule/" | echo
    fi
}

开始制作

echo "#####################"
echo "? Cleaning Framework output dir: ${FREAMEWORK_OUTPUT_DIR}"
rm -rf "$FREAMEWORK_OUTPUT_DIR"

echo "? Archive framework: ${FREAMEWORK_NAME}"
archiveAllPlatforms "$FREAMEWORK_NAME"

echo "? Make universal framework: ${FREAMEWORK_NAME}.framework"
makeUniversalFramework

# Clean Build
rm -rf "./Build"

if [ ${REVEAL_FRAMEWORK_IN_FINDER} = true ]; then
    open "${FREAMEWORK_OUTPUT_DIR}/"
fi

去除动态库中的模拟器指令集

正如之前提到的,App 打包过程中,需要将 App 依赖的动态库中的模拟器指令集去除,这里是完整的脚本, 如何使用,请点 Stripping unwanted architectures from dynamic libraries。在 app targetBuild Phase 下新建 Run Script,并放到 Embed Frameworks 下面,然后将脚本复制进去就行。

#!/bin/sh

APP_PATH="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"

echo $APP_PATH

# This script loops through the frameworks embedded in the application and
# removes unused architectures.
find "$APP_PATH" -name '*.framework' -type d | while read -r FRAMEWORK
do
    FRAMEWORK_EXECUTABLE_NAME=$(defaults read "$FRAMEWORK/Info.plist" CFBundleExecutable)
    FRAMEWORK_EXECUTABLE_PATH="$FRAMEWORK/$FRAMEWORK_EXECUTABLE_NAME"
    echo "Executable is $FRAMEWORK_EXECUTABLE_PATH"

    EXTRACTED_ARCHS=()

    for ARCH in $ARCHS
    do
        echo "Extracting $ARCH from $FRAMEWORK_EXECUTABLE_NAME"
        lipo -extract "$ARCH" "$FRAMEWORK_EXECUTABLE_PATH" -o "$FRAMEWORK_EXECUTABLE_PATH-$ARCH"
        EXTRACTED_ARCHS+=("$FRAMEWORK_EXECUTABLE_PATH-$ARCH")
    done

    echo "Merging extracted architectures: ${ARCHS}"
    lipo -o "$FRAMEWORK_EXECUTABLE_PATH-merged" -create "${EXTRACTED_ARCHS[@]}"
    rm "${EXTRACTED_ARCHS[@]}"

    echo "Replacing original executable with thinned version"
    rm "$FRAMEWORK_EXECUTABLE_PATH"
    mv "$FRAMEWORK_EXECUTABLE_PATH-merged" "$FRAMEWORK_EXECUTABLE_PATH"
done

制作 XCFramework 脚本

生成 Universal Framework的函数

你可以在这里直接获取制作 xcframwork 的完整脚本。编译单个平台和全部平台的函数跟制作 Framework 一样,所以这里直接列出制作 xcframework 的函数。<font color=red>制作 XCFramewrok 的时候,需要将 Build Settings 中的 Build Libraries for Distribution 设置为 YES。不过即使不设置,脚本也会将其设置为 YES。</font>

function makeXCFramework {

    FRAMEWORK_RELATIVE_PATH="Products/Library/Frameworks"
    OUTPUT_DIR="${FREAMEWORK_OUTPUT_DIR}/DynamicFramework"

    mkdir -p "${OUTPUT_DIR}"

    xcodebuild -create-xcframework \
        -framework "${ARCHIVE_PATH_IOS_DEVICE}/${FRAMEWORK_RELATIVE_PATH}/${FREAMEWORK_NAME}.framework" \
        -framework "${ARCHIVE_PATH_IOS_SIMULATOR}/${FRAMEWORK_RELATIVE_PATH}/${FREAMEWORK_NAME}.framework" \
        -output "${OUTPUT_DIR}/${FREAMEWORK_NAME}.xcframework"
}

开始制作

echo "#####################"
echo "? Cleaning XCFramework output dir: ${FREAMEWORK_OUTPUT_DIR}"
rm -rf $FREAMEWORK_OUTPUT_DIR

#### Make XCFramework

echo "? Archive framework: ${FREAMEWORK_NAME}"
archiveAllPlatforms $FREAMEWORK_NAME

echo "? Make framework: ${FREAMEWORK_NAME}.xcframework"
makeXCFramework

# Clean Build
rm -rf "./Build"

if [ ${REVEAL_XCFRAMEWORK_IN_FINDER} = true ]; then
    open "${FREAMEWORK_OUTPUT_DIR}/"
fi

最后生成的 xcframework 长这个样子:

<img src="https://gitee.com/evanxlh/Resources/raw/master/blog/make-frameworks-xcode/xcframe.png" alt="Generated xcarchives" style="zoom:67%;" />

小技巧

  • 查看 framework 包含的指令集

    lipo -info /path/to/xxx.framework/xxx
    
  • 查看 dynamic framework 是否支持 bitcode

    otool -arch armv7 -l /path/to/xxx.framework/xxx | grep __bundle
    

    如果包含 bitcode, 你会看到 <font color=red>sectname __bundle</font> 信息。如果不包含,Terminal 输出为空。

  • 查看 static framework 是否支持 bitcode

    otool -arch armv7 -l /path/to/xxx.framework/xxx | grep __bitcode
    

    如果包含 bitcode, 你会看到 <font color=red>sectname __bitcode</font> 信息。如果不包含,Terminal 输出为空。

参考

非常感谢以下文章的贡献者,使我对编译原理相关知识,以及对静态库、动态库、framework 有了更深刻的认识。

GCC

GCC 百科

GCC, the GNU Compiler Collection

C Pre-Processor

Compiler Design Overview

Apple Frameworks

Dynamic Library Programming Topics

Mach-O Programming Topics

Code Loading Programming Topics

Framework Programming Guide

Stripping Unwanted Architectures From Dynamic Libraries In Xcode

XcodeBuild Destination

Xcode Build Settings

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