Compose Multiplatform 三方库 Coil(coil-core)的 OpenHarmony 鸿蒙化适配实战

发布时间:2026/10/1 8:36:08
Compose Multiplatform 三方库 Coil(coil-core)的 OpenHarmony 鸿蒙化适配实战 Compose Multiplatform 三方库 Coilcoil-core的 OpenHarmony 鸿蒙化适配实战上游 3.3.0 LRU 强弱双级缓存 会话式 JSON 桥 DevEco 模拟器三页签实测库版本coil-kt/coil 1731511v3.3.0 线Apache 2.0验证环境Kotlin 2.2.21-1.0.0鸿蒙定制版kotlinx-serialization 1.9.1-1.0.0DevEco Studio 26.0.0DevEco 模拟器HarmonyOS 7.0.0API 26前面几篇把日历、图表这些 CMP 库搬上鸿蒙之后这次轮到图片加载生态的地基coil-kt/coil。Coil 3 号称Kotlin Multiplatform 时代的图片加载库名字就来自CoroutineImageLoaderAndroid/iOS/Desktop 全平台覆盖。查了 CPF-KMP-CMP 官方清单和 AtomGit鸿蒙上同样没有它的位置——于是接着做。先说边界图片加载库的完整链路是网络请求 → 解码 → 缓存 → 显示其中网络ktor/okhttp和显示Compose Canvas两端都和平台强耦合本次不搬。但链路中间那段——LRU 强/弱双级内存缓存、MIME 解析、请求尺寸模型、缓存键组合——是纯粹的 Kotlin 数据结构与算法恰恰是图片加载库最容易写错的部分驱逐时机、字节记账、弱引用生命周期。结论先说上游核心源码零逻辑修改仅两处编译依赖替换下文详述Kotlin/Native 编出双 ABI.soDevEco 模拟器三页签实测跑通44 个单测全绿30 上游 14 桥。*先睹为快DevEco 模拟器实测。左内存缓存页LRU 驱逐与弱引用回退全部由 Kotlin/Native 侧的上游 RealMemoryCache 驱动右MIME 解析页上游 MimeTypeMap 的 100 类型扩展名表在鸿蒙侧原样生效*一、先看清楚图片加载库的值钱部分在哪照例先翻上游源码分布。coil-core 的 commonMain 按包分目录内容对平台的依赖memory/MemoryCache接口 StrongMemoryCacheWeakMemoryCacheRealMemoryCache纯 Kotlin atomicfu 锁 WeakReferenceutil/LruCache/LruMutableMapLRU 核心、mimeTypes100 类型扩展名表、collections/logging/contexts几乎为零size/Size/Dimension/Scale/Precision纯 Kotlinnetwork/、decode/、transform/请求执行、平台解码器、变换ktor/okhttp、Skia/Bitmap重度平台耦合Compose 层AsyncImage等Canvas重度耦合又是熟悉的格局容易写错的全在中段平台耦合全在两端。LRU 缓存这种东西自己手写一遍不难写对很难——驱逐到哪个字节数停被强缓存驱逐的条目去哪答案弱引用层继续存活直到 GCsetMaxSize收缩时要不要立即驱逐答案要这些语义上游用 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/NativeohosArm64 / 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 actualPlatformContext 等 │ └── 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 进程里的HashMapInt, MemoryCache。有个图片缓存特有的设计点要想清楚缓存值是什么上游MemoryCache.Value包着Image可绘制的位图对象而鸿蒙侧真实位图只能存在 ArkTS 的 PixelMap 里Kotlin/Native 侧没有 Skia。硬要把位图搬过桥就得做字节流序列化 像素格式转换纯粹为了 demo 得不偿失。所以桥协议里set存的是哑图BridgeImage——一个宽/高/字节数可控的记账对象privateclassBridgeImage(overridevalwidth:Int,overridevalheight:Int,overridevalsize:Long,overridevalshareable:Booleantrue,):Image{overridefundraw(canvas:Canvas){}}真实位图由 ArkTS 侧持有Kotlin 侧只做缓存行为的记账哪个 key 进来了、占了多少字节、什么时候被驱逐、被驱逐后弱引用层还能不能命中。这些恰是上游缓存最值得验证的行为语义——图片本身不过是测试数据。协议上get命中时返回imageId/width/height/bytes/extrasArkTS 拿着 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.BuildermaxSizeBytes定长、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 没有预编译 klibtransform 插件也只覆盖官方 target。解法是本地 shim// commonMain —— 签名与 atomicfu 完全一致publicexpectopenclassSynchronizedObject(){publicfunlockImpl()publicfununlockImpl()}publicinlinefunTsynchronized(lock:SynchronizedObject,block:()-T):T{lock.lockImpl()try{returnblock()}finally{lock.unlockImpl()}}// ohosMain —— 自旋锁NAPI JS 线程是唯一常规调用方publicactualopenclassSynchronizedObjectactualconstructor(){privatevalspinAtomicInt(0)actualfunlockImpl(){while(!spin.compareAndSet(0,1)){}}actualfununlockImpl(){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 WeakReferenceexpect/actual 再来一次WeakMemoryCache的核心机制是WeakReferenceEntry——强缓存驱逐后条目靠弱引用续命直到 GC 才真正消失。JVM 有java.lang.ref.WeakReferenceKotlin/Native 的kotlin.native.ref.WeakReference在新内存模型下同样可用但 commonMain 里没有公共 API。老朋友 expect/actual// commonMainpublicexpectclassWeakRefT:Any(referred:T){publicfunget():T?}// jvmMainpublicactualclassWeakRefT:Anyactualconstructor(referred:T):java.lang.ref.WeakReferenceT(referred){publicactualoverridefunget():T?super.get()}注意 Kotlin/Native 的WeakReference构造后要用executeAfterGarbageCollection之类手段才能观察到回收——JVM 单测里System.gc()后弱引用判空这个上游测试语义照常工作ohos 侧 demo 不依赖 GC 时机弱引用回退靠强缓存驱逐但对象仍被 images 表强持有来演示见 4.4。4.3 桥接层JVM 单测把语义锁死CoilBridge.kt放 commonMain14 个桥接单测全部在 JVM 跑。大部分 op 是直接的参数搬运三个语义点值得记录LRU 驱逐 → 弱引用回退本次适配最核心的验证点。maxSize 只够放一张图第二张进来把第一张从强缓存挤掉、赶进弱引用层get第一张依然命中——这是上游RealMemoryCache的招牌行为TestfunlruEvictionTriggersWeakFallback(){validnewCache(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 依然命中上游弱引用回退valacall({op:get,id:$id,key:a})assertTrue(str(a,hit).toBoolean())// 但强缓存 size 只剩 bvalstatscall({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是MapString, Any存入时快照Key的extras是MapString, String参与相等判定。桥协议里两个都有set时传的 extras 同时成为 Key extras 与 Value extrasget返回的是 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 .soarm64 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/OhosCoilFreestrings搜索二进制即可确认 CName 导出HAP 6.3 MB。五、运行效果DevEco 模拟器实测冷启动 hilog 的CoilNapi/CoilDemotag 下每条请求/响应的头部与耗时都有记录全链路可追溯。5.1 内存缓存页LRU 驱逐 弱引用回退活演示2MB 双级缓存预填 5 张图后逐张存入一张触发驱逐。三个统计卡实时显示 Kotlin 侧上报的强缓存占用/容量上限/条目数强弱*内存缓存页统计数字全部来自 Kotlin/Native 侧 RealMemoryCache 的实时状态条目列表的驱逐顺序按上游 LRU 语义排列*操作按钮的语义对照“get 最老条目”演示弱引用回退——最老条目早已被 LRU 驱逐出强缓存get却依然命中弱层续命日志区打出hittrue与图宽高“容量减半”走trimToSize强缓存立刻驱逐到目标字节但count不降弱层仍持有“清空”后 size 与 count 同时归零clear是真清。每一击操作日志实时追加行为与第 4.3 节单测锁死的语义一一对应。5.2 MIME 解析页100 类型表原样生效输入框任意 URL/扩展名mimeTypeop 解析下方速查表实时跑 6 个典型用例大写.PNG、带#fragment的 jpg、无扩展名*MIME 解析页上游 MimeTypeMap 的 URL 解析截扩展名、忽略查询串与 fragment与 100 类型扩展名映射在鸿蒙侧零改动生效*单测锁的边界行为在页面上一眼可查https://example.com/photo.PNG?w100→image/png扩展名大小写不敏感pic.jpg#fragment→image/jpegfragment 不影响无扩展名 URL →foundfalse。5.3 Size / Key 页模型组合与字符串化三组对照卡Size(400)半定尺寸widthPx400、heightPx 缺失、isDefinedtrue、Size.ORIGINAL双轴 Undefined、isOriginaltrue、MemoryCache.Key的 extras 组合与toString展示*Size/Key 页上游 Dimension 模型的 Pixels/Undefined 二态与 Key 的 extras 快照、字符串化在鸿蒙侧原样可查*六、踩坑记#坑现象解法1atomicfu 无 ohos klibRealMemoryCache编译不过本地SynchronizedObjectexpect/actual shimJVMReentrantLockohosAtomicInt 自旋import 行改指本地——全库唯一非原样行2Poko代码生成不可用Key/FakeImage缺 equals/hashCode/toString手写三个方法语义与注解生成一致3Kotlin/Native 无 JVM 式 GC 单测语义弱引用回收时机不可控JVM 单测靠WeakReference直接断言 GC 语义ohos demo 用强持有下弱层命中演示回退语义不赌 GC 时机4keys计数预期错setMaxSize收缩后 count 不减疑似 bug上游keys strong.keys weak.keys弱层条目仍计入——正确语义测试预期修正5gradlew 启动器要 JAVA_HOMEorg.gradle.java.home在 Gradle 起来后才生效构建/IDE 全链路统一gradle.properties 脚本内先设JAVA_HOME再调 gradlew6真实位图过桥成本高PixelMap 序列化 像素格式转换纯为 demo 服务哑图记账模式Kotlin 侧只记账宽高/字节/extras真图留 ArkTS 侧七、FAQQ1为什么只搬缓存不搬网络请求和 AsyncImage两端都是平台强耦合网络层绑定 ktor/okhttp鸿蒙侧有 ohos.net.http 原生方案直接在 ArkTS 拉流更顺显示层绑定 Skia/Compose CanvasArkUI 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每次返回新 idregistry 多实例并存。Demo 全局一个缓存、页面aboutToAppear建、aboutToDisappearfree要模拟内存缓存 磁盘缓存前置的多级结构可以建多个实例各自配 maxSize。Q4maxSizePercent在鸿蒙上按什么算走上游Builder.maxSizePercent(context, 0.15)context 是 ohos 侧单例PlatformContext.INSTANCE默认按平台总内存 15% 折算模拟器 512MB → 80530636 字节单测锁了这个数。要精确到当前应用可用内存可在 ohos actual 里接ohos.app.ability.ApplicationManager——属于后续增量。Q5这个切片对真实图片加载器ImageKnife 等有什么用ImageKnife 等 FlutterOH/ArkTS 图片库最缺的恰是经过大规模生产验证的缓存语义。本适配把 Coil 的双级缓存行为原样搬到.soArkTS 图片库可以直接过桥取用请求前置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/coil1731511v3.3.0 线Apache 2.0Demo 入口仓库example/ohosAppDevEco Studio 26.0.0 直接打开构建脚本仓库scripts/build-so.ps1、example/ohosApp/build-hap.ps1