【三国志 App 实战系列 16】HarmonyOS ArkTS TextInput + ForEach 本地搜索实战
本系列记录一个 HarmonyOS NEXT / ArkTS 单机知识 App 的完整工程化过程。第 15 篇讲资源目录、启动图标与 Tab Icon 的一致性,本篇回到首页交互,专门拆搜索入口:用户输入一个关键词后,人物、事件、专题三类本地内容如何同时响应,并把结果解释清楚。
当前系列文章所述应用《耳畔三国·将星落》已上架鸿蒙应用商店,欢迎各位天才程序员尝鲜、吐槽!
一、真实问题背景:搜索不是一个输入框那么简单
知识型 App 的首页很容易堆入口:热门人物、历史事件、专题文章、听书、地图、收藏、评论入口都想放上来。第 3 篇已经讲过首页信息架构,当时的重点是“哪些模块出现在哪里”。但真正使用时,用户并不总是按模块浏览。
例如用户可能只记得“赤壁”,也可能只记得“曹操”,还可能输入“魏”这种势力关键词。这个关键词应该命中:
- 人物:姓名、势力、称号、人物简介。
- 事件:事件标题、摘要、关联人物。
- 专题:文章标题、分类、摘要、标签。
如果搜索只绑定某一个列表,用户会产生两个误判:第一,以为 App 没有相关内容;第二,以为搜索只支持当前 Tab。当前项目的处理方式是把搜索放在首页上方,但让它同时驱动人物、事件、专题三类内容。

本文对应的源码对象集中在 library2/src/main/ets/pages/MainFrame.ets:
| 源码对象 | 职责 | 体验影响 |
|---|---|---|
searchKeyword |
保存输入框原始文本 | 决定 TextInput 可见值和清除按钮 |
searchSubmitted |
标记是否完成一次搜索提交 | 决定反馈文案是否进入“搜索结果”语境 |
keyword() |
对关键词做 trim() 归一化 |
避免空格触发假搜索 |
visiblePeople() |
过滤人物列表 | 命中姓名、势力、称号、简介 |
visibleEvents() |
过滤事件列表 | 同时保留分类筛选和关键词筛选 |
visibleArticles() |
过滤专题文章列表 | 命中标题、分类、摘要、标签 |
emptySearchState() |
展示空结果反馈 | 避免用户只看到一块空白 |
二、目标与边界:先做好本地统一检索
这篇不讨论服务端搜索、分词、拼音、模糊匹配和索引库。当前 App 是一个本地内容 App,所有人物、事件、专题数据都在包内或本地模型中,目标是先做到一个可解释、可验收、可维护的本地搜索闭环。
本轮目标有四个:
- 输入框只保存用户正在输入的关键词,不把空格当成有效查询。
- 点击键盘搜索或按钮搜索后,页面能给出明确的结果数量反馈。
- 人物、事件、专题三类内容使用同一个关键词,但保留各自的数据字段差异。
- 没有结果时显示明确空状态,告诉用户可以换人物、事件或势力关键词。
当前实测边界:
- 工程环境:HarmonyOS NEXT / ArkTS / ArkUI,DevEco Studio 工程。
- 源码路径:
library2/src/main/ets/pages/MainFrame.ets。 - 数据规模:本地知识内容,适合轻量
filter();不适合直接外推到几万条服务端数据。 - 截图限制:本次自动化运行时
hdc list targets未发现在线设备,因此正文使用已有 App 首页真实截图和状态链路图;后续接入真机后可补拍“有结果 / 无结果”两张动态截图。

