SDWebImage源码解读之干货大总结

这是我认为的一些重要的知识点进行的总结。

1.图片编码简介

大家都知道,数据在网络中是以二进制流的形式传播的,那么我们该如何把那些1和0解析成我们需要的数据格式呢?

说的简单一点就是,当文件都使用二进制流作为传输时,需要制定一套规范,用来区分该文件到底是什么类型的。 文件头有很多个,我们在这里就介绍一些主流的且跟图片相关的文件头。

  • JPEG (jpg),文件头:FFD8FFE1
  • PNG (png),文件头:89504E47
  • GIF (gif),文件头:47494638
  • TIFF tif;tiff 0x49492A00
  • TIFF tif;tiff 0x4D4D002A
  • RAR Archive (rar),文件头:52617221
  • WebP : 524946462A73010057454250

可以看出来我们通过每个文件头的第一个字节就能判断出是什么类型。但是值得注意的是52开头的。这个要做特别的判断。

WebP这种格式很特别。是由12个字节组成的文件头,我们如果把这些字节通过ASCII编码后会得到下边这样一张表格:


这里有一个小技巧:我们如何获取NSData中的第一个字节呢?答案是:

uint8_t c;
[data getBytes:&c length:1];

2.如何提醒某个方法已废弃

+ (NSString *)contentTypeForImageData:(NSData *)data __deprecated_msg("Use `sd_contentTypeForImageData:`");

通过__deprecated_msg宏告诉开发者该方法不建议使用,那么在平时的开发中,如果一个方法被另一个新的方法替代了,就可以使用这个宏来告诉其他的开发者,我有了一个更好的方案。

- (void)oldTest __deprecated_msg("该方法已被`newTest`替代,请使用新方法!");

3.修改图片到指定尺寸

为什么要修改图片到指定尺寸呢?一个最主要的原因就是我们确实需要某个尺寸的图片,但是还有一个与性能优化相关的原因。

大家都知道屏幕是由一个个的像素组成的,像素的原理先不说,假如一个控件的大小是(100, 100),我们下载的图片是(200, 200),若要把200*200的图片放到100*100的屏幕上是需要额外的计算的。这涉及了像素对其的问题,我们可以把下载下载的图片在异步线程修改为100*100的尺寸,在主线程显示,能够一定程度的提升性能。

如果我们想获取一个img的实际尺寸,应该使用image.size*image.scaleSDWebImage通过- (UIImage *)sd_animatedImageByScalingAndCroppingToSize:(CGSize)size这个方法可以实现修改图片到指定尺寸的功能。这个方法的内部主要是通过计算和绘制实现。

有一个重要的知识点:我们该如何把NSData转为UIImage呢?UIImage有一个方法可以转换,但是如果这个NSData存放了很多张图片呢?这个时候应该如何解析?

这个时候就需要使用CGImageSourceRef了,我们应该要记住,把NSData转成CGImageSourceRef后,我们就能获得很多属性。包括图片数量,duration等很多信息。

有这样一个方法:UIImage *SDScaledImageForKey(NSString *key, UIImage *image),目的是根据图片的名字来scale图片,这是什么意思呢?假如我们把两张图片导入到工程中,他们的名字是img@2x.pngimg@3x.png,他们的大小分别为:200*200, 300*300。我们通过imageNamed赋值的时候,会发现其实这个图片只有100*100的大小。那么在不同的设备上,Apple是怎么选择出正确的图片呢?

4.配置文件

我们平时可能很少做跨平台的开发工作,因此配置文件就显得没那么必要,我们在这里简要的介绍下SD中配置文件的组成:

#ifdef __OBJC_GC__
    #error SDWebImage does not support Objective-C Garbage Collection
#endif

SDWebImage不支持垃圾回收机制,垃圾回收(Gargage-collection)是Objective-c提供的一种自动内存回收机制。在iPad/iPhone环境中不支持垃圾回收功能。
当启动这个功能后,所有的retain,autorelease,release和dealloc方法都将被系统忽略。

#if !TARGET_OS_IPHONE && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_WATCH
    #define SD_MAC 1
#else
    #define SD_MAC 0
#endif

