像素工坊-01/06:HarmonyOS ImageSource 探测与解码入口【共创稿事节】
一张只有几百字节的图片,解码后可能需要几十万字节。把文件大小当成图像大小,会让入口校验检查错对象。像素工坊从一个固定的四象限样本开始:先确认编码内容和尺寸,再决定是否生成可显示的位图。这个入口建立之后,缩略图、裁切、像素实验和重新编码才有共同的起点。

一、让输入样本能够被任何人复现
在 DevEco Studio 新建名为 PixelLab 的 Empty Ability 工程,选择 Stage 模型,把兼容基线设为 API 12。保留模板中的入口能力和页面配置,新增下文列出的两个公共文件,再替换首页。所有输入都由字符串解码得到,不访问网络、相册或公共目录,因此不需要声明运行时权限。入口能力仍然加载默认的首页,工程里也不需要添加图片资源文件。
这套六篇连载使用同一张宽二百五十六、高一百九十二像素的图片。左上是青色,右上是黄色,左下是深蓝,右下是珊瑚色;左上另放一个白色方块,用来分辨镜像和旋转方向。颜色块没有摄影内容的随机细节,边界又足够清楚,后面的变换可以用坐标与颜色共同核对。若换成自己拍摄的图片,压缩率、方向信息和色彩配置都可能改变,不应沿用样本的确定性判断。
把下面代码保存到公共目录的样本文件。这里的字符串是一份完整的 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;
}
使用缓冲区切片是为了把有效字节范围写清楚。带偏移的数组视图可以只覆盖底层缓冲区的一部分,而图像源接收的是整个缓冲区。当前解码器返回的数组通常已经紧凑,仍然保留这个边界处理,后续接入文件片段时就不必依赖偶然成立的偏移条件。输入数据在图像源释放之前保持不变,避免一边解析一边修改同一片字节。
二、把探测与解码分成两个决策点
公共目录中的解码文件实现完整入口。压缩输入限制为一兆字节,两个方向的尺寸各不超过一千零二十四像素,只允许 PNG。这是示例产品自己的范围,不是系统支持能力的上限。检查发生在调用创建位图之前;即使输入压缩得很小,超过尺寸范围也会被明确拒绝。文件字节限制与像素尺寸限制各有作用,不能互相替代。
import { image } from '@kit.ImageKit';
import { sampleBytes } from './PixelFixture';
export interface DecodeResult {
map: image.PixelMap;
detail: string;
}
export async function inspectAndDecode(): Promise<DecodeResult> {
const bytes = sampleBytes();
if (bytes.byteLength === 0 || bytes.byteLength > 1024 * 1024) {
throw new Error('压缩输入超过限制');
}
const source = image.createImageSource(bytes);
let candidate: image.PixelMap | undefined = undefined;
try {
const info = await source.getImageInfo(0);
const width = info.size.width;
const height = info.size.height;
if (!Number.isInteger(width) || !Number.isInteger(height) ||
width <= 0 || height <= 0 || width > 1024 || height > 1024) {
throw new Error('源图尺寸不在示例范围内');
}
if (info.mimeType !== 'image/png') {
throw new Error('本入口只接受 PNG');
}
candidate = await source.createPixelMap({
editable: false,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
index: 0
});
await source.release();
return {
map: candidate,
detail: `源图 ${width} × ${height} px\n解码 RGBA_8888 · 不可编辑`
};
} 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;
}
}
图像源负责解释编码,位图保存已经解码的像素,两者不是一个对象的两种名字。读取图像信息可以回答这张输入声称是什么尺寸和格式,却不能保证全部图像数据都可解码;截断的图片可能通过部分头部读取,然后在解码阶段失败。因此这里保留两个异常边界,也没有把探测成功显示成处理完成。正式输出只出现在创建位图与图像源释放都完成之后。
此处显式指定不可编辑和四通道格式。不可编辑符合本篇只显示、不改写的用途,后续裁切和写像素时会重新建立可编辑工作副本。格式选择保证后续内存推导有明确前提;它没有把四个通道的字节顺序授权给任意读写方法,也不意味着图像源的压缩文件本来就是这种排列。编码格式、位图格式和界面尺寸应该分别记录。

