HarmonyOS 7 新特性实战(22):让搜索、详情与收藏状态可定位、可回读
一个可以实际操作的阅读页面
UI 层的最小任务包括搜索旋龟、打开详情、收藏,再回到搜索结果。页面使用本地条目和 Preferences,不依赖系统智能体执行,因此可以单独验证控件定位与结果回读。系统智能入口需要另一条调用与回读记录。

搜索输入有明确的提示语和可访问性文本,控件标识固定为 readingSearch。条目按钮标识由稳定业务 ID 构造,旋龟对应 readingEntry-xuangui。实测通过 UI 树确认控件边界后按坐标操作,尚未建立按 ID 自动寻址的回归脚本;这些固定标识为后续选择器测试提供入口。
TextInput({ text: this.query, placeholder: '搜索神兽名称或正文' })
.id('readingSearch')
.accessibilityText('搜索神兽名称或正文')
.enabled(this.ready)
.onChange((value: string) => {
this.query = value;
this.entries = this.repository.search(value);
})
搜索当前是本地字面包含匹配,不是语义检索。查询会 trim 首尾空白,但不自动纠正错别字。结果数量和空结果提示都能被 UI 树读取;搜索旋龟得到 1 条,加入不匹配词后得到 0 条。
收藏动作使用明确的目标状态
按钮文案从“收藏此条目”变成“已收藏”,并在成功后禁用,避免自动化重试将收藏反向取消。服务层也检查条目是否已收藏,重复请求直接返回。数据提交和 flush 完成后才更新内存状态与页面反馈。
if (this.favorites.includes(id)) { return; }
const next = this.favorites.concat(id);
await this.store.put('favorites', JSON.stringify(next));
await this.store.flush();
this.favorites = next;

这段幂等处理适用于本 Demo 单页串行操作,页面 busy 状态阻止并发点击。若以后多个页面或服务同时写入收藏,需要把串行化移入共享 Repository,不应把这份页面级保护当作跨进程事务。写入错误会展示具体原因,替身测试检查了 flush 失败时内存不报告成功。
返回和重启是两种不同验证
返回搜索结果应保留本页查询条件;重启应用后则从本地收藏存储恢复,而不是依赖旧页面仍留在内存。查询词没有持久化,所以重启回到初始列表,收藏状态单独回读。验证记录分别保存这两个行为,避免把页面状态恢复与持久化混为一谈。
扩展系统智能操控前的语义要求
强制停止应用再启动,打开旋龟详情,页面回读“已收藏:从本地记录恢复”,收藏按钮保持禁用。这次操作验证了进程重启后的持久化路径。