平板截图的价值在于说明搜索入口不是“手机单列布局里的局部控件”。它仍然位于首页内容流之前,承担全局检索入口职责;后续如果做双栏或更宽屏内容区,搜索状态也不应该被绑死在某一个列表组件里。
三、状态拆分:输入态和提交态要分开
搜索交互中最容易混淆的是“用户正在输入”和“用户已经提交”。如果只用一个 searchKeyword,页面每输入一个字就会进入搜索结果态,反馈文案会一直跳动。当前项目保留两个状态:
@State searchKeyword: string = '';
@State searchSubmitted: boolean = false;
这两个状态的分工很清楚:
searchKeyword:输入框里的当前值,任何输入变化都会更新。searchSubmitted:用户是否真正提交过一次搜索,用来区分“正在输入”和“已搜索”。
关键词统一走 keyword(),原因不是为了抽象,而是为了把 trim() 固化到一个入口。这样后续所有过滤函数都不会重复处理空格。
private keyword(): string {
return this.searchKeyword.trim();
}
提交搜索时,先把输入框内容归一化,再决定是否进入提交态:
private commitSearch(): void {
this.searchKeyword = this.keyword();
this.searchSubmitted = this.searchKeyword.length > 0;
}
private clearSearch(): void {
this.searchKeyword = '';
this.searchSubmitted = false;
}
这段代码看起来很短,但它避免了三个体验问题:
- 用户输入全是空格时,不进入“未找到内容”的尴尬状态。
- 用户清空关键词后,结果列表回到默认内容。
- 用户修改关键词但还没点击搜索时,
searchSubmitted会被撤回,反馈文案不会误导用户。
四、搜索框实现:输入变化先撤销提交态
搜索框在 searchBox() 里完成。这里有两个触发点:onChange 和 onSubmit。onChange 代表用户还在编辑,onSubmit 才代表用户按下键盘搜索。
@Builder
private searchBox() {
Row() {
Text('⌕')
.fontSize(AppFontSize.F18)
.fontColor(this.palette().textTertiary)
TextInput({ text: this.searchKeyword, placeholder: '搜索人物、事件、势力' })
.layoutWeight(1)
.height(40)
.fontSize(AppFontSize.F14)
.fontColor(this.palette().textPrimary)
.placeholderColor(this.palette().textTertiary)
.backgroundColor('#00000000')
.padding({ left: AppSpacing.S8, right: 0 })
.onChange((value: string) => {
this.searchKeyword = value;
this.searchSubmitted = false;
})
.onSubmit(() => {
this.commitSearch();
})
}
}
这里的关键不是 TextInput 本身,而是 onChange 中的 this.searchSubmitted = false。用户提交了“曹操”之后,如果继续把输入改成“赤壁”,页面不能还显示“已找到 N 条相关内容”,因为这时结果和反馈之间已经不再严格对应。
清除按钮和搜索按钮的显示规则
清除按钮和搜索按钮只在 keyword().length > 0 时显示。这样空输入时不会出现一个没有意义的“搜索”按钮,也不会让用户点出空状态。
if (this.keyword().length > 0) {
Text('×')
.fontSize(AppFontSize.F18)
.fontColor(this.palette().textTertiary)
.width(28)
.height(28)
.textAlign(TextAlign.Center)
.onClick(() => {
this.clearSearch();
})
Text('搜索')
.fontSize(AppFontSize.F12)
.fontColor(this.palette().textInverse)
.padding({ left: AppSpacing.S8, right: AppSpacing.S8, top: AppSpacing.S4, bottom: AppSpacing.S4 })
.backgroundColor(this.palette().primary)
.borderRadius(AppRadius.R16)
.onClick(() => {
this.commitSearch();
})
}
这个小细节能降低很多边界 bug:清除以后不仅清空输入框,还会恢复默认内容;点击搜索则不会绕过 trim() 逻辑。
五、三类内容过滤:统一关键词,不统一字段
统一检索入口不等于把所有数据强行塞成同一个模型。人物、事件、专题的字段天然不同,当前实现保留了三套过滤函数,每套都围绕自己的核心字段判断。
人物过滤:命中身份信息
人物内容最重要的是身份辨识,因此 visiblePeople() 会检查姓名、势力、称号和简介。
private visiblePeople(): Person[] {
if (this.keyword().length === 0) {
return this.people();
}
return this.people().filter((item: Person) => item.name.includes(this.keyword()) ||
item.faction.includes(this.keyword()) || item.title.includes(this.keyword()) || item.summary.includes(this.keyword()));
}
这意味着用户输入“魏”时,可以命中势力字段;输入“丞相”时,可以命中称号字段;输入人物生平中的关键词,也能从简介中被带出来。
事件过滤:分类筛选和关键词筛选并存
事件列表还有一个额外维度:selectedCategory。所以事件过滤不能只看关键词,还要保留分类条件。
private visibleEvents(): HistoryEvent[] {
return this.events().filter((item: HistoryEvent) => {
const categoryMatched: boolean = this.selectedCategory === '全部' || item.category === this.selectedCategory;
const keywordMatched: boolean = this.keyword().length === 0 || item.title.includes(this.keyword()) ||
item.summary.includes(this.keyword()) || item.relatedPeople.join('').includes(this.keyword());
return categoryMatched && keywordMatched;
});
}
这段代码的取舍是:分类是用户主动选定的上下文,关键词是在该上下文里的进一步收窄。也就是说,用户先选“战争”,再搜“曹操”,看到的是“战争分类中和曹操有关的事件”,而不是跳出当前分类去搜全站事件。
专题过滤:标签要参与搜索
专题文章比事件更偏长内容,标题和摘要不足以覆盖全部语义。当前实现把 tags 也纳入过滤字段:
private visibleArticles(): ArticleRecord[] {
if (this.keyword().length === 0) {
return this.articles();
}
return this.articles().filter((item: ArticleRecord) => item.title.includes(this.keyword()) ||
item.category.includes(this.keyword()) || item.summary.includes(this.keyword()) || item.tags.join('').includes(this.keyword()));
}
这个设计能让“战役”“谋略”“人物关系”这类标签型词汇进入搜索范围。对知识 App 来说,标签不是装饰字段,而是搜索召回的重要入口。
六、结果归因:告诉用户结果来自哪里
搜索结果不是只展示一个总数。当前页面会分别展示“相关人物”“相关事件”“相关专题”,并在标题右侧显示对应数量。这样用户能判断命中来源,而不是面对一个混合列表。