使用预编译的一个最大的好处就是:在代码编译阶段可以把不需要的代码不进行编译。

5.dispatch_main_async_safe

我们来看看这个宏,按理说我使用dispatch_main_async就可以了,为什么要加入safe呢?那么这个safe主要是解决那些不安全的问题呢?

#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block)\
    if (strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), dispatch_queue_get_label(dispatch_get_main_queue())) == 0) {\
        block();\
    } else {\
        dispatch_async(dispatch_get_main_queue(), block);\
    }
#endif
  • 我们可以像这样在定义宏的时候使用换行,但需要添加 \ 操作符
  • 如果当前线程已经是主线程了,那么在调用dispatch_async(dispatch_get_main_queue(), block)有可能会出现crash
  • 如果当前线程是主线程,直接调用,如果不是,调用dispatch_async(dispatch_get_main_queue(), block)

6.图像存储

首先图像的存储是二维的,所以我们需要考虑如何表示图像中某个特定位置的值。然后,我们需要考虑具体的值应该如何量化。另外,根据我们捕捉图像的途径,也会有不同的方式来编码图形数据。一般来说,最直观的方式是将其存为位图数据,可如果你想处理一组几何图形,效率就会偏低。一个圆形可以只由三个值 (两个坐标值和半径) 来表示,使用位图会使文件更大,却只能做粗略的近似。

不同于位图把值存在阵列中,矢量格式存储的是绘图图像的指令。在处理一些可以被归纳为几何形状的简单图像时,这样做显然更有效率;但面对照片数据时矢量储存就会显得乏力了。建筑师设计房屋更倾向于使用矢量的方式,因为矢量格式并不仅仅局限于线条的绘制,也可以用渐变或图案的填充作为展示,所以利用矢量方式完全可以生成房屋的拟真渲染图。

用于填充的图案单元则更适合被储存为一个位图,在这种情况下,我们可能需要一个混合格式。一个非常普遍的混合格式的一个例子是 PostScript,(或者时下比较流行的衍生格式,PDF),它基本上是一个用于绘制图像的描述语言。上述格式主要针对印刷业,而 NeXT 和 Adobe 开发的 Display Postscript 则是进行屏幕绘制的指令集。PostScript 能够排布字母,甚至位图,这使得它成为了一个非常灵活的格式。

7.矢量图像

矢量格式的一大优点是缩放。矢量格式的图像其实是一组绘图指令,这些指令通常是独立于尺寸的。如果你想扩大一个圆形,只需在绘制前扩大它的半径就可以了。位图则没这么容易。最起码,如果扩大的比例不是二的倍数,就会涉及到重绘图像,并且各个元素都只是简单地增加尺寸,成为一个色块。由于我们不知道这图像是一个圆形,所以无法确保弧线的准确描绘,效果看起来肯定不如按比例绘制的线条那样好。也因此,在像素密度不同的设备中,矢量图像作为图形资源会非常有用。位图的话,同样的图标,在视网膜屏幕之前的 iPhone 上看起来并没有问题,在拉伸两倍后的视网膜屏幕上看起来就会发虚。就好像仅适配了 iPhone 的 App 运行在 iPad 的 2x 模式下就不再那么清晰了。

虽然 Xcode 6 已经支持了 PDF 格式,但迄今仍不完善,只是在编译时将其创建成了位图图像。最常见的矢量图像格式为 SVG,在 iOS 中也有一个渲染 SVG 文件的库,SVGKit。

8.位图

大部分图像都是以位图方式处理的,从这里开始,我们就将重点放在如何处理它们上。第一个问题,是如何表示两个维度。所有的格式都以一系列连续的行作为单元,而每一行则水平地按顺序存储了每个像素。大多数格式会按照行的顺序进行存储,但是这并不绝对,比如常见的交叉格式,就不严格按照行顺序。其优点是当图像被部分加载时,可以更好的显示预览图像。在互联网初期,这是一个问题,随着数据的传输速度提升,现在已经不再被当做重点。