“搜索玄龟,打开详情,然后收藏。”人看到页面会寻找搜索框和收藏按钮,智能操控入口也需要稳定地识别这些操作。若页面只有图标、列表行缺少实体信息、按钮文字与状态不一致,就很难判断某次动作究竟有没有完成。
GUI 操控的应用侧准备可以从可识别、可执行、可回读三个方面展开。系统级入口的开放资格与能力范围必须另行核实;无障碍可用、UI 测试能点击,都不能自动证明应用已经获得全域操控能力。
给操作对象稳定的语义
搜索框应有清晰用途,结果行包含名称与必要的区分信息,收藏按钮显示当前动作。只有星形图标而没有语义说明时,用户与自动化工具都容易误解它表示“已经收藏”还是“点击收藏”。
实体 ID 与控件可读标签分别管理。标签可以本地化,实体标识保持稳定;不要依赖列表序号识别条目,因为排序和筛选会改变位置。对同名条目增加分类或出处信息,比给工具暴露内部数据库路径更有用。
动作完成后必须能观察到新状态
收藏成功后,按钮、提示和持久化记录应一致。只有一个瞬时提示而按钮仍旧显示“收藏”,会让后续操控重复执行。网络或存储失败时恢复可执行状态,并显示具体原因。
以下是应用用来描述操作步骤的合同,不是系统 GUI Agent 指令格式:
interface UiActionExpectation {
actionId: string;
entityId: string;
beforeState: string;
desiredState: string;
timeoutMs: number;
}
const favouriteExpectation: UiActionExpectation = {
actionId: 'setFavourite', entityId: 'xuangui',
beforeState: 'not_favourited', desiredState: 'favourited', timeoutMs: 3000
};
操作完成判定应读取业务状态或稳定 UI 状态,不只依赖触摸事件发送成功。超时后先检查是否已经完成,再决定重试,避免重复写入。
页面跳转需要保留执行上下文
从结果列表进入详情后,目标 ID 应与选中条目一致。应用恢复、横竖屏变化或系统弹窗可能打断流程,因此每一步都需要重新定位当前页面,而不是沿用旧坐标。
详情未加载完成时禁用依赖数据的动作,并提供可识别的加载状态。不要把按钮暂时放在屏幕外仍标成可点击;隐藏和禁用应该有一致语义。
把可重试操作和敏感操作分开
搜索和打开详情通常可安全重试。删除内容、分享私人资料或提交交易则需要额外的用户授权与结果检查,不能因为 GUI 能点击就默认用户同意所有后续动作。
应用可以把“设置为已收藏”做成幂等业务命令,而不是“切换收藏”。这样同一个意图被重发时不会把收藏取消。对不可逆操作,保留明确确认界面和操作对象信息。
弹窗是正常状态,不是异常角落
权限说明、资源不可用提示和登录过期都会改变可操作页面。应为这些分支提供清晰出口,不让关闭按钮被遮挡,也不在失败后保留无限转圈的页面。
测试脚本要覆盖弹窗出现时的停止与恢复,特别是应用正在执行收藏或下载时。工具无法理解某个页面时,应向用户交回控制,不通过坐标猜测继续点下去。
验收分为页面准备与系统接入两层
先用可重复 UI 测试完成搜索、进入和收藏,记录目标控件、实体 ID 与最后状态。这验证应用的可操作性。再使用实际获准的智能入口执行同一任务,保存入口请求、动作轨迹与结果回读,验证系统协作。
多语言、大字体、空结果和重复实体都应进入测试集。控件坐标变化后仍能准确完成任务,才说明语义定位起作用;一次固定分辨率的坐标点击录像不能代替这项检查。
重试前先做一次状态判定
| 回读结果 | 是否重试收藏 | 理由 |
|---|---|---|
| 目标 ID 正确且已收藏 | 否 | 原操作已经达成 |
| 目标 ID 正确、未收藏且无在途操作 | 可重试显式设置 | 仍需完成目标状态 |
| 当前页面目标不一致 | 否 | 先恢复定位 |
| 状态未知或仍在提交 | 暂不重试 | 等待或交回控制 |
基于固定坐标的路径适合短期布局回归,语义定位更能适应内容排序与尺寸变化;二者的验收目标应分别记录。先保证实体与控件语义,再验证状态回读,最后接系统智能入口。3000 毫秒只是示例预算,不能成为超时后不检查就再次点击的理由。
参考:HarmonyOS 开发工具与 UI 调测、HarmonyOS 7 能力入口。
节点动作与收藏回读的对应关系
搜索节点同时提供稳定 ID 和可读语义;收藏在写入完成后回读为已收藏,重复点击由 busy 与已有状态共同阻止。异步返回还核对选中的条目,避免把上一条目的结果显示到下一条目。
import { common } from '@kit.AbilityKit';
import { ReadingEntry, ReadingRepository } from '../services/ReadingRepository';
import { LabTheme } from '../common/LabTheme';
interface ReadingRouteParams { entryId?: string; }
@Entry
@Component
struct ReadingLabPage {
@State query: string = '';
@State entries: ReadingEntry[] = [];
@State selected: ReadingEntry | undefined = undefined;
@State favoriteSaved: boolean = false;
@State ready: boolean = false;
@State busy: boolean = false;
@State feedback: string = '正在读取本地收藏';
@State largeText: boolean = false;
private repository: ReadingRepository = new ReadingRepository();
private active: boolean = true;
aboutToAppear(): void { this.active = true; this.open(); }
aboutToDisappear(): void { this.active = false; }
onBackPress(): boolean {
if (this.selected) { this.returnToResults(); return true; }
return false;
}
private async open(): Promise<void> {
try {
await this.repository.open(this.getUIContext().getHostContext() as common.UIAbilityContext);
if (!this.active) { return; }
this.entries = this.repository.search(this.query);
this.ready = true;
this.feedback = '可搜索名称或正文';
const params = this.getUIContext().getRouter().getParams() as ReadingRouteParams | undefined;
if (params?.entryId) {
const entry = this.entries.find((item: ReadingEntry) => item.id === params.entryId);
if (entry) { this.select(entry); } else { this.feedback = '关联条目不存在,可从列表选择'; }
}
} catch (error) { if (this.active) { this.feedback = `存储不可用:${(error as Error).message}`; } }
}
private select(entry: ReadingEntry): void {
this.selected = entry;
this.favoriteSaved = this.repository.isFavorite(entry.id);
this.feedback = this.favoriteSaved ? '已收藏:从本地记录恢复' : '尚未收藏';
}
private returnToResults(): void {
this.selected = undefined;
this.favoriteSaved = false;
if (this.ready) { this.feedback = '可搜索名称或正文'; }
}
private async save(): Promise<void> {
if (!this.selected || this.busy || this.favoriteSaved) { return; }
const id = this.selected.id;
this.busy = true;
try {
await this.repository.favorite(id);
if (this.active && this.selected?.id === id) { this.favoriteSaved = true; this.feedback = '收藏已保存到本机'; }
} catch (error) {
if (this.active && this.selected?.id === id) { this.feedback = `收藏失败:${(error as Error).message}`; }
}
finally { if (this.active) { this.busy = false; } }
}
build() {
Column({ space: 14 }) {
Text('15 / 23 · 阅读任务实验').fontSize(24).fontWeight(FontWeight.Bold)
Button(this.largeText ? '恢复正文字号' : '放大正文字号').id('readingFont')
.onClick(() => { this.largeText = !this.largeText; })
if (!this.selected) {
TextInput({ text: this.query, placeholder: '搜索神兽名称或正文' }).id('readingSearch')
.accessibilityText('搜索神兽名称或正文').enabled(this.ready)
.onChange((value: string) => {
this.query = value;
this.entries = this.repository.search(value);
if (this.ready) { this.feedback = '可搜索名称或正文'; }
})
Text(`找到 ${this.entries.length} 条`).fontSize(16).id('readingCount')
List({ space: 12 }) {
ForEach(this.entries, (entry: ReadingEntry) => {
ListItem() {
Button(entry.title).id(`readingEntry-${entry.id}`).width('100%').height(88)
.fontSize(this.largeText ? 24 : 18).onClick(() => { this.select(entry); })
}
}, (entry: ReadingEntry) => entry.id)
}.layoutWeight(1).width('100%')
if (this.entries.length === 0 && this.ready) { Text('没有匹配条目,请修改关键词').id('readingEmpty') }
} else {
Text(this.selected.title).fontSize(this.largeText ? 28 : 22).id('readingTitle')
Text(this.selected.source).fontSize(16)
Scroll() { Text(this.selected.body).fontSize(this.largeText ? 26 : 19).width('100%') }
.layoutWeight(1).width('100%')
Button(this.favoriteSaved ? '已收藏' : '收藏此条目').id('readingFavorite')
.enabled(!this.busy && !this.favoriteSaved).onClick(() => { this.save(); })
Button('返回搜索结果').id('readingBack').onClick(() => { this.returnToResults(); })
}
Text(this.feedback).fontSize(15).id('readingFeedback')
Button('返回目录').onClick(() => { this.getUIContext().getRouter().back(); })
}.padding(20).width('100%').height('100%').backgroundColor(LabTheme.background)
}
}
| 操作或边界 | 应检查的结果 |
|---|---|
| 搜索为空结果 | 显示零条与空态文案 |
| 收藏写入失败 | 保留未收藏状态并提示失败 |
| 切换条目后写入结束 | 不能改写另一条目的收藏状态 |
返回列表时,把提示切回当前任务
收藏提示属于当前条目,搜索提示属于列表。按钮返回和系统返回统一调用 returnToResults:清空选中条目和收藏展示状态,恢复“可搜索名称或正文”,Preferences 中的收藏记录继续保留。关键词变化也恢复搜索提示。收藏成功或失败的异步回调只在选中条目仍匹配时更新反馈,避免晚到结果覆盖列表或另一条目。
2026-09-20 在 HBN-AL80(API 26)覆盖安装后,旋龟仍回读为已收藏;按钮返回与系统返回均显示搜索提示。输入“不存在样本”得到零条结果及修改关键词提示。


更多推荐



所有评论(0)