iOS 常见知识点(二):RunLoop

注:本篇博客只在 ibireme深入理解RunLoop 基础上做了点方便自己复习该知识点的修改,能力有限,如果有理解的不对的地方,还希望能帮忙指出。

首先了解一下程序、进程和线程

程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。而线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

一般来说,一个线程一次只能执行一个任务,执行完成后线程就会退出。所以程序运行的时候,需要一个机制,让线程能随时处理事件但不退出。

以前写游戏的时候就写过这样的东西,通常是一个 do while 循环,让程序一直运转,直到接收到退出信息。

而 RunLoop 就是让线程在没有处理消息时休眠以避免资源占用,在有消息到来时立刻被唤醒。

线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时默认是没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。

在 CoreFoundation 里面关于 RunLoop 有5个类:

CFRunLoopRef
CFRunLoopModeRef
CFRunLoopSourceRef
CFRunLoopTimerRef
CFRunLoopObserverRef

他们的层级关系为,一个 RunLoop 对象包含若干个 Mode 对象,每个 Mode 又包含若干个 Source/Timer/Observer,RunLoop 在运作的时候,一次只能运作与一个 Mode 之下,如果需要切换 Mode,需要退出 Loop 才能重新指定一个 Mode。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

而一个 Source 对象是一个事件,Source 有两个版本:Source0 和 Source1,Source0 只包含一个函数指针,并不能主动触发,需要将 Source0 标记为待处理,在 RunLoop 运转的时候,才会处理这个事件(如果 RunLoop 处于休眠状态,则不会被唤醒去处理),而 Source1 包含了一个 mach_port 和一个函数指针,mach_port 是 iOS 系统提供的基于端口的输入源,可用于线程或进程间通讯。而 RunLoop 支持的输入源类型中就包括基于端口的输入源,可以做到对 mach_port 端口源事件的监听。所以监听到 source1 端口的消息时,RunLoop 就会自己醒来去执行 Source1 事件(也能称为被消息唤醒)。也就是 Source0 是直接添加给 RunLoop 处理的事件,而 Source1 是基于端口的,进程或线程之间传递消息触发的事件(为什么要 0 和 1 来命名,每次都记不住,GG)。

Timer 是基于时间的触发器,CFRunLoopTimerRef 和 NSTimer 可以通过 Toll-free bridging 技术混用,Toll-free bridging 是一种允许某些 ObjC 类与其对应的 CoreFoundation 类之间可以互换使用的机制,当将 Timer 加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行 Timer 回调。

Observer(观察者)都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
};

也就是可以在这几个时机去安排 RunLoop 执行一些其他的任务。

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

RunLoop 的 CFRunLoopMode 和 CFRunLoop 的结构大致如下:

struct __CFRunLoopMode {
    CFStringRef _name;            // Mode Name, 例如 @"kCFRunLoopDefaultMode"
    CFMutableSetRef _sources0;    // Set
    CFMutableSetRef _sources1;    // Set
    CFMutableArrayRef _observers; // Array
    CFMutableArrayRef _timers;    // Array
    ...
};
 
struct __CFRunLoop {
    CFMutableSetRef _commonModes;     // Set
    CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
    CFRunLoopModeRef _currentMode;    // Current Runloop Mode
    CFMutableSetRef _modes;           // Set
    ...
};

这里有个概念叫 "CommonModes":一个 Mode 可以将自己标记为"Common"属性(通过将其 ModeName 添加到 RunLoop 的 "commonModes" 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 "Common" 标记的所有Mode里。

如上文说的 RunLoop 一次循环只能运行在一个 Mode 下,是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。但如果一个 Source/Timer/Observer 想在多个 Mode 下运作,则可以分别加入到多个 Mode,或者给两个 Mode 添加 "Common" 标记,再将 Source/Timer/Observer 加入到 RunLoop 的 "commonModeItems" 。

应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为"Common"属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,如果想让它回调则可以将这个 Timer 分别加入这两个 Mode。或将 Timer 加入到顶层的 RunLoop 的 "commonModeItems" 。

CFRunLoop 对外暴露的管理 Mode 接口只有下面2个:

CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
CFRunLoopRunInMode(CFStringRef modeName, ...);

Mode 暴露的管理 mode item 的接口有下面几个:

CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);
CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopRemoveObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopRemoveTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);

