像素工坊-03/06:HarmonyOS 裁切旋转的操作顺序【共创稿事节】
编辑器里两个相邻按钮,经常让人误以为操作可以随意交换。裁切和旋转却会改变后续坐标的含义:先取出一块图再转动,和先把整图转动再按原数值取块,通常得到不同内容。像素工坊第三篇把这件事落实为一个可以核对尺寸、色块和方向标记的处理项目。

一、先写清楚这次变换的交付条件
工程沿用 API 12、Stage 模型和 PixelLab 名称。新增公共目录中的变换文件,首页只运行一个固定请求:在二百五十六乘一百九十二的源图里,从横坐标三十二、纵坐标二十四开始,裁切宽一百六十、高九十六的区域,然后顺时针旋转九十度。最终位图应为宽九十六、高一百六十,属于竖向输出。
本篇重新列出样本文件,直接使用它就能复现,无需从相册找一张看起来相似的图片。四个颜色区域让区域来源可见,白块又能指出裁切是否截掉了部分内容。输入和输出都留在内存中,不需要权限声明。模板入口能力和页面路由保持默认,开发者只需要添加下列两个公共文件并替换首页。
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 TransformResult {
map: image.PixelMap;
detail: string;
}
function checkRegion(region: image.Region, size: image.Size): void {
const values: number[] = [region.x, region.y,
region.size.width, region.size.height];
if (values.some((v: number) => !Number.isInteger(v)) ||
region.x < 0 || region.y < 0 ||
region.size.width <= 0 || region.size.height <= 0 ||
region.x + region.size.width > size.width ||
region.y + region.size.height > size.height) {
throw new Error('裁切区域超出当前位图');
}
}
export async function transformSample(): Promise<TransformResult> {
const source = image.createImageSource(sampleBytes());
let work: image.PixelMap | undefined = undefined;
try {
work = await source.createPixelMap({
editable: true,
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
index: 0
});
const region: image.Region = {
x: 32, y: 24, size: { width: 160, height: 96 }
};
const before = await work.getImageInfo();
checkRegion(region, before.size);
await work.crop(region);
await work.rotate(90);
const after = await work.getImageInfo();
if (after.size.width !== 96 || after.size.height !== 160) {
throw new Error('变换后尺寸不符合预期');
}
await source.release();
return {
map: work,
detail: '先裁切 160 × 96 px\n再顺时针旋转 90° → 96 × 160 px'
};
} catch (error) {
if (work !== undefined) {
await work.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 { transformSample } from '../common/TransformLab';
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 transformSample();
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('源图坐标 (32, 24) · 160 × 96').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('#B65B2B')
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' : '#B65B2B')
.fontColor(this.done || this.busy ? '#6C7785' : '#FFFFFF')
.enabled(!this.busy && !this.done)
.onClick(() => { this.run(); })
}.width('100%').height('100%').padding({ top: 28 })
.backgroundColor('#FAF4ED')
}
}

图 3:固定请求完成后的可达页面示意。顶部是裁切起点和源区域,预览展示九十六乘一百六十的竖向结果。
这张结果图里,原来裁切区域的下方被旋转到左侧,上方被旋转到右侧。白色方块只剩下位于源区域内的那一部分,并出现在输出右上角。这个小细节比整张图的方向更敏感:若白块完整保留,可能没有真正执行指定裁切;若它落在别的位置,就应检查旋转方向或操作顺序。
页面成功后禁用执行按钮,防止在同一结果上再次叠加裁切。后续要支持连续编辑,应该把每个用户操作定义为针对当前位图的请求,并在每次修改前重新校验尺寸;要支持“恢复原图”,则必须保留原编码输入或明确的基准快照。单纯给按钮加一个重新执行的名字,并不能自动恢复已经被裁掉的内容。
页面隐藏时保留预览及当前操作,返回后继续使用同一份结果;没有把隐藏当成永久退出。真正销毁时才关闭回写并释放普通字段持有的位图,销毁回调不修改状态变量。若处理此后才结束,返回位图由迟到分支归还。这一清理与空间变换是独立责任,裁切后的对象再小也需要明确拥有者;正在被界面使用的对象不能因退后台就提前释放。
四、用顺序对照证明实现没有偷换坐标
先保持默认代码,检查输出宽高和颜色位置。随后另建一个对照函数,从原始样本重新解码,先旋转整图,再按同样的区域数值裁切。整图旋转后尺寸为一百九十二乘二百五十六,此时原来的区域数值仍然合法,但它们指向的是旋转后坐标系中的一块区域,输出也会变成横向的一百六十乘九十六。两个结果不同正是实验要观察的事实。

