收藏按钮点亮以后,真正麻烦的事情才刚开始。
拼豆制图里同时存在两种内容:50 张内置图纸由仓库提供,用户上传图片后又会生成新的 70×70 图纸。前者更新时应该自动获得最新内容,后者如果只保存一个 ID,应用重启后就再也找不到完整矩阵。再叠加"最近生成""我的作品""当前选中图纸"三个入口,很容易出现同一张图重复、取消收藏后仍显示、导出失败却提前加入作品等状态错乱。
这篇文章不把收藏当成一个布尔值,而是把它还原成一套本地内容系统:收藏保存关系,生成记录保存内容,页面只消费合并后的结果。

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


先复现三个最容易被忽略的故障
单看收藏按钮,下面三条路径都可能"看起来没问题":
- 收藏一张内置图纸,进入收藏页能看到它。
- 生成一张用户图纸,最近生成区域能打开它。
- 点击导出,页面提示已保存。
真正的故障往往出现在路径交叉以后:
| 操作序列 | 常见结果 | 根因 |
|---|---|---|
| 生成 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 或内容摘要。关键不是采用哪一种格式,而是满足三条约束:
- 同一个实体在收藏、详情、导出和持久化中使用同一个 ID。
- 内置 ID 与用户 ID 的命名空间不冲突。
- 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[];
}
推荐写入顺序如下:
- 校验
Pattern和稳定 ID。 - 把完整图纸写入临时文件。
- 临时文件成功后替换正式文件。
- 更新轻量索引并
flush。 - 返回最新快照给页面。
如果第 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
- 收藏内置图,图库卡片、收藏页和个人中心数量同时变化。
- 取消收藏后,图纸仍存在于图库,只从收藏结果移除。
- 连续保存同一用户图纸两次,最近记录只保留一条且移动到顶部。
- 连续生成 11 张不同图纸,队列只保留最新 10 张。
- 从最近生成进入编号页,确认打开的 ID 与标题一致。
- 从编号页返回收藏页,数量与卡片状态仍一致。
- 结束进程并重新启动,内置收藏和用户图纸均能恢复。
- 删除一个图纸文件,启动时应显示损坏状态或移除索引,不能打开其他图纸冒充。
- 模拟索引写入失败,页面不应提前展示"已收藏"。
常见问题按所有权排查
| 现象 | 优先检查 | 根因 | 修复方向 |
|---|---|---|---|
| 收藏页重复图纸 | 合并顺序和 ID | 当前图纸与记录重复 | ID 去重并固定优先级 |
| 内置图更新后收藏仍旧 | 收藏存储结构 | 保存了完整副本 | 内置内容只保存关系 |
| 用户图重启后消失 | 内容持久化 | 只保存了 ID | 图纸文件与索引分开保存 |
| 取消收藏但数量不变 | 派生来源 | 页面维护独立计数 | 从同一快照重新计算 |
| 导出取消仍出现作品 | 提交时机 | 生成和导出语义混合 | 拆开两个事务 |
| 打开 A 却显示 B | 解析兜底 | 缺失 ID 被静默替换 | 返回空状态并记录损坏 |
| 操作偶尔回滚 | 异步写入 | UI 先成功、存储后失败 | 保存成功后应用快照 |
| 存储越来越慢 | 写入粒度 | 每次重写全部矩阵 | 轻量索引与独立文件分层 |
排查时先问"这份数据由谁拥有",再看 UI。收藏关系、生成内容、当前选择和派生数量如果各有唯一来源,大多数同步问题会自然消失。
小结
收藏系统真正需要管理的是关系与内容的生命周期。内置图纸通过稳定 ID 建立收藏关系,用户图纸由生成记录保存完整内容;收藏页按照明确顺序合并,当前图纸按照明确优先级解析。再把轻量索引与大矩阵文件分开持久化,应用才能在返回页面、导出取消和冷启动以后仍然保持一致