本系列记录一个 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,所有人物、事件、专题数据都在包内或本地模型中,目标是先做到一个可解释、可验收、可维护的本地搜索闭环。

本轮目标有四个:

  1. 输入框只保存用户正在输入的关键词,不把空格当成有效查询。
  2. 点击键盘搜索或按钮搜索后,页面能给出明确的结果数量反馈。
  3. 人物、事件、专题三类内容使用同一个关键词,但保留各自的数据字段差异。
  4. 没有结果时显示明确空状态,告诉用户可以换人物、事件或势力关键词。

当前实测边界:

  • 工程环境: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() 里完成。这里有两个触发点:onChangeonSubmitonChange 代表用户还在编辑,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

输出为空,表示当前没有可用真机或模拟器目标:


因此本文没有现场补拍“搜索有结果”和“搜索无结果”的真机截图,而是使用既有真实首页截图加状态链路图解释实现。后续如果在真机上复核,建议补充两个输入样例:

输入样例 预期表现 重点观察
曹操 人物、事件、专题至少一类有结果 结果总数和分组数量是否一致
不存在的关键词 展示空结果卡片 是否出现清除入口,默认列表能否恢复

十、问题复盘:热门词不能拍脑袋

搜索入口上线后,最容易加的功能是“热门搜索词”。但对当前项目来说,热门词不能拍脑袋写几个看起来常见的词。原因有三个:

  1. 热门词如果来自运营想象,很可能和真实数据字段不匹配,点击后反而无结果。
  2. 当前过滤字段包含姓名、势力、称号、简介、事件关联人物、专题标签,热门词应该从这些字段中抽样。
  3. 本地 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

当前工程的相关片段集中在同一个页面文件中,维护成本可控。如果后续把人物、事件、专题拆到独立组件,建议把 keywordsubmittedclearSearch() 继续留在首页容器层,子组件只接收已经过滤后的数据,不再各自保存一份搜索状态。

十二、工程验收清单

这类搜索交互建议按“状态、数据、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.nameHistoryEvent.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 层的全部渲染逻辑。

第三步才是把字段映射到过滤函数。当前三类内容的命中字段并不相同:

内容类型 命中字段 不能混用的原因
人物 namefactiontitlesummary 用户可能搜姓名、势力、称号或人物简介关键词
事件 titlesummaryrelatedPeople,并叠加 selectedCategory 事件列表既有分类上下文,也有关联人物
专题 titlecategorysummarytags 专题文章更依赖分类和标签召回

这张表可以直接用下面的命令复核:

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()));

最后还要复核渲染层没有因为搜索结果数量变化而复用错误组件。三类列表都使用内容对象的稳定 idForEach 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 听书链路,避免每新增一种内容类型就复制一套页面逻辑。

Logo

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

更多推荐