羽毛球口袋教练 HarmonyOS 学习应用(08):搜索页面的空结果与回滚
一、搜索页面的空结果与回滚的业务入口与状态边界
“搜索页面的空结果与回滚”要处理的问题是:搜索词变化时不能保留旧结果位置。状态边界、资源生命周期和失败路径需要在页面组合之前确定;窗口变化、断网、返回页面和重复操作才能沿同一套规则恢复。
处理“搜索页面的空结果与回滚”时,业务输入、状态变化和异常反馈必须落在明确的边界内。下文用状态模型、关键接口和失败路径展开这条链路。参考 arkui。

二、搜索页面的空结果与回滚的职责拆分
“搜索页面的空结果与回滚”采用四层分工:输入层接收点击、文本和选择;领域服务层完成校验、转换和持久化;页面状态层订阅明确字段;异常层处理空数据、权限、网络、设备能力和资源缺失。页面扩展时只接入对应层,不复制已有判断。
| 层级 | 责任 | 这篇的落点 |
|---|---|---|
| 输入层 | 收集点击、筛选、文本或文件选择 | 搜索词变化时不能保留旧结果位置 |
| 服务层 | 校验参数、读写数据、处理异步结果 | 搜索框、过滤列表和空状态 |
| 状态层 | 用可观测字段驱动 UI 刷新 | 选中态、加载态、错误态分离 |
| 验收范围 | 正常输入、空状态、失败与恢复 | 页面状态、结构图与异常结果 |
三、搜索页面的空结果与回滚的关键状态流转
关键实现聚焦状态从页面进入服务层后的约束:输入先归一化,状态只经由一个入口更新,失败信息保留给页面反馈。
@Component
class SearchService {
static search(keyword: string, moduleId?: string): SearchHit[] {
const kw = keyword.trim().toLowerCase()
return COURSES
.filter(c => moduleId === undefined || moduleId === 'all' || c.moduleId === moduleId)
.filter(c => SearchService.matches(c, kw))
.map(c => ({ course: c, moduleTitle: findModule(c.moduleId)?.title ?? '' }))
}
}
页面以 keyword 是否为空分支:空值显示热门搜索,非空值调用 SearchService;命中数为 0 时显示“未找到相关内容”。清空关键词后切回热门搜索与课程入口,这就是本篇所说的回滚。
“搜索页面的空结果与回滚”的入口验收检查三项:不可用条件是否禁用入口、离开后状态是否复位、失败后是否保留继续操作的路径。失败分支与主流程使用同一套状态回收规则。
四、搜索页面的空结果与回滚的异常处理与回退
边界流程决定“搜索页面的空结果与回滚”是否稳定:空数据不能继续渲染详情,异步失败要给出下一步操作,多窗口或跨设备状态不能只依赖页面局部字段。
“搜索页面的空结果与回滚”按生命周期划分状态:即时交互留在页面,需要跨页面、重启或共享的数据经由 Service、Repository 或持久化层管理。截图用于观察页面结果,结构图用于说明状态和依赖的归属。
五、搜索页面的空结果与回滚的验收路径
- 进入“搜索页面的空结果与回滚”对应页面,确认入口、标题、主要内容和操作区可见。
- 完成一次目标交互,核对加载、成功或下一步状态是否同步。
- 触发空输入、重复操作或不可用条件,确认提示和恢复入口。
- 返回后重新进入,核对选中态、列表、统计或播放状态。
- 分别检查窄窗口与宽窗口,确认文本、卡片和操作控件没有重叠。
六、搜索页面的空结果与回滚的边界结论
“搜索页面的空结果与回滚”的稳定性取决于状态、服务、UI 和异常路径是否各归其位。边界清楚后,功能扩展不会把数据恢复、资源释放和页面渲染缠在同一个入口。
补充:空结果不是异常,它是查询状态的一种输出
搜索页常见的错误是把“没有匹配课程”当成数据加载失败,于是用户既看不到自己输入了什么,也不知道该清除关键词还是切换模块。本篇把关键词和模块条件保留在查询状态中,命中列表与空结果只是在同一查询下的两种投影。
static search(keyword: string, moduleId?: string): SearchHit[] {
const kw = keyword.trim().toLowerCase()
return COURSES
.filter((course) => moduleId === undefined || moduleId === 'all' || course.moduleId === moduleId)
.filter((course) => SearchService.matches(course, kw))
.map((course) => ({ course, moduleTitle: findModule(course.moduleId)?.title ?? '' }))
}
空关键词和无命中词不能使用同一段提示。前者适合展示热门问题或引导探索,后者应显示当前词和清除动作;否则用户无法判断系统是没有开始搜索,还是确实没有数据。
private hits(): SearchHit[] {
return SearchService.search(this.keyword, this.moduleFilter)
}
private clearQuery(): void {
this.keyword = ''
this.moduleFilter = 'all'
}
| 查询状态 | 列表区域 | 下一步 |
|---|---|---|
| 空关键词 | 热门问题 | 选择示例或输入词 |
| 有词且命中 | SearchHit 列表 | 进入课程详情 |
| 有词无命中 | 保留关键词的空态 | 清除或调整模块 |
验收时先在单一模块输入一个不存在的关键词,确认空态仍展示当前条件;随后清除关键词并切回全部模块,确认结果列表恢复。这证明回滚的是查询状态,而不是通过重新打开页面碰巧得到默认列表。
补充:从页面现象回到服务边界
这个能力的真正约束是“无结果搜索不应把筛选条件和可返回路径一起丢失”。因此不能只把一次点击的视觉变化当作完成,而要让后续页面和下一次启动都从同一份领域状态推导结果。
当前实现的核心决策是先规范化关键词并以课程字段匹配返回 SearchHit。下方保留的是同一条调用链中的连续代码,而不是为了填充篇幅截取的孤立片段;它可以直接看到输入如何被规范化、何处写回状态、以及后续读取依赖什么。
export class SearchService {
static search(keyword: string, moduleId?: string): SearchHit[] {
const kw: string = keyword.trim().toLowerCase();
return COURSES
.filter(c => moduleId === undefined || moduleId === 'all' || c.moduleId === moduleId)
.filter(c => SearchService.matches(c, kw))
.map<SearchHit>((c: Course): SearchHit => {
const m: LearnModule | undefined = findModule(c.moduleId);
return { course: c, moduleTitle: m?.title ?? '' } as SearchHit;
});
}
private static matches(c: Course, kw: string): boolean {
if (kw.length === 0) return true;
if (c.title.toLowerCase().indexOf(kw) >= 0) return true;
if (c.desc.toLowerCase().indexOf(kw) >= 0) return true;
if (c.subCategoryLabel.indexOf(kw) >= 0) return true;
for (const k of c.keywords) {
if (k.toLowerCase().indexOf(kw) >= 0) return true;
}
return false;
}
}
private hits(): SearchHit[] {
return SearchService.search(this.keyword, this.moduleFilter);
}
@Builder
resultSection() {
Column() {
if (this.hits().length === 0) {
EmptyState({
emoji: '🔍',
title: '未找到相关内容',
subtitle: '换个关键词试试,或在分类里浏览'
});
} else {
this.resultTitle();
Column({ space: AppSizes.s2 }) {
ForEach(this.hits(), (h: SearchHit) => {
this.hitRow(h);
}, (h: SearchHit) => h.course.id);
}
.padding({ left: AppSizes.s4, right: AppSizes.s4 });
}
}
.width('100%');
}
@Builder
resultTitle() {
Row() {
Text('搜索结果(' + this.hits().length + ')')
.fontSize(AppText.fsTitle)
.fontWeight(AppText.fwSemi)
.fontColor(this.palette.textPrimary)
.layoutWeight(1);
}
.width('100%')
.padding({ left: AppSizes.s4, right: AppSizes.s4, top: AppSizes.s2, bottom: AppSizes.s2 });
}
@Builder
hitRow(h: SearchHit) {
Row({ space: AppSizes.s3 }) {
Column() {
PlaceholderImage({
imgKey: h.course.thumbKey,
label: h.course.title,
imgRes: courseCover(h.course.thumbKey)
})
.width(72).height(56);
}
.width(72).height(56);
Column({ space: AppSizes.s1 }) {
Text(h.course.title)
.fontSize(AppText.fsSubtitle)
.fontWeight(AppText.fwSemi)
.fontColor(this.palette.textPrimary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis });
Text(h.moduleTitle + ' · ' + h.course.subCategoryLabel)
.fontSize(AppText.fsSmall)
边界不应由页面临时 if 掩盖:空关键词、模块筛选和无命中必须区分。服务层把空值、重复操作或恢复后的数据先变成稳定结果,页面再选择展示列表、空态或提示,因此没有必要以假成功掩盖失败。
验收动作应当沿着数据流走完,而不是只看一个控件。输入无命中词、保留条件,再清除词并回读课程结果。当动作、存储或规则计算、以及第二个入口的读回都一致时,文章中的工程结论才有可检查的含义。
这类拆分还带来一个维护收益:新增入口时只要复用既有服务和状态键,就不会复制旧判断。若未来业务规则扩展,应把新条件放在同一决策边界,并用同样的回读路径验证,而不是为新页面补一套不可追踪的局部状态。读者排查问题时也应先确认事实来源和更新时机,再检查页面是否正确投影;反过来从颜色、空态或单次点击结果推断服务层成功,往往会把偶然显示误写成稳定能力。
更多推荐


所有评论(0)