Coil KMP 跨端图片库源码分析

Coil KMP 跨端图片库源码分析

1. 概述

1.1 简介

Coil 图片加载库正逐步扩展对 Kotlin Multiplatform (KMP) 的支持,未来将覆盖 Android、iOS 和 Desktop 平台。它基于 KMP 开发,充分利用 Kotlin 协程和跨平台特性,致力于提供统一、高效的图片加载解决方案。

主要目标:

  • 提供统一的跨平台图片加载 API
  • 高性能的图片加载和缓存
  • 支持 Compose Multiplatform
  • 轻量级设计

1.2 核心特性

  1. 跨平台支持

    • Android/iOS/Desktop 统一 API
    • 针对平台特定优化
  2. 现代化设计

    • Kotlin Coroutines 异步处理
    • Compose Multiplatform 支持
    • Flow API
  3. 高性能

    • 多级缓存
    • 内存优化
    • 并发控制
  4. 扩展性

    • 自定义 ImageLoader
    • 自定义拦截器、组件
    • 灵活的配置项

2. 架构

2.1 整体架构

Coil 采用清晰的三层架构设计,每层职责明确,相互协作又相对独立:

Compose UI 层

作为用户交互的门面,负责:

  • 提供 Compose UI 图片组件
  • 管理图片加载状态和生命周期
  • 处理用户交互和视图更新

请求核心层

作为整个库的核心系统,负责:

  • 调度图片加载请求和解码
  • 管理内存和磁盘缓存
  • 控制并发和队列优先级

平台层

作为底层实现的适配器,负责:

  • 处理平台特定的图片请求和解码
  • 优化平台特定的性能表现
  • 提供原生 API 的桥接能力

架构图

架构图

时序图

时序图

2.2 UI 层

Coil 在 Compose UI 层提供了图片加载支持,其中核心组件 AsyncImage 专为声明式架构设计,弥补了原生 Image 组件无法直接请求资源的不足。它内置了完善的基于 State 的状态管理系统,通过状态流转机制,能够轻松适配 Compose 的声明式特性,实现图片加载过程中的自动刷新与动态更新。

// 1. AsyncImage Composable
@Composable
fun AsyncImage(
    model: Any?,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    transform: (State) -> State = DefaultTransform,
    onState: ((State) -> Unit)? = null,
    // ... 其他参数
)

AsyncImage 的核心能力依赖于 AsyncImagePainter 实现。作为一个同时继承自 Painter 和 RememberObserver 的类,AsyncImagePainter 不仅能够接入 Image 的绘制能力(Image 接收一个 painter 作为参数),还可以响应 Compose 的重组机制,实现高效的状态刷新。

