iOS开发之多线程(GCD与NSOperation)

概述

iOS开发中,多线程是必然碰到的,自己这两天有空稍微总结了一下。简单的概念如线程/进程等就不说了。

何为多线程?

多线程其实针对单核的CPU来设计的,CPPU同一时间只能执行一条线程,耳朵线程就是让CPU快速的在多个线程之间进行调度

多线程优点:

  • 能够适当提高资源利用率
  • 能够适当提高资源利用率

缺点:

  • 开线程需要一定的内存空间,默认一条线程占用栈区间512KB
  • 线程过多会导致COU在线程上调度的开销比较大
  • 程序设计比较复杂,比如线程间通信,多线程的数据共享

在iOS中其实有4套多线程方案,它们分别是

  • pthread
  • NSThread
  • GCD
  • NSOperation

四种方案对比如下:

多线程.png

由于平时大多数只用到GCD和NSOperation,下面就主要讨论这两种多线程方案实现

GCD简介

GCD以block为基本单位,一个block中的代码可以为一个任务。下文中提到 任务 ,可以理解为执行某个block

GCD有两大重要概念,分别是队列执行方式;使用block的过程,概括来说就是把block放进合适的队列,并选择合适的执行方式去执行block的过程。

GCD有三种队列:

  • 串行队列(先进入队列的任务先出队列,每次只执行一个任务)
  • 并发队列 (依然是先进先出,不过可以形成多个任务并发)
  • 主队列 (这是一个特殊的串行队列,而且队列中的任务 一定会在主线程中执行)

两种执行方式:

  1. 同步执行
  2. 异步执行

关于同步异步、串行并行和线程的关系,如下表格所示

| 同步 | 异步
---|------|-------
主队列|在主线程中执行|在主线程中执行
串行队列|在当前线程中执行|新建线程执行
并发队列|在当前线程中执行|新建线程执行

可以看到,同步方法不一定在本线程,异步方法亦不一定新开线程(主队列)

所以,我们在编程时考虑的是同步Or异步 以及 串行Or 并行,而不是仅仅考虑是否新开线程

GCD死锁问题

在使用GCD的过程中,如果向当前串行队列中同步派发一个任务,就会导致死锁,例如:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"1"); //任务1
    dispatch_sync(dispatch_get_main_queue(), ^{
        NSLog(@"2"); //任务2
    });
    NSLog(@"3"); //任务3    
}

参考文章:

以上代码就发生了死锁,控制台只能打印1。因为我们目前在主队列中,又将要同步地添加一个block到主队列(串行)中。

理论分析

dispatch_sync表示同步的执行任务,也就是说执行dispatch_sync后,当前队列会阻塞。而dispatch_sync中的block如果要在当前队列中执行,就得等待当前队列执行完成。

上面例子中,首先主队列执行任务1,然后执行dispatch_sync,随后在队列中新增一个任务2。因为主队列是同步队列,所以任务2要等dispatch_sync执行完才能执行,但是dispatch_sync是同步派发 ,要等任务2执行完才算是结束。在主队列中的两个任务互相等待,导致了死锁。当然,由于死锁,后面添加的任务3也不会执行了。

解决方案

通常情况下我们不必要用dispatch_sync,因为dispatch_async能够更好地利用CPU,提升程序运行速度。

只有当我们需要去报队列中的任务必须顺序执行时,才考虑使用dispatch_sync。在使用dispatch_sync的时候应该分析当前处于哪个队列,以及任务会提交到哪个队列。

GCD任务组

在开发中有这个需求,在A,B,C,D这四个任务全部结束后进行一些处理,那么我们怎么知道四个任务都已经执行完了呢?这时候我们就需要用到dispatch_group了。

dispatch_queue_t dispatchQueue = dispatch_queue_create("hyq.queue.next", DISPATCH_QUEUE_CONCURRENT);
    dispatch_group_t dispatchGroup = dispatch_group_create();
    dispatch_group_async(dispatchGroup, dispatchQueue, ^(){
        NSLog(@"任务A");
    });
    dispatch_group_async(dispatchGroup, dispatchQueue, ^(){
        NSLog(@"任务B");
    });
    dispatch_group_async(dispatchGroup, dispatchQueue, ^(){
        NSLog(@"任务C");
    });
    dispatch_group_async(dispatchGroup, dispatchQueue, ^(){
        NSLog(@"任务D");
    });
    dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^(){
        NSLog(@"end");
    });

