前言
公司项目使用了Kotlin协程
处理常规任务调度,代码简洁优雅。最近看到Coil图片加载库使用了Kotlin协程
,清爽统一,就研读了一下源码。
Coil(代表 "Coroutine Image Loader")是一个流行的 Kotlin 图片加载库,它利用 Kotlin 协程和 Jetpack Compose 提供了一个简洁、高效的方式来处理图片。
Coil 集成和使用分析
1. 集成依赖
要开始使用 Coil,首先需要将其依赖添加到项目中。在你的 app 模块的 build.gradle
文件中,添加以下依赖:
scss
implementation("io.coil-kt:coil:1.4.0")
2.全局配置
kotlin
class Application : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
// GIFs
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
// SVGs
add(SvgDecoder.Factory())
// Video frames
add(VideoFrameDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder(this)
// Set the max size to 25% of the app's available memory.
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(filesDir.resolve("image_cache"))
.maxSizeBytes(512L * 1024 * 1024) // 512MB
.build()
}
.okHttpClient {
// Don't limit concurrent network requests by host.
val dispatcher = Dispatcher().apply { maxRequestsPerHost = maxRequests }
// Lazily create the OkHttpClient that is used for network operations.
OkHttpClient.Builder()
.dispatcher(dispatcher)
.build()
}
// Show a short crossfade when loading images asynchronously.
.crossfade(true)
// Ignore the network cache headers and always read from/write to the disk cache.
.respectCacheHeaders(false)
// Enable logging to the standard Android log if this is a debug build.
.apply { if (BuildConfig.DEBUG) logger(DebugLogger()) }
.build()
}
}
通过上述的全局配置代码,清晰的可以看出也是同Glide一样使用了三级缓存
。
3.加载图片到ImageView
kotlin
holder.image.apply {
load(item.uri) {
placeholder(ColorDrawable(item.color))
error(ColorDrawable(Color.RED))
parameters(item.parameters)
listener { _, result -> placeholder = result.memoryCacheKey }
}
}
综上三点基本的使用我们就已经清楚了,配合Kotlin一些语法糖,是不是极其的简单。
前置知识
图片缓存
首先根据全局配置代码,同样使用了三级缓存,了解Glide的同学对这部分一定是很容易了,简要的说一下。
-
内存缓存:
- Coil 使用内存缓存来快速加载已经加载过的图片。当请求一个图片时,Coil 首先会检查内存缓存是否已经有这个图片的副本。
- 如果找到了,图片可以立即从内存中加载,避免了网络请求或磁盘读取的开销。
- Coil 默认使用的内存缓存是基于 LruCache 实现的。
-
磁盘缓存:
- 如果图片不在内存缓存中,Coil 接下来会检查磁盘缓存。磁盘缓存存储了之前从网络上下载的图片文件。
- 这可以减少对网络请求的依赖,特别是在没有网络连接或网络连接较慢的情况下。
- Coil 通常会利用 OkHttp 的磁盘缓存机制,因为 Coil 内部使用 OkHttp 进行网络请求。
-
网络请求:
- 如果一个图片既不在内存缓存中,也不在磁盘缓存中,Coil 最后会发起一个网络请求来获取这个图片。
- 下载的图片会被存入磁盘缓存(如果配置了的话),并放入内存缓存中,以便于后续的快速加载。
如何设计图片加载框架?
图片占用实际内存空间计算
首先图片加载到内存中会根据 1像素所占字节数 * 图片宽高 = 实际内存占用。
在Android中实际会根据图片属性来计算像素占字节数大小,举例常用的Bitmap属性:
- ARGB_8888 带有alpha透明通道的图片格式,最佳质量的图片格式,可以存储丰富的红绿蓝信息。那么一像素占用4字节(8 * 4 / 8 = 4)。
- RGB_565 没有透明通道的红绿蓝像素存储格式,绿色为什么要用多一位呢?是因为人眼对绿色比较敏感所以为了更加让绿色战士的精准,所以针对绿色多了一位去存储。那么一像素占用2字节(5 + 6 + 5 = 16 / 8 = 2)。
不过在Android系统中,加载本地文件(darwable文件夹下)时,由于我们的设备屏幕尺寸是多样的,并且对应的屏幕密度(dpi)也是不一样的,所以不同分辨率的图片在不同密度的屏幕上占用的内存也是不一样的。所以为什么我们在做屏幕适配的时候要对应不同分辨率的图片,设计会切出不同图片(hdpip、xhdpi、xxhdpi...)
ini
drawable资源图片:占用内存空间大小 = 每个像素大小 x 相对宽度 x 相对高度
相对宽度 = 图片宽度 x (手机设备像素密度 / drawable-xx目录对应的像素密度 )
相对高度 = 图片高度 x (手机设备像素密度 / drawable-xx目录对应的像素密度 )
drawable-xx目录对应的像素密度值,如下:
标准值为mdpi=160,hdpi=1.5x160=240,xdpi=2x160=320,xxdpi=3x160=480
真实手机设备像素密度值怎么计算呢?如下:
手机像素密度值 = 手机对角线上多少个像素 / 手机对角线上多少英寸
Coil#ImageLoader
同样提供了两个同步异步的方法去真正执行图片加载流程。 而同步的方法使用挂起函数来实现的。
Coil#RealImageLoader
RealImageLoader装配了很多属性,比如三级缓存配置、事件回调、类型访问者、还有责任链拦截器配置。 红框中代码可知是配置拦截器,并且EngineIntercepter是最后一个拦截器(非常像Okhttp对吧)。
Coil#Interceptor
Coil#RealInterceptorChain
似曾相识吧?OkHttp责任链模式也是以类似的方式进行启动整体的责任链单一职责的流程。
Coil#RealImageLoader#executeMain()
看一下真正执行图片加载的入口。 首先如果有占位图,那么优先从内存缓存中找到占位图的bitmap,然后调用target接口中的onStart方法把占位图设置进去,让ImageView加载。 之后会调用proceed()方法执行调用链,很简单吧。
Coil#EngineInterceptor
这一部分就是从包装类ImageRequest获取到请求图片的信息,并从chain.size获取到加载到目标空间的尺寸,这个很重要,贯穿整个decode流程。
之后根据request信息从内存缓存map中查找,如果内存中已经存在该图片资源那么就直接返回查询到的图片资源。当然这个map也是使用的Lru算法,将最近经常使用的图片保留移除不经常使用的图片,这个我们后续说。 若内存缓存中不存在该图片资源那么就要执行后续的execute()方法了。
为什么我们不直接在这里去检查DiskCache呢?
哈哈,本质上只有网络加载才需要DiskCache, 所以在Coil中创建了一个Fetcher接口,区分图片加载源是Url、Drawable、File...
Coil#EngineInterceptor#execute()
这里过度了一步,如果没有自定义fetcher和decoder,那么就采用默认的方式。然后执行fetch()方法去真正执行获取图片资源的流程,如果fetch()结束,会返回fetchResult对象,然后针对是什么类型的资源做后续的逻辑,如果是SourceResult就需要IO操作进行图像解码,这个decode也是一个接口根据图片类型使用一对一的解码器。而如果是DrawableResult那么就可以直接返回一个包装类型ExecuteResult(),当解码结束,就是图像转换流程,同样对应不同的转换器。
接下来我们看看HttpUriFetcher。
Coil#HttpUriFetcher#fetch()
从磁盘中找到缓存图像,如果存在直接返回图像资源。
如果没有则执行executeNetworkRequest(cacheStrategy.networkRequest!!)
去真正执行网络请求。请求成功,将Response写入到磁盘中,并标记数据来源是网络。
Coil#HttpUriFetcher#executeNetworkRequest()
使用OkHttp进行网络请求。 这里有线程判断 如果在主线程就会去调用execute()同步方法,否则使用 await()。这个await()方法内部是调用了OkHttp的异步方法,只不过使用了suspendCancellableCoroutine()将异步转为同步。 Coil针对fetch, decode流程都有对应的协程Dispatcher,所以可以指定调度。 到这里以后网络加载的流程就结束了。
Coil#EngineInterceptor#decode()
components.newDecoder(fetchResult, options, imageLoader, searchIndex)
这行代码针对不同的图片类型通过工厂模式创建出对应的解码器,我们以BitmapFactoryDecoder看一下。
kotlin
class BitmapFactoryDecoder(
private val source: ImageSource,
private val options: Options,
private val parallelismLock: Semaphore = Semaphore(Int.MAX_VALUE),
private val exifOrientationPolicy: ExifOrientationPolicy = ExifOrientationPolicy.RESPECT_PERFORMANCE
) : Decoder {
@Deprecated(message = "Kept for binary compatibility.", level = DeprecationLevel.HIDDEN)
constructor(
source: ImageSource,
options: Options
) : this(source, options)
@Deprecated(message = "Kept for binary compatibility.", level = DeprecationLevel.HIDDEN)
constructor(
source: ImageSource,
options: Options,
parallelismLock: Semaphore = Semaphore(Int.MAX_VALUE)
) : this(source, options, parallelismLock)
override suspend fun decode() = parallelismLock.withPermit {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
val safeSource = ExceptionCatchingSource(source.source())
val safeBufferedSource = safeSource.buffer()
// Read the image's dimensions.
inJustDecodeBounds = true
BitmapFactory.decodeStream(safeBufferedSource.peek().inputStream(), null, this)
safeSource.exception?.let { throw it }
inJustDecodeBounds = false
// Get the image's EXIF data.
val exifData = ExifUtils.getExifData(outMimeType, safeBufferedSource, exifOrientationPolicy)
safeSource.exception?.let { throw it }
// Always create immutable bitmaps as they have better performance.
inMutable = false
if (SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
configureConfig(exifData)
configureScale(exifData)
// Decode the bitmap.
val outBitmap: Bitmap? = safeBufferedSource.use {
BitmapFactory.decodeStream(it.inputStream(), null, this)
}
safeSource.exception?.let { throw it }
checkNotNull(outBitmap) {
"BitmapFactory returned a null bitmap. Often this means BitmapFactory could not " +
"decode the image data read from the input source (e.g. network, disk, or " +
"memory) as it's not encoded as a valid image format."
}
// Fix the incorrect density created by overloading inDensity/inTargetDensity.
outBitmap.density = options.context.resources.displayMetrics.densityDpi
// Reverse the EXIF transformations to get the original image.
val bitmap = ExifUtils.reverseTransformations(outBitmap, exifData)
return DecodeResult(
drawable = bitmap.toDrawable(options.context),
isSampled = inSampleSize > 1 || inScaled
)
}
/** Compute and set [BitmapFactory.Options.inPreferredConfig]. */
private fun BitmapFactory.Options.configureConfig(exifData: ExifData) {
var config = options.config
// Disable hardware bitmaps if we need to perform EXIF transformations.
if (exifData.isFlipped || exifData.isRotated) {
config = config.toSoftware()
}
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == MIME_TYPE_JPEG) {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(exifData: ExifData) {
// Requests that request original size from a resource source need to be decoded with
// respect to their intrinsic density.
val metadata = source.metadata
if (metadata is ResourceMetadata && options.size.isOriginal) {
inSampleSize = 1
inScaled = true
inDensity = metadata.density
inTargetDensity = options.context.resources.displayMetrics.densityDpi
return
}
// This occurs if there was an error decoding the image's size.
if (outWidth <= 0 || outHeight <= 0) {
inSampleSize = 1
inScaled = false
return
}
// srcWidth and srcHeight are the original dimensions of the image after
// EXIF transformations (but before sampling).
val srcWidth = if (exifData.isSwapped) outHeight else outWidth
val srcHeight = if (exifData.isSwapped) outWidth else outHeight
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
// Calculate the image's sample size.
inSampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale
)
// Calculate the image's density scaling multiple.
var scale = DecodeUtils.computeSizeMultiplier(
srcWidth = srcWidth / inSampleSize.toDouble(),
srcHeight = srcHeight / inSampleSize.toDouble(),
dstWidth = dstWidth.toDouble(),
dstHeight = dstHeight.toDouble(),
scale = options.scale
)
// Only upscale the image if the options require an exact size.
if (options.allowInexactSize) {
scale = scale.coerceAtMost(1.0)
}
inScaled = scale != 1.0
if (inScaled) {
if (scale > 1) {
// Upscale
inDensity = (Int.MAX_VALUE / scale).roundToInt()
inTargetDensity = Int.MAX_VALUE
} else {
// Downscale
inDensity = Int.MAX_VALUE
inTargetDensity = (Int.MAX_VALUE * scale).roundToInt()
}
}
}
class Factory(
maxParallelism: Int = DEFAULT_MAX_PARALLELISM,
private val exifOrientationPolicy: ExifOrientationPolicy = ExifOrientationPolicy.RESPECT_PERFORMANCE
) : Decoder.Factory {
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN")
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
@Deprecated(message = "Kept for binary compatibility.", level = DeprecationLevel.HIDDEN)
constructor(maxParallelism: Int = DEFAULT_MAX_PARALLELISM) : this(maxParallelism)
private val parallelismLock = Semaphore(maxParallelism)
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
return BitmapFactoryDecoder(result.source, options, parallelismLock, exifOrientationPolicy)
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
}
/** Prevent [BitmapFactory.decodeStream] from swallowing [Exception]s. */
private class ExceptionCatchingSource(delegate: Source) : ForwardingSource(delegate) {
var exception: Exception? = null
private set
override fun read(sink: Buffer, byteCount: Long): Long {
try {
return super.read(sink, byteCount)
} catch (e: Exception) {
exception = e
throw e
}
}
}
internal companion object {
internal const val DEFAULT_MAX_PARALLELISM = 4
}
}
这个解码器比较重要,涉及了常见的Bitmap处理过程。 首先这里使用Okio进行对图片流进行写入,使用Bitmap.decodeStream()将输入流解码为Bitmap,当然前后先使用inJustDecodeBounds
去控制是否加载到内存,之后获取到EXIF资源,去做图片像素存储方式以及采样率和尺寸比例的操作。
kotlin
configureConfig(exifData)
configureScale(exifData)
这两个方法就对Bitmap资源进行处理的两个方法。我们分别看一下。
kotlin
/** Compute and set [BitmapFactory.Options.inPreferredConfig]. */
private fun BitmapFactory.Options.configureConfig(exifData: ExifData) {
var config = options.config
// Disable hardware bitmaps if we need to perform EXIF transformations.
if (exifData.isFlipped || exifData.isRotated) {
config = config.toSoftware()
}
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == MIME_TYPE_JPEG) {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
如果当前图片是JPEG并且当前的属性是ARGB_8888,那么就设置为RGB_565, 因为JPEG是不支持Alpha通道的,所以使用ARGB_8888是资源浪费,但前提是要支持RGB_565。
kotlin
private fun BitmapFactory.Options.configureScale(exifData: ExifData) {
// Requests that request original size from a resource source need to be decoded with
// respect to their intrinsic density.
val metadata = source.metadata
if (metadata is ResourceMetadata && options.size.isOriginal) {
// 代码 1
inSampleSize = 1
inScaled = true
inDensity = metadata.density
inTargetDensity = options.context.resources.displayMetrics.densityDpi
return
}
// This occurs if there was an error decoding the image's size.
if (outWidth <= 0 || outHeight <= 0) {
inSampleSize = 1
inScaled = false
return
}
// srcWidth and srcHeight are the original dimensions of the image after
// EXIF transformations (but before sampling).
// 代码 2
val srcWidth = if (exifData.isSwapped) outHeight else outWidth
val srcHeight = if (exifData.isSwapped) outWidth else outHeight
// 代码 3
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
// 代码 4
// Calculate the image's sample size.
inSampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale
)
// 代码 5
// Calculate the image's density scaling multiple.
var scale = DecodeUtils.computeSizeMultiplier(
srcWidth = srcWidth / inSampleSize.toDouble(),
srcHeight = srcHeight / inSampleSize.toDouble(),
dstWidth = dstWidth.toDouble(),
dstHeight = dstHeight.toDouble(),
scale = options.scale
)
// Only upscale the image if the options require an exact size.
if (options.allowInexactSize) {
scale = scale.coerceAtMost(1.0)
}
// 代码 6
inScaled = scale != 1.0
if (inScaled) {
if (scale > 1) {
// Upscale
inDensity = (Int.MAX_VALUE / scale).roundToInt()
inTargetDensity = Int.MAX_VALUE
} else {
// Downscale
inDensity = Int.MAX_VALUE
inTargetDensity = (Int.MAX_VALUE * scale).roundToInt()
}
}
}
这部分代码代表对Bitmap采样率&尺寸进行调整的过程,我们知道如果Bitmap的实际宽高大于要加载的目标控件(ImageView)的宽高,那么对内存来说就是资源浪费,我们要把Bitmap进行裁剪或者宽高调整。所以这个方法的核心是对上述内容的处理。
-
在代码1处,在加载
res/
文件夹下的资源图片,如果目标尺寸未指定,那么加载以原始图片宽高为准,设置采样率为1,以及需要根据图片在不同文件(hdpi、xhdpi...)下的固有密度进行调整,所以inDensity
等于图片的密度,inTargetDensity
设置为屏幕的像素密度。 -
在代码2处,若当前图片资源发生过旋转,那么图片目标计算宽高要取相反的,以确保内容可以被充分加载。
kotlin
internal val ExifData.isSwapped get() = rotationDegrees == 90 || rotationDegrees == 270
- 在代码3处,想要将图片加载到UI控件时要计算的最终大小
dstWidth
&dstHeight
,如果是原始大小就是图片资源的大小,否则就是控件的大小。
kotlin
internal inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
internal inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
- 在代码4处,要根据图片原始宽高与目标UI控件大小进行采样率计算。
less
@JvmStatic
fun calculateInSampleSize(
@Px srcWidth: Int,
@Px srcHeight: Int,
@Px dstWidth: Int,
@Px dstHeight: Int,
scale: Scale
): Int {
val widthInSampleSize = (srcWidth / dstWidth).takeHighestOneBit()
val heightInSampleSize = (srcHeight / dstHeight).takeHighestOneBit()
return when (scale) {
Scale.FILL -> minOf(widthInSampleSize, heightInSampleSize)
Scale.FIT -> maxOf(widthInSampleSize, heightInSampleSize)
}.coerceAtLeast(1)
}
可见,很简单的计算, 原始宽度 / 目标宽度
取整,原始高度 / 目标高度
取整,然后根据缩放类型FILL
&FIT
进行计算。
-
- 如果
scale
是Scale.FILL
,函数选择宽度和高度采样大小中的较小者。这意味着图像将被填充到目标尺寸,可能会被裁剪。
- 如果
-
- 如果
scale
是Scale.FIT
,则选择较大者。这意味着图像将完全适应目标尺寸,但可能会留下空白区域。
- 如果
- 在代码5处,由于设置过采样率,如果采样率为2,那么响应图片资源的宽和高会缩2倍。 所以在这里用
srcWidth/inSampleSize
去作为采样后的宽和高,之后再计算缩放比例,因为这时候需要把图片加载到UI控件上,需要根据控件的大小对应调整。
kotlin
fun computeSizeMultiplier(
@Px srcWidth: Double,
@Px srcHeight: Double,
@Px dstWidth: Double,
@Px dstHeight: Double,
scale: Scale
): Double {
val widthPercent = dstWidth / srcWidth
val heightPercent = dstHeight / srcHeight
return when (scale) {
Scale.FILL -> maxOf(widthPercent, heightPercent)
Scale.FIT -> minOf(widthPercent, heightPercent)
}
}
相似的逻辑去计算缩放百分比。
- 代码6处,如果百分比为1,说明不需要进行缩放调整。如果不等于那么就要进行缩放, 由于缩放是基于密度的改变,所以缩放比例如果 > 1,说明图片需要放大,假设
scale = 2
,这里使用了Int.MAX_VALUE
作为一个锚点,如果需要放大那么就将inDensity
调整为Int.MAX_VALUE / 2
,inTargetDensity = Int.MAX_VALUE
这样就实现了放大2倍的效果。
小知识点
- 协程挂起函数
runInterruptible
runInterruptible
是Kotlin协程中的一个函数,它允许你在协程中运行可中断的代码块。这个函数特别有用于在协程中执行那些可能需要长时间运行且需要能够响应取消请求的操作。在 Kotlin 协程中,取消是协作性的,这意味着协程代码需要定期检查取消状态,并在适当时候响应取消。然而,有些操作(如阻塞 I/O 操作或某些系统调用)本身不是可中断的,也就是说,它们不会自己检查协程的取消状态。这就是 runInterruptible
派上用场的地方。在Coil中大量的使用可中断函数,防止b某些阻塞式行为本应该释放但仍然占用CPU的问题。
使用runInterruptible
。
runInterruptible
如何做到的中断?什么原理?
原理很简单,执行可中断挂起函数时,会调用ThreadState#setup()方法,为job注入当前的ThreadState
对象,由于当前的ThreadState
类复写了invoke
方法,可以看到invoke方如何判断,如果当前ThreadState==WORKING
时,并且当前automic要更新为INTERRUPTING状态时,会调用targetThread.interrupted()
方法, 当前的Thread
的IO等等阻塞事件将会结束。
至此整体流程大概梳理了一下,但是省略了一些代码,比如 Fetch
,Decoder
所有的实现类,有兴趣大家可以看看,比如GIFDecoder
等等。还有内存&磁盘缓存也都是常谈的LruCache(LinkedHashMap)
,以及在什么时机进行触发清除不经常使用的Bitmap,内存缓存又分为强缓存与弱缓存,当移除不常用的Bitmap时会将不常用的Bitmap转移到弱缓存之中,等待流程。
有其他的博客评估过Coil的性能,相比于Glide,Coil加载图片的平均耗时会比Glide慢100ms左右,可能跟协程的性能少许关系,后续等有空看一下。