// 2. rememberAsyncImagePainter
@Stable
class AsyncImagePainter internal constructor(
    input: Input,
) : Painter(), RememberObserver {
    // ...

    // 驱动重组的关键对象:改变时,触发 DrawScope.onDraw 绘制
    private var painter: Painter? by mutableStateOf(null)

    override fun DrawScope.onDraw() {
        // ...
        // 绘制当前 Painter
        painter?.apply { draw(size, alpha, colorFilter) }
    }

    // Compose 组件激活时触发
    @OptIn(ExperimentalCoroutinesApi::class)
    override fun onRemembered() = trace("AsyncImagePainter.onRemembered") {
        // 动图起播 比如 Loading 动画
        (painter as? RememberObserver)?.onRemembered()

        // 开启工作线程
        rememberJob = scope.launch {
            restartSignal
                .flatMapLatest { _input }
                .mapLatest { input ->
                    // 调用 ImageLoader 加载图片
                    val previewHandler = previewHandler
                    if (previewHandler != null) {
                        val request = updateRequest(input.request, isPreview = true)
                        previewHandler.handle(input.imageLoader, request)
                    } else {
                        val request = updateRequest(input.request, isPreview = false)
                        input.imageLoader.execute(request).toState()
                    }
                }
                // 返回结果,更新状态
                .collect(::updateState)
        }
    }
    
    // Compose 组件移除触发
    override fun onForgotten() {
        rememberJob = null
        // 动图停播
        (painter as? RememberObserver)?.onForgotten()
    }

    // 根据请求结果更新状态、Painter
    private fun updateState(state: State) {
        val previous = _state.value
        val current = transform(state)
        _state.value = current
        // 更新 Painter
        painter = maybeNewCrossfadePainter(previous, current, contentScale) ?: current.painter

        if (previous.painter !== current.painter) {
            // 原状态下的动图停播
            (previous.painter as? RememberObserver)?.onForgotten()
            (current.painter as? RememberObserver)?.onRemembered()
        }
        // ...
    }

AsyncImagePainter 的重组逻辑主要依赖其内部的另一个 painter 成员,这是一个 State 的对象。当 painter 状态发生变化时,会触发 Compose 重组并调用其绘制函数 onDraw 进行更新。而 painter 的状态更新则由图片加载器 ImageLoader 的加载结果驱动(通过 updateState 函数更新),从而实现图片加载与 UI 绘制的无缝衔接。

2.3 请求核心层

ImageLoader 是 Coil 框架的核心组件,负责发起图片加载流程,并协调各个功能模块,依次完成图片资源的请求、解码、缓存等工作。其设计基于责任链模式,通过模块化的职责分工实现灵活的加载流程。

责任链中的核心职责

ImageLoader 的加载流程由一系列职责模块组成,通过责任链串联起来,主要包括以下功能:

  1. Mapper 转换器
    用于统一和转换请求资源的类型。例如,在 Android 平台中,资源 ID 和资源 URI 都可以表示应用资源。通过 Mapper,资源 ID 会被转换为 URI,从而实现两者共享同一个资源请求器。

  2. MemoryCache 内存缓存
    在生成资源唯一 Key 的同时,负责内存缓存的读取与写入,提升加载速度并减少重复解码。

  3. DiskCache 磁盘缓存
    用于缓存原始资源(如网络图片的未解码数据),减少重复的网络请求。

  4. Decoder 解码器
    负责解码不同格式的图片资源(如 JPEG、PNG、WebP 等),将原始数据转换为可用的位图。

  5. Fetcher 请求器
    用于请求不同来源的图片资源,包括网络图片、应用内资源、本地文件路径等。

EngineInterceptor:责任链的调度核心

尽管上述职责模块看似独立,但它们并未直接封装在责任链的拦截器中。ImageLoader 的责任链内部仅包含一个核心拦截器——EngineInterceptor。顾名思义,EngineInterceptor 的作用是调度整个加载流程,将 map -> memory -> disk -> fetch -> decode 的职责串联起来。

这些职责模块本身并非具体功能的实现体,而是功能组件的调度器。 功能组件则通过 ComponentRegistry 对象进行统一管理和调度。

ComponentRegistry:功能组件的管理中心

ComponentRegistry 是 ImageLoader 的功能组件注册中心,保存了大量负责不同能力的功能组件。通过遍历这些组件,ImageLoader 实现了动态的功能调度。例如:

  • Fetcher 调度
    fetch 操作会遍历 ComponentRegistry 中的请求器工厂列表(fetcherFactories),找到符合当前请求需求的工厂,并通过该工厂实例化具体的请求器来响应请求。

  • Decoder 调度
    类似地,decode 操作会遍历解码器工厂列表,动态选择合适的解码器来处理图片资源。

这样设计使得 ImageLoader 的功能高度模块化和可扩展,开发者可以通过自定义组件轻松扩展框架的能力。

2.4 平台层

在大多数情况下,代码是共享的。然而,共享代码在使用平台特性时存在一定的局限性。为弥补跨平台开发的这些劣势,平台层的设计旨在充分利用各平台的特性,通过差异化实现来完善功能的同时提升性能。

其中,最具代表性的功能是解码模块。以移动平台为例,Android 基于原生 Framework 渲染框架,因此在高版本系统中更适合使用支持硬件解码的 ImageDecoder。而 iOS 在 KMM 中依赖 Skia UI 框架,因此使用 Skia 自身的解码能力则更加高效和适配。因此,针对不同平台,Coil 分别采用了 StaticImageDecoder 和 SkiaImageDecoder 作为解码组件,充分发挥了各平台的性能优势。

// 跨平台抽象层
internal expect fun ComponentRegistry.Builder.addAndroidComponents(
    options: RealImageLoader.Options,
): ComponentRegistry.Builder

// main
internal actual fun ComponentRegistry.Builder.addAndroidComponents(
    options: RealImageLoader.Options,
): ComponentRegistry.Builder = apply {
    ...

    // Decoders
    val parallelismLock = Semaphore(options.bitmapFactoryMaxParallelism)
    if (enableStaticImageDecoder(options)) {
        add(
            StaticImageDecoder.Factory(
                parallelismLock = parallelismLock,
            ),
        )
    }
    add(
        BitmapFactoryDecoder.Factory(
            parallelismLock = parallelismLock,
            exifOrientationStrategy = options.bitmapFactoryExifOrientationStrategy,
        ),
    )
}

private fun enableStaticImageDecoder(options: RealImageLoader.Options): Boolean {
    // Require API 29 for ImageDecoder support as API 28 has framework bugs:
    // https://github.com/element-hq/element-android/pull/7184
    return SDK_INT >= 29 &&
        options.bitmapFactoryExifOrientationStrategy.let { it == RESPECT_PERFORMANCE || it == RESPECT_ALL }
}

// nonAndroidMain
internal actual fun ComponentRegistry.Builder.addAndroidComponents(
    options: RealImageLoader.Options,
): ComponentRegistry.Builder {
    return this
        // Decoders
        .add(SkiaImageDecoder.Factory())
}

3. 核心模块

3.1 核心 API

核心API类图

3.2 请求模块

在图片加载框架中,Fetcher 是负责从不同来源获取图片数据的核心组件。根据资源类型,Fetcher 可分为以下三类:

网络图片请求器

  • 用途:从网络 URL 加载远程图片资源。

  • 示例 URL

    "https://www.sample.com/sample.jpg"  
    "http://www.example.com/image.png"  
    
  • 实现类NetworkFetcher

    • 基于 Ktor 实现高效的网络请求。

应用内图片资源请求器

  • 用途:加载应用内置的图片资源(如 res/drawableassets 目录中的资源)。

  • 示例资源

    R.drawable.sample
    sample_image
    
  • 实现类

    • Android
      • ResourceUriFetcher:加载 res/drawable 资源。
      • AssetUriFetcher:加载 assets 目录资源。
    • iOS
      • 开发中

Coil 确实在向跨平台图片加载库的方向发展,但目前仍主要支持 Android 平台,其他平台的支持正在开发中。

本地路径资源请求器

  • 用途:加载设备存储中的图片文件(如文件系统路径或媒体库资源)。

  • 示例路径

    // Android 示例  
    "content://media/external/images/media/12345"  // 媒体库图片  
    "file:///storage/emulated/0/Download/sample.jpg"  // 文件路径  
    
    // iOS 示例  
    "file:///var/mobile/Containers/Data/sample.jpg"  // 沙盒文件路径  
    "assets-library://asset/asset.jpg"  // 相册图片  
    
  • 实现类

    • Android
      • ContentUriFetcher:处理 content:// URI。
      • FileUriFetcher:处理 file:// URI。
    • iOS
      • 开发中

3.3 缓存模块

内存缓存

Coil 采用双层内存缓存机制:强引用 LruCache 和弱引用缓存。缓存操作遵循优先级策略:写入时优先存入强引用缓存,当强引用缓存项被移除时会自动降级到弱引用缓存;读取时则优先查找强引用缓存,未命中时再查找弱引用缓存。

内存缓存写入流程图

磁盘缓存

Coil 在磁盘缓存方面采用了 DiskLruCache 机制,并结合 Okio 实现跨平台的文件读写操作。

internal class RealDiskCache(
    override val maxSize: Long,
    override val directory: Path,
    override val fileSystem: FileSystem, // okio.FileSystem
    cleanupDispatcher: CoroutineDispatcher,
) : DiskCache {

    private val cache = DiskLruCache(
        fileSystem = fileSystem,
        directory = directory,
        cleanupDispatcher = cleanupDispatcher,
        maxSize = maxSize,
        appVersion = 3,
        valueCount = 2,
    )
...

3.4 解码模块

Coil 针对不同平台优化了解码方式:在 Kotlin/JVM 后端使用 Android 原生解码,在 Kotlin/Native 后端则采用 Skia 解码,以适配各平台的性能和功能需求。

静态图

  • Android
    • API > 28:StaticImageDecoder
    • API <= 28:BitmapFactoryDecoder
  • iOS
    • SkiaImageDecoder

动态图

  • Android
    • API >= 28:AnimatedImageDecoder
    • API < 28:GifDecoder
  • iOS
    • 未实现

虽然 Coil 未直接提供 iOS 平台的动图解码器,但借助 Skia 的解码能力仍可实现。通过使用 Codec.makeFromData 解码动图,并封装一个继承自 PainterRememberObserverSkiaGifPainter,可以在基于 Compose 的环境中实现动图的解码、播放以及自刷新组件。

4. 可能的潜在问题

  1. 主线程创建对象问题
    • ImageRequest 的创建过程发生在主线程,在部分平台(如 iOS 和 Harmony)上,这可能导致性能劣化,尤其是在以 Kotlin/Native 为后端的平台上表现更为明显。
  2. Skia 软解码性能劣势
    • 以 Kotlin/Native 为后端的平台依赖 Skia 进行软解码,无法利用硬件解码器,导致解码性能相较硬件解码存在劣势。

5. 总结

Coil KMP 是一个设计优秀的跨平台图片加载框架,虽然目前跨平台功能尚未完全完善,但其灵活的架构和模块化设计,使开发者可以基于它轻松扩展,打造真正的跨平台图片加载库。

compose-imageLoader 是一个真正实现跨平台的图片加载库。从源码来看,该库的实现几乎完全沿用了 Coil 的框架设计,这也从侧面证明了 Coil 框架设计的优秀与通用性。

优势:

  1. 统一的跨平台 API
  2. 现代化的协程基础设施
  3. 优秀的缓存策略
  4. 良好的扩展性

不足:

  1. 部分平台亟待完善
  2. 平台特定优化有限
  3. 生态相对不够成熟

总的来说,Coil KMP 为跨平台开发提供了一个可靠的图片加载解决方案,值得在实际项目中采用。

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

推荐阅读更多精彩内容