《海浪角》的图谱编辑已经能够在八乘八针格里落针,支持镜像预览、缩放命中、染色批次选择和平板分栏。功能连起来后,最容易留下的故障反而来自一个普通动作:用户给第五行第五列加了一针湖蓝,随后按撤销,又按重做。画布可能按照点击顺序切换成五针、四针、五针,但右侧线色计数和库存余额如果仍保存上一次的结果,就会出现“图上五针、预算只有四针”的矛盾。

沿用已经确定的图谱记录:B湖蓝在第二行第二、三列,C珊瑚橙在第三行第四列,A乳白在第四行第四列,原稿是四针,完成率6.25%。第五行第五列此前只是选中焦点,没有颜色。这一次按选定的B-02湖蓝批次落针,图谱变成五针;撤销恢复空格,重做再落回湖蓝。需求不是把整个图谱复制三份,而是让一次业务变更能够精确地前进与回退。

落针撤销、重做与用线预算

先确定哪些状态属于历史

图谱针位属于可以回退的业务数据,当前线色、选中格、缩放比例则属于视图操作。用户放大画布后按一次撤销,期待的是撤销最近一针,而不是把缩放比例还原。如果把页面全部状态打包进历史栈,每个面板展开和尺寸变化都会占用一条撤销记录,既难理解,也不利于后续维护。

我的处理方式是为每次真正改变格子的操作建立一条命令:记录稳定格子键、修改前的颜色和修改后的颜色。第五行第五列的键仍采用5-5,与前一篇分栏页面相同。命令的before为空字符串表示此前尚未落针,after为B表示本次使用湖蓝。遇到重复点选相同颜色的情况,前后内容一致,直接返回,不向历史栈添加空操作。

两条栈分别保存可撤销命令和可重做命令。用户加针后清空重做栈;撤销时从撤销栈取出最近命令,把它的before应用回图谱,再将命令压入重做栈;重做则按after重新应用并回到撤销栈。这样既能连续操作,也能明确保证“撤销后又进行了新编辑,旧的重做路径必须失效”。

用稳定键记录最小变更

下方把八乘八业务模型保持为有序针位数组,而不是直接存储六十四个组件实例。命令对象只包含业务含义,编辑器持有数组副本并以整体替换更新。为了防止同一对象在历史栈里被后来修改,命令中的前后颜色都使用值类型,每次写入返回新的数组。后续要持久化历史记录时也更容易转为普通数据。

export type ThreadColor = 'A' | 'B' | 'C';
export interface StitchCell {
  row: number;
  col: number;
  colorId: ThreadColor;
}
export interface StitchChange {
  key: string;
  before: ThreadColor | '';
  after: ThreadColor | '';
}
export const SEED: StitchCell[] = [
  { row: 2, col: 2, colorId: 'B' },
  { row: 2, col: 3, colorId: 'B' },
  { row: 3, col: 4, colorId: 'C' },
  { row: 4, col: 4, colorId: 'A' }
];
export function keyOf(row: number, col: number): string {
  return `${row}-${col}`;
}
export function readColor(cells: StitchCell[], key: string): ThreadColor | '' {
  const found: StitchCell | undefined = cells.find(
    (c: StitchCell) => keyOf(c.row, c.col) === key
  );
  return found ? found.colorId : '';
}
export function writeCell(cells: StitchCell[], key: string,
  color: ThreadColor | ''): StitchCell[] {
  const point: string[] = key.split('-');
  const row: number = Number(point[0]);
  const col: number = Number(point[1]);
  if (!Number.isInteger(row) || !Number.isInteger(col) ||
      row < 1 || row > 8 || col < 1 || col > 8) {
    return cells;
  }
  const kept: StitchCell[] = cells.filter(
    (c: StitchCell) => keyOf(c.row, c.col) !== key
  );
  return color === '' ? kept : [...kept, { row, col, colorId: color }];
}