表示位图最简单的方法是将二进制作为每个像素的值:一个像素只有开、关两种状态,我们可以在一个字节中存储八个像素,效率非常高。不过,由于每一位只有最多两个值,我们只能储存两种颜色。考虑到现实中的颜色数以百万计,上述方法听起来并不是很有用。不过有一种情况还是需要用到这样的方法:遮罩。比如,图像的遮罩可以被用于透明性,在 iOS 中,遮罩被应用在 tab bar 的图标上 (即便实际图标不是单像素位图)。

如果要添加更多的颜色,有两个基本的选择:使用一个查找表,或直接用真实的颜色值。GIF 图像有一个颜色表 (或色彩面板),可以存储最多 256 种颜色。存储在位图中的值是该查询列表中的索引值,对应着其相应的颜色。所以,GIF 文件仅限于 256 色。对于简单的线条图或纯色图,这是一种不错的解决方法。但对于照片来说,就会显示的不够真实,照片需要更精细的颜色深度。进一步的改进是 PNG 文件,这种格式可以使用一个预置的色板或者独立的通道,它们都支持可变的颜色深度。在一个通道中,每个像素的颜色分量 (红,绿,蓝,即 RGB,有时添加透明度值,即RGBA) 是直接指定的。

GIF 和 PNG 对于具有大面积相同颜色的图像是最好的选择,因为它们使用的 (主要是基于游程长度编码的) 压缩算法可以减少存储需求。这种压缩是无损的,这意味着图像质量不会被压缩过程影响。

一个有损压缩图像格式的例子是 JPEG。创建 JPEG 图像时,通常会指定一个与图像质量相关的压缩比值参数,压缩程度过高会导致图像质量恶化。JPEG 不适用于对比鲜明的图像 (如线条图),其压缩方式对类似区域的图像质量损害会相对严重。如果某张截图中包含了文本,且保存为 JPEG 格式,就可以清楚地看到:生成的图像中字符周围会出现杂散的像素点。在大部分照片中不存在这个问题,所以照片主要使用 JPEG 格式。

总结:就放大缩小而言,矢量格式 (如 SVG) 是最好的。对比鲜明且颜色数量有限的线条图最适合 GIF 或 PNG (其中 PNG 更为强大),而照片,则应该使用 JPEG。当然,这些都不是不可逾越的规则,不过通常而言,对一定的图像质量与图像尺寸而言,遵守规则会得到最好的结果。

9.NSCache

对于很多开发者来说,NSCache是一个陌生人,因为大家往往对NSMutableDictionary情有独钟。可怜的 NSCache 一直处于 NSMutableDictionary 的阴影之下。就好像没有人知道它提供了垃圾处理的功能,而开发者们却费劲力气地去自己实现它。

没错,NSCache 基本上就是一个会自动移除对象来释放内存的 NSMutableDictionary。无需响应内存警告或者使用计时器来清除缓存。唯一的不同之处是键对象不会像 NSMutableDictionary 中那样被复制,这实际上是它的一个优点(键不需要实现 NSCopying 协议)。

当有缓存数据到内存的业务的时候,就应该考虑NSCache了,有缓存就有清楚缓存。

NSCache 每个方法和属性的具体作用,请参考这篇文章NSCache

NSCache在收到内存警告的时候会释放自身的一部分资源

如果我们想在收到内存警告时,释放所有的内容,可以参考下边的代码:

@interface AutoPurgeCache : NSCache
@end

@implementation AutoPurgeCache

- (nonnull instancetype)init {
    self = [super init];
    if (self) {
#if SD_UIKIT
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(removeAllObjects) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
    }
    return self;
}

- (void)dealloc {
#if SD_UIKIT
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
}

@end

10.NSOperation

NSOperation想必大家都知道,为了让程序执行的更快,我们用多线程异步的方式解决这个问题,GCDNSOperation都能实现多线程,我们这里只介绍NSOperation。如果大家想了解更多NSOperation的知识,我觉得这篇文章写得挺好:多线程之NSOperation简介

