基于 kmp/cmp 的跨平台图片加载方案 - 适配 Android View/Compose/ios

0 阅读18分钟

1. 背景

在客户端开发中,图片加载是一个常见且高频的场景。典型的图片加载需求通常包含以下几种形态:

  • 网络图片:拉取远程图片,涉及磁盘缓存、ETag/304 协商缓存等

  • 自绘兜底图:在网络图片加载完成前,根据业务数据(如名称 + 背景色)实时绘制一张占位图

  • 本地资源图:加载 App 内置的静态资源图片,且可能需要随皮肤切换而变化

传统做法是在各平台独立实现,例如 Android 端基于 Glide/Coil + Canvas 自绘。

这意味着同一套业务的加载逻辑、缓存策略、兜底图绘制规则需要在多端各写一遍,不仅开发成本高,还容易出现平台间表现不一致的问题。

随着 KMP(Kotlin Multiplatform)和 CMP(Compose Multiplatform)的成熟,我们有了将图片加载逻辑下沉到跨平台共享层的可能。但挑战在于:加载后的图片最终要渲染到 UI 上,而 Android View 体系、Compose 和 iOS 的渲染方式各不相同。

如何设计一个方案,让加载、缓存、自绘逻辑在 commonMain 中共享,同时适配三端的渲染消费方式?

2. 方案选型

在设计跨平台图片加载方案时,对外暴露的接口形态会直接影响内部的实现方案和使用方的接入成本。我评估了三种方案,最终选择了基于 Painter 的方案。

1. 方案一:仅提供 @Composable 组件

在 commonMain 中封装一个 @Composable 组件,内部完成加载、缓存、渲染的全部逻辑:

@Composable
fun MiniLogo(pictureId: Long, width: Dp, height: Dp) {
    // 内部处理加载 + 缓存 + 绘制
}

问题一:原生页面中性能不佳。 每张图片都需要一个 ComposeView 作为容器。ComposeView 会创建独立的组合(Composition),包含完整的组合、布局、绘制三个阶段的开销。在原生列表场景下,一屏可能展示几十上百个张图片,实测表现为明显的滑动卡顿。

问题二:不够灵活。 部分原生场景中,图片并非以独立组件的形式展示,而是作为原生控件的附属图片使用(如 Android 的 textView.setCompoundDrawables()、iOS 的 UIButton.setImage())。@Composable 组件无法直接转化为原生平台的图片对象,要适配这些场景需要额外的渲染和转换,接入成本较高。

2. 方案二:Flow< Bitmap>

将加载结果以 Flow<Bitmap> 的形式发射给下游,由各平台自行消费:

fun fetchBitmap(pictureId: Long, width: Int, height: Int): Flow<Bitmap>

// 使用方
fetchBitmap(pictureId, 40, 40).collect { bitmap ->
    // 第一次收到兜底图,第二次收到真实图,刷新 UI
}

这种方案解决了灵活性和性能问题 - Bitmap 是各平台通用的图片格式,下游可以自由地将其转化为平台原生的图片对象使用。

但问题在于配置变化场景。 自绘的兜底图(名称文案 + 背景色)和本地资源图会随运行时配置变化而失效:

  • 语言切换:兜底图上的文案会变(如 "苹果" → "Apple")

  • 皮肤切换:文字颜色、本地资源图可能变化

Bitmap 是一张已经画死的图片,配置变化后缓存中的 Bitmap 就过期了。要处理这个问题,需要在配置变化时清除缓存并强制使用方重新请求(通过 SharedFlow 通知、或要求使用方监听配置变化事件重新调用接口),方案侵入性较强,使用方接入成本高。

3. 方案三:Flow< Painter> ✅

Painter 是 Compose UI 中的绘制抽象,它描述的是 "怎么画" 而不是 "画好的结果"。真正的绘制发生在 Painter.onDraw() 被调用时,类似于 android 中的 Drawable。

fun fetchPainter(pictureId: Long, width: Int, height: Int): Flow<Painter>

性能优势:Painter 工作在绘制阶段,不参与 Compose 的组合阶段。在 Android View 体系中,通过将 Painter 包装为 Drawable,直接绘制到 View 的 Canvas 上,不需要创建 ComposeView 和 Composition。实测在列表场景中无卡顿。