图 2:工程树、连续代码与页面预览的布局示意,展示输入检查后才创建位图的顺序。
函数成功返回后,调用方获得位图的释放责任。函数失败时,已经创建的候选位图由函数自己清理;图像源也会尝试释放。清理错误进入诊断输出,但原始处理错误继续向外传播,避免用户只看到释放失败而失去真正的解码原因。这里没有提前释放正在被异步调用使用的对象,也没有把清空变量等同于归还底层资源。
三、把确认过的结果交给页面
首页只保留一次运行入口,成功后按钮禁用。这样第一次实验不会把重复解码、替换旧图和本篇的探测问题混在一起;需要重新做实验时销毁并重新创建页面实例。页面使用固定大小的白色预览区域,位图按原始宽高比放入其中。页面的逻辑尺寸以视口单位表达,图片信息中的宽高以像素表达,两者并不建立一比一承诺。
import { image } from '@kit.ImageKit';
import { inspectAndDecode } from '../common/DecodeLab';
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 inspectAndDecode();
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('固定样本 · PNG · 256 × 192').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('#006D77')
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' : '#006D77')
.fontColor(this.done || this.busy ? '#6C7785' : '#FFFFFF')
.enabled(!this.busy && !this.done)
.onClick(() => { this.run(); })
}.width('100%').height('100%').padding({ top: 28 })
.backgroundColor('#F2F7F5')
}
}
页面只有在异步函数返回后才设置预览和结果文字。隐藏时保留位图和当前任务,返回后继续显示同一结果;显示与隐藏回调只记录可操作状态。真正销毁时,才关闭回写并从普通所有权字段取出位图释放,不修改任何状态变量。若销毁发生在等待期间,迟到位图直接归还。组件生命周期约束说明了这两类事件的区别。模块级释放辅助函数不捕获页面,界面也不会因退后台提前拿到失效位图。
失败后按钮恢复可用,成功后则保持关闭。不要在每次重新构建页面时调用解码函数,否则状态变化会再次启动工作,形成隐藏的重复分配。处理入口放在明确的用户操作中,状态只描述等待、处理中、成功或失败;预览区域没有拿默认色块冒充真实解码结果。错误消息保留在页面上,便于区分入口拒绝与系统调用失败。
四、用三个反例检查入口是否真的生效
先运行未经修改的样本,预期看到四象限与左上白块,尺寸文字为二百五十六乘一百九十二。这个预期来自样本构造,并非设备性能测量。然后只修改压缩输入上限,使它小于样本长度,页面应在创建图像源之前报告输入超限。最后把允许的最大宽度改为一百二十八,样本应在探测之后、创建位图之前被拒绝。两种拒绝发生在不同层,错误文字也必须能区分。

图 3:输入字节、图像源与位图的职责边界;尺寸通过不代表全部编码内容有效。
还可以在样本函数返回前保留前十六个字节,构造明显不完整的 PNG。失败可能出现在创建图像源、读取信息或真正解码时,具体阶段由解析器决定;验证目标是错误被接住、成功状态没有出现,以及已创建资源具有清理出口。不要把某次设备返回的错误编号写成所有损坏图片的固定结果。若要记录错误码,应从系统异常对象提取,不应凭消息文字猜测。
审查这一入口时,可以沿着数据前进方向提问:压缩字节是否完整,尺寸是否合理,产品是否接受这种格式,候选位图是否真正创建成功,页面是否还需要它。每个问题对应代码中的一个判断或交接点。没有必要为了显示一张样本先搭建文件选择器和数据库;把最小链路做清楚,后续接入外部来源时就能复用这些判断。
本篇把格式探测作为产品策略而不是安全保证。图片解码器仍然可能因为数据损坏、内存紧张或实现差异拒绝工作,调用方必须保留错误路径。尺寸限制也只控制本工具愿意接收的输入,不代表能预测所有临时分配。下一篇将把目标尺寸提前到解码阶段,并分别计算理论像素负担和读取到的位图字节数,避免只把界面缩小当成内存优化。
接入真实文件时,首先保持输入来源和格式判断的独立性。文件扩展名来自命名,可能为空,也可能被用户修改;它适合作为展示提示,却不足以决定解码方式。本例取图像源报告的媒体类型,并使用允许列表。允许列表的意义是明确这条工作流承诺处理的范围,而不是宣称其他格式无法被系统解码。扩大范围时,先补上对应样本和验收条件,再调整判断。
帧索引也需要成为显式契约。静态样本只有第一帧,代码始终使用零作为索引。将来允许多帧资源时,读取哪一帧、最终展示哪一帧、是否保留动画,都会成为独立产品问题。不要因为一个接口存在可选索引,就自动把所有输入都当成静态照片。固定索引让本篇的尺寸探测与实际解码指向同一帧,减少两次调用各取不同内容的可能。
还要关注结果对象的可见范围。返回结构只包含位图和用于显示的说明,没有把图像源、原始字节和内部异常句柄一起交给页面。这样页面无法绕过入口再次解码,也不会意外延长图像源的生命周期。接口越接近使用方真正需要的内容,资源责任就越容易追踪。后面增加裁切参数时,应修改处理请求,而不是让页面直接操作处理中途的对象。
尺寸校验的四个条件各有理由:整数检查拒绝无法对应像素网格的值,正数检查排除空图,单边上限限制极长条图,压缩输入上限限制进入解析器的数据量。当前样本小,似乎只写一个乘积判断也够用,但明确单边约束能让预览和后续操作的适用范围更清楚。检查顺序还让拒绝路径保持简单,避免先申请大缓冲区再决定是否接受输入。
如果把这一页作为教学练习,最好先只观察文字状态,再检查颜色图案。状态可以证明代码走到了哪个交接点,图案可以证明输出的空间结构符合预期。二者合起来比单纯看到一块彩色内容更可靠。预览缩放会改变屏幕上的边缘观感,因此首篇不对屏幕采样颜色做严格数值断言;通道值的验证应留给真正读回像素的实验。
这些选择使解码入口成为一个稳定的模块:调用者提供编码内容,入口核对范围,系统返回位图,拥有者决定显示与释放。以后更换输入渠道时,无需让相册授权、网络响应和位图变换共享同一组页面变量。可以先在固定样本上验证处理行为,再逐步引入外部来源,发生问题时也能迅速判断是输入变化还是解码逻辑变化。
参考文档
更多推荐



所有评论(0)