OpenHarmony ArkTS 实战:@ohos/imageknife 图片加载、变换与二级缓存

库版本:@ohos/imageknife v3.2.10|验证环境:DevEco Studio 26.0.0 Release|HarmonyOS 7.0.0(API 26)|DevEco 模拟器

在 OpenHarmony ArkTS 应用里,图片几乎无处不在:列表头像、商品大图、运营 Banner、详情页长图、用户上传的相册缩略图。只用系统 Image 组件能「把图显示出来」,但加载中的状态、失败后的兜底、磁盘缓存、圆形裁剪、滤镜这些「图片加载的周边工作」,全都要业务自己写。

@ohos/imageknife 把这些活一次性收进一个声明式组件:声明一个 ImageKnifeOption 配置对象,交给 ImageKnifeComponent 渲染,下载、缓存、解码、变换、刷新全部由库内部处理。它的定位和用法都非常接近 Android 的 Glide / Picasso。

本文按能力维度拆开来讲:二级缓存怎么配、占位 / 失败图什么时候触发、objectFit 三种填充的差异、圆角与圆形裁剪的实现层差别、五种内置滤镜、GIF / SVG 多格式渲染、进度监听与 onComplete 回调,每部分都配 DevEco 模拟器实测截图。


在这里插入图片描述

一、背景与选型

1.1 系统 Image 组件能做到哪一步

ArkUI 的 Image 组件本身支持 $rawfile、资源引用和网络地址,渲染一张图并不难。真正的难点在「图片加载」这件事的周边:

  • 加载过程没有状态:网络图从发起请求到首帧渲染之间,页面是空白还是占位图,Image 不管。
  • 失败没有兜底:地址 404、断网、超时,需要业务自己监听 onError 再切换兜底图。
  • 没有磁盘缓存:每次进入页面都可能重新下载一遍。
  • 变换能力有限:只有 objectFit 和 borderRadius,圆形裁剪、模糊、灰度这类像素级处理要自己去接 ImageKit。
  • 列表滚动容易抖:没有请求去重与取消机制,快速滑动会同时发起大量请求。

这些能力单看每一条都不难,但要在每个业务页面重复实现一遍,代码会迅速失控。

1.2 ImageKnife 解决什么问题

@ohos/imageknife v3.2.10 由 CPF-ApplicationTPC 社区维护,用「配置对象 + 声明式组件」的方式,把图片加载链路收口:

  1. 声明式:配置对象只描述「要什么」,不写命令式流程。
  2. 二级缓存:内存 + 磁盘 LRU,二次进入页面秒开。
  3. 内置变换:7 种常用图像变换,无需自己接 ImageKit。
  4. 多格式:PNG / JPEG / GIF / SVG 自动识别,GIF 自带播放。
  5. 状态完备:占位图、失败图、进度回调、完成回调开箱可用。

1.3 职责分层

理解分层之后,排查问题时能立刻定位到「这是业务的配置问题」还是「这是库内部的行为」:

层承担者职责
声明层ImageKnifeComponent挂载到 UI,感知 ImageKnifeOption 变化并触发刷新
配置层ImageKnifeOption描述图源、占位、填充、边框、变换、缓存策略
加载层库内部加载器按 loadSrc 类型走网络 / 资源 / 本地文件
缓存层内存缓存 + 磁盘缓存二级 LRU,命中即跳过下载与解码
变换层Transformation在解码后的 PixelMap 上做像素级处理

业务侧只需要维护前两层,后三层不用管。

1.4 三条路线对比

方案网络加载磁盘缓存占位 / 失败图图像变换业务工作量
系统 Image支持无自行实现仅 objectFit / 圆角中,能力弱
手写下载 + 自建缓存自行实现自行实现自行实现自行实现高
@ohos/imageknife支持二级 LRU内置内置 7 种低

结论:只要应用存在网络图,就值得直接上 ImageKnife。自己写一套缓存的维护成本,远高于引入一个成熟的库。


二、环境搭建

