(二)Doodle - 精简的图片加载框架 - 原理篇

本篇是系列的第二篇,专门讲述Doodle的设计和实现,概述和用法见另外两篇文章:
(一)Doodle - 精简的图片加载框架 - 概述篇
(三)Doodle - 精简的图片加载框架 - 用法篇

原理篇涉及代码较多,最好能配合源码阅读。
https://github.com/BillyWei01/Doodle

一、架构

解决复杂问题,思路都是相似的:分而治之。
Doodle的核心的类不多:


参考MVC的思路,我们将框架划分三层:

  • Interface: 框架入口和外部接口
  • Processor: 逻辑处理层
  • Storage:存储层,负责各种缓存。

结构图如下,包含了框架的部分核心类及其依赖关系(A->B表示A依赖B)。

  • 外部接口
    Doodle: 提供全局参数配置,图片加载入口,以及缓存,生命周期,任务暂停/恢复等接口。
    Config: 全局参数配置。包括缓存路径,缓存大小,默认编码,自定义Downloader/Decoder等参数。
    Request: 封装请求参数。包括数据源,解码参数,行为参数,以及目标(ImageView等)。

  • 执行单元
    Controller : 负责请求调度, 以及结果反馈等,是“请求-工作线程-目标”之间的桥梁。
    Worker: 工作线程,异步执行加载,解码,变换,存储等。
    Decoder: 负责具体的解码工作,包括计算缩放比例(降采样/上采样),图片剪裁,图片方向处理等。
    DataFetcher: 负责数据源的解析,提供统一的信息提取,文件解码等接口。
    Downloader: 负责文件下载。

  • 存储组件
    MemoryCache: 管理Bitmap缓存,包含LRU缓存和引用缓存。
    DiskCache: 文件缓存管理,分别提供给Worker(结果缓存)和Downloader(原图缓存)。

二、流程

仅从结构图不足以了解框架的运作机制,接下来我们结合流程作分析。
概括来说,图片加载包含封装参数,获取数据,解码,变换,缓存,显示等操作。

  1. 获取Request
    Doodle类中定义了几个静态方法,返回Request对象,作为请求的开始。

  2. 封装参数
    从指定来源,到输出结果,中间可能经历很多流程,这些参数会贯穿整个过程,前面的结构图可以看出这一点。
    封装参数完成后,会构造出一个key, 用于索引缓存。

  3. 内存缓存
    Controller-Worker是UI线程和后台线程的分界。
    请求的开始,先读一下内存缓存是否存在所请求的bitmap, 如果存在则直接显示,否则启用后台线程。

  4. Worker
    进入工作线程后,其实还是会再检查一次内存缓存,上图中简略了,没有画出来。
    如果内存缓存中没有所需要的bitmap, 则先尝试读取结果缓存:
    如果存在,则直接解码,得到bitmap后缓存到内存并显示;
    若不存在,则需要获取数据并解码,这个解码要比从读取结果缓存后的解码要复杂许多(可能需要采样或剪裁)。
    解码原文件后,如果请求中设置了Transformation的话,需要执行Transformation,完了之后还在保存到内存以及磁盘(结果缓存)。
    值得一提的是:只又网络文件才有“原图缓存”一说,本地文件不需要再做“缓存”。

  5. 显示图片
    显示结果,可能需要做些动画(淡入动画,crossFade等);
    如果结果动图(Animatable),则启动一下动画。

以上简化版的流程(只是众多路径中的一个分支),更多细节我们接下来慢慢分析。

三、API设计

前面提到,Config类负责全局参数配置,Request承载单个请求的参数封装。
二者都有Doodle的静态方法提供对象实例。

public final class Doodle {
    public static Config config() {
        return Config.INSTANCE;
    }

    //  load bitmap by file path, url, or asserts path
    public static Request load(String path) {
        return new Request(path);
    }

    // load bitmap from drawable or raw resource
    public static Request load(int resID) {
        return new Request(resID);
    }

    public static Request load(Uri uri) {
        return new Request(uri);
    }
}

示例用法如下:

全局配置:

Doodle.config()
    .setLogger(Logger)
    .setSourceFetcher(OkHttpSourceFetcher)
    .addDrawableDecoders(GifDecoder)

图片加载请求:

Doodle.load(path).into(imageView);

Request类:

public final class Request {
    // 缓存key
    private CacheKey key;

    // 数据源
    final String path;
    Uri uri;
    private String sourceKey;

    // 解码参数
    int targetWidth;
    int targetHeight;
    ClipType clipType = ClipType.NOT_SET;
    boolean enableUpscale = false;
    DecodeFormat decodeFormat = DecodeFormat.ARGB_8888;
    List<Transformation> transformations;
    Map<String, String> options;

    // 加载行为
    MemoryCacheStrategy memoryCacheStrategy = MemoryCacheStrategy.LRU;
    DiskCacheStrategy diskCacheStrategy = DiskCacheStrategy.ALL;
    int placeholderId = -1;
    int errorId = -1;
    int animationId;
    // ...此处省略一些参数...

    // 目标
    Request.Waiter waiter;
    SimpleTarget simpleTarget;
    WeakReference<ImageView> targetReference;
}

Request主要职能是封装请求参数,参数可以大约划分为4类:

  • 1、图片源:内置支持File,Uri,http,drawable,raw,assets等,可以扩展。
  • 2、解码参数:宽高,缩放/剪裁类型,解码格式……等。
  • 3、加载行为:缓存策略,占位图,动画……等。
  • 4、目标:ImageView或者接口回调等。

其中,图片源解码参数决定了最终的bitmap, 所以,我们拼接这些参数请求作为key,这个key会用于缓存的索引和任务的去重。
拼接参数后字符串很长,所以需要压缩成摘要,128bit的摘要即可(原理参考:生日攻击)。

