一个可以实际操作的阅读页面

UI 层的最小任务包括搜索旋龟、打开详情、收藏,再回到搜索结果。页面使用本地条目和 Preferences,不依赖系统智能体执行,因此可以单独验证控件定位与结果回读。系统智能入口需要另一条调用与回读记录。

2026-09-19 搜索结果回读

搜索输入有明确的提示语和可访问性文本,控件标识固定为 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;

2026-09-19 收藏完成后的持久状态

这段幂等处理适用于本 Demo 单页串行操作,页面 busy 状态阻止并发点击。若以后多个页面或服务同时写入收藏,需要把串行化移入共享 Repository,不应把这份页面级保护当作跨进程事务。写入错误会展示具体原因,替身测试检查了 flush 失败时内存不报告成功。

返回和重启是两种不同验证

返回搜索结果应保留本页查询条件;重启应用后则从本地收藏存储恢复,而不是依赖旧页面仍留在内存。查询词没有持久化,所以重启回到初始列表,收藏状态单独回读。验证记录分别保存这两个行为,避免把页面状态恢复与持久化混为一谈。

扩展系统智能操控前的语义要求

强制停止应用再启动,打开旋龟详情,页面回读“已收藏:从本地记录恢复”,收藏按钮保持禁用。这次操作验证了进程重启后的持久化路径。

2026-09-19 重启后恢复收藏

“搜索玄龟,打开详情,然后收藏。”人看到页面会寻找搜索框和收藏按钮,智能操控入口也需要稳定地识别这些操作。若页面只有图标、列表行缺少实体信息、按钮文字与状态不一致,就很难判断某次动作究竟有没有完成。

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)覆盖安装后,旋龟仍回读为已收藏;按钮返回与系统返回均显示搜索提示。输入“不存在样本”得到零条结果及修改关键词提示。

空结果页显示搜索提示,收藏记录保留在本地

覆盖安装后仍能读回旋龟收藏

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