调饮台交互工程(第 5 讲):HarmonyOS搜索监听与过期结果隔离【共创稿事节】
搜索框已经输入“桂花”,列表却忽然回到上一次“桂”的结果。输入框没有丢字,过滤函数也没有写错,真正发生的是两次异步操作按相反顺序结束。调饮台最后一篇处理这条时序边界:触发搜索只是开始,结果能否进入页面,还必须再次确认它属于当前输入。

一、把故障拆成三个独立问题
搜索过程中至少有三件事:观察输入变化、决定何时启动检索、决定哪一次结果可以发布。@Monitor 解决第一件事,防抖计时器减少第二件事的重复触发,序号与关键词校验约束第三件事。把它们混成一个回调,往往会在正常输入时工作,在返回较慢或页面离开时出错。
工程仍使用 MixLab 和 API 12 的状态管理 V2。保留前四篇的模型、计价和草稿页面,新增 common/DrinkSearchService.ets、components/DrinkSearch.ets,再对第04篇入口做少量接入。服务读取第01篇的本地饮品目录,通过可控延时建立异步边界,不访问网络,因此不增加网络权限,也不把示例服务当成远端接口。
官方 @Monitor 支持监听 V2 状态变量,并提供异步变化回调;本例仅使用从 API 12 已支持的明确路径 query,不使用后续版本新增的通配符配置。它不是每次键盘事件的同步记录器,也不负责取消已经启动的请求。具体监听规则见 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-monitor/@Monitor 官方说明。
排查时先问旧结果为什么有权覆盖新页面。只在发起请求前检查关键词是不够的,因为关键词会在等待期间变化。结果返回后必须重新检查;错误提示和加载状态也一样,否则旧请求失败时仍会清空新结果,旧请求结束时仍会把新请求的加载状态关闭。
防抖也不能代替身份校验。它只能合并短时间内尚未启动的工作,不能让已经发出的请求按顺序返回。把防抖时间调得很长,可能暂时降低问题出现的概率,却会让输入反馈变慢。应该先保证任何完成次序下都只能发布当前结果,再根据交互需要选择等待时间,而不是用等待时间掩盖时序缺陷。
搜索状态的所有者同样需要明确。输入框负责表达用户当前条件,结果列表表示该条件对应的候选项,正式订单则表示用户已经接受的选择。清空搜索不应该清空订单,检索失败也不应该把已选饮品变成空白。把这三者分开后,失败影响的范围才会停留在检索区域。
二、用可控服务复现完成次序
这个服务只做目录过滤。输入“桂”时延时七百毫秒,其余关键词延时二百毫秒,输入“失败”时返回错误。时间是复现顺序用的配置值,并非接口延迟测试结果。过滤返回目录项,不修改目录;因此子组件把结果交给页面后,仍由页面决定是否接受选择。
// common/DrinkSearchService.ets
import { DrinkSpec, DRINKS } from './DrinkOrder';
export function searchDrinks(keyword: string): Promise<DrinkSpec[]> {
return new Promise<DrinkSpec[]>((resolve, reject) => {
const delay: number = keyword === '桂' ? 700 : 200;
setTimeout(() => {
if (keyword === '失败') {
reject(new Error('检索失败'));
return;
}
resolve(DRINKS.filter((drink: DrinkSpec) =>
drink.name.includes(keyword)));
}, delay);
});
}
复现时先输入“桂”,让它超过防抖等待时间并启动检索,再输入“桂花”。若两次输入紧挨着完成,第一次任务可能根本没有启动,测试到的只是防抖功能,不能说明旧结果隔离已经正确。测试步骤需要覆盖真实的交叠区间,而不是仅比较最终文字。
这里没有给示例服务添加取消接口。防抖计时器可以取消尚未发起的检索,已经启动的服务仍会结束。页面通过发布条件忽略过期结果,能够保证界面一致性,但不会节省已经消耗的计算或网络资源。以后换成支持取消的真实接口,应同时取消资源与保留发布校验,两种措施解决的问题不同。
示例服务的失败分支使用拒绝结果而不是在计时器回调里直接抛出错误。后者发生在另一个执行阶段,不能仅靠调用位置的同步异常捕获来处理。将失败纳入返回的异步结果,组件才能在同一个等待流程中处理成功与失败。接入实际服务时,也应确认错误究竟通过返回值、回调还是拒绝结果传播,不能凭函数名推测。
三、让发布条件覆盖成功、失败与收尾
搜索组件只接收当前饮品标识和是否允许操作,并通过事件输出所选饮品。查询词、结果、加载状态属于组件内部。普通序号与计时器句柄不参与显示,因此不需要为了管理它们而额外创建响应式状态。
// components/DrinkSearch.ets
import { DrinkSpec, DRINKS } from '../common/DrinkOrder';
import { money } from '../common/Pricing';
import { searchDrinks } from '../common/DrinkSearchService';
@ComponentV2
export struct DrinkSearch {
@Param selectedId: string = 'osmanthus';
@Param disabled: boolean = false;
@Event onPick: (drink: DrinkSpec) => void = (drink: DrinkSpec) => {};
@Local query: string = '';
@Local results: DrinkSpec[] = DRINKS;
@Local status: string = '全部饮品';
@Local loading: boolean = false;
private generation: number = 0;
private timer: number = -1;
private active: boolean = false;
aboutToAppear(): void {
this.active = true;
this.scheduleSearch();
}
aboutToDisappear(): void {
this.active = false;
this.invalidate();
}
invalidate(): void {
this.generation += 1;
if (this.timer !== -1) { clearTimeout(this.timer); }
this.timer = -1;
}
acceptInput(value: string): void {
if (value === this.query) { return; }
this.invalidate();
this.loading = false;
this.results = [];
this.status = '等待搜索';
this.query = value;
}
@Monitor('query')
onQueryChange(monitor: IMonitor): void {
this.scheduleSearch();
}
scheduleSearch(): void {
if (!this.active) { return; }
if (this.timer !== -1) { clearTimeout(this.timer); }
const keyword: string = this.query.trim();
const version: number = this.generation;
if (keyword.length === 0) {
this.timer = -1;
this.results = DRINKS;
this.status = '全部饮品';
this.loading = false;
return;
}
this.timer = setTimeout(() => {
this.timer = -1;
this.runSearch(keyword, version);
}, 200);
}
canPublish(keyword: string, version: number): boolean {
return this.active && version === this.generation
&& keyword === this.query.trim();
}
async runSearch(keyword: string, version: number): Promise<void> {
if (!this.canPublish(keyword, version)) { return; }
this.loading = true;
this.status = '搜索中';
try {
const found: DrinkSpec[] = await searchDrinks(keyword);
if (!this.canPublish(keyword, version)) { return; }
this.results = found;
this.status = `找到 ${found.length} 款饮品`;
} catch (error) {
if (!this.canPublish(keyword, version)) { return; }
this.results = [];
this.status = '搜索失败,请重试';
} finally {
if (this.canPublish(keyword, version)) { this.loading = false; }
}
}
retry(): void {
this.invalidate();
this.scheduleSearch();
}
build() {
Column({ space: 12 }) {
Text('查找饮品').fontSize(18)
TextInput({ text: this.query, placeholder: '输入饮品名称' })
.enabled(!this.disabled)
.onChange((value: string) => this.acceptInput(value))
Text(this.status).fontColor('#727A84')
Text('检索结果').fontSize(16)
ForEach(this.results, (drink: DrinkSpec) => {
Button(`${drink.name} · ${money(drink.basePriceFen)}`)
.width('100%').enabled(!this.disabled)
.backgroundColor(drink.id === this.selectedId ? '#176B5B' : '#E8EEEB')
.fontColor(drink.id === this.selectedId ? '#FFFFFF' : '#263D36')
.onClick(() => this.onPick(drink))
}, (drink: DrinkSpec) => drink.id)
Button('重新搜索').enabled(!this.loading && !this.disabled)
.onClick(() => this.retry())
}.width('100%').padding(18).backgroundColor('#FFFFFF').borderRadius(18)
}
}

