鸿蒙特性插件 | Flutter 鸿蒙 PixelMap 插件 flutter_ohos_pixelmap 从 0 到 1 实战跑通

插件版本:flutter_ohos_pixelmap 0.1.0|验证环境:Flutter 鸿蒙 SDK 3.44.9(oh-3.44.9-dev)|DevEco Studio 26.0.0.821|HarmonyOS 7.0.0(API 26)

Flutter 在鸿蒙上做图片处理,很容易下意识去找 Android 的 Bitmap 或 iOS 的 UIImage。这三条路在 HarmonyOS 上都走不通。鸿蒙图像管线的一等公民是 PixelMap:解码、裁剪、旋转、读写像素、再编码,全部围着它转。RNOH 侧已经有 react-native-image-pixelmap 把这层能力露出来了,Flutter 侧此前没有对等的鸿蒙特性插件。本文记录我从零做 flutter_ohos_pixelmap 的过程——不是把某个 pub.dev 库搬过来,而是直接把 @kit.ImageKit@kit.ArkGraphics2D 接到 Dart。

工程上最容易写散的不是「调几个 API」,而是三件事:PixelMap 不能走 StandardMessageCodec,必须用整数句柄;几何变换是原地 Promise,滤镜却会产出另一张图;Flutter 打过来的 Uint8List 在 ArkTS 侧可能带着 byteOffset。后面每一段都围着这三件事展开。
在这里插入图片描述

一、环境搭建

本章不展开,直接引用官方文档:Flutter OH 开发环境搭建指导

在这里插入图片描述

本文实际使用:

Flutter OHoh-3.44.9-dev
Dart SDK随 Flutter OH 3.44.9
DevEco Studio26.0.0.821
HarmonyOS SDK7.0.0 (API 26)
插件产物HAR
原生语言ArkTS 严格模式(禁止 any
验证方式example 真机 / 模拟器 flutter run

flutter doctor -v 里 HarmonyOS toolchain 和 API 26 两项都是 [√] 后再继续。DevEco 6.x 默认 SDK 停在 API 24,编 flutter_ohos HAR 会缺 autoFillManager,必须用 DevEco 26.0.0 Release。
在这里插入图片描述

二、应用背景

2.1 业务里真实会碰到的场景

头像裁切、水印、缩略图、主题色取样、滤镜预览,这些需求在 Flutter 业务里非常常见:

  • 头像裁切:相册选图后按 1:1 中心裁剪,再压成 JPEG 上传。
  • 水印 / 角标:在指定坐标 setPixel 或整缓冲 writePixels 叠一层品牌色。
  • 列表缩略图:原图 scale(0.5, 0.5)encode(image/jpeg, quality: 80),减少列表内存。
  • 主题色取样:对 (8,8) 一类采样点 getPixel,给卡片背景配色。
  • 滤镜预览:灰度 / 模糊 / 提亮 / 反色要能「预览后取消」,原图不能被滤镜管线覆盖掉。

Android 用 Bitmap + Canvas,iOS 用 UIImage + Core Image。鸿蒙对应的是:

  • @kit.ImageKitImageSource / PixelMap / ImagePacker
  • @kit.ArkGraphics2DeffectKit.Filter

如果业务自己在每个页面里拼 MethodChannel,很快会把句柄泄漏、格式转换和滤镜生命周期写散。插件要做的就是把这条管线收成一个 Dart 对象:OhosPixelMap

example 启动后会先走 createFromRgba(256x256) 画一张渐变,再 encode() 成 PNG 预览。后面所有变换、像素读写、滤镜,都是在这张图上操作:

createFromRgba 渐变预览

启动后首屏:256x256 RGBA 渐变 + getInfo 文本
在这里插入图片描述

2.2 为什么要做成鸿蒙特性插件,而不是三方库共建

这不是「三方库共建」。PixelMap 是鸿蒙图像栈自己的类型,Android/iOS 没有同名对象。pub.dev 上的 image 包是纯 Dart 编解码,不碰系统 PixelMap,也用不上 effectKit。对标对象是本周已经过审的 flutter_ohos_formreact-native-ohos-formreact-native-image-pixelmap

做成 Flutter 插件的价值是:

  1. Dart 侧用统一句柄操作 native 图像,而不是把整张图反复打成 Uint8List 来回拷。
  2. 几何变换走 PixelMap 原地 API,滤镜走 effectKit,语义和系统文档一致。
  3. 和 RNOH 的 react-native-image-pixelmap 形成双端对等能力,同一套鸿蒙图像经验在 Flutter / RN 都能复用。

一句话:让 Flutter 应用在 HarmonyOS 上直接创建、变换、取样、编码 PixelMap,并且能调用系统 effectKit 滤镜。

2.3 三条路线为什么都走不通

路线为什么不选
直接适配 pub.dev image纯 Dart zlib/JPEG 实现,输出的是 Dart Image,不是鸿蒙 PixelMap。后续没法接 effectKit,也没法把结果交给系统图库 / Image 组件的 native 路径。
在业务工程里手写 MethodChannel句柄仓库、ArrayBuffer 偏移、ImageSource.release()、滤镜 clone 这些坑每个 App 都要踩一遍。
给插件硬塞 Android/iOS stubPixelMap 在另外两端没有对应物。stub 只会把「依赖阶段就该失败」推迟到运行时 UnimplementedError

所以 pubspec.yaml 只声明 ohos 平台。业务一旦 import 到非 ohos 工程,应该在依赖阶段失败。

三、接口全景与能力映射

Dart 对外只有一个句柄对象 OhosPixelMap,通道名固定为 flutter_ohos_pixelmap。native 侧入口是 FlutterOhosPixelmapPlugin

3.1 创建 / 解码

Dart APIMethodChannel鸿蒙 API返回
OhosPixelMap.isSupported()isSupported恒为 truebool
OhosPixelMap.createEmpty(width, height)createEmptyimage.createPixelMapSync(options)新句柄
OhosPixelMap.createFromRgba(rgba, width, height)createFromRgbaimage.createPixelMap(buffer, options)新句柄
OhosPixelMap.decodeFromBytes(bytes)decodeFromBytescreateImageSource(buffer).createPixelMap()新句柄
OhosPixelMap.decodeFromPath(path)decodeFromPathcreateImageSource(path).createPixelMap()新句柄

createEmpty / createFromRgba 一律用 RGBA_8888 + editable: true + AlphaType.UNPREMULrgba.length 必须等于 width * height * 4,这个校验放在 Dart 侧,避免无效缓冲打到 native。

3.2 查询 / 几何变换 / 克隆

Dart API行为鸿蒙 API
getInfo()读宽高、pixelFormat、alphaType、density、stride、mimeType、byteCountgetImageInfo() + getBytesNumberPerRow() + getPixelBytesNumber()
scale(sx, sy)原地缩放,0.5 表示减半pixelMap.scale
rotate(degree)原地顺时针旋转pixelMap.rotate
flip(horizontal, vertical)原地翻转pixelMap.flip
crop(x, y, width, height)原地裁剪,区域必须落在当前边界内pixelMap.crop(Region)
opacity(rate)原地改透明度,rate ∈ [0.0, 1.0]pixelMap.opacity
clone()复制一份,返回新句柄pixelMap.clone

3.3 像素读写 / 编码 / 滤镜 / 生命周期

Dart API行为鸿蒙 API
getPixel(x, y)读 1x1readPixels(PositionArea)
setPixel(x, y, PixelColor)写 1x1writePixels(PositionArea)
readPixels()整图 RGBAreadPixelsToBuffer
writePixels(rgba)整图覆盖writeBufferToPixels
encode(format, quality)PNG / JPEG 打包ImagePacker.packing
blur(radius) / grayscale() / brightness(bright) / invert()先 clone 再滤镜,返回新句柄effectKit.createEffect
dispose()释放 native 对象,Dart handle 置 0PixelMap.release

语义必须分开,不能抹成「所有操作都返回新对象」:

调用当前 handle原因
scale / rotate / flip / crop / opacity原地修改对应 PixelMap 自身的 Promise 方法
blur / grayscale / brightness / invert不变,另给新 handleFilter 会生成另一张 PixelMap
clone不变,另给新 handle给业务留一份备份再做破坏性裁剪
dispose置 0之后再调任何方法抛 StateError

opacity 最容易和滤镜搞混:看起来也是「效果变了」,但它改的是当前 PixelMap,handle 不变。example 点「透明度 0.45」后预览变淡,info 里的句柄数字还是原来那个。

opacity 0.45

原地 opacity(0.45),预览变淡但 handle 不变

四、从 0 到 1 的六阶段路线

对照本周过审的 flutter_ohos_form,我把插件拆成六段,每段都有可运行产物,而不是写完再一次性联调。

阶段目标验收
1. 骨架flutter plugin + 仅声明 ohos 平台pluginClass 能被 example 拉起,isSupported() 返回 true
2. 句柄仓库ArkTS PixelMapStore创建 / 释放句柄不串号;onDetachedFromEnginereleaseAll
3. 编解码createEmpty / createFromRgba / decodeFromBytes / encodeexample 能看到 PNG 预览,JPEG 往返后仍能显示
4. 几何变换scale / rotate / flip / crop / opacity预览尺寸、方向、透明度变化
5. 像素级getPixel / setPixel / readPixels / writePixels(8,8) 写出 2x2 红点,日志里 before/after 颜色不同
6. 滤镜blur / grayscale / brightness / invert返回新句柄,原图仍可 dispose;预览区换成滤镜结果

阶段 6 的观感是这样:单独 blur(12) 后,example 会把原图替换成滤镜结果。看起来像「图被改了」,其实是另一份 PixelMap,原句柄已经被 Demo 释放。

effectKit blur

单独 blur(12),原图已被 example 替换为滤镜结果

阶段 1 卡过一次:getUniqueClassName() 写错,example 编译过了,但所有调用都是 notImplemented。后面单独放到踩坑表。

五、工程骨架

5.1 目录

flutter_ohos_pixelmap/
├── lib/
│   ├── flutter_ohos_pixelmap.dart      # 导出 OhosPixelMap / PixelMapInfo / PixelColor
│   └── src/
│       ├── ohos_pixel_map.dart         # Dart 句柄对象
│       ├── pixel_map_info.dart
│       └── pixel_color.dart
├── ohos/src/main/ets/components/plugin/
│   ├── FlutterOhosPixelmapPlugin.ets   # MethodCallHandler
│   ├── PixelMapStore.ets               # 句柄仓库
│   └── PixelMapTypes.ets               # 跨通道 payload
├── example/                            # 按接口分组的 Demo
├── README.md
├── README.OpenHarmony.md
└── README.OpenHarmony_CN.md

鸿蒙特性插件不要生成 Android/iOS 目录。flutter create --platforms=ohos 之后只保留 ohos。

5.2 只声明 ohos 平台

name: flutter_ohos_pixelmap
version: 0.1.0
homepage: https://atomgit.com/oh-flutter/flutter_ohos_pixelmap

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: FlutterOhosPixelmapPlugin

getUniqueClassName() 必须原样返回 FlutterOhosPixelmapPlugin。新版 flutter_ohos 用这个字符串和 pluginClass 对表:

const CHANNEL_NAME: string = 'flutter_ohos_pixelmap';
const CLASS_NAME: string = 'FlutterOhosPixelmapPlugin';

export default class FlutterOhosPixelmapPlugin implements FlutterPlugin, MethodCallHandler {
  getUniqueClassName(): string {
    return CLASS_NAME;
  }

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME);
    this.channel.setMethodCallHandler(this);
  }
}

写错的典型表现是:HAR 编过了,example 也起来了,Dart MethodChannel('flutter_ohos_pixelmap') 发出去没有 handler。

宿主 example/ohos/build-profile.json5 必须带 "signingConfig": "default"。这是审核硬性项,空数组 signingConfigs 可以留着让 DevEco 用默认调试证书,但 product 上这一行不能删。

六、关键决策

动手前先把这五条钉死,后面少返工。

决策选择理由
跨通道对象整数 handle,不传 PixelMapStandardMessageCodec 不认识鸿蒙 native 对象
几何变换原地修改当前 handle与 PixelMap Promise API 一致,少一次拷贝
滤镜clone 后返回新 handle预览 / 取消必须能回到原图
像素格式固定 RGBA_8888 + UNPREMULDart Image.memoryPixelColor 都按 RGBA 解释
空画布createPixelMapSync无像素缓冲,同步创建更简单;带 RGBA 的路径仍走异步 createPixelMap

另外两条工程决策:

  1. 参数读取用 call.argument(key),不要写 call.arguments。新版 flutter_ohos 的 MethodCall 字段就是 args,插件封装后的取值入口是 argument
  2. ArkTS 严格模式禁止 any。对象字面量先声明 image.InitializationOptionsimage.Regionimage.PositionAreaimage.PackingOptioncall.argument 的结果按 number / boolean / string / Object 显式断言。

七、ArkTS 实现详解

7.1 句柄仓库,而不是把 PixelMap 塞进 MethodChannel

Dart 只保存 int handle。仓库从 1 起号,0 留给「已释放」:

export class PixelMapStore {
  private readonly maps: Map<number, image.PixelMap> = new Map();
  private nextId: number = 1;

  put(pixelMap: image.PixelMap): number {
    const id: number = this.nextId;
    this.nextId = this.nextId + 1;
    this.maps.set(id, pixelMap);
    return id;
  }

  release(id: number): boolean {
    const pixelMap: image.PixelMap | undefined = this.maps.get(id);
    if (pixelMap === undefined) {
      return false;
    }
    this.maps.delete(id);
    try {
      pixelMap.release();
    } catch (error) {
      // 系统可能已经回收,忽略二次 release
    }
    return true;
  }
}

onDetachedFromEngine 里必须 releaseAll()。热重载或退出 example 时,引擎会拆插件;如果不把仓库清掉,native 图像会一直占解码缓冲,连续点「渐变 RGBA」几次就能把模拟器打满。

onMethodCall 里除了 isSupported 同步返回,其余全部进 async invoke。PixelMap / effectKit 都是 Promise API,不能在 handler 里同步 await 之外的路径直接 result.success。失败统一走 result.error('PIXELMAP_ERROR', message, null),Dart 侧就能看到可读的 PlatformException

7.2 编解码走 ImageSource / ImagePacker

空画布没有像素缓冲,用同步接口:

const options: image.InitializationOptions = {
  size: { width: width, height: height },
  pixelFormat: image.PixelMapFormat.RGBA_8888,
  editable: true,
  alphaType: image.AlphaType.UNPREMUL
};
const pixelMap: image.PixelMap = image.createPixelMapSync(options);
return this.store.put(pixelMap);

空画布没有像素填充,预览会偏黑或透明,但 getInfo() 已经能读到宽高和 editable RGBA_8888

createEmpty 空画布

空画布 128x128,editable RGBA_8888,无像素填充

从 RGBA 创建走另一个重载。editable: true 是后面 crop / setPixel / opacity 的前提,忘了这行,几何变换会在 native 里抛不可编辑。

解码压缩图必须先 createImageSource,用完立刻 source.release(),否则 ImageSource 和 PixelMap 会同时占一份解码缓冲:

const source: image.ImageSource = image.createImageSource(buffer);
try {
  const pixelMap: image.PixelMap = await source.createPixelMap();
  return this.store.put(pixelMap);
} finally {
  source.release();
}

编码对称地包一层 ImagePacker

const packer: image.ImagePacker = image.createImagePacker();
const option: image.PackingOption = {
  format: format,
  quality: quality
};
try {
  const packed: ArrayBuffer = await packer.packing(pixelMap, option);
  return new Uint8Array(packed);
} finally {
  packer.release();
}

PNG 忽略 quality,JPEG 才吃 0-100。example 里「JPEG 往返」按钮就是:encode(image/jpeg, quality: 80)decodeFromBytes → 再 encode() 成 PNG 给 Image.memory 预览。日志里能看到 JPEG bytes=...,用来确认 ImagePacker 真的输出了压缩数据,而不是原样把 RGBA 扔回去。

JPEG 往返

ImagePacker 打 JPEG 再 ImageSource 解码回来

7.3 几何变换原地改

scale / rotate / flip / opacity 都是当前 PixelMap 上的 Promise,改完不换 handle。crop 需要拼 image.Region