DevEco Studio 及 SDK 版本配置参考官方指南,本文不展开:

  • DevEco Studio 26.0.0 Release(SDK API 26)
  • ohpm 包管理工具(DevEco 内置)
  • 运行目标:DevEco 模拟器(真机行为一致)

网络权限是本文唯一需要提前配置的项。本地 rawfile 图不需要权限,但加载网络图必须在 entry/src/main/module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" }
    ]
  }
}

漏掉这一条的表现很有迷惑性:loadSrc 是网络地址时永远走 errorholderSrc,代码本身完全正确,日志里是网络不可用。排查网络图不显示时,优先看这里。


三、API 全景

3.1 组件与配置对象

API / 字段类型作用
ImageKnifeComponent({ imageKnifeOption })组件图片加载组件,唯一必填配置是 imageKnifeOption
loadSrcstring | Resource主图源,支持网络 URL 与 $rawfile 资源
placeholderSrcResource | string | PixelMap加载中占位图
errorholderSrc同上加载失败占位图
objectFitImageFit填充模式(Contain / Cover / Fill)
borderBorderOptions圆角与边框,圆角用 { radius: 24 }
transformationPixelMapTransformation图像变换器
writeCacheStrategyCacheStrategy缓存策略
progressListener(progress: number) => void下载进度回调,取值范围 0~100
onComplete() => void首帧可渲染时触发一次

3.2 内置变换器

变换器参数效果
BlurTransformation(radius)模糊半径高斯模糊,半径越大越糊
GrayScaleTransformation()无灰度
SepiaTransformation()无褐色 / 复古
BrightnessTransformation(v)0~1亮度调整,越大越亮
InvertTransformation()无反色
CropCircleTransformation()无圆形裁剪
CropCircleWithBorderTransformation(w, color)描边宽 + RGB圆形 + 描边

3.3 CacheStrategy 缓存策略

策略行为适用场景
CacheStrategy.Default内存 + 磁盘二级缓存绝大多数网络图
CacheStrategy.Memory仅内存缓存一次会话内的临时图
CacheStrategy.File仅磁盘缓存大图、列表缩略图
CacheStrategy.None不缓存,每次都重新加载验证码、一次性签名 URL

底层按 LRU 淘汰。命中内存缓存时既不下载也不解码,这是「二次进入秒开」的根本原因。


四、集成步骤

4.1 安装依赖

在 entry/oh-package.json5 添加:

{
  "dependencies": {
    "@ohos/imageknife": "3.2.10"
  }
}

执行安装:

ohpm install

安装成功后工程根目录出现 oh_modules/@ohos/imageknife,同时 oh-package-lock.json5 会生成对应的锁定记录。如果 oh_modules 下没有这个目录,说明依赖没装上,后续 import 会直接编译报错。

4.2 准备图片素材

将图片放到 entry/src/main/resources/rawfile/ 目录,用 $rawfile(xxx) 引用:

entry/src/main/resources/rawfile/
  photo.jpg        # 主图(滤镜 / 圆角演示)
  scene.jpg        # 风景图(圆形裁剪 / 褐色演示)
  placeholder.png  # 占位图
  error.png        # 失败图
  anim.gif         # GIF 动图
  vector.svg       # SVG 矢量图

rawfile 下的资源不参与资源编译,适合直接放原始图片。$rawfile('photo.jpg') 返回一个 Resource 对象,可以直接赋给 loadSrc,也可以赋给 placeholderSrc / errorholderSrc。

4.3 引入组件与类型

import {
  ImageKnifeComponent,
  ImageKnifeOption,
  CacheStrategy,
  BlurTransformation,
  GrayScaleTransformation,
  SepiaTransformation,
  BrightnessTransformation,
  InvertTransformation,
  CropCircleTransformation,
  CropCircleWithBorderTransformation
} from '@ohos/imageknife';

4.4 最小可运行页面

@Entry
@Component
struct Index {
  @State opt: ImageKnifeOption = new ImageKnifeOption();