配置变化天然适配:Painter 的 onDraw() 在每次绘制时执行,配置变化时一定会重新 onDraw(),可以在绘制时动态读取当前的皮肤颜色和本地资源:

override fun DrawScope.onDraw() {
    // 每次绘制时读取当前肤色,皮肤切换重新draw时自动获取新的颜色
    val textColor = CurrentSkin.colorToken.ct_text
    drawText(textLayoutResult, color = textColor, ...)
}

灵活性:Painter 在 Compose 中可直接用于 Image(painter = ...);在 Android View 体系中,通过包装为 Drawable,可设置到 ImageView、TextView.compoundDrawable 等任意控件上;ios 端同样可通过类似的桥接层消费。

3. 整体架构

1. 核心类与讲解

整个方案的核心逻辑位于 KMP 的 commonMain 中,Android 和 iOS 共享同一套加载、缓存、策略和渲染代码。对原生通过 ProviderImpl 暴露 Flow<Painter> 接口。内部按职责拆分为五个模块:

  • RequestConfig 描述请求参数。
  • DrawInfoCache 负责内存缓存。缓存三个阶段图片(附录5.1)的绘制数据。
  • Strategy 按品类生成绘制描述(DrawInfo)。这里封装策略,是因为不同品类生成三个阶段图片的方式可能不同。
  • ImageFetcher 负责利用主流框架,拉取网络图片、解码与缓存。
  • PainterRenderer 将 DrawInfo 结合渲染配置转化为最终的 Painter。
  • ProviderImpl 做流程编排,负责将缓存、策略、渲染三个模块串联起来。

各模块职责单一、边界清晰,平台相关的部分通过 expect/actual 桥接,数量控制在最少。

image.png

DrawInfo 是什么?

DrawInfo 是一个 sealed class,描述"画什么",它是策略层和渲染层之间的中间产物:策略层根据业务逻辑决定要展示什么内容(一个文字+背景色、一张网络拉取的 Bitmap、或者一个本地资源 ID),把这些信息封装为 DrawInfo 返回;渲染层再结合调用方的渲染配置(剪裁、描边)将 DrawInfo 转化为最终的 Painter。

