一个叫“保存图片”的函数,如果只接收位图和扩展名,很容易把产品决策藏进实现细节:透明区域应该保留,还是铺在白底上?输出变小意味着质量合适,还是内容已经丢失?像素工坊第五篇把编码重构成明确的策略,把透明度处理放在编码器之前,并在返回结果后再次解码核对。

透明图片与白底编码策略

一、从文件后缀重构到输出契约

本篇使用 API 12 的 Stage 工程,新增编码实验文件并替换首页。输入由代码构造,不依赖相册、网络或磁盘,也不需要额外权限。和前面的空间变换不同,这次关注的是同一内容如何进入不同编码格式。代码不写文件,因此输出长度是内存编码结果的字节数,不能称为已经成功保存到相册的文件大小。

我们规定两条策略:需要透明度的输出使用 PNG;需要 JPEG 的输出先与白色背景合成,再交给编码器。背景颜色属于产品决策,不能让不支持透明度的格式替我们猜。把透明图直接送入 JPEG 编码,透明信息无法被保留,还可能出现不符合设计的底色。策略必须在调用前选择,后缀只负责描述已经确定的格式。

接口还应明确质量参数的含义。数值九十是编码器的质量档位,不表示保留百分之九十的原始像素,也不意味着文件一定压缩到原来的十分之一。输入内容、尺寸和格式都会影响输出长度。我们不把一个编码质量数值推广成所有格式共同的视觉质量刻度,更不会用它直接推导精确文件大小。

API 12 中,本篇使用返回缓冲区的编码方法;官方文档从 API 13 开始建议迁移到新的返回数据方法。这里固定基线,避免把较新的接口复制进旧工程。升级工程时可以替换编码调用,但透明度处理、资源所有权和读回验证仍应保留。接口名称变了,不代表输出契约可以省略。

二、把透明样本和白底合成写成可检查的数据

样本宽九十六、高六十四,分成三条等宽竖带,透明度依次为零、一百二十八和二百五十五。颜色通道统一为红十二、绿一百一十、蓝一百六十。第一条完全透明,第二条约半透明,第三条不透明。这个设计让同一个函数同时触及透明边界、混合状态和不透明边界,而不需要复杂摄影素材。

透明样本明确使用非预乘颜色,白底版本则按每个通道的透明度合成公式计算,并把输出透明度固定为完全不透明。公式采用整数四舍五入,确保相同输入得到相同字节。它是在本实验的八位通道数值上定义的合成规则,并未声称是线性光空间的高精度图像处理;如果产品需要色彩管理,应另行确定运算空间。

import { image } from '@kit.ImageKit';

export interface EncodingReport {
  pngBytes: number;
  jpegBytes: number;
}

function overWhite(channel: number, alpha: number): number {
  return Math.floor((channel * alpha + 255 * (255 - alpha) + 127) / 255);
}

async function makeAlphaSample(flattenWhite: boolean): Promise<image.PixelMap> {
  const pixels = new Uint8Array(96 * 64 * 4);
  for (let y = 0; y < 64; y++) {
    for (let x = 0; x < 96; x++) {
      const alpha = x < 32 ? 0 : (x < 64 ? 128 : 255);
      const index = (y * 96 + x) * 4;
      pixels[index] = flattenWhite ? overWhite(160, alpha) : 160;
      pixels[index + 1] = flattenWhite ? overWhite(110, alpha) : 110;
      pixels[index + 2] = flattenWhite ? overWhite(12, alpha) : 12;
      pixels[index + 3] = flattenWhite ? 255 : alpha;
    }
  }
  return image.createPixelMap(pixels.buffer as ArrayBuffer, {
    size: { width: 96, height: 64 }, editable: false,
    srcPixelFormat: image.PixelMapFormat.BGRA_8888,
    pixelFormat: image.PixelMapFormat.BGRA_8888,
    alphaType: flattenWhite ? image.AlphaType.OPAQUE : image.AlphaType.UNPREMUL
  });
}

async function encodeOne(format: string, flattenWhite: boolean): Promise<ArrayBuffer> {
  const map = await makeAlphaSample(flattenWhite);
  let packer: image.ImagePacker | undefined = undefined;
  try {
    packer = image.createImagePacker();
    if (packer.supportedFormats.indexOf(format) < 0) {
      throw new Error(`设备编码器不支持 ${format}`);
    }
    return await packer.packing(map, {
      format: format, quality: 90, needsPackProperties: false
    });
  } finally {
    try {
      if (packer !== undefined) { await packer.release(); }
    } finally {
      await map.release();
    }
  }
}

