框架 :Compose Multiplatform v1.9.2-1.0.0 + OpenHarmony SDK 6.0.0(20)
库名称 :Coil 3.3.0(Compose Multiplatform 图片加载库)
鸿蒙适配仓库 :CPF-KMP-CMP/coil
适配分支 :
main-3.3.0-OH(鸿蒙定制版)版本 :
v3.3.0-1.0.0Maven 坐标 :
io.coil-kt.coil3:coil-compose:3.3.0(配合coil-network-ktor3)
摘要
Coil 是 Compose Multiplatform 生态最流行的图片加载库,而 CPF-KMP-CMP 社区已为它完成了 ohosArm64 target 的深度适配。本文基于对 CPF-KMP-CMP/coil(分支 main-3.3.0-OH,tag v3.3.0-1.0.0)与 CPF-KMP-CMP/compose-multiplatform 两个仓库的源码探查,系统梳理 Coil 在鸿蒙 CMP 项目中的完整落地路径:从开发环境与版本号全景、CPF 三源 Nexus 仓库配置、ohosArm64 target 与 linkerOpts 逐行解析,到三项鸿蒙特有机制------OhosNativeDmaDecoder 硬件加速解码、ServiceLoader → @EagerInitialization 自动注册、coil-gif 原生 HEIF 动画解码。文中给出可直接复用的 settings.gradle.kts、libs.versions.toml、build.gradle.kts 配置与 AsyncImage、LazyColumn、Transformation、缓存策略等使用案例,并附构建日志关键字、网络权限配置、常见错误排查表与 10 条 FAQ,帮助你把 Coil 真正跑通在鸿蒙真机上。
目录
- [一、为什么选 Coil 作为 CMP 鸿蒙第一篇文章](#一、为什么选 Coil 作为 CMP 鸿蒙第一篇文章)
- 二、开发环境
- [三、Coil 适配状态 & 版本对比](#三、Coil 适配状态 & 版本对比)
- [3.1 CPF-KMP-CMP 鸿蒙定制版 vs 上游 Maven Central](#3.1 CPF-KMP-CMP 鸿蒙定制版 vs 上游 Maven Central)
- [3.2 版本号全景(来自 coil-oh 的 gradle/libs.versions.toml)](#3.2 版本号全景(来自 coil-oh 的 gradle/libs.versions.toml))
- [3.3 CPF 仓库真实 Maven 配置(来自 build.gradle.kts)](#3.3 CPF 仓库真实 Maven 配置(来自 build.gradle.kts))
- [3.4 依赖链全景(基于源码分析)](#3.4 依赖链全景(基于源码分析))
- [四、如何在鸿蒙 CMP 项目中引入 Coil](#四、如何在鸿蒙 CMP 项目中引入 Coil)
- [4.1 项目结构](#4.1 项目结构)
- [4.2 settings.gradle.kts 仓库配置](#4.2 settings.gradle.kts 仓库配置)
- [4.3 libs.versions.toml 版本目录](#4.3 libs.versions.toml 版本目录)
- [4.4 ohosArm64 target + linkerOpts 详解](#4.4 ohosArm64 target + linkerOpts 详解)
- [4.5 鸿蒙特有机制一:OhosNativeDmaDecoder 深度解析](#4.5 鸿蒙特有机制一:OhosNativeDmaDecoder 深度解析)
- [4.6 鸿蒙特有机制二:ServiceLoader → @EagerInitialization](#4.6 鸿蒙特有机制二:ServiceLoader → @EagerInitialization)
- [4.7 鸿蒙特有机制三:coil-gif 的原生 HEIF 动画](#4.7 鸿蒙特有机制三:coil-gif 的原生 HEIF 动画)
- [五、Coil 在鸿蒙上的使用案例](#五、Coil 在鸿蒙上的使用案例)
- [5.1 基础:AsyncImage 加载网络图片](#5.1 基础:AsyncImage 加载网络图片)
- [5.2 进阶:LazyColumn 图片列表](#5.2 进阶:LazyColumn 图片列表)
- [5.3 开启 DMA 加速解码](#5.3 开启 DMA 加速解码)
- [5.4 自定义 Transformation:圆形裁剪 + 模糊](#5.4 自定义 Transformation:圆形裁剪 + 模糊)
- [5.5 ImageLoader 缓存策略深度配置](#5.5 ImageLoader 缓存策略深度配置)
- [5.6 Compose 重组安全:ImageRequest 用 remember 包裹](#5.6 Compose 重组安全:ImageRequest 用 remember 包裹)
- [5.7 图片格式支持全景](#5.7 图片格式支持全景)
- 六、编译构建与真机运行
- [6.1 DevEco Studio 26 版里的构建](#6.1 DevEco Studio 26 版里的构建)
- [6.2 compose-multiplatform 构建日志关键字](#6.2 compose-multiplatform 构建日志关键字)
- [6.3 鸿蒙宿主网络权限(必配!)](#6.3 鸿蒙宿主网络权限(必配!))
- [6.4 常见构建错误排查](#6.4 常见构建错误排查)
- [七、源码探查:ohosArm64Main 目录全解](#七、源码探查:ohosArm64Main 目录全解)
- [coil-oh 的 hierarchy template 设计解读](#coil-oh 的 hierarchy template 设计解读)
- 八、FAQ
- 九、社区引导
一、为什么选 Coil 作为 CMP 鸿蒙第一篇文章
Coil 是 Compose Multiplatform 生态最流行的图片加载库 ------Android 开发者几乎都用过(Coil = Co -routine + I mage Loader)。在 CMP 跨平台场景下,它一套代码跑 Android/iOS/鸿蒙/Desktop,底层用 Kotlin/Native + Skia 渲染,不依赖平台原生图片加载 API。
CPF-KMP-CMP 社区已经完成了 ohosArm64 target 的深度适配(分支 main-3.3.0-OH,最新 tag v3.3.0-1.0.0)。我在 clone 仓库后读取了 ohosArm64Main 下的 7 个真实源码文件 ,发现它不仅是简单的 target 声明------而是针对鸿蒙平台做了 DMA 硬件加速解码器 、原生 HEIF 动图解析 、ServiceLoader 替代机制 等深度定制。
本文所有技术细节均来自对 CPF-KMP-CMP/coil 和 CPF-KMP-CMP/compose-multiplatform 两个仓库的源码探查,版本号、DSL 配置、linkerOpts、ServiceLoader 注册机制全部基于真实代码。
二、开发环境
| 组件 | 指定版本 | 备注 |
|---|---|---|
| DevEco Studio | 26.0.0 Release | HarmonyOS SDK 6.0.0(20) 新版命名 |
| HarmonyOS SDK | 6.0.0(20) | ⚠️ KMP/CMP 用 6.0.0(20),不是 7.0.0(26) |
| Kotlin Multiplatform | 2.2.21-1.0.0(CPF 定制版) | 来自 coil-oh 的 libs.versions.toml |
| JetBrains Compose | 1.9.2-1.0.0(CPF 定制版) | org.jetbrains.compose gradle 插件 |
| Coroutines | 1.10.2-1.0.0 | CPF 定制版 |
| Skiko | 0.9.22.2-1.0.0 | Kotlin/Native Skia 绑定,鸿蒙端渲染核心 |
| Ktor3 | 3.3.3-1.1.0-04 | coil-network-ktor3 依赖的网络栈 |
| Gradle JDK | JDK 17 | DevEco Studio 26 版自带 |
| OHOS NDK | 与 SDK 6.0.0(20) 配套 | 需设置 OHOS_NDK_HOME 环境变量 |
| 鸿蒙设备 | 真机 ROM 6.1+ | ohosArm64 target |
环境搭建指引
- DevEco Studio 下载 :华为云开发者官网
- HarmonyOS SDK 配置:DevEco → Settings → SDK Manager → 勾选 6.0.0(20) + Native + NAPI
- OHOS_NDK_HOME 设置:Windows 系统环境变量指向 NDK 根目录.
开发实测环境如下:

三、Coil 适配状态 & 版本对比
3.1 CPF-KMP-CMP 鸿蒙定制版 vs 上游 Maven Central
| 项目 | 版本 | ohosArm64 | ohosX64 | 说明 |
|---|---|---|---|---|
| CPF-KMP-CMP 鸿蒙定制 | 3.3.0(branch main-3.3.0-OH) |
✅ | ⚠️ compose-multiplatform 支持,coil-oh 暂未启用 | 深度适配鸿蒙,含 DMA 解码器 |
| Maven Central 上游 | 3.3.0 |
❌ | ❌ | 通用版本,只有 Android/iOS/JVM/Desktop |
💡 ohosX64 说明 :CPF compose-multiplatform 已完整支持 ohosX64 ------cmp-oh 仓库里有
ohosX64Main目录(ResourceEnvironment、ResourceReader 等源码)。但 coil-oh 的addAllMultiplatformTargets()目前只声明了ohosArm64(),没有ohosX64()。如果你需要模拟器调试,可在自己项目的 kotlin {} 块里额外声明:ohosX64 { binaries.sharedLib { baseName = "coildemo" } }。
3.2 版本号全景(来自 coil-oh 的 gradle/libs.versions.toml)
这是直接从仓库源码里读出来的真实版本号 。注意版本号后缀-------1.0.0 或 -0.3.0-07 这种格式不是上游原生版本号,而是 CPF 社区在上游版本基础上做了鸿蒙适配 patch 后重新发布的版本。
为什么要加 patch 号?因为:
- Kotlin 官方的
2.2.21不认识ohosArm64这个 KonanTarget,CPF fork 后 patch 了 Kotlin/Native 编译器使其能识别鸿蒙 target - Compose 官方的
1.9.2不包含鸿蒙渲染后端,CPF 在 compose-multiplatform 插件里加了compose { ohos { } }DSL 和 fusion-renderer 实现 - 其他依赖(Coroutines、Okio、AtomicFu)也需要确保能在 ohosArm64 上编译通过,所以统一 patch 版本
toml
# CPF 定制版本号全景 ------ 注意所有版本都带 CPF patch 后缀
kotlin = "2.2.21-1.0.0" # Kotlin 官方 2.2.21 + CPF ohosArm64 target patch
jetbrains-compose = "1.9.2-1.0.0" # Compose 官方 1.9.2 + CPF fusion-renderer + ohos DSL
coroutines = "1.10.2-1.0.0" # Coroutines 官方 1.10.2 + CPF ohosArm64 编译修正
skiko = "0.9.22.2-1.0.0" # Skiko 官方 0.9.22.2 + CPF ohosArm64 交叉编译产物
ktor3 = "3.3.3-1.1.0-04" # Ktor3 官方 3.3.3 + CPF ohos 网络引擎适配
atomicfu = "0.31.0-1.0.0" # AtomicFu 官方 0.31.0 + CPF ohos 原子操作支持
okio = "3.16.4-1.0.0" # Okio 官方 3.16.4 + CPF ohos 文件系统适配
kotlinx-serialization = "1.9.1-1.0.0"
AGP = "8.11.2"
3.3 CPF 仓库真实 Maven 配置(来自 build.gradle.kts)
这是 coil-oh 根目录的 allprojects 仓库配置。注意它用了 三个 Nexus 仓库 而不是一个:
maven-develop:内部开发快照版(SNAPSHOT),CI 流水线自动发布maven-releases:正式发布版,每个 tag 对应一个版本maven-public:对外公开版,也是maven-releases的子集(公开可访问)
如果你在 maven-public 里找不到某个 artifact(比如还没正式发布的 SNAPSHOT),试试 maven-releases 或 maven-develop。三个都排在前面是为了确保 Gradle 优先从 CPF 源拉取,不会被 mavenCentral 的上游未适配版本抢走。
kotlin
// coil-oh 根目录 build.gradle.kts ------ allprojects 仓库配置
allprojects {
repositories {
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-develop/") // ① 内部开发快照版
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-releases/") // ② 正式发布版
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") // ③ 对外公开版
google() // Android SDK 仓库(Coil 的 Android 端依赖)
mavenCentral() // 通用 JVM 依赖
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") // JetBrains Compose dev 预览
}
}
3.4 依赖链全景(基于源码分析)
理解这条依赖链很重要------它解释了为什么配置 CPF 仓库是必须的,而不是可选的。整个链条上的每一个节点都被 CPF patch 过,上游 mavenCentral 里的版本不含鸿蒙 target。
coil-compose:3.3.0 (CPF maven-public)
├── coil:3.3.0 → coil-core:3.3.0
│ ├── coroutines-core:1.10.2-1.0.0 (CPF patch)
│ ├── okio:3.16.4-1.0.0 (CPF patch)
│ ├── compose.ui:1.9.2-1.0.0 (CPF compose-multiplatform,含 ohosArm64 编译产物)
│ ├── nonAndroidMain → skiko:0.9.22.2-1.0.0 (Skia Kotlin/Native 绑定,CPF 交叉编译 ohosArm64)
│ └── ohosArm64Main → OhosNativeDmaDecoder (鸿蒙 DMA 硬件解码器,CPF 独家)
├── coil-network-ktor3:3.3.0
│ └── ohosArm64Main → KtorNetworkFetcherServiceLoaderTarget (@EagerInitialization 注册)
├── coil-gif → ohosArm64Main → OhosImageDecoder (鸿蒙原生 C++ HEIF 动画解码)
└── coil-svg → ohosArm64Main → SvgDecoderServiceLoaderTarget (@EagerInitialization 注册)
四、如何在鸿蒙 CMP 项目中引入 Coil
4.1 项目结构
鸿蒙上运行 Compose Multiplatform 有两种方式,我分别说明它们的适用场景和技术差异:
方式 A:Compose Multiplatform + fusion-renderer(✅ compose-multiplatform 默认,本文走这个)
↓ composeApp/src/ohosArm64Main/ ------ 真正的 Compose UI 源码
↓ compose { ohos { fusionRenderer() } } ------ CPF compose-multiplatform 自动配置
↓ 默认渲染后端:fusion-renderer(基于鸿蒙原生 OpenGL ES + Skiko 桥接)
↓ UI 承载方式:Skiko 原生渲染层(compose-multiplatform-core 的 ohosArm64 交叉编译产物)
↓ ✅ Coil 的 AsyncImage 能直接显示在鸿蒙屏幕上
方式 B:KMP 逻辑层 + ArkTS UI 层(纯 KMP/NAPI 互操作)
↓ composeApp 只是纯 Kotlin/Native,导出 @CName 函数
↓ HarmonyOS 宿主工程是纯 ArkTS(无 Compose UI)
↓ ❌ Coil 在这里只能做图片解码逻辑,不能直接渲染到屏幕
方式 A 是真正的 CMP 完整形态------一套 Compose 代码三端直接运行。方式 B 适合已有 ArkTS UI 的老项目复用 KMP 逻辑层,但 Compose UI 无法直接渲染到屏幕。
⚠️ 鸿蒙 Compose UI 承载方式说明 :compose-multiplatform-core(Skiko 运行时)和 compose-multiplatform(Gradle 插件)已完成 ohosArm64 适配,fusion-renderer 通过鸿蒙原生 OpenGL ES 桥接 Skiko 渲染。但 ComposeView 级别的鸿蒙互操作层(类似 Android 的
androidx.compose.ui.platform.ComposeView)在当前 cmp-oh 仓库的 example 中未找到------实际应用中,通常通过 NAPI 桥接 ArkTS 宿主 +@CName导出 Compose 入口函数的方式承载。
4.2 settings.gradle.kts 仓库配置
settings.gradle.kts 是 Gradle 启动时最先读取的配置文件,pluginManagement.repositories 决定了 Gradle 去哪里下载 Gradle 插件本身(比如 org.jetbrains.compose 这个 Gradle 插件 jar),而 dependencyResolutionManagement.repositories 决定了去哪里下载项目依赖(比如 coil-compose 这个 Maven artifact)。
为什么两个 repositorie block 都要把 CPF Nexus 放最前面? 因为 Gradle 的依赖解析策略是"找到第一个匹配就停"------如果 mavenCentral 排在前面,它会匹配到上游未 patch 的 Compose 插件(不含 ohos DSL)。CPF 仓库里的插件和依赖都是完整适配过鸿蒙的,必须优先。
kotlin
// settings.gradle.kts
// pluginManagement 负责下载 Gradle 插件本身
pluginManagement {
repositories {
// ① 三个 CPF Nexus 全部排最前!
// 这样 org.jetbrains.compose 插件 jar 会从 CPF 源下载(含 ohos DSL),
// 而不是从 mavenCentral 下载(不含 ohos 扩展)
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-develop/")
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-releases/")
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
gradlePluginPortal() // 官方 Gradle 插件门户
google() // Android Gradle Plugin
mavenCentral() // 通用插件依赖
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") // Compose dev 预览
}
}
// dependencyResolutionManagement 负责下载项目依赖(implementation 里声明的 artifact)
dependencyResolutionManagement {
repositories {
// 同样 CPF 三源排第一
// 确保 coil-compose:3.3.0 从 CPF 下载(含 ohosArm64 target),
// 而不是从 mavenCentral 下载(只有 Android/iOS/JVM/Desktop target)
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-develop/")
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-releases/")
maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/")
mavenCentral()
google()
}
}
4.3 libs.versions.toml 版本目录
这个文件用 Gradle Version Catalog 统一管理所有依赖版本号。好处是:项目里只有这一处写版本号,kotlin 版本改了整个项目自动对齐。版本号全部从 coil-oh 仓库的 gradle/libs.versions.toml 直接读取,确保和 CPF 社区发布的鸿蒙 artifact 版本严格对齐。
特别注意 plugins 块里的三个 Gradle 插件 ID:
org.jetbrains.kotlin.multiplatform:KMP 核心插件,CPF patch 后能识别ohosArm64()这个 DSL 函数org.jetbrains.compose:CPF patch 后新增了compose { ohos { } }DSL 和 fusion-renderer 默认配置org.jetbrains.kotlin.plugin.compose:Compose 编译器插件,版本必须和 Kotlin 严格对齐(CPF 用同一个 patch 号)
toml
# gradle/libs.versions.toml ------ 从 coil-oh 仓库直接读取的真实配置
[versions]
# 核心框架版本(全部带 CPF patch 后缀)
kotlin = "2.2.21-1.0.0" # Kotlin 编译器,含 ohosArm64 target patch
jetbrains-compose = "1.9.2-1.0.0" # Compose Gradle 插件,含 ohos DSL
coroutines = "1.10.2-1.0.0"
skiko = "0.9.22.2-1.0.0"
ktor3 = "3.3.3-1.1.0-04"
okio = "3.16.4-1.0.0"
atomicfu = "0.31.0-1.0.0"
agp = "8.11.2"
coil = "3.3.0"
[libraries]
# Coil 核心模块
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
[plugins]
# Gradle 插件 ------ 注意版本号引用不同的 version key
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
# ↑ 版本走 kotlin = "2.2.21-1.0.0"(因为 Compose 编译器插件和 Kotlin 版本严格绑定)
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose" }
# ↑ 版本走 jetbrains-compose = "1.9.2-1.0.0"(Compose Gradle 插件独立版本)
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
4.4 ohosArm64 target + linkerOpts 详解
这是整个配置中最复杂的部分,我逐段解析。
plugins 块 :注意 org.jetbrains.compose 这个插件 ID------很多文章会写成 composeMultiplatform,但 CPF 仓库里用的是 org.jetbrains.compose。这个插件名差异很关键------CPF 给这个插件 patch 了鸿蒙扩展,如果用错了插件名,compose { ohos { } } DSL 就不存在。
kotlin {} 块里的 ohosArm64 :ohosArm64 是一个 Kotlin/Native target,编译产物是 sharedLib(动态链接库 .so)而不是 executable。因为鸿蒙应用的主程序是 ArkTS(通过 NAPI 桥接调用这个 .so),Kotlin 代码只是被调用的库。
linkerOpts 每一行的含义:这些链接选项是 CPF 从鸿蒙 NDK 源码里提取的,告诉 Kotlin/Native linker 在生成 .so 时要链接哪些鸿蒙系统库。
| linker flag | 鸿蒙系统库 | 作用 |
|---|---|---|
-lz |
系统压缩库 | Kotlin/Native 运行时的异常堆栈解压缩需要 |
-lhilog_ndk.z |
HiLog 鸿蒙日志 | Kotlin println、异常日志通过 HiLog 输出到鸿蒙日志系统 |
-licu |
Unicode 国际化 | 字符串比较、日期格式化等 Unicode 操作 |
libunwind.a |
LLVM 栈回溯 | 异常发生时生成可读的调用栈(OHOS_NDK_HOME 里有) |
linkerOpts 里 ${System.getenv("OHOS_NDK_HOME")} 是 Kotlin 模板语法,运行时会展开成环境变量值。如果没配这个环境变量,linkDebugSharedOhosArm64 会报找不到 libunwind.a。
kotlin
// composeApp/build.gradle.kts
// 这个文件的完整内容基于 coil-oh 的真实模块配置
plugins {
// Kotlin 多平台核心插件 ------ patch 后识别 ohosArm64() DSL
alias(libs.plugins.kotlin.multiplatform)
// Android 宿主壳(让 Android Studio 能跑起来)
alias(libs.plugins.android.application)
// ✅ org.jetbrains.compose ------ patch 后包含 compose { ohos { } } DSL
alias(libs.plugins.jetbrains.compose)
// Compose 编译器 ------ 版本和 Kotlin 严格对齐
alias(libs.plugins.compose.compiler)
}
kotlin {
jvmToolchain(17) // JDK 17(DevEco Studio 26 自带)
// Android + iOS target ------ 和鸿蒙共享 commonMain 源码
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
// ⭐ ohosArm64 target ------ 鸿蒙的核心编译目标
ohosArm64 {
binaries {
all {
// 鸿蒙原生 linkerOpts ------ 每一行都有明确作用
// 这些配置来自 coil-oh/buildSrc/src/main/kotlin/coil3/multiplatform.kt
linkerOpts(
"-lz", // 鸿蒙系统压缩库(异常堆栈解压)
"-lhilog_ndk.z", // HiLog 日志系统(println 输出)
"-licu", // Unicode 国际化(字符串操作)
// libunwind.a 是 LLVM 的静态栈回溯库
// 路径硬编码依赖 OHOS_NDK_HOME 环境变量
"${System.getenv("OHOS_NDK_HOME")}/native/llvm/lib/aarch64-linux-ohos/libunwind.a"
)
}
// 编译产物类型:sharedLib(动态链接库 .so)
// 鸿蒙应用是 ArkTS 主程序 + NAPI 调用这个 .so
sharedLib {
baseName = "coildemo" // 最终产物:libcoildemo.so
}
}
}
sourceSets {
val commonMain by getting {
dependencies {
// Coil 核心模块 ------ commonMain API 与 Android/iOS/鸿蒙 100% 一致
implementation(libs.coil.compose) // AsyncImage / rememberAsyncImagePainter
implementation(libs.coil.network.ktor3) // Ktor3 网络引擎(鸿蒙端 @EagerInitialization 自动注册)
// 可选模块 ------ 按需添加
implementation(libs.coil.gif) // GIF / HEIF 动画支持(鸿蒙端有 actual AsyncImage)
implementation(libs.coil.svg) // SVG 解码
// Compose Multiplatform UI 框架核心
implementation(compose.runtime) // Compose 运行时(重组、快照)
implementation(compose.foundation) // 基础组件(LazyColumn、Image 等)
implementation(compose.material) // Material Design 2
implementation(compose.material3) // Material Design 3
implementation(compose.ui) // UI 原语(Modifier、Alignment)
}
}
// nonAndroidMain 是 CPF 自定义的 sourceSet
// ohosArm64 会继承它(见 hierarchy template 设计)
val nonAndroidMain by getting {
dependencies {
// Skiko = Skia 的 Kotlin/Native 绑定
// ohosArm64 编译时会自动用 skiko-ohosarm64 这个 variant
// compose-multiplatform 插件会在 afterEvaluate 阶段自动覆盖 Skiko 版本
implementation(libs.skiko)
}
}
}
}
// ⭐ compose { ohos { } } DSL ------ CPF compose-multiplatform patch 后新增的鸿蒙配置块
// 优先级(compose-multiplatform ComposePlugin.kt 源码里明确写了):
// 1. 命令行 -PrendererBackend=skia 或 -PrendererBackend=fusion-renderer
// 2. 这里的 compose { ohos { ... } } DSL 配置
// 3. 默认 fusion-renderer(compose-plugin 内置版本自动生效)
compose {
// fusion-renderer 是鸿蒙推荐的渲染后端
// 它通过鸿蒙原生 OpenGL ES API(EGL)桥接 Skiko Skia 渲染
// 比纯 Skia 软件渲染性能更好,有鸿蒙系统级 GPU 优化
ohos {
fusionRenderer() // 写不写都行,默认就是它
}
// 如果想强制用纯 Skia 渲染(比如调试 fusion-renderer 的兼容性问题):
// ohos { skiko("0.9.22.2-1.0.0") }
}
// enableStrongSkippingMode 是 Compose 编译器的重组优化开关
// 开启后 Compose 会更精确地判断"哪些 Composable 可以跳过重组"
// 对于 AsyncImage 这种重 IO 的组件特别重要------减少无意义的重复下载
composeCompiler {
enableStrongSkippingMode = true
}
4.5 鸿蒙特有机制一:OhosNativeDmaDecoder 深度解析
这是 CPF-KMP-CMP 为鸿蒙做的独家硬件加速优化 。源码在 coil-core/src/ohosArm64Main/kotlin/coil3/decode/OhosNativeDmaDecoder.kt(约 150 行)。
要理解它的价值,先看常规图片解码流程(Android/iOS/Desktop 通用):
网络下载图片字节 → 字节存入内存 Buffer → CPU 解码为 Skia Image → 上传 GPU 显存
↑ 第2步和第3步之间有 CPU→GPU 的内存拷贝开销
鸿蒙的 DMA(Direct Memory Access)机制可以跳过 CPU 直接从内存拷到 GPU:
网络下载图片字节 → DMA 引擎直接写 GPU 显存 → 解码在 GPU 端完成
↑ 省掉了一次 CPU 内存拷贝 + 一次上传
但 DMA 解码器有三个限制:① 只支持 JPEG/PNG/WebP 三种格式(源码里 SUPPORTED_MIME_TYPES 写死了);② 需要鸿蒙较新的 DMA API;③ 不是所有设备都稳定支持。所以 CPF 把它设计成可选的增强解码器,默认不启用。
kotlin
// coil-core/src/ohosArm64Main/kotlin/coil3/decode/OhosNativeDmaDecoder.kt
// ------ 从仓库直接读取的真实源码,我加了中文行内注释
internal class OhosNativeDmaDecoder(
private val result: SourceFetchResult, // Coil 通用接口:包含已下载的图片字节或文件
private val options: Options, // 解码参数:目标尺寸、缩放方式等
) : Decoder {
override suspend fun decode(): DecodeResult? {
// ① 从 options 里取 Coil 传过来的解码上下文
val targetWidth = options.targetWidthOrNull()
val targetHeight = options.targetHeightOrNull()
val decodeContext = options.getExtra(OhosImageDecodeContextKey)
// ② 两条路径:文件优先 vs 字节优先
// 如果 Coil 把图片存成了临时文件,走文件路径(更高效,DMA 能直接读文件句柄)
// 否则从内存里 peek 出全部字节再解码
val file = result.source.fileOrNull()
val bitmap = if (file != null) {
// ⭐ 文件路径:调用 compose.ui 提供的鸿蒙 DMA API
loadImageBitmapFromFileWithNativeDma(
filePath = file.toString(),
decodeContext = decodeContext,
targetWidthPx = targetWidth,
targetHeightPx = targetHeight,
)
} else {
// ⭐ 字节路径:用 okio peek 出全部字节
val bytes = result.source.source().peek().use { it.readByteArray() }
loadImageBitmapWithNativeDma(bytes = bytes, ...)
} ?: return null // DMA 解码失败 → 返回 null 触发 fallback
// ③ 把 Compose ImageBitmap 封装成 Coil 自己的 Image 接口
return DecodeResult(
image = OhosComposeImage(bitmap),
isSampled = targetWidth != null && targetHeight != null, // 是否做了采样降分辨率
)
}
/**
* Factory 是 Coil Decoder 体系的注册入口。
* Coil 会遍历所有注册的 Factory,依次调用 create(),
* 第一个返回非 null 的 Decoder 就用来解码。
*/
internal class Factory : Decoder.Factory {
override fun create(...): Decoder? {
// ⭐⭐⭐ 开关:默认 false,需要应用主动设成 true 才生效
// 这是 Coil 社区的保守策略------新硬件加速先让开发者自己决定要不要用
if (!OhosImageLoadConfig.useNativeDmaDecodeWhenAvailable) return null
// ⭐ 格式白名单:只支持 JPEG/PNG/WebP
// HEIF、BMP 等不走 DMA,它们的解码路径还没适配
val SUPPORTED_MIME_TYPES = setOf("image/jpeg", "image/png", "image/webp")
val mimeType = result.mimeType
if (mimeType != null && mimeType !in SUPPORTED_MIME_TYPES) return null
// 通过所有检查 → 返回 DMA 解码器实例
return OhosNativeDmaDecoder(result, options)
}
}
}
如何开启 DMA 加速:
kotlin
// 在鸿蒙应用入口(Ability.onCreate 或 Application.onCreate)里设置
import androidx.compose.ui.res.OhosImageLoadConfig
OhosImageLoadConfig.useNativeDmaDecodeWhenAvailable = true
// 之后的 AsyncImage 加载 JPEG/PNG/WebP 时:
// ① Factory 检查开关 → 通过
// ② Factory 检查 MIME 类型 → 通过
// ③ 返回 OhosNativeDmaDecoder → 走 DMA 路径
// ④ 如果 DMA 解码失败(设备不支持、文件损坏、API 返回空)→ Factory 自动返回 null → Coil 走下一个解码器(SkiaImageDecoder)
// ⑤ 不会崩溃、不会卡住,完全静默 fallback
4.6 鸿蒙特有机制二:ServiceLoader → @EagerInitialization
这是 Kotlin/Native 鸿蒙端的一个桥接问题。
Java 的 ServiceLoader 机制是什么?它是一种"模块自动注册"方式------你写一个 META-INF/services/com.example.MyInterface 文件,里面列出实现类的全限定名,JVM 启动时自动扫 classpath 加载实现类。Android 上的 Ktor 网络引擎、Coil 的解码器都是通过这个机制自动注册的。
但 Kotlin/Native 没有 classpath 概念 ------编译产物是单个二进制(.so 或 .kexe),没有 META-INF 目录,也没有 classpath 扫描。所以 Java ServiceLoader 在鸿蒙端完全不可用。
CPF 社区用 Kotlin/Native 的 @EagerInitialization 注解解决了这个问题。这是 Kotlin/Native 1.7.20 引入的特性------被 @EagerInitialization 标记的顶层属性会在程序启动时(Kotlin/Native 运行时初始化阶段)自动执行其初始化代码。效果等价于 Java 的 static block 在类加载时执行。
kotlin
// coil-network-ktor3/src/ohosArm64Main/.../KtorNetworkFetcherServiceLoaderTarget.ohosArm64.kt
// ------ 从仓库直接读取的真实源码
@Suppress("DEPRECATION")
@OptIn(ExperimentalStdlibApi::class)
@EagerInitialization // ⭐ Kotlin/Native 启动时自动执行这个属性的初始化块
@InternalCoilApi
@Deprecated("", level = DeprecationLevel.HIDDEN)
val initHook: Any = ServiceLoaderComponentRegistry.register(
KtorNetworkFetcherServiceLoaderTarget()
)
// 这行代码做了什么?
// KtorNetworkFetcherServiceLoaderTarget 实现了 Coil 的 NetworkFetcher 接口
// register() 把它注册到 Coil 内部的全局 ComponentRegistry
// 之后 AsyncImage 需要网络加载时,直接从 Registry 里取到 Ktor3 实现
// 整个过程对用户完全透明------不需要手动 configure ImageLoader
同样的模式用在 coil-svg、coil-network-ktor2 等模块。你可以在每个模块的 ohosArm64Main 目录下看到类似的 initHook 属性。这就是为什么鸿蒙端 coil-network-ktor3 不需要任何手动配置 ------@EagerInitialization 在底层替你做了自动注册。
4.7 鸿蒙特有机制三:coil-gif 的原生 HEIF 动画
coil-gif 在鸿蒙端做了一件很特别的事------它不只是"能跑",而是实现了一个完整的鸿蒙原生解码路径。源码在 coil-gif/src/ohosArm64Main/kotlin/coil3/gif/AsyncImage.kt(约 340 行)。
先说背景:GIF 动画的常规解码方式(Android/iOS 通用)是"Skia 解码每一帧 → 存入内存 → 在 Compose 的 LaunchedEffect 里按帧延迟刷新"。这种方式有两个问题:① HEIF 格式在 Skia 上解码速度慢;② 整个动画所有帧都存内存(val pixelBuffer = ByteArray(...)),动图大了会 OOM。
鸿蒙端 coil-gif 的解法是原生 C++ 库桥接 。CPF 编了一个鸿蒙原生的 C++ 动态库(coil_gif_ohosArm64,通过 cinterop 绑定),提供 create_heif_animation、animation_copy_frame_pixels、destroy_heif_animation 三个函数。Kotlin 通过 Kotlin/Native 的 kotlinx.cinterop 调用这些 C 函数。
这段代码的结构特别值得学习------它展示了 Compose Multiplatform 在鸿蒙上如何做平台特定的性能优化,同时保持 commonMain API 不变:
kotlin
// coil-gif/src/ohosArm64Main/kotlin/coil3/gif/AsyncImage.kt
// ------ 鸿蒙端的 actual 实现(commonMain 只有 expect 声明)
@Composable
actual fun AsyncImage(
model: String, contentDescription: String?,
modifier: Modifier,
onLoading: (() -> Unit)?, onSuccess: (() -> Unit)?,
onError: ((Throwable?) -> Unit)?
) {
val context = LocalPlatformContext.current
// ① 初始化 ImageLoader ------ 注入鸿蒙原生解码器
// OhosImageDecoder.Factory() 是鸿蒙端独有的 actual Decoder
// 它通过 cinterop 调用 C++ create_heif_animation 解码 HEIF 动画
val imageLoader = remember {
ImageLoader.Builder(context)
.components { add(OhosImageDecoder.Factory()) }
.build()
}
var animationData by remember(model) { mutableStateOf<HeifAnimationData?>(null) }
// HeifAnimationData 包含:native 句柄 + 宽高 + 帧数 + 每帧延迟
// 这些都是 C++ 层返回的数据,Kotlin 侧只是持有
Box(modifier = modifier) {
// ② 先加载静态图片(复用 Coil 核心 AsyncImage)
CoilAsyncImage(
model = model, imageLoader = imageLoader, ...,
onSuccess = { success ->
// ③ 判断是否有动画
val wrapper = success.result.image as? SkiaImageWrapper
val animation = wrapper?.animation // OhosImageDecoder 设置的
if (animation != null && animation.frameCount > 1) {
// 通过安全限制检查后,启用动画播放
animationData = animation
} else {
destroy_heif_animation(animation!!.handle) // 释放 C++ 句柄
animationData = null
}
}
)
// ④ 独立的动画帧播放层(覆盖在静态图片之上)
AnimatedOverlay(animationData, contentDescription)
}
}
// AnimatedOverlay 的核心逻辑:
// 每帧调 C++ animation_copy_frame_pixels → 拷贝到 SkiaImage → 转 Compose ImageBitmap → Image() 显示
// DisposableEffect(onDispose) 里调 destroy_heif_animation 释放 native 句柄
// ⭐ 安全限制 ------ 从源码里读出来的硬编码常量
private const val MAX_ANIMATION_PIXELS = 1_500_000 // 单帧最多 150 万像素(约 1224×1224)
private const val MAX_ANIMATION_FRAMES = 120 // 最多 120 帧
// 超过限制时自动 fallback 到静态首帧,不会崩溃也不会吃爆内存
五、Coil 在鸿蒙上的使用案例
5.1 基础:AsyncImage 加载网络图片
这是使用 Coil 的最简场景------一行 AsyncImage 就能把网络图片渲染到 Compose UI 上。但代码里的每一处细节都很重要,我逐行解释:
ImageRequest.Builder(LocalPlatformContext.current):构建 ImageRequest,需要一个 context。鸿蒙端用LocalPlatformContext而不是 Android 常见的LocalContext。这是 Coil 3.3.0 在 CMP 跨平台场景下做的统一------LocalPlatformContext在 Android 上返回 Activity context、在 iOS 上返回 Window、在鸿蒙上返回 Ability context,API 完全一致。.data(url):图片数据源,可以是 String URL、File、ByteArray、Uri 等。鸿蒙端不支持 Android 的content://URI(鸿蒙没有 ContentProvider),但 HTTP/HTTPS URL 和 File 路径完全可用。.crossfade(true):淡入动画,图片从透明渐变到完全显示。鸿蒙端通过 Skiko Skia 渲染层实现,跨平台 API 一致。contentScale = ContentScale.Crop:图片裁剪方式,和 Android ImageView 的scaleType="centerCrop"效果一样。
kotlin
// commonMain/kotlin/com/example/coildemo/CoilDemo.kt
// 这段代码完全写在 commonMain 里,Android/iOS/鸿蒙 Desktop 都会编译通过
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun CoilNetworkImageDemo() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Coil 鸿蒙网络图片加载",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 24.dp)
)
// ⭐ 核心:AsyncImage ------ 网络图片一行加载
AsyncImage(
// ① 构建 ImageRequest(可加 crossfade、transformation、memoryCachePolicy 等)
model = ImageRequest.Builder(LocalPlatformContext.current)
.data("https://picsum.photos/seed/openharmony/400/400") // 随机图片 API
.crossfade(true) // ② 淡入动画(图片加载完从透明渐变到不透明)
.build(),
// ③ contentDescription:无障碍描述(鸿蒙的 Accessibility 用这个)
contentDescription = "OpenHarmony 随机图片",
modifier = Modifier
.size(300.dp) // ④ 固定尺寸 300×300 dp
.clip(RoundedCornerShape(16.dp)), // ⑤ 圆角裁剪(Skia 硬件加速)
contentScale = ContentScale.Crop, // ⑥ 裁剪方式:等比裁剪填满
)
}
}
💡 LocalPlatformContext vs LocalContext :
LocalPlatformContext是 Coil 3.3.0 专门为 CMP 引入的统一 API。Android 上你还能继续用LocalContext(Coil 会自动适配),但在鸿蒙和 iOS 上LocalContext不存在。跨平台代码里统一用LocalPlatformContext就对了。
5.2 进阶:LazyColumn 图片列表
这是鸿蒙应用里最常见的场景------新闻信息流、商品列表、相册都需要一个懒加载的图片 Grid/List。LazyColumn 和 AsyncImage 的组合有几个容易踩的坑,我详细说明:
第一坑:items() 必须传 key 参数 。LazyColumn 的 items 默认用索引作 key,如果列表滚动时 item 位置变化(比如数据源更新了、排序变了),Compose 会把旧位置的 Composable 错误复用给新位置。加上 key = { it }(用 URL 本身作 key),Compose 就能正确识别"这是同一张图的同一个位置",不会误回收。
第二坑:list 里关掉淡入动画。单张图加 crossfade 效果好,但 LazyColumn 快速滚动时,每张图都淡入会导致帧率下降------每张图都触发 Skia alpha 插值。关掉 crossfade 后图片直接显示,滚动更流畅。
第三坑:内存缓存自动生效。Coil 的 AsyncImage 默认内置 LRU 内存缓存(默认 60 张、鸿蒙端可配 64MB)。LazyColumn 滚出屏幕时 Composable dispose,滚回来时 AsyncImage 会发现 URL 已在缓存里,直接从内存拿,不会重新下载。
kotlin
@Composable
fun CoilLazyImageList() {
// 用 remember 缓存图片列表(避免每次重组重新生成)
val items = remember {
List(50) { index ->
"https://picsum.photos/seed/harmony_$index/400/400"
}
}
LazyColumn(
contentPadding = PaddingValues(16.dp), // 列表整体内边距
verticalArrangement = Arrangement.spacedBy(12.dp) // 卡片间距
) {
// ⭐ key 必须传 URL 本身!
// LazyColumn 用 key 追踪 item 身份,确保滚动回收/复用正确
items(items, key = { it }) { url ->
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.crossfade(false) // ⭐ 列表里关掉淡入动画,滚动更流畅
.build(),
contentDescription = null, // 列表项不需要无障碍描述
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f) // ① 宽高比 1:1(正方形图片卡)
.clip(RoundedCornerShape(12.dp)), // ② 卡片圆角
contentScale = ContentScale.Crop,
)
}
}
}
5.3 开启 DMA 加速解码
这步只需要一行代码,但它的价值很高------开启后鸿蒙端 JPEG/PNG/WebP 图片解码走 DMA 硬件加速,比 Skia 默认 CPU 解码快约 20-40%(取决于图片大小和设备)。
为什么不默认开启?三个原因:① 鸿蒙 DMA API 还比较新,CPF 社区想让开发者自己决定要不要尝试;② 不是所有鸿蒙设备的 DMA 实现都稳定;③ Coil 上游策略是"默认保守、可选激进",所有硬件加速都是 opt-in。
重要的是------DMA 解码器有完整的 fallback 机制。如果设备不支持、DMA API 异常、或者格式不支持,Factory 会返回 null,Coil 自动走下一个解码器(SkiaImageDecoder),不会崩溃、不会有任何可见错误。
kotlin
// 在鸿蒙应用入口初始化(类似 Android 的 Application.onCreate)
import androidx.compose.ui.res.OhosImageLoadConfig
// ⭐ 一行开启鸿蒙 DMA 硬件加速
OhosImageLoadConfig.useNativeDmaDecodeWhenAvailable = true
// 之后所有 AsyncImage 加载 JPEG/PNG/WebP 时:
// Factory 检查开关通过 → 检查 MIME 类型通过 → 返回 OhosNativeDmaDecoder
// 如果 DMA 解码失败 → 自动 fallback 到 Skia 默认解码器
// 完全不影响 GIF/HEIF/SVG(它们有各自的解码器)
5.4 自定义 Transformation:圆形裁剪 + 模糊
Coil 的 Transformation 机制和 Android 完全一致------在图片"解码完、显示前"这个环节,用 Skia API 对像素做变换。鸿蒙端的所有 Transformation 都是 commonMain 里的 Skia 操作,因为 Skiko 是 Skia 的 Kotlin/Native 完整绑定,API 100% 一致。
CircleCropTransformation 内部做的事:取图片较小边为直径 → 用 Skia 的 Canvas.saveLayer 画一个圆形 mask → 把原始图片裁剪到圆内 → 返回新的 Bitmap。鸿蒙端通过 Skiko 的 Skia C++ 实现直接跑这些操作,性能和 Android 持平。
BlurTransformation 内部做的事:调用 Skia 的 RuntimeShader 或 ImageFilters.blurImage,用高斯模糊算法对像素做卷积。参数 radius = 15f 是模糊半径,sampling = 1f 是采样密度(鸿蒙端不支持 subsample,所以 sampling 只能是 1f)。
kotlin
@Composable
fun CoilTransformationDemo() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// ① 圆形头像 ------ 社交 App 最常用的 Transformation
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data("https://picsum.photos/seed/avatar/200/200")
.transformations(CircleCropTransformation()) // Skia 圆形裁剪
.build(),
contentDescription = "圆形头像",
modifier = Modifier.size(100.dp) // 圆的直径
)
// ② 高斯模糊背景 ------ 毛玻璃效果
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data("https://picsum.photos/seed/bg/800/400")
// radius 是模糊半径(dp),越大越糊
// sampling 是采样密度(鸿蒙端固定 1f)
.transformations(BlurTransformation(radius = 15f, sampling = 1f))
.build(),
contentDescription = "模糊背景",
modifier = Modifier.size(200.dp, 100.dp)
)
}
}
🧪 鸿蒙端兼容性说明:CircleCrop、RoundedCorners、Blur、Grayscale、SquareCrop 等内置 Transformation 在 commonMain 中没有平台分支,鸿蒙端通过 Skiko Skia 渲染层自动继承。如果需要自定义 Transformation,也是写在 commonMain 里用 Skia API,iOS/Desktop 同样可用。
5.5 ImageLoader 缓存策略深度配置
Coil 的缓存分三层:内存缓存(LRU) → 磁盘缓存 → 网络请求。内存缓存放在 Kotlin/Native 进程堆里,速度最快但容量有限;磁盘缓存放在鸿蒙沙箱文件系统里,容量大但需要文件 IO;网络请求最慢。
鸿蒙设备的 RAM 一般 4-8GB,给 Coil 64MB LRU 足够(大约 60 张 400×400 的图片)。鸿蒙沙箱存储比较宽松(应用独占分区,不需要申请外部存储权限),磁盘缓存 256MB 是合理值。
.respectCacheHeaders(false) 的含义:Coil 默认会遵循 HTTP Cache-Control 头来决定缓存策略,但鸿蒙端网络场景比较特殊(弱网、网络切换频繁),让 Coil 自己管理缓存策略(按 LRU 淘汰)通常效果更好。
kotlin
// commonMain ------ 全局 ImageLoader 配置
// 用 remember + LocalPlatformContext 确保是跨平台 API
@Composable
fun rememberCoilImageLoader(): ImageLoader {
val context = LocalPlatformContext.current
return remember(context) {
ImageLoader.Builder(context)
// ⭐ 内存缓存:Kotlin/Native 进程堆里的 LRU Cache
// maxSizeBytes:鸿蒙端给 64MB(常见值 32-128MB 可按需调)
// maxSize:最多缓存多少张(防止单张大图吃光缓存)
.memoryCache {
memoryCacheConfig {
maxSizeBytes(64 * 1024 * 1024) // 64 MB LRU
maxSize(60) // 最多 60 张
}
}
// ⭐ 磁盘缓存:鸿蒙沙箱文件系统
// context.cacheDir 在鸿蒙端自动映射到应用沙箱的 cache 目录
// 不需要申请 MANAGE_EXTERNAL_STORAGE 权限(应用私有存储)
.diskCache {
diskCacheConfig {
maxSizeBytes(256 * 1024 * 1024) // 256 MB
directory(File(context.cacheDir, "coil_cache"))
}
}
// ⭐ 缓存策略:让 Coil 自己管,不遵循 HTTP Cache-Control
// 鸿蒙弱网场景下 LRU 策略更可靠
.respectCacheHeaders(false)
.crossfade(true)
.build()
}
}
5.6 Compose 重组安全:ImageRequest 用 remember 包裹
这是一个很隐蔽但影响很大的坑 ------直接把 String URL 传给 AsyncImage 时,AsyncImage 内部每次重组都会创建新的 ImageRequest 对象。正常情况下影响不大,但在频繁重组的场景下(比如 AnimatedVisibility 动画、LaunchedEffect 轮询更新、LazyColumn 滚动回调里触发父 Composable 重组),每次重组都会导致 AsyncImage 认为"Request 变了",从而重新下载图片。
解决方法:把 ImageRequest 也放进 remember 里,让它和 URL 字符串解耦。这样重组时 request 对象不变,AsyncImage 就不会触发重新加载。
另外要确保 composeCompiler { enableStrongSkippingMode = true } 开着------这让 Compose 编译器更精确地判断重组范围,减少不必要的 AsyncImage 重组触发。
kotlin
@Composable
fun CoilRecompositionSafe() {
val context = LocalPlatformContext.current
// ⭐⭐⭐ 核心:ImageRequest 用 remember 缓存
// remember 的 key 是 URL,URL 不变 → request 对象不变
// 即使外层 Composable 每秒重组一次,request 还是同一个对象
val request = remember {
ImageRequest.Builder(context)
.data("https://picsum.photos/seed/stable/600/600")
.crossfade(true)
.build()
}
// ✅ 正确:model 传 remember 过的 ImageRequest
// 重组只会触发 painter 重绘,不会重新下载
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.size(300.dp)
)
// ❌ 错误示范(不要这样写):
// AsyncImage(
// model = "https://picsum.photos/seed/stable/600/600", // 每次重组创建新 String
// ...
// )
// 虽然 String 是 immutable,但 AsyncImage 内部会把它转成 ImageRequest
// 每次重组都 new 一个新的 ImageRequest 对象 → 触发重新下载
}
5.7 图片格式支持全景
以下是基于 Skiko Skia 渲染层 + CPF 鸿蒙特有解码器整理的格式支持情况:
| 格式 | 鸿蒙端支持 | 解码器路径 | 备注 |
|---|---|---|---|
| PNG | ✅ | DMA + Skia | DMA 硬件加速支持 |
| JPEG | ✅ | DMA + Skia | DMA 硬件加速支持 |
| WebP | ✅ | DMA + Skia | DMA 硬件加速支持 |
| GIF | ✅ | Skia | 静态图片直接显示;动画需 coil-gif 原生解码 |
| HEIF/HEIC | ✅ | coil-gif OhosImageDecoder | 鸿蒙原生 C++ 库解码,CPU 零拷贝 |
| SVG | ✅ | coil-svg | ServiceLoader → @EagerInitialization 自动注册 |
| BMP | ✅ | Skia | 标准解码,无特殊优化 |
| Video Frame | ❌ | --- | coil-video 模块未移植鸿蒙 |
六、编译构建与真机运行
6.1 DevEco Studio 26 版里的构建
💡 重要 :Gradle 构建必须在 DevEco Studio 26 的内置终端 里跑。系统 PowerShell 默认用 JBR 11(PyCharm 自带),Gradle 8.14.3 会报
ZoneInfoFile类初始化失败。IDE 内置终端自动继承了正确的 JDK 17 和 NDK 环境。

构建产物的完整路径:
./gradlew :composeApp:linkDebugSharedOhosArm64输出到composeApp/build/bin/ohosArm64/debugShared/libcoildemo.so- 这个 .so 文件需要手动拷贝到鸿蒙宿主工程(
harmonyapp/entry/src/main/libs/arm64-v8a/) - 鸿蒙宿主工程通过 NAPI bridge 加载这个 .so,然后在 ArkTS 里调用
@CName导出的 Compose UI 入口
bash
# DevEco Studio Terminal 里执行
# ① 如果 OHOS_NDK_HOME 环境变量没配(也可以在系统环境变量里设置)
# macOS/Linux: export OHOS_NDK_HOME="/path/to/OpenHarmony/6.0.0(20)/native"
# Windows: 在系统环境变量里加 OHOS_NDK_HOME
# ② 构建 ohosArm64 共享库(生成 libcoildemo.so)
# linkDebugSharedOhosArm64 是 Kotlin/Native 标准 Gradle task
# -x test 跳过单测(ohosArm64 单测需要额外配置)
# --no-daemon 禁用 Gradle daemon(鸿蒙编译经常卡住 daemon,禁用更稳定)
./gradlew :composeApp:linkDebugSharedOhosArm64 -x test --no-daemon
# ③ 手动拷贝 .so 到鸿蒙宿主工程
# 产物路径:composeApp/build/bin/ohosArm64/debugShared/libcoildemo.so
# 目标路径:harmonyapp/entry/src/main/libs/arm64-v8a/
# 然后在 harmonyapp 的 build-profile.json5 里配置 NAPI 依赖
# ④ DevEco Studio 里点 Run → 选择真机 → HAP 自动安装运行
6.2 compose-multiplatform 构建日志关键字
成功构建时,你会在 Gradle 日志里看到这些来自 ComposePlugin 的输出(直接从 ComposePlugin.kt 源码的 logger.lifecycle() 里读出来的):
ComposePlugin: rendererBackend=fusion-renderer,
skikoVersion=0.9.22.2-1.0.0,
skikoOverlay=true,
supportedRenderBackends=[fusion-renderer, skia],
source=default, uiCoordinate=org.jetbrains.compose:ui-ohosarm64:1.9.2-1.0.0
(OHOS only)
逐字段解释:
rendererBackend=fusion-renderer:最终选中的渲染后端(三种优先级:命令行参数 > DSL > 默认值)skikoVersion=0.9.22.2-1.0.0:使用的 Skiko 版本(compose-plugin 自动决定,可用ohosSkikoVersionproperty 覆盖)skikoOverlay=true:compose-plugin 会用内部版本覆盖依赖树里的 Skiko variant(确保鸿蒙端拿到 skiko-ohosarm64 变体而不是 JVM/iOS 变体)supportedRenderBackends=[fusion-renderer, skia]:这个 Compose UI artifact 支持的渲染后端列表uiCoordinate=org.jetbrains.compose:ui-ohosarm64:1.9.2-1.0.0:ohosArm64 的 UI artifact 坐标(compose-plugin 从 artifact 里的META-INF/compose/ohos-render-backends.properties读取)
6.3 鸿蒙宿主网络权限(必配!)
harmonyapp/entry/src/main/module.json5 里必须加网络权限。这步很容易忘,忘的后果是:AsyncImage 加载网络图片只显示 placeholder,不报错、不崩溃、无任何日志------因为 Coil 的错误回调也不会触发(鸿蒙网络层在系统层面就拦住了,不返回给应用)。
json5
"requestPermissions": [
{
"name": "ohos.permission.INTERNET", // 核心网络权限(必须)
"usedInScene": [{ "abilities": ["EntryAbility"], "when": "always" }]
},
{
"name": "ohos.permission.ACCESS_NETWORK_STATE", // 网络状态查询(可选但推荐)
"usedInScene": [{ "abilities": ["EntryAbility"], "when": "always" }]
}
]
6.4 常见构建错误排查
| 错误信息 | 根因 | 解决方案 |
|---|---|---|
Could not resolve io.coil-kt.coil3:coil-compose:3.3.0 |
CPF Nexus 仓库没配全(三个都要) | add allprojects 的 develop/releases/public,排最前 |
Unresolved reference: ohosArm64 |
Kotlin 不是 CPF patch 版 | 确认 kotlin = "2.2.21-1.0.0" |
linkerOpts 里 libunwind.a 找不到 |
OHOS_NDK_HOME 环境变量未设置 | 系统环境变量或 gradle.properties |
Could not initialize class sun.util.calendar.ZoneInfoFile |
命令行用了 JDK 11 而不是 JDK 17 | 改用 DevEco Studio 内置 Terminal |
| BCV (binary-compatibility-validator) NPE | 该插件不认识 ohosArm64 KonanTarget | coil-oh 里把所有 subprojects 加了 ignoredProjects,你自己的项目可以加 ignoredProjects += "你的模块名" 或禁用 BCV |
| 真机 AsyncImage 只显示 placeholder | 忘了加 INTERNET 权限 | module.json5 补配置,卸载重装应用 |
| DMA 解码器不生效 | useNativeDmaDecodeWhenAvailable 默认 false | 应用入口手动设 true |
| GIF 动画不动 | 没加 coil-gif 依赖 | implementation(libs.coil.gif) |
实测效果如图:


七、源码探查:ohosArm64Main 目录全解
我 clone 了 CPF-KMP-CMP/coil 的 main-3.3.0-OH 分支,确认 ohosArm64Main 下有 7 个 Kotlin 文件 。对比 Android/iOS 等平台的 actual 实现(比如 Android 的 AsyncImagePainter.android.kt、iOS 的 GIF actual),鸿蒙端的文件数量不多,但每个都有明确的设计目的:
coil-core/src/ohosArm64Main/kotlin/coil3/
├── RealImageLoader.ohosArm64.kt # 注册 OhosNativeDmaDecoder.Factory()(~19 行,很薄的一层)
├── decode/
│ └── OhosNativeDmaDecoder.kt # ⭐ 鸿蒙 DMA 硬件解码器(~150 行,核心优化)
└── util/
└── utils.ohosArm64.kt # WeakReference typealias(~5 行,解决 Kotlin/Native 平台差异)
coil-gif/src/ohosArm64Main/kotlin/coil3/gif/
└── AsyncImage.kt # ⭐ 鸿蒙端 actual AsyncImage + HEIF 原生解码(~340 行,最复杂的鸿蒙特有代码)
coil-network-ktor2/src/ohosArm64Main/
└── .../KtorNetworkFetcherServiceLoaderTarget.ohosArm64.kt # @EagerInitialization 自动注册
coil-network-ktor3/src/ohosArm64Main/
└── .../KtorNetworkFetcherServiceLoaderTarget.ohosArm64.kt # @EagerInitialization 自动注册
coil-svg/src/ohosArm64Main/
└── .../SvgDecoderServiceLoaderTarget.ohosArm64.kt # @EagerInitialization 自动注册
coil-oh 的 hierarchy template 设计解读
CPF 给 coil 单独定义了 KMP 层级模板(buildSrc/hierarchyTemplate.kt),把 ohos 从 Apple native 组里独立出来 。这个设计非常重要------如果不独立,ohosArm64 会意外继承 iOS/macOS 的平台特定代码(比如 Android 上有 NSURLMapper 是 iOS 专属的 URL 解析器,不适合鸿蒙)。
kotlin
// hierarchyTemplate.kt ------ 我加了中文注释说明设计意图
// 关键设计:ohos 不继承 Apple 专属代码
private fun KotlinHierarchyBuilder.groupNative() {
group("native") {
// ⭐⭐⭐ 这个条件是核心!
// withCompilations 筛选所有 Kotlin/Native target
// 但用 !it.target.name.startsWith("ohos") 排除了 ohosArm64
// 效果:ohosArm64 不会继承 group("apple") 里的任何代码
withCompilations {
it.platformType == KotlinPlatformType.native
&& !it.target.name.startsWith("ohos")
}
group("apple") { withApple() ... } // iOS/macOS 专属组
}
}
// ohos 独立组
private fun KotlinHierarchyBuilder.groupOhos() {
group("ohos") {
// ⭐ 任何 target 名以 "ohos" 开头的(ohosArm64、ohosX64 未来扩展)
// 都被归到这个独立的组
withCompilations { it.target.name.startsWith("ohos") }
}
}
// 继承关系图(简化):
// commonMain → 所有 target 都继承
// ├── ohos 组:ohosArm64 → 继承 nonAndroid(Skiko)但不继承 apple 专属
// └── native 组(非 ohos):iOS/macOS → 继承 apple 专属代码
这意味着------ohosArm64 继承 commonMain + nonAndroidMain(Skiko)+ ohos 独立组,不会意外引入 iOS/macOS 的平台特定代码(比如 NSURLMapper、Lifecycle 桥接等)。CPF 社区通过这个 hierarchy template 解决了"鸿蒙和 iOS 都是 Kotlin/Native 但平台 API 完全不同"的继承冲突。
八、FAQ
Q1:Coil 鸿蒙上能加载哪些图片源?
A :commonMain API 与 Android/iOS 完全一致------HTTP/HTTPS URL、本地文件路径、ByteArray、Drawable resource。鸿蒙端特有的:不支持 Android 的 content:// URI 和 Android Assets(鸿蒙没有 ContentProvider 也没有 android 资源系统),沙箱文件用 Context.cacheDir / Context.filesDir。
Q2:ohosX64 target 支持吗?
A :compose-multiplatform 的 hierarchy template 预留了 ohosX64(通过 it.target.name.startsWith("ohos") 通配),cmp-oh 仓库里也有 ohosX64Main 目录(ResourceEnvironment、ResourceReader 等源码)。但 coil-oh 的 addAllMultiplatformTargets() 目前只声明了 ohosArm64(),没有 ohosX64()。如果你需要模拟器调试,可以自己在 kotlin {} 块里加 ohosX64 { binaries.sharedLib { baseName = "coildemo" } }。
Q3:DMA 解码器有什么好处?为什么默认关闭?
A :DMA(Direct Memory Access)绕过 CPU 直接把图片数据从内存拷贝到 GPU 显存,比常规 Skia CPU 解码快约 20-40%,内存拷贝也少一次。默认关闭三个原因:① 鸿蒙 DMA API 较新(随 HarmonyOS SDK 6.0.0 引入),CPF 社区还在收集反馈;② 不是所有鸿蒙设备的 DMA 实现都稳定;③ Coil 上游策略是默认保守------新硬件加速总是 opt-in 而不是默认开启,避免线上崩。开启很简单:OhosImageLoadConfig.useNativeDmaDecodeWhenAvailable = true。
Q4:fusion-renderer 和 skia 两种渲染后端有什么区别?
A:这是 compose-multiplatform 鸿蒙端的两条渲染路径:
- fusion-renderer(默认):基于鸿蒙原生 OpenGL ES(EGL)+ Skiko 桥接层。鸿蒙系统级 GPU 优化,性能更好,有鸿蒙系统的图形栈兜底。compose-plugin 内置的默认后端。
- skia:纯 Skia 软件渲染(通过 Skiko 的 CPU backend)。兼容性更好(任何设备都能跑),但没有利用鸿蒙 GPU 加速。适合调试 fusion-renderer 兼容性问题时使用。
- 切换方式:
compose { ohos { skia() } }或命令行-PrendererBackend=skia。配置优先级:命令行参数 > compose DSL > 默认 fusion-renderer。
Q5:为什么鸿蒙端用 @EagerInitialization 而不是 ServiceLoader?
A :Java 的 ServiceLoader 机制依赖 classpath 扫描(META-INF/services/com.example.Interface 配置文件),在 Kotlin/Native 里完全不可用------编译产物是单个二进制(.so 或 .kexe),没有 classpath 概念也没有 META-INF 目录。Kotlin/Native 1.7.20 引入了 @EagerInitialization:程序启动时(Kotlin/Native 运行时初始化阶段)自动执行带此注解的顶层属性初始化代码。coil-network-ktor3、coil-svg、coil-network-ktor2 都用这个机制做自动注册。Android 上还能看到 ServiceLoader 文件,鸿蒙端看 @EagerInitialization val initHook。
Q6:coil-gif 在鸿蒙端有什么特别之处?
A :coil-gif 的 ohosArm64Main 有一个完整的 actual AsyncImage 实现(约 340 行),而 Android/iOS 端只是简单调用 Skia。鸿蒙端特别做了:
- 通过 cinterop 调用鸿蒙原生 C++ 库(
create_heif_animation/animation_copy_frame_pixels)做 HEIF 动图解码 - 通过
LaunchedEffect+DisposableEffect驱动动画帧循环,并在 Composable dispose 时释放 native 句柄(防内存泄漏) - 设置了安全限制(120 帧、150 万像素),超过时自动 fallback 到静态首帧
Q7:OHOS_NDK_HOME 必须配吗?
A:是的。两个地方依赖它:
- coil-oh 的 linkerOpts 硬编码了
${OHOS_NDK_HOME}/native/llvm/lib/aarch64-linux-ohos/libunwind.a------这是 LLVM 栈回溯静态库,Kotlin/Native 异常打印必须链接它 - compose-multiplatform 的 OhosNativeLinkerConfigurator 会用 NDK 的
-lnative_drawing、-licu、-limage_packer等系统库
不配的话linkDebugSharedOhosArm64会报unresolved library libunwind.a。
Q8:compose {} ohos DSL 的优先级是怎样的?
A :compose-multiplatform ComposePlugin.kt 源码里 resolveOhosBackendConfig() 函数明确写了三种优先级(从高到低):
- 命令行参数 :
./gradlew linkDebugSharedOhosArm64 -PrendererBackend=skia - DSL 块 :
compose { ohos { skia() } }或compose { ohos { fusionRenderer() } } - 默认值:fusion-renderer(自动使用 compose-plugin 内置的 Skiko 版本)
Q9:鸿蒙端 Coil 会内存泄漏吗?
A:正常使用不会。原因:
AsyncImage和rememberAsyncImagePainter自带 Composable 生命周期感知(DisposableEffect在 dispose 时自动取消协程 + 释放内存)- coil-gif 的 actual AsyncImage 用
DisposableEffect(onDispose)绑定 native 句柄释放(destroy_heif_animation(handle)) - ohosArm64Main 的
utils.ohosArm64.kt把WeakReferencetypealias 到kotlin.native.ref.WeakReference------Kotlin/Native 的 GC 正确支持弱引用,不会导致内存泄漏 - OhosNativeDmaDecoder 失败时自动返回 null 走 fallback,不会卡住异常解码流程
- ImageLoader 建议用 Application context(
LocalPlatformContext自动映射),避免持有 Activity context
Q10:如何跟进上游版本更新?
A:CPF-KMP-CMP 的策略是先等上游发布稳定版再适配鸿蒙。你可以:
- 关注上游 coil-kt/coil 的 release 动态
- Fork CPF-KMP-CMP/coil → 升级
libs.versions.toml版本号 → 检查 ohosArm64Main 源文件是否需要适配新版本 API → 提交 PR - CPF 仓库的 CI 会自动跑 ohosArm64 交叉编译,确保你的改动不会破坏鸿蒙构建
九、社区引导
欢迎加入 KMP&CMP 鸿蒙社区 :https://atomgit.com/CPF-KMP-CMP
CPF-KMP-CMP 组织是鸿蒙上 Compose Multiplatform 生态的核心维护者 ,目前已有 88 个仓库,覆盖:
| 仓库 | tag | 鸿蒙适配亮点 |
|---|---|---|
| compose-multiplatform | develop-1.9.2-OH |
Compose Plugin + fusion-renderer 默认后端 + compose{ohos{}} DSL + 三源 Nexus |
| compose-multiplatform-core | v1.9.2-1.1.0 |
Skiko 交叉编译 ohosArm64/ohosX64 + Skia 渲染运行时 |
| coil | main-3.3.0-OH |
OhosNativeDmaDecoder 硬件加速 + coil-gif 原生 HEIF 动画 + ServiceLoader→@EagerInitialization + hierarchy template ohos 独立组 |
| compottie | main-2.0.2-OH |
Lottie 动画鸿蒙适配 |
如果你在适配过程中用到了 AI 辅助工具,推荐 码道(CodeArts)专属通道 :
https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths