把一张大图放进很小的组件,改变的是展示范围;让解码器直接返回较小的位图,改变的才是后续持有的数据规模。两种操作最终可能看起来一样,因此缩略图问题不能仅靠肉眼判断。像素工坊的第二篇把目标尺寸变成输入契约,并给每个内存数字标明它究竟来自公式还是运行时查询。

不同尺寸位图与内存关系

一、缩小的究竟是哪一层

继续使用 API 12 的 Stage 工程和上一期的固定 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 { sampleBytes } from './PixelFixture';

export interface ThumbResult {
  map: image.PixelMap;
  detail: string;
  pixelBytes: number;
}

export function fitInside(width: number, height: number,
  boxWidth: number, boxHeight: number): image.Size {
  const values: number[] = [width, height, boxWidth, boxHeight];
  if (values.some((v: number) => !Number.isInteger(v) || v <= 0)) {
    throw new Error('尺寸必须是正整数');
  }
  const ratio = Math.min(1, boxWidth / width, boxHeight / height);
  return {
    width: Math.max(1, Math.floor(width * ratio)),
    height: Math.max(1, Math.floor(height * ratio))
  };
}

export async function makeThumbnail(): Promise<ThumbResult> {
  const source = image.createImageSource(sampleBytes());
  let candidate: image.PixelMap | undefined = undefined;
  try {
    const info = await source.getImageInfo(0);
    const size = fitInside(info.size.width, info.size.height, 128, 128);
    candidate = await source.createPixelMap({
      desiredSize: size,
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
      editable: false,
      index: 0
    });
    const actual = await candidate.getImageInfo();
    if (actual.size.width !== size.width || actual.size.height !== size.height) {
      throw new Error('输出尺寸与目标不一致');
    }
    const pixelBytes = candidate.getPixelBytesNumber();
    if (pixelBytes > 1024 * 1024) {
      throw new Error('输出位图超过一兆字节');
    }
    const kib = size.width * size.height * 4 / 1024;
    await source.release();
    return {
      map: candidate, pixelBytes: pixelBytes,
      detail: `${size.width} × ${size.height} px\n理论 ${kib} KiB · 读回尺寸一致`
    };
  } catch (error) {
    if (candidate !== undefined) {
      await candidate.release().catch((e: Error) => console.error(e.message));
    }
    await source.release().catch((e: Error) => console.error(e.message));
    throw error;
  }
}

这里不把目标框直接传给期望尺寸。期望尺寸是一组具体宽高,不承担我们自定义的等比适配规则。也没有设置解码裁剪区域,以免把缩小和裁剪混为一谈。对于需要铺满方框的头像,应采用另一条明确的裁剪规则;本篇保留图像全部内容,白色方向标记始终存在于左上区域。

先校验正整数,使计算函数既能处理正常图片,也能直接测试边界。零尺寸、负数与非整数都被拒绝。函数输出不超过目标框,也不超过源图两个方向的原始大小。极端长条图经向下取整后至少保留一个像素,但这不意味着其细节足够识别;产品可以在此之外增加最小可读性要求,不必让基础比例函数替界面做判断。

解码后还读取一次实际位图信息。检查目标值只能证明请求正确,读回值才能确认本次返回满足工作流条件。代码把不匹配作为失败,而不是直接把请求值印在结果栏当成事实。对当前固定 PNG,尺寸应当一致;如果未来更换格式、动态范围或增加其他选项,这个读回检查仍然能保护后续依赖具体宽高的算法。

三、理论像素负担与真实持有对象

四通道、每通道一字节时,宽乘高乘四给出紧凑像素数据的理论规模。原样本的理论值是一百九十二 KiB,缩略图则是四十八 KiB,正好变为四分之一。这个比例由两个方向都缩小一半得出,与设备性能无关。页面把数字标成理论值,避免把它误读为整个应用的常驻内存或这次操作的分配峰值。

缩略图生成后的应用页面

