Compose Multiplatform 三方库 Coil(coil-core)的 OpenHarmony 鸿蒙化适配实战
Compose Multiplatform 三方库 Coil(coil-core)的 OpenHarmony 鸿蒙化适配实战(上游 3.3.0 + LRU 强弱双级缓存 + 会话式 JSON 桥 + DevEco 模拟器三页签实测)
库版本:coil-kt/coil 1731511(v3.3.0 线,Apache 2.0)|验证环境:Kotlin 2.2.21-1.0.0(鸿蒙定制版)|kotlinx-serialization 1.9.1-1.0.0|DevEco Studio 26.0.0|DevEco 模拟器|HarmonyOS 7.0.0(API 26)
前面几篇把日历、图表这些 CMP 库搬上鸿蒙之后,这次轮到图片加载生态的"地基":coil-kt/coil。Coil 3 号称"Kotlin Multiplatform 时代的图片加载库"(名字就来自 Coroutine Image Loader),Android/iOS/Desktop 全平台覆盖。查了 CPF-KMP-CMP 官方清单和 AtomGit,鸿蒙上同样没有它的位置——于是接着做。

先说边界:图片加载库的完整链路是"网络请求 → 解码 → 缓存 → 显示",其中网络(ktor/okhttp)和显示(Compose Canvas)两端都和平台强耦合,本次不搬。但链路中间那段——LRU 强/弱双级内存缓存、MIME 解析、请求尺寸模型、缓存键组合——是纯粹的 Kotlin 数据结构与算法,恰恰是图片加载库最容易写错的部分(驱逐时机、字节记账、弱引用生命周期)。结论先说:上游核心源码零逻辑修改(仅两处编译依赖替换,下文详述),Kotlin/Native 编出双 ABI .so,DevEco 模拟器三页签实测跑通,44 个单测全绿(30 上游 + 14 桥)。
*先睹为快:DevEco 模拟器实测。左:内存缓存页,LRU 驱逐与弱引用回退全部由 Kotlin/Native 侧的上游 RealMemoryCache 驱动;右:MIME 解析页,上游 MimeTypeMap 的 100+ 类型扩展名表在鸿蒙侧原样生效*
一、先看清楚:图片加载库的"值钱部分"在哪
照例先翻上游源码分布。coil-core 的 commonMain 按包分:
| 目录 | 内容 | 对平台的依赖 |
|---|---|---|
memory/ | MemoryCache 接口 + StrongMemoryCache + WeakMemoryCache + RealMemoryCache | 纯 Kotlin + atomicfu 锁 + WeakReference |
util/ | LruCache/LruMutableMap(LRU 核心)、mimeTypes(100+ 类型扩展名表)、collections/logging/contexts | 几乎为零 |
size/ | Size/Dimension/Scale/Precision | 纯 Kotlin |
network/、decode/、transform/ | 请求执行、平台解码器、变换 | ktor/okhttp、Skia/Bitmap,重度平台耦合 |
| Compose 层 | AsyncImage 等 | Canvas,重度耦合 |
又是熟悉的格局:容易写错的全在中段,平台耦合全在两端。LRU 缓存这种东西自己手写一遍不难,写对很难——驱逐到哪个字节数停?被强缓存驱逐的条目去哪(答案:弱引用层继续存活,直到 GC)?setMaxSize 收缩时要不要立即驱逐(答案:要)?这些语义上游用 8 个单测文件锁死了,白送不抄是浪费。所以路线不变:缓存/解析/尺寸/键模型原样复用,网络与绘制两端不做。

二、整体链路
ArkTS (Index.ets / CoilApi.ets)
│ import coilNative from 'libcoil.so'
▼ coilNative.call('{"op":"createCache","maxSizeBytes":2097152}')
libcoil.so ← C++ NAPI 薄层:字符串进、字符串出
│ extern "C" OhosCoilCall / OhosCoilFree
▼
libohoscmpcoil.so ← Kotlin/Native(ohosArm64 / ohosX64)
│ CoilBridge:解析 op → 定位 cache id → 调用上游 → JSON 序列化
▼
上游 RealMemoryCache / StrongMemoryCache / WeakMemoryCache
+ LruCache / MimeTypeMap / Size / MemoryCache.Key(零逻辑修改)
+ 两处编译依赖替换(@Poko → 手写;atomicfu 锁 → 本地 shim)
工程结构:
cmp-coil-demo/
├── coil/ # 库模块(上游 vendored + shims)
│ └── src/
│ ├── commonMain/kotlin/coil3/ ← 上游原样(memory/util/size/annotation)
│ ├── commonMain/kotlin/coil3/util/SynchronizedObject.kt ← 新增 expect
│ ├── jvmMain/kotlin/ ← JVM actual(供单测在 JVM 跑)
│ ├── ohosMain/kotlin/ ← ohos actual(PlatformContext 等)
│ └── commonTest/kotlin/ ← 上游单测 30 个(原样搬入)
├── example/nativeApp/ # CoilBridge + CoilExport,产出 libohoscmpcoil.so
├── example/ohosApp/ # DevEco 工程(三页签 Demo)
└── scripts/build-so.ps1 # 单测 + 双 ABI 一键构建