我们把NSOperation最核心的使用方法总结一下:

  1. NSOperation有两个方法:main()start()。如果想使用同步,那么最简单方法的就是把逻辑写在main()中,使用异步,需要把逻辑写到start()中,然后加入到队列之中。
  2. 大家有没有想过NSOperation什么时候执行呢?按照正常想法,难道要我们自己手动调用main()start()吗?这样肯定也是行的。当调用start()的时候,默认的是在当前线程执行同步操作,如果是在主线程调用了,那么必然会导致程序死锁。另外一种方法就是加入到operationQueue中,operationQueue会尽快执行NSOperation,如果operationQueue是同步的,那么它会等到NSOperation的isFinished等于YES后,在执行下一个任务,如果是异步的,通过设置maxConcurrentOperationCount来控制同事执行的最大操作,某个操作完成后,继续其他的操作。
  3. 并不是调用了canche就一定取消了,如果NSOperation没有执行,那么就会取消,如果执行了,只会将isCancelled设置为YES。所以,在我们的操作中,我们应该在每个操作开始前,或者在每个有意义的实际操作完成后,先检查下这个属性是不是已经设置为YES。如果是YES,则后面操作都可以不用在执行了。

能够引起思考的地方就是,比如说我有一系列的任务要执行,我有两种选择,一种是通过数组控制数据的取出顺序,另外一种就是使用队列

11.dispatch_barrier_async

我们可以创建两种类型的队列,串行和并行,也就是DISPATCH_QUEUE_SERIAL,DISPATCH_QUEUE_CONCURRENT。那么dispatch_barrier_async和dispatch_barrier_sync究竟有什么不同之处呢?

barrier这个词是栅栏的意思,也就是说是用来做拦截功能的,上边的这另种都能够拦截任务,换句话说,就是只有我的任务完成后,队列后边的任务才能完成。

不同之处就是,dispatch_barrier_sync控制了任务往队列添加这一过程,只有当我的任务完成之后,才能往队列中添加任务。dispatch_barrier_async不会控制队列添加任务。但是只有当我的任务完成后,队列中后边的任务才会执行。

那么在这里的任务是往数组中添加数据,对顺序没什么要求,我们采取dispatch_barrier_async就可以了,已经能保证数据添加的安全性了。

- (nullable NSArray<id> *)callbacksForKey:(NSString *)key {
    __block NSMutableArray<id> *callbacks = nil;
    dispatch_sync(self.barrierQueue, ^{
        // We need to remove [NSNull null] because there might not always be a progress block for each callback
        callbacks = [[self.callbackBlocks valueForKey:key] mutableCopy];
        [callbacks removeObjectIdenticalTo:[NSNull null]];
    });
    return [callbacks copy];    // strip mutability here
}

这个方法是根据key取出所有符合key的block,这里采用了同步的方式,相当于加锁。比较有意思的是[self.callbackBlocks valueForKey:key]这段代码,self.callbackBlocks是一个数组,我们假定他的结构是这样的:

@[@{@"completed" : Block1}, 
@{@"progress" : Block2}, 
@{@"completed" : Block3}, 
@{@"progress" : Block4}, 
@{@"completed" : Block5}, 
@{@"progress" : Block6}]

调用[self.callbackBlocks valueForKey:@"progress"]后会得到[Block2, Block4, Block6].
removeObjectIdenticalTo:这个方法会移除数组中指定相同地址的元素。

- (BOOL)cancel:(nullable id)token {
    __block BOOL shouldCancel = NO;
    dispatch_barrier_sync(self.barrierQueue, ^{
        [self.callbackBlocks removeObjectIdenticalTo:token];
        if (self.callbackBlocks.count == 0) {
            shouldCancel = YES;
        }
    });
    if (shouldCancel) {
        [self cancel];
    }
    return shouldCancel;
}

这个函数,就是取消某一回调。使用了dispatch_barrier_sync,保证,必须该队列之前的任务都完成,且该取消任务结束后,在将其他的任务加入队列。

12.对我的一点联想

如果有人问你:你怎么看待编程这件事?你怎么回答。这个问题是我在看这个类的时候,忽然出现在我脑子中的。我突然意识到,其实不管是函数还是属性,他们都是数据。我们编写的所有程序都是在处理数据。函数本身也是一种特殊的数据

真正难的是生产数据的这一过程。举个例子,给你一堆菜籽,要求生产出油来。怎么办?我们首先为这个任务设计一个函数:

- (油)用菜籽生产油(菜籽);

这就是我们最外层的函数,也应该是我们最开始想到的函数。然后经过我们的研究发现,这个生产过程很复杂,必须分工合作才能实现。于是我们把这个任务分割为好几个小任务:

1. - (干净的菜籽)取出杂质(菜籽);
2. - (炒熟的菜籽)把菜籽炒一下(干净的菜籽);
3. - (蒸了的菜籽)把菜籽蒸一下(炒熟的菜籽);
4. - (捆好的菜籽)把菜籽包捆成一块(蒸了的菜籽);
5. - (油)撞击菜籽包(捆好的菜籽);

大家有没有发现,整个榨油的过程就是对数据的处理。这一点其实很重要。如果没有把- (油)用菜籽生产油(菜籽);这一任务进行拆分,我们就会写出复杂无比的函数。那么就有人要问了,只要实现这个功能就行了呗。其实这往往是写不出好代码的原因。

整个任务的设计应该是事先就设计好的。任务被分割成更小更简单的部分,然后再去实现这些最小的任务,不应该是编写边分割任务,往往临时分割的任务(也算是私有函数吧)没有最正确的界限。

有了上边合理的分工之后呢,我们就可以进行任务安排了。我们回到现实开发中来。上边5个子任务的难度是不同的。有的人可能基础比较差,那么让他去干筛菜籽这种体力活,应该没问题。那些炒或者蒸的子任务是要掌握火候的,也就是说有点技术含量。那么就交给能胜任这项工作的人去做。所有的这一切,我们只要事先定义好各自的生产结果就行了,完全不影响每个程序的执行。

怎么样?大家体会到这种编程设计的好处了吗?我还可以进行合并,把炒和煮合成一个小组,完全可行啊。好了这方面的思考就说这么多吧。如果我想买煮熟了的菜籽,是不是也很简单?

有的人用原始的撞击菜籽包榨油,有的人却用最先进的仪器榨油,这就是编程技术和知识深度的区别啊。

13.initialize和load的区别

initializeload 这两个方法比较特殊,我们通过下边这个表格来看看他们的区别

.. +(void)load +(void)initialize
执行时机 在程序运行后立即执行 在类的方法第一次被调时执行
若自身未定义,是否沿用父类的方法?
类别中的定义 全都执行,但后于类中的方法 覆盖类中的方法,只执行一个

14权重系数q

_HTTPHeaders = [@{@"Accept": @"image/webp,image/*;q=0.8"} mutableCopy];

我们看看image/webp,image/*;q=0.8是什么意思,image/webp是web格式的图片,q=0.8指的是权重系数为0.8,q的取值范围是0 - 1, 默认值为1,q作用于它前边分号;前边的内容。在这里,image/webp,image/*;q=0.8表示优先接受image/webp,其次接受image/*的图片。

15.dispatch_group_enter和dispatch_group_leave

SDWebImage源码解读之SDWebImageDownloader的评论区,有小伙伴提出了SD在特定使用场景会崩溃的情况,我也做了一些实验。

- (void)test {
    NSURL *url = [NSURL URLWithString:@"http://upload-images.jianshu.io/upload_images/1432482-dcc38746f56a89ab.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240"];
    
    SDWebImageManager *manager = [SDWebImageManager sharedManager];
    
    dispatch_group_t group = dispatch_group_create();
    
    dispatch_group_enter(group);
    [manager loadImageWithURL:url options:SDWebImageRefreshCached progress:nil completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
        dispatch_group_leave(group);
    }];
    
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"下载完成了");
    });
}

在上边的函数中,我使用了dispatch_group_t dispatch_group_enter dispatch_group_leave 目的是等待所有的异步任务完成。

enter和leave方法必须成对出现,如果调用leave的次数多于enter就会崩溃,当我们使用SD时,如果Options设置为SDWebImageRefreshCached,那么这个completionBlock至少会调用两次,首先返回缓存中的图片。其次在下载完成后再次调用Block,这也就是崩溃的原因。

要想重现上边方法的崩溃,等图片下载完之后,再从新调用该方法就行。

16.SDWebImage主要组成模块

总结

以上就是SDWebImage中的一些小知识点,下一篇我会带来Alamofire的源码解读。

由于个人知识有限,如有错误之处,还望各路大侠给予指出啊

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

推荐阅读更多精彩内容