Harmony os 技术实战|拼豆制图06:收藏 ID、生成记录与重启恢复怎么不打架

收藏按钮点亮以后,真正麻烦的事情才刚开始。

拼豆制图里同时存在两种内容:50 张内置图纸由仓库提供,用户上传图片后又会生成新的 70×70 图纸。前者更新时应该自动获得最新内容,后者如果只保存一个 ID,应用重启后就再也找不到完整矩阵。再叠加"最近生成""我的作品""当前选中图纸"三个入口,很容易出现同一张图重复、取消收藏后仍显示、导出失败却提前加入作品等状态错乱。

这篇文章不把收藏当成一个布尔值,而是把它还原成一套本地内容系统:收藏保存关系,生成记录保存内容,页面只消费合并后的结果。

本文将解决以下问题:

  • 为什么内置图纸只保存收藏 ID,用户图纸却必须保存内容。
  • 如何构造最近使用队列,并保证同一图纸只出现一次。
  • 收藏页如何合并多个来源,同时维持稳定顺序。
  • 当前图纸如何按明确优先级解析,避免打开错误内容。
  • 如何从页面内存状态演进到 Preferences 与文件存储。

先复现三个最容易被忽略的故障

单看收藏按钮,下面三条路径都可能"看起来没问题":

  1. 收藏一张内置图纸,进入收藏页能看到它。
  2. 生成一张用户图纸,最近生成区域能打开它。
  3. 点击导出,页面提示已保存。

真正的故障往往出现在路径交叉以后:

操作序列 常见结果 根因
生成 A → 再生成 A → 收藏页 A 出现两次 最近记录与当前图纸重复合并
收藏内置 B → 仓库更新 B 收藏页仍显示旧 B 收藏保存了完整对象副本
生成 C → 重启 → 打开收藏 只剩 ID,详情为空 用户内容没有持久化
导出 D → 系统界面取消 D 仍进入作品列表 业务提交时机早于操作成功
取消收藏 E → 返回"我的" 数量没有变化 多个页面各维护一份计数

因此,问题不在 toggleFavorite() 写得够不够短,而在于数据所有权没有定义清楚。

收藏关系与图纸内容必须分开

内置图纸和用户图纸的生命周期不同。用同一种存储方式处理它们,要么复制大量数据,要么丢失用户内容。

typescript 复制代码
export type PatternSource = 'builtin' | 'generated';

export interface FavoriteRelation {
  patternId: string;
  createdAt: number;
}

export interface GeneratedPatternRecord {
  pattern: Pattern;
  source: PatternSource;
  createdAt: number;
  updatedAt: number;
}

export interface UserContentSnapshot {
  version: number;
  favorites: FavoriteRelation[];
  generatedRecords: GeneratedPatternRecord[];
}

这四个结构表达了两类事实:

  • FavoriteRelation 只是"用户喜欢哪个 ID",它不拥有图纸内容。
  • GeneratedPatternRecord 才拥有用户生成的完整 Pattern

内置图纸被收藏后,页面仍通过 PatternRepository.getPatternById() 获取最新版本;用户图纸则从生成记录中恢复。收藏关系和内容实体不再互相冒充。

稳定 ID 是所有合并逻辑的前提

如果用户每次导出同一张图都生成新 ID,任何去重算法都无能为力。ID 应在"生成完成"时确定,而不是在"查看、收藏或导出"时重新创建。

typescript 复制代码
private createGeneratedPatternId(sourceUri: string, createdAt: number): string {
  const normalized = sourceUri
    .toLowerCase()
    .replace(/[^a-z0-9]/g, '_')
    .substring(0, 32);
  return `user-generated-${createdAt}-${normalized}`;
}

生产项目还可以使用 UUID 或内容摘要。关键不是采用哪一种格式,而是满足三条约束:

  1. 同一个实体在收藏、详情、导出和持久化中使用同一个 ID。
  2. 内置 ID 与用户 ID 的命名空间不冲突。
  3. ID 不依赖可修改的标题、分类名或展示文案。