图片文件的来源,通常有网络文件,drawable/raw资源, assets文件,本地文件等。
当然,严格来说,除了网络文件之外,其他都是本地文件,只是不同形式而已。

四、 缓存设计

几大图片加载框架都实现了缓存,各种文章中,有说二级缓存,有说三级缓存。
其实从存储来说,可简单地分为内存缓存和磁盘缓存。
同样是内存/磁盘缓存,也有多种形式,例如Glide的“磁盘缓存”就分为“原图缓存”和“结果缓存”,
而Picasso/Coil只依赖OkHttp缓存网络图片的原图,并没有实现自己的磁盘缓存,也就没有保存解码后的结果了。

4.1 内存缓存

为了复用计算结果,提高用户体验,通常会做bitmap的缓存;
而由于要限制缓存的大小,需要淘汰机制(通常是LRU策略)。
Android SDK提供了LruCache类,查看源码,其核心是LinkedHashMap。
为了更好地定制,这里我们不用SDK提供的LruCache,直接用LinkedHashMap,封装自己的LruCache

private static class BitmapWrapper {
    final Bitmap bitmap;
    final int bytesCount;

    BitmapWrapper(Bitmap bitmap) {
        this.bitmap = bitmap;
        this.bytesCount = Utils.getBytesCount(bitmap);
    }
}
final class LruCache {
    private static final long MIN_TRIM_SIZE = Runtime.getRuntime().maxMemory() / 64;
    private static long sum = 0;
    private static final Map<CacheKey, BitmapWrapper> cache =  new LinkedHashMap<>(16, 0.75f, true);

    static synchronized Bitmap get(CacheKey key) {
        BitmapWrapper wrapper = cache.get(key);
        return wrapper != null ? wrapper.bitmap : null;
    }

    static synchronized void put(CacheKey key, Bitmap bitmap) {
        long capacity = Config.memoryCacheCapacity;
        if (bitmap == null || capacity <= 0 || cache.containsKey(key)) {
            return;
        }
        BitmapWrapper wrapper = new BitmapWrapper(bitmap);
        cache.put(key, wrapper);
        sum += wrapper.bytesCount;
        if (sum > capacity) {
            trimToSize(capacity * 9 / 10);
        }
    }

    private static void trimToSize(long size) {
        Iterator<Map.Entry<CacheKey, BitmapWrapper>> iterator = cache.entrySet().iterator();
        while (iterator.hasNext() && sum > size) {
            Map.Entry<CacheKey, BitmapWrapper> entry = iterator.next();
            BitmapWrapper wrapper = entry.getValue();
            WeakCache.put(entry.getKey(), wrapper.bitmap);
            iterator.remove();
            sum -= wrapper.bytesCount;
        }
    }
}

LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。
进行淘汰时,移除遍历器前端的元素,直至缓存总大小降低到指定大小以下。

有时候需要加载比较大的图片,占用内存较高,放到LruCache可能会“挤掉”其他一些bitmap;
或者有时候滑动列表生成大量的图片,也有可能会“挤掉”一些bitmap。
这些被挤出LruCache的bitmap有可能很快又会被用上,但在LruCache中已经索引不到了,如果要用,需重新解码。
值得指出的是,被挤出LruCache的bitmap,在GC时并不一定会被回收,如果bitmap还被引用,则不会被回收;
但是不管是否被回收,在LruCache中都索引不到了。

我们可以将一些可能短暂使用的大图片,以及这些被挤出LruCache的图片,放到弱引用的容器中。
在被回收之前,还是可以根据key去索引到bitmap。

private static class BitmapReference extends WeakReference<Bitmap> {
    private final CacheKey key;

    BitmapReference(CacheKey key, Bitmap bitmap, ReferenceQueue<Bitmap> q) {
        super(bitmap, q);
        this.key = key;
    }
}
final class WeakCache {
    private static final Map<CacheKey, BitmapReference> cache = new HashMap<>();
    private static final ReferenceQueue<Bitmap> queue = new ReferenceQueue<>();

    static synchronized Bitmap get(CacheKey key) {
        cleanQueue();
        BitmapReference ref = cache.get(key);
        return ref != null ? ref.get() : null;
    }

    static synchronized void put(CacheKey key, Bitmap bitmap) {
        cleanQueue();
        if (bitmap != null) {
            BitmapReference ref = cache.get(key);
            if (ref == null || ref.get() != bitmap) {
                cache.put(key, new BitmapReference(key, bitmap, queue));
            }
        }
    }

    private static void cleanQueue() {
        BitmapReference reference = (BitmapReference) queue.poll();
        while (reference != null) {
            BitmapReference ref = cache.get(reference.key);
            if (ref != null && ref.get() == null) {
                cache.remove(reference.key);
            }
            reference = (BitmapReference) queue.poll();
        }
    }
}

以上实现中,BitmapWeakReference是WeakReference的子类,除了引用Bitmap的功能之外,还记录着key, 以及关联了ReferenceQueue;
当Bitmap被回收时,BitmapWeakReference会被放入ReferenceQueue,
我们可以遍历ReferenceQueue,移除ReferenceQueue的同时,取出其中记录的key, 到cache中移除对应的记录。
利用WeakReference和ReferenceQueue的机制,索引对象的同时又不至于内存泄漏。

最后,综合LruCacheWeakCache,统一索引:

final class MemoryCache {
    static Bitmap getBitmap(CacheKey key) {
        Bitmap bitmap = LruCache.get(key);
        if (bitmap == null) {
            bitmap = WeakCache.get(key);
        }
        return bitmap;
    }

    static void putBitmap(CacheKey key, Bitmap bitmap, boolean toWeakCache) {
        if (toWeakCache) {
            WeakCache.put(key, bitmap);
        } else {
            LruCache.put(key, bitmap);
        }
    }

    // ...
}

声明内存缓存策略:

public enum MemoryCacheStrategy {
    NONE,
    WEAK,
    LRU
}