三、桥协议:会话式 + 哑图记账
图表篇确立了"有状态库走会话式协议"的范式,缓存直接沿用:createCache 拿整数 cache id,后续 10 个带 id 的操作,free 释放。registry 是 .so 进程里的 HashMap<Int, MemoryCache>。
有个图片缓存特有的设计点要想清楚:缓存值是什么? 上游 MemoryCache.Value 包着 Image(可绘制的位图对象),而鸿蒙侧真实位图只能存在 ArkTS 的 PixelMap 里,Kotlin/Native 侧没有 Skia。硬要把位图搬过桥,就得做字节流序列化 + 像素格式转换,纯粹为了 demo 得不偿失。所以桥协议里 set 存的是哑图(BridgeImage)——一个宽/高/字节数可控的记账对象:
private class BridgeImage(
override val width: Int,
override val height: Int,
override val size: Long,
override val shareable: Boolean = true,
) : Image {
override fun draw(canvas: Canvas) {}
}
真实位图由 ArkTS 侧持有,Kotlin 侧只做"缓存行为"的记账:哪个 key 进来了、占了多少字节、什么时候被驱逐、被驱逐后弱引用层还能不能命中。这些恰是上游缓存最值得验证的行为语义——图片本身不过是测试数据。协议上 get 命中时返回 imageId/width/height/bytes/extras,ArkTS 拿着 imageId 去自己的映射表里找真图即可。这个"哑图记账"模式对所有"值是平台对象、逻辑在公共层"的库都通用。
{"op":"createCache","maxSizeBytes":2097152}
→ {"id":1,"initialMaxSize":2097152}
{"op":"set","id":1,"key":"https://example.com/cat.jpg","imageId":0,"width":800,"height":600,"extras":{"scale":"FIT"}}
→ {"ok":true,"size":1920000}
{"op":"get","id":1,"key":"https://example.com/cat.jpg"}
→ {"hit":true,"imageId":0,"width":800,"height":600,"bytes":1920000,"extras":{"scale":"FIT"}}
createCache 完整走上游 MemoryCache.Builder:maxSizeBytes 定长、maxSizePercent 按平台内存比例(上游默认 0.15,模拟器 512MB 内存算出 80530636 字节——单测里就用这个值锁 Builder 链路的正确性)、strongReferencesEnabled/weakReferencesEnabled 两个开关透传。
四、适配过程
4.1 上游搬入:两处编译依赖替换,零逻辑修改
这次上游文件不要求全部"一字节不动",有两处编译依赖替换(不改任何行为),先坦白:
替换一:@Poko → 手写 equals/hashCode/toString。上游 MemoryCache.Key 和测试用 FakeImage 用 @Poko(编译期生成这些方法的注解)避免手写样板。Poko 是一个独立的 KSP 处理器,为两个类引入整套代码生成管线不值得。手写这三个方法是无脑的机械劳动,语义与注解生成完全一致——Key 本来就是数据类语义(key 字符串 + extras 映射,equals 逐字段比较)。
替换二:atomicfu 锁 → 本地 expect/actual shim。RealMemoryCache 里的并发保护用 kotlinx.atomicfu 的 SynchronizedObject/synchronized。atomicfu 对鸿蒙定制 ohosArm64/ohosX64 target 没有预编译 klib,transform 插件也只覆盖官方 target。解法是本地 shim:
// commonMain —— 签名与 atomicfu 完全一致
public expect open class SynchronizedObject() {
public fun lockImpl()
public fun unlockImpl()
}
public inline fun <T> synchronized(lock: SynchronizedObject, block: () -> T): T {
lock.lockImpl()
try { return block() } finally { lock.unlockImpl() }
}
// ohosMain —— 自旋锁:NAPI JS 线程是唯一常规调用方
public actual open class SynchronizedObject actual constructor() {
private val spin = AtomicInt(0)
actual fun lockImpl() { while (!spin.compareAndSet(0, 1)) { } }
actual fun unlockImpl() { spin.store(0) }
}
ohos actual 用 kotlin.concurrent.atomics.AtomicInt 自旋锁;JVM actual 用 ReentrantLock(单测跑 JVM,语义与上游对齐)。RealMemoryCache.kt 的 import 行从 atomicfu 改指本地同名 shim——全库唯一一行非原样的改动。
除了这两处,memory/、util/、size/、annotation/ 下的文件全部原样搬入,包括那 100+ 行的 MIME 扩展名映射表和 5 个上游单测文件(30 个用例)。
4.2 WeakReference:expect/actual 再来一次
WeakMemoryCache 的核心机制是 WeakReference<Entry>——强缓存驱逐后条目靠弱引用续命,直到 GC 才真正消失。JVM 有 java.lang.ref.WeakReference,Kotlin/Native 的 kotlin.native.ref.WeakReference 在新内存模型下同样可用,但 commonMain 里没有公共 API。老朋友 expect/actual:
// commonMain
public expect class WeakRef<T : Any>(referred: T) {
public fun get(): T?
}
// jvmMain
public actual class WeakRef<T : Any> actual constructor(referred: T) : java.lang.ref.WeakReference<T>(referred) {
public actual override fun get(): T? = super.get()
}
注意 Kotlin/Native 的 WeakReference 构造后要用 executeAfterGarbageCollection 之类手段才能观察到回收——JVM 单测里 System.gc() 后弱引用判空这个上游测试语义照常工作,ohos 侧 demo 不依赖 GC 时机(弱引用回退靠"强缓存驱逐但对象仍被 images 表强持有"来演示,见 4.4)。
4.3 桥接层:JVM 单测把语义锁死
CoilBridge.kt 放 commonMain,14 个桥接单测全部在 JVM 跑。大部分 op 是直接的参数搬运,三个语义点值得记录:
LRU 驱逐 → 弱引用回退(本次适配最核心的验证点)。maxSize 只够放一张图,第二张进来把第一张从强缓存挤掉、赶进弱引用层,get 第一张依然命中——这是上游 RealMemoryCache 的招牌行为:
@Test
fun lruEvictionTriggersWeakFallback() {
val id = newCache(40000)
call("""{"op":"set","id":$id,"key":"a","imageId":1,"bytes":40000}""")
call("""{"op":"set","id":$id,"key":"b","imageId":2,"bytes":40000}""")
// "a" 被从强缓存挤掉、进了弱缓存,get 依然命中(上游弱引用回退)
val a = call("""{"op":"get","id":$id,"key":"a"}""")
assertTrue(str(a, "hit").toBoolean())
// 但强缓存 size 只剩 b
val stats = call("""{"op":"cacheStats","id":$id}""")
assertEquals("40000", str(stats, "size"))
}
等等,“哑图被 images 表强持有,怎么会进弱引用层还能命中”?这正是哑图记账的巧处:上游 WeakMemoryCache 的弱引用包的是 RealStrongMemoryCache.Entry(含着哑图),桥侧 images 表持有的是另一个引用——弱引用层"命中"取决于 Entry 对象是否仍可达。桥的 images 表确实让 Entry 间接保持强可达,所以弱引用层稳定命中(demo 语义:被驱逐的图在 ArkTS 侧还有引用时,缓存仍能找回)。这演示的是上游文档里"弱引用回退"的语义而非性能特征——真正 GC 语义的验证在 JVM 单测侧靠 WeakReference 直接断言,ohos 侧不赌 GC 时机。
keys 计数含弱引用层。setMaxSize 收缩后,被驱逐的两个 key 在 cacheKeys 里仍然计入(上游 keys = strong.keys + weak.keys)。第一次跑这个断言时我以为 count 会减,实际不减——这是正确语义不是 bug(条目还活着,只是降到弱层)。
get 的 extras 是 Value 自带、不是 Key 的。上游 MemoryCache.Value 里的 extras 是 Map<String, Any>(存入时快照),Key 的 extras 是 Map<String, String>(参与相等判定)。桥协议里两个都有:set 时传的 extras 同时成为 Key extras 与 Value extras,get 返回的是 Value 侧快照。测试锁死:同 key 不同 extras 的 get 不命中(Key 不相等)。
异常处理照例:Kotlin 侧 try 全部 Throwable,包成 {"error": "类名: 消息"} 返回,free 一个不存在的 id、未知 op 都有测试覆盖。
4.4 NAPI 层与编译部署
C++ 层沿用"谁分配谁释放"的 82 行薄层:OhosCoilCall 返回的 C 字符串用 OhosCoilFree 释放,CMake 链接 entry/libs/<abi>/libohoscmpcoil.so。构建脚本照 koalaplot 那套:
# 1. 全部 JVM 单测(44 个:30 上游 + 14 桥)
.\scripts\build-so.ps1 # 内含 :coil:jvmTest :example:nativeApp:jvmTest
# 2. 双 ABI release .so(arm64 2.6 MB / x86_64 2.5 MB)+ 部署到 ohosApp
# build-so.ps1 一并完成 linkReleaseShared + 拷贝
# 3. hvigor 打 hap,安装启动
powershell -File example\ohosApp\build-hap.ps1
powershell -File example\ohosApp\install-run.ps1
产物验证:双 ABI .so 里确认导出符号 OhosCoilCall/OhosCoilFree(strings 搜索二进制即可确认 CName 导出),HAP 6.3 MB。
五、运行效果(DevEco 模拟器实测)
冷启动 hilog 的 CoilNapi/CoilDemo tag 下每条请求/响应的头部与耗时都有记录,全链路可追溯。
5.1 内存缓存页:LRU 驱逐 + 弱引用回退活演示
2MB 双级缓存,预填 5 张图后逐张"存入一张"触发驱逐。三个统计卡实时显示 Kotlin 侧上报的强缓存占用/容量上限/条目数(强+弱):
*内存缓存页:统计数字全部来自 Kotlin/Native 侧 RealMemoryCache 的实时状态;条目列表的驱逐顺序按上游 LRU 语义排列*
操作按钮的语义对照:“get 最老条目” 演示弱引用回退——最老条目早已被 LRU 驱逐出强缓存,get 却依然命中(弱层续命),日志区打出 hit=true 与图宽高;“容量减半” 走 trimToSize,强缓存立刻驱逐到目标字节,但 count 不降(弱层仍持有);“清空” 后 size 与 count 同时归零(clear 是真清)。每一击操作日志实时追加,行为与第 4.3 节单测锁死的语义一一对应。
5.2 MIME 解析页:100+ 类型表原样生效
输入框任意 URL/扩展名,mimeType op 解析;下方速查表实时跑 6 个典型用例(大写 .PNG、带 #fragment 的 jpg、无扩展名):
*MIME 解析页:上游 MimeTypeMap 的 URL 解析(截扩展名、忽略查询串与 fragment)与 100+ 类型扩展名映射在鸿蒙侧零改动生效*
单测锁的边界行为在页面上一眼可查:https://example.com/photo.PNG?w=100 → image/png(扩展名大小写不敏感);pic.jpg#fragment → image/jpeg(fragment 不影响);无扩展名 URL → found=false。
5.3 Size / Key 页:模型组合与字符串化
三组对照卡:Size(400) 半定尺寸(widthPx=400、heightPx 缺失、isDefined=true)、Size.ORIGINAL(双轴 Undefined、isOriginal=true)、MemoryCache.Key 的 extras 组合与 toString 展示:
*Size/Key 页:上游 Dimension 模型的 Pixels/Undefined 二态与 Key 的 extras 快照、字符串化在鸿蒙侧原样可查*
六、踩坑记
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | atomicfu 无 ohos klib | RealMemoryCache 编译不过 | 本地 SynchronizedObject expect/actual shim(JVM=ReentrantLock,ohos=AtomicInt 自旋),import 行改指本地——全库唯一非原样行 |
| 2 | @Poko 代码生成不可用 | Key/FakeImage 缺 equals/hashCode/toString | 手写三个方法,语义与注解生成一致 |
| 3 | Kotlin/Native 无 JVM 式 GC 单测语义 | 弱引用回收时机不可控 | JVM 单测靠 WeakReference 直接断言 GC 语义;ohos demo 用"强持有下弱层命中"演示回退语义,不赌 GC 时机 |
| 4 | keys 计数预期错 | setMaxSize 收缩后 count 不减,疑似 bug | 上游 keys = strong.keys + weak.keys,弱层条目仍计入——正确语义,测试预期修正 |
| 5 | gradlew 启动器要 JAVA_HOME | org.gradle.java.home 在 Gradle 起来后才生效 | 构建/IDE 全链路统一 gradle.properties + 脚本内先设 JAVA_HOME 再调 gradlew |
| 6 | 真实位图过桥成本高 | PixelMap 序列化 + 像素格式转换纯为 demo 服务 | 哑图记账模式:Kotlin 侧只记账(宽高/字节/extras),真图留 ArkTS 侧 |
七、FAQ
Q1:为什么只搬缓存,不搬网络请求和 AsyncImage?
两端都是平台强耦合:网络层绑定 ktor/okhttp(鸿蒙侧有 @ohos.net.http 原生方案,直接在 ArkTS 拉流更顺),显示层绑定 Skia/Compose Canvas(ArkUI Image 组件天生干这个)。中段的缓存/解析/尺寸/键模型是纯 Kotlin,恰是"自己写容易错"的部分。上游语义用 44 个单测锁死,ArkTS 侧网络拉图 + libcoil.so 做缓存记账即可拼出完整链路。
Q2:哑图记账的弱引用回退是真语义吗?
上游 WeakMemoryCache 弱引用命中依赖 Entry 可达性。demo 的 images 表持有哑图导致弱层稳定命中,演示的是"对象仍被引用时弱层续命"的文档语义;真正 GC 后弱层判空的语义在 JVM 单测里靠 WeakReference 直接锁。要在 ohos 侧观察 GC 回收,需要 kotlin.native.ref.Cleaner 或手动 GC.collect() 配合,demo 有意不赌时机。
Q3:多缓存实例怎么管理?
createCache 每次返回新 id,registry 多实例并存。Demo 全局一个缓存、页面 aboutToAppear 建、aboutToDisappear free;要模拟"内存缓存 + 磁盘缓存前置"的多级结构,可以建多个实例各自配 maxSize。
Q4:maxSizePercent 在鸿蒙上按什么算?
走上游 Builder.maxSizePercent(context, 0.15),context 是 ohos 侧单例 PlatformContext.INSTANCE,默认按平台总内存 15% 折算(模拟器 512MB → 80530636 字节,单测锁了这个数)。要精确到当前应用可用内存,可在 ohos actual 里接 @ohos.app.ability.ApplicationManager——属于后续增量。
Q5:这个切片对真实图片加载器(ImageKnife 等)有什么用?
ImageKnife 等 FlutterOH/ArkTS 图片库最缺的恰是经过大规模生产验证的缓存语义。本适配把 Coil 的双级缓存行为原样搬到 .so,ArkTS 图片库可以直接过桥取用:请求前置 get 判命中,下载后 set 记账,内存吃紧 trimToSize,页面销毁 free。语义不用重写,上游演进免费跟进。
八、总结
这次 Coil 适配给这套"上游零逻辑修改"路线又添了两块拼图:
- "值是平台对象、逻辑在公共层"的库有了标准解法。哑图记账模式让缓存行为(驱逐/回退/记账)在 Kotlin 侧完整验证,值本体留在 ArkTS——这个模式对数据库、偏好存储、任意"容器型"库都通用。
- 依赖替换有下限。atomicfu 锁与
@Poko这两处替换是"编译依赖"级别的(签名一致、语义一致),上游逻辑零改动。判断一个替换是否越界,看它的单测是否还全绿——30 个上游单测原样通过就是零逻辑修改的证明。 - 会话式桥协议二次复用。图表篇的
create → 带 id 操作 → free范式平移到缓存场景零成本,说明这个协议设计对"有状态库"已趋成熟。 - 验收闭环:44 个单测全绿(30 上游 + 14 桥)+ DevEco 模拟器三页签实测 + hilog 全链路可追溯 + 双 ABI
.so导出符号确认。
适配成果将推至 https://atomgit.com/oh-tpc/coil,含双 ABI .so、完整 ArkTS Demo 与双语 README。图片加载生态的地基打好了,下一篇可以顺着往网络/解码方向啃。
本文代码与资源
- 适配仓库:https://atomgit.com/oh-tpc/coil
- 上游原库:https://github.com/coil-kt/coil(1731511,v3.3.0 线,Apache 2.0)
- Demo 入口:仓库
example/ohosApp(DevEco Studio 26.0.0 直接打开) - 构建脚本:仓库
scripts/build-so.ps1、example/ohosApp/build-hap.ps1
更多推荐


所有评论(0)