欢迎光临
我们一直在努力

CMP for OpenHarmony 实战:Coil 图片加载库鸿蒙化使用心得

框架: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.0 Maven 坐标: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 适配状态 & 版本对比
    • 3.1 CPF-KMP-CMP 鸿蒙定制版 vs 上游 Maven Central
    • 3.2 版本号全景(来自 coil-oh 的 gradle/libs.versions.toml)
    • 3.3 CPF 仓库真实 Maven 配置(来自 build.gradle.kts)
    • 3.4 依赖链全景(基于源码分析)
  • 四、如何在鸿蒙 CMP 项目中引入 Coil
    • 4.1 项目结构
    • 4.2 settings.gradle.kts 仓库配置
    • 4.3 libs.versions.toml 版本目录
    • 4.4 ohosArm64 target + linkerOpts 详解
    • 4.5 鸿蒙特有机制一:OhosNativeDmaDecoder 深度解析
    • 4.6 鸿蒙特有机制二:ServiceLoader → @EagerInitialization
    • 4.7 鸿蒙特有机制三:coil-gif 的原生 HEIF 动画
  • 五、Coil 在鸿蒙上的使用案例
    • 5.1 基础:AsyncImage 加载网络图片
    • 5.2 进阶:LazyColumn 图片列表
    • 5.3 开启 DMA 加速解码
    • 5.4 自定义 Transformation:圆形裁剪 + 模糊
    • 5.5 ImageLoader 缓存策略深度配置
    • 5.6 Compose 重组安全:ImageRequest 用 remember 包裹
    • 5.7 图片格式支持全景
  • 六、编译构建与真机运行
    • 6.1 DevEco Studio 26 版里的构建
    • 6.2 compose-multiplatform 构建日志关键字
    • 6.3 鸿蒙宿主网络权限(必配!)
    • 6.4 常见构建错误排查
  • 七、源码探查:ohosArm64Main 目录全解
    • coil-oh 的 hierarchy template 设计解读
  • 八、FAQ
  • 九、社区引导

一、为什么选 Coil 作为 CMP 鸿蒙第一篇文章

Coil 是 Compose Multiplatform 生态最流行的图片加载库——Android 开发者几乎都用过(Coil = Co-routine + Image 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

项目版本ohosArm64ohosX64说明
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 版本
  • # 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 的上游未适配版本抢走。

    // 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 仓库里的插件和依赖都是完整适配过鸿蒙的,必须优先。

    // 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 号)

    # 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。

    // 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 把它设计成可选的增强解码器,默认不启用。

    // 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 加速:

    // 在鸿蒙应用入口(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 在类加载时执行。

    // 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 不变:

    // 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" 效果一样。

    // 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 已在缓存里,直接从内存拿,不会重新下载。

    @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),不会崩溃、不会有任何可见错误。

    // 在鸿蒙应用入口初始化(类似 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)。

    @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 淘汰)通常效果更好。

    // 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 重组触发。

    @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 入口

    # 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 自动决定,可用 ohosSkikoVersion property 覆盖)
    • 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 的错误回调也不会触发(鸿蒙网络层在系统层面就拦住了,不返回给应用)。

    "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 解析器,不适合鸿蒙)。

    // 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 把 WeakReference typealias 到 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

    赞(0)
    未经允许不得转载:171主机测评 » CMP for OpenHarmony 实战:Coil 图片加载库鸿蒙化使用心得
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址