NONE: 不缓存到内存
WEAK: 缓存到WeakCache
LRU:缓存到LruCache

4.2 磁盘缓存

前面提到,Glide有两种磁盘缓存:“原图缓存”和“结果缓存”,
Doodle也仿照类似的策略,可以选择缓存原图和结果。
原图缓存指的是Http请求下来的未经解码的文件;
结果缓存指经过解码,剪裁,变换等,变成最终的bitmap之后,通过bitmap.compress()压缩保存。
其中,后者通常比前者更小,而且解码时不需要再次剪裁和变换等,所以从结果缓存获取bitmap通常要比从原图获取快得多。

为了尽量使得api相似,Doodle直接用Glide v3的缓存策略定义(Glide v4有一些变化)。

public enum DiskCacheStrategy {
    NONE,
    SOURCE,
    RESULT,
    ALL;
}

NONE: 不缓存到磁盘
SOURCE: 只缓存原图
RESULT: 只缓存结果
ALL: 既缓存原图,也缓存结果。

磁盘缓存需要有一个管理工具,通常见得最多的是DiskLruCache,比如OkHttp和Glide都是用的DiskLruCache。
笔者觉得DiskLruCache的日志写入效率不够高,于是自己自己实现了磁盘缓存管理类: DiskCache。

DiskCache机制:
内存中维护一个CacheKey->Record和HashMap, Record包含“CacheKey, 访问order, 文件大小”;
磁盘上对应一个日志文件,记录所有Record:CacheKey占16字节,order占4字节,文件大小占4字节,共24字节。
日志文件用mmap的方式打开, 更新Record时,根据Record的offset进行写入。
新增和读取缓存:获取"maxOrder +1", 作为Record的新order。
当容量超出限制,或者缓存数量超过限制,先删除order最小的文件(其实就是LRU策略)。
Detele操作:磁盘中,Record的order更新为0(这样打开日志文件时可以知道这条记录失效了),HashMap中对应的Record。

相比于DiskLruCache, DiskCache日志记录更加紧凑(二进制),写入更加快速(mmap),
此外, 除了增加Record外,DiskCache不需要追加内容(不需要频繁扩容):Record的更新和删除,只需覆写日志文件中对应的order字段即可。

五、 解码

SDK提供了BitmapFactory/MediaMetadataRetrieverI,用于降图片/视频文件解码成bitmap,但这仅是图片解码的最基础的工作;
图片解码,前前后后要准备各种材料,留心各种细节,是图片加载过程中最复杂的步骤之一。

5.1 数据读取

前面提到 ,Doodle支持File,Uri,http,drawable,raw,assets等数据源。
不同的数据源,获取数据的方式的API不一样,但大致可以分为两种,File和InputStream。
例如,http文件可以下载完成后用File打开,也可以直接用网络API返回的InputStream读取;
assets可以通过AssetManager获取InputStream;
uri可以通过ContentResolver获取InputStream;

最后,如果以上API都无法读取,可以通过自定义DataParser,使Doodle支持该类型的数据源。

public interface DataParser {
    InputStream parse(String path);
}

数据读取的大部分代码在DataLoader类中, 这里贴一下解析部分的代码:

static DataFetcher parse(Request request) throws IOException {
    DataLoader loader;
    boolean fromSourceCache = false;
    String path = request.path;
    if (path.startsWith("http")) {
        CacheKey key = new CacheKey(path);
        String cachePath = Downloader.getCachePath(key);
        if (cachePath != null) {
            loader = new FileLoader(new File(cachePath));
            fromSourceCache = true;
        } else {
            if (request.onlyIfCached) {
                throw new IOException("No cache");
            }
            if (request.diskCacheStrategy.savaSource()) {
                loader = new FileLoader(Downloader.download(path, key));
            } else {
                loader = new StreamLoader(path, Downloader.getInputStream(path), null);
            }
        }
    } else if (path.startsWith(ASSET_PREFIX)) {
        loader = new StreamLoader(path, Utils.appContext.getAssets().open(path.substring(ASSET_PREFIX_LENGTH)), null);
    } else if (path.startsWith(FILE_PREFIX)) {
        loader = new FileLoader(new File(path.substring(FILE_PREFIX_LENGTH)));
    } else {
        InputStream inputStream = handleByDataParsers(path);
        if (inputStream != null) {
            loader = new StreamLoader(path, inputStream, null);
        } else {
            Uri uri = request.uri != null ? request.uri : Uri.parse(path);
            loader = new StreamLoader(path, Utils.getContentResolver().openInputStream(uri), uri);
        }
    }
    return new DataFetcher(path, loader, fromSourceCache);
}

private static InputStream handleByDataParsers(String path) {
    if (Config.dataParsers != null) {
        for (DataParser parser : Config.dataParsers) {
            InputStream inputStream = parser.parse(path);
            if (inputStream != null) {
                return inputStream;
            }
        }
    }
    return null;
}

DataParser负责提供数据读取的API,而具体读取数据在DataLoader中实现。
DataLoader是接口,有两个实现类:FileLoader和StreamLoader。
对于File而言,其实也可以转化为FileInputStream,这样的话只需要一个StreamLoader就可以了。
那为什么区分开来呢? 这一切都要从读取图片头信息开始讲。

5.2 文件预读

解码过程中通常需要预读一些头信息,如文件格式,图片分辨率等,作为接下来解码策略的参数,例如用图片分辨率来计算采样比例。
inJustDecodeBounds设置为true时, BitmapFactory不会返回bitmap, 而是仅仅读取文件头信息,其中最重要的是图片分辨率。

val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, options)

读取了头信息,计算解码参数之后,将inJustDecodeBounds设置为false,
再次调用BitmapFactory.decodeStream即可获取所需bitmap。
可是,有的InputStream不可重置读取位置,同时BitmapFactory.decodeStream方法要求从头开始读取。
那先关闭流,然后再次打开不可以吗? 可以,不过效率极低,尤其是网络资源时,断开连接再重连?代价太大了。