const region: image.Region = {
  x: x,
  y: y,
  size: { width: width, height: height }
};
await pixelMap.crop(region);

example 的「中心裁剪」先 getInfo() 拿当前宽高,再算 128x128(不足则取全图)的中心原点。这样无论前面有没有 scale(0.5, 0.5),裁剪都不会越界。

scale 是原地改当前句柄:点「缩小 0.5」之后,info 宽高会减半,预览跟着变,但 handle 不变。

scale 缩小 0.5

原地 scale(0.5, 0.5),info 宽高减半

再点「旋转 90°」,渐变方向会转过来,仍然是同一份 PixelMap:

scale / rotate

缩小 0.5 后再旋转 90°

opacity(0.45) 同样原地改,语义见第三章那张对比图。clone 是给破坏性操作留后路。滤镜内部也会 clone 一次,但那次 clone 的生命周期由插件接管;业务自己调 clone() 拿到的是另一个 Dart 对象,必须自己 dispose

7.4 单像素读写用 1x1 PositionArea

getPixel(x, y) 不是系统单独 API,必须对 1x1 区域 readPixels

const buffer: ArrayBuffer = new ArrayBuffer(4);
const area: image.PositionArea = {
  pixels: buffer,
  offset: 0,
  stride: 4,
  region: { x: x, y: y, size: { width: 1, height: 1 } }
};
await pixelMap.readPixels(area);
const view: Uint8Array = new Uint8Array(buffer);

stride 必须是 width * 4。1x1 时就是 4。写成 0 或漏写,读出来会是全 0,日志里 before/after 都变成 PixelColor(r: 0, g: 0, b: 0, a: 0),看起来像通道通了、其实像素根本没读到。

setPixel 对称地 writePixels。example 在 (8,8) (9,8) (8,9) (9,9) 写了 2x2 红点,方便截图时肉眼确认——单像素在 256x256 预览里几乎看不见。中心裁剪后再写,红点会落在预览左上附近:

crop / setPixel

中心裁剪后 (8,8) 出现 2x2 红点

整图读写走另一对 API:readPixelsToBuffer / writeBufferToPixels。缓冲长度用 pixelMap.getPixelBytesNumber(),不要自己用 width * height * 4 去猜,stride 对齐时 byteCount 可能更大。

7.5 effectKit:先 clone,再出新图

private async applyFilter(
  call: MethodCall,
  apply: (filter: effectKit.Filter) => void
): Promise<number> {
  const source: image.PixelMap = this.requirePixelMap(call);
  const cloned: image.PixelMap = await source.clone();
  const filter: effectKit.Filter = effectKit.createEffect(cloned);
  apply(filter);
  const output: image.PixelMap = await filter.getEffectPixelMap();
  return this.store.put(output);
}

blur(12) / grayscale() / brightness(1.4) / invert() 都走这条公共路径。effectKit.createEffect 拿到的是 Filter 管线,getEffectPixelMap() 产出的是另一张图。如果覆盖原 handle,业务无法做「滤镜预览 / 取消」。所以滤镜一律返回新句柄,原图由调用方决定是否 dispose

灰度之后再模糊,预览会换成新图,日志里的 handle 也会变:

effectKit grayscale / blur

灰度 + 模糊,句柄已换成新 PixelMap

example 里 _replace 会先 dispose 旧对象再接新句柄。真实业务如果要做「对比滑杆」,应该同时拿着原图 handle 和滤镜 handle,滑杆松手后再决定丢掉哪一份。

大图注意内存:滤镜路径会多一次 clone,峰值大约是两份 PixelMap。列表页批量出缩略图时,先 scale 再滤镜,不要对 4K 原图直接 blur

7.6 字节缓冲的坑

Flutter 把 Uint8List 打过通道后,ArkTS 侧可能是 Uint8Array,也可能已经是 ArrayBuffercreatePixelMap / createImageSource 要的是 ArrayBuffer,所以插件里统一收口:

private toArrayBuffer(value: Object): ArrayBuffer {
  if (value instanceof ArrayBuffer) {
    return value;
  }
  if (value instanceof Uint8Array) {
    const view: Uint8Array = value as Uint8Array;
    if (view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) {
      return view.buffer;
    }
    const copy: ArrayBuffer = new ArrayBuffer(view.byteLength);
    new Uint8Array(copy).set(view);
    return copy;
  }
  throw new Error('bytes must be Uint8Array or ArrayBuffer');
}

这里不能直接 view.buffer 完事:如果 byteOffset != 0,后面的 createImageSource 会把共享缓冲的前缀垃圾一起解码。表现是 JPEG 往返偶发花屏,或者 createFromRgba 颜色整体偏移。example 的渐变图左上是黑、右下偏青,如果整图发绿,优先查这一段。

7.7 参数断言

requireNumber / requireBoolean / requireString / requireObject 都做空值检查。ArkTS 严格模式下 call.argument(key) as number 不会在缺失时自动抛错,缺参数会变成 undefined,再传给 pixelMap.scale 就是一次很难读的 native 崩溃。缺 handle 时明确抛 invalid PixelMap handle: N,example 点「release」后再点「缩小 0.5」,日志里能看到这条,而不是引擎红屏。

八、Dart 句柄对象设计

8.1 只暴露 handle,不暴露 Channel

class OhosPixelMap {
  OhosPixelMap._(this.handle);

  static const MethodChannel _channel =
      MethodChannel('flutter_ohos_pixelmap');

  int handle;
  bool get isDisposed => handle <= 0;
}

构造函数是私有的。业务只能通过 createEmpty / createFromRgba / decodeFromBytes / decodeFromPath / clone / 滤镜方法拿到实例,避免自己 OhosPixelMap(0) 造一个空壳。

dispose 先把 handle 置 0,再发 release。顺序不能反:如果等 native 返回再置 0,连续两次 dispose(例如 _replaceState.dispose 叠在一起)会把同一个 id 释放两次。第二次 store.release 只是返回 false,但日志会吵。置 0 之后再调 scale 会走 _ensureAlive(),抛 OhosPixelMap has already been disposed

example 点「release」后,预览区必须变成「PixelMap 已释放」,再点变换只打日志不崩溃:

release 后空状态

release 后预览区必须变成「PixelMap 已释放」

8.2 元数据和颜色

PixelMapInfo 对应 native 的 PixelMapInfoPayload:宽、高、pixelFormat、alphaType、density、stride、mimeType、byteCount。example 预览区下面那行等宽文本就是 info.toString()

PixelColor 是 RGBA 四个 intgetPixel 读 map,setPixelcolor.toMap() 展开进参数。不要在 Dart 里用 Color.value 直接塞,Flutter 的 Color 是 ARGB,和 PixelMap 的 RGBA 顺序相反。

8.3 业务侧最小用法

final map = await OhosPixelMap.createFromRgba(
  rgba: bytes,
  width: 256,
  height: 256,
);
final info = await map.getInfo();
await map.scale(0.5, 0.5);
await map.crop(x: 16, y: 16, width: 96, height: 96);
final png = await map.encode();
final gray = await map.grayscale();
await map.dispose();
await gray.dispose();

滤镜返回值是新对象。上面这段如果只 disposemapgray 对应的 native PixelMap 会一直留在 PixelMapStore 里,直到引擎卸载。

九、example 怎么分层验收

example/lib/main.dart 按接口分组,而不是做一个「看起来能跑」的单按钮页。启动后自动 createFromRgba(256x256) 渐变,并 encode() 成 PNG 预览。底部有调用日志,成功打 ,失败打 加异常信息。

运行:

cd flutter_ohos_pixelmap/example
flutter pub get
flutter run -d <ohos-device>

9.1 分层验收清单

操作期望
创建启动 / 「渐变 RGBA」256x256 渐变,info 显示 256x256
创建「空画布」预览变黑或透明,尺寸 128x128
编解码「JPEG 往返」仍能预览,日志出现 JPEG bytes=
变换「缩小 0.5」info 变成约 128x128
变换「旋转 90°」渐变方向改变
变换「水平翻转」左右对调
变换「中心裁剪」尺寸收到 128 或当前短边
变换「透明度 0.45」预览变淡
像素「读写 (8,8)」左上附近出现红点,日志 before/after 不同
滤镜「模糊 / 灰度 / 提亮 / 反色」预览换成新图,handle 已更换
生命周期「release」预览区文字变成「PixelMap 已释放」
生命周期release 后再点变换日志 ,不崩溃