async function verify(bytes: ArrayBuffer, mime: string, alpha: number): Promise<void> {
  const source = image.createImageSource(bytes);
  let decoded: image.PixelMap | undefined = undefined;
  try {
    const info = await source.getImageInfo(0);
    if (info.mimeType !== mime || info.size.width !== 96 || info.size.height !== 64) {
      throw new Error('编码结果的格式或尺寸不符');
    }
    decoded = await source.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.BGRA_8888, index: 0
    });
    const pixel = new Uint8Array(4);
    await decoded.readPixels({
      pixels: pixel.buffer as ArrayBuffer, offset: 0, stride: 4,
      region: { x: 8, y: 8, size: { width: 1, height: 1 } }
    });
    if (pixel[3] !== alpha) { throw new Error('输出透明度不符合策略'); }
  } finally {
    try {
      if (decoded !== undefined) { await decoded.release(); }
    } finally {
      await source.release();
    }
  }
}

export async function compareEncoding(): Promise<EncodingReport> {
  const png = await encodeOne('image/png', false);
  await verify(png, 'image/png', 0);
  const jpeg = await encodeOne('image/jpeg', true);
  await verify(jpeg, 'image/jpeg', 255);
  return { pngBytes: png.byteLength, jpegBytes: jpeg.byteLength };
}

输入缓冲的通道顺序与位图格式都显式指定为蓝绿红透明度。上一期已经验证了区域读写的顺序,这里继续使用同一约定。透明度为零时,颜色通道仍然可以保存数值,但显示时不应贡献可见颜色;白底合成则将它变成白色。不能只把透明度字节改为二百五十五,因为那样会把原本完全不可见的颜色突然暴露出来。

中间透明度的白底结果能够手工计算。对于一百二十八的透明度,三个通道分别得到一百三十三、一百八十二、二百零七。这个结果是编码前的确切值;JPEG 有损编码之后,不应再要求每个颜色字节严格相等。相反,完全透明的 PNG 区域在读回后仍应保持透明,JPEG 输出则应是不透明图像,这两项是格式策略的关键断言。

三、将编码器变成短生命周期对象

每次编码创建自己的编码器,先查询支持格式,再发出一次编码调用,等待完成后释放。编码期间输入位图保持不变且不能释放。把位图释放写在发起调用之后但等待之前,会让原生编码器继续使用已经失效的输入。异步接口返回一个承诺,只表示工作被发起,并不表示它已经复制了全部需要的数据。

白色主题 DevEco Studio 中的编码策略代码

图 2:格式能力检查、编码调用和先释放编码器后释放输入位图的布局示意。

清理使用嵌套的收尾结构,确保编码器释放失败时仍会尝试释放位图。对外错误仍需被页面接住,不能因为编码字节已经产生,就忽略资源结束阶段的失败。更完整的产品可以分别记录主操作错误和清理错误,避免诊断信息相互覆盖;本例保持结构紧凑,同时让两类资源都有明确的释放出口。

编码返回的是独立的字节缓冲区,之后可以交给新的图像源验证。我们先核对媒体类型和宽高,再实际解码,再读取左侧透明区中的一个像素。只读取图像头只能证明头部可解析,完整解码才说明当前结果能够再次生成位图。检查点放在区域内部,避开有损编码中更容易出现变化的颜色交界。

格式支持查询不是成功承诺。设备表示支持某种编码格式之后,当前输入、参数、内存状态仍可能导致失败,所以代码继续保留异常处理。应用也不应该把查询结果永久硬编码到界面,尤其当以后增加依赖硬件的格式时。当前示例只比较两种基础输出,扩展格式应新增自己的样本与验收规则。

四、让返回值与产品说明一致

首页把两条策略分别展示为白色卡片,不展示未测量的耗时或压缩比。点击按钮之后依次完成编码和读回验证,只有两个输出都满足条件时才出现完成状态。实际字节长度由运行结果返回,示例写入开发诊断输出,文章没有预填一个固定长度,因为编码器实现和格式细节可能影响它。

import { EncodingReport, compareEncoding } from '../common/EncodingLab';

@Entry
@Component
struct Index {
  @State status: string = '等待编码';
  @State done: boolean = false;
  @State busy: boolean = false;
  private alive: boolean = true;
  private visible: boolean = true;

