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 作为 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 号?因为:

  1. Kotlin 官方的 2.2.21 不认识 ohosArm64 这个 KonanTarget,CPF fork 后 patch 了 Kotlin/Native 编译器使其能识别鸿蒙 target
  2. Compose 官方的 1.9.2 不包含鸿蒙渲染后端,CPF 在 compose-multiplatform 插件里加了 compose { ohos { } } DSL 和 fusion-renderer 实现
  3. 其他依赖(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 自动决定,可用 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 的错误回调也不会触发(鸿蒙网络层在系统层面就拦住了,不返回给应用)。

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。鸿蒙端特别做了:

  1. 通过 cinterop 调用鸿蒙原生 C++ 库(create_heif_animation / animation_copy_frame_pixels)做 HEIF 动图解码
  2. 通过 LaunchedEffect + DisposableEffect 驱动动画帧循环,并在 Composable dispose 时释放 native 句柄(防内存泄漏)
  3. 设置了安全限制(120 帧、150 万像素),超过时自动 fallback 到静态首帧

Q7:OHOS_NDK_HOME 必须配吗?

A:是的。两个地方依赖它:

  1. coil-oh 的 linkerOpts 硬编码了 ${OHOS_NDK_HOME}/native/llvm/lib/aarch64-linux-ohos/libunwind.a------这是 LLVM 栈回溯静态库,Kotlin/Native 异常打印必须链接它
  2. compose-multiplatform 的 OhosNativeLinkerConfigurator 会用 NDK 的 -lnative_drawing、-licu、-limage_packer 等系统库
    不配的话 linkDebugSharedOhosArm64 会报 unresolved library libunwind.a。

Q8:compose {} ohos DSL 的优先级是怎样的?

A :compose-multiplatform ComposePlugin.kt 源码里 resolveOhosBackendConfig() 函数明确写了三种优先级(从高到低):

  1. 命令行参数 :./gradlew linkDebugSharedOhosArm64 -PrendererBackend=skia
  2. DSL 块 :compose { ohos { skia() } } 或 compose { ohos { fusionRenderer() } }
  3. 默认值: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 的策略是先等上游发布稳定版再适配鸿蒙。你可以:

  1. 关注上游 coil-kt/coil 的 release 动态
  2. Fork CPF-KMP-CMP/coil → 升级 libs.versions.toml 版本号 → 检查 ohosArm64Main 源文件是否需要适配新版本 API → 提交 PR
  3. 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

相关推荐
小雨青年2 小时前
【HarmonyOS 7 小艺 Skill 开发实战】 01 创建第一个会后行动助手
华为·harmonyos
fengci.2 小时前
CTF2刷题记录(困难部分)
android·开发语言·前端·学习·php
老王IT2 小时前
Rust开发环境搭建教程
开发语言·后端·rust
货拉拉技术2 小时前
一句话叫车:货拉拉小程序 AI 叫车实战
前端·agent·ai编程
qeen872 小时前
【Linux】磁盘读写文件原理及文件系统基本概念介绍
linux·运维·服务器·笔记·学习
独隅2 小时前
HarmonyOS 核心能力与实效展示
华为·harmonyos
ITxiaobing20232 小时前
海外广告投放:AdTech 场景下 IP 地址库选型思考
服务器·网络·tcp/ip
Rabitebla2 小时前
【Linux 底层原理】工具篇 (三):gcc/g++ 编译工具、动静态库与编译原理
linux·运维·服务器·笔记·学习
进击的荆棘2 小时前
Linux系统——进程信号
linux·运维·服务器·信号