writeCell先移除原位置再写入新颜色,避免一个格子出现两条记录。位置不合法时返回原数组,但在提交层仍应该额外提示越界原因,避免错误触点被误认为已经落针。针位数量用数组长度求得,格子键则用于UI高亮,不能把数组的第几个元素误当作实际的行列。镜像预览也保持独立,不进入这一套编辑命令,因此只是看镜像不会污染撤销历史。

撤销与重做由同一个编辑服务处理

把两条栈包进StitchHistory,让页面只调用place、undo和redo。每个方法都返回更新后的针位快照,而不是在组件内写几份相互依赖的计数。历史栈最多保留五十步;达到上限时丢弃最早的撤销命令,防止连续编辑后不断累积内存。超出容量只影响能退回多远,不影响当前图谱的数据完整性。

export class StitchHistory {
  private cells: StitchCell[];
  private undoStack: StitchChange[] = [];
  private redoStack: StitchChange[] = [];
  constructor(seed: StitchCell[]) { this.cells = [...seed]; }
  snapshot(): StitchCell[] { return [...this.cells]; }
  place(key: string, color: ThreadColor): boolean {
    const pair: string[] = key.split('-');
    const row: number = Number(pair[0]);
    const col: number = Number(pair[1]);
    if (!Number.isInteger(row) || !Number.isInteger(col) ||
        row < 1 || row > 8 || col < 1 || col > 8) return false;
    const before: ThreadColor | '' = readColor(this.cells, key);
    if (before === color) return false;
    const change: StitchChange = { key, before, after: color };
    this.cells = writeCell(this.cells, key, color);
    this.undoStack = [...this.undoStack, change].slice(-50);
    this.redoStack = [];
    return true;
  }
  undo(): boolean {
    const change: StitchChange | undefined = this.undoStack.pop();
    if (!change) return false;
    this.cells = writeCell(this.cells, change.key, change.before);
    this.redoStack.push(change);
    return true;
  }
  redo(): boolean {
    const change: StitchChange | undefined = this.redoStack.pop();
    if (!change) return false;
    this.cells = writeCell(this.cells, change.key, change.after);
    this.undoStack.push(change);
    return true;
  }
  canUndo(): boolean { return this.undoStack.length > 0; }
  canRedo(): boolean { return this.redoStack.length > 0; }
}

撤销和重做遇到空栈会返回false,页面可据此禁用按钮。若已有四针,执行place('5-5','B')得到五针;undo()恢复四针,再执行redo()又得到五针。如果撤销后改为在第六行第六列放C,原来的第五行第五列重做命令被清空,这是分叉编辑的必要规则。每次服务更新后都要将snapshot()交给ArkUI的响应式状态,单纯修改普通类的内部数组不会自动让界面刷新。

预算由针位快照派生,不修改库存原值

第三篇规定每针按0.35米并预留12%余量,预算单针用量为0.392米。四针时A、B、C分别是1、2、1针,共1.568米。第五行第五列加B后变为1、3、1针,总预算1.960米;湖蓝需求从0.784米变成1.176米。选用的B-02同批次线材库存是1.20米,预算可用余额只剩0.024米。撤销后应回到0.416米的预算余额,重做后又变成0.024米。

这里的“余额”属于预算推导值,并不意味着实体库存已经扣减。真正执行领线或消耗登记时,还需要独立的库存提交操作。若把计算预算和扣减库存混成一个点击动作,撤销图谱时可能错误地增加实体线材,甚至把B-01与B-02两个染色批次相加。图谱编辑器只读取已确认的B-02批次总量,再按当前针位计算需求。