  aboutToAppear(): void {
    this.opt.loadSrc = $rawfile('photo.jpg');
    this.opt.objectFit = ImageFit.Cover;
  }

  build() {
    Column() {
      ImageKnifeComponent({ imageKnifeOption: this.opt })
        .width(150)
        .height(110)
        .borderRadius(8)
    }
    .width('100%')
    .height('100%')
  }
}

有一个必须记住的点:ImageKnifeOption 一定要用 @State 修饰。组件内部通过 @ObjectLink 感知配置变化,如果写成普通成员变量,后续修改 objectFit、切换 loadSrc 都不会触发重新渲染,页面看起来像「卡住了」。


五、基础加载:loadSrc / 占位图 / 失败图

在这里插入图片描述

5.1 三种图源写法

// 本地 rawfile
opt.loadSrc = $rawfile('photo.jpg');

// 网络图片
opt.loadSrc = 'https://<你的图片服务器>/photo.png';

// 运行时拿到的资源(例如相册选择结果)
opt.loadSrc = this.pickedResource;

loadSrc 的类型是 string | Resource,网络地址走字符串,本地与运行时资源走 Resource。库内部按类型分派加载器,业务不需要自己判断。

5.2 占位图与失败图

const net = new ImageKnifeOption();
net.loadSrc = NET_URL;
net.objectFit = ImageFit.Cover;
net.placeholderSrc = $rawfile('placeholder.png');   // 加载中显示
net.errorholderSrc = $rawfile('error.png');         // 加载失败显示

两个字段的触发时机完全不同:

字段触发时机触发条件
placeholderSrc请求发出 → 首帧渲染完成之间loadSrc 尚未就绪
errorholderSrc加载链路最终失败网络不可用、404、解码失败

实践中有两个容易误判的现象:

  • 占位图一闪而过:本地图几乎瞬时命中,占位图的展示窗口只有一帧,肉眼很难捕捉,这是正常的,不代表配置没生效。想确认可以临时把 loadSrc 换成慢速网络图。
  • 失败图不出现:必须让 loadSrc 真的失败才会触发,例如一个不存在的域名。仅靠网络慢是不会走失败图的。
基础加载/占位/失败图

basic 页签:左为基础加载 loadSrc 本地图,中为网络图加载中显示 placeholder,右为无效 URL 触发 errorholder 失败图

5.3 组件渲染与尺寸

ImageKnifeComponent({ imageKnifeOption: opt })
  .width(150)
  .height(110)
  .borderRadius(8)

width / height 建议显式给出,配合 objectFit 决定内容如何落进容器。不写尺寸时图片会按原始宽高比撑开,在列表里会造成行高跳动。


六、objectFit 填充模式

optContain.objectFit = ImageFit.Contain; // 保持比例完整显示
optCover.objectFit   = ImageFit.Cover;   // 保持比例裁剪填满
optFill.objectFit    = ImageFit.Fill;    // 拉伸填满(可能变形)
模式行为是否裁剪是否变形典型场景
Contain完整显示,容器内留白否否详情大图、GIF、SVG
Cover按短边撑满,超出部分裁掉是否头像、卡片封面
Fill直接拉伸到容器尺寸否是纯色背景、拉伸无所谓的装饰图
objectFit 填充模式

fit 页签:Contain 完整显示、Cover 裁剪填满、Fill 拉伸填满三种模式对比

列表里的封面图几乎都用 Cover:容器比例固定、图片比例千奇百怪,Cover 能保证不留白又不变形,代价是上下或左右会被裁掉一部分。反过来,构图比填满更重要的图(商品主图、详情长图)应该用 Contain。

Fill 是三者里最容易踩坑的:它不裁剪也不保持比例,只是把图硬拉到容器尺寸。只有对失真不敏感的场景(纯色底、品牌色块)才建议使用。


七、圆角与圆形裁剪

在这里插入图片描述

7.1 圆角:border.radius

optRounded.border = { radius: 24 };

