鸿蒙特性插件 | Flutter 鸿蒙 PixelMap 插件 flutter_ohos_pixelmap 从 0 到 1 实战跑通
鸿蒙特性插件 | 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 OH | oh-3.44.9-dev |
| Dart SDK | 随 Flutter OH 3.44.9 |
| DevEco Studio | 26.0.0.821 |
| HarmonyOS SDK | 7.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.ImageKit的ImageSource/PixelMap/ImagePacker@kit.ArkGraphics2D的effectKit.Filter
如果业务自己在每个页面里拼 MethodChannel,很快会把句柄泄漏、格式转换和滤镜生命周期写散。插件要做的就是把这条管线收成一个 Dart 对象:OhosPixelMap。
example 启动后会先走 createFromRgba(256x256) 画一张渐变,再 encode() 成 PNG 预览。后面所有变换、像素读写、滤镜,都是在这张图上操作:
启动后首屏:256x256 RGBA 渐变 + getInfo 文本

2.2 为什么要做成鸿蒙特性插件,而不是三方库共建
这不是「三方库共建」。PixelMap 是鸿蒙图像栈自己的类型,Android/iOS 没有同名对象。pub.dev 上的 image 包是纯 Dart 编解码,不碰系统 PixelMap,也用不上 effectKit。对标对象是本周已经过审的 flutter_ohos_form、react-native-ohos-form、react-native-image-pixelmap。
做成 Flutter 插件的价值是:
- Dart 侧用统一句柄操作 native 图像,而不是把整张图反复打成
Uint8List来回拷。 - 几何变换走 PixelMap 原地 API,滤镜走 effectKit,语义和系统文档一致。
- 和 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 stub | PixelMap 在另外两端没有对应物。stub 只会把「依赖阶段就该失败」推迟到运行时 UnimplementedError。 |
所以 pubspec.yaml 只声明 ohos 平台。业务一旦 import 到非 ohos 工程,应该在依赖阶段失败。
三、接口全景与能力映射
Dart 对外只有一个句柄对象 OhosPixelMap,通道名固定为 flutter_ohos_pixelmap。native 侧入口是 FlutterOhosPixelmapPlugin。
3.1 创建 / 解码
| Dart API | MethodChannel | 鸿蒙 API | 返回 |
|---|---|---|---|
OhosPixelMap.isSupported() | isSupported | 恒为 true | bool |
OhosPixelMap.createEmpty(width, height) | createEmpty | image.createPixelMapSync(options) | 新句柄 |
OhosPixelMap.createFromRgba(rgba, width, height) | createFromRgba | image.createPixelMap(buffer, options) | 新句柄 |
OhosPixelMap.decodeFromBytes(bytes) | decodeFromBytes | createImageSource(buffer).createPixelMap() | 新句柄 |
OhosPixelMap.decodeFromPath(path) | decodeFromPath | createImageSource(path).createPixelMap() | 新句柄 |
createEmpty / createFromRgba 一律用 RGBA_8888 + editable: true + AlphaType.UNPREMUL。rgba.length 必须等于 width * height * 4,这个校验放在 Dart 侧,避免无效缓冲打到 native。
3.2 查询 / 几何变换 / 克隆
| Dart API | 行为 | 鸿蒙 API |
|---|---|---|
getInfo() | 读宽高、pixelFormat、alphaType、density、stride、mimeType、byteCount | getImageInfo() + 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) | 读 1x1 | readPixels(PositionArea) |
setPixel(x, y, PixelColor) | 写 1x1 | writePixels(PositionArea) |
readPixels() | 整图 RGBA | readPixelsToBuffer |
writePixels(rgba) | 整图覆盖 | writeBufferToPixels |
encode(format, quality) | PNG / JPEG 打包 | ImagePacker.packing |
blur(radius) / grayscale() / brightness(bright) / invert() | 先 clone 再滤镜,返回新句柄 | effectKit.createEffect |
dispose() | 释放 native 对象,Dart handle 置 0 | PixelMap.release |
语义必须分开,不能抹成「所有操作都返回新对象」:
| 调用 | 当前 handle | 原因 |
|---|---|---|
scale / rotate / flip / crop / opacity | 原地修改 | 对应 PixelMap 自身的 Promise 方法 |
blur / grayscale / brightness / invert | 不变,另给新 handle | Filter 会生成另一张 PixelMap |
clone | 不变,另给新 handle | 给业务留一份备份再做破坏性裁剪 |
dispose | 置 0 | 之后再调任何方法抛 StateError |
opacity 最容易和滤镜搞混:看起来也是「效果变了」,但它改的是当前 PixelMap,handle 不变。example 点「透明度 0.45」后预览变淡,info 里的句柄数字还是原来那个。
原地 opacity(0.45),预览变淡但 handle 不变
四、从 0 到 1 的六阶段路线
对照本周过审的 flutter_ohos_form,我把插件拆成六段,每段都有可运行产物,而不是写完再一次性联调。
| 阶段 | 目标 | 验收 |
|---|---|---|
| 1. 骨架 | flutter plugin + 仅声明 ohos 平台 | pluginClass 能被 example 拉起,isSupported() 返回 true |
| 2. 句柄仓库 | ArkTS PixelMapStore | 创建 / 释放句柄不串号;onDetachedFromEngine 能 releaseAll |
| 3. 编解码 | createEmpty / createFromRgba / decodeFromBytes / encode | example 能看到 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 释放。
单独 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,不传 PixelMap | StandardMessageCodec 不认识鸿蒙 native 对象 |
| 几何变换 | 原地修改当前 handle | 与 PixelMap Promise API 一致,少一次拷贝 |
| 滤镜 | clone 后返回新 handle | 预览 / 取消必须能回到原图 |
| 像素格式 | 固定 RGBA_8888 + UNPREMUL | Dart Image.memory 和 PixelColor 都按 RGBA 解释 |
| 空画布 | createPixelMapSync | 无像素缓冲,同步创建更简单;带 RGBA 的路径仍走异步 createPixelMap |
另外两条工程决策:
- 参数读取用
call.argument(key),不要写call.arguments。新版 flutter_ohos 的MethodCall字段就是args,插件封装后的取值入口是argument。 - ArkTS 严格模式禁止
any。对象字面量先声明image.InitializationOptions、image.Region、image.PositionArea、image.PackingOption,call.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:
空画布 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 扔回去。
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, 0.5),info 宽高减半
再点「旋转 90°」,渐变方向会转过来,仍然是同一份 PixelMap:
缩小 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 预览里几乎看不见。中心裁剪后再写,红点会落在预览左上附近:
中心裁剪后 (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 也会变:
灰度 + 模糊,句柄已换成新 PixelMap
example 里 _replace 会先 dispose 旧对象再接新句柄。真实业务如果要做「对比滑杆」,应该同时拿着原图 handle 和滤镜 handle,滑杆松手后再决定丢掉哪一份。
大图注意内存:滤镜路径会多一次 clone,峰值大约是两份 PixelMap。列表页批量出缩略图时,先 scale 再滤镜,不要对 4K 原图直接 blur。
7.6 字节缓冲的坑
Flutter 把 Uint8List 打过通道后,ArkTS 侧可能是 Uint8Array,也可能已经是 ArrayBuffer。createPixelMap / 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(例如 _replace 和 State.dispose 叠在一起)会把同一个 id 释放两次。第二次 store.release 只是返回 false,但日志会吵。置 0 之后再调 scale 会走 _ensureAlive(),抛 OhosPixelMap has already been disposed。
example 点「release」后,预览区必须变成「PixelMap 已释放」,再点变换只打日志不崩溃:
release 后预览区必须变成「PixelMap 已释放」
8.2 元数据和颜色
PixelMapInfo 对应 native 的 PixelMapInfoPayload:宽、高、pixelFormat、alphaType、density、stride、mimeType、byteCount。example 预览区下面那行等宽文本就是 info.toString()。
PixelColor 是 RGBA 四个 int。getPixel 读 map,setPixel 把 color.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();
滤镜返回值是新对象。上面这段如果只 dispose 了 map,gray 对应的 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 调用全部 notImplemented | getUniqueClassName() 和 pluginClass 不一致 | 两边都写成 FlutterOhosPixelmapPlugin |
call.arguments 编译失败或取值永远 undefined | 新版 MethodCall 字段是 args,取值入口是 argument(key) | 改成 call.argument('width') as number |
ArkTS 编译报 Any / 对象字面量类型不匹配 | 严格模式禁止 any,未标注 InitializationOptions 等 | 先声明类型再赋值 |
| JPEG 往返花屏、渐变整体偏色 | Uint8Array.byteOffset != 0 时直接用了 view.buffer | toArrayBuffer 按偏移拷贝 |
| 连续创建后内存涨、热重载泄漏 | ImageSource / ImagePacker 没 release,或引擎卸载时没 releaseAll | finally 里 release;onDetachedFromEngine 清仓库 |
crop / setPixel 抛不可编辑 | createEmpty 忘了 editable: true | 创建 options 固定带上 |
getPixel 永远是 0,0,0,0 | PositionArea.stride 写错 | 1x1 时 stride=4 |
| 滤镜后无法恢复原图 | 用滤镜结果覆盖了原 handle | 滤镜返回新句柄,原图由调用方 dispose |
| 单像素红点截图上看不见 | 256x256 里 1px 太小 | example 写 2x2 |
| example 装不上 / 审核卡签名 | 缺 signingConfig: default | product 里补这一行 |
| 非 ohos 工程能依赖但运行崩溃 | 误加了 Android/iOS stub | 特性插件只声明 ohos |
十一、FAQ
Q1:为什么不直接适配 pub.dev 的 image 包?
image 是纯 Dart 编解码库,不碰鸿蒙 PixelMap,也用不上 effectKit。本周过审激励明确偏向「鸿蒙特性插件」。对标对象是 flutter_ohos_form / react-native-image-pixelmap,不是把已有 Dart 包再包一层。
Q2:getUniqueClassName 和 pluginClass 不一致会怎样?
引擎注册表对不上,Dart MethodChannel('flutter_ohos_pixelmap') 发出去没有 handler。表现是所有调用卡住或 notImplemented。两者必须都是 FlutterOhosPixelmapPlugin。
Q3:ArkTS 严格模式最容易踩什么?
不要写 any。call.argument 的结果按 number / boolean / string / Object 显式断言。对象字面量先声明 image.InitializationOptions、image.Region、image.PositionArea、image.PackingOption。滤镜回调的参数类型写成 (filter: effectKit.Filter) => void,不要省略。
Q4:滤镜为什么不原地改?
effectKit.createEffect 拿到的是 Filter 管线,getEffectPixelMap() 产出的是另一张图。如果覆盖原 handle,业务无法做「滤镜预览 / 取消」。所以滤镜一律返回新句柄,原图由调用方决定是否 dispose。几何变换之所以原地改,是因为 PixelMap 自己的 scale / rotate / crop 就是这个语义,插件不去「纠正」系统 API。
反色也是同一条路径:返回新句柄,example 预览换成滤镜结果。
反色滤镜,同样返回新句柄
Q5:模拟器和真机差异?
模拟器可以走通创建、变换、读写像素和预览。ImagePacker 的 JPEG 体积、effectKit 模糊半径观感可能和真机不一致。发文截图优先真机;模拟器能证明通道通,但要在「已知限制」写明。壁纸服务、部分图形加速路径在模拟器上本来就有差异,不要拿模拟器 JPEG 体积当回归基线。
Q6:decodeFromPath 能直接读相册 URI 吗?
不能。它要的是沙箱内可读路径,例如 context.cacheDir 下的文件。相册 URI 需要先走 photoAccessHelper 或 file_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.yaml、lib/、双语 README。不要带.hvigor、oh_modules、build/、签名材料。 - 标题带 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-pixelmap | Flutter flutter_ohos_pixelmap |
|---|---|---|
| 创建空画布 / RGBA | 有 | createEmpty / createFromRgba |
| 解码 bytes / path | 有 | decodeFromBytes / decodeFromPath |
| 几何变换 | 有 | 原地 scale / rotate / flip / crop / opacity |
| 像素读写 | 有 | getPixel / setPixel / readPixels / writePixels |
| 编码 | 有 | encode PNG/JPEG |
| 系统滤镜 | 视版本 | effectKit blur / grayscale / brightness / invert |
| 跨语言对象 | JS tag / handle | Dart int handle |
两边都没有把 PixelMap 本身打过桥,都是 handle。差别在生命周期:RN 侧更多靠 JS GC 最终化,Flutter 侧要求业务显式 dispose,和 ui.Image.dispose() 的习惯一致。
十四、总结
flutter_ohos_pixelmap 把鸿蒙图像栈的三件套接到了 Flutter:ImageSource 负责进、PixelMap 负责算、ImagePacker 负责出,effectKit 负责系统滤镜。工程上最关键的不是把 API 罗列全,而是三件事:
- 句柄仓库:Dart 只持有
int,引擎卸载时releaseAll。 - 语义分开:几何变换原地改,滤镜返回新句柄,业务才能做预览 / 取消。
- 偏移安全转换:
Uint8List到ArrayBuffer必须处理byteOffset,否则编解码会吃到共享缓冲的前缀垃圾。
这是鸿蒙特性插件,不是三方库平移。和本周已通过的 flutter_ohos_form、react-native-image-pixelmap 属于同一类题目。0.1.0 先把可演示、可释放、可截图的主路径做扎实;HDR、GPU PixelMap、Texture 预览留到后面有真实业务再加。
欢迎加入 CPF-Flutter 鸿蒙社区:
- CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
- Flutter OHOS 开发环境搭建指南:https://atomgit.com/CPF-Flutter/flutter_samples/blob/master/docs/ohos/getting-started/flutter-oh-env-setup.md
- HarmonyOS ImageKit:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-image
- HarmonyOS effectKit:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-effectkit
- 本文插件:https://atomgit.com/oh-flutter/flutter_ohos_pixelmap
- 本文 example:https://atomgit.com/oh-flutter/flutter_ohos_pixelmap/tree/main/example
更多推荐




所有评论(0)