大家好,我是熊猫钓鱼!欢迎大家和我一起探讨技术。希望您能点赞关注,谢谢!

在这里插入图片描述

摘要

本文聚焦如何在 OpenHarmony 上为 Kotlin Multiplatform 图片加载库 Kamel 做适配落地。Kamel 用一套跨平台 API(ImageSource 数据源 / ImageRequest 请求 / ImageLoader 加载门面 + 内存缓存 + 平台解码)统一封装各平台图片加载。本文用 ArkTS 桥接 @kit.NetworkKit 的 @ohos.net.http(下载)与 @kit.ImageKit 的 @ohos.multimedia.image(解码),把「下载字节 → 解码位图 → 回填缓存 → 上屏」翻译成语义层的统一加载流水线,全程遵循本适配工程的三层架构:语义层 Kamel.ets(ImageSource 三源联合 / ImageRequest / ImageResult / DecodedImage / KamelEngine 平台接口 / MemoryCache LRU / ImageLoader 门面,不碰 @kit.*),引擎层 OhosKamelEngine.ets 真正接上系统网络与解码,验收页 KamelDemo.ets 提供「远程加载 / 示例图(bytes 源免网络)/ 清空缓存」三件套,并附内存缓存命中演示。

文章第二部分拆解 OpenHarmony 的图片加载真实基础(@ohos.net.http 的连接生命周期与 ARRAY_BUFFER 返回、@ohos.multimedia.image 的 ImageSource/createPixelMap、ArkUI Image 显示 PixelMap),第三至六节逐层对照本仓库代码讲语义层、引擎层与验收页,第七节总结 ArkTS 适配踩到的真实坑,第八节对照上游库讲本项目的 API 命名与类型设计决策(pixelMap 以 Object 形态跨层、Resource 源暂未实现、MemoryCache 简化为极简 LRU)。

本适配基于 HarmonyOS SDK 6.0.0(20) + KMP&CMP 鸿蒙社区工具链 v1.1.0(Kotlin 2.2.21 / CMP 1.9.2)开发,assembleHap 编译 BUILD SUCCESSFUL、零 ArkTS error,模拟器即可跑示例图解码与缓存演示。

目录

一、Kamel 是什么,以及为什么要在 OpenHarmony 上适配它

Kamel 是 Kotlin Multiplatform 生态中一个轻量的图片加载库,它的设计目标与 Coil / Glide 类似,但天然跨平台:用一套 API 屏蔽各平台「下载 → 解码 → 缓存 → 上屏」的差异。本系列此前已经适配了 Decompose、Ktor、Notifier、kable、MVIKotlin、Reaktive、kotlin-inject、Moko Permissions 八个库。Kamel 与 Ktor / Notifier / kable / Moko Permissions 同属「真接口真实现」这一档——它本身就依赖平台提供网络下载与图片解码能力,我们的做法不是把上游 Kotlin 源码编译进 ohosArm64,而是用 ArkTS 把「网络下载」与「图像解码」这两件事真正接上 OpenHarmony 的系统能力,同时把加载编排逻辑用纯 ArkTS 复刻出来。

本文聚焦本仓库 kamel/ 目录下的真实代码,逐层对照讲清楚语义层、引擎层与验收页,并总结 ArkTS 严格模式下踩到的真实坑。

上游 Kamel 的核心契约可以概括成四块:

  • ImageSource:数据源,描述图片「从哪来」——远程 URL、内存字节、还是平台资源;
  • ImageRequest:一次加载请求,包裹 ImageSource 并携带配置(是否走缓存等);
  • ImageLoader:加载门面,对应 getImage / load,内部编排「查缓存 → 取字节 → 解码 → 回填缓存」;
  • 平台解码与内存缓存:Kamel 在 Kotlin 端用 Ktor 下载、用平台图像解码器解码,并维护 MemoryCache。

适配目标很明确:把图片加载的语义还原成 ArkTS(语义层),并让引擎层真正调用系统网络栈与图像解码器(引擎层),最终在验收页用 ArkUI 把它显示出来。

二、OpenHarmony 的图片加载真实基础(适配的真实底座)

