HarmonyOS摄影选片连载05:批注撤销重做与版本快照【共创稿事节】
摄影选片应用已经从系统相册取得P001~P003的会话素材,并为P001建立独立的工作副本。P001原图尺寸为4000×3000像素,原图坐标系中保存着一个从(600,450)到(1800,1200)的矩形框,以及三枚标记点:(1020,800)、(1680,620)、(1820,1180)。前一阶段解决了图片缩放到1.6倍、平移(-40,-20)后标记位置不漂移的问题,但编辑动作本身还缺少安全的撤销与重做。
常见场景是摄影师把第二个标记从(1680,620)移动到(1700,650),发现位置不合适后点击撤销,再点击重做。如果把每次双指缩放和每帧拖动都写进历史,撤销按钮会连续走过大量无意义的视图帧;如果只记录视图坐标,换用平板打开后又很难还原原始位置。本章将“批注文档编辑”与“临时视图变换”严格分开,把一次完整的拖动结束当作一次可撤销操作,保存的是原图像素坐标。

一个快照只描述批注文档,不描述用户视角
工程增加model/AnnotationHistory.ets与service/AnnotationHistoryStore.ets。快照中保留assetId、源图宽高、框选区域、点标记与文档修订号。图片资源路径仍由前两篇的素材管理服务维护,历史里不复制原图字节。这样每次撤销只在较小的结构化对象之间切换,不会随照片分辨率增长而消耗大量内存。
尤其要避免把zoom、panX或当前窗口宽度加入持久快照。视图参数依然由上一章的ViewTransform维护,重做后重新通过toView()投影标记。撤销修改的是原始点坐标,平移放大不应改变“可撤销编辑”的数量。
export interface PixelPoint {
id: string;
x: number;
y: number;
}
export interface PixelRect {
x: number;
y: number;
width: number;
height: number;
}
export interface AnnotationDocument {
assetId: string;
sourceWidth: number;
sourceHeight: number;
revision: number;
region: PixelRect;
points: PixelPoint[];
}
export interface HistoryState {
past: AnnotationDocument[];
present: AnnotationDocument;
future: AnnotationDocument[];
}
P001初始化时,present记录上一章的三枚标记和原始框选;past与future都为空。当第二枚点移动结束时,把修改前的文档放入past,新文档成为present,future清空。一个手势内部不断改变的是临时拖动位置,在用户松开且坐标校验通过后才形成新的历史节点。
用不可变更新避免旧快照被悄悄改写
ArkUI状态变化后会更新组件显示,但如果历史数组里的多个对象指向同一份可变数组,稍后修改某一点就可能同时改变以前的快照。快照应复制矩形及每个点对象,保证历史项之间没有共享的内部批注引用。下面的movePoint()还检查标记身份与原图边界;找不到点时直接返回原文档,不制造空操作。
import { AnnotationDocument, PixelPoint } from '../model/AnnotationHistory';
export function copyDocument(doc: AnnotationDocument): AnnotationDocument {
return {
...doc,
region: { ...doc.region },
points: doc.points.map((p: PixelPoint) => ({ ...p }))
};
}
export function movePoint(
doc: AnnotationDocument, id: string, x: number, y: number
): AnnotationDocument {
if (!Number.isFinite(x) || !Number.isFinite(y) ||
x < 0 || y < 0 || x > doc.sourceWidth || y > doc.sourceHeight) {
throw new Error('批注坐标超出原图范围');
}
if (!doc.points.some((p: PixelPoint) => p.id === id)) {
return doc;
}
return {
...copyDocument(doc),
revision: doc.revision + 1,
points: doc.points.map((p: PixelPoint) =>
p.id === id ? { ...p, x: Math.round(x), y: Math.round(y) } : { ...p })
};
}
同一标记的身份mark-02不会随移动改变。保存前应比较新旧位置,如果位置没有变化就无需新增历史项,否则反复点击“完成”可能充满没有差异的快照。对框选矩形也要采用同样策略:将两个角规范化为左上角、宽度和高度,并在原图范围内检查,随后才提交一次历史更新。
撤销与重做是两条有方向的栈
past保存可回退的旧文档,future保存撤销以后可恢复的文档。点击撤销:从past取出最后一项,把当前present放到future前端;点击重做则从future取出第一项,当前present追加到past尾部。任何新的有效编辑都意味着分叉到另一条历史线,因此要清空future,否则用户会把旧分支上的操作意外接到新的文档上。
一份会话可以把撤销深度控制为20步。这是本应用的内存管理策略,而不是ArkUI系统限制。为避免直接篡改参数对象,函数始终返回新状态。界面将返回值赋给@State变量,撤销按钮只需根据past.length判断是否可用。
import { AnnotationDocument, HistoryState } from '../model/AnnotationHistory';
import { copyDocument } from './AnnotationMath';
export function commitEdit(state: HistoryState, next: AnnotationDocument): HistoryState {
const past = [...state.past, copyDocument(state.present)].slice(-20);
return { past, present: copyDocument(next), future: [] };
}
export function undo(state: HistoryState): HistoryState {
if (state.past.length === 0) { return state; }
const previous = state.past[state.past.length - 1];
return {
past: state.past.slice(0, -1),
present: copyDocument(previous),
future: [copyDocument(state.present), ...state.future]
};
}
export function redo(state: HistoryState): HistoryState {
if (state.future.length === 0) { return state; }
return {
past: [...state.past, copyDocument(state.present)].slice(-20),
present: copyDocument(state.future[0]),
future: state.future.slice(1)
};
}
当点2从(1680,620)改为(1700,650)时,第一次提交会增加一个历史节点;撤销使点2回到(1680,620),重做再次得到(1700,650)。若撤销之后先修改点3,则旧的点2重做路径应立即失效。视图继续停在1.6倍也没有关系,因为原图坐标没有依赖该倍率。