首先我们要通过dispatch_group_create方法生成一个组

然后我们把dispatch_async方法换成dispatch_group_async。这个方法多了一个参数,第一个参数填刚创建的分组

最后调用dispatch_group_notify方法。这个方法表示把第三个参数block传入第二个参数队列中去。而且可以保证第三个参数block执行时,group中所有任务已经全部完成。

dispatch_group_wait

dispatch_group_wait的完整定义如下:

dispatch_group_wait(dispatch_group_t  _Nonnull group, dispatch_time_t timeout)

第一个参数表示要等待的group,第二个则表示等待时间。返回值表示经过指定的等待时间,属于这个group的任务是否已经全部执行完。如果是则返回0,否则返回非0.

第二个参数dispatch_time_t类型的参数还有两个特殊值:DISPATCH_TIME_NOWDISPATCH_TIME_FOREVER,前者表示立刻检查这个group的任务是否已经完成,后者则表示一直到属于这个group的任务全部完成。

dispatch_after

通过GCD还可以进行简单的定时 的操作,比如在1秒后执行某个block。代码如下:

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"666");
    });

dispatch_after有三个参数。第一个表示时间,也就是从现在起往后1秒钟。第二个参数表示提交到哪个队列,第三个参数表示要提交的任务。

需要注意的是dispatch_after仅表示在指定时间后提交任务 ,而非执行任务。如果任务提交到主队列,它将在main runloop中执行,对于每隔1/60秒执行一个的RunLoop1,任务最多可能在1+1/60秒后执行。

GCD进阶

GCD也有一些强大的特性。接下来我们主要讨论以下几个部分:

  • dispatch_suspenddispatch_resume
  • dispatch_once
  • dispatch_barrier_async
  • dispatch_semaphore

我们知道NSOperationQueue有暂停suspend和恢复resume。其实GCD中的队列也有类似的功能。

dispatch_suspend(dispatchQueue);
dispatch_resume(dispatchQueue);

这些函数不会影响到队列中已经执行的任务,队列暂停后,已经添加到队列中但是还没有执行的任务 不会执行,知道队列被恢复。

dispatch_once

dispatch_once在单例模式被广泛使用

  • dispatch_once函数可以确保某个block在应用程序执行过程中只被处理一次,而且它是线程安全的。所以单例模式可以很简单的实现,代码实现如下:
+ (Manager *)sharedInstance {
    static Manager *sharedManagerInstance = nil;
    static dispatch_once_t once;

    dispatch_once($once, ^{
        sharedManagerInstance = [[Manager alloc] init];
    });

    return sharedManagerInstance;
}

这段代码中我们创建一个值为nil的sharedManagerInstance静态对象,然后把它的初始化代码放到dispatch_once中完成。

这样,只有第一次调用sharedInstance方法时才会进行对象的初始化,以后每次只是返回sharedManagerInstance而已。

dispatch_barrier_async

我们知道在写入时,不能再其他线程读取或写入。但是多个线程同时读取数据是没有问题的。所以我们可以把读取任务放入并行队列,把写入任务放入串行队列,并且保证写入任务执行过程中没有读取任务可以执行。

这样的需求就比较常见,GCD提供了一个非常简单的解决方法dispatch_barrier_async

假设我们有 四个读取任务,在第二、三个任务之间有一个写入任务,代码大概如下:

dispatch_queue_t dispatchQueue = dispatch_queue_create("hyq.queue.next", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue, block1_for_reading)  
dispatch_async(queue, block2_for_reading)

/*
    这里插入写入任务,比如:
    dispatch_async(queue, block_for_writing)
*/

dispatch_async(queue, block3_for_reading)  
dispatch_async(queue, block4_for_reading)

如果代码这样写,由于几个block是并发执行,就有可能在前两个block中读取到已经修改了的数据。如果是有多写入任务,那问题更严重,可能会有数据竞争。