有的InputStream实现了mark(int)和reset()方法,就可以通过标记和重置支持重新读取。
这一类InputStream会重载markSupported()方法,并返回true, 我们可以据此判断InputStream是否支持重读。

幸运的是AssetInputStream就支持重读;
不幸的是FileInputStream居然不支持,OkHttp的byteStream()返回InputStream也不支持。

对于文件,我们通过搭配RandomAccessFile和FileDescriptor来重新读取(RandomAccessFile有seek方法);
而对于其他的InputStream,只能曲折一点,通过缓存已读字节来支持重新读取。
SDK提供的BufferedInputStream就是这样一种思路, 通过设置一定大小的缓冲区,以滑动窗口的形式提供缓冲区内重新读取。
遗憾的是,BufferedInputStream的mark函数需指定readlimit,缓冲区会随着需要预读的长度增加而扩容,但是不能超过readlimit;
若超过readlimit,则读取失败,从而解码失败。

    /**
     * @param readlimit the maximum limit of bytes that can be read before
     *                  the mark position becomes invalid.
     */
    public void mark(int readlimit) {
        marklimit = readlimit;
        markpos = pos;
    }

于是readlimit设置多少就成了考量的因素了。
Picasso早期版本设置64K, 结果遭到大量的反馈说解码失败,因为有的图片需要预读的长度不止64K。
从Issue的回复看,Picasso的作者也很无奈,最终妥协地将readlimit设为MAX_INTEGER。
但即便如此,后面还是有反馈有的图片无法预读到图片的大小。
笔者很幸运地遇到了这种情况,经调试代码,最终发现Android 6.0的BufferedInputStream,
其skip函数的实现有问题,每次skip都会扩容,即使skip后的位置还在缓冲区内也会扩容。
造成的问题是有的图片预读时需多次调用skip函数,然后缓冲区就一直double直至抛出OutOfMemoryError……
不过Picasso最终还是把图片加载出来了,因为Picasso catch了Throwable, 然后重新直接解码(不预读大小);
虽然加载出来了,但是代价不小:只能全尺寸加载,以及前面预读时申请的大量内存(虽然最终会被GC),所造成的内存抖动。

Glide没有这个问题,因为Glide自己实现了类似BufferedInputStream功能的InputStream,完美地绕过了这个坑;
Doodle则是copy了Android 8.0的SDK的BufferedInputStream,精简代码,加入一些byte[]复用的代码等,可以说是改装版BufferedInputStream。

回头看前面一节的问题,为什么不统一用“改装版BufferedInputStream”来解码?
因为有的图片预读的长度很长,需要开辟较大的缓冲区,从这个角度看,用RandomAccessFile更节约内存。
同时,Doodle读取数据时会缓存头部的部分字节,如此,对于判断文件类型等需要用到头部字节的地方,就不需要重复读取了。

5.3 图片采样

有时候需要显示的bitmap比原图的分辨率小。
比方说原图是 4096 * 4096, 如果按照ARGB_8888的配置全尺寸解码出来,需要占用64M的内存!
不过app中所需的bitmap通常会小很多, 这时就要降采样了。
比方说需要300 * 300的bitmap, 该怎么做呢?
网上通常的说法是设置 options.inSampleSize 来降采样。
阅读SDK文档,inSampleSize 需是整数,而且是2的倍数,
不是2的倍数时,会被 “be rounded down to the nearest power of 2”
比方说前面的 4096 * 4096 的原图,
当inSampleSize = 16时,解码出256 * 256 的bitmap;
当inSampleSize = 8时,解码出512 * 512 的bitmap。
即使是inSampleSize = 8,所需内存也只有原来的1/64(1M),效果还是很明显的。

Picasso和Glide v3就是这么降采样的。
如果你发现解码出来的图片是300 * 300 (比如使用Picasso时调用了fit()函数),应该是有后续的处理(通过Matrix 和 Bitmap.createBitmap 继续缩放)。

那能否直接解码出300 * 300的图片呢? 可以的。
查看 BitmapFactory.cpp 的源码,其中有一段:

const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
   scale = (float) targetDensity / density;
}

对应BitmapFactory.Options的两个关键参数:inDensity 和 inTargetDensity。
上面的例子,设置inTargetDensity=300, inDensity=4096(还要设置inScale=true), 则可解码出300 * 300的bitmap。
额外提一下,Glide v4也换成这种采样策略了。

解码的过程为,通过获取图片的原始分辨率,结合Request的width和height, 以及ScaleType,
计算出最终要解码的宽高, 设置inDensity和inTargetDensity然后decode。
当然,有时候decode出来之后还要做一些加工,比方说ScaleType为CENTER_CROP
则需要在decode之后进行裁剪,取出中间部分的像素。

关于ScaleType,Doodle是直接获取ImageView的ScaleType, 所以无需再特别调用函数指定;
当然也提供了指定ScaleType的API, 对于target不是ImageView时或许会用到。

 public Request scaleType(ImageView.ScaleType scaleType)

还有就是,解码时默认是向下采样的。
比如,如果原图只有100 * 100, 但是ImageView是200 * 200,最终也是解码出100 * 100的bitmap。
因为ImageView假如是CENTER_CROP或者FIX_XY等ScaleType,显示时通常会在渲染阶段自行缩放的。
如果确实就是需要200 * 200的分辨率,可以调用enableUpscale() 方法。
调用enableUpscale()后,不管原图是100100还是400400,最终都可以得到一个200*200的bitmap。

5.4 图片方向

相信不少开发都遇到拍照后图片旋转的问题(尤其是三星的手机)。
网上有不少关于此问题的解析,这是其中一篇:关于图片EXIF信息中旋转参数Orientation的理解

Android SDK提供了ExifInterface 来获取Exif信息,Picasso正是用此API获取旋转参数的。
很可惜ExifInterface要到 API level 24 才支持通过InputStream构造对象,低于此版本,仅支持通过文件路径构造对象。
故此,Picasso当前版本仅在传入参数是文件路径时才可处理旋转问题。

Glide自己实现了头部解析,主要是获取文件类型和exif旋转信息。
Doodle抽取了Glide的HeaderParser,并结合工程做了一些精简和代码优化, 嗯,又一个“改装版”。
decode出bitmap之后,根据获取的旋转信息,调用setRotatepostScale进行对应的旋转和翻转,即可还原正确的显示。

5.5 变换

解码出bitmap之后,有时候还需要做一些处理,如圆形剪裁,圆角,滤镜等。
Doodle参考Picasso/Glide, 提供了类似的API:Transformation

public interface Transformation {
    Bitmap transform(Bitmap source);

    String key();
}

实现变换比较简单,实现Transformation接口,处理source,返回处理后的bitmap即可;
当然,还要在key()返回变换的标识,通常写变换的名称就好,如果有参数, 需拼接上参数。
Transformation也是决定bitmap最终结果的因素之一,所以需要重载key(), 作为Request的key的一部分。
Transformation可以设置多个,处理顺序会按照设置的先后顺序执行。

Doodle预置了两个常用的Transformation。
CircleTransformation:圆形剪裁,如果宽高不相等,会先取中间部分(类似CENTER_CROP)。
RoundedTransformation:圆角剪裁,可指定半径。

更多的变换,可以到glide-transformations寻找,
虽然不能直接导入引用, 但是对bitmap的处理是相同的,改造一下就可使用。

5.6 自定义解码

Doodle内置了使用BitmapFactory获取图片,和使用MediaMetadataRetriever来获取视频缩略图的Decoder。
对于其他BitmapFactory和MediaMetadataRetriever都不支持的文件,可以注入自定义Decoder来解码。
Doodle提供两个自定义解码接口:

public interface DrawableDecoder {
    Drawable decode(DecodingInfo info);
}
public interface BitmapDecoder {
    Bitmap decode(DecodingInfo info);
}

Glide有类似的接口:ResourceDecoder。
但ResourceDecoder需要实现两个方法,在handles方法中判断是否能处理,返回true才会调用decode方法。

public interface ResourceDecoder<T, Z> {
  boolean handles(@NonNull T source, @NonNull Options options) throws IOException;

  Resource<Z> decode(@NonNull T source, int width, int height, @NonNull Options options)
      throws IOException;
}

相比于Glide,Doodle简化了接口的定义:

  1. 只需实现decode方法;
  2. 规约了结果的类型(Bitmap, Drawable)。

实现类根据DecodingInfo判断是否可以处理,如果可以处理,解码成Bitmap/Drawable返回,否则直接返回null即可。
DecodingInfo提供的信息如下:

public final class DecodingInfo {
    public final String path;
    public final int targetWidth;
    public final int targetHeight;
    public final ClipType clipType; // 缩放类型
    public final DecodeFormat decodeFormat; // 解码格式(RGB_8888,RGB565,RGB_AUTO)
    public final Map<String, String> options; // 自定义参数

    // ...省略部分代码...

    // 获取头部26个字节(大部分文件可以通过头部字节识别出文件类型)
    public byte[] getHeader() throws IOException {
        return getDataFetcher().getHeader();
    }

    // Doodle内置了部分文件类型的解析
    public MediaType getMediaType() throws IOException {
        return getDataFetcher().getMediaType();
    }

    // 获取文件的所有数据
    public byte[] getData() throws IOException {
        return getDataFetcher().getData();
    }
}

实现了接口后,可通过两种方法使用:

  1. 注册到全局配置Config中:对所有请求生效,每个请求都会先走一遍所有注册的自定义的Decoder。
  2. 设置到Request中,仅对单个请求生效。

接下来分别举例这两种用法。

5.7 GIF图

GIF有静态的,也有动态的。
BitmapFactory支持解码GIF图片的第一帧,所以各个图片框架都支持GIF缩率图。
至于GIF动图,Picasso当前是不支持的,Glide支持,但据反馈有些GIF动图Glide显示不是很流畅。
Doodle本身也没有实现GIF动图的解码,但是留了解码接口,结合第三方GIF解码库, 可实现GIF动图的加载和显示。
GIF解码库,推荐 android-gif-drawable

具体用法:
实现DrawableDecoder接口。

import pl.droidsonroids.gif.GifDrawable

object GifDecoder : DrawableDecoder {
    override fun decode(info: DecodingInfo): Drawable? {
        if (info.mediaType != MediaType.GIF) {
            return null
        }
       return GifDrawable(info.data)
    }
}

在App启动时,注入实现类:

fun initApplication(context: Application) {
      Doodle.config().addDrawableDecoders(GifDecoder)
}

注册了Gif解码器后,请求图片和普通的请求没区别:如果图片源是GIF动图,会解码得到GifDrawable。

Doodle.load(url).into(gifImageView)

当然也可以指定不需要显示动图, 调用asBitmap方法即可。

这里而额外提一下Glide的情况:
Glide有三个接口:
asDrawable(默认), asBimap, asGif。
asDrawable时,如果源文件是动图则显示动图,如果源文件是静态图则显示静态图(bitmap);
asBitmap时,总是显示静态图;
asGif时,如果源文件是动图则显示动图,如果源文件是静态图则不显示(空白)。
我原本以为asGif就是鸡肋,有asDrawable和asBitmap就够了,直到我遇到这么一个case:
我当时在测试相册相关的代码,先调用了asBitmap,确实就都显示静态图了;然后再调asDrawable, 重新编译,启动,我原本预期相册列表中如果原文件是Gif文件能显示动图,结果总是显示静态图片。
然后我改动代码,当mime(媒体数据库中获取)等于"image/gif", 调用asGif,这才显示了动图;
而且还有例外,有的图片文件本身是Gif动图,单文件后缀是jpg, 在媒体数据库中mime也是"image/jpeg"。
对于这种例外,要么忽略,要么只能先读取每一个图片文件的头部字节,以判断文件是不是Gif文件;
而在图片框架之外读头部字节是有代价的,在主线程的话怕ANR,在IO线程读的话会让代码破碎,“变丑”,不管用协程还是线程。

