摄影选片应用已经从系统相册取得P001~P003的会话素材,并为P001建立独立的工作副本。P001原图尺寸为4000×3000像素,原图坐标系中保存着一个从(600,450)到(1800,1200)的矩形框,以及三枚标记点:(1020,800)、(1680,620)、(1820,1180)。前一阶段解决了图片缩放到1.6倍、平移(-40,-20)后标记位置不漂移的问题,但编辑动作本身还缺少安全的撤销与重做。

常见场景是摄影师把第二个标记从(1680,620)移动到(1700,650),发现位置不合适后点击撤销,再点击重做。如果把每次双指缩放和每帧拖动都写进历史,撤销按钮会连续走过大量无意义的视图帧;如果只记录视图坐标,换用平板打开后又很难还原原始位置。本章将“批注文档编辑”与“临时视图变换”严格分开,把一次完整的拖动结束当作一次可撤销操作,保存的是原图像素坐标。

花卉P001批注文档的版本快照与撤销重做关系

一个快照只描述批注文档,不描述用户视角

工程增加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()。页面消失前若有未保存修改,可提示保存或保留会话草稿,但不宜在组件每次重新构建时自动写入。

手机端P001花卉批注的撤销、重做与保存版本

调试可围绕四条确定的输入展开。把点2移动到(1700,650),检查历史长度增加一次而不是几十次;按撤销回到(1680,620),重做回到新位置;再次撤销后移动点3,检查重做栈立即清空;在缩放1.6倍与平移(-40,-20)时执行前述操作,确认视图变换始终通过同一矩阵投影。然后检查保存完成前后的按钮状态、重进页面后的资产身份以及损坏JSON的恢复提示。

对于拖动到图像外的触点,应先调用上一章的逆变换并检查原图边界。只有坐标有效、业务值真正发生变化时才提交快照;否则保持当前历史不变。HiLog记录资产ID、修订号、撤销栈长度、重做栈长度和保存阶段即可,不需输出图库原始URI,也不必把整份批注JSON打印出来。这样既降低日志噪声,也避免不必要的素材路径暴露。

到这里,摄影选片工作流中的照片副本、原图坐标、视图变换与编辑历史各有明确职责。撤销重做只调整结构化批注内容,页面缩放不会污染历史,保存失败也不会掩盖尚待处理的修改。下一步就可以在稳定的批注文档基础上加入批注意见的分组、检索以及选片交付清单,而不必重新处理坐标与历史的基础规则。

参考资料:ArkUI状态变化监听、Preferences数据持久化、ArkTS语言概述、2D画布绘制与显示。

Logo

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

更多推荐