像素工坊-06/06:HarmonyOS 处理作业与资源交接【共创稿事节】
一条图像处理链路最容易漏掉的,常常不是某个变换公式,而是结果已经生成之后谁还持有什么。图像源、工作位图和编码器都成功创建,不等于作业已经完成;页面看到了预览,也不等于中间资源已经归还。像素工坊的最后一篇从设计评审角度检查这条边界,把可交付输出与处理中对象明确分开。

一、作业的成功必须包括资源收尾
本篇继续使用 API 12 的 Stage 工程,固定输入仍是第一篇的四象限 PNG。新增管线文件并替换首页,模板入口和页面配置保持默认,不增加权限。处理预设是先探测输入,裁切指定区域,顺时针旋转九十度,再缩小为一半,最后编码为 PNG 并重新解码验证。输出应为四十八乘八十像素,既有完整空间变换,也有格式交付。
与第三篇直接把位图交给页面不同,这里把编码后的内容转换成小尺寸的数据地址。页面持有普通字符串,作业内部的原生图像对象在返回前全部完成释放。这个选择适合本例很小的示例输出,代价是额外编码与字符串表示,不应直接推广成大图缓存方案。它让评审重点集中在处理中资源是否有统一归属。
成功定义包含三个条件:处理得到预期输出,输出能够重新解码,中间资源完成归还。只要任何条件不满足,就不发布成功状态。这样用户看到“已交付”时,含义清楚,不会实际对应一个仍在后台等待收尾的候选结果。对于需要更高吞吐的产品,可以重新设计流水线,但必须重新说明成功与资源释放之间的关系。
import { util } from '@kit.ArkTS';
const SAMPLE: string = 'iVBORw0KGgoAAAANSUhEUgAAAQAAAADACAIAAABkjyoxAAABu0lEQVR42u3csQmAMBRF0URSuoiTOI22OoK1i1k5gZWtA6i1ZUAIgXN6ER5cfpcYhilQztnvRiioMQECAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQANQp5X7wrMsvP47jbH1cABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAA4Cv7XSDv+eACgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABIAATIAAQAAgABAACAAGAAEAAIAAQAAgABAACgIqlbjusUNB1t0ZwAUAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAJAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAqMELss4MLe5Dg2YAAAAASUVORK5CYII=';
export function sampleBytes(): ArrayBuffer {
const bytes = new util.Base64Helper().decodeSync(SAMPLE);
return bytes.buffer.slice(bytes.byteOffset,
bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
本例不隐藏文件写入或相册保存步骤。输出一直留在内存,界面只显示已经验证过的编码内容。要加入落盘,应把它作为明确的新阶段,定义失败后的文件处置和交付条件,而不是把一个内存字节数组命名成已保存文件。数据在哪里、谁拥有它、什么时候可以宣布完成,应当在接口层就能回答。
二、创建成功后立即登记释放责任
管线将每个创建成功的资源登记到清理列表。图像源、工作位图、编码器、验证图像源和验证位图按创建顺序入列,结束时逆序释放。只有已经成功返回的对象才登记;创建过程失败时不存在的对象不需要凭空清理。每个登记项捕获自己的实例,后续变量变化不会把释放动作指向另一个对象。
import { image } from '@kit.ImageKit';
import { util } from '@kit.ArkTS';
import { sampleBytes } from './PixelFixture';
export class JobControl {
cancelled: boolean = false;
check(): void {
if (this.cancelled) { throw new Error('页面已销毁,停止交付'); }
}
}
interface CleanupEntry {
name: string;
close: () => Promise<void>;
}
export interface PipelineResult {
imageUri: string;
detail: string;
}
export async function runPipeline(control: JobControl): Promise<PipelineResult> {
const cleanups: CleanupEntry[] = [];
let candidate: PipelineResult | undefined = undefined;
let failure: string = '';
try {
control.check();
const bytes = sampleBytes();
if (bytes.byteLength === 0 || bytes.byteLength > 1024 * 1024) {
throw new Error('编码输入不符合预算');
}
const source = image.createImageSource(bytes);
cleanups.push({ name: '输入图像源', close: () => source.release() });
const info = await source.getImageInfo(0);
if (info.mimeType !== 'image/png' ||
info.size.width !== 256 || info.size.height !== 192) {
throw new Error('这条预设只接受固定样本');
}
control.check();
const work = await source.createPixelMap({
editable: true, desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
index: 0
});
cleanups.push({ name: '工作位图', close: () => work.release() });
if (work.getPixelBytesNumber() > 1024 * 1024) {
throw new Error('工作位图超过预算');
}
await work.crop({ x: 32, y: 24, size: { width: 160, height: 96 } });
await work.rotate(90);
await work.scale(0.5, 0.5);
control.check();
const packer = image.createImagePacker();
cleanups.push({ name: '编码器', close: () => packer.release() });
if (packer.supportedFormats.indexOf('image/png') < 0) {
throw new Error('设备不支持 PNG 编码');
}
const encoded = await packer.packing(work, {
format: 'image/png', quality: 100, needsPackProperties: false
});
if (encoded.byteLength > 256 * 1024) { throw new Error('编码输出超过预算'); }
control.check();
const verifySource = image.createImageSource(encoded);
cleanups.push({ name: '验证图像源', close: () => verifySource.release() });
const verified = await verifySource.createPixelMap({ index: 0 });
cleanups.push({ name: '验证位图', close: () => verified.release() });
const outputInfo = await verified.getImageInfo();
if (outputInfo.size.width !== 48 || outputInfo.size.height !== 80) {
throw new Error('输出尺寸验证失败');
}
const base64 = new util.Base64Helper().encodeToStringSync(new Uint8Array(encoded));
candidate = {
imageUri: `data:image/png;base64,${base64}`,
detail: 'PNG · 48 × 80 px\n输出已读回 · 作业资源已归还'
};
} catch (error) {
failure = (error as Error).message;
}
const cleanupErrors: string[] = [];
for (let i = cleanups.length - 1; i >= 0; i--) {
try {
await cleanups[i].close();
} catch (error) {
cleanupErrors.push(`${cleanups[i].name}:${(error as Error).message}`);
}
}
if (failure.length > 0 || cleanupErrors.length > 0) {
throw new Error([failure, ...cleanupErrors].filter((s: string) => s.length > 0).join(';'));
}
control.check();
if (candidate === undefined) { throw new Error('没有可交付的输出'); }
return candidate;
}
逆序清理让依赖关系更容易解释。验证位图先于验证图像源结束,编码器先于输入工作位图结束,最后才处理最早的输入图像源。实际调用已经顺序等待完成,因此清理开始时没有仍在使用这些对象的异步编辑或编码。这里的列表并不承担取消底层任务的能力,只是把作业最终拥有的资源集中起来归还。
处理异常没有立即跳出整个函数,而是先记录主错误,再执行所有登记项的收尾。某一项释放失败不会阻止后面的资源尝试释放,最后把主错误与清理错误一起报告。这样的结构避免了一个释放异常遮住所有后续清理,也避免只保留最后一次错误、丢失最先失败的操作。错误文字保留资源名称,便于定位交接点。

图 2:逆序清理所有已登记对象,汇总错误后才允许返回候选输出的布局示意。
输入限制沿用前面几篇的分层思路:编码字节先做范围检查,探测结果确认固定样本身份,工作位图再核对字节预算,编码结果最后检查输出长度。后两个检查发生在对象已经存在之后,不能宣称阻止了全部分配峰值。它们限制继续处理和交付的范围,事前约束则由固定输入和明确尺寸预设提供。
这条预设保留原图解码尺寸,是因为裁切区域使用原图像素坐标。如果直接套用第二篇的缩略图尺寸,却不同比例修改裁切区域,就会把同一组数字解释为另一块内容甚至越界。这里先完成源坐标下的裁切,再旋转和缩小,最终宽高可以逐步推导。每一步的坐标空间都由前一步的输出决定。
三、候选输出通过检查后再交给页面
编码器返回字节之后,管线重新创建图像源并解码,核对最终尺寸。只有字节长度合理、解码成功、尺寸正确,才构造候选输出。候选输出出现时仍没有离开函数;清理列表全部执行结束之后,它才真正返回。这个时间差是作业边界的关键,避免把内存编码完成误当成整个流程完成。
字符串地址显式声明 PNG,内容也确实由 PNG 编码器生成,媒体类型与实际数据保持一致。页面组件可以加载这种地址,内部显示资源由组件自身管理,业务代码不再直接释放已经交给页面的位图。仍然需要接住组件的显示错误,因为一次独立读回成功并不能保证所有界面环境下都一定显示成功。
import { JobControl, runPipeline } from '../common/PipelineLab';
@Entry
@Component
struct Index {
@State imageUri: string = '';
@State detail: string = '';
@State status: string = '等待作业';
@State busy: boolean = false;
@State done: boolean = false;
private control: JobControl = new JobControl();
private alive: boolean = true;
private visible: boolean = true;
private async run(): Promise<void> {
if (!this.alive || !this.visible || this.busy || this.done) { return; }
this.control = new JobControl();
this.busy = true;
this.status = '作业处理中';
try {
const result = await runPipeline(this.control);
if (!this.alive) { return; }
this.imageUri = result.imageUri;
this.detail = result.detail;
this.status = '已交付';
this.done = true;
} catch (error) {
if (this.alive) { this.status = `作业失败:${(error as Error).message}`; }
} finally {
if (this.alive) { this.busy = false; }
}
}
onPageShow(): void { this.visible = true; }
onPageHide(): void { this.visible = false; }
aboutToDisappear(): void {
this.alive = false;
this.control.cancelled = true;
this.visible = false;
}
build() {
Column({ space: 16 }) {
Text('像素工坊').fontSize(16).fontColor('#64738F')
Text('完整处理作业').fontSize(28).fontWeight(FontWeight.Bold)
.fontColor('#263451')
Text('探测 → 编辑 → 编码 → 验证').fontSize(14).fontColor('#64738F')
Stack() {
if (this.imageUri.length > 0) {
Image(this.imageUri).width(180).height(160).objectFit(ImageFit.Contain)
.onError(() => { this.status = '输出可解码,界面预览失败'; })
} else {
Text('等待最终输出').fontSize(17).fontColor('#64738F')
}
}.width(312).height(200).backgroundColor('#FFFFFF').borderRadius(16)
Text(this.status).fontSize(24).fontColor('#384D85')
Text(this.detail).fontSize(15).fontColor('#64738F')
.width(312).height(62).textAlign(TextAlign.Center)
Button('运行完整作业').width(192).height(42)
.backgroundColor(this.done || this.busy ? '#DDE3EE' : '#384D85')
.fontColor(this.done || this.busy ? '#64738F' : '#FFFFFF')
.enabled(!this.busy && !this.done).onClick(() => { this.run(); })
}.width('100%').height('100%').padding({ top: 32 })
.backgroundColor('#F0F3F9')
}
}

图 3:作业完成后的可达页面示意,显示四十八乘八十输出、读回验证和作业资源收尾结果。
页面只接受普通结果对象,也没有公开编码器或工作位图给按钮操作。隐藏时保留任务和结果地址,返回后继续显示;真正销毁才设置停止交付标记,不清空状态变量,也不直接释放编码器正在使用的输入。管线在异步阶段之间观察标记,当前系统调用仍等待结束,然后统一清理。停止交付与立即中断底层计算是不同能力。
在清理结束之后再检查一次停止标记,是为了覆盖页面恰好在收尾期间被销毁的情况。即使候选输出已经准备好,也没有理由交给不再需要它的页面。页面自身还保留存活检查,这不是取消系统处理的第二种实现,而是对界面状态写入的最后一道条件。内部资源的归还始终由管线负责。
四、评审失败路径,而非只看成功图片
第一项审查是每次创建后是否立即登记。可以从代码里逐个标记原生对象,确认没有只登记图像源却漏掉验证位图。第二项审查是有没有在等待异步完成前开始清理。第三项审查是候选结果有没有提前赋给页面。三项都直接对应代码结构,不依赖一次运行恰好没有出错。

图 4:输出先成为候选,再完成逆序清理,最后交付。失败和停止交付分支都经过同一个资源收尾阶段。
复现时先运行默认预设,核对竖向图片、最终尺寸和成功文字。接着把裁切宽度改成超过输入范围的数值,确认处理失败后没有成功输出;再把预期输出尺寸改错,确认即使编码已经完成,读回验证失败仍然阻止交付。后一个试验尤其能发现页面是否过早拿到了编码结果,不能只测试最前面的输入拒绝。
销毁页面的实验应覆盖解码、编辑和编码等待阶段;另外检查隐藏再返回,确认结果仍可用。由于固定样本很小,人工点击不一定刚好落在某一阶段,可以在调试时加入明确的可控门闩,让测试代码决定何时继续,而不要把固定延时写进生产逻辑。验收目标是没有迟到界面写入,清理责任仍然完整;本文没有给出设备耗时,也不把示意时序的间距解释为时间比例。
清理失败后的策略也需要团队明确。示例阻止成功返回并保留错误详情,没有假装所有资源都已释放。真实产品可以增加受控重试和资源诊断,但不能无限重复释放未知状态对象,也不能静默吞掉错误然后显示已完成。资源句柄的最终状态应由接口结果和项目约定共同决定,而不是由页面想显示的颜色决定。
输出编码会增加一次验证解码的成本,这是一项刻意选择的完整性检查。若以后移除它来降低开销,需要说明由什么条件替代可解码性验证,哪些错误会更晚暴露。设计评审不要求所有路径采用最重的验证,而是要求成本与交付保证对应。小型工具可以先保留清晰边界,得到真实设备数据后再决定哪些阶段值得优化。
至此,像素工坊已经拥有从固定输入到最终编码输出的闭环:探测明确接受范围,尺寸策略描述数据规模,空间变换遵循当前坐标,像素实验验证缓冲契约,编码策略处理透明度,作业边界统一资源归属。后续增加功能时,可以把它放进对应阶段,并为新的资源登记释放责任,而不必让页面重新承担整个图像处理链路。
清理列表还有一个实际约束:一次作业只登记自己创建并拥有的资源。若未来接收调用方借来的位图,就不能在函数结束时随意加入释放列表;应先约定借用期间是否允许修改、何时结束借用,以及失败后谁负责恢复。所有权来自接口契约,而不是对象恰好出现在某个函数的局部变量里。当前预设从固定字节创建所有中间对象,因此责任没有跨模块歧义。
并发扩展时,也不要让多个作业共享同一个可编辑工作位图。即使每个作业内部都顺序等待,两条作业之间仍可能交叉修改,清理列表也可能重复释放同一实例。可以共享不可变的编码输入,每个作业创建自己的处理中对象,并对同时运行的数量设置上限。这样每条失败路径只需处理自己登记的资源,资源预算也能按单次成本估算。
最后,结果接口只返回已经过验证的内容和必要说明,并不返回清理列表或内部句柄。调用方因此无法在作业结束后重新触碰已释放资源。若产品以后需要继续编辑,应显式请求新的工作会话,而不是从上一次的预览对象里找回底层句柄。这个限制让处理、预览和再次编辑各自拥有清楚的开始与结束。
参考文档
更多推荐




所有评论(0)