9.2 日志才是全链路验收

前面各章已经按接口穿插了预览截图。example 底部还有一块调用日志:成功打 ,失败打 加异常信息。release 后再点变换,这里必须出现 ,而不是进程崩溃。

调用日志

底部日志覆盖创建、变换、像素读写、滤镜、release 全链路

十、踩坑表

现象根因处理
插件编过了,Dart 调用全部 notImplementedgetUniqueClassName()pluginClass 不一致两边都写成 FlutterOhosPixelmapPlugin
call.arguments 编译失败或取值永远 undefined新版 MethodCall 字段是 args,取值入口是 argument(key)改成 call.argument('width') as number
ArkTS 编译报 Any / 对象字面量类型不匹配严格模式禁止 any,未标注 InitializationOptions先声明类型再赋值
JPEG 往返花屏、渐变整体偏色Uint8Array.byteOffset != 0 时直接用了 view.buffertoArrayBuffer 按偏移拷贝
连续创建后内存涨、热重载泄漏ImageSource / ImagePacker 没 release,或引擎卸载时没 releaseAllfinally 里 release;onDetachedFromEngine 清仓库
crop / setPixel 抛不可编辑createEmpty 忘了 editable: true创建 options 固定带上
getPixel 永远是 0,0,0,0PositionArea.stride 写错1x1 时 stride=4
滤镜后无法恢复原图用滤镜结果覆盖了原 handle滤镜返回新句柄,原图由调用方 dispose
单像素红点截图上看不见256x256 里 1px 太小example 写 2x2
example 装不上 / 审核卡签名signingConfig: defaultproduct 里补这一行
非 ohos 工程能依赖但运行崩溃误加了 Android/iOS stub特性插件只声明 ohos

十一、FAQ

Q1:为什么不直接适配 pub.dev 的 image 包?

image 是纯 Dart 编解码库,不碰鸿蒙 PixelMap,也用不上 effectKit。本周过审激励明确偏向「鸿蒙特性插件」。对标对象是 flutter_ohos_form / react-native-image-pixelmap,不是把已有 Dart 包再包一层。

Q2:getUniqueClassNamepluginClass 不一致会怎样?

引擎注册表对不上,Dart MethodChannel('flutter_ohos_pixelmap') 发出去没有 handler。表现是所有调用卡住或 notImplemented。两者必须都是 FlutterOhosPixelmapPlugin

Q3:ArkTS 严格模式最容易踩什么?

不要写 anycall.argument 的结果按 number / boolean / string / Object 显式断言。对象字面量先声明 image.InitializationOptionsimage.Regionimage.PositionAreaimage.PackingOption。滤镜回调的参数类型写成 (filter: effectKit.Filter) => void,不要省略。

Q4:滤镜为什么不原地改?

effectKit.createEffect 拿到的是 Filter 管线,getEffectPixelMap() 产出的是另一张图。如果覆盖原 handle,业务无法做「滤镜预览 / 取消」。所以滤镜一律返回新句柄,原图由调用方决定是否 dispose。几何变换之所以原地改,是因为 PixelMap 自己的 scale / rotate / crop 就是这个语义,插件不去「纠正」系统 API。

反色也是同一条路径:返回新句柄,example 预览换成滤镜结果。

effectKit invert

反色滤镜,同样返回新句柄

Q5:模拟器和真机差异?

模拟器可以走通创建、变换、读写像素和预览。ImagePacker 的 JPEG 体积、effectKit 模糊半径观感可能和真机不一致。发文截图优先真机;模拟器能证明通道通,但要在「已知限制」写明。壁纸服务、部分图形加速路径在模拟器上本来就有差异,不要拿模拟器 JPEG 体积当回归基线。

Q6:decodeFromPath 能直接读相册 URI 吗?

不能。它要的是沙箱内可读路径,例如 context.cacheDir 下的文件。相册 URI 需要先走 photoAccessHelperfile_picker 一类能力把文件拷进沙箱,再把本地路径传进来。插件不申请相册权限,也不解析 file:// / datashare://

