教室的电子白板上写满了解题过程,下课前点击“保存”,第二天再次进入页面,笔迹却只剩下一张不能编辑的图片。这种功能看起来已经完成了存储,实际上丢掉了批注板最重要的资产:一条条独立、可撤销、可修改的笔迹。另一类问题刚好相反,应用保存了每根线的坐标,导出给学生的 PNG 却出现笔画被裁掉、比例变形、工具栏一起进入图片的问题。持久化和图片导出虽然都叫“保存”,目标完全不同。

沿着前四篇的工程结构,触点已经变成曲线片段,坐标已经统一到文档空间,BoardHistory 负责可逆操作,BoardRenderer 负责把文档画到屏幕。现在要坚持一个原则:可编辑文档保留几何语义,分享图片只负责呈现。两者共用同一份有效笔迹,不以屏幕像素反推几何,也不用一张快照充当恢复文件。这样才能在恢复之后继续撤销、添加或删除笔迹,而不必重新描边。

课堂批注持久化与图片导出技术封面

批注保存、文档恢复和图片导出服务于三种不同的使用需求。

文件里应该放哪些内容

上一节 BoardHistory.all() 已经能够按绘制顺序返回 StrokeRecord[],每条记录包含稳定 ID、颜色、线宽、层次顺序以及二次贝塞尔片段。持久化时,最重要的不是把命令日志照单全收,而是固定某一时刻真正有效的文档。历史游标适合处理当前会话的撤销与重做;持久化对象则应回答“现在画布上究竟有什么”。两者隔离后,后续加入自动保存或多人协作才不会让过期的撤销分支混入正式文档。

一个实际可维护的文档头还需要版本号与画布逻辑尺寸。手机、平板和大屏的组件宽高会变化,保存中的坐标不应该跟着变。这里约定所有笔迹点都保存在文档坐标系里,canvasWidth 和 canvasHeight 表示逻辑白板大小而不是屏幕物理像素。恢复时交给上一节 ViewportTransform 重新映射到当前显示空间。

// model/BoardDocument.ets
import { StrokeRecord, copyRecord } from './BoardCommand';
import { BoardHistory } from './BoardHistory';

export interface BoardDocument {
  version: number;
  canvasWidth: number;
  canvasHeight: number;
  updatedAt: number;
  strokes: StrokeRecord[];
}

export function makeDocument(history: BoardHistory): BoardDocument {
  return {
    version: 1,
    canvasWidth: 1600,
    canvasHeight: 900,
    updatedAt: Date.now(),
    strokes: history.all().map((item: StrokeRecord) => copyRecord(item))
  };
}

export function checkDocument(doc: BoardDocument): boolean {
  if (doc.version !== 1 || !Number.isFinite(doc.canvasWidth) ||
    !Number.isFinite(doc.canvasHeight) || doc.canvasWidth <= 0 ||
    doc.canvasHeight <= 0 || !Array.isArray(doc.strokes)) {
    return false;
  }
  if (doc.strokes.length > 30000) { return false; }
  const ids: Set<string> = new Set();
  for (const stroke of doc.strokes) {
    if (!stroke.id || ids.has(stroke.id) || !Array.isArray(stroke.pieces)) {
      return false;
    }
    ids.add(stroke.id);
    if (!Number.isFinite(stroke.width) || stroke.width <= 0 ||
      stroke.width > 100 || stroke.pieces.length > 30000) {
      return false;
    }
  }
  return true;
}

version 不是展示给用户看的应用版本,而是数据结构的解释规则。未来如果引入压感、画笔混合模式或者多图层,旧文件仍可能按版本转换到新结构。即使目前只有一个版本,也应保留这个字段。格式校验除了检查字段存在,还需要为笔迹总数、片段数与线宽设置合理上限;否则异常文件打开时可能造成大量内存分配。对曲线控制点还可以继续检查 x、y 坐标是否为有限值,避免 NaN 进入 Canvas 的绘制链路。服务端同步得到的文件更要执行相同检查,不能假定数据来源可靠就跳过解析。

写入一半时,怎样保住上一份内容

将 JSON.stringify(doc) 直接覆盖 board.json,实现简单,但保存途中应用退出会留下不完整的 JSON,下一次打开就遇到解析异常。还有一个被忽略的细节:文件写入与按钮反馈不属于同一个瞬间。只有完整写入、关闭文件句柄,并完成文件切换,界面才应该报告保存完成。为了避免覆盖已经存在的可用文档,这里采用“写临时文件 → 改成新的版本文件名 → 恢复时选择最新有效文件”的方式。版本文件名按固定宽度数字排列,便于按名称检索;清理旧版本应放在新版本已经能够读取之后。

// storage/BoardRepository.ets
import { fileIo } from '@kit.CoreFileKit';
import { BoardDocument, checkDocument } from '../model/BoardDocument';

export class BoardRepository {
  constructor(private filesDir: string) {}

  private directory(): string {
    const dir = `${this.filesDir}/whiteboard`;
    if (!fileIo.accessSync(dir)) { fileIo.mkdirSync(dir); }
    return dir;
  }

  save(doc: BoardDocument, revision: number): string {
    if (!checkDocument(doc) || !Number.isSafeInteger(revision) || revision < 0) {
      throw new Error('文档或修订号无效');
    }
    const dir = this.directory();
    const name = `board-${revision.toString().padStart(12, '0')}.json`;
    const temporary = `${dir}/${name}.writing`;
    const target = `${dir}/${name}`;
    const file = fileIo.openSync(temporary,
      fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
    try {
      fileIo.writeSync(file.fd, JSON.stringify(doc), { encoding: 'utf-8' });
    } finally {
      fileIo.closeSync(file);
    }
    fileIo.renameSync(temporary, target);
    return target;
  }

  loadLatest(): BoardDocument | undefined {
    const dir = this.directory();
    const names = fileIo.listFileSync(dir)
      .filter((name: string) => /^board-[0-9]{12}\.json$/.test(name))
      .sort().reverse();
    for (const name of names) {
      try {
        const parsed = JSON.parse(fileIo.readTextSync(`${dir}/${name}`)) as BoardDocument;
        if (checkDocument(parsed)) { return parsed; }
      } catch (error) {
        console.error(`读取批注失败: ${name} ${JSON.stringify(error)}`);
      }
    }
    return undefined;
  }
}

这里用独立的修订号,而不是直接以 Date.now() 作为唯一文件标识。调用方应为同一白板保证修订号递增;如果以后允许多窗口编辑,还需要在保存前检查当前基础修订号,处理写入冲突。临时文件后缀 .writing 不进入恢复候选列表,即使一次写入异常中断,也不会让下一次加载选择到半成品。清理旧版本时至少保留一份可恢复记录,并检查新文件能够通过格式校验,避免清理动作先于保存完成。

注意,文件级安全不等于业务级一致性。发起保存后如果用户继续书写,正在序列化的 doc 应是一份已经复制出来的快照,不能指向实时修改的曲线数组。makeDocument() 通过 copyRecord 固定有效笔迹,配合 BoardHistory 的命令提交边界,可以避免保存文件把两次操作交叉写入。对于文件很多的长期项目,还应考虑归档与保存间隔,避免每个触点 Move 事件都创建一次 JSON。

DevEco Studio 中的批注存储与导出模块

页面操作与文件存储、图像编码拆成独立的服务接口。

从文件恢复到可继续编辑的白板

恢复不是读到 JSON 就把文字显示在屏幕上。它需要完成版本检查、笔迹拷贝、绘制顺序恢复,以及视口映射重置。假如课堂白板上原来有十八条笔迹,重新打开后仍应拥有十八个不同的 ID,颜色、线宽和曲线控制点不变。更重要的是,撤销历史的语义要提前约定。当前方案将恢复后的内容视为一个新的编辑起点,恢复前的命令栈不跨进程延续,避免打开文件后第一次撤销就把昨天的历史动作倒回去。

可以在上一节的 BoardHistory 中增加一个快照装载入口。它与 execute() 不同:装载并不产生新的命令,只构建当前文档,因而不会污染历史游标。

// 以下方法加入上一篇的 BoardHistory 类内部
loadSnapshot(items: StrokeRecord[]): void {
  this.records.clear();
  this.orderIds = [];
  this.commands = [];
  this.cursor = 0;
  const sorted = items.map((v: StrokeRecord) => copyRecord(v))
    .sort((a: StrokeRecord, b: StrokeRecord) => a.order - b.order);
  for (const record of sorted) {
    if (this.records.has(record.id)) {
      throw new Error('恢复数据包含重复笔迹');
    }
    this.records.set(record.id, record);
    this.orderIds.push(record.id);
  }
}

装载完成后,通知上一节的 HistoryRepaint 对整个文档画一次。之后新增或删除笔迹仍走脏区计算。这里的全量刷新是必要的,因为恢复时旧画布缓冲区并不保存历史图像;但不能因此把每一次编辑都退回全量重画。恢复结束还应将当前缩放、平移调整到一个适合阅读的视图,而不是把上次手机上的平移像素原样用于平板。逻辑尺寸属于文档,窗口坐标属于当前设备,两者的职责不能互换。

导出图片需要明确边界和尺寸

“把白板导出为 PNG”有两种常见需求。一种是把正在查看的区域直接截取下来,适合课堂临时分享;另一种是生成固定逻辑尺寸的整页图像,用于打印、存档或课件附件。前者可以用 ArkUI 的 componentSnapshot.get() 获取指定组件的 PixelMap,再通过 ImagePacker.packToData() 编码 PNG;后者应额外准备固定尺寸的绘制容器,使用同一份笔迹在导出尺寸上重绘。把当前手机页面直接截图,再强行拉伸到教学大屏尺寸,线宽、文字与几何比例都会受到影响。

下面使用组件快照走第一条路径,重点是将画布根容器单独设置 id。工具栏、保存按钮和提示信息位于这个根容器外侧,因此不会进入导出的图片。编码后的字节可以写入应用文件目录,返回路径供后续预览、分享或者文件选择流程使用。

// export/BoardImageExporter.ets
import { componentSnapshot } from '@kit.ArkUI';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';

export class BoardImageExporter {
  async exportCurrentView(filesDir: string): Promise<string> {
    const pixelMap: image.PixelMap = await componentSnapshot.get('board-export-root');
    const packer: image.ImagePacker = image.createImagePacker();
    const path = `${filesDir}/classroom-board.png`;
    let file: fileIo.File | undefined;
    try {
      const bytes: ArrayBuffer = await packer.packToData(pixelMap,
        { format: 'image/png', quality: 100 });
      file = fileIo.openSync(path,
        fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
      fileIo.writeSync(file.fd, bytes);
      return path;
    } finally {
      if (file) { fileIo.closeSync(file); }
      await packer.release();
      pixelMap.release();
    }
  }
}

使用时需要给 Column 等实际画布根组件设置 .id('board-export-root'),再把导出操作交给 BoardImageExporter。如果导出内容包含半透明笔触或橡皮擦形成的透明区域,PNG 比 JPEG 更适合保存边缘信息。输出文件在应用文件目录中,并不意味着已经进入用户相册;需要供外部应用访问时,还应按相应文件分享流程处理访问权限。对大尺寸图像,PixelMap 与编码缓冲区会同时占用内存,导出按钮应防止重复点击,并在编码结束后释放对象。

课堂批注板的保存与图片导出界面

可编辑笔迹保存在文档文件中,图片导出则生成一份便于阅读的视觉成果。

用恢复链路检查保存方案

一套值得信赖的保存功能,不应只检查“文件存在”。可以构造几组明确的输入:空白板保存后恢复,笔迹数为零;一条曲线完成后立刻保存,控制点与颜色一致;撤销一条笔迹后保存,恢复的文件里不再包含它;保存中途发生文件异常,最近一次完整版本依旧能读取;将画布放大后导出当前区域,PNG 的内容范围应与 board-export-root 一致。这些场景覆盖文档、历史、文件与图像四个层面,比单看保存按钮的提示更有定位价值。

发生笔迹缺失时先检查 BoardDocument.strokes 的数量与顺序,再检查 BoardHistory.loadSnapshot(),最后检查 BoardRenderer。如果文件有完整曲线而页面没有显示,问题更接近恢复映射或重绘;如果 PNG 边缘裁切,优先检查快照容器尺寸、画布留白以及当前缩放;如果 JSON 无法解析,优先排查写入中断、文件版本与候选选择逻辑。把检查次序固定下来,能减少在界面、存储、渲染之间来回猜测。

为跨设备编辑留下完整文档

这套课堂白板到这里已经拥有五层可独立演进的结构:触点采集描述输入,曲线模型保存形状,坐标转换描述视口,命令日志描述编辑,可编辑文档描述最终内容。图片只是一种输出形式,不承担下一次编辑的源数据。下一篇会继续把这套结构放到平板、折叠屏和 PC 的不同窗口尺寸与输入方式里,重点讨论笔迹宽度、视口重排、触控与鼠标输入的坐标一致性,以及切换设备后怎样仍然读取同一份文档。

参考文档:华为开发者:ArkUI Canvas 自定义绘制;华为开发者:组件截图获取 PixelMap;华为开发者:文件访问;华为开发者:图像编码 。

Logo

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

更多推荐