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

完整图像作业中的资源交接

一、作业的成功必须包括资源收尾

本篇继续使用 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;
}

逆序清理让依赖关系更容易解释。验证位图先于验证图像源结束,编码器先于输入工作位图结束,最后才处理最早的输入图像源。实际调用已经顺序等待完成,因此清理开始时没有仍在使用这些对象的异步编辑或编码。这里的列表并不承担取消底层任务的能力,只是把作业最终拥有的资源集中起来归还。

处理异常没有立即跳出整个函数,而是先记录主错误,再执行所有登记项的收尾。某一项释放失败不会阻止后面的资源尝试释放,最后把主错误与清理错误一起报告。这样的结构避免了一个释放异常遮住所有后续清理,也避免只保留最后一次错误、丢失最先失败的操作。错误文字保留资源名称,便于定位交接点。

白色主题 DevEco Studio 中的作业收尾代码

图 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:输出先成为候选,再完成逆序清理,最后交付。失败和停止交付分支都经过同一个资源收尾阶段。

复现时先运行默认预设,核对竖向图片、最终尺寸和成功文字。接着把裁切宽度改成超过输入范围的数值,确认处理失败后没有成功输出;再把预期输出尺寸改错,确认即使编码已经完成,读回验证失败仍然阻止交付。后一个试验尤其能发现页面是否过早拿到了编码结果,不能只测试最前面的输入拒绝。

销毁页面的实验应覆盖解码、编辑和编码等待阶段;另外检查隐藏再返回,确认结果仍可用。由于固定样本很小,人工点击不一定刚好落在某一阶段,可以在调试时加入明确的可控门闩,让测试代码决定何时继续,而不要把固定延时写进生产逻辑。验收目标是没有迟到界面写入,清理责任仍然完整;本文没有给出设备耗时,也不把示意时序的间距解释为时间比例。

清理失败后的策略也需要团队明确。示例阻止成功返回并保留错误详情,没有假装所有资源都已释放。真实产品可以增加受控重试和资源诊断,但不能无限重复释放未知状态对象,也不能静默吞掉错误然后显示已完成。资源句柄的最终状态应由接口结果和项目约定共同决定,而不是由页面想显示的颜色决定。

输出编码会增加一次验证解码的成本,这是一项刻意选择的完整性检查。若以后移除它来降低开销,需要说明由什么条件替代可解码性验证,哪些错误会更晚暴露。设计评审不要求所有路径采用最重的验证,而是要求成本与交付保证对应。小型工具可以先保留清晰边界,得到真实设备数据后再决定哪些阶段值得优化。

至此,像素工坊已经拥有从固定输入到最终编码输出的闭环:探测明确接受范围,尺寸策略描述数据规模,空间变换遵循当前坐标,像素实验验证缓冲契约,编码策略处理透明度,作业边界统一资源归属。后续增加功能时,可以把它放进对应阶段,并为新的资源登记释放责任,而不必让页面重新承担整个图像处理链路。

清理列表还有一个实际约束:一次作业只登记自己创建并拥有的资源。若未来接收调用方借来的位图,就不能在函数结束时随意加入释放列表;应先约定借用期间是否允许修改、何时结束借用,以及失败后谁负责恢复。所有权来自接口契约,而不是对象恰好出现在某个函数的局部变量里。当前预设从固定字节创建所有中间对象,因此责任没有跨模块歧义。

并发扩展时,也不要让多个作业共享同一个可编辑工作位图。即使每个作业内部都顺序等待,两条作业之间仍可能交叉修改,清理列表也可能重复释放同一实例。可以共享不可变的编码输入,每个作业创建自己的处理中对象,并对同时运行的数量设置上限。这样每条失败路径只需处理自己登记的资源,资源预算也能按单次成本估算。

最后,结果接口只返回已经过验证的内容和必要说明,并不返回清理列表或内部句柄。调用方因此无法在作业结束后重新触碰已释放资源。若产品以后需要继续编辑,应显式请求新的工作会话,而不是从上一次的预览对象里找回底层句柄。这个限制让处理、预览和再次编辑各自拥有清楚的开始与结束。

参考文档

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