要让 Kamel 在鸿蒙上跑起来,必须先理解系统给了什么。本节拆解三个真实基础。

网络:@ohos.net.http

OpenHarmony 的 HTTP 能力由 @kit.NetworkKit 提供。最关键的两点:

  1. 连接生命周期:http.createHttp() 的产物必须用完 destroy(),否则连接池不释放、长跑会泄漏(这一点在 Ktor 适配时已经踩过);
  2. 返回类型声明:默认 request 返回的是字符串,expectDataType 设为 http.HttpDataType.ARRAY_BUFFER 时,response.result 才是 ArrayBuffer,我们才能拿到原始图片字节去解码。

解码:@ohos.multimedia.image

图片解码由 @kit.ImageKit 提供:image.createImageSource(arrayBuffer) 从一个 ArrayBuffer 创建 ImageSource,再 createPixelMap() 得到 PixelMap(鸿蒙里的「位图」)。解码完成后应调用 source.release() 释放底层句柄,避免泄漏。

上屏:ArkUI Image

ArkUI 的 Image 组件可以直接消费 PixelMap——Image(pixelMap) 即可把一个解码后的位图显示出来。这正是我们「引擎层解码、验收页上屏」的衔接点。

三、适配架构:语义层 / 引擎层 / 验收页三层

延续本工程一贯的三层架构:

层文件职责
语义层kamel/src/Kamel.etsImageSource 三源联合 / ImageRequest / ImageResult / DecodedImage / KamelEngine 平台接口 / MemoryCache LRU / ImageLoader 门面,不碰 @kit.*
引擎层kamel/src/OhosKamelEngine.ets实现 KamelEngine:fetch(桥 @ohos.net.http)/ decode(桥 @ohos.multimedia.image)
验收页kamel/src/KamelDemo.etsURL 输入 / 远程加载 / 示例图 / 清空缓存 / Image(pixelMap) 上屏 / 事件日志

语义层只依赖自身的接口与类型,引擎层可替换——就像 Ktor 把 HttpClient 与引擎解耦一样,Kamel 的 ImageLoader 也只依赖 KamelEngine 接口,换一套引擎(比如换成缓存优先的变体)不影响语义层。

四、语义层:图片加载契约(对照原库)

语义层是本次适配里最「重」的一层——它不仅要定义契约,还自带内存缓存与加载编排。下面逐段对照 Kamel.ets 看。

ImageSource:三源联合

export type ImageSource =
  | { kind: 'remote'; url: string }
  | { kind: 'bytes'; data: Uint8Array }
  | { kind: 'resource'; id: string };

用「可分辨联合(discriminated union)」来表达与原库 ImageSource 对应的三种来源。kind 作为判别字段,让 ArkTS 在 switch / if 里能精确收窄类型——这是 ArkTS 严格模式下比 any 安全得多的写法。

DecodedImage:解码结果,pixelMap 刻意用 Object 持有

export class DecodedImage {
  readonly pixelMap: Object | null;
  readonly bytes: Uint8Array | null;
  readonly width: number;
  readonly height: number;
  constructor(pixelMap: Object | null, bytes: Uint8Array | null, width: number, height: number) {
    this.pixelMap = pixelMap;
    this.bytes = bytes;
    this.width = width;
    this.height = height;
  }
}

注意 pixelMap 的类型是 Object | null,而不是 image.PixelMap。这是本次适配的一个刻意设计:语义层绝不引入 @kit.*,哪怕只是类型注解。解码出的位图在语义层只能被「当作对象持有」,真正要显示时,由验收页把它 as image.PixelMap 交给 ArkUI——守住边界,跨层才不会被具体平台类型绑架。

KamelEngine 平台接口 + MemoryCache + ImageLoader

export interface KamelEngine {
  fetch(url: string): Promise<Uint8Array>;
  decode(bytes: Uint8Array): Promise<DecodedImage>;
}

KamelEngine 是语义层与引擎层之间的契约,对应 Kamel 上游「平台解码/下载能力」的抽象。MemoryCache 是一个极简 LRU(Map 保持插入顺序,超容删最旧),对应上游的 MemoryCache 概念;ImageLoader 则是门面,编排整条流水线:

async load(request: ImageRequest): Promise<ImageResult> {
  const key = cacheKeyOf(request.source);
  if (request.useCache) {
    const cached = this.cache.get(key);
    if (cached !== undefined) return new ImageResult(cached, true, null); // 命中
  }
  try {
    let bytes: Uint8Array;
    if (request.source.kind === 'remote') bytes = await this.engine.fetch(request.source.url);
    else if (request.source.kind === 'bytes') bytes = request.source.data;
    else return new ImageResult(null, false, `resource 源暂未实现(id=${request.source.id})`);
    const decoded = await this.engine.decode(bytes);
    if (request.useCache) this.cache.put(key, decoded);
    return new ImageResult(decoded, false, null);
  } catch (e) {
    return new ImageResult(null, false, String(e));
  }
}

可以看到,load 本身完全没有网络与解码代码,只负责「取键 → 查缓存 → 取字节 → 解码 → 回填」的编排,具体能力全部委托给注入的 KamelEngine。

在这里插入图片描述

  • pixelMap 以 Object 形态跨层:上游 ImageBitmap 是平台富类型,本项目为了让语义层零 @kit,选择用 Object 持有,显示侧再强转;
  • Resource 源暂未实现:本适配聚焦 remote + bytes 两条主链路(也是图片加载最常见场景),resource 留作引擎层扩展点;
  • MemoryCache 简化:上游还有磁盘缓存与尺寸权重,本项目只做极简 LRU,足以演示缓存命中语义。

五、引擎层:OhosKamelEngine 桥接系统能力

引擎层 OhosKamelEngine 是「真接口真实现」的落点,它把 KamelEngine 的 fetch / decode 真接上系统能力。

fetch:桥 @ohos.net.http

async fetch(url: string): Promise<Uint8Array> {
  const request = http.createHttp();
  try {
    const options: http.HttpRequestOptions = {
      method: http.RequestMethod.GET,
      expectDataType: http.HttpDataType.ARRAY_BUFFER
    };
    const response = await request.request(url, options);
    const result = response.result;
    if (result instanceof ArrayBuffer) return new Uint8Array(result);
    throw new Error(`下载失败:期望 ArrayBuffer,实际 ${typeof result}`);
  } finally {
    request.destroy(); // 无论成败都必须释放
  }
}

两点要强调:expectDataType 必须声明 ARRAY_BUFFER 才能拿到 ArrayBuffer;http.createHttp() 出的实例必须在 finally 里 destroy(),否则连接泄漏。

decode:桥 @ohos.multimedia.image

async decode(bytes: Uint8Array): Promise<DecodedImage> {
  const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
  const source = image.createImageSource(buffer);
  // ImageSource.createPixelMap 接收可选的 DecodingOptions;不传则按原图全量解码。
  // 注意别误用 InitializationOptions(其 size 字段必填),否则 ArkTS 报缺 size。
  const pixelMap = await source.createPixelMap();
  const info = await pixelMap.getImageInfo();
  source.release(); // 解码完即释放 ImageSource,避免句柄泄漏
  // ImageInfo 的尺寸在 info.size(Size 接口)上,不是 info.width / info.height
  return new DecodedImage(pixelMap, bytes, info.size.width, info.size.height);
}

时序图如下:
在这里插入图片描述
这里有一个容易踩的坑:ArkTS 的 Uint8Array 可能只是某个大 ArrayBuffer 上的视图,byteOffset / byteLength 未必从 0 开始。解码需要「完整且独立的 ArrayBuffer」,所以先用 bytes.buffer.slice(...) 切出一份独立副本再交给 createImageSource。解码完成后 source.release() 释放底层句柄。