7.2 圆形:CropCircleTransformation

optCircle.transformation = new CropCircleTransformation();

7.3 圆形带描边

optCircleBorder.transformation =
  new CropCircleWithBorderTransformation(8, { r_color: 52, g_color: 152, b_color: 219 });

三种方式的本质差别在于「在哪一层生效」:

方式实现层效果备注
border.radius组件绘制层圆角矩形不改变图片像素,开销最小
CropCircleTransformationPixelMap 变换层正圆按短边裁圆,非正方形源图会被裁边
CropCircleWithBorderTransformationPixelMap 变换层正圆 + 描边参数为描边宽度与描边 RGB

关键结论:border.radius 只是绘制时的圆角遮罩,图片本身没有变化;两个 CropCircle 变换器是真的在解码后的 PixelMap 上裁出圆形,因此对非正方形的原图会「切掉」边角内容。做头像时要先确认源图是否是正方形,否则人脸可能被裁掉一部分。

圆角与圆形裁剪

shape 页签:radius:24 圆角矩形、CropCircle 圆形裁剪、CropCircleWithBorder 圆形带蓝色描边


八、五种滤镜变换

optBlur.transformation   = new BlurTransformation(15);
optGray.transformation   = new GrayScaleTransformation();
optSepia.transformation  = new SepiaTransformation();
optBright.transformation = new BrightnessTransformation(0.35);
optInvert.transformation = new InvertTransformation();
变换器参数取值效果典型场景
BlurTransformation半径 15高斯模糊背景模糊、毛玻璃底层
GrayScaleTransformation无灰度失效态、历史图片
SepiaTransformation无褐色复古风、老照片
BrightnessTransformation0.35提亮夜景补光、暗图预览
InvertTransformation无反色深色主题素材、创意效果
图像滤镜变换

filter 页签:Blur(15) 模糊、GrayScale 灰度、Sepia 褐色、Brightness 提亮、Invert 反色五种滤镜效果

三个实践结论:

  1. 变换发生在解码之后,属于像素级运算,有实实在在的开销。列表页批量出缩略图时,应该先用小尺寸图源再叠滤镜,不要对着原图直接上 BlurTransformation。
  2. v3.2.10 的 transformation 是单值字段,一次只能挂一个变换器。需要组合效果(例如先模糊再圆形裁剪)时,要自己实现 PixelMapTransformation,在内部串联处理。
  3. 能用 border.radius 解决的,不要用变换器解决。前者零开销,后者要走一遍 PixelMap 处理,只是为了让角变圆并不划算。

九、GIF 动图与 SVG 矢量图

optGif.loadSrc = $rawfile('anim.gif');   // GIF 自动播放
optGif.objectFit = ImageFit.Contain;

optSvg.loadSrc = $rawfile('vector.svg'); // SVG 矢量渲染
optSvg.objectFit = ImageFit.Contain;
GIF 与 SVG

format 页签:左为 GIF 动图(本地 anim.gif)自动播放,右为 SVG 矢量图(本地 vector.svg)渲染结果

四个要点:

  • 格式自动识别:业务不需要判断文件后缀,也不用为 GIF / SVG 换不同组件,loadSrc 给什么就渲染什么。
  • GIF 自动播放:组件加载后动图即开始播放,不需要额外调用播放 API。
  • 两种格式都建议用 Contain:GIF 的构图是固定的,Cover 会裁掉动画边缘;SVG 本身是矢量,拉伸没有收益,Contain 才能保证长宽比正确。
  • SVG 渲染成本高于位图,列表里大量使用要谨慎,能用位图替代就替代。

十、缓存策略与进度监听

10.1 网络图 + 二级缓存 + 进度回调

optCache.loadSrc = NET_URL;
optCache.objectFit = ImageFit.Contain;
optCache.placeholderSrc = $rawfile('placeholder.png');
optCache.errorholderSrc = $rawfile('error.png');
optCache.writeCacheStrategy = CacheStrategy.Default;
optCache.progressListener = (p: number): void => {
  this.progressText = `${Math.floor(p)}%`;
};
optCache.onComplete = (): void => {
  this.completeText = 'onComplete: 加载完成';
};
缓存与进度