我没有细究Glide为什么会如此。
在写Doodle时,我只创建了asBitmap方法,因为在Doodle的实现中,asBitmap为true或false是两个不同的请求(CacheKey不一样),不会相互干扰。

5.8 相册缩略图

很多APP内置了自定义的ImagePicker, ImagePicker需要显示媒体库中的视频/图片。
直接读取媒体库的文件去解码的话比较耗时,更快的做法是读取SDK提供的获取缩略图的接口,访问系统已经生成好的缩略图文件。

Glide中也有类似的实现:MediaStoreImageThumbLoader/MediaStoreVideoThumbLoader。
但是获取缩略图的方法在Android高版本已经失效了(我测试的机器是Android 10)。
使用Glide且希望通过读缩略图文件显示相册的话需要自己实现ModelLoader和ResourceDecoder。

Doodle内置的读取媒体缩略图的实现:

class MediaThumbnailDecoder implements BitmapDecoder {
    static final String KEY = "ThumbnailDecoder";
    static final MediaThumbnailDecoder INSTANCE = new MediaThumbnailDecoder();

    @Override
    public Bitmap decode(DecodingInfo info) {
        String path = info.path;
        if (!(path.startsWith("content://media/external/") && info.options.containsKey(KEY))) {
            return null;
        }
        Bitmap bitmap = null;
        try {
            Uri uri = Uri.parse(path);
            ContentResolver contentResolver = Utils.appContext.getContentResolver();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                try {
                    bitmap = contentResolver.loadThumbnail(uri, new Size(info.targetWidth, info.targetHeight), null);
                } catch (Exception ignore) {
                }
            }
            if (bitmap == null) {
                int index = path.lastIndexOf('/');
                if (index > 0) {
                    long mediaId = Long.parseLong(path.substring(index + 1));
                    bitmap = MediaStore.Video.Thumbnails.getThumbnail(
                            contentResolver,
                            mediaId,
                            MediaStore.Video.Thumbnails.MINI_KIND,
                            null
                    );
                }
            }
        } catch (Throwable e) {
            LogProxy.e("Doodle", e);
        }
        return bitmap;
    }
}

要启用该Decoder可以调用一下Request的方法:

public Request enableThumbnailDecoder() {
    addOption(MediaThumbnailDecoder.KEY, "");
    return setBitmapDecoder(MediaThumbnailDecoder.INSTANCE);
}

这个方法只作用于当前Request,不会干扰其他请求。
比如有的地方要访问媒体文件的预览图,也是传入相同的uri,该请求不会被这个Decoder拦截处理,就会读取原文件。
稳妥起见,enableThumbnailDecoder方法还设置了options,。
options会参与CacheKey的摘要计算,这里设置options主要是为了确保CacheKey不同于直接访问原图的请求(否则可能会读到彼此的缓存)。

5.9 图片复用

很多文章讲图片优化时都会提图片复用。
Doodle在设计阶段也考虑了图片复用,并且也实现了,但实现后一直纠结其收益和成本。

  • 正在使用的图片不能被复用,所以要添加引用计数策略,附加代码很多,且占用一些额外的计算资源。
  • 即使图片没有被引用,根据局部性原理,该图片可能稍后有可能被访问,所以也不应该马上被复用。
  • 大多数情况下,符合复用条件(不用一段时间,尺寸符合要求)的并不多。
  • 通过统计ImageView是否引用bitmap的策略,有可能“逃逸”,比方说可以从ImageView获取Drawable,然后获取其中的bitmap, 用作其他用途,这样即使ImageView被回收了,其曾经attach的bitmap其实也是“在用”的。一旦统计不能覆盖,并且被复用了,会导致原来在用的的地方显示错误。

个人观点,或许某些封闭的使用场景做图片复用会比较合适,但对于图片加载框架而言,使用场景比较复杂,做图片服用的风险和成本大于收益。
综合考虑,Doodle没有去做bitmap复用。

六、 任务调度

图片加载的过程中,数据获取和图片解码等操作发生在后台线程。
一旦涉及异步,就得考虑并发控制,时序控制,线程切换,任务取消等情况。
任务调度这部分,笔者的另一篇文章其实有讲述过,考虑阅读流畅性,就不开新篇了,直接在本篇写吧。

6.1 并发控制

Doodle需要后台线程的有两处:图片加载和缓存结果到磁盘。
我们希望两种任务相互独立,并且后者串行执行就好(缓存相对加载没那么优先)。
常规做法是创建两个线程池,一个调newFixedThreadPool, 一个调newSingleThreadExecutor。
但是这样的话两个线程池的线程旧不能彼此复用了,然后还得维持几个核心线程。
Doodle的做法是在真正执行任务的Executor上套队列,由队列控制并发窗口,这样既各自控制了并发。

然后就是,负责图片加载的任务队列,设置多少并发量呢?

final class Scheduler {
    private static final int CUP_COUNT = Runtime.getRuntime().availableProcessors();
    private static final int WINDOW_SIZE = Math.min(Math.max(2, CUP_COUNT), 4);
    static final PipeExecutor pipeExecutor = new PipeExecutor(WINDOW_SIZE, WINDOW_SIZE * 2);
}