为什么要 DrawInfo?而不是策略类直接返回 Painter?

  1. 更灵活的架构演进:如果未来出现未考虑到的情况,对外接口从 Flow<Painter> 切换为 Flow<Bitmap> 或其他形态,只需替换渲染层,策略层和缓存层完全不用改动。如果策略直接返回 Painter,输出形态的变更会波及策略层和缓存层。
  2. 方便使用方自定义:如果支持使用方自定义兜底图,让使用方传入一个 DrawInfo(如 NameInitial("A")比让使用方自己构造 Painter 更友好。渲染能力在内部统一封装,使用方只需描述 "画什么"。 当然如果后续需要更灵活的配置,添加一个自定义 Painter 的能力也很方便。

2. 完整的图片请求流程

image.png

image.png

4. 详细设计

1. 请求接口

RequestConfig 是每次图片加载请求的完整描述,通过 DSL 风格构建:

provider.fetchPainter(pictureId = "12345", width = 40, height = 40) {
    clip = Clip.Circle                   // 圆形裁剪
    stroke = Stroke(width = 1f)          // 1px 描边
}
    .map {
        // 转化为下游可消费对象
    }

注意: 目标尺寸为必填参数。图片框架按尺寸解码图片,避免加载原图浪费内存。

2. 策略匹配

不同业务场景,兜底图、真实图、错误图的生成逻辑可能完全不同。

为了让每种类型的逻辑独立,可以采用策略模式:定义一个 Strategy 抽象基类,声明 placeholder()realImage()error() 三个方法,每种类型各实现一个子类。

abstract class Strategy {
    abstract fun handles(pictureId: String): Boolean       // 是否处理该类型
    open suspend fun placeholder(pictureId: String, config: RequestConfig): DrawInfo? { ... }  // 兜底图
    open suspend fun realImage(pictureId: String, config: RequestConfig): DrawInfo? { ... }    // 真实图
    open suspend fun error(pictureId: String, config: RequestConfig): DrawInfo? { ... }        // 错误图
}

// 匹配逻辑:遍历策略列表,返回第一个 handles() 为 true 的策略
fun from(pictureId: String): Strategy = strategies.first { it.handles(data) } 

3. Painter 实现与适配

1. Painter 的实现

PainterRenderer 根据 DrawInfo 的类型创建不同的 Painter 子类,每个 Painter 在 onDraw() 中完成具体的绘制逻辑。

所有 Painter 统一支持 Clip(裁剪)和 Stroke(描边)等 RequestConfig 中支持的通用能力,这部分通过抽取公共方法处理。

DrawInfo 的子类与 Painter 的子类一一对应,每种 "画什么" 都有对应的 "怎么画" ,例如:

  • DrawInfo.NameInitial(名称文案 + 背景色)→ NameInitialPainter(绘制圆形背景 + 居中文字)
  • DrawInfo.RemoteBitmap(网络图片)→ BitmapPainter(绘制远程拉取的图片)
  • DrawInfo.StaticIcon(本地资源 ID)→ StaticIconPainter(绘制本地资源图片)
image.png

这里用 NameInitialPainter 举例:

class NameInitialPainter(
    private val drawInfo: DrawInfo.NameInitial,  // 名称文案 + 背景色
    private val textMeasurer: TextMeasurer,
    ...
) : Painter() {
    override fun DrawScope.onDraw() {
        drawWithClipAndStroke(clip, stroke) { // 绘制剪裁、描边等
            drawRect(color = drawInfo.bgColor) // 绘制颜色背景

            val minWidth = minOf(size.width, size.height)
            val fontSizePx = minWidth * 0.45f // 1
            val style = TextStyle(fontSize = fontSizePx.toSp(), ...)
            val textLayout = textMeasurer.measure(drawInfo.name, style, density = this) // 2

            val textColor = CurrentSkin.colorToken.ct_text_h1  // 3
            drawText(
                textLayoutResult = textLayout,
                color = textColor,
                topLeft = Offset(
                    x = (size.width - textLayout.size.width) / 2f,
                    y = (size.height - textLayout.size.height) / 2f,
                ),
            )
        }
    }
}

代码中的注释是值得注意的几个点:

  1. 字号按比例计算:假设绘制区域是 100×100px,字高 = 100 × 45% = 45px,文字大约占区域的一半,视觉上不会太大也不会太小。但 TextMeasurer 只接受 sp 单位,所以需要 fontSizePx.toSp() 先将 px 转为 sp。measure() 内部会将 sp 转回 px 进行排版,最终排版出来的文字就是我们期望的像素大小。
  2. density = this 保证换算一致:DrawScope 继承自 Density,使用它代替 TextMeasurer 可防止 Density 过期问题(附录5.2)。
  3. 颜色可随肤:文字颜色在每次绘制时根据当前肤色动态读取,不需要强制使用方在切换肤色时手动刷新。

2. 平台适配

1. Compose

Image() 组件直接支持 Painter。

val painter by providerImpl.fetchPainter(pictureId, 40, 40).collectAsState(initial = EmptyPainter)

Image(painter = painter, contentDescription = null)
2. Android

Android View 体系的控件(ImageView、TextView 等)接受的是 Drawable,不是 Painter。核心方法是桥接一个继承 Android Drawable 的包装类,内部通过 CanvasDrawScope 将 Painter 的绘制逻辑转发到 Android Canvas 上:

class PainterDrawable(
    private val painter: Painter,
    private val width: Int,
    private val height: Int,
) : Drawable() {

    private val density = Density(
        context.resources.displayMetrics.density,
        context.resources.displayMetrics.scaledDensity,
    )
    
    private val drawScope = CanvasDrawScope()

    override fun draw(canvas: Canvas) {
        val composeCanvas = androidx.compose.ui.graphics.Canvas(canvas)
        drawScope.draw(
            density = density,
            layoutDirection = LayoutDirection.Ltr,
            canvas = composeCanvas,
            size = Size(bounds.width().toFloat(), bounds.height().toFloat()),
        ) {
            with(painter) { draw(size) }  // Painter 直接绘制到 View 的 Canvas
        }
    }

    override fun getIntrinsicWidth(): Int = width
    override fun getIntrinsicHeight(): Int = height
    ...
}
3. ios

iOS 端采用类似的思路:将 Painter 桥接为 iOS 原生可消费的形态。Compose Multiplatform 在 iOS 上基于 Skia 渲染,Painter 的 onDraw() 最终绘制到 Skia Canvas 上。原理与 Android PainterDrawable 一致 — 提供一个包装层,将 Painter 的绘制指令转发到平台原生的渲染管线。

这里可以找 ios 的同事实现,我已经找 ios 的同事验证没问题。

4. 内存缓存

内存缓存使用 androidx.collection.LruCache(该库支持 KMP ),它内部基于 LRU(最近最少使用)算法淘汰条目,且读写操作 线程安全,且支持通过构造参数 自定义缓存上限。缓存中存储的是 DrawInfo 纯数据:

internal object DrawInfoCache {

    private val cache = object : LruCache<String, DrawInfo>(20 * 1024 * 1024) {
        override fun sizeOf(key: String, value: DrawInfo): Int {
            return when (value) {
                is DrawInfo.RemoteBitmap -> value.bitmap.width * value.bitmap.height * 4
                else -> 1  // NameInitial、StaticIcon 极轻量
            }
        }
    }

    fun get(pictureId: Long, width: Int, height: Int, imageType: ImageType): DrawInfo? { ... }
    fun put(pictureId: Long, width: Int, height: Int, imageType: ImageType, drawInfo: DrawInfo) { ... }
    fun clear() { cache.evictAll() }
}

sizeOf 策略:LruCache 需要知道每个条目的"成本"来决定淘汰顺序。RemoteBitmap 持有一张 Bitmap,按 width × height × 4(ARGB 每像素 4 字节)估算内存占用,这是缓存中的大头。NameInitial 和 StaticIcon 只存几个字段(一个字符串、一个颜色值、一个资源 ID),内存可忽略不计,统一按 1 计算。这样 20MB 的上限实际上主要限制的是缓存了多少张网络图片。

ImageType:用于区分兜底图/真实图/错误图的缓存数据。他们共用同一个 LRU 缓存。

5. 请求网络图片、解码、磁盘缓存

网络图片的请求、解码、磁盘缓存等能力通常由图片加载框架提供,不需要自行实现。在 KMP 生态中,Coil 是目前最主流的选择(详见附录5.3)。

1. 普通场景:直接使用 coil

对于普通场景,直接使用项目中为 Coil 配置好的网络能力和磁盘缓存即可,无需额外定制。

// 架构组提供项目通用的 ImageLoader
val imageLoader = ImageLoader.Builder(platformContext)
    .components {
        // 项目默认的网络模块
        ...
    }
    .diskCache {
        // 项目默认的缓存配置
        ...
    }
    .build()
    
----------------------------------

// 业务场景
object ImageFetcher {
    suspend fun fetch(url: String, width: Int, height: Int): Bitmap? {
        val request = ImageRequest.Builder(platformContext)
            .data(url)
            .size(width, height)
            .build()
            
        return (imageLoader.execute(request) as SuccessResult).image.toBitmap()
    }
}

2. ETag 场景:禁用 Coil 缓存,使用 HTTP 缓存

对于 URL 固定但图片内容可能更新的场景,服务端通过 ETag 标识版本,客户端需要协商校验。此时 Coil 的磁盘缓存不再适用 — 它以 URL 为 key,命中后直接返回旧图,不会发起网络请求,无法感知服务端图片已更新。

可以利用 HTTP 缓存框架(如 ktor、OkHttp、NSURLCache)的协商缓存能力(详见附录5.4),通过自定义 Fetcher 接管网络请求:

具体做法是 禁用 Coil 缓存,可以通过 依赖 Ktor,或者 桥接原生网络框架。以下示例使用桥接方式,Android 端桥接 OkHttp(自带 HTTP Cache 能力):

// commonMain:ImageFetcher,禁用 Coil 缓存,通过自定义 Fetcher 接管网络请求
object ImageFetcher {

    private val imageLoader by lazy {
        ImageLoader.Builder(platformContext)
            .memoryCachePolicy(CachePolicy.DISABLED)   // 内存缓存由 DrawInfoCache 管理
            .diskCachePolicy(CachePolicy.DISABLED)     // 磁盘缓存由 HTTP Cache 管理
            .components {
                add(HttpFetcher.Factory())
            }
            .build()
    }

    suspend fun fetch(url: String, width: Int, height: Int): Bitmap? {
        ...
    }

    // 自定义 Coil Fetcher,通过 expect/actual 桥接平台网络框架
    private class HttpFetcher(private val url: String) : Fetcher {
        override suspend fun fetch(): FetchResult {
            val httpResult = fetchHttp(url).getOrThrow()
            ...              
        }
    }
}

// commonMain:expect 声明
internal expect suspend fun fetchHttp(url: String): Result<HttpResult>

-----------------------------

// androidMain:actual 实现,使用带 HTTP Cache 的 OkHttpClient
private val httpClient = OkHttpClient.Builder()
    .cache(Cache(File(cacheDir, "http_cache"), 20L * 1024 * 1024)) // 开启缓存、etag校验
    .build()

internal actual suspend fun fetchHttp(url: String): Result<HttpResult> {
    // OkHttp 自动处理 ETag/304/max-age,开发者无需手动管理
}

6. ProviderImpl 流程编排

ProviderImpl 是对外唯一入口,负责将缓存、策略、渲染三个模块串联起来,返回 Flow<Painter>。它本身不包含任何业务逻辑或渲染逻辑,只做流程编排:

object ProviderImpl {

    fun fetchPainter(
        pictureId: Long, width: Int, height: Int,
        config: RequestConfig.() -> Unit = {},
    ): Flow<Painter> = flow {
        val cfg = RequestConfig(pictureId, width, height).apply(config)

        // 1. 查内存缓存,命中则直接渲染返回
        DrawInfoCache.get(pictureId, width, height, REAL)?.let { drawInfo ->
            PainterRenderer.toPainter(drawInfo, cfg)?.let {
                emit(it)
                return@flow
            }
        }

        val strategy = Strategy.from(pictureId)

        // 2. 并行:兜底图(同步) + 真实图(异步网络加载)
        val realDeferred = scope.async { strategy.realImage(...) }

        strategy.placeholder(...)?.let { drawInfo ->
            DrawInfoCache.put(pictureId, width, height, PLACEHOLDER, drawInfo)
            PainterRenderer.toPainter(drawInfo, cfg)?.let { emit(it) }
        }

        // 3. 真实图加载完成
        realDeferred.await()?.let { drawInfo ->
            DrawInfoCache.put(pictureId, width, height, REAL, drawInfo)
            PainterRenderer.toPainter(drawInfo, cfg)?.let {
                emit(it)
                return@flow
            }
        }

        // 4. 真实图失败,发射错误图
        strategy.error(...)?.let { drawInfo ->
            PainterRenderer.toPainter(drawInfo, cfg)?.let { emit(it) }
        }
    }
}

5. 附录

1. 什么是兜底图、真实图、错误图?

通常,图片的加载分为三个阶段,按顺序发射,下游收到新图片直接替换展示即可:

  1. 兜底图(Placeholder) :本地立即生成的占位图,用于在网络图片回来前给用户一个视觉反馈。通常是自绘的样式或静态图标。

  2. 真实图片(Real) :从网络加载的真实图片,或自绘的图片。加载成功后替换兜底图。

  3. 错误图(Error) :真实图片加载失败时的兜底。

2. 如何在非 @Composable 中绘制文字?

1. 需要手动构造 TextMeasurer

Compose 中绘制文字需要一个 TextMeasurer 对象,用于把文字排版成可以绘制的结果。例如:

输入:文字内容 + 样式(字号、字重、字体)

输出:TextLayoutResult(每个字形的位置、总宽高、换行信息)

然后将排版的结果交给 drawText() 画到 Canvas 上。

val textLayoutResult = textMeasurer.measure(...)

drawText(
    textLayoutResult = textLayoutResult,
    ...
)

在 Compose 中,TextMeasurer 是从 CompositionLocal 获取的,意味着在 组合阶段 才能获取:

@Composable
fun MyCanvas() {
    val textMeasurer = rememberTextMeasurer()
    Canvas(modifier) {
        drawText(textMeasurer.measure("A", TextStyle(fontSize = 16.sp)))
    }
}

但如果要在非组合阶段中使用 TextMeasurer,则需要 手动构造 TextMeasurer。而 TextMeasurer 构造参数中的 FontFamily.Resolver 和 Density 都是平台相关的,Compose 没有提供 commonMain 可用的统一创建 API,所以需要通过 expect/actual 自行桥接

// commonMain
internal expect fun createMiniLogoFontFamilyResolver(): FontFamily.Resolver 
internal expect fun createMiniLogoDensity(): Density

// androidMain
internal actual fun createMiniLogoFontFamilyResolver(): FontFamily.Resolver =
    createFontFamilyResolver(Global.getContext())

internal actual fun createMiniLogoDensity(): Density {
    val metrics = Global.getContext().resources.displayMetrics
return Density(metrics.density, metrics.scaledDensity)
}

// 构造 TextMeasurer 的场景
internal object Xxx {

    private val textMeasurer by lazy {
    TextMeasurer(
            defaultFontFamilyResolver = createMiniLogoFontFamilyResolver(),
            defaultDensity = createMiniLogoDensity(),
            defaultLayoutDirection = LayoutDirection.Ltr,
        )
    }
    
    ...
}

2. 为什么 TextMeasurer 需要 Resolver?

操作系统里存的是具体的字体文件(Android: /system/fonts/Roboto-Bold.ttf,iOS: SF-Pro-Text-Bold.otf)。FontFamily.Resolver 就是从抽象名称到具体字体文件的翻译器。

TextMeasurer 排版一个字符时,需要从字体文件中读取该字符的字形轮廓和宽高数据,流程如下:

Resolver 查找对应的字体文件
        │
        ▼
Roboto-Bold.ttf (Android) / SF-Pro-Text-Bold.otf (iOS)
        │
        ▼
从字体文件读取 "A" 的字形数据:宽 28px,高 32px
        │
        ▼
TextMeasurer 用这些数据完成排版

不同平台字体文件的存储位置和访问方式不同,所以 Resolver 的创建是平台相关的 — Android 需要 Context 来访问系统字体目录,iOS 通过 Skia 引擎直接读取。

3. 为什么需要 Density?

sp 和 px 之间的换算汇率。TextMeasurer 接收的字号单位是 sp,排版时需要转为 px 才能计算字形占多少像素。Density 封装了两个系数:density(屏幕像素密度)和 fontScale(用户字体缩放倍数),换算公式为 px = sp × density × fontScale

例如同样 fontSize = 14.sp

  • 在 density=2.0、fontScale=1.0 的普通手机上:14 × 2.0 × 1.0 = 28px
  • 在 density=3.5 的高清手机上:14 × 3.5 × 1.0 = 49px(像素更多,但物理大小一致)
  • 在用户开了大字体(fontScale=1.3)的手机上:14 × 2.0 × 1.3 = 36px(字体放大 30%)

4. Density 过期问题与应对

假如 TextMeasurer 通过 by lazy 创建,构造时的 Density 被固化。但 Density 在运行时可能发生变化:

  • 用户在系统设置中调整字体大小(fontScale 变化)
  • 折叠屏展开/折叠,内外屏 density 不同

如果 TextMeasurer 内部仍持有旧的 Density,measure() 排版时 sp → px 的换算就会用旧汇率,导致文字大小与实际屏幕不匹配。

解决方案:TextMeasurer measure() 方法接受一个 density 参数,可以用绘制上下文的 density 覆盖构造时的默认值。

3. Coil 简介

在 KMP 生态中,Coil(Coroutine Image Loader)是目前图片加载框架中最主流的选择,从 3.0 开始支持 KMP,可在 commonMain 中使用。它基于协程,API 简洁,且具备高度可定制性:

  • 网络层可替换:主流有 Ktor 和 OkHttp 两种网络模块(需额外引入依赖),也可以不引入任何网络模块,通过自定义 Fetcher 接管网络请求,使用项目中已有的网络框架。
  • 缓存策略可配置:内存缓存和磁盘缓存均可独立开关。
  • 解码流程可扩展:支持自定义 Decoder 处理特殊图片格式

1. 网络层

// 方式一:引入 Ktor 网络模块,开箱即用
// 只需添加依赖 io.coil-kt.coil3:coil-network-ktor3,无需额外配置
val imageLoader = ImageLoader.Builder(platformContext).build()

// 方式二:不引入网络模块,通过自定义 Fetcher 使用项目已有的网络框架
val imageLoader = ImageLoader.Builder(platformContext)
    .components {
        add(MyCustomFetcher.Factory())
    }
    .build()

// 发起加载请求
val request = ImageRequest.Builder(platformContext)
    .data("https://cdn.example.com/image.jpg")
    .size(width, height)
    .build()
val result = imageLoader.execute(request) // 挂起函数
val bitmap = (result as SuccessResult).image.toBitmap()

2. 磁盘缓存

Coil 自带磁盘缓存能力,基于 DiskLruCache 实现,以 URL 为 key 存储解码前的原始图片数据:

val imageLoader = ImageLoader.Builder(platformContext)
    .diskCachePolicy(CachePolicy.ENABLED)
    .diskCache {
        DiskCache.Builder()
            .directory(cacheDir / "image_cache")
            .maxSizeBytes(50L * 1024 * 1024)  // 50MB
            .build()
    }
    .build()

缓存命中后跳过网络请求,直接从磁盘读取并解码。这种磁盘缓存适用于两类场景:

  1. 图片更新时 URL 会随之变化(如带版本号 ?v=2),旧 URL 自然不会命中缓存;
  2. 不需要检查图片是否更新的场景,缓存命中后直接从磁盘读取并解码,跳过网络请求。

4. Etag 与 http 缓存

ETag(Entity Tag)是 HTTP 协议中的 资源版本标识

  • 服务端在响应头中返回一个 ETag 值(如 ETag: "abc123"),代表当前资源的版本。

  • 客户端下次请求同一 URL 时,携带 If-None-Match: "abc123" 请求头。

  • 服务端比对版本:如果资源未变,返回 304(Not Modified),不传输图片数据,客户端继续使用本地缓存;如果资源已更新,返回 200 + 新图片 + 新 ETag。

ETag 通常与 HTTP 缓存框架配套使用。HTTP 框架(如 Android 的 OkHttp、iOS 的 NSURLCache)内置了完整的 HTTP 缓存语义,自动处理以下流程:

  1. 首次请求:服务端返回图片 + ETag + Cache-Control: max-age=xxx,框架自动将完整响应存入磁盘
  2. max-age 未过期:直接从磁盘返回缓存的响应,不发起网络请求
  3. max-age 过期后:自动携带 If-None-Match: {ETag} 发起请求,304 则继续用缓存,200 则更新

开发者不需要自己维护 ETag、管理缓存文件或判断缓存有效期,这些都由 HTTP 框架按协议标准自动完成。

此外,如果 svr 要求每次请求都确认资源是否最新(不信任 max-age),可以使用 Cache-Control: no-cache。no-cache 并不意味着"不缓存",而是 "每次使用缓存前必须向 服务 端验证" 。框架仍然会缓存响应到磁盘,但每次请求都会携带 If-None-Match: {ETag} 向服务端确认,304 则用缓存,200 则更新。相比 max-age 方案多了一次网络往返,但保证了数据的实时性。

以 Android 端 OkHttp 为例,启用 HTTP 缓存和 ETag 协商只需开启 cache

// 1. 创建带 Cache 的 OkHttpClient
val httpClient = OkHttpClient.Builder()
    .cache(Cache(File(cacheDir, "http_cache"), 20L * 1024 * 1024))  // 20MB 磁盘缓存
    .build()

// 2. 正常发起请求,ETag 协商由 OkHttp 自动处理
val request = Request.Builder().url("https://cdn.example.com/image.jpg").build()
val response = httpClient.newCall(request).execute()