如果使用dispatch_barrier_async函数,代码就可以这么写:

dispatch_async(queue, block1_for_reading)  
dispatch_async(queue, block2_for_reading)

dispatch_barrier_async(queue, block_for_writing)

dispatch_async(queue, block3_for_reading)  
dispatch_async(queue, block4_for_reading) 

dispatch_barrier_async会把队列的运行周期分为这三个过程:

  1. 首先等目前追加到并行队列中所有任务都执行完成
  2. 开始执行dispatch_barrier_async中的任务这时候即便向并行队列提交任务,也不会执行
  3. dispatch_barrier_async中任务执行完成后,并行队列恢复正常。

总的来说,dispatch_barrier_async起到了承上启下的作用。它保证此前的任务都先于自己执行,此后的任务也迟于自己执行。正如barrier的含义一样,它起到一个栅栏或者分水岭的作用。

使用并行队列和diapatch_barrier_async方法,就可以高效的进行数据和文件读写了。

dipactch_semaphore

首先介绍一下信号量(semaphore)的概念。信号量是持有计数的信号,举个生活中的例子来看:

假设有一个房子,它对应进程的概念,房子里的人就对应着线程。一个进程可以包括多个线程。这个房子(进程)有很多资源,比如花园、客厅灯,是所有人(线程)共享的。

但是有些地方,比如卧室,最多只有两个人进去睡觉。怎么办呢》在卧室门口挂上两把钥匙。进去的人(线程)就拿着要是进去,没有钥匙就不能进去,出来的时候把钥匙放回门口。

这时候,门口的钥匙数量就称为信号量(Semaphore)。很明显,信号量为0时需要等地,信号量不为零时,减去1而且不等待。

在GCD中,创建信号量的代码如下:

dispatch_semaphore_t semaphore = dispatch_semaphore_create(3);

这句代码通过diapatch_semaphore_create方法创建一个信号量初始值为3.然后就可以调用dispatch_semaphore_wait方法了。

dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

dispatch_semaphore_wait方法表示一直等待直到信号量的值大于等于1,当这个方法执行后,会把第一个信号量参数的值减1。

第二个参数是一个dispatch_time_t类型的时间,它表示这个方法最大的等待时间。

返回值也和dispatch_group_wait方法一样,返回0表示在规定的时间内 第一个参数信号量的值已经大于等于1,否则表示已超过规定等待时间,但信号量的值还是0.

dispatch_semaphore_wait方法返回0,因为此时的信号量的值大于等于1,任务获得了可以执行的权限。这时候我们就可以安全的执行需要进行排他控制的任务了。

任务结束时还需要调用dispatch_semaphore_signal()方法,将信号量的值加1.这类似于之前所说的,从卧室出来要把锁放回门上,否则后来的人就无法进入了。示例代码如下:

dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
    
    dispatch_queue_t diapatchQueue = dispatch_queue_create("hyq.gcd.next", DISPATCH_QUEUE_CONCURRENT);
    
    NSMutableArray *array = [NSMutableArray array];
    
    for (int i = 0; i < 100000; i++) {
        dispatch_async(diapatchQueue, ^{
            
            /*
                某个线程执行到这里,如果信号量为1,那么wait方法返回1,开始执行接下来的操作。与此同时,因为信号量
                变为0,其他执行到这里的线程必须等待
             */
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
            
            /*
                执行了wait方法后,信号量变成了0.可以进行接下来的操作。这时候其他线程都得等待wait方法返回
                可以对array修改的线程在任意时刻都只有一个,可以安全的修改array
             */
            [array addObject:@(i)];
            
            /*
                排他操作执行结束,记得要调用signal方法,把信号量的值加1.这样,如果有别的线程在等待wait函数返回,就由最先等待的线程执行
             */
            dispatch_semaphore_signal(semaphore);
        });
    }

NSOperation

NSOperationNSOperationQueue主要介绍以下几个方面:

  1. NSOperationNSOperationQueue的用法介绍
  2. NSOperation的暂停、恢复和取消
  3. 通过KVO对NSOperation的状态进行检测
  4. 多个NSOperation之间的依赖关系
  5. 进程间通信