Q7:能不能把 PixelMap 直接丢给 Image 组件?

当前版本不行。Flutter 的 Image.memory 吃的是编码后的 PNG/JPEG bytes,所以 example 每次刷新都 encode() 一次。后续如果要做零拷贝预览,需要再接 Texture / ImageProvider 的 ohos 后端,这已经超出 0.1.0 的范围。

Q8:Issue / PR 怎么写?

如果后续推到 oh-flutter/flutter_ohos_pixelmap

  • Issue:说明这是鸿蒙特性插件,列出 ImageKit / effectKit 映射表,附 example 截图,写明「不是三方库平移」。
  • PR:只提交白名单——ohos/example/ohos/pubspec.yamllib/、双语 README。不要带 .hvigoroh_modulesbuild/、签名材料。
  • 标题带 Flutter,正文只放 AtomGit 源站链接。

十二、已适配 / 未适配 / 已知限制

已适配

  • 空画布、RGBA 创建、bytes 解码、路径解码
  • 信息查询、原地几何变换(scale / rotate / flip / crop / opacity)、克隆、释放
  • 单像素和整缓冲读写
  • PNG / JPEG 编码(JPEG quality 0-100)
  • effectKit:blur / grayscale / brightness / invert
  • example 按接口分组 + 调用日志 + signingConfig: default

未适配

  • HDR PixelMap / toSdr
  • GPU PixelMap
  • setColorSpace / 色彩管理
  • packToFile 直接落盘
  • 动画帧 / 动图拆帧
  • Texture 零拷贝预览
  • Android / iOS(有意不提供 stub)

已知限制

  • 仅 ohos。非鸿蒙工程在依赖阶段就应该失败。
  • decodeFromPath 需要沙箱可读路径,不是相册 URI 自动授权。
  • 滤镜路径会多一次 clone,大图注意内存。
  • 模拟器编解码质量、effectKit 半径观感与真机可能不同。
  • Image.memory 预览每次都要 encode(),不适合 60fps 动画预览。
  • createFromRgba 的缓冲必须是紧凑 RGBA_8888,不接受 stride 对齐后的 padded buffer。

十三、和 RNOH image-pixelmap 的对位

Flutter 侧刻意跟 react-native-image-pixelmap 对齐能力边界,方便同一套鸿蒙图像经验复用:

能力RNOH react-native-image-pixelmapFlutter flutter_ohos_pixelmap
创建空画布 / RGBAcreateEmpty / createFromRgba
解码 bytes / pathdecodeFromBytes / decodeFromPath
几何变换原地 scale / rotate / flip / crop / opacity
像素读写getPixel / setPixel / readPixels / writePixels
编码encode PNG/JPEG
系统滤镜视版本effectKit blur / grayscale / brightness / invert
跨语言对象JS tag / handleDart int handle

两边都没有把 PixelMap 本身打过桥,都是 handle。差别在生命周期:RN 侧更多靠 JS GC 最终化,Flutter 侧要求业务显式 dispose,和 ui.Image.dispose() 的习惯一致。

十四、总结

flutter_ohos_pixelmap 把鸿蒙图像栈的三件套接到了 Flutter:ImageSource 负责进、PixelMap 负责算、ImagePacker 负责出,effectKit 负责系统滤镜。工程上最关键的不是把 API 罗列全,而是三件事:

  1. 句柄仓库:Dart 只持有 int,引擎卸载时 releaseAll
  2. 语义分开:几何变换原地改,滤镜返回新句柄,业务才能做预览 / 取消。
  3. 偏移安全转换Uint8ListArrayBuffer 必须处理 byteOffset,否则编解码会吃到共享缓冲的前缀垃圾。

这是鸿蒙特性插件,不是三方库平移。和本周已通过的 flutter_ohos_formreact-native-image-pixelmap 属于同一类题目。0.1.0 先把可演示、可释放、可截图的主路径做扎实;HDR、GPU PixelMap、Texture 预览留到后面有真实业务再加。

欢迎加入 CPF-Flutter 鸿蒙社区:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