export interface ThreadBudget {
  colorId: ThreadColor;
  stitches: number;
  requiredMeters: number;
}
export function calculateBudget(cells: StitchCell[]): ThreadBudget[] {
  const colors: ThreadColor[] = ['A', 'B', 'C'];
  return colors.map((colorId: ThreadColor): ThreadBudget => {
    const stitches: number = cells.filter(
      (cell: StitchCell) => cell.colorId === colorId
    ).length;
    return {
      colorId, stitches,
      requiredMeters: Math.round(stitches * 0.35 * 1.12 * 1000) / 1000
    };
  });
}
@Entry
@Component
struct HistoryPage {
  @State private stitches: StitchCell[] = [...SEED];
  @State private selectedKey: string = '5-5';
  private history: StitchHistory = new StitchHistory(SEED);
  private change(): void {
    if (this.history.place(this.selectedKey, 'B')) {
      this.stitches = this.history.snapshot();
    }
  }
  private undoChange(): void {
    if (this.history.undo()) this.stitches = this.history.snapshot();
  }
  private redoChange(): void {
    if (this.history.redo()) this.stitches = this.history.snapshot();
  }
  build() {
    Column({ space: 12 }) {
      Text('海浪角 · 落针历史')
      Text(`已落针 ${this.stitches.length}/64`)
      Text(`选中格 ${this.selectedKey}`)
      Row({ space: 12 }) {
        Button('落针 B').onClick(() => this.change())
        Button('撤销').onClick(() => this.undoChange())
        Button('重做').onClick(() => this.redoChange())
      }
      ForEach(calculateBudget(this.stitches), (budget: ThreadBudget) => {
        Text(`${budget.colorId}:${budget.stitches}针 / ${budget.requiredMeters}米`)
      }, (budget: ThreadBudget) => budget.colorId)
    }.padding(16)
  }
}

页面上的按钮只承担发起命令和接收新快照,实际八乘八Grid仍使用前几篇的绘制组件,网格单元依据selectedKey显示焦点与颜色。上面的页面片段聚焦命令入口和预算投影;整合时将它们嵌入现有画布与线色库存面板。服务实例跟随编辑会话存在,离开编辑页时应决定是否把当前针位和历史记录序列化保存;重新打开页面需要重建同一条栈顺序,否则只能恢复图谱,无法继续撤销前次编辑。

开发环境中的编辑命令和预算更新

用四个状态节点定位错误

可以把交互分成四个观察点:初始四针,落B后五针,撤销后四针,重做后五针。每个节点都核对相同的三组信息:针位集合、A/B/C针数以及B-02预算余额。状态变化必须从命令到针位,再到预算投影逐层传播,而不是让三个区域分别维护一份数字。第五行第五列最初为空,落针后才出现蓝色X;如果撤销后蓝色X仍在,但数量变为四,问题就在画布读取的旧快照。

另一条边界是库存刚好接近阈值。湖蓝三针需要1.176米,对比1.20米库存仍有0.024米可用;若继续给另一个空格落第四针湖蓝,需求将达到1.568米,应阻止最终的备线确认并提示缺口0.368米。是允许用户继续设计图稿,还是禁止库存不足时编辑,应由业务决定;编辑操作与领线确认分离,就能同时支持自由设计和严谨库存校对。

在大屏分栏中,命令服务仍只保留一份。左右布局切换不会写入历史;在手机小屏展开线色抽屉也不会多加撤销步骤。第05篇的稳定焦点键5-5可直接传入命令,缩放后的触点仍通过第04篇坐标反算后得到这个键。经过这些边界整理,历史功能不会破坏前五篇建立的图谱、库存和布局规则。

五针状态的撤销重做及线色余量

让撤销操作保持可预测

撤销记录的职责是恢复数据改变,而不是恢复所有视觉效果。针位数组是唯一事实来源,预算与完成率是可重新计算的结果,线材批次选择与放大比例则独立存放。后续如果增加批量填色,可以让一个批量命令包含多条针位变化并一次入栈,确保用户的一次操作对应一次撤销,而不是需要逐格退回。这样的分层,比给每个按钮单独写逆向处理更容易维护。

相关技术参考:华为开发者:创建网格Grid/GridItem、华为开发者:@Watch状态变化通知、华为开发者:ArkTS语言简介。

Logo

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

更多推荐