默认情况下,WINDOW_SIZE由可用处理器数量决定,并且限定在[2,4]之间,考虑到目前新设备基本在4个以上,所以大多数情况下就是4了。
不直接设定为CPU_COUNT的原因之一,是考虑限制功耗(一些CPU在发热时会关闭核心或者降频),
毕竟解码图片是计算密集型任务,挺消耗CPU的;
而且除了解码外,还有一个保存结果的任务队列,需要将bitmap编码为文件,也是计算密集型任务。
总得留点计算资源刷新UI吧。
对于需要下载网络文件的任务,相对于占用CPU的时间,其消耗在IO的时间要更多,所以通常对于IO密集型的任务,建议增加并发。
故此,在下载前,Doodle会将并发窗口+1(最多增加到WINDOW_SIZE*2), 在下载完成后将并发窗口-1(最少减到WINDOW_SIZE)。
Doodle的源码中,将实现这部分逻辑的Executor命名为PipeExecutor。

6.2 任务排队

图片加载可能会碰到这样的场景:
几乎同一时间,需要加载相同路径的图片,到不同的ImageView, 并且这些ImageView的宽高和ScaleType相同。
比方说聊天窗口的头像,就是这种case。
就拿这个case来说,打开聊天窗口,就会生成多个相同的Request(CacheKey相同),在还没有内存缓存的情况下,会创建多个异步任务,同时解码。
这样的话就会生成多个相同的bitmap, 浪费CPU和内存。
还有另一种case, 需要下载网络图片的任务,在没有原图缓存的情况下,也有可能会重复下载。

为了避免重复解码或者重复下载,需要做一些措施。
Doodle的做法是,用tag标记任务,用一个Set记录正在执行的任务,用一个Map缓存等待执行的任务。
执行一个任务,如果Set中保存该任务的tag, 则将任务保存到一个 tag->list 的Map中排队,等Set中的任务执行结束后,再从Map中取出执行。
对于解码任务而言,正好用CacheKey作为tag;而如果这个任务是需要网络下载的,则用Url构造CacheKey作为tag。
代码和示意图如下:

static class TagExecutor {
    private static final Set<CacheKey> scheduledTags = new HashSet<>();
    private static final Map<CacheKey, LinkedList<Runnable>> waitingQueues = new HashMap<>();

    public synchronized void execute(CacheKey tag, Runnable r) {
        if (r == null) {
            return;
        }
        if (!scheduledTags.contains(tag)) {
            start(tag, r);
        } else {
            LinkedList<Runnable> queue = waitingQueues.get(tag);
            if (queue == null) {
                queue = new LinkedList<>();
                waitingQueues.put(tag, queue);
            }
            queue.offer(r);
        }
    }

    private void start(CacheKey tag, Runnable r) {
        scheduledTags.add(tag);
        pipeExecutor.execute(new Wrapper(r) {
            @Override
            public void run() {
                try {
                    task.run();
                } finally {
                    scheduleNext(tag);
                }
            }
        });
    }

    private synchronized void scheduleNext(CacheKey tag) {
        scheduledTags.remove(tag);
        LinkedList<Runnable> queue = waitingQueues.get(tag);
        if (queue != null) {
            Runnable r = queue.poll();
            if (r == null) {
                waitingQueues.remove(tag);
            } else {
                start(tag, r);
            }
        }
    }
}

private static abstract class Wrapper implements Runnable {
    final Runnable task;

    Wrapper(Runnable r) {
        this.task = r;
    }
}

TagExecutor实现的效果就是:相同tag的任务串行,不同tag的任务并行。
相同tag的任务串行为什么可以防止重复解码?因为框架中有MemeryCache, 解码成功后会保存cache,排在后面的相同CacheKey的任务,读取cache就好,不需要再次解码了。
下载的case同理。

示意图中的假定真正执行任务的Executor的并发为2, 实际上我们会设定一个并发更大的Executor作为RealExecutor, 毕竟PipeExecutor已经做了并发控制了。
RealExecutor可以在Config中设定,如果没有设定,Doodle会调用Executors.newCachedThreadPool()创建一个。
总的而言,在RealExecutor套两层队列,分别实现了并发控制和防止重复任务的功能。

6.3 任务管理

准备好Executor, 只是任务调度的一部分。
我希望有一个工具,支持一下功能:

  1. 主线程/后台线程切换;
  2. 取消任务;
  3. 调用一个方法,block当前线程,直到后台线程完成时,在当前方法返回结果。

SDK其实有这样的工具:AsyncTask。
AsyncTask目前已经被标记“Deprecated”了,而且不方便定制功能,于是,我抽取了AsyncTask的部分代码,实现了工具类ExAsyncTask。

FutureTask+Callable+Handler(我称之为AsyncTask三剑客),很好地实现了上面提到三个功能。
相对AsyncTask,做了一些改动,包括:

  • 移除了范型;
  • 精简了一些不需要的方法,比如onPreExecute,onProgressUpdate等;
  • Executor换上前面提到的TagExecutor。

如果仅仅是这些,那么完全可以extend AsyncTask来实现。
但是接下来的功能,需要引用其中的一些私有成员,所以没办法,只能copy其关键代码重新定义一个类(ExAsyncTask)了,

6.4 生命周期

异步任务还在执行而UI界面已销毁的情况是比较普遍的,图片加载也不例外。
需要有相关的机制,在页面销毁时通知图片加载任务取消。
提到“通知”,很自然地就会想到观察者模式。

LifecycleManager维护了 hostHash -> List<WeakReference<ExAysncTask>> 的一个map (应为hostHash是int类型,用SparseArray来承载)。
其中List<WeakReference<ExAysncTask>> 由中间类Holder持有和维护。
host指的任务所在的“宿主”,其实就是“界面”的具体对象实例,通常是Activity,当然也可以是Fragment或者View, 这个由使用者决定,可以通过Request的observeHost方法指定。

    public Request observeHost(Object host) {
        this.hostHash = System.identityHashCode(host);
        return this;
    }

