HarmonyOS桌游卡版连载04:版式快照持久化与导出前一致性复核【共创稿事节】
桌游卡牌编辑器进入最终交付环节,最棘手的问题不再是画出辅助线,而是“刚才看到的卡面”与“准备用于导出的卡面”能否确定是同一版。“森林邮局”的画布规格已经固定,前面建立了矩形碰撞检查,也完成了手机与平板编辑区的重排。如果用户移动标题后立即切换页面,存储操作尚未完成,导出页可能仍取到旧版草稿;如果图片素材被删除,几何坐标虽然合法,导出内容却可能缺图。收尾阶段需要明确编辑态、持久化快照和导出检查这三种不同的状态。

快照是内容版本,不是页面截图
同一个卡牌项目在手机紧凑页面、平板双栏页面里的工具位置不同,但“森林邮局”的实际规格始终是出血画布69×94毫米、裁切成品63×88毫米、安全框53×78毫米。快照应保存毫米坐标的元素、资源标识、卡面规格、允许重叠关系和版式修订号,不保存当前预览容器宽度、选中的页签或滚动条位置。这些视图字段属于临时交互状态,写入快照只会给跨设备恢复制造差异。
项目延续CardGeometry.ets与LayoutInspector.ets,在model/CardSnapshot.ets定义持久化模型,service/SnapshotStore.ets接入用户首选项,在service/ExportPreflight.ets集中执行导出前检查,pages/ExportCheckPage.ets展示风险和版本。素材文件由应用资源模块维护,快照只保存稳定标识与期望版本。这样既避免把二进制图片塞入轻量键值存储,也方便在素材替换后识别引用变化。
版本号具有两个用途。schemaVersion标识JSON结构怎样解析,未来添加旋转角度等字段时可以安排迁移;revision表示卡面内容发生了多少次修改,用来确认当前编辑态和持久化结果是否一致。不要将两者合并。一个旧结构文件即使修订号很高,也不表示它能被新代码无条件解析。快照内保留卡面元素ID是为了让错误列表在恢复后仍能定位到相同标题或说明框。
export interface RectMm {
x: number; y: number; width: number; height: number;
}
export interface CardElement {
id: string;
kind: 'TITLE' | 'ILLUSTRATION' | 'TEXT' | 'ORNAMENT';
box: RectMm;
assetId: string;
keepInsideSafe: boolean;
}
export interface CardSnapshot {
schemaVersion: number;
revision: number;
cardId: string;
trimWidthMm: number;
trimHeightMm: number;
bleedMm: number;
safeInsetMm: number;
elements: CardElement[];
assetIds: string[];
allowedOverlapPairs: string[];
}
export function normalizeSnapshot(source: CardSnapshot): CardSnapshot {
if (source.schemaVersion !== 1 || source.revision < 0 ||
source.trimWidthMm <= 0 || source.trimHeightMm <= 0) {
throw new Error('快照结构或尺寸无效');
}
const ids = source.elements.map((e: CardElement) => e.id);
if (new Set(ids).size !== ids.length) {
throw new Error('元素ID重复');
}
return {
...source,
elements: source.elements.map((e: CardElement) =>
({ ...e, box: { ...e.box } })),
assetIds: [...source.assetIds],
allowedOverlapPairs: [...source.allowedOverlapPairs]
};
}
这里用新对象复制元素数组和矩形,避免保存操作与编辑态共享可变引用。对象展开只是构建一个与业务数据等价的新值,并非文件系统事务。元素结构检查还应在恢复后结合几何校验完整执行;例如assetId为空并不影响矩形计算,却会使导出清单无法正确关联素材。对于历史结构需要显式迁移函数,不宜在JSON.parse后立即把所有未知字段当作当前类型使用。
持久化要保存完整修订并确认落盘
卡面规格与元素集合体量较小,适合用Preferences保存序列化后的一个版本快照。@kit.ArkData提供用户首选项能力,getPreferencesSync需要有效的应用上下文与配置选项,写入后使用flush完成持久化。快照以一个JSON字符串作为值,避免把同一版本的标题、图片和修订号拆成多个键,产生一部分字段是新值、另一部分还是旧值的混合状态。
存储服务在页面创建时初始化,页面退出前如有未保存修改,可向用户展示“草稿尚未保存”的状态;不能假设aboutToDisappear能够可靠完成所有异步写入。频繁拖动期间应合并保存请求,并以当前修订号判断写入顺序。若两个存储操作并发,完成顺序可能与用户编辑顺序相反,故应在本地串行队列里完成写入。Preferences面向应用内轻量数据,不应被当作具有多进程并发事务的数据库。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { CardSnapshot, normalizeSnapshot } from '../model/CardSnapshot';
export class SnapshotStore {
private store: preferences.Preferences;
constructor(context: common.UIAbilityContext) {
this.store = preferences.getPreferencesSync(context,
{ name: 'forest_card_snapshots' });
}
async read(): Promise<CardSnapshot | null> {
const value = await this.store.get('card_snapshot', '');
if (typeof value !== 'string' || value.length === 0) { return null; }
try {
return normalizeSnapshot(JSON.parse(value) as CardSnapshot);
} catch (_) {
throw new Error('草稿内容无法解析,请保留原数据核对');
}
}
async save(snapshot: CardSnapshot): Promise<void> {
const clean = normalizeSnapshot(snapshot);
const old = await this.read();
if (old && old.revision >= clean.revision) {
throw new Error('存储版本不允许回退');
}
await this.store.put('card_snapshot', JSON.stringify(clean));
await this.store.flush();
}
}
save按顺序调用的前提是调用方已经串行化保存请求;只依赖一次“读旧版、再写新版”的比较,不能解决不同进程同时写入的竞争。恢复草稿时应先检查结构版本,再进行矩形与资源校验,解析失败只提示当前数据不可用,不能自动用空模板覆盖原JSON。保存成功之后仍应记录该版本的revision,让导出页有可以比较的值。需要多个项目或复杂历史快照时,应将项目索引与历史版本管理迁移到更适合的持久化方案,而不是无限向一个Preferences键追加大量图像数据。
导出前复核覆盖四类约束
导出动作要接受已经保存的快照,而不是直接读取页面上的零散输入框。第一类检查是尺寸:宽高必须有效,69×94毫米画布对应63×88毫米裁切框和53×78毫米安全框。第二类是元素:坐标、宽高、文字与插画的交集及重要内容越界,继续调用第二篇的inspectLayout。第三类是资源:快照引用的素材ID要全部能在资源目录中解析到,不能因页面预览保留内存图像而忽略文件缺失。第四类是版本:正在编辑的修订号与已保存快照一致,而且检查期间没有产生新修改。
允许重叠的装饰关系必须从快照中读取,不应在导出页另建一套规则。还要区分“显示时的绿色提示”和“当前快照真正通过检查”:布局刷新不会改变风险数据,而标题移动、文本增长或素材替换会使旧检查结果失效。可让导出检查生成一份清单,附上修订号和每项规则结果;在生成最终资源之前重新比对当前修订号。只有检查通过的固定快照才能进入后续实际图像渲染或印刷文件制作流程。
import { CardSnapshot, CardElement } from '../model/CardSnapshot';
import { buildGuides } from '../model/CardGeometry';
import { inspectLayout } from './LayoutInspector';
export interface PreflightResult {
revision: number;
ready: boolean;
issues: string[];
}
export function checkExport(snapshot: CardSnapshot,
workingRevision: number, availableAssetIds: string[]): PreflightResult {
const issues: string[] = [];
if (snapshot.revision !== workingRevision) {
issues.push('编辑内容与保存快照版本不一致');
}
if (snapshot.trimWidthMm !== 63 || snapshot.trimHeightMm !== 88 ||
snapshot.bleedMm !== 3 || snapshot.safeInsetMm !== 5) {
issues.push('卡牌尺寸与当前印前规格不一致');
}
const guides = buildGuides({
trimWidthMm: snapshot.trimWidthMm,
trimHeightMm: snapshot.trimHeightMm,
bleedMm: snapshot.bleedMm,
safeInsetMm: snapshot.safeInsetMm
});
const warnings = inspectLayout(snapshot.elements,
guides.safe, snapshot.allowedOverlapPairs);
warnings.forEach((w) => issues.push(w.detail));
snapshot.elements.forEach((e: CardElement) => {
if (e.assetId.length > 0 && !availableAssetIds.includes(e.assetId)) {
issues.push(`${e.id}引用的素材不存在`);
}
});
return { revision: snapshot.revision,
ready: issues.length === 0, issues };
}
这里使用前两篇已经实现的几何函数,系统没有提供一个名为checkExport的通用ArkUI接口。availableAssetIds应当来自应用实际能够访问的资源索引,而不是界面里预先写死的测试列表;如果资源尚在异步加载,导出按钮应等待检查完成。检查通过也只代表本工程制定的版式规则满足要求,不自动推导出印刷设备的色彩、分辨率、字体嵌入或纸张工艺已经符合生产条件。实际导出格式需要另行实现渲染、编码、文件写入与失败恢复。

页面状态与失败回退
导出核对页上至少展示三种状态:正在读取、检查发现问题、检查通过。用户点开核对页时先读取快照,若内容为空,给出返回编辑器的入口;若结构版本不兼容,则要求迁移或恢复备份。确认导出前还要再次比较修订号。这样可以阻止用户在平板另一编辑区域修改标题之后,继续使用手机旧核对页面上已经过时的绿色提示。
import { CardSnapshot } from '../model/CardSnapshot';
import { checkExport, PreflightResult } from '../service/ExportPreflight';
@Entry
@Component
struct ExportCheckPage {
@State private revision: number = 13;
@State private issues: string[] = [];
@State private ready: boolean = false;
private snapshot: CardSnapshot | null = null;
private validate(currentRevision: number, assets: string[]): void {
if (!this.snapshot) {
this.ready = false;
this.issues = ['尚未读取到版式快照'];
return;
}
const result: PreflightResult = checkExport(
this.snapshot, currentRevision, assets);
this.ready = result.ready;
this.revision = result.revision;
this.issues = result.issues;
}
build() {
Column({ space: 12 }) {
Text('森林邮局 · 导出前复核').fontSize(22)
Text(`当前快照修订 v${this.revision}`)
Text(this.ready ? '版式检查通过,可生成清单' : '尚有项目需要处理')
ForEach(this.issues, (item: string) => {
Text(item).fontColor('#B34730')
}, (item: string) => item)
Button('生成导出清单').enabled(this.ready)
}.padding(16)
}
}
页面代码展示的快照装载与资源清单,需要由上层导航和服务在进入页面时注入,再调用validate刷新;这里没有把页面预览伪装成完整的文件输出实现。实际应用可在保存后立即重新读取快照,确认修订号和字段结构相符,再展示可导出状态。enabled与风险列表的来源是同一份校验结果,避免出现按钮可点但检查区仍报错的相互矛盾。
手机上可以把检查项竖向列出,平板上则把卡面与检查清单并排;这种变化继续沿用前一篇的容器断点,持久化内容不包含断点信息。展示“已保存快照v13、碰撞0处、越界0处、资源完整”时,还要给出“保存后再次编辑需重新核对”的提醒。清单标识出所依据的修订号,才能让后续操作知道它是否仍有效。
验收路径与工程收束
复核过程从一个可重复的编辑流程开始:先将标题放回安全框、清除图文交集,保存新修订;随后重新进入编辑器,确认69×94、63×88、53×78三组尺寸和元素ID仍一致;再次切换手机、平板布局,检查修订号没有因为UI重排而增加;最后移除一项素材引用,检查清单应明确报告缺失并阻断下一步。完成素材恢复后重新保存,再生成与最新修订号绑定的检查清单。以上是工程应覆盖的条件路径,不是对特定终端或打包结果的宣称。
当检查出现异常,先看schemaVersion与revision,再看磁盘保存操作是否真正完成,最后逐项检查坐标、规则和素材ID。绝不能因当前页面缓存了插画图像,就认为历史资源引用仍有效。如果校验期间收到新的编辑动作,应将清单标记为过期,而不是只刷新一个“已保存”的图标。多设备场景尤其应注意:在同一应用的不同窗口间同步草稿,需要独立的变更协调机制,Preferences本身不提供跨设备冲突合并语义。

到这里,“森林邮局”的四篇工程主线形成闭环:毫米坐标建立出血与安全边界,元素ID串联碰撞与越界检查,容器断点解决双端编辑区重排,最终以固定修订快照执行保存和导出前复核。下一步真正接入位图渲染或文件导出时,仍要从通过校验的同一快照读取数据,不能绕过这条版本与资源检查链路。
接口参考:华为ArkData用户首选项API、ContainerReader容器断点官方文档、Canvas绘图接口与实例。Preferences提供的是轻量持久化能力,Canvas负责绘制,版本检查、素材完整性与几何导出门禁均是应用定义的工程规则。
更多推荐

所有评论(0)