图 4:同一组区域数值放在不同操作阶段,会选择不同的像素;示意中的尺寸由几何规则计算。
如果产品要求“先旋转,但仍保留原图中选中的那块内容”,就不能直接复用原来的区域数值,需要把选框四个角映射到新的坐标系,再取对应区域。对于九十度旋转可以写出整数映射,但仍要说明坐标是像素中心还是矩形边界,避免最后一行、最后一列的偏差。本文不引入交互选框,正是为了把这两类定义分开。
再做一个越界试验:把裁切起点的横坐标改为一百二十八,宽度保持一百六十。区域右边界会超过源图宽度,应该在系统裁切调用之前被我们的检查拒绝。将宽度改为零也应得到明确失败。不要依赖系统自动截断无效区域;自动截断会让用户输入与实际交付不一致,后续更难解释为什么少了一条边。
镜像可以作为额外的对称性检查。在另一个新解码的可编辑对象上连续执行两次水平翻转,图像应该回到原方向;但这并不能证明裁切与翻转可以交换顺序。翻转改变区域相对于原点的位置,裁切则改变边界,组合操作仍需遵循同样的坐标推理。每增加一种编辑能力,都应明确它是修改内容、边界还是二者兼有。
本篇使用直角和整数区域,没有讨论插值质量,也不把输出的屏幕显示边缘当成像素级证据。若加入任意角度、缩放或重采样,图像会经过新的插值过程,颜色边缘和尺寸都可能变化。应把那类效果单独放入验收,而不要让本篇的四象限断言延伸为所有图像编辑的质量保证。
项目交付还应包括操作失败后的重试行为。测试时故意制造越界,看到失败后再恢复合法区域、重新进入页面,应该得到相同结果。这个过程检查的不是系统稳定性评分,而是代码是否仍从未被修改的输入开始。固定输入、单个工作副本、完成后交接,三个边界合在一起,使后续编码管线能够信任收到的就是最终编辑结果。
审阅代码时尤其要留意页面属性与真实位图操作的区别。界面旋转属性可能只改变绘制表现,并不修改将来交给编码器的像素内容;本篇使用位图的旋转方法,最终尺寸由位图自身读取。这样的输出在下一篇像素实验和后面的编码处理中仍然保持相同方向,不需要再从界面截图倒推编辑结果。
处理副本的选择还决定了预览是否稳定。如果把当前正在显示的位图直接交给编辑函数,异步裁切期间界面可能继续读取它,用户会看见中间形状,失败后也无法再显示原图。示例让工作副本先在内部完成,成功才交接到页面,因此预览只有等待和最终结果两种有效状态。将来支持实时编辑时,需要重新定义中间结果是否属于产品的一部分。
矩形边界的解释也要写进接口文档。起点包含在区域内,宽高表示需要保留的像素数量,因此右边界以起点加宽度表达,而不是再多加一个像素。绘图工具在屏幕上描边时可能把边线画在边界两侧,那是装饰线的表现,不应反过来扩大裁切区域。把处理区域与可见边框分开,才能避免拖动一圈后输出尺寸总差一个像素。
参考文档
更多推荐




所有评论(0)