  private async run(): Promise<void> {
    if (!this.alive || !this.visible || this.busy || this.done) { return; }
    this.busy = true;
    this.status = '正在编码并读回';
    try {
      const result: EncodingReport = await compareEncoding();
      console.info(`PNG bytes=${result.pngBytes}, JPEG bytes=${result.jpegBytes}`);
      if (!this.alive) { return; }
      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.visible = false;
  }

  build() {
    Column({ space: 18 }) {
      Text('像素工坊').fontSize(16).fontColor('#61776B')
      Text('透明度与编码').fontSize(28).fontWeight(FontWeight.Bold)
        .fontColor('#273A2E')
      Text('三段透明度样本 · 96 × 64 px').fontSize(14).fontColor('#61776B')
      Column({ space: 8 }) {
        Text('PNG').fontSize(26).fontWeight(FontWeight.Bold).fontColor('#48785B')
        Text('保留透明度').fontSize(18).fontColor('#273A2E')
        Text('透明区域读回 A = 0').fontSize(14).fontColor('#61776B')
      }.width(312).height(130).justifyContent(FlexAlign.Center)
        .backgroundColor('#FFFFFF').borderRadius(16)
      Column({ space: 8 }) {
        Text('JPEG').fontSize(26).fontWeight(FontWeight.Bold).fontColor('#48785B')
        Text('先与白底合成').fontSize(18).fontColor('#273A2E')
        Text('输出区域读回 A = 255').fontSize(14).fontColor('#61776B')
      }.width(312).height(130).justifyContent(FlexAlign.Center)
        .backgroundColor('#FFFFFF').borderRadius(16)
      Text(this.status).fontSize(18).fontColor('#48785B')
      Button('比较两种编码').width(192).height(42)
        .backgroundColor(this.done || this.busy ? '#DCE5DE' : '#48785B')
        .fontColor(this.done || this.busy ? '#61776B' : '#FFFFFF')
        .enabled(!this.busy && !this.done).onClick(() => { this.run(); })
    }.width('100%').height('100%').padding({ top: 32 })
      .backgroundColor('#F0F5EF')
  }
}

隐藏和销毁分别处理:退后台只关闭可操作标记,保留当前编码任务与完成状态,返回后仍能看到同一轮结果。真正销毁时才关闭回写,不在析构回调里重置状态变量。页面不持有原生输入位图,所有编码及验证对象仍由实验函数按原顺序收尾。

页面中的透明度数字是策略验收条件,完成状态则来自实际检查结果,两者不能混为一谈。尚未运行时,页面仍会说明要保留透明或合成白底,但状态保持等待编码。若某个输出失败,不能让另一张卡片的静态描述被误认为成功结果;对需要逐项反馈的产品,应为每个输出建立独立结果状态。

五、用输出读回约束重构结果

第一组验证保持默认参数,检查格式、宽高和透明边界。第二组故意让 JPEG 分支使用未铺底的透明输入,观察结果与产品白底策略不一致。这一对照说明问题发生在编码前的语义准备,而不一定是编码器质量设置。第三组把格式改成一个支持列表里不存在的值,确认能力检查给出明确失败,页面不会显示两种输出完成。

透明度策略、编码与读回断言矩阵

图 3:PNG 保留透明度,JPEG 先合成白底;读回验证分别检查格式、尺寸和透明边界,编码前颜色公式与编码后结果分开评价。

如果希望比较画质,应该额外保存经过定义的参考像素,并使用适合有损输出的误差度量或视觉验收。不能把重新解码成功等同于画质合格,也不能对 JPEG 逐字节比对后把正常的有损差异报告成损坏。透明度策略、可解码性、颜色质量和文件长度是四个不同指标,测试记录需要分别表达。

元数据同样属于输出策略。示例明确不请求打包原始属性,输入又由代码构造,因此不承诺保留拍摄位置、方向或其他来源信息。接入真实照片后,应先决定哪些属性需要保留,再检查格式和接口是否支持。仅仅把像素编码成功,不代表照片的附属信息已经完整迁移;反过来,默认丢弃属性也不一定符合产品需求。

编码缓存也应跟随策略变化。如果缓存键只有源图身份,透明 PNG 与白底 JPEG 就可能互相覆盖;即便格式相同,白底颜色或质量档位改变也会产生不同输出。把这些参数作为明确请求的一部分,有助于缓存、重试和日志共同使用同一份定义。不要让界面按钮决定一半参数、公共函数暗中补另一半,否则难以复现。

重构的最终检验是调用方是否知道自己将得到什么。它应能说清楚输出格式、透明度行为、尺寸与验证范围,而不需要阅读编码器内部实现。下一篇会把这个编码模块接到完整处理作业中,从图像源到工作位图再到编码结果,统一资源的持有、完成和交接时机。

白底合成还有一个容易被忽略的细节:不能对已经预乘过的颜色再乘一次透明度。那样会让半透明区域比预期更暗。本例从非预乘通道开始,公式只有一次透明度乘法,生成白底版本后透明度固定为完全不透明。若输入来自其他解码或编辑模块,应先读取并确认透明度表示,必要时做转换,不能只看图像是否显得半透明就猜测内部存储。

如果未来允许用户选择背景色,合成公式中的白色常量就应成为显式参数,并加入结果描述和缓存身份。背景色变化通常不会改变输出尺寸,却会改变每个半透明像素的颜色。验收时可以选全黑和全白两个背景检查边界,再检查中间颜色。把选择写进请求比依赖页面全局主题更可靠,浅色模式切换到深色模式也不会偷偷改变导出内容。

重构过程中,测试对象最好保留一份包含完全透明、半透明和完全不透明区域的固定样本。只用不透明照片验证 PNG 与 JPEG,无法覆盖透明度策略;只用完全透明图片,又看不出合成公式是否重复乘了透明度。三条竖带提供了不同约束,彼此补充。它们属于正确性样本,不承担真实照片画质代表性的角色。

参考文档

Logo

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

更多推荐