cache 页签:网络图使用 Default 缓存策略,progressListener 实时显示下载百分比,onComplete 回调显示加载完成

10.2 二级缓存是怎么工作的

CacheStrategy.Default 会同时写入内存与磁盘两级:

级别命中后的动作生命周期
内存直接取 PixelMap,不下载不解码随进程存活
磁盘跳过网络请求,仍需解码随应用数据存活

因此同一张网络图会经历三个阶段:

  1. 首次进入:下载 → 解码 → 渲染,progressListener 全程回调。
  2. 二次进入(同进程):命中内存缓存,直接渲染,进度回调不会触发。
  3. 进程重启后进入:命中磁盘缓存,跳过下载但仍需解码。

这就是「二次进入秒开」的来源。理解这三段的差别,能解释很多「为什么进度条不动了」的疑问——不是回调失效,而是压根没走网络。

四种策略的差异见第三章表格。其中 CacheStrategy.None 要特别小心:它意味着每次进入页面都重新发一次请求,只适合验证码、一次性签名 URL 这类内容,普通内容图慎用。

10.3 进度与完成回调的边界

  • progressListener 回调的是 0~100 的进度值,只在真正发生网络下载时持续回调。命中缓存时这条路径不会走到,进度文本可能一直停在初始值。
  • onComplete 在首帧可渲染时触发一次,适合用来收掉自定义 loading、上报曝光埋点。它和 progressListener 不冲突,两者可以同时配置。

十一、完整示例工程

工程用 @Builder 封装单元格,顶部 Tab 切换六类演示,每个 ImageKnifeOption 都用 @State 修饰并在 aboutToAppear 中统一初始化:

import {
  ImageKnifeComponent,
  ImageKnifeOption,
  CacheStrategy,
  BlurTransformation,
  GrayScaleTransformation,
  SepiaTransformation,
  BrightnessTransformation,
  InvertTransformation,
  CropCircleTransformation,
  CropCircleWithBorderTransformation
} from '@ohos/imageknife';

// 本地素材(rawfile 目录,用 $rawfile 引用)
const PHOTO: Resource = $rawfile('photo.jpg');
const SCENE: Resource = $rawfile('scene.jpg');
const PLACEHOLDER: Resource = $rawfile('placeholder.png');
const ERROR_HOLDER: Resource = $rawfile('error.png');
const GIF: Resource = $rawfile('anim.gif');
const SVG: Resource = $rawfile('vector.svg');
// 网络图片:换成任意可访问的 HTTPS 图片地址
const NET_URL: string = 'https://example.com/demo.gif';

interface DemoTab {
  key: string;
  label: string;
}

// 工具函数:构造带主图与填充模式的基础 option
function baseOption(src: string | Resource, fit: ImageFit): ImageKnifeOption {
  const o = new ImageKnifeOption();
  o.loadSrc = src;
  o.objectFit = fit;
  return o;
}

@Entry
@Component
struct Index {
  @State currentTab: string = 'basic';
  @State progressText: string = '-';
  @State completeText: string = '-';

  // ============ 各演示项的 ImageKnifeOption(必须用 @State 以配合组件的 @ObjectLink)============
  @State optBasic: ImageKnifeOption = new ImageKnifeOption();
  @State optPlaceholder: ImageKnifeOption = new ImageKnifeOption();
  @State optError: ImageKnifeOption = new ImageKnifeOption();
  @State optFitContain: ImageKnifeOption = new ImageKnifeOption();
  @State optFitCover: ImageKnifeOption = new ImageKnifeOption();
  @State optFitFill: ImageKnifeOption = new ImageKnifeOption();
  @State optRounded: ImageKnifeOption = new ImageKnifeOption();
  @State optCircle: ImageKnifeOption = new ImageKnifeOption();
  @State optCircleBorder: ImageKnifeOption = new ImageKnifeOption();
  @State optBlur: ImageKnifeOption = new ImageKnifeOption();
  @State optGray: ImageKnifeOption = new ImageKnifeOption();
  @State optSepia: ImageKnifeOption = new ImageKnifeOption();
  @State optBright: ImageKnifeOption = new ImageKnifeOption();
  @State optInvert: ImageKnifeOption = new ImageKnifeOption();
  @State optGif: ImageKnifeOption = new ImageKnifeOption();
  @State optSvg: ImageKnifeOption = new ImageKnifeOption();
  @State optCache: ImageKnifeOption = new ImageKnifeOption();