标题可以改,ID 不能跟着改。否则一次重命名就会让原收藏关系失效。

ArkUI 页面状态只负责驱动当前会话

现有页面使用三个状态字段表达收藏和生成内容:

typescript 复制代码
@State favoriteIds: string[] = [
  'anime-1',
  'idol-1',
  'designer-1'
];
@State generatedPattern: Pattern = PatternRepository.getPatterns()[0];
@State generatedRecords: Pattern[] = [];

private isFavorite(id: string): boolean {
  return this.favoriteIds.indexOf(id) >= 0;
}

这适合作为第一阶段实现:数组重新赋值后,依赖它们的卡片、数量和收藏页会重新计算。但它仍然只是会话状态,进程结束后不会自动恢复。

更稳的演进方式是:页面进入时从 UserContentService 读取快照,操作成功后由服务返回新快照,再一次性更新页面状态。不要让收藏页直接读 Preferences、详情页直接改数组、导出页又写文件。

切换收藏要返回新关系,而不是修改图纸

收藏切换只处理 ID,不应该顺便复制或删除 Pattern。用新数组替换旧数组,也让状态变化更明确。

typescript 复制代码
private toggleFavorite(id: string): void {
  if (this.isFavorite(id)) {
    const next: string[] = [];
    for (let i = 0; i < this.favoriteIds.length; i++) {
      if (this.favoriteIds[i] !== id) {
        next.push(this.favoriteIds[i]);
      }
    }
    this.favoriteIds = next;
    return;
  }

  this.favoriteIds = this.favoriteIds.concat([id]);
}

这个函数拥有的边界很小:输入一个稳定 ID,输出一组新的收藏 ID。它不决定图纸从哪里加载,也不决定收藏页怎么排序。

真正接入持久化后,建议把它改成异步服务调用:保存成功再替换页面数组;保存失败则保留旧状态并给出提示。这样不会出现图标已经点亮、重启后却消失的"假成功"。

最近生成应该是一条有上限的 MRU 队列

最近生成不是普通列表,而是 Most Recently Used 队列:最新记录在前,同 ID 只保留一份,总数有上限。

typescript 复制代码
private upsertGeneratedRecord(
  pattern: Pattern,
  current: Pattern[],
  limit: number = 10
): Pattern[] {
  const next: Pattern[] = [pattern];

  for (let i = 0; i < current.length && next.length < limit; i++) {
    if (current[i].id !== pattern.id) {
      next.push(current[i]);
    }
  }
  return next;
}

这里把"插到最前、删除旧副本、截断上限"放在一个纯函数里。它很容易测试,也不会因为页面多了一个入口就复制一套逻辑。

上限不只是界面需求。70×70 图纸包含 4900 个格子,十张完整矩阵已经明显比十个收藏 ID 更重。如果用户内容长期保存,应把图纸主体放到独立文件,最近记录只保存摘要和文件索引。

收藏页是多来源合并,不是简单过滤

收藏页需要按顺序读取三个来源:最近生成记录、当前刚生成但尚未进入记录的图纸、内置仓库。合并时以 ID 为唯一键。

typescript 复制代码
private favoritePatterns(): Pattern[] {
  const result: Pattern[] = [];

  for (let i = 0; i < this.generatedRecords.length; i++) {
    const item = this.generatedRecords[i];
    if (this.isFavorite(item.id) && !this.hasPatternInList(result, item.id)) {
      result.push(item);
    }
  }

  if (this.generatedPattern.id.startsWith('user-generated') &&
    this.isFavorite(this.generatedPattern.id) &&
    !this.hasPatternInList(result, this.generatedPattern.id)) {
    result.push(this.generatedPattern);
  }

  for (let i = 0; i < this.patterns.length; i++) {
    const item = this.patterns[i];
    if (this.isFavorite(item.id) && !this.hasPatternInList(result, item.id)) {
      result.push(item);
    }
  }
  return result;
}

