
最近浏览看起来是小功能,但它很容易拖慢页面。中式美食详情页每打开一道菜都会记录浏览,如果不去重、不限制数量,Preferences 里会越塞越多,首页最近浏览区也会变得不可信。我的处理原则很直接:页面先把用户看到的问题挡住,数据层再把真正的边界兜住,最后用可重复的操作去验收。这样写出来的逻辑不一定最炫,但上线后更稳。
这个问题的原因通常不是某一行代码写错,而是页面状态、业务口径和数据回读没有分开。只要原因判断错了,后面修起来就会变成哪里闪修哪里,最后越修越乱。
| 应用 | 中式美食 |
| 页面 | 最近浏览 |
| 技术栈 | HarmonyOS、ArkUI、ArkTS、本地数据 |
| 主要问题 | 状态口径不清楚,页面显示和本地数据对不上 |
| 处理目标 | 用户重复操作、返回页面、刷新列表时结果保持一致 |
| DevEco Studio | 6.1.0 Release |
| HarmonyOS SDK | API 18 |
| 存储方式 | Preferences |
| 页面入口 | 菜谱详情页、首页最近浏览区 |
| 验收重点 | 去重、截断、更新时间、重新进入后顺序一致 |
为什么这个问题会出现
很多页面刚开始能跑,是因为测试路径太顺了:点一次、等结果、再看页面。但真实使用里,用户不会按我们的节奏来。用户会连续点,会返回再进来,会改条件后又取消,也会在网络慢的时候以为自己没点上。
| 页面变量直接改 | 代码短,马上能看到效果 | Repository 失败后页面不知道怎么回滚 |
| 不记录动作来源 | 所有入口走同一段逻辑 | 返回列表时不知道该刷新哪一块 |
| 不做兜底校验 | 单次测试能通过 | 重复点击或旧数据会把状态弄乱 |
| 只看 UI 不看数据 | 页面像是对了 | 下次进入又变成旧状态 |
我现在写这类功能,会先问自己一个问题:这个状态到底是谁负责?页面负责展示,Repository 负责数据口径,路由负责入口来源。只要这三件事混在一起,后面必然难排查。
页面层先把状态说清楚
在 最近浏览 里,我不会让一个布尔值承担所有含义。页面里至少要区分“正在处理”“当前展示结果”“已经确认的数据”。这样出问题时能知道到底是哪一步错了。
type PageStatus = 'idle' | 'loading' | 'saving' | 'error';
@State private pageStatus: PageStatus = 'idle';
@State private currentItems: RecipeItem[] = [];
@State private errorMessage: string = '';
private canTriggerAction(): boolean {
return this.pageStatus !== 'loading' && this.pageStatus !== 'saving';
}
这段代码不是为了显得复杂,而是为了避免页面状态互相抢。比如正在保存时,不应该再触发同一个写动作;加载失败时,也不能直接展示空列表,否则用户会以为真的没有数据。
最近浏览不要只会 push
最近浏览最常见的错误,就是每进一次详情页就往数组里 push 一条。这样短期看能用,时间一长就会出现同一道菜重复很多次,首页最近浏览区也会被旧数据撑大。
interface ViewedRecipe {
recipeId: string;
recipeName: string;
coverResource: string;
viewedAt: number;
}
const RECENT_VIEW_LIMIT = 30;
const RECENT_VIEW_KEY = 'recent_viewed_recipes';
我会先把数据结构定清楚:最近浏览不是完整菜谱表,只保存首页入口需要展示和跳转的字段。这样 Preferences 里的内容轻一点,读取也快一点。
写入时先去重,再放到最前面
中式美食详情页每次打开菜谱时,都会更新最近浏览。但更新不是追加,而是“旧记录移除,新记录放最前面,超过上限就截断”。
async recordViewedRecipe(recipe: RecipeItem): Promise<void> {
const list = await this.readViewedRecipes();
const nextItem: ViewedRecipe = {
recipeId: recipe.id,
recipeName: recipe.name,
coverResource: recipe.coverResource,
viewedAt: Date.now()
};
const filtered = list.filter(item => item.recipeId !== recipe.id);
const nextList = [nextItem, …filtered].slice(0, RECENT_VIEW_LIMIT);
await this.preferences.put(RECENT_VIEW_KEY, JSON.stringify(nextList));
await this.preferences.flush();
}
这段代码解决的是三个问题:同一道菜不会重复出现,最新浏览一定排在最前面,Preferences 不会无限膨胀。这个口径比单纯 push 稳很多。
| 读取旧列表 | 拿到已有浏览记录 | 保留其他菜谱顺序 |
| filter 去重 | 移除同一个 recipeId | 防止重复卡片 |
| 放到头部 | 更新最新浏览时间 | 新看的内容靠前 |
| slice 截断 | 限制最多 30 条 | 防止 Preferences 越存越大 |
读取时要容错旧数据
Preferences 里存的是字符串,读取时不能假设它永远是正确 JSON。应用升级、字段变更、手动清缓存,都可能让读取失败。
async readViewedRecipes(): Promise<ViewedRecipe[]> {
const raw = await this.preferences.get(RECENT_VIEW_KEY, '[]') as string;
try {
const list = JSON.parse(raw) as ViewedRecipe[];
return list
.filter(item => item.recipeId && item.recipeName)
.sort((left, right) => Number(right.viewedAt || 0) – Number(left.viewedAt || 0))
.slice(0, RECENT_VIEW_LIMIT);
} catch (error) {
await this.preferences.put(RECENT_VIEW_KEY, '[]');
await this.preferences.flush();
return [];
}
}
这里的 catch 不是为了吞错误,而是为了让最近浏览不要拖垮首页。最近浏览是辅助入口,它坏了应该清空重来,不能影响菜谱列表和详情页主流程。
业务 key 要按真实含义来定
最近浏览越存越多怎么办? 不能靠一个全局变量糊过去。中式美食里我会给关键动作一个业务 key,这个 key 必须能看懂它锁住的是哪件事。
private buildActionKey(recipeId: string): string {
return `viewedAt:${recipeId}`;
}
private async runWithActionLock(recipeId: string, task: () => Promise<void>): Promise<void> {
const key = this.buildActionKey(recipeId);
if (this.runningKeys.has(key)) {
return;
}
this.runningKeys.add(key);
try {
await task();
} finally {
this.runningKeys.delete(key);
}
}
这里的重点是 finally。只要是异步动作,就一定要考虑失败和异常。否则按钮锁住以后不释放,用户只能退出页面重进。
Repository 层再兜一次
页面层能挡住大部分误操作,但它不是最后防线。以后功能多了,同一段 Repository 可能被详情页、列表页、服务卡片、导入逻辑一起调用。如果 Repository 自己不判断,别的入口一样能写出脏数据。
export class ViewedRecipeRepository {
async save(recipeId: string, payload: Record<string, string>): Promise<void> {
const oldRecord = await this.findByRecipeId(recipeId);
if (oldRecord) {
await this.update(oldRecord.id, {
…payload,
updatedAt: Date.now()
});
return;
}
await this.insert({
recipeId,
…payload,
createdAt: Date.now(),
updatedAt: Date.now()
});
}
}
这段逻辑表达的是一个简单口径:同一道菜相关的状态,先看有没有旧记录。有就更新,没有再插入。不要每次都新增,否则数据会越来越脏。
| ArkUI 页面 | 按钮、弹窗、列表展示 | 让用户知道当前动作有没有进行中 |
| ViewModel | 组织页面状态和动作 key | 避免一个变量承担太多含义 |
| Repository | 查重、更新、插入 | 防止其他入口绕过页面写脏数据 |
| 验收用例 | 重复点、返回、刷新、异常 | 确认不是单次路径刚好成功 |
返回页面后要读真实数据
很多状态问题不是发生在当前页面,而是发生在返回后。比如详情页里保存成功了,列表页如果还拿旧数组显示,用户看到的就是错的。
async onPageShow(): Promise<void> {
await this.reloadVisibleState();
}
private async reloadVisibleState(): Promise<void> {
const latest = await this.repository.queryCurrentState();
this.currentItems = this.mergeState(this.currentItems, latest);
}
我不建议让详情页直接去改列表页内部数组。短期看省事,长期看会让入口越来越乱。更稳的做法是:写操作完成后,相关页面自己回读一次需要展示的状态。
为什么这样能解决问题
这个方案能解决问题,不是因为代码多写了几行,而是因为每一层都有自己的责任。页面层解决的是“用户现在能不能继续点”的问题,Repository 解决的是“这条业务数据到底应该新增还是更新”的问题,页面返回后重新读取解决的是“别的页面看到的是不是最新结果”的问题。
如果只做页面层,重复点击少了,但别的入口仍然可能写脏数据。如果只做 Repository,数据是干净了,但用户点按钮时没有反馈,会以为应用卡住。如果只做返回刷新,页面看起来会恢复,但中间写错的数据已经进库了。三层合起来,才是一个比较稳的处理方式。
| 只禁用按钮 | 页面重建或其他入口可能绕过 | Repository 再查重和更新 |
| 只查重入库 | 用户连续点时没有反馈 | ArkUI 层给 busy 状态 |
| 只返回刷新 | 数据写错后只是重新显示错数据 | 写入前先定业务口径 |
| 只靠人工测试 | 慢请求和旧数据覆盖测不出来 | 固定重复点击和返回刷新用例 |
以后怎么避免同类问题
后面再写中式美食的页面,我会先把动作分成两类:只影响展示的动作,和会改本地数据的动作。只影响展示的动作可以轻一点,比如切换 tab、展开说明;会改本地数据的动作就必须按上面的方式处理,不能只写一个 onClick。
我的检查清单也会固定下来:
| 是否有业务 actionKey | 连续操作时不知道该拦哪一个动作 |
| 是否有 finally 释放状态 | 异常后按钮可能一直不可用 |
| Repository 是否查重 | 别的入口会写出重复数据 |
| 返回页面是否回读 | 列表状态和详情页状态可能不一致 |
| 失败提示是否具体 | 用户不知道是保存失败还是页面失败 |
这样做的好处是,问题以后再出现时,我不用从整页代码里乱找。先看 actionKey,再看 Repository 口径,再看返回刷新,基本就能定位到是哪一层没有守住。
还有一个容易忽略的小点:不要把这类状态只写在注释里。真正有用的是把口径落到变量名、方法名和测试动作里。比如 reloadVisibleState() 比 refresh() 更清楚,buildActionKey() 比 getKey() 更清楚,save() 里面先 findByRecipeId() 再 insert() 也比直接写一串数据库调用更容易看懂。
代码写清楚以后,后面自己回来排查也快。看到列表乱了,就先查 query 的排序;看到按钮乱了,就先查 actionKey;看到数据重复了,就先查 Repository 的查重逻辑。这个顺序固定下来,排查效率会高很多。
private async verifyAfterAction(recipeId: string): Promise<void> {
const pageState = this.currentItems.find(item => item.id === recipeId);
const dbState = await this.repository.findByRecipeId(recipeId);
if (!pageState || !dbState) {
this.errorMessage = '页面状态和本地数据没有对上,需要重新加载';
await this.reloadVisibleState();
}
}
这段验收代码不一定要原样放到生产逻辑里,但它提醒我:每个会改数据的动作,都要能回答“页面看到的”和“本地查到的”是不是同一件事。
我会怎么验证
只看一次正常点击没有意义,必须按容易出错的路径测。
| 快速连续触发同一个动作 | 只有一次有效写入 |
| 写入过程中返回再进入 | 页面从 Repository 读到真实状态 |
| 取消操作或失败重试 | 临时状态不会污染正式结果 |
| 旧数据缺少字段 | 兜底排序或默认值不让页面崩 |
| 刷新三次列表 | 展示顺序和状态保持一致 |
最后说一句
最近浏览越存越多怎么办? 表面是页面问题,实际是状态边界问题。中式美食这类内容应用,页面入口多、状态也多,如果只靠一次点击后的临时变量,后面一定会遇到列表刷新不对、按钮状态不对、数据重复的问题。
我的做法就是把事情拆开:页面负责当前交互,ViewModel 负责动作节流和状态整理,Repository 负责真实数据口径。这样以后功能继续加,问题也能沿着这条线查,不会变成到处猜。