  private tabs: DemoTab[] = [
    { key: 'basic',   label: '基础/占位/失败' },
    { key: 'fit',     label: 'objectFit' },
    { key: 'shape',   label: '圆角/圆形' },
    { key: 'filter',  label: '滤镜变换' },
    { key: 'format',  label: 'GIF/SVG' },
    { key: 'cache',   label: '缓存/进度' }
  ];

  aboutToAppear(): void {
    // 1. 基础加载
    this.optBasic = baseOption(PHOTO, ImageFit.Cover);

    // 2. 占位图(加载中显示)
    this.optPlaceholder = baseOption(NET_URL, ImageFit.Cover);
    this.optPlaceholder.placeholderSrc = PLACEHOLDER;

    // 3. 失败图(加载错误显示)
    this.optError = baseOption('https://invalid.example.com/not-exist-404.png', ImageFit.Cover);
    this.optError.placeholderSrc = PLACEHOLDER;
    this.optError.errorholderSrc = ERROR_HOLDER;

    // 4. objectFit 三种模式
    this.optFitContain = baseOption(PHOTO, ImageFit.Contain);
    this.optFitCover = baseOption(PHOTO, ImageFit.Cover);
    this.optFitFill = baseOption(PHOTO, ImageFit.Fill);

    // 5. 圆角(border.radius)
    this.optRounded = baseOption(SCENE, ImageFit.Cover);
    this.optRounded.border = { radius: 24 };

    // 6. 圆形裁剪
    this.optCircle = baseOption(PHOTO, ImageFit.Cover);
    this.optCircle.transformation = new CropCircleTransformation();

    // 7. 圆形带描边
    this.optCircleBorder = baseOption(SCENE, ImageFit.Cover);
    this.optCircleBorder.transformation =
      new CropCircleWithBorderTransformation(8, { r_color: 52, g_color: 152, b_color: 219 });

    // 8~12. 滤镜变换
    this.optBlur = baseOption(PHOTO, ImageFit.Cover);
    this.optBlur.transformation = new BlurTransformation(15);

    this.optGray = baseOption(PHOTO, ImageFit.Cover);
    this.optGray.transformation = new GrayScaleTransformation();

    this.optSepia = baseOption(SCENE, ImageFit.Cover);
    this.optSepia.transformation = new SepiaTransformation();

    this.optBright = baseOption(PHOTO, ImageFit.Cover);
    this.optBright.transformation = new BrightnessTransformation(0.35);

    this.optInvert = baseOption(SCENE, ImageFit.Cover);
    this.optInvert.transformation = new InvertTransformation();

    // 13. GIF 动图
    this.optGif = baseOption(GIF, ImageFit.Contain);

    // 14. SVG 矢量图
    this.optSvg = baseOption(SVG, ImageFit.Contain);

    // 15. 缓存策略 + 进度监听 + onComplete
    this.optCache = baseOption(NET_URL, ImageFit.Contain);
    this.optCache.placeholderSrc = PLACEHOLDER;
    this.optCache.errorholderSrc = ERROR_HOLDER;
    this.optCache.writeCacheStrategy = CacheStrategy.Default;
    this.optCache.progressListener = (p: number): void => {
      this.progressText = `${Math.floor(p)}%`;
    };
    this.optCache.onComplete = (): void => {
      this.completeText = 'onComplete: 加载完成';
    };
  }