输入处理函数立即递增序号,然后才把词交给监听器。这个顺序阻止旧任务在监听器尚未执行的间隙继续发布。防抖负责延迟新任务,不应该延迟旧任务失效的时机。相同文本直接返回,避免输入值未变化、监听器不再触发时,却把现有结果无故清空。
序号和关键词同时检查,分别约束任务身份与当前语义。用户从“桂”改成“茉莉”再改回“桂”,关键词再次相等,早期任务仍然不是最后一次请求;序号能够区分它们。组件消失时再让序号失效,并标记不可发布,避免离开的页面继续接受结果。
初始化由 aboutToAppear 显式安排,不依赖监听器对初始值自动执行。重试也直接调用调度入口,因为同一个查询词并不一定产生新的状态变化。监听器没有反向修改查询词,因此不会因为处理监听又触发同一监听形成循环。官方对监听中再次修改被监听值的限制也值得一起核对。

组件消失与应用进入后台不是同一个事件。本例的活动标记只描述组件是否仍可接收结果,没有承诺切到后台就终止检索。如果产品要求后台停止搜索,需要在拥有页面生命周期信息的位置转发停用信号,并在恢复时重新调度。不要把一个组件回调扩大解释成所有系统生命周期的统一出口。
还要注意计时器句柄的归属。组件持有的是防抖计时器,因此清理它只能取消尚未调用服务的那一段等待;服务内部的示例计时器不属于组件。代码通过版本检查拒绝它的旧结果,但它仍可能短暂持有闭包。对于持续时间长、占用资源大的真实任务,应提供服务层取消能力,避免把忽略结果误当成资源已经释放。
四、接回草稿页面,保留已有约束
在第04篇 pages/Index.ets 的模型导入中增加 DrinkSpec 与 selectDrink,并导入新组件。入口页已有的订单、草稿、计算属性与三个编辑方法全部保留。把下面方法加入 Index,再将组件调用放到“已确认订单”卡片之前;页面副标题改成“搜索饮品,保留当前订单”。
import { DrinkOrder, DrinkSpec, cloneOrder, selectDrink } from '../common/DrinkOrder';
import { DrinkSearch } from '../components/DrinkSearch';
// 放在 Index 结构体内,与 beginEdit 等方法同级。
chooseDrink(drink: DrinkSpec): void {
if (this.editing) {
this.message = '请先确认或取消草稿';
return;
}
selectDrink(this.order, drink);
this.message = '已切换饮品';
}
// 放在 Index.build() 的 Column 中,已确认订单卡片之前。
DrinkSearch({
selectedId: this.order.drinkId,
disabled: this.editing,
onPick: (drink: DrinkSpec) => this.chooseDrink(drink)
})
编辑草稿时暂停选择饮品,是本系列采用的冲突策略。这样草稿基于哪个正式订单始终清楚,不会出现上方换成茉莉鲜奶、下方仍在编辑桂花乌龙的情况。按钮禁用是反馈,父方法的判断是兜底;两者共同维护第04篇的生效边界,而不是搜索组件自行猜测订单状态。
界面效果对应搜索“桂花”完成后:状态为找到一款饮品,结果按钮显示桂花乌龙及基础价十八元,正式订单仍显示中杯、冰饮、不加料、合计十八元。搜索改变候选列表,不会自动替换当前订单;只有用户选择结果并被页面接受之后,订单才变化。
五、把异常路径变成可重复检查
检查至少覆盖旧成功晚到、旧失败晚到、清空输入、相同词重试与离开组件。旧成功不应替换新结果,旧失败不应覆盖当前状态;清空输入应立即回到全部目录;重试同一关键词必须发起新任务;离开组件之后完成的任务不得继续发布。加载状态也应随当前任务结束,而非随任意任务结束。
对于空结果与失败结果,界面状态需要不同。找到零款饮品说明检索完成但没有匹配,搜索失败说明没有得到可靠结果。不能用同一个“暂无内容”掩盖它们,否则用户不知道该改关键词还是重试。若未来保留上次结果以减少闪烁,也必须标注它对应的查询条件,避免旧内容被误读成新结果。
再补一条往返输入的检查:先搜索“桂”,切换为“茉莉”,随后重新输入“桂”,让最早与最后的关键词相同。若只比较词而没有序号,最早的任务可能重新获得发布资格;现有实现会把它挡住。这个场景能够区分表面上能防止覆盖的方案和真正按请求身份隔离的方案,值得长期留在回归清单中。
迁移到远端检索时,返回数据还需要经过结构与业务校验。数组类型断言不能保证每条数据都有合法标识和价格,服务层应检查后构造饮品对象,再交给组件渲染。超时和取消也要进入明确的错误或终止语义,不能让加载状态永久停留。发布检查继续放在组件边界,而数据校验和资源释放放在服务边界,两层各自解决可验证的问题。
观察异步顺序时,可以给每次发起与结束记录同一个本地序号,并同时记录是否被接受。记录目的在于解释先后关系,不需要把完整用户输入长期写进日志。尤其接入真实商品搜索、地址或姓名查询后,调试字段应按实际需求控制范围,避免为排查时序保存无关内容。操作顺序可以由发起位置、返回位置和发布检查位置的断点逐步观察。
这五篇形成了同一套调饮交互:订单有明确所有者,组件通过参数与事件协作,价格由规则派生,草稿在确认时生效,异步结果在资格检查后发布。每增加一个功能,都可以继续沿这条路径问清楚数据从哪里来、允许在哪里改、什么时候才算当前值,而不是等到画面异常后再用延时修补。
官方参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-monitor/@Monitor状态变量监听、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-event/@Event组件输出、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-local/@Local组件内部状态。
更多推荐
所有评论(0)