从简单 意义上来说 ,NSOperation是对GCD中的block进行的封装,它也表示一个要被执行的任务。和GCD的block类似,NSOperation对象有一个start()方法表示开始执行这个任务。

不仅如此,NSOperation表示的任务还可以被取消。它还有三种状态isExecutedisFinishedisCancelled以方便我们铜鼓KVC对它的状态进行监听。

想要开始执行一个任务可以这么写:

NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
        NSLog(@"task----%@",[NSThread currentThread]);
    }];
    [op start];

打印结果如下:

[21921:884043] task----<NSThread: 0x608000063980>{number = 1, name = main}

我们创建了一个NSBlockOperation,并且设置好它的block,也就是要执行的任务。这个任务就会在主线程中执行。为什么不直接使用NSOperation呢?

  • 因为NSOperation本身是一个抽象类,要使用可以通过以下几个方法:

    • 使用NSInvocationOperation
    • 使用NSBlockOperation
    • 自定义NSOperation的子类

NSBlockOperation可以用来封装一个或多个block,后面会介绍如何自定义NSOperation的子类。

同时,还可以调用addExecutionBlock方法追加几个任务,这些任务会并行执行(也就是说很有可能运行在别的线程里)

最后,调用start方法让NSOperation方法运行起来。start是一个同步方法。

NSOpaerationQueue

从上面我们知道,默认的NSOperation是同步执行的。简单的看一下NSOperation类的定义会发现它只有一个只读属性asynchronous

这意味着如果想要异步执行,就需要自定义NSOperation的子类。或者使用NSOperationQueue

NSOperationQueue类似于GCD中的队列。我们知道GCD中的队列有三种:主队列、串行队列和并行队列。NSOperationQueue更简单,只有两种:主队列非主队列

我们自己生成的NSOperationQueue对象都是非主队列,主队列可以用[NSOperationQueue mainQueue]取得。

NSOperationQueue的主队列是串行队列,而且其中所有NSOperation都会在主线程中执行。

对于非主队列来说,一旦一个NSOperation被放入其中,那这个NSOperation一定是兵法执行的。因为NSOperationQueue会为每一个NSOperation创建线程并调用它的start方法。

NSOperationQueue有一个属性叫maxConcurrentOperationCount,它表示最多支持多少个NSOperation并发执行。如果maxConCurrentOperationCount被设置为1,就以为这个队列是串行队列。

因此,NSOperationQueue和GCD中的队列有这样的对应关系:

| NSOperation | GCD
---|------------|----
主队列 | [NSOperationQueue mainQueue] | dispatch_get_mian_queue()
串行队列 | 自建队列 maxConcurrentOperationCount为1 | dispatch_queue_create("",DISPATCH_QUEUE_SERIAL)
并发队列 | 自建队列 maxConcurrentOperationCount大于1 | dispatch_queue_create("",DISPATCH_QUEUE_CONCURRENT)

那么如何利用NSOperationQueue实现异步操作?代码如下:

    //自建队列
    NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
        NSLog(@"task0---%@", [NSThread currentThread]);
    }];
    [op addExecutionBlock:^{
        NSLog(@"task1---%@", [NSThread currentThread]);

    }];
    [op addExecutionBlock:^{
        NSLog(@"task2---%@", [NSThread currentThread]);

    }];
    [op addExecutionBlock:^{
        NSLog(@"task3---%@", [NSThread currentThread]);

    }];
    [queue addOperation:op];
    NSLog(@"操作结束");

执行结果如下:

2017-02-20 14:47:18.074 GCDAndNSOperation[22578:915368] task2---<NSThread: 0x60000026a9c0>{number = 5, name = (null)}
2017-02-20 14:47:18.074 GCDAndNSOperation[22578:915371] task0---<NSThread: 0x60000026a980>{number = 3, name = (null)}
2017-02-20 14:47:18.074 GCDAndNSOperation[22578:915401] task3---<NSThread: 0x60800026a6c0>{number = 6, name = (null)}
2017-02-20 14:47:18.074 GCDAndNSOperation[22578:915369] task1---<NSThread: 0x60800026a580>{number = 4, name = (null)}
2017-02-20 14:47:18.074 GCDAndNSOperation[22578:915176] 操作结束

使用NSOperationQueue来执行任务与之前的区别在于,首先创建一个非主队列。然后用addOperation方法替换之前的start方法。刚刚已经说过,NSOperationQueue会为每一个NSOperation创建线程并调用它们的start方法。

观察一下运行结果 ,所有的NSOperation都没有在主线程执行,从而成功的实现了异步、并行处理。

取消任务

如果我们有两次网络请求,第二次请求会用到第一次的数据。假设此时网络情况不好,第一次请求超时了,那么第二次请求也没有必要发送了。当然,用户也有可能人为地取消某个NSOperation

当某个NSOperation被取消时,我们应该尽可能的清除NSOperation内部的数据并且把cancelfinished设为true,把executing设为false

     //取消某个NSOperation
    [operation cancel];
    //取消某个NSOperationQueue剩余的NSOperation
    [queue cancelAllOperations];

设置依赖

有时候一个网络请求是用到另一个网络请求获得的数据,这时候我们要确保第二次请求的手第一个请求已经执行完。但是我们同时还希望用到NSOperationQueue的并发特性(因为可能不止这两个任务)

这时候我们可以设置NSOperation之间的依赖关系,很简单,代码如下:

[operation1 addDependency: operation2];

需要注意的是NSOperation之间的相互依赖会导致死锁

NSOperationQueue暂停与恢复

这个也很简单,只要修改suspended属性即可

queue.suspended = true; //暂停queue中所有operation
queue.suspended = false; //恢复queue中所有operation

NSOperation优先级

GCD中,任务(block)是没有优先级的,而队列具有优先级。和GCD相反,我们一般考虑NSOperation的优先级

NSOperation有一个NSOperationQueuePriority枚举类型的属性queuePriority

typedef NS_ENUM(NSInteger, NSOperationQueuePriority) {
    NSOperationQueuePriorityVeryLow = -8L,
    NSOperationQueuePriorityLow = -4L,
    NSOperationQueuePriorityNormal = 0,
    NSOperationQueuePriorityHigh = 4,
    NSOperationQueuePriorityVeryHigh = 8
};

需要注意的是,NSOperationQueue也不能完全保证优先级高的任务 一定先执行

进程间通信

有时候我们在子线程中执行完一些操作的时候,需要回到主线程做一些事情(如进行UI操作),因此需要从当前线程回到主线程,以下载并显示图片为例,代码如下:

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
// 子线程下载图片
[queue addOperationWithBlock:^{
    NSURL *url = [NSURL URLWithString:@"http://img.pconline.com.cn/images/photoblog/9/9/8/1/9981681/200910/11/1255259355826.jpg"];
    NSData *data = [NSData dataWithContentsOfURL:url];
    UIImage *image = [[UIImage alloc] initWithData:data];
    // 回到主线程进行显示
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        self.imageView.image = image;
    }];
}];

小结

NSOperation和GCD如何选择

  • GCD以block为单位,代码简洁。同时GCD中的队列、组、信号量、source、barriers都是组成并行编程的基本原语。对于一次性的计算,或者仅仅为了加快现有方法的运行速度,选择轻量化的GCD就更加方便
  • NSOperation可以用来规划一组任务之间的依赖关系,设置它们的优先级,任务能被取消。队列可以暂停、恢复。NSOperation还可以自定义子类。这些都是GCD没有具备的。
  • 可以根据情况有效结合NSOperationGCD一起使用

最后,有个很经典的面试题,GCD和NSOperation有什么区别

答案基本就是对上面所说的的总结

  • GCD是纯C语言的API,NSOperation是基于GCD的OC版本封装
  • GCD只支持FIFO的队列,NSOperation可以很方便地调整执行顺序,设置最大并发数量
  • NSOperationQueue可以轻松在operation间设置依赖关系,而GCD需要些很多代码才能实现
  • NSOperationQueue支持KVO,可以检测operation是否正在执行(isExecuted),是否结束(isFinisn),是否取消(isCancel)
  • GCD的执行速度比NSOperation快

参考文章 :
iOS多线程编程总结
关于iOS多线程,你看我就够了

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

推荐阅读更多精彩内容