适配中踩到的真实坑:

  1. http.createHttp() 必须 destroy():不释放会连接泄漏,Ktor 适配时也踩过同一处;
  2. 构造函数参数属性被禁:ArkTS 严格模式不允许 constructor(private readonly x) 这种参数即属性的写法,所有字段必须显式声明再赋值;
  3. Uint8Array 可能是视图:解码前要用 bytes.buffer.slice(byteOffset, ...) 拿独立 ArrayBuffer,否则字节错位;
  4. Image 显示 PixelMap 需条件渲染:@State pixelMap 为 null 时不能 Image(null),否则报错,必须 if (this.pixelMap !== null) 包住;
  5. @State 不可变更新:日志数组用 [...this.logLines, item] 展开生成新数组,直接 push 不会触发刷新;
  6. expectDataType 决定返回形态:不声明 ARRAY_BUFFER,response.result 是字符串而非 ArrayBuffer;
  7. ImageSource.createPixelMap 别传 InitializationOptions:它期望 DecodingOptions(可选),误传 InitializationOptions 会因 size 必填而报「缺少 size」;不传即按原图全量解码;
  8. ImageInfo 尺寸在 info.size 上:取宽高要用 info.size.width / info.size.height,直接用 info.width / info.height 在 ArkTS 下不存在该属性;
  9. Base64Helper 解码是实例方法 decodeSync:util.Base64Helper 没有静态 decode,必须先 new util.Base64Helper() 再调 decodeSync(str) 拿到字节;
  10. ImageFit 是全局枚举:objectFit(ImageFit.Contain) 不需要从 @kit.ArkUI import,误 import 会报「未导出」。

开发代码界面如下:
在这里插入图片描述

遇到报错信息如下:

在这里插入图片描述
调试解决!
对应 DevEco Studio 实测 assembleHap 报出的 12 个 ArkTS ERROR。
所有修复均对照本机 SDK 类型定义核实(@ohos.multimedia.image.d.ts 与 @ohos.util.d.ts),非凭记忆推断。

修复清单(12 处 ERROR → 5 类根因)

#文件:行报错码根因修法
1–3Kamel.ets:36-38arkts-no-obj-literals-as-typesImageSource 用了内联对象字面量联合类型拆成 3 个具名接口 RemoteSource / BytesSource / ResourceSource + type ImageSource = … 联合
4–5KamelDemo.ets:58,66arkts-no-untyped-obj-literals{ kind: 'remote', url } 字面量无对应具名类型上面的接口化一并对消,字面量现能匹配 RemoteSource / BytesSource
6OhosKamelEngine.ets:41InitializationOptions 缺 size误把 InitializationOptions(size 必填)当 createPixelMap 参数ImageSource.createPixelMap 实际收可选 DecodingOptions,直接 source.createPixelMap() 无参全量解码
7–8OhosKamelEngine.ets:46ImageInfo 无 width/height尺寸在 info.size: Size 上改 info.size.width / info.size.height
9/11KamelDemo.ets:10ImageFit 未从 @kit.ArkUI 导出ImageFit 是 ArkUI 全局枚举,不能 import移除 import,ImageFit.Contain 仍可直接用
10KamelDemo.ets:65Base64Helper 无 decodebase64 解码是实例方法 decodeSyncnew util.Base64Helper().decodeSync(SAMPLE_PNG_BASE64)

改动文件

  • kamel/src/Kamel.ets + demo ets/kamel/Kamel.ets(语义层:ImageSource 接口化)
  • kamel/src/OhosKamelEngine.ets + demo ets/kamel/OhosKamelEngine.ets(引擎层:createPixelMap 无参 + info.size)
  • kamel/src/KamelDemo.ets + demo ets/pages/KamelDemo.ets(验收页:移除 ImageFit import + decodeSync)
  • kamel/docs/11-Kamel技术博客.md + 11-Kamel完整版.md(同步代码样例,并在「踩坑」节新增 7/8/9/10 四条,正是本次踩到的雷)