持久化时区分“编辑成功”与“保存成功”
撤销和重做主要发生在内存中,离开页面前需要把当前文档及有限的历史栈保存为会话记录。Preferences提供getPreferences()、put()和flush(),适合体积受控的少量JSON;完整照片副本仍应留在应用文件目录。本章为P001使用单独的键,避免P002的编辑覆盖它。对同一文档串行执行保存,不能同时发出多次整包覆盖写入。
保存按钮点击后先锁定当前快照,等待落盘完成才将界面的“有改动”标记清零。若保存失败,仍保留内存里的文档与撤销栈,并允许再次保存;没有必要因此删除摄影师刚做的标记。加载过程中对assetId、宽高、历史数组和点坐标进行校验,遇到格式异常应进入恢复提示,而不是直接覆盖旧存储。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { HistoryState } from '../model/AnnotationHistory';
export async function saveHistory(
context: common.UIAbilityContext, state: HistoryState
): Promise<void> {
if (state.present.assetId.length === 0) {
throw new Error('图片身份为空');
}
const store = await preferences.getPreferences(context, 'photo_annotation_history');
const key = `history_${state.present.assetId}`;
await store.put(key, JSON.stringify(state));
await store.flush();
}
export async function readHistory(
context: common.UIAbilityContext, assetId: string
): Promise<HistoryState | null> {
const store = await preferences.getPreferences(context, 'photo_annotation_history');
const value = await store.get(`history_${assetId}`, '');
if (typeof value !== 'string' || value.length === 0) { return null; }
const parsed = JSON.parse(value) as HistoryState;
if (!parsed.present || parsed.present.assetId !== assetId ||
!Array.isArray(parsed.past) || !Array.isArray(parsed.future)) {
throw new Error('批注历史格式错误');
}
return parsed;
}
这种数据模型允许后续在文件层引入更完整的历史版本格式。文档修订号与撤销栈序号也需要区分:修订号记录编辑形成的内容变化,撤销只是当前内容指针的转换,不意味着原始文件已被覆盖。若需要多终端同步,必须额外定义服务端的版本冲突策略,单设备Preferences不能替代跨设备版本协调。
页面生命周期和调试检查路径
pages/AnnotationHistoryPage.ets可以持有@State private history与@State private saving,分别驱动撤销重做按钮状态以及保存按钮的禁用状态。页面进入时先加载P001对应的记录,再用上一章的正向坐标变换绘制区域框和标记点;用户拖动点时只更新临时视觉位置,松开时调用movePoint()和commitEdit()。页面消失前若有未保存修改,可提示保存或保留会话草稿,但不宜在组件每次重新构建时自动写入。

调试可围绕四条确定的输入展开。把点2移动到(1700,650),检查历史长度增加一次而不是几十次;按撤销回到(1680,620),重做回到新位置;再次撤销后移动点3,检查重做栈立即清空;在缩放1.6倍与平移(-40,-20)时执行前述操作,确认视图变换始终通过同一矩阵投影。然后检查保存完成前后的按钮状态、重进页面后的资产身份以及损坏JSON的恢复提示。
对于拖动到图像外的触点,应先调用上一章的逆变换并检查原图边界。只有坐标有效、业务值真正发生变化时才提交快照;否则保持当前历史不变。HiLog记录资产ID、修订号、撤销栈长度、重做栈长度和保存阶段即可,不需输出图库原始URI,也不必把整份批注JSON打印出来。这样既降低日志噪声,也避免不必要的素材路径暴露。
到这里,摄影选片工作流中的照片副本、原图坐标、视图变换与编辑历史各有明确职责。撤销重做只调整结构化批注内容,页面缩放不会污染历史,保存失败也不会掩盖尚待处理的修改。下一步就可以在稳定的批注文档基础上加入批注意见的分组、检索以及选片交付清单,而不必重新处理坐标与历史的基础规则。
更多推荐


所有评论(0)