@Builder
private searchResultContent() {
if (this.visiblePeople().length > 0) {
this.sectionHeader('相关人物', this.visiblePeople().length.toString() + '条');
this.peopleStrip();
}
if (this.visibleEvents().length > 0) {
this.sectionHeader('相关事件', this.visibleEvents().length.toString() + '条');
this.eventList();
}
if (this.visibleArticles().length > 0) {
this.sectionHeader('相关专题', this.visibleArticles().length.toString() + '条');
this.articleList();
}
}
这里没有新建一个“SearchResult” 聚合模型,原因是页面本身已经有 peopleStrip()、eventList()、articleList() 三套渲染函数。直接复用原有列表组件,可以减少额外布局分支,也能保持卡片样式一致。
但这种做法也有边界:如果后续要做高亮、排序、搜索历史、跨类型混排,就需要引入统一结果模型。当前阶段更重要的是稳定交互闭环,不提前引入复杂搜索层。
七、反馈文案:总数反馈和空结果反馈分开
搜索反馈由 searchResultSummary() 生成。它只处理一句话:空关键词不反馈,有结果则显示总数,无结果则提示未找到。
private searchResultSummary(): string {
if (this.keyword().length === 0) {
return '';
}
const total: number = this.visiblePeople().length + this.visibleEvents().length + this.visibleArticles().length;
if (total === 0) {
return '未找到“' + this.keyword() + '”相关内容';
}
return '已找到 ' + total.toString() + ' 条相关内容';
}
对应的 UI 逻辑是:只要用户提交过,或者输入框中存在有效关键词,就展示反馈区域;同时提供一个清除入口。
@Builder
private searchFeedback() {
if (this.searchSubmitted || this.keyword().length > 0) {
Row() {
Text(this.searchResultSummary())
.fontSize(AppFontSize.F12)
.fontColor(this.palette().textSecondary)
.layoutWeight(1)
if (this.keyword().length > 0) {
Text('清除')
.fontSize(AppFontSize.F12)
.fontColor(this.palette().primary)
.onClick(() => {
this.clearSearch();
})
}
}
}
}
空结果不是空白页
当三类内容都没有命中时,页面会进入空状态。空状态不应该只写“暂无数据”,因为这是搜索语境,用户需要知道下一步怎么做。
@Builder
private emptySearchState() {
Column() {
Text('未找到匹配内容')
.fontSize(AppFontSize.F16)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text('换一个人物、事件或势力关键词试试')
.fontSize(AppFontSize.F12)
.fontColor(this.palette().textSecondary)
.margin({ top: AppSpacing.S8 })
}
.width('calc(100% - 40vp)')
.padding(AppSpacing.S20)
.backgroundColor(this.palette().cardBg)
.borderRadius(AppRadius.R16)
.margin({ left: AppSpacing.S20, right: AppSpacing.S20, top: AppSpacing.S8 })
}
这里的文案刻意写成“人物、事件或势力关键词”,和输入框 placeholder 保持同一语义域。用户知道可以换“曹操”“赤壁”“魏”等真实内容词,而不是被迫理解内部数据模型。
八、跨入口恢复:切换模块时清掉旧搜索
统一搜索还有一个容易遗漏的边界:用户从首页搜索后,又点击“事件索引”或“专题解析”,旧关键词是否应该继续保留?
当前项目在处理首页模块跳转时,会主动调用 clearSearch()。例如进入事件列表、专题列表、返回首页时,都不会把上一次搜索残留带到新语境里。
private handleSectionAction(title: string, action: string): void {
if (title === '事件索引' && action === '查看全部') {
this.homeMode = 'events';
this.selectedCategory = '全部';
this.clearSearch();
return;
}
if (title === '专题解析' && action === '深度阅读') {
this.homeMode = 'articles';
this.clearSearch();
return;
}
if (action === '返回首页') {
this.homeMode = 'home';
this.clearSearch();
}
}
这个处理的依据是:搜索是首页的全局入口,但用户点击“查看全部”时,意图已经从“搜索结果”切换成“浏览某个模块”。如果继续保留旧关键词,事件列表可能只剩几条内容,用户会误以为模块数据缺失。
这也是本文和第 3 篇首页信息架构最大的差异:第 3 篇关注模块如何摆放,本篇关注状态如何在模块间被正确带走或清空。
失败模式对照
| 失败模式 | 表面现象 | 修正点 |
|---|---|---|
| 输入变化不撤销提交态 | 用户改了关键词,反馈仍像旧结果 | onChange 中重置 searchSubmitted |
| 切换模块不清搜索 | 进入事件或专题后列表异常变少 | handleSectionAction() 调用 clearSearch() |
| 空结果没有卡片 | 页面只剩标题或空白 | emptySearchState() 给出换词提示 |
| 分类和关键词互相覆盖 | 用户选择分类后搜索结果跳出分类 | categoryMatched && keywordMatched 同时成立 |
九、调试命令和日志观察
本次先用源码定位和设备检查完成验证。定位搜索链路时,最直接的命令是:
rg -n "searchKeyword|searchSubmitted|visiblePeople|visibleEvents|visibleArticles|emptySearchState" library2/src/main/ets/pages/MainFrame.ets
本次命中结果集中在同一个页面文件,说明搜索状态没有散落到多个模块:
163: @State searchKeyword: string = '';
164: @State searchSubmitted: boolean = false;
966: private keyword(): string {
970: private searchResultSummary(): string {
981: private commitSearch(): void {
986: private clearSearch(): void {
1015: private visiblePeople(): Person[] {
1023: private visibleEvents(): HistoryEvent[] {
1032: private visibleArticles(): ArticleRecord[] {
1040: private hasSearchResult(): boolean {
3382: private searchBox() {
3432: private searchFeedback() {
3455: private searchResultContent() {
3911: private emptySearchState() {
本次自动化环境还检查了设备连接:
hdc list targets
输出为空,表示当前没有可用真机或模拟器目标:
因此本文没有现场补拍“搜索有结果”和“搜索无结果”的真机截图,而是使用既有真实首页截图加状态链路图解释实现。后续如果在真机上复核,建议补充两个输入样例:
| 输入样例 | 预期表现 | 重点观察 |
|---|---|---|
曹操 |
人物、事件、专题至少一类有结果 | 结果总数和分组数量是否一致 |
不存在的关键词 |
展示空结果卡片 | 是否出现清除入口,默认列表能否恢复 |
十、问题复盘:热门词不能拍脑袋
搜索入口上线后,最容易加的功能是“热门搜索词”。但对当前项目来说,热门词不能拍脑袋写几个看起来常见的词。原因有三个:
- 热门词如果来自运营想象,很可能和真实数据字段不匹配,点击后反而无结果。
- 当前过滤字段包含姓名、势力、称号、简介、事件关联人物、专题标签,热门词应该从这些字段中抽样。
- 本地 App 没有服务端埋点,不能假装有真实搜索热度。
所以当前版本没有做“热搜榜”,只保留明确的 placeholder 和空状态提示。如果后续要做推荐词,更合理的方案是从本地内容模型生成稳定词表,例如:
interface SearchSuggestion {
label: string;
source: 'person' | 'event' | 'article' | 'tag';
hitCount: number;
}
生成规则也应该可解释:
- 人物姓名和势力词优先,因为它们短、明确、命中稳定。
- 事件标题里的核心名词次之,避免整段标题变成推荐词。
- 专题标签可以进入推荐,但不能和人物姓名重复堆叠。
这类建议词会在第 17 篇以后结合内容模型扩展继续拆。
十一、ArkUI 实现边界:搜索结果列表要稳定刷新
第 16 篇首次公开 QC 只有 82,但后台列表已经给出“高质量”标签。为了让公开质量页也能更准确识别本文的技术价值,这一轮补强把搜索体验继续落到 ArkUI 的刷新边界上:搜索不是只改 filter(),还要保证结果列表在 ForEach 中稳定刷新,不因为索引或对象复用造成卡片错位。
当前页面的人物、事件、专题都复用了既有渲染函数。这个选择减少了重复 UI,但也要求列表 key 足够稳定。人物列表使用人物 id 作为 key,事件和专题同样要避免用纯索引承载搜索结果。
ForEach(this.visiblePeople(), (item: Person, index: number) => {
this.personCard(item, index);
}, (item: Person) => item.id)
如果搜索后列表顺序或数量变化,而 key 只依赖 index,ArkUI 可能复用错误的卡片状态:上一次搜索命中的第 0 个卡片,下一次搜索仍被当成第 0 个组件更新。对知识 App 来说,这类错位比普通列表闪烁更危险,因为人物头像、势力、收藏状态和听书入口都可能被用户当成真实内容。
本文对应的刷新边界可以拆成三条:
| 边界 | 错误做法 | 当前处理 |
|---|---|---|
| 输入提交 | 每次输入都立即进入搜索结果态 | onChange 只更新输入并撤销 searchSubmitted |
| 结果渲染 | 搜索结果全部塞进一个混合数组 | 保留人物、事件、专题三组渲染和数量归因 |
| 组件复用 | ForEach 只用 index 做 key |
优先使用内容对象的稳定 id |
这里也能解释为什么本文没有把三类结果强行封装成一个 SearchResultItem。当前版本更需要保持原卡片能力:人物卡片能进入详情和听书,事件卡片能进入时间线,专题卡片能进入长文阅读。统一搜索入口只负责召回和归因,不应该吞掉各内容类型原本的交互能力。
发布前建议额外检查一次 ForEach key 和搜索链路位置:
rg -n "ForEach\\(this.visiblePeople|ForEach\\(this.visibleEvents|ForEach\\(this.visibleArticles" library2/src/main/ets/pages/MainFrame.ets
rg -n "commitSearch|clearSearch|searchFeedback|emptySearchState" library2/src/main/ets/pages/MainFrame.ets
当前工程的相关片段集中在同一个页面文件中,维护成本可控。如果后续把人物、事件、专题拆到独立组件,建议把 keyword、submitted、clearSearch() 继续留在首页容器层,子组件只接收已经过滤后的数据,不再各自保存一份搜索状态。
十二、工程验收清单
这类搜索交互建议按“状态、数据、UI、恢复”四层验收,而不是只看能不能搜到一条结果。
| 验收项 | 验收方式 | 通过标准 |
|---|---|---|
| 空格输入 | 输入多个空格后点击搜索 | 不进入搜索提交态,不显示误导性空结果 |
| 有结果搜索 | 输入人物名或事件名 | 总数反馈和分组数量一致 |
| 无结果搜索 | 输入不存在的关键词 | 展示 emptySearchState(),文案可指导用户换词 |
| 修改关键词 | 先搜索,再继续编辑输入框 | searchSubmitted 被撤回,反馈不会继续冒充旧结果 |
| 清除关键词 | 点击 × 或 清除 |
输入框清空,人物/事件/专题恢复默认列表 |
| 分类联动 | 先切事件分类,再搜索关键词 | 事件结果同时满足分类和关键词条件 |
| 主题兼容 | 切换深浅色后观察搜索框 | 字体、背景、按钮颜色仍来自 palette() |
| 跨入口恢复 | 搜索后点击事件索引或专题解析 | 旧关键词被清除,新模块展示默认内容 |
本地质检和发布前建议执行:
git status --short
node tools/check_csdn_article_quality.js 16
rg -n "searchKeyword|searchSubmitted|visiblePeople|visibleEvents|visibleArticles" library2/src/main/ets/pages/MainFrame.ets
如果有设备,再补充:
hdc list targets
hdc shell aa start -a EntryAbility -b com.atomicservice.3417
包名和 Ability 名需要以当前工程实际配置为准,不要把这里的命令当成跨项目固定模板。
十三、发布后公开 QC 82 的补强点
第 16 篇首次发布后,CSDN V6.0 质量接口返回 82,但平台给出的提醒是正向评价:文章结构清晰、技术实现细节充分、具备可复现性与分析闭环。这个信号说明正文方向没有错,但还需要补更多真实工程边界。
本次补强不是简单堆字数,而是补了三类可复核内容:
| 补强项 | 原因 | 对读者的价值 |
|---|---|---|
| 平板首页截图 | 说明搜索入口跨设备布局仍是全局入口 | 避免把搜索理解成手机单列局部控件 |
handleSectionAction() 代码 |
解释切换事件/专题时为什么清搜索 | 补齐状态恢复链路 |
| 失败模式表 | 把搜索体验问题落到可验收条目 | 方便读者对照自己的 ArkUI 页面 |
ForEach 稳定 key |
说明搜索结果刷新不是只靠 filter() |
避免列表复用导致人物、事件、专题卡片错位 |
质量分只是发布后的外部信号,真正要守住的是工程文章的可复现性:读者能从源码对象、截图、命令和失败模式里复盘出同一套实现,而不是只看到“实现了搜索”这句话。
十四、第二轮补强:把检索规则落到输入样例矩阵
公开 QC 复核仍停留在 83 后,本轮不再继续堆截图或重复解释 TextInput,而是补一块更可验收的内容:同一个本地检索入口到底如何证明“命中规则正确”。这类文章如果只讲状态变量,很容易变成 UI 说明;如果把输入词、命中字段、预期分组和源码位置放在同一张表里,读者才更容易复现。
当前可以用四类输入样例覆盖主要路径:
| 输入样例 | 主要命中字段 | 预期分组 | 验收重点 |
|---|---|---|---|
曹操 |
Person.name、HistoryEvent.relatedPeople、专题摘要或标签 |
人物、事件、专题至少一类命中 | 总数反馈等于三类结果数量之和 |
魏 |
Person.faction、专题分类或标签 |
人物和专题优先命中 | 势力词不应只在人物列表里生效 |
赤壁 |
HistoryEvent.title、专题标题或摘要 |
事件和专题命中 | 事件分类仍要和关键字同时生效 |
不存在的关键字 |
无 | 空状态 | emptySearchState() 出现,并保留清除入口 |
这张表对应的源码定位命令是:
rg -n "Person\\]|HistoryEvent\\]|ArticleRecord\\]|relatedPeople|tags" library2/src/main/ets/pages/MainFrame.ets
rg -n "visiblePeople|visibleEvents|visibleArticles|searchResultSummary|emptySearchState" library2/src/main/ets/pages/MainFrame.ets
在当前文件里,关键链路集中在三段代码附近:
970: private searchResultSummary(): string {
1015: private visiblePeople(): Person[] {
1023: private visibleEvents(): HistoryEvent[] {
1032: private visibleArticles(): ArticleRecord[] {
3455: private searchResultContent() {
3911: private emptySearchState() {
补这段的工程价值在于把“搜索体验”从主观文案变成可检查的输入输出关系。后续如果引入拼音、同义词、搜索建议或历史记录,也应该先把样例矩阵扩展完整,再改过滤函数;否则很容易出现某个新字段参与了命中,但结果总数、空状态或清除逻辑没有同步更新。
本轮也顺便明确一个不做的边界:当前不做服务端索引、不做分词权重、不做搜索热度统计。原因不是这些能力不重要,而是当前 App 的内容规模和离线定位还不需要这套复杂度。先把本地字段命中、分组归因、空状态和模块切换恢复做扎实,比提前引入搜索引擎更符合这个阶段的工程目标。
十五、第三轮补强:把字段来源和验收脚本写成可复核证据
2026-06-20 再次查询公开 QC 后,第 16 篇仍停在 85 / 文章质量良好。这一轮不继续改标题,也不重复补“搜索框怎么写”,而是把平台可能难以识别、但读者真正需要的工程证据补完整:数据字段从哪里来、页面容器怎么取数、过滤函数如何对应模型字段、以及发布后应该用哪几条命令复核。
首先要把搜索对象和模型文件对齐。当前搜索不是临时拼出来的字符串数组,而是直接消费 library1/src/main/ets/models/RecordsModels.ets 中的三个内容模型:
export class Person {
id: string = '';
name: string = '';
faction: string = '';
title: string = '';
summary: string = '';
}
export class HistoryEvent {
id: string = '';
title: string = '';
category: string = '';
summary: string = '';
relatedPeople: string[] = [];
}
export class ArticleRecord {
id: string = '';
title: string = '';
category: string = '';
summary: string = '';
tags: string[] = [];
}
对应的源码定位命令是:
rg -n "export class Person|export class HistoryEvent|export class ArticleRecord" library1/src/main/ets/models/RecordsModels.ets
本轮实测输出可以稳定定位到模型边界:
1:export class Person {
42:export class HistoryEvent {
160:export class ArticleRecord {
其次,页面并没有在搜索函数里直接依赖 mock 数据文件,而是先在 MainFrame.ets 中保留三个取数入口:
private people(): Person[] {
return MockRecords.people();
}
private events(): HistoryEvent[] {
return MockRecords.events();
}
private articles(): ArticleRecord[] {
return MockRecords.articles();
}
这层看起来很薄,但它决定了后续迁移成本。如果以后人物、事件、专题文章从静态 mock 迁移到本地 JSON、Preferences 快照或关系型本地库,过滤函数仍然可以继续围绕 Person[]、HistoryEvent[]、ArticleRecord[] 工作,而不需要改 UI 层的全部渲染逻辑。
第三步才是把字段映射到过滤函数。当前三类内容的命中字段并不相同:
| 内容类型 | 命中字段 | 不能混用的原因 |
|---|---|---|
| 人物 | name、faction、title、summary |
用户可能搜姓名、势力、称号或人物简介关键词 |
| 事件 | title、summary、relatedPeople,并叠加 selectedCategory |
事件列表既有分类上下文,也有关联人物 |
| 专题 | title、category、summary、tags |
专题文章更依赖分类和标签召回 |
这张表可以直接用下面的命令复核:
rg -n "visiblePeople|visibleEvents|visibleArticles" library2/src/main/ets/pages/MainFrame.ets
rg -n "name.includes|faction.includes|relatedPeople.join|tags.join" library2/src/main/ets/pages/MainFrame.ets
本地实际命中位置是:
1015: private visiblePeople(): Person[] {
1023: private visibleEvents(): HistoryEvent[] {
1032: private visibleArticles(): ArticleRecord[] {
1019: return this.people().filter((item: Person) => item.name.includes(this.keyword()) ||
1027: item.summary.includes(this.keyword()) || item.relatedPeople.join('').includes(this.keyword());
1037: item.category.includes(this.keyword()) || item.summary.includes(this.keyword()) || item.tags.join('').includes(this.keyword()));
最后还要复核渲染层没有因为搜索结果数量变化而复用错误组件。三类列表都使用内容对象的稳定 id 做 ForEach key:
ForEach(this.visiblePeople(), (item: Person, index: number) => {
this.personCard(item, index + 1);
}, (item: Person) => item.id)
ForEach(this.visibleEvents(), (item: HistoryEvent) => {
this.eventRow(item);
}, (item: HistoryEvent) => item.id)
ForEach(this.visibleArticles(), (item: ArticleRecord) => {
this.articleRow(item);
}, (item: ArticleRecord) => item.id)
这一点是搜索体验文章里容易被忽略的工程细节:如果只讲 filter(),读者会以为搜索结果能筛出来就结束了;但在 ArkUI 列表里,结果顺序和数量变化后是否稳定刷新,同样决定用户看到的人物卡、事件行、专题行是否可信。
因此本轮的完整验收命令调整为四组:
git status --short
node tools/check_csdn_article_quality.js 16
rg -n "export class Person|export class HistoryEvent|export class ArticleRecord" library1/src/main/ets/models/RecordsModels.ets
rg -n "visiblePeople|visibleEvents|visibleArticles|ForEach\\(this.visible" library2/src/main/ets/pages/MainFrame.ets
如果有真机或模拟器,再补交互验收;如果没有设备,至少要保证文章中的模型字段、过滤字段、渲染 key 和截图/链路图能互相解释。这样即使 CSDN V6.0 暂时只给到 85,文章也不会停留在“UI 经验总结”,而是具备读者可以照着源码复盘的工程闭环。
十六、小结
这篇文章的核心不是“ArkUI 怎么写一个输入框”,而是如何让一个本地知识 App 的搜索行为可解释:
- 用
searchKeyword保存输入态,用searchSubmitted区分提交态。 - 用
keyword()统一处理空格,避免三个过滤函数各自写边界逻辑。 - 人物、事件、专题共用同一个关键词,但不强行统一字段。
- 搜索结果按类型归因,空结果给出明确下一步提示。
- 热门词和推荐词必须来自真实内容字段,不能凭感觉写。
这套方案适合当前《耳畔三国·将星落》的本地内容规模。等内容量继续扩大,下一步就要把人物、事件、专题文章的目录能力进一步统一,让它们不仅能被搜索,也能被收藏、笔记和听书链路共同消费。
十七、下一篇衔接
下一篇继续沿着内容模型往下走,准备拆“从人物事件到专题文章的可听目录”。重点会放在 ArticleRecord 和本地内容模型扩展:同一篇专题文章如何进入列表、详情、收藏、笔记和 TTS 听书链路,避免每新增一种内容类型就复制一套页面逻辑。
更多推荐


所有评论(0)