可以注意到 mode 并不像 Source/Timer/Observer 一样有 Remove 方法,所以 mode 只能增加,不能减少。

当你传入一个新的 mode name 但 RunLoop 内部没有对应 mode 时,RunLoop会自动帮你创建对应的 CFRunLoopModeRef。

接下来看 RunLoop 的执行逻辑

图片来源:ibireme.png

按 1 - 10 来理理,首先要知道 Observer 是观察者,也就是下面这几种状态都会通知观察者,开发者也添加一个观察者,去在以下几种状态的时候,执行一些任务,比如将没啥实时性要求的东西,在即将进入休眠状态时执行。

    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop

其中第 2 步虽然通知 Observer 即将处理 Timer,但其实并没有真的即将处理 Timer 回调,这个通知每次 Loop 循环都会调用,但 Timer 只有在注册的时间到了才会在第 9 步去执行,第 4 步处理运行的 mode 里待处理的 Source0,其中第 5 步会判断 mode item 里是否有 Source1 处于 ready 状态(也就是 Source1 的端口已经收到消息),有的话跳到第 9 步,处理 Source1 事件,然后进入下一个循环,没有的话说明 mode item 里的事件都处理完毕,线程进入休眠状态,等待 Source1,Timer 或者外部手动将 RunLoop 唤醒(上文说 Source0 并不能唤醒 RunLoop,所以一般会通过手动唤醒 RunLoop,来让 RunLoop 处理新加入进去的 Source0)。

可以看到,实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里,去执行加入到 RunLoop 里的 Source0,Timer 的回调,以及 Observer 回调,以及用于线程或进程间通讯的 Source1,当所有的都处理完之后,结束一次循环,进入休眠状态,休眠的时候等待 Timer 注册的时间点或者 Source1 唤醒 RunLoop(也可以手动唤醒)。

从上面我们可以了解,线程和进程之间的通讯是基于 mach port 传递消息实现的,这也是 RunLoop 的核心。有必要了解一下 mach port, OSX/iOS 的系统架构分为 4 层,从外到内为应用层,应用框架层,核心框架层,Darwin。应用层包括用户能接触到的图形应用,应用框架层即开发人员接触到的 Cocoa 等框架,核心框架层包括各种核心框架、OpenGL 等内容,Darwin 即操作系统的核心,包括系统内核、驱动、Shell 等内容。

Darwin 核心的架构:

图片来源:ibireme_2.png

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。

XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。

BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。
IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

在 Mach 中,进程、线程和虚拟内存都被称为"对象"。Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。"消息"是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。

Mach 中的对象通过一个 Mach 端口发送一个消息,消息中会携带目标端口,这个消息会从用户空间传递到内核空间,再由内核空间传递到目标端口,实现线程或进程之间的通讯。(也就是线程或进程之间的通讯不能绕过系统内核)。目标端口接收到消息,因为 RunLoop 会对 mach_port 端口源进行监听,如果 RunLoop 此时处于休眠状态,则被唤醒,便可以处理已经接收到消息的 source1 事件。

RunLoop 实现了很多功能

启动后,系统默认注册了5个Mode:

1:kCFRunLoopDefaultMode: App的默认 Mode,通常主线程是在这个 Mode 下运行的。

2:UITrackingRunLoopMode: 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。

3:UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。

4:GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。

5:kCFRunLoopCommonModes: 这是一个占位的 Mode,没有实际作用。

AutoreleasePool

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()(因为需要设置不同的优先级,所以注册两个)。

