SDWebImage 源码分析

SDWebImage 源码分析

首先我 fork 了 SDWebImage 的源码,见 conintet/SDWebImage,这样在本文的链接中都是链到我的 fork 中,这么做的目的是防止将来 SDWebImage 代码发生变化导致本文的链接不准确。

有关 SD (SDWebImage 简称为 SD) 的使用方式还是得参考其 README 或者 wiki。本文只是阅读其源码的笔记。

图片下载

最先分析的就是图片下载部分的代码,因为这是最核心的功能。

因为 SD 在 UIImageView 上通过 Category 的方式增加了简单易用的 API,类似下面:

- (void)sd_setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder;

于是通过几步 Jump to Definition 就可以发现,SD 的图片下载操作是由 SDWebImageDownloaderOperation 来完成的,于是看一下它的初始化方法:

- (id)initWithRequest:(NSURLRequest *)request
              options:(SDWebImageDownloaderOptions)options
             progress:(SDWebImageDownloaderProgressBlock)progressBlock
            completed:(SDWebImageDownloaderCompletedBlock)completedBlock
            cancelled:(SDWebImageNoParamsBlock)cancelBlock;

通过上面的方法签名,可以大概反向的知道:

  1. 使用了 NSURLRequest,那么很可能内部就使用的 NSURLConnection 来完成的下载
  2. 既然提供了 progresscompleted 这两个 callback,那么内部势必需要知道下载的进度
  3. 因为提供了 cancelled 这个 callback,那么内部的下载操作还需要可以取消

再看一下 SDWebImageDownloaderOperation 是继承于 NSOperation,因为下载是一个可以独立出来的计算单元,所以作为 Opreation 是很好理解的。然后在实际的图片下载中,为了下载的效率,下载的 Opreations 之间肯定是需要并发的。Operation 默认在其被调用的线程中是同步执行的,不过由于 Operation Queue 的存在,它可以将其中的 Operations 分别 attach 到由系统控制的线程中,而这些由系统控制的线程之间是并发执行的。

查看 SDWebImageDownloaderOperation 源码发现内部果然是使用的 NSURLConnection,那么由于需要提供 cancelled 的功能以及需要监听下载进度,故必须将 NSURLConnection 的实例配置成异步的方式:

具体代码在 L96

// 配置异步 NSURLConnection 的方式

// 实例化一个 NSURLConnection,并将自身(SDWebImageDownloaderOperation)设置为 NSURLConnection 实例的委托
self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
// 因为上一步的 startImmediately:NO,所以这里手动的触发 start
// 这样的效果和直接 startImmediately:YES 是一样的
[self.connection start];
// 因为上面两步结合起来或者直接 startImmediately:YES 的结果就是下载例程将会在当前 Run Loop 上以默认的模式进行调度,
// 而在 iOS 中除了主线程之外的线程都是默认没有运行 Run Loop 的,所以需要手动的运行一下
CFRunLoopRun();
// 之后的代码将会被 CFRunLoopRun() 所阻塞,这样 operation 所在的线程
// 就不会自动的退出,于是需要额外的代码在下载完成之后手动的停止 RunLoop 使得
// operation 所在的线程可以退出

对于下载进度的监听,SDWebImageDownloaderOperation 是通过将自身设置为 NSURLConnection 委托的形式完成的:

- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response 在这一委托方法的实现中,SDWebImageDownloaderOperation 主要是获取服务端响应的 meta 信息,尝试根据响应的 statusCode 对下载过程进行预判,比如如果是 304 状态码直接从本地缓存中返回图片。但是这里的代码写的有些繁琐了,并且性能上也是存在些问题。首先可以看下这幅概览图:

URL Loading System
URL Loading System

上面就是 URL Loading System 的层次结构,可见 NSHTTPURLResponseNSURLResponse 唯一的子类,并且含有其父类没有的 statusCode 方法。于是使用 isKindOfClass: 来判断参数是否是 NSHTTPURLResponse 就可以了,使用 respondsToSelector: 没有额外的好处而且丢失了性能,见 Performance penalty using respondsToSelector

- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 通过实现这个委托方法,就可以知道有 new response chunk 被接收,于是可以向外提供 progress,另外 SD 还实现了 display image progressively,按照代码中的描述,出自于这里 Progressive image download with ImageIO,其中有一小段是说 iOS 的实现相对于 Mac 需要点额外的步骤,而我将其示例代码下载了之后,在注释掉其中关于 iOS 适配的部分代码后运行,发现注释掉也是可以的:

        /// Create the image
        CGImageRef image = CGImageSourceCreateImageAtIndex(_imageSource, 0, NULL);
        if (image) {
//#ifdef __IPHONE_4_0 // iOS
//            CGImageRef imgTmp = [self createTransitoryImage:image];
//            if (imgTmp) {
//                [_delegate downloadedImageUpdated:imgTmp];
//                CGImageRelease(imgTmp);
//            }
//#else // Mac OS
//            [_delegate downloadedImageUpdated:image];
//#endif
            [_delegate downloadedImageUpdated:image];
            CGImageRelease(image);
        }

也就是说这段 L290 代码实际是有一点性能问题的,应该找到一个临界的版本号以此适配老版本,而不是直接 TARGET_OS_IPHONE

还有一点在使用时需要注意的就是,如果需要获得具体的 progress 百分比,那么在 new chunk 到达的时候,除了需要知道已经下载了的 chunks 的 size 总和之外,还需要知道 Content-Length,也就是在这里试图通过响应的 meta 信息(HTTP Headers)中获取 expectedContentLength

而根据 HTTP 协议的描述 [1, 2],如果服务端的响应采用了 chunked 的方式,那么客户端实现必须忽略服务端响应中的 Content-Length(如果有的话。按照标准定义,在使用 chunked 时,服务端也应该不返回 Content-Length,当然一般情况下也没法返回),换句话说,如果服务端响应的图片信息使用 chunked transfer encoding 的话,那么客户端在图片没有完全下载好之前就无法知道图片的总大小,于是试图显示一个下载百分比的进度条就不行了。这段算是 tips 吧。

- (void)connectionDidFinishLoading:(NSURLConnection *)aConnection,需要知道下载完成的时间点,故实现了这个委托方法

在另外的一些委托方法中,SD 完成了取消下载的相应操作,以及当请求的 HTTPS 证书不可信时的操作,以及当服务端资源需要访问授权时的操作。

小结

SD 通过 SDWebImageDownloaderOperation 将图片的下载操作封装成 NSOperation,在内部通过设置 NSURLConnection 为异步的方式,并将自身设置为 NSURLConnection 委托,从而向外部提供下载进度控制的功能。

图片缓存

下一步需要分析的就是 SD 的缓存机制,首先从 SD 的 README 中得知 SD 提供了常见了 two-levels cache 机制,即 memory-disk 的方式。在上一段分析下载的过程里,发现 SD 下载图片还是借由的 NSURLConnection,从 Understanding Cache Access 得知,iOS 中的 URL loading system 已经自带了 two-levels cache 的机制,那么为什么 SD 需要自己再实现一套呢?SD 自己是这样解释的,完整的解释见 How is SDWebImage better than X?,大概的意思就是:

虽然 NSURLCache 提供了 two-levels cache,但是它缓存的内容是 raw bytes,也就是说从 NSURLCache 中取出的是图片的 raw bytes,如果需要使用图片还需要进行进一步的操作,比如解析图片的信息,使其成为在 iOS 中可以使用的形式。而 SD 的缓存的则是将解析后的可以在 iOS 中直接使用的图片,这样从缓存中取回的内容就不需要在解析一遍了,从而进一步节约了系统资源。

进一步了解 two-levels cache 或者 N-levels cache,其核心思想就是将需要缓存的内容放到多个 cache storages 中,然后在取出缓存内容时,尽量的从响应速度较快的 storage 中取回。那么很明显,对于 memory-disk 这样的 two-levels cache,无非就是将需要缓存的内容同时放到 memory 和 disk 中,然后取回的时候先尝试较快的 storage,那么势必先检索 memory cache storage,如果 memory cache 没有命中的话,则尝试 disk cache storage。下一步就是分析 SD 中具体是如何完成这些工作的。

首先 SD 中使用 SDWebImageManager 去集中管理图片的下载操作,并且 SDWebImageManager 使用了单例的模式,在其初始化操作是这样的:

- (id)init {
    if ((self = [super init])) {
         // 初始化 two-levels cache,它以 SDImageCache 的单例去操作
        _imageCache = [self createCache];
        // 以单例的形式初始化 SDWebImageDownloader
        _imageDownloader = [SDWebImageDownloader sharedDownloader];
        // 存放失败的 URLs,为了 re-try 的判断
        _failedURLs = [NSMutableSet new];
        // 正在运行的 operations,方便统一的管理
        _runningOperations = [NSMutableArray new];
    }
    return self;
}

执行下载操作的是 SDWebImageManager 中的这个方法(具体的实现在 L110):

- (id <SDWebImageOperation>)downloadImageWithURL:(NSURL *)url
                                         options:(SDWebImageOptions)options
                                        progress:(SDWebImageDownloaderProgressBlock)progressBlock
                                       completed:(SDWebImageCompletionWithFinishedBlock)completedBlock;

downloadImageWithURL 的具体实现中,使用了 SDWebImageCombinedOperation 来统一管理两个操作(主要是取消的功能),一个操作就是先尝试从缓存中取回图片,另一个操作就是如果缓存没有命中,尝试从源地址下载的操作。这样只要取消 SDWebImageCombinedOperation 就会同时取消那两个操作。

在下载的 subOperation 中,使用了 weakOperationL183

这是因为 这里,如果在 subOperation 中没有使用 weakOperation 的话,那么就会发生 retain cycle

                                     retain                            retain
+---------------------------------+           +---------------------+           +----------------------+
|   SDWebImageCombinedOperation   +----------->     cancelBlock     +----------->     subOperation     |
+----------------^----------------+           +---------------------+           +-----------+----------+
                 |                                                                          |
                 |                                                                          |
                 |                                                                          |
                 |                                    retain                                |
                 +--------------------------------------------------------------------------+

另外由于需要在 self.runningOperationadd/remove SDWebImageCombinedOperation 的实例,所以加上了 __block 修饰

由于 SDWebImageManager 是单例的形式,而其可能在多线程的情况下被调用,所以对于其非线程安全的属性,在操作时使用了 @synchronized 来确保数据的完整性。

具体的业务逻辑是这样的:

  1. 首先从 SD 自己的缓存存储中尝试取回图片 L149
  2. 如果在 SD 自己的缓存存储中没有取到图片,或者选项中标记需要刷新缓存,那么此时就需要从源地址下载图片,但是之前还需要判断下源地址是否允许被下载 L158
  3. L159 的意思是,如果选项标记需要刷新缓存,但是在本地缓存中找到了相关图片,那么就先使用这个缓存的图片调用下 completedBlock,然后再继续进行下载操作。

其实这一步放得有些散了,它是和 L180 以及 L216 搭配起来的。通过 L180,当发现 Response 是被 NSURLCache 缓存的,那么 L216 的条件就会满足,为什么会满足呢?因为 这里,于是 downloadedImagenil

满足条件了于是就什么也没做(要做的在 L159 已经被做了)。也就是说一旦设置了 SDWebImageRefreshCached 选项,那么在使用 NSURLConnection 下载的时候,发现 Response 是此前缓存的,那么就直接从 SD 的缓存中返回处理好的图片,这么做的原因上文已经说过了 NSURLCache 的缓存是数据的 raw bytes,而 SD 中缓存的图片数据是 out of the box。

  1. 如果新下载了图片,那么肯定是要先将其存储在 SD 缓存中,SD 提供了缓存选项可以让调用者决定是单存 memory 或 disk 或 both,见 L237

上面主要是分析了 SDWebImageManager 在下载图片时的操作,即先检索本地 SD 缓存,然后再根据下载选项决定是否从源地址进行下载,以及下载好图片之后将其存放到 SD 缓存中。

并发下载

在第一节中介绍了 SD 将下载操作封装为了 SDWebImageDownloaderOperation。SD 内部在使用时,并不是直接操作 SDWebImageDownloaderOperation 的,而是使用的 SDWebImageDownloader 单例,在 SDWebImageDownloader 单例初始化的时候,产生了一个 NSOperationQueue,见 L67,并且设置了对了的并发数为 6,见 L68。然后在需要下载的时候,将 SDWebImageDownloaderOperation 实例添加到了其内部的下载队列中,这要就完成了并发下载的功能。