图 2:生成一百二十八乘九十六缩略图后的可达页面示意;预览区域仍按四比三展示,数字明确标注为理论像素量。

位图提供的字节数查询是另一个指标。示例返回该值,并用它对输出做一兆字节上限检查,但没有用它替代进程内存测量。解码器临时空间、压缩输入、界面纹理和对象管理开销都可能在这个数字之外。正确的分析应区分单个位图的数据规模与整个处理阶段的峰值,否则即便接口数字下降,仍然不能推导出应用内存按同样比例下降。

缩略图策略的另一个价值是限制生命周期内的重复占用。如果先把原图完整解码,再创建缩小结果,在两者交接之前可能同时持有两份像素。直接请求目标尺寸能够减少返回到应用层的位图负担,但系统内部采用什么中间过程不能仅从接口推断。对于真实大图,仍需在目标设备上记录峰值与耗时,本例的数学表不能替代这些记录。

import { image } from '@kit.ImageKit';
import { makeThumbnail } from '../common/ThumbnailLab';

function releasePreview(map: image.PixelMap): void {
  map.release().catch((error: Error) => console.error(error.message));
}

@Entry
@Component
struct Index {
  @State preview: image.PixelMap | undefined = undefined;
  @State status: string = '等待开始';
  @State detail: string = '';
  @State busy: boolean = false;
  @State done: boolean = false;
  private alive: boolean = true;
  private visible: boolean = true;
  private owned: image.PixelMap | undefined = undefined;