顺序本身就是产品规则:用户刚生成的内容优先,内置收藏随后。hasPatternInList 不是为了掩盖 ID 混乱,而是防止当前图纸与最近记录在过渡阶段重复出现。

数据量继续增长时,可以临时建立 Set<string> 降低查重成本;当前几十条数据里,清楚的合并规则比微小的复杂度优化更重要。

当前图纸解析要有明确优先级

编号页只持有 selectedPatternId,真正绘制时要把 ID 解析成完整 Pattern。如果优先级不稳定,同一个 ID 可能错误命中旧缓存。

typescript 复制代码
private getSelectedPattern(): Pattern {
  if (this.selectedPatternId === this.generatedPattern.id) {
    return this.generatedPattern;
  }

  for (let i = 0; i < this.generatedRecords.length; i++) {
    if (this.generatedRecords[i].id === this.selectedPatternId) {
      return this.generatedRecords[i];
    }
  }

  const builtin = PatternRepository.getPatternById(this.selectedPatternId);
  if (builtin !== null) {
    return builtin;
  }

  return this.patterns[0];
}

推荐优先级是"当前生成 → 已保存用户记录 → 内置仓库 → 明确兜底"。缓存可以加在仓库查询之后,但缓存键必须包含 ID,不能只保存一个裸对象。

兜底到第一张图纸虽然能避免空引用,却可能掩盖数据损坏。更严格的版本应返回 Pattern | null,由页面显示"图纸已不存在",而不是悄悄打开另一张内容。

提交时机要区分"生成成功"和"导出成功"

现有流程在调用系统相册前执行 savePatternRecord(pattern)。这意味着用户取消保存时,图纸仍会进入最近生成和收藏。

这不一定是错误,关键看产品语义:

业务定义 记录提交时机 用户取消导出后的结果
"生成即作品" 图片转换成功后 仍保留作品,符合预期
"保存后才算作品" 相册创建成功后 不进入作品
"导出只是附加动作" 与作品保存完全分离 取消导出不影响作品

更清晰的代码应把两件事拆开:

typescript 复制代码
private async generatePattern(): Promise<void> {
  const result = await this.imageConvertService.convert(this.pickedImageUri);
  const snapshot = await this.userContentService.saveGenerated(result.pattern);
  this.applySnapshot(snapshot);
  this.selectedPatternId = result.pattern.id;
  this.activeTab = 'numbered';
}

private async exportPattern(pattern: Pattern): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext;
  await PatternExportService.savePatternToGallery(context, pattern);
}

生成负责创建和保存作品,导出只负责把已有作品交给系统相册。以后增加分享、打印或云同步时,边界依然清楚。

轻量索引与大矩阵分开持久化

收藏 ID、时间戳和最近记录摘要适合放 Preferences;4900 个格子的完整矩阵更适合独立 JSON 文件。不要让一次收藏开关都重写所有图纸内容。

typescript 复制代码
export interface GeneratedPatternIndex {
  id: string;
  title: string;
  width: number;
  height: number;
  colorCount: number;
  beadCount: number;
  fileName: string;
  updatedAt: number;
}

export interface UserContentIndex {
  version: number;
  favoriteIds: string[];
  generated: GeneratedPatternIndex[];
}

推荐写入顺序如下:

  1. 校验 Pattern 和稳定 ID。
  2. 把完整图纸写入临时文件。
  3. 临时文件成功后替换正式文件。
  4. 更新轻量索引并 flush
  5. 返回最新快照给页面。

如果第 2 步失败,旧索引仍然可用;如果索引写入失败,新文件可以在下次启动时作为孤儿文件清理。这个顺序比先写索引、后写内容更不容易留下"索引存在但文件缺失"的坏记录。

页面数量必须由同一份结果派生

收藏页、个人中心和底部徽标都需要显示收藏数量。不要在每个入口中手动 +1-1,而应从最新快照重新派生:

typescript 复制代码
private applySnapshot(snapshot: UserContentSnapshot): void {
  this.favoriteIds = snapshot.favorites.map(
    (item: FavoriteRelation) => item.patternId
  );
  this.generatedRecords = snapshot.generatedRecords.map(
    (item: GeneratedPatternRecord) => item.pattern
  );
}

private favoriteCount(): number {
  return this.favoritePatterns().length;
}

同一屏的多个卡片必须消费同一次刷新结果。若一个区域读 favoriteIds,另一个区域读旧的缓存计数,就会出现"图标已取消、数字还没变"的一帧延迟或永久不一致。

验证要覆盖返回路径和冷启动

仅在当前页面点几次爱心不够。建议准备一张内置图和两张用户生成图,完成下面这组回归:

powershell 复制代码
hvigor assembleHap --no-daemon
  1. 收藏内置图,图库卡片、收藏页和个人中心数量同时变化。
  2. 取消收藏后,图纸仍存在于图库,只从收藏结果移除。
  3. 连续保存同一用户图纸两次,最近记录只保留一条且移动到顶部。
  4. 连续生成 11 张不同图纸,队列只保留最新 10 张。
  5. 从最近生成进入编号页,确认打开的 ID 与标题一致。
  6. 从编号页返回收藏页,数量与卡片状态仍一致。
  7. 结束进程并重新启动,内置收藏和用户图纸均能恢复。
  8. 删除一个图纸文件,启动时应显示损坏状态或移除索引,不能打开其他图纸冒充。
  9. 模拟索引写入失败,页面不应提前展示"已收藏"。

常见问题按所有权排查

现象 优先检查 根因 修复方向
收藏页重复图纸 合并顺序和 ID 当前图纸与记录重复 ID 去重并固定优先级
内置图更新后收藏仍旧 收藏存储结构 保存了完整副本 内置内容只保存关系
用户图重启后消失 内容持久化 只保存了 ID 图纸文件与索引分开保存
取消收藏但数量不变 派生来源 页面维护独立计数 从同一快照重新计算
导出取消仍出现作品 提交时机 生成和导出语义混合 拆开两个事务
打开 A 却显示 B 解析兜底 缺失 ID 被静默替换 返回空状态并记录损坏
操作偶尔回滚 异步写入 UI 先成功、存储后失败 保存成功后应用快照
存储越来越慢 写入粒度 每次重写全部矩阵 轻量索引与独立文件分层

排查时先问"这份数据由谁拥有",再看 UI。收藏关系、生成内容、当前选择和派生数量如果各有唯一来源,大多数同步问题会自然消失。

小结

收藏系统真正需要管理的是关系与内容的生命周期。内置图纸通过稳定 ID 建立收藏关系,用户图纸由生成记录保存完整内容;收藏页按照明确顺序合并,当前图纸按照明确优先级解析。再把轻量索引与大矩阵文件分开持久化,应用才能在返回页面、导出取消和冷启动以后仍然保持一致

相关推荐
程序员黑豆1 小时前
鸿蒙应用开发之生命周期方法完全指南
前端·harmonyos
2601_955759621 小时前
ClaudeAPI成本中心与业务标签设计指南
android·java·数据库
仙人球部落 揞殺1 小时前
Sql Server查询性能优化之走出索引的误区
数据库·性能优化
Bryce学亮1 小时前
FileLine,基于 Qt 6 + QML 构建的跨平台文件传输与即时通讯工具
数据库·c++·人工智能·python·qt·github
AFinalStone1 小时前
Android 7系统异常问题排查(五)Framework层(下)—System Server崩溃
android·tombstone·系统异常
AFinalStone1 小时前
Android 7系统异常问题排查(八)系统追踪—Trace机制与性能诊断
android·系统异常
轻揉小乔 真新人2 小时前
T-SQL查询进阶—理解SQL Server中的锁
服务器·数据库·sql
雾喔2 小时前
算法练习7
java·数据结构·算法
全麦面包 time展天2 小时前
走向DBA[MSSQL篇] 从SQL语句的角度 提高数据库的访问性能
数据库·sqlserver·dba