做社区App的搜索结果页时,我遇到了一个看似简单、实际坑不少的需求:把用户输入的搜索关键词在结果文本里标成醒目的颜色。第一反应是上正则,一行replace换标签,或者直接用 RichText 组件渲染 HTML。结果试了一圈发现,OpenHarmony 这边的富文本方案远没有想象中顺手,正则的转义和性能问题也让人头疼。最后我干脆绕开所有花活,只用字符串操作函数(indexOf、slice、toLowerCase 这些)从头写了一个文本高亮关键词标记器,效果反而最稳。
这个标记器的核心思路很简单:扫描原文,找出所有关键词出现的位置,合并重叠区间,再按区间切片,用 Text + Span 逐段渲染。它不依赖任何三方库,不依赖正则,也不用 HTML 解析器,纯 ArkTS 就能落地。如果你正在做 OpenHarmony 应用,涉及搜索高亮、敏感词标注、笔记标签这类功能,或者单纯想看看怎么用最朴素的字符串操作组合出“智能”效果,这篇文章值得花几分钟读完。
1. 项目整体设计与思路拆解
1.1 这个标记器解决什么问题
文本高亮听起来是小事,实际场景里面到处都用得上。搜索结果页要把命中的关键词标红,聊天界面要把敏感词标出来提醒用户,阅读器里用户划线的词语需要突出显示,编辑器里甚至要做简单的语法着色。这些功能的底层逻辑是一致的:给你一段原文和一个关键词列表,把原文中被命中的部分标记出来,其余部分保持原样。
我最初的版本是给搜索结果做高亮,关键词通常只有一个。后来产品经理加需求,说聊天消息里出现的多个敏感词要一起标出来,而且不区分大小写。这一下就从“找一次”变成了“找多次”,还得处理多个关键词相互重叠、重复命中的情况。如果只针对单一关键词硬编码,代码会越写越丑,所以我干脆把逻辑收敛成一个独立的标记器模块,输入原文和关键词数组,输出可渲染的分段结果,UI 层只负责把分段结果映射成 Span。
这个设计本质上是在做“内容与展示分离”:标记器只产出数据结构,不关心最终渲染成红色还是绿色、加粗还是下划线。后续想调整样式,或者从 Text 组件换成 RichEditor,业务代码完全不用动。
1.2 技术选型:为什么放弃正则和 RichText
做关键词高亮,常见方案有三条路。第一条是正则表达式,用new RegExp(keyword, 'g')配合replace给命中文本加特殊标记;第二条是走富文本组件,OpenHarmony 里有 RichText 可以解析简单的 HTML 字符串;第三条就是本文用的 Text + Span + 字符串扫描。我最终选了第三条,原因很实际。
正则的问题在于转义和性能。关键词是用户输入的,可能包含.、*、(、)这些正则元字符,每次都得先做一层转义。转义本身不难,但转义完了,正则表达式的可读性就崩了。而且正则回溯在极端情况下会出现性能抖动,在低端设备上尤其明显。更麻烦的是,正则无法直接给出“位置信息”,你虽然能用匹配结果拿到 index,但多关键词多次 replace 时,位置容易乱。
RichText 组件的问题在于控制粒度。它接受 HTML 字符串,但样式是通过标签控制的,关键词和普通文本混在一起,样式切换、点击事件绑定都不够灵活。在 ArkUI 的声明式体系里,Text组件内部的Span子组件才是更原生、更可控的方式。每个 Span 可以独立设置颜色、字号、字重,还能绑定点击事件,渲染性能和原生文本一样好。
三种方案对比下来,选择就很清晰了:
| 方案 | 实现成本 | 灵活度 | 性能 | 坑点 |
|---|---|---|---|---|
| 正则替换 | 低,几行代码 | 中,但转义麻烦 | 中,极端情况回溯 | 元字符转义、多关键词位置错乱 |
| RichText 组件 | 中 | 低,样式依赖标签 | 中,需解析 HTML | 事件粒度粗、样式能力受限 |
| Text + Span + 字符串扫描 | 偏高,核心算法要写 | 高,完全可控 | 可控,纯线性扫描 | 需处理区间合并、边界条件 |
一句话总结:如果只是 Demo 演示,正则最快;如果是正规功能,Text + Span + 字符串扫描最稳。这个选择的本质是用一点点算法复杂度,换取长期的维护确定性和渲染灵活性。
1.3 整体架构与数据流
标记器的架构分四层。第一层是输入预处理,对关键词数组做去重、去空、trim,避免脏数据干扰后续算法。第二层是区间扫描,对每一个关键词,在原文中找到所有出现的起止位置,保存成MatchRange结构。第三层是区间合并,把所有关键词产生的区间按起始位置排序,再合并交叠的部分,得到一个不重叠的区间列表。第四层是切片生成,根据合并后的区间,把原文切成若干段,每段标记是否为关键词,最终输出一个HighlightSegment数组。
数据流是单向的:原始文本 + 关键词列表 → 预处理 → 扫描 → 合并 → 切片 → 渲染。每一层只做一件事,方便单独测试。比如合并逻辑出错,可以单独喂一组区间进去验证;切片结果不对,单独打印每一段的文本和标记位就行了。这种清晰的分层结构,比一大坨for循环嵌套到底的写法好排查得多。
2. 核心字符串操作细节与算法解析
2.1 用 indexOf 扫描关键词位置
字符串扫描的起点是indexOf。这个函数在 ArkTS 里和 JavaScript 行为一致,返回子串第一次出现的位置索引,找不到就返回 -1。我们可以利用它的第二个参数fromIndex实现连续扫描:第一轮从索引 0 开始找,找到后把查找起点挪到命中位置的末尾,再继续找下一处,直到找不到为止。
这里有个容易踩的坑:很多人的第一反应是循环里用indexOf,找到之后把fromIndex设成index + 1。这样做会漏掉邻接的匹配。假设原文是“aaa”,关键词是“aa”,第一次命中[0, 2),如果只把起点加 1,第二次查找会从索引 2 开始,结果就漏掉了[1, 3)。正确的做法是从index + keyword.length继续扫,也就是沿着字符串向前移动,不做重叠匹配。
非重叠扫描在实际业务里更合理。关键词本身是一个个独立的词,命中的位置重叠太多反而会让高亮区域糊成一片。设计成非重叠之后,每个关键词的匹配次数也就被限制在文本长度除以关键词长度以内,扫描的复杂度严格可控。
扫描部分的代码逻辑大概是这样的:
private static findKeywordRanges( searchSource: string, searchKeyword: string ): MatchRange[] { const ranges: MatchRange[] = []; const keywordLength = searchKeyword.length; let cursor = 0; while (true) { const index = searchSource.indexOf(searchKeyword, cursor); if (index === -1) { break; } ranges.push({ start: index, end: index + keywordLength }); cursor = index + keywordLength; } return ranges; }每次命中都会得到一个半开区间[start, end),start 是关键词开头的位置,end 是结尾位置的后一位。这个区间语义在后续切片时非常顺手,因为slice(start, end)拿到的正好是关键词本身。
2.2 区间排序与合并
单关键词扫描之后,你会得到一堆分散的区间。多个关键词扫描完毕,问题就来了:区间之间可能交叉、嵌套、或是相邻。比如原文是“OpenHarmony 开源操作系统”,关键词列表里有“OpenHarmony”和“系统”,两个区间一个在开头一个在结尾,互不影响;但如果关键词列表里同时有“OpenHarmony”和“Open”,第二个关键词命中的区域完全落在第一个区间内部,不合并的话,同一个文本片段会被重复切分,渲染出来的 Span 会异常。
合并算法其实不复杂:先把所有区间按 start 升序排列,start 相同时按 end 升序;然后顺序遍历,维护一个当前区间,如果下一个区间的 start 小于等于当前区间的 end,就说明两者交叠或相邻,把当前区间的 end 扩展成两者中较大的 end;否则说明当前区间已经独立,收进结果,开始处理下一个。
这个合并逻辑要处理的一个重要细节是:排序必须先行。不排序直接两两比较,会出现前面区间和后面区间交叠,但后面区间的 start 反而小于前面区间的 start,这时候合并结果就会错。排序后,遍历时当前区间的 start 永远是递增的,合并条件就简化为只比较 end。
private static mergeRanges(ranges: MatchRange[]): MatchRange[] { if (ranges.length === 0) { return []; } const sorted = ranges.slice().sort((a, b) => { if (a.start !== b.start) { return a.start - b.start; } return a.end - b.end; }); const merged: MatchRange[] = []; let currentRange = sorted[0]; for (let i = 1; i < sorted.length; i++) { const nextRange = sorted[i]; if (nextRange.start <= currentRange.end) { currentRange = { start: currentRange.start, end: Math.max(currentRange.end, nextRange.end) }; } else { merged.push(currentRange); currentRange = nextRange; } } merged.push(currentRange); return merged; }注意:slice()必须先拷贝一份数组再排序,不能直接sort原始数组。因为sort是原地排序,会破坏调用方持有的数组顺序,后续如果还要基于原顺序做分析,就会得到错误结果。
2.3 大小写不敏感与中英文混合场景
搜索场景里,“OpenHarmony”和“openharmony”通常被视为同一个词。所以扫描时不区分大小写是刚需。实现思路很朴素:把原文和关键词都通过toLowerCase()转成小写,在小写文本上进行indexOf扫描,得到位置信息后,回原文本按位置切片展示。
这样做的效果是:匹配逻辑用的是小写文本,展示内容仍然来自原文,大小写形态完全保留。例如原文是“OpenHarmony is great”,关键词“openharmony”,扫描时会命中索引 0 到 13,切片时从原文拿到的还是“OpenHarmony”,展示出来就是原样的大写开头。
中文没有大小写概念,这个方案对中文完全无副作用,反而因为统一走小写逻辑,不用为中文写特殊分支。唯一需要注意的是,toLowerCase()对某些特殊 Unicode 字符会改变字符串长度。最经典的例子是德语ß,转大写后会变成SS,长度从 1 变成 2。如果文本里混入这种字符,小写文本里的位置和原文的位置就对不上了,切片会把错误的字符切进去。绝大多数中文 App 场景不会遇到这个问题,但如果你做的是国际化版本,就要多留一个心眼。
一个更稳妥的长远方案是逐字符归一化匹配,或者用双数组双向映射记录位置变化。但在当前的项目阶段,我选择直接使用toLowerCase加文档注释,把限制写清楚,而不是过度设计。
2.4 按最终区间切片原文
区间合并结束后,就到了真正产出分段结果的环节。这一步的逻辑是:从头开始遍历合并后的区间数组,维护一个当前游标current。对每个区间[start, end),先看current到start之间有没有原文片段,有就标记为普通文本;然后切片start到end的文本,标记为关键词;最后把current移到end。循环结束之后,再检查current是否已经走到原文末尾,没走完就把剩下部分标记为普通文本。
这个算法可以处理一个边缘情况:多个区间之间有大量普通文本,切出来的普通片段可能非常长。比如一段 800 字的文章只命中了两个词,普通文本片段就有几百字。这没关系,Span 本身就是整段渲染的,长普通文本拆成一个大 Span 和拆成多个小 Span,视觉上没区别,但大 Span 的渲染效率和内存占用更优。
切片时我推荐用slice而不是substring。slice和substring在非负索引的情况下效果一致,但slice支持负数索引,语义与数组切片一致,代码读起来更自然。日常写字符串处理,把slice作为默认切片函数,能少记一套差别。
3. 完整实战:项目代码与运行效果
3.1 核心类 HighlightMarker
把所有逻辑收敛成一个静态工具类,大概是下面这样。它对外只暴露一个入口方法buildSegments,入参是原文、关键词列表、是否忽略大小写,出参是分段数组。
// HighlightMarker.ets export interface HighlightSegment { content: string; isKeyword: boolean; } interface MatchRange { start: number; end: number; } export class HighlightMarker { static buildSegments( source: string, keywords: string[], caseInsensitive: boolean = true ): HighlightSegment[] { const segments: HighlightSegment[] = []; if (!source || !keywords || keywords.length === 0) { segments.push({ content: source, isKeyword: false }); return segments; } // 1. 清理关键词:trim、去空、去重 const cleanedKeywords: string[] = []; for (const item of keywords) { const keyword = item.trim(); if (keyword.length > 0 && cleanedKeywords.indexOf(keyword) === -1) { cleanedKeywords.push(keyword); } } if (cleanedKeywords.length === 0) { segments.push({ content: source, isKeyword: false }); return segments; } // 2. 统一小写,降低重复转换开销 const lowerSource = caseInsensitive ? source.toLowerCase() : source; const allRanges: MatchRange[] = []; for (const keyword of cleanedKeywords) { const lowerKeyword = caseInsensitive ? keyword.toLowerCase() : keyword; const searchSource = caseInsensitive ? lowerSource : source; const ranges = HighlightMarker.findKeywordRanges(searchSource, lowerKeyword); for (const range of ranges) { allRanges.push(range); } } // 3. 合并重叠区间 const mergedRanges = HighlightMarker.mergeRanges(allRanges); // 4. 切片生成片段 let current = 0; for (const range of mergedRanges) { if (range.start > current) { segments.push({ content: source.slice(current, range.start), isKeyword: false }); } segments.push({ content: source.slice(range.start, range.end), isKeyword: true }); current = range.end; } if (current < source.length) { segments.push({ content: source.slice(current), isKeyword: false }); } return segments; } private static findKeywordRanges( searchSource: string, searchKeyword: string ): MatchRange[] { const ranges: MatchRange[] = []; const keywordLength = searchKeyword.length; let cursor = 0; while (true) { const index = searchSource.indexOf(searchKeyword, cursor); if (index === -1) { break; } ranges.push({ start: index, end: index + keywordLength }); cursor = index + keywordLength; } return ranges; } private static mergeRanges(ranges: MatchRange[]): MatchRange[] { if (ranges.length === 0) { return []; } const sorted = ranges.slice().sort((a, b) => { if (a.start !== b.start) { return a.start - b.start; } return a.end - b.end; }); const merged: MatchRange[] = []; let currentRange = sorted[0]; for (let i = 1; i < sorted.length; i++) { const nextRange = sorted[i]; if (nextRange.start <= currentRange.end) { currentRange = { start: currentRange.start, end: Math.max(currentRange.end, nextRange.end) }; } else { merged.push(currentRange); currentRange = nextRange; } } merged.push(currentRange); return merged; } }这一段代码里有一个细节值得专门说:buildSegments开头对空参数的处理,会把原文原封不动地包成一个isKeyword: false的片段。这个兜底非常关键,因为在 UI 层调用时,你无法保证外部永远传一个合法的关键词数组,如果返回空数组,ForEach 就什么都渲染不出来,页面会空白。
3.2 在 ArkUI 页面中渲染高亮 Span
有了分段数组,UI 层就很简单了。在Text组件内部用ForEach遍历分段,根据isKeyword决定用高亮样式还是普通样式。高亮样式我一般用主题色加中等字重,普通样式用默认文本色,两者形成明显的视觉层级。
import { HighlightMarker, HighlightSegment } from './HighlightMarker'; @Entry @Component struct HighlightDemoPage { private content: string = 'OpenHarmony是一个开源项目,OpenHarmony 支持一次开发、多端部署。' + '开发者可以通过 ArkTS 快速构建应用,OpenHarmony 的生态正在快速发展。'; private keywords: string[] = ['OpenHarmony', '开源', 'ArkTS']; private segments: HighlightSegment[] = []; aboutToAppear(): void { this.segments = HighlightMarker.buildSegments(this.content, this.keywords, true); } build() { Column({ space: 16 }) { Text() { ForEach(this.segments, (segment: HighlightSegment) => { if (segment.isKeyword) { Span(segment.content) .fontColor('#E84026') .fontWeight(FontWeight.Medium) } else { Span(segment.content) .fontColor('#182431') } }, (segment: HighlightSegment) => segment.content + '_' + segment.isKeyword) } .fontSize(16) .lineHeight(26) .width('100%') Text(`识别到 ${this.segments.filter((item: HighlightSegment) => item.isKeyword).length} 个高亮片段`) .fontSize(14) .fontColor('#666666') } .padding(16) .width('100%') .alignItems(HorizontalAlign.Start) .backgroundColor('#F1F3F5') } }这里有两点必须注意。第一,ForEach的第三个参数是键值生成函数,必须保证返回字符串,而且最好唯一。我直接用segment.content + '_' + segment.isKeyword做键,但在极端情况下,如果原文里恰好有两段内容一样,且isKeyword也一样,键就会重复。更稳妥的做法是在HighlightSegment接口里加一个自增序号字段,用序号做键,彻底避免重复。第二,Text组件内只允许放Span和ForEach,千万不要混入其他组件,否则编译直接报错。
实际跑下来,高亮效果非常干净:关键词部分显示主题色,其余部分保持普通颜色,整段文本的换行和间距与原生Text完全一致,没有任何富文本渲染的白屏或字体跳动问题。
3.3 扩展:给高亮关键词绑定点击事件
高亮不只是为了看,很多时候是为了点。比如搜索结果里,点击高亮的命中词可以跳转到详情页;聊天敏感词场景,点击高亮词可以弹出提示说明。Span组件天然支持onClick,我们只需要在渲染isKeyword片段的Span上挂事件即可。
由于ForEach的闭包里能拿到当前segment,所以绑事件非常直接:
if (segment.isKeyword) { Span(segment.content) .fontColor('#E84026') .fontWeight(FontWeight.Medium) .onClick(() => { this.onKeywordClick(segment.content); }) }这里有个潜在的闭包陷阱:如果ForEach的迭代变量被后续复用,闭包里捕获的segment可能不是当前项。好在 ArkUI 的ForEach闭包每次迭代都会生成独立上下文,实测下来绑定结果是正确的。但为了保险起见,也可以在事件回调里通过传入的content再次匹配原始关键词列表,拿到完整的业务数据对象。
3.4 性能优化建议
这个实现是纯线性扫描,单关键词扫描一次原文,时间复杂度是O(n),多个关键词就是O(n * m),m 是关键词数量。实测下来,在 10000 字符的文本里扫描 10 个关键词,耗时在个位数毫秒,完全够日常业务使用。但有几个优化点可以提前埋好:
- 关键词预处理缓存:如果同一批关键词要反复高亮不同文本,可以把去重、小写转换这些结果缓存起来,避免每次重复计算。
- 限制高亮片段数量:在极端文本里,一个关键词可能出现几百次,产生几百个 Span。渲染上千个 Span 会导致帧率下降。可以在
buildSegments里加一个最大命中数参数,超过后只保留前 N 个命中,或者对后续命中做降级处理。 - 避免频繁调用 buildSegments:如果文本编辑框里每次输入都触发高亮,建议做 debounce 处理,用户停止输入 300 毫秒后再重新计算。
提示:ForEach 渲染大量 Span 时,键值生成函数非常重要。如果键值不稳定,组件会反复创建和销毁,反而比不优化更卡。
另外一个容易被忽略的点是:buildSegments每次返回的数组都是新对象,如果在build方法里直接调用,会在每次组件刷新时重新计算。对于静态内容影响不大,但如果是滚动列表里的项,建议在aboutToAppear或者列表数据源里预先计算好,渲染时直接使用。
4. 常见问题与排查技巧实录
实现过程中我踩了不少坑,也帮同事排查过类似的代码问题,整理成速查表相当有必要。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 页面直接白屏,没有任何文本 | 关键词数组为空导致分段结果为空 | 空关键词时包一个isKeyword: false的完整原文片段 |
| 关键词没被高亮,但普通文本渲染正常 | 关键词包含首尾空格,匹配失败 | 扫描前对关键词做trim() |
| 同一个词高亮了两遍,且中间文本也被高亮 | 重复关键词未去重,区间未合并 | 关键词去重 + 合并重叠区间 |
| 高亮位置整体错位,越到后面越偏 | 特殊 Unicode 字符大小写转换导致长度变化 | 对可疑文本用length校验,或限制必须用原始大小写 |
| 滚动列表滑动卡顿 | 每个 Item 重新计算分段数组,且 Span 数量过多 | 预计算分段结果,限制最大命中数 |
这几个问题里,我实际遇到最多的是前两个,单独展开说一下。
4.1 空关键词导致死循环
这是字符串扫描最容易出的事故。indexOf('', cursor)对任意cursor都会返回cursor本身,因为空字符串在任何位置都能匹配。如果你忘记了过滤空关键词,扫描循环就会陷入死循环:找到的位置永远是cursor,cursor更新后还是原来的位置,while永远跳不出来。虽说不至于把设备搞死机,但页面会彻底卡住,非常吓人。
解决方式是在预处理阶段就彻底干掉空字符串。我用了trim()加length > 0双重判断,顺手把全空格字符串也过滤了。这个过滤必须在扫描之前做,否则等于没过滤。
4.2 重复关键词造成零碎片段
假设关键词列表是['Harmony', 'Harmony'],两遍扫描会产生两组完全相同的区间。如果区间不合并,切片阶段会把同一个位置切成两遍,产生一个关键词片段、一个普通片段、又一个关键词片段,展示效果看似正常,但多了一堆无意义的空片段,还会干扰点击事件的绑定逻辑。
解决方案是去重和合并一起做。去重解决的是“完全一样”的问题,合并解决的是“部分重叠”的问题。我见过只去重不合并的代码,遇到['OpenHarmony', 'Open']这种关键词组合时,依然会出现区间嵌套,所以这两步一个都不能少。
4.3 ForEach 渲染空 Span 的问题
即使算法正确,也可能出现空字符串入数组的情况。比如区间start === end时,切片得到的content是空字符串,Span 渲染空字符串通常不会报错,但会在部分设备上产生一个多余换行或空白占位,导致行高异常。
最保险的做法是在buildSegments的最后一步,给每个片段加一个content.length > 0的判断,空片段直接跳过不 push 进结果数组。这个防御性代码成本极低,能省掉很多后续排查时间。
4.4 大小写转换引发长度偏移
前面提过toLowerCase()可能改变字符串长度。如果只是中文和普通英文,完全碰不到这个问题。但如果你做的是面向海外的应用,或者用户文本由输入法自动生成了一些特殊字符,就要小心了。定位方法也简单:在buildSegments里临时打印source.length和lowerSource.length,如果发现两者不一致,说明文本里有长度会变的字符,此时就需要走逐字符匹配路线,或者至少对这部分文本降级为不区分大小写的方案。
4.5 长文本下的卡顿与排查方法
拿一万字的文章做高亮,生成几百个 Span 在部分中低端设备上确实会掉帧。排查性能问题时,我一般先在buildSegments前后打点,用console.time记录扫描和切片耗时:
console.time('highlight-marker'); const segments = HighlightMarker.buildSegments(this.content, this.keywords, true); console.timeEnd('highlight-marker');如果耗时在 10 毫秒以内,瓶颈就不在算法,而在渲染层。此时优先看 ForEach 的键值生成函数,以及 Span 的数量。如果耗时超过 50 毫秒,多半是关键词数量太多,比如上百个词做敏感词库扫描,这时需要引入更高效的多模式匹配算法,比如 Aho-Corasick 自动机,但这就超出纯字符串操作的范畴了。
我个人在实际开发里的体会是:Mark II 版本的“关键词 + 颜色 + 点击事件”模型,已经覆盖了搜索、标注、敏感词三类常见的业务场景。如果后续要支持“每个关键词不同颜色”的精细控制,只需在HighlightSegment里加一个可选的keywordIndex字段,渲染时根据索引去颜色表里取色即可,核心扫描和合并算法一行都不用改。这种通过数据字段承载展示差异的设计,比写一堆分支条件灵活得多。