第一个 Observer 监视的事件是 Entry(即将进入Loop),用来创建自动释放池,且设置的优先级最高,保证创建释放池发生在其他所有回调之前。

第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时去释放旧的池并创建新池;Exit(即将退出Loop) 时释放自动释放池。这个 Observer 的优先级最低,保证其释放池子发生在其他所有回调之后。

在主线程执行的代码,通常是写在诸如事件回调、Timer 回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏。

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的事件传递。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个 Observer 的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行 GestureRecognizer 的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay 方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。也就是 UI 是在界面 RunLoop 休眠之前更新的,所以如果想在 UI 更新之后做一些事情,可以注册一个 Observer 监听 kCFRunLoopAfterWaiting(刚从休眠中唤醒)。

定时器

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

GCD

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。

网络请求

iOS 中,关于网络请求的接口自下至上有如下几层:

CFSocket
CFNetwork       ->ASIHttpRequest
NSURLConnection ->AFNetworking
NSURLSession    ->AFNetworking2, Alamofire

通常使用 NSURLConnection 时,你会传入一个 Delegate,当调用了 [connection start] 后,这个 Delegate 就会不停收到事件回调。实际上,start 这个函数的内部会会获取 CurrentRunLoop,然后在其中的 DefaultMode 添加了多个需要手动触发的 Source0。

当开始网络传输时,NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoader 和 com.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 基于 mach port 的 Source1 接收来自底层 CFSocket 的消息,当收到消息后,其会在合适的时机将 Source0 标记为待处理,同时唤醒 Delegate 线程的 RunLoop 来让其处理 Source0。完成一个由 CFSocket 线程到网络请求所在线程的数据处理。

AFNetworking

AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 创建了一个线程,并在这个线程中启动了一个 RunLoop,RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run] 之前先创建了一个新的 NSMachPort 添加进去了。只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。当需要这个后台线程执行网络请求任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..] 将这个任务扔到了该线程的 RunLoop 中。

AsyncDisplayKit

AsyncDisplayKit 是 Facebook 推出的用于保持界面流畅性的框架,其原理大致如下:

UI 线程中一旦出现繁重的任务就会导致界面卡顿,这类任务通常分为3类:排版,绘制,UI对象操作。

排版通常包括计算视图大小、计算文本高度、重新计算子式图的排版等操作。
绘制一般有文本绘制 (例如 CoreText)、图片绘制 (例如预先解压)、元素绘制 (Quartz)等操作。
UI对象操作通常包括 UIView/CALayer 等 UI 对象的创建、设置属性和销毁。

其中前两类操作可以通过各种方法扔到后台线程执行,而最后一类操作只能在主线程完成,并且有时后面的操作需要依赖前面操作的结果 (例如TextView创建时可能需要提前计算出文本的大小)。ASDK 所做的,就是尽量将能放入后台的任务放入后台,不能的则尽量推迟 (例如视图的创建、属性的调整)。

为此,ASDK 创建了一个名为 ASDisplayNode 的对象,并在内部封装了 UIView/CALayer,它具有和 UIView/CALayer 相似的属性,例如 frame、backgroundColor 等。所有这些属性都可以在后台线程更改,开发者可以只通过 Node 来操作其内部的 UIView/CALayer,这样就可以将排版和绘制放入了后台线程。但是无论怎么操作,这些属性总需要在某个时刻同步到主线程的 UIView/CALayer 去。

ASDK 仿照 QuartzCore/UIKit 框架的模式,实现了一套类似的界面更新的机制:即在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务(需要将 Node 的属性同步到主线程的 UIView/CALayer 去的任务),然后一一执行。

总结:

ibireme深入理解RunLoop 前前后后我看了好几遍,每次都看的不深入,总是瞬时了解,睡一觉就记不清楚细节了,这次自己逐行去理解,并写下来,加上点自己的理解,也写了点代码去观察 RunLoop,算是对 RunLoop 的理解更加深入了。

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

推荐阅读更多精彩内容