  @Builder
  cell(title: string, opt: ImageKnifeOption, w: number, h: number) {
    Column({ space: 6 }) {
      ImageKnifeComponent({ imageKnifeOption: opt })
        .width(w)
        .height(h)
        .backgroundColor('#F0F0F0')
        .borderRadius(8)
      Text(title).fontSize(12).fontColor('#666666')
    }
    .margin(6)
  }

  build() {
    Column() {
      // 标题
      Row() {
        Text('@ohos/imageknife · OpenHarmony')
          .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333').layoutWeight(1)
        Text('v3.2.10').fontSize(12).fontColor('#999999')
      }.width('100%').padding(12)

      // Tab 切换
      Scroll() {
        Row({ space: 8 }) {
          ForEach(this.tabs, (t: DemoTab) => {
            Button(t.label)
              .fontSize(13)
              .height(34)
              .backgroundColor(this.currentTab === t.key ? '#007DFF' : '#E8E8E8')
              .fontColor(this.currentTab === t.key ? '#FFFFFF' : '#333333')
              .onClick(() => { this.currentTab = t.key; })
          }, (t: DemoTab) => t.key)
        }.padding({ left: 12, right: 12 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .width('100%')
      .align(Alignment.Start)

      // 内容区
      Scroll() {
        Column() {
          if (this.currentTab === 'basic') {
            Text('基础加载 / 占位图 / 失败图').fontSize(14).fontColor('#333').width('100%').padding(10)
            Row() {
              this.cell('基础加载 loadSrc', this.optBasic, 150, 110)
              this.cell('placeholder 占位', this.optPlaceholder, 150, 110)
            }
            Row() {
              this.cell('errorholder 失败图', this.optError, 150, 110)
            }
          } else if (this.currentTab === 'fit') {
            Text('objectFit 填充模式').fontSize(14).fontColor('#333').width('100%').padding(10)
            Row() {
              this.cell('Contain', this.optFitContain, 150, 120)
              this.cell('Cover', this.optFitCover, 150, 120)
            }
            Row() {
              this.cell('Fill', this.optFitFill, 150, 120)
            }
          } else if (this.currentTab === 'shape') {
            Text('圆角 border.radius / 圆形裁剪').fontSize(14).fontColor('#333').width('100%').padding(10)
            Row() {
              this.cell('圆角 radius:24', this.optRounded, 150, 120)
              this.cell('圆形 CropCircle', this.optCircle, 120, 120)
            }
            Row() {
              this.cell('圆形+边框', this.optCircleBorder, 120, 120)
            }
          } else if (this.currentTab === 'filter') {
            Text('图像变换 transformation').fontSize(14).fontColor('#333').width('100%').padding(10)
            Row() {
              this.cell('模糊 Blur(15)', this.optBlur, 120, 100)
              this.cell('灰度 GrayScale', this.optGray, 120, 100)
            }
            Row() {
              this.cell('褐色 Sepia', this.optSepia, 120, 100)
              this.cell('亮度 Brightness', this.optBright, 120, 100)
            }
            Row() {
              this.cell('反色 Invert', this.optInvert, 120, 100)
            }
          } else if (this.currentTab === 'format') {
            Text('多格式:GIF 动图 / SVG 矢量图').fontSize(14).fontColor('#333').width('100%').padding(10)
            Row() {
              this.cell('GIF 动图', this.optGif, 150, 150)
              this.cell('SVG 矢量', this.optSvg, 150, 150)
            }
          } else if (this.currentTab === 'cache') {
            Text('缓存策略 + 进度监听 + onComplete').fontSize(14).fontColor('#333').width('100%').padding(10)
            this.cell('网络图(Default 缓存)', this.optCache, 260, 180)
            Column({ space: 6 }) {
              Text(`progressListener: ${this.progressText}`).fontSize(13).fontColor('#007DFF')
              Text(this.completeText).fontSize(13).fontColor('#34C759')
              Text('二次进入本页从缓存秒开(内存+磁盘二级 LRU)').fontSize(12).fontColor('#999')
            }.padding(12).width('100%').alignItems(HorizontalAlign.Start)
          }
        }.width('100%')
      }
      .layoutWeight(1)
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%').height('100%').backgroundColor('#FFFFFF')
  }
}

结构上只有三件事值得强调:所有 ImageKnifeOption 用 @State 修饰、cell 用 @Builder 复用、Tab 只切换 currentTab 而不重建 option。选项复用是这里有意的设计——每切换一次页签就重建 option,会导致图片重新走一遍加载链路,缓存命中率被自己拉低。


十二、常见问题

Q1:修改 ImageKnifeOption 属性后图片不刷新?

ImageKnifeOption 必须用 @State 修饰,组件内部通过 @ObjectLink 感知变化。声明为普通成员变量时,改属性不会触发重渲染。

Q2:网络图加载不出来,一直显示失败图?

按顺序排查三点:module.json5 是否声明了 ohos.permission.INTERNET;地址本身是否可访问;HTTP 明文地址还需要在应用配置中允许明文传输。绝大多数情况是第一条。

Q3:占位图与失败图都不显示?

placeholderSrc 只在加载过程中显示,本地图秒加载时窗口极短,可能一闪而过;errorholderSrc 需要 loadSrc 真正失败(例如无效域名)才会触发。想验证配置是否生效,把 loadSrc 换成一个慢速网络地址即可。

Q4:多个 transformation 能叠加吗?

v3.2.10 的 transformation 字段是单个变换器。需要组合效果(先模糊再圆形裁剪)时,要自己实现 PixelMapTransformation,在内部按顺序串联处理。

Q5:缓存占用磁盘空间过大怎么办?

ImageKnife 内部按 LRU 自动淘汰,也可以通过全局配置调整内存 / 磁盘缓存上限;对不需要长期缓存的图片,把 writeCacheStrategy 设为 CacheStrategy.Memory(仅内存)或 None(不缓存)即可。

Q6:progressListener 一直不回调?

它只在真正发生网络下载时回调。如果图片已经命中内存或磁盘缓存,自然不会有进度回调——这是缓存生效的表现,不是回调失效。

Q7:border.radius 和 CropCircleTransformation 该用哪个?

只要圆角矩形就用 border.radius,它不改变图片像素、开销最小;需要正圆时才用 CropCircleTransformation,注意它会按短边裁掉非正方形源图的边角内容。

Q8:GIF 能暂停或控制播放吗?

组件加载后 GIF 自动播放,不需要业务干预,当前版本也没有暴露暂停 / 单帧控制能力。需要精细控制动效的应用,建议改用 Lottie 这类支持帧级控制的方案。


十三、总结

@ohos/imageknife v3.2.10 在 OpenHarmony ArkTS 工程里的集成流程很短:ohpm install 装依赖,声明 @State ImageKnifeOption 配置对象,用 ImageKnifeComponent 渲染,最后别忘了在 module.json5 里加网络权限。

本文实测验证的能力清单:

  • 基础加载(本地 rawfile / 网络 URL)与 loadSrc 双类型分派
  • placeholderSrc / errorholderSrc 的触发时机与行为差异
  • objectFit 三种填充模式的裁剪与变形差异
  • 圆角(border.radius)与两种圆形裁剪变换器的实现层差别
  • 五种内置滤镜:模糊 / 灰度 / 褐色 / 亮度 / 反色
  • GIF 自动播放与 SVG 矢量渲染
  • CacheStrategy 四种策略与内存 + 磁盘二级 LRU 缓存
  • progressListener 进度回调与 onComplete 完成回调

对需要网络图片加载与缓存的鸿蒙原生应用来说,@ohos/imageknife 是目前最省事的方案:它把「图片加载」从一个业务反复手写的痛点,变成了一个配置对象。


参考资料

Logo

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

更多推荐