  private async run(): Promise<void> {
    if (!this.alive || !this.visible || this.busy || this.done) { return; }
    this.busy = true;
    this.status = '处理中';
    try {
      const result = await makeThumbnail();
      if (!this.alive) {
        await result.map.release();
        return;
      }
      this.owned = result.map;
      this.preview = result.map;
      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.visible = false;
    const old = this.owned;
    this.owned = undefined;
    if (old !== undefined) { releasePreview(old); }
  }

  build() {
    Column({ space: 14 }) {
      Text('像素工坊').fontSize(16).fontColor('#697586')
      Text('解码尺寸预算').fontSize(28).fontWeight(FontWeight.Bold)
        .fontColor('#243343')
      Text('目标框 128 × 128 px').fontSize(14).fontColor('#697586')
      Stack() {
        if (this.preview !== undefined) {
          Image(this.preview).width(256).height(192).objectFit(ImageFit.Contain)
        } else {
          Text('等待图像').fontSize(18).fontColor('#697586')
        }
      }.width(312).height(220).backgroundColor('#FFFFFF').borderRadius(16)
      Text(this.status).fontSize(20).fontColor('#355C9A')
      Text(this.detail).fontSize(15).fontColor('#49586B')
        .width(312).height(62).textAlign(TextAlign.Center)
      Button('生成缩略图').width(192).height(42)
        .backgroundColor(this.done || this.busy ? '#DCE2E8' : '#355C9A')
        .fontColor(this.done || this.busy ? '#6C7785' : '#FFFFFF')
        .enabled(!this.busy && !this.done)
        .onClick(() => { this.run(); })
    }.width('100%').height('100%').padding({ top: 28 })
      .backgroundColor('#F1F4FA')
  }
}

页面沿用隐藏时保留结果、销毁时释放所有权的约定。退后台不会清空预览,也不会重置一次运行状态;待完成的固定任务可以继续结束,回来后显示原结果。销毁回调只修改普通字段,不改状态变量,避免把资源清理写成一次新的界面刷新。

界面预览沿用第一篇的固定容器,这恰好说明展示大小与数据大小没有被绑定。第一篇原图和本篇缩略图占用相同的预览位置,但本篇的像素密度更低。不要因为两张示意图的外观接近,就认定优化没有发生;也不要因为小图看上去更模糊,就断言解码器出错。验收应同时检查实际尺寸和产品允许的清晰度。

四、把尺寸算法与设备结果分开验证

先用四组纯计算输入检查公式:横图装入方框、竖图装入方框、已经小于目标框的图片,以及极窄图片。对应的结果可以不依赖系统图像接口计算出来。然后才运行固定样本,确认读回宽高符合目标、方向标记没有丢失、失败路径不显示完成。数学函数通过只证明尺寸策略正确,不证明任何设备解码速度。

输入尺寸目标框期望输出四通道理论字节数
256 × 192128 × 128128 × 9649,152
192 × 256128 × 12896 × 12849,152
32 × 16128 × 12832 × 162,048
1 × 100032 × 321 × 32128

比例计算与像素面积对比

图 3:源图与缩略图的面积、理论字节量和比例关系;全部数字由尺寸公式得到,不表示真机内存测量。

还可以改变目标框而不改变界面容器。把目标框改为六十四乘六十四时,输出应变为六十四乘四十八;改为五百一十二见方时,由于禁止主动放大,输出仍保持原始尺寸。这两个方向分别检验缩小与不放大规则。若只是把目标框文字改了,而处理函数仍传入原常量,页面会制造假实验,因此所有结果说明都由返回尺寸生成。

输入尺寸的上限属于外层策略。纯比例函数只要求正整数,不承担压缩文件大小、图像格式与来源授权的检查;在真实管线中,应把第一篇的输入门槛保留在它之前。本篇固定样本不引入未知输入,所以省去重复的格式分支,但没有把比例函数包装成通用安全解码器。函数责任明确,日后增加不同缩略图规格也只需要增加参数。

需要缓存多种尺寸时,应把目标像素尺寸作为缓存身份的一部分。同一输入的列表缩略图和大预览不是可互换的结果;直接用文件名作键,可能把低清结果误用于大尺寸展示。缓存是否持有位图还会延长它的生命周期,所以本篇先保持一次运行、一个输出。缓存策略应建立在已经明确的尺寸与资源契约上,而不应靠全局数组积攒结果。

对于需要快速首屏的场景,尺寸选择还涉及内容用途。纯色样本几乎没有细节损失,不能代表文字截图或细密纹理。可以在保持同一比例函数的前提下增加新的测试图片,比较小字是否仍可读、边缘是否足够清晰。这样的比较评价的是规格选择,不应与编码质量或颜色变换混成一个总分。每项实验保留一个主要变量,结论才容易解释。

本篇交付的是一项可重复的尺寸决策:输入明确的像素框,返回经过读回确认的位图,附上可解释的理论规模。下一篇会在可编辑副本上做空间变换。届时尺寸仍然是操作的前置条件,但顺序会改变结果:先裁切再旋转和先旋转再裁切,处理的并非同一块源图区域。

预算检查还要区分事前和事后。目标尺寸公式可以在分配前估算结果规模,实际字节数只能在位图已经存在之后查询。因此,事后超过上限就释放输出,能够阻止继续持有不合格结果,却无法追回已经出现的分配峰值。对来自外部的大图,应同时限制源图尺寸,并在请求阶段选择合理目标,而不是只靠最后一行检查保护内存。

如果不同设备的返回字节数出现差异,应先确认位图格式、行对齐方式和解码选项相同,再比较数值。不能看到差异就认定其中一个设备泄漏,也不能把多出的所有字节都解释成像素内容。读回尺寸用于空间契约,返回字节数用于对象级预算,进程统计用于整体资源评估,三个指标各有观察对象。记录时保留这些标签,后续分析才不会混用。

本例使用异步接口并不意味着任意重计算都自动离开主线程。比例公式和字符串组装仍在调用它们的执行上下文中运行,只是工作量非常小。若将来增加逐像素循环或批量解码,需要另行设计执行位置与并发上限。当前先把一次工作的输出规模限定好,能让后续并发设计拥有可估算的单位成本,而不会用无限并发掩盖尺寸策略缺失。

参考文档

Logo

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

更多推荐