通过identityHashCode取hash,可以避免直接引用host, 以免内存泄漏。
identityHashCode的区分度要比hashCode要更高,并且考虑到同一时刻加载图片的“界面”不会有太多个,所以用identityHashCode替代host是可行的。
退一万步讲,即使同一时刻有两个host的identityHashCode一样,也不会导致太大的问题,最多不过是任务取消而已。

如果用户没有特别设定,Doodle会通过ImageView找到其所attach的Activity并取其identityHashCode作为hostHash。
另一方面,在Doodle初始化时,做了监听Activity的生命周期回调,并在回调中调用notify方法。

static void registerActivityLifecycle(final Context context) {
        if (!(context instanceof Application)) {
            return;
        }
        ((Application) context).registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
            public void onActivityResumed(Activity activity) {
                Doodle.notifyResume(this);
            }

            public void onActivityPaused(Activity activity) {
                Doodle.notifyPause(this);
            }

            public void onActivityDestroyed(Activity activity) {
                Doodle.notifyDestroy(activity);
            }
        });
}

如果用户通过Request指定了非Activity的host, 可以自行在该host对应的生命周期中调notify方法,
当然,这并非必须的操作:即使没有调notify也没关系,最多就是任务不能及时取消而已,没有什么大问题。

ExAsyncTask关于生命周期部分的实现:

abstract class ExAsyncTask {
    public final void execute(int hostHash) {
        if (mStatus != Status.PENDING) {
            return;
        }
        if (hostHash != 0) {
            LifecycleManager.register(hostHash, this);
        }
        mHostHash = hostHash;
        mStatus = Status.RUNNING;
        Scheduler.tagExecutor.execute(generateTag(), mFuture);
    }

    private void finish(Object result) {
        detachHost();
        if (isCancelled()) {
            onCancelled();
        } else {
            onPostExecute(result);
        }
        mStatus = Status.FINISHED;
    }

    private void detachHost() {
        if (mHostHash != 0) {
            LifecycleManager.unregister(mHostHash, this);
        }
    }

    void handleEvent(int event) {
        if (!isCancelled() && mStatus != Status.FINISHED) {
            if (event == Event.PAUSE) {
                Scheduler.pipeExecutor.pushBack(mFuture);
            } else if (event == Event.RESUME) {
                Scheduler.pipeExecutor.popFront(mFuture);
            } else if (event == Event.DESTROY) {
                mHostHash = 0;
                cancel(true);
            }
        }
    }
}
Worker worker = new Worker(request, imageView);
worker.execute(request.hostHash);

Worker是ExAsyncTask的实现类。
Worker启动时,会将请求的hostHash以及自身注册到LifecycleManager;
Worker结束时,如果mHostHash不为0,则执行LifecycleManager的unregister。
在启动后,结束前,如果host通过LifecycleManager发送“PAUSE/RESUME/DESTROY"消息, ExAsyncTask的handleEvent会被回调。
ExAsyncTask在handleEvent做对应的响应。
特别地,如果host销毁,取消任务。
另外,在页面发送pause时,任务会切换到back队列,发送resume时,任务会切换到front队列;
这个是PipeExecutor的另外一个特性:内置front/back两个队列,取任务时先从front队列取。
效果就是,在页面不可见(但又不是销毁)时发送pause事件,加载任务优先级会降低(低于新的页面)。

这是加载时间加长1s,并发窗口设置为1后的效果图:

代码实现上,Worker继承了ExAsyncTask之后,只需专注于在doInBackground方法中加载图片,在onPostExecute方法中显示结果即可。
以上所述的各种功能/效果都分散到ExAsyncTask、TagExecutor、PipeExecutor、LifecycleManager等类中了。

七、加载前后处理

在启动异步线程之前:

  • 如果目标是ImageView, 取其attache的Activity出来,判断其是否finishing或者destroy, 是则返回。
  • 检查ImageView的tag(Request对象)的key和当前请求是否相同,如果相同则直接返回,
    否则,取消之前的任务(Request有Worke的弱引用)。
  • 清空ImageView当前的Drawable。
  • 如果有bitmap缓存,直接取bitmap设置带到ImageView, 返回;
    否则,设置placeholder drawable。
  • 创建Worker, 用WeakReference包裹,赋值给Request的workerReference变量。
  • 给ImageView设置tag, tag为Request对象。
    用setTag(int key, final Object tag)方法记录tag, 这样就不会和常规的setTag(Object tag)冲突了。
    Glide用的就是setTag(Object tag),应该有朋友踩过这个坑。

异步线程结束之后:

  • 访问Request对象的targetReference变量(ImageView的弱引用),尝试取出ImageView。
  • 若成功取出ImageView, 判断其tag和当前Request是否相等,若相等则说明加载任务的对象没变,否则直接返回。
  • 从ImageView取出Activity, 判断其是否finishing或者destroy, 是则返回。
  • 若Worker返回了result(bitmap或者drawable), 设置到ImageView;若返回了null,设置error drawable。

这些处理大多在Controller中实现,除了这些之外,Controller还负责实现任务的暂停/恢复(多用在RecycleView滚动)。
可以说,Controller是Request, Target, 和Worker之间的桥梁。

八、总结

通篇下来,读者可能也注意到了,Doodle的实现大量参考了Picasso和Glide,尤其是后者,有的部分甚至直接copy其处理(Exif部分),关于这一点我大方承认,三人行,必有我师嘛。
当然,有正面借鉴也有反面借鉴,比如asBitmap, setTag的处理。
然后也有创新的部分,比如DiskCache和任务调度。

概括地说,图片加载过程可分为几个部分:数据源,数据获取,文件解码,结果,目标。
Glide的实现中的,大量使用了接口和范型,对图片加载的各过程进行抽象。
Doodle定义的接口相对Glide简单很多,但也足够通过自定义解码,实现加载任意类型的文件。

好了,原理篇就分析到这里,希望对读者有所启发。

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

推荐阅读更多精彩内容