缓存的细节

现在开始分析下 SD 中的一些关于缓存操作的细节。检索本地 SD 缓存分为两步,当检索 memory cache storage 时,采用的是同步的方式,这是因为内存缓存的操作速度是很快的,当检索 disk cache storage 时,SD 使用的是异步的方式,见 L372。SD 将缓存存储以及其相关的操作封装为 SDImageCache 并且以单例的模式进行操作,SDImageCache 的初始化在 SDWebImageManager 的初始化中进行调用。

有一点需要注意的就是,SD 中实现的 sharedXXX 方法并不能表示一个确切的单例模式,具体的描述见 Create singleton using GCD's dispatch_once in Objective C,如果用其他面向对象语言描述的话就是,必须将构造函数隐藏起来不要让外部调用到,比如设置成 private,然后提供一个类似 getSingleton 的静态方法。不过就像上面的链接中描述的一样,如果口头约定总是使用 sharedXXX 方法来获取实例对象的话那也没有太大的问题。

对于异步的检索磁盘的方式,SD 采用的是 GCD,首先在 SDImageCache 初始化时创建了一个 ioQueue,注意 SD 中采用的是一个 serial queue,见 L99。使用 serial queue 的目的就是省得使用锁去管理磁盘数据的读写了。

对于内存缓存,SD 实现了一个内部类 AutoPurgeCache,它继承自 NSCache,功能就是在通过 Notification 来接受内存不足的通知,然后清除自身存储缓存所占用的内存空间。但是注意到一个细节,比如在 L106,看到下面的代码:

dispatch_async(dispatch_get_main_queue(), ^{
    [[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:self];
});

为什么需要在主线程上 postNotificationName:(注:如遇到方法的签名我没有写全的情况请不必在意) 呢?

具体的内容在 Notification Programming Topics,大概的意思就是:

Regular notification centers deliver notifications on the thread in which the notification was posted. Distributed notification centers deliver notifications on the main thread. At times, you may require notifications to be delivered on a particular thread that is determined by you instead of the notification center. For example, if an object running in a background thread is listening for notifications from the user interface, such as a window closing, you would like to receive the notifications in the background thread instead of the main thread. In these cases, you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread.

上面的一段引用其实说了几点内容,不过当前只需要知道第一句的意思:通常情况下 notification center 会把 posted notifications 派送给与 post 动作所在的同一线程中的 observers。而上面的 L106 中的代码可以看出,它期望的 observers 是在主线程的,那么 observers 就可以在主线程中更新 UI 来给用户相关的进度提示。

那为什么需要 dispatch_async 呢?这是因为 Notification Centers 中描述的:

A notification center delivers notifications to observers synchronously. In other words, when posting a notification, control does not return to the poster until all observers have received and processed the notification. To send notifications asynchronously use a notification queue, which is described in Notification Queues

再看 AutoPurgeCache 中注册的 observer L24,observer 注册在 AutoPurgeCache 运行时所在的线程,根据上面的第一段引用中的描述,对于 local notification 而言,postor 和 receiver 需要在同一线程,于是就猜测是不是对于系统通知而言,会在所有的线程上进行 notify。但是没有在 Apple Doc 中找到明确的相关文字描述,不过进过测试确实对于系统通知而言,notifition center 会对进程中的所有线程进行 notify。下面是测试的代码:

@interface Worker : NSThread
@end

@implementation Worker

- (void)main
{
    NSLog(@"Worker is running...");
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(testNotification) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];

    [[NSRunLoop currentRunLoop] addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    NSLog(@"Worker is exiting...");
}

- (void)testNotification
{
    NSLog(@"testNotification");
}

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    Worker* worker = [[Worker alloc] init];
    [worker start];
}

可以运行模拟器然后 Hardware -> Simulate Memory Warning 就可以看到子线程是可以接收到通知的。

以上就是我阅读源码后的分析,虽然没有面面俱到,也还是希望能有所帮助。

[2015-11-24 修正]

上面有一段这样说到:

另外由于需要在 self.runningOperation
中 add/remove
SDWebImageCombinedOperation
的实例,所以加上了 __block
修饰

我今天回头看了一下,发现我之前那样的描述是不对的。

首先可以看下这里的描述,大概意思就是说如果需要让那些被 block 所 captured 变量是 mutable 的,那么就需要使用 __block 前缀去修饰。

那么看看上面提到的 SD 中的代码,简化后就是这样:

// 这里的 __block 不需要
 __block SDWebImageCombinedOperation *operation = [SDWebImageCombinedOperation new];

operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
    @synchronized (self.runningOperations) {
        [self.runningOperations addObject:operation];
    }

    @synchronized (self.runningOperations) {
        [self.runningOperations removeObject:operation];
    }
}];

return operation;

注意到在 cacheOperation 那一行产生的 block,它对 operation 进行了 capture,但是在 block 内部并没有改变 operation 的指向。所以这里的 __block 是不需要的。Obj 对象在 block 是以引用去操作的,可以想象是对象的内存地址被捕获,如果是这样就需要加上 __block

 __block SDWebImageCombinedOperation *operation = nil;

operation.cacheOperation = [self.imageCache queryDiskCacheForKey:key done:^(UIImage *image, SDImageCacheType cacheType) {
    // 捕获这 operation,然而我们需要改变它的内容
    // 把它的内容变成新对象的地址
    // 所以上面使用了 __block 前缀修饰
    operation = [SDWebImageCombinedOperation new]
  
    @synchronized (self.runningOperations) {
        [self.runningOperations addObject:operation];
    }

    @synchronized (self.runningOperations) {
        [self.runningOperations removeObject:operation];
    }
}];

return operation;

我看可以使用下面的代码来验证下上面的说法:

//
//  main.m
//  __block
//
//  Created by mconintet on 11/24/15.
//  Copyright © 2015 mconintet. All rights reserved.
//

#import <Foundation/Foundation.h>

int main(int argc, const char* argv[])
{
    @autoreleasepool
    {
        static NSMutableArray* arr;
        static dispatch_once_t once;
        dispatch_once(&once, ^{
            arr = [[NSMutableArray alloc] init];
        });

        dispatch_semaphore_t sema = dispatch_semaphore_create(0);
        dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);

        NSInteger opCount = 3;

        for (NSInteger i = opCount; i > 0; i--) {
            NSOperation* op = [[NSOperation alloc] init];

            dispatch_async(queue, ^{
                [arr addObject:op];
            });

            dispatch_async(queue, ^{
                [arr removeObject:op];
                if (![arr count]) {
                    dispatch_semaphore_signal(sema);
                }
            });
        }

        dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
        NSLog(@"arr count: %ld", [arr count]);
    }
    return 0;
}

对比下这段代码:

//
//  main.m
//  __block
//
//  Created by mconintet on 11/24/15.
//  Copyright © 2015 mconintet. All rights reserved.
//

#import <Foundation/Foundation.h>

int main(int argc, const char* argv[])
{
    @autoreleasepool
    {
        static NSMutableArray* arr;
        static dispatch_once_t once;
        dispatch_once(&once, ^{
            arr = [[NSMutableArray alloc] init];
        });

        dispatch_semaphore_t sema = dispatch_semaphore_create(0);
        dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_SERIAL);

        NSInteger opCount = 3;

        for (NSInteger i = opCount; i > 0; i--) {
            NSOperation* op;

            dispatch_async(queue, ^{
                op = [[NSOperation alloc] init]
                [arr addObject:op];
            });

            dispatch_async(queue, ^{
                [arr removeObject:op];
                if (![arr count]) {
                    dispatch_semaphore_signal(sema);
                }
            });
        }

        dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
        NSLog(@"arr count: %ld", [arr count]);
    }
    return 0;
}

你会发现后一段代码会被 IDE 提示:

为什么不能赋值?因为指针的捕获也是作为了 const,和基本类型一样。

总结起来说就是,objc 对象在 block 中捕获的是指向其真实地址的指针,指针以 const 的形式被捕获,不使用 __block 修饰就无法改变指针的内容,但是对于指针指向的对象,它们的内容还是可以改变的。

[2015-11-26 修正]
上面的关于 NSNotification 的说明有些纰漏,修正见 NSNotificationCenter

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

推荐阅读更多精彩内容