关键经验(桥接型适配常见雷,后续库直接复用)

  1. InitializationOptions(size 必填) vs DecodingOptions(可选) 易混:ImageSource.createPixelMap 收的是 DecodingOptions,不传即按原图全量解码;误传 InitializationOptions 会因 size 必填报缺字段。
  2. ImageInfo 尺寸一律走 info.size:取宽高用 info.size.width / info.size.height,直接用 info.width / info.height 在 ArkTS 下不存在该属性。
  3. base64 解码用 new util.Base64Helper().decodeSync:util.Base64Helper 没有静态 decode,必须先实例化再调 decodeSync(str) 拿字节。
  4. ArkUI 枚举是全局:ImageFit、FontWeight、Alignment、BarState 等均为全局枚举,不要从 @kit.ArkUI import,误 import 会报「未导出」。
  5. 对象字面量类型要具名:ArkTS 严格模式禁止内联对象字面量作为类型声明(arkts-no-obj-literals-as-types),也禁止字面量无对应具名类/接口(arkts-no-untyped-obj-literals);可分辨联合统一用具名接口表达。

修复状态

  • 12 个 ERROR 已全部修复
    在这里插入图片描述
    在这里插入图片描述

六、验收页:远程加载 / 示例图 / 缓存演示

验收页 KamelDemo.ets 把三层串起来。与 Moko Permissions 不同,Kamel 的引擎不需要 UIAbilityContext——http 与 image 都是全局系统能力,所以页面里直接 new ImageLoader(new OhosKamelEngine()) 即可。

三个核心动作:

  1. 加载远程图:把输入框的 URL 包成 ImageRequest({ kind: 'remote' }) 交给 loader.load,成功后得到 DecodedImage,this.pixelMap = result.image.pixelMap as image.PixelMap,再用 Image(this.pixelMap) 上屏;
  2. 加载示例图(bytes 源,免网络):内置一段 96×96 橙黄渐变 PNG 的 base64,new util.Base64Helper().decodeSync(...) 转成字节后包成 ImageRequest({ kind: 'bytes' })。这一步完全不经过网络,专门给「模拟器无稳定网络」的场景做演示与截图;
  3. 清空缓存:loader.clearCache() 把 MemoryCache 清空,页面上 cacheSize 归零。

ImageResult.fromCache 是缓存命中的直观证据:同一 URL 第二次 load,会跳过 fetch 与 decode,直接返回 fromCache = true。

在这里插入图片描述
在这里插入图片描述
运行界面如下:
在这里插入图片描述
点击加载远超图如下:
在这里插入图片描述
在这里插入图片描述

七、关于 API 命名与类型的一点设计说明(本项目的适配决策)

对照上游 Kamel 库,本项目的命名与类型做了几处务实收敛:

  • ImageSource 三源命名沿用上游:remote / bytes / resource 直接对应 Kamel 的数据源语义,不另造词;但 resource 在本适配中暂未实现(留作引擎层扩展点),聚焦 remote + bytes 主链路;
  • pixelMap 以 Object 形态跨层:上游 ImageBitmap 是平台富类型,本项目为了守住「语义层零 @kit」的边界,只用 Object 持有,显示侧再 as image.PixelMap;
  • MemoryCache 简化为极简 LRU:不实现上游的磁盘缓存与尺寸权重,只保留「命中 / 淘汰」语义,足以演示缓存价值;
  • KamelEngine 用接口而非具体类:保留「换引擎」的扩展点,与 Ktor 把 HttpClient 与引擎解耦是同一思路。

八、版本与运行环境

本适配基于 HarmonyOS SDK 6.0.0(20)(API 20)+ KMP&CMP 鸿蒙社区工具链 v1.1.0(Kotlin 2.2.21 / CMP 1.9.2)开发,使用 DevEco Studio 26.0.0 Release。模拟器即可跑「示例图解码」与「缓存命中」演示;远程加载需在真机或带网络的模拟器上验证(INTERNET 权限已在 module.json5 声明)。

总结

Kamel 的适配再次印证了本系列的方法论:语义层用纯 ArkTS 还原跨平台契约(不碰 @kit.*),引擎层把真接口真实现接上系统能力,验收页用真实 UI 验证能力。图片加载看似简单,却完整覆盖了「网络下载 + 图像解码 + 内存缓存 + 上屏」四件事,是检验「桥接型适配」成色的很好样本。

欢迎加入 KMP&CMP 鸿蒙社区: https://atomgit.com/CPF-KMP-CMP

如果你也想体验鸿蒙原生 AI 开发,可通过 AtomCode 专属邀请链接加入:
https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgiths

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