ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenHarmony文本高亮:用字符串操作替代正则的稳定方案

OpenHarmony文本高亮:用字符串操作替代正则的稳定方案 在 OpenHarmony 上做文本高亮听起来是个小功能实际一碰就知道坑不少。通常的解法是正则替换或者往字符串里塞 HTML 标签但这两条路我走下来都不太稳。后来我用纯字符串操作重写了一遍——用indexOf找位置、用substring切片段——才彻底把这块做扎实。这个标记器本质上做三件事给定一段原文和一组关键词算出所有命中位置把原文切成普通段和高亮段交给 UI 分级渲染。难点不在最后一步而在第二步——如何在保留原文大小写、处理多个重叠关键词的前提下把切分逻辑写干净。下面我把整个过程拆开讲代码是基于 ArkTS 的OpenHarmony 工程里可以直接用。1. 项目拆解与方案选型为什么字符串操作更合适1.1 搜索标注场景到底要什么先说清楚这个模块要解决什么。它本质上是三个输入一个输出输入一段原始文本、一个关键词集合、一组渲染样式输出一段在 UI 上能区分命中区域和非命中区域的可视化文本。注意这里有个隐藏需求——原始文本不能变高亮只是展示层的操作。比如原文是OpenHarmony 支持多种分布式能力搜索harmony时要把Harmony这一个词标红同时保留原文大小写不能把原文改写成小写。这个场景在 OpenHarmony 应用里很常见搜索页面结果摘要、操作日志中的错误码标注、聊天记录过滤、电子书阅读器的关键词定位、设置项搜索匹配等。我自己遇到的最典型场景是结果摘要搜索服务端返回一大段摘要本地要把用户输入的关键词再标一遍避免在请求时拼 HTML。这不只是因为前端要做二次过滤很多时候是产品要求关键词是动态变化的不能依赖服务端。这类需求的特点有几个命中词数量通常不多个位数到十几个原文长度中等几十到几千字符关键词一般来自用户输入不可信对响应速度有要求需要在 UI 主线程毫秒级完成。围绕这些特点方案选择就有了边界不需要引入重型组件不需要解析 HTML 或富文本协议更不应该为了让一个简单的标注功能去引入复杂状态管理。1.2 为什么不用正则表达式做高亮很多人的第一反应是正则new RegExp(( keywords.join(|) ), gi)然后用replace把命中内容替换成带标记的字符串。我最早也是这么写的几分钟就出效果但用着用着就会发现它的问题集中爆发。第一个坑是特殊字符转义。关键词是用户输入的很可能包含[、(、*、$这类正则元字符。如果不转义匹配结果会完全错乱。写一个escapeRegExp函数不难但这意味着每次构造正则前都要遍历关键词做处理漏一个就翻车。第二个坑是正则回溯导致的性能不稳。动态拼接出来的正则如果关键词组合得刁钻在某些长文本上可能触发灾难性回溯界面直接卡死。这种问题在本地调试时很难复现一旦用户量上来就会变成偶发崩溃。第三个坑是拿不到精准位置信息。正则的捕获组信息拿来做高亮替换还可以但如果想区分多个关键词、想控制每个词的样式或者想拿到命中区间去实现前后文预览就会很难受。纯字符串操作方案把这三个问题都绕开了用indexOf查找时间复杂度基本可控没有解析器的复杂度主流程就是几个基础函数程序员一眼能看明白出问题也好排查。我个人观点是能用显式逻辑解决的问题就不要引入魔法。2. 核心算法设计定位、合并、切割三段式2.1 定位用 indexOf 把所有命中位置算出来第一步把人在文本里找词翻译成程序在字符串里找词。最基础的工具是String.prototype.indexOf(searchString, fromIndex)它从指定位置开始找找不到返回 -1。为了支持大小写不敏感我会先做一次小写化把原文和所有关键词都转成小写再在小写原文上做indexOf。这样做的关键是索引不会改变——toLowerCase()之后字符串长度不变绝大多数情况下所以小写字符串上的下标可以直接映射回原字符串最后用原字符串substring取值就能保留原文的大小写。伪代码大概是对每个关键词从头开始循环indexOf每找到一个就把[start, end)记下来然后把起始位置推进到end继续找下一个直到返回 -1。这里稍微提醒一句起始位置只能推进到end不能end 1。如果推进到end 1两个相邻或重叠的关键词命中会被漏掉。比如原文 OpenHarmony关键词 [open]命中区间是 [0,4)如果从 4 往后继续找就找不到同一段里的第二个 open 了。还好对单个关键词循环的场景推进到end已经够用。所有关键词都查完后会得到一个区间列表。这个列表在进入合并阶段之前不需要排序因为下一步统一处理。2.2 合并重叠区间归一避免重复高亮多个关键词之间经常出现重叠。比如关键词列表是 [OpenHarmony, Harmony]原文是 OpenHarmony那第一个关键词命中 [0,12)第二个关键词命中 [4,11)。如果不做处理后面切分时 [4,11) 这一段会再次被切出来导致同一个区域的文本被重复处理UI 层也可能出现同一个 Span 被渲染两遍的问题。我的做法很简单先把区间按start升序排序然后遍历如果当前区间的start小于等于上一个区间的end说明两者重叠或相邻就把两者合并end取较大值否则就直接开启新区间。这个逻辑处理完所有区间之间都有序且互不重叠切分就非常干净。这里有个细节值得讲相邻区间要不要合并比如关键词 [abc, def]原文 abcdef第一个区间 [0,3)第二个区间 [3,6)按我的判断条件start last.end会合并成 [0,6)。这对于 UI 展示来说是合理的两个关键词连在一起视觉上就是一整块高亮中间不会闪断。但如果你需要保留一条细缝只要把条件改成start last.end即可不过要小心相邻但不重叠的边界处理。我基本不会去抠这个合并后视觉更顺。2.3 切割用 substring 输出有序分段区间合并完成后就进入切分阶段。这一步的逻辑可以想象成拿着放大镜从左往右扫过原文用一个cursor游标记录当前扫描位置每遇到一个高亮区间先把cursor到区间起点之间的内容切成普通段再把区间本身切成高亮段最后把游标移动到区间结束位置。扫描完所有区间后如果游标还没到原文末尾剩下的部分作为最后一个普通段。每个段我用一个类来承载export class HighlightSegment { text: string; // 原始文本片段 isHighlight: boolean; // 是否为高亮段 }最终返回一个HighlightSegment[]其中普通段和高亮段是交替出现的。为什么返回数组而不是直接返回带 HTML 的字符串因为这样就把切分和渲染彻底解耦了。UI 层想用 Span 就用 Span想输出 HTML 就给 HTML想导出带 ANSI 颜色的终端文本也方便。这是我这套方案最想强调的设计字符串操作只负责结构不负责样式。下面给一个完整的 ArkTS 工具函数可以直接复制到工程里用export class HighlightSegment { text: string ; isHighlight: boolean false; constructor(text: string, isHighlight: boolean) { this.text text; this.isHighlight isHighlight; } } export function markKeywords(source: string, keywords: string[]): HighlightSegment[] { const result: HighlightSegment[] []; if (!source || !keywords || keywords.length 0) { result.push(new HighlightSegment(source, false)); return result; } const lowerSource source.toLowerCase(); const ranges: Array{ start: number; end: number } []; // 去重和过滤空关键词 const seen new Setstring(); for (let i 0; i keywords.length; i) { const kw keywords[i]?.trim(); if (!kw) { continue; } const lowerKw kw.toLowerCase(); if (seen.has(lowerKw)) { continue; } seen.add(lowerKw); let pos 0; while (pos lowerSource.length) { const idx lowerSource.indexOf(lowerKw, pos); if (idx -1) { break; } ranges.push({ start: idx, end: idx lowerKw.length }); pos idx lowerKw.length; } } if (ranges.length 0) { result.push(new HighlightSegment(source, false)); return result; } // 排序并合并重叠区间 ranges.sort((a, b) a.start - b.start); const merged: Array{ start: number; end: number } []; for (const r of ranges) { if (merged.length 0 || r.start merged[merged.length - 1].end) { merged.push({ start: r.start, end: r.end }); } else { const last merged[merged.length - 1]; last.end Math.max(last.end, r.end); } } // 按区间切割 let cursor 0; for (const m of merged) { if (m.start cursor) { result.push(new HighlightSegment(source.substring(cursor, m.start), false)); } result.push(new HighlightSegment(source.substring(m.start, m.end), true)); cursor m.end; } if (cursor source.length) { result.push(new HighlightSegment(source.substring(cursor), false)); } return result; }这段代码有几个可复用的小经验去重用的是Set避免同一个关键词重复参与匹配空字符串关键词直接跳过防死循环substring的区间是左闭右开end不用减一。pos推进在while里理论上如果关键词是空串会无限循环所以trim()之后的空判断非常关键。3. 在 OpenHarmony 工程里落地ArkUI 渲染全流程3.1 接入 ArkTS 的组件与状态管理工具函数写完之后剩下的工作就是把它接到 UI 上。在 OpenHarmony 的 ArkUI 框架里渲染多段不同样式文本最合适的组件是TextSpan。Text可以内嵌多个Span每个Span有独立的fontColor、fontSize、fontWeight等属性这天然就是我们需要的分段渲染能力。具体接入方式在页面里用State定义两个变量一个存原始文本一个存切分结果State private segments: HighlightSegment[] [];在onPageShow或者搜索回调里调用工具函数this.segments markKeywords(this.resultText, this.searchKeywords);然后渲染Text() { ForEach(this.segments, (seg: HighlightSegment) { if (seg.isHighlight) { Span(seg.text) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor(#E84026) } else { Span(seg.text) .fontSize(16) .fontColor(#333333) } }, (seg: HighlightSegment, index: number) index.toString()) }这样写有个隐形好处普通文本和高亮文本的基线、行高是统一的因为它们在同一个Text容器里。我之前见过有人图省事把高亮词拆成单独的Text组件再横向排列结果出现行高不一致的情况特别是当高亮词包含换行时槽位参差不齐。用Span从根上规避了这个问题。3.2 如果非要输出 HTML转义不能省有的场景绕不开 HTML比如你用的是RichText组件或者要把高亮结果提交给 Web 端。这时候可以从同一个markKeywords函数出发输出一个带span标签的字符串。但有个致命问题一定要处理原始文本里的、、必须先转义否则会影响 HTML 结构。我之前踩过一个特别典型的坑搜索结果里有条日志内容包含[ERR] build: failed这样的一行搜索ERROR时RichText直接把这行当标签解析了页面瞬间错乱。后来我强制做了 HTML 转义把原文换成lt;、换成gt;、换成amp;。这里注意高亮段和普通段都要转义不能只转义普通段。转义完成后再给高亮段包一层span stylecolor:...才安全。function escapeHtml(input: string): string { return input .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); }有同学会问这不又用正则了吗没错replace内部确实有正则的影子但这只是转义工具不是匹配逻辑不存在用户输入构造动态正则的风险。它处理的是固定替换模式安全性没问题。当然你要洁癖到零正则也可以自己写循环替换不过意义不大。3.3 为什么不直接用 RichText 包打天下既然有RichText为什么我还强调用Span我实测下来的体感是RichText在不同 API 版本上对标签样式支持的完整度差异比较大。它能解析基础标签但当你希望拿到精确的命中段来控制点击事件或自定义字体时会发现它像一个黑盒你没法在某个span上单独注册点击回调也没法精确知道当前渲染到了哪一段。这不是RichText的设计目标它更适合展示服务端下发的富文本而不是做本地交互式标注。反过来Span方案的所有数据都是我们自己切出来的数组每一段的text、isHighlight都是显式的。想做点击跳转给高亮段包个onClick就行。想做默认色、高亮色切换改一下fontColor的条件判断。这种看得见摸得着的感觉在开发效率和排查问题时的体验是RichText给不了的。4. 性能边界与隐藏陷阱4.1 性能实测十万级文本也能稳住先说结论在典型场景下这套纯字符串操作方案的性能完全够用甚至可以说游刃有余。复杂度上indexOf本身的时间复杂度跟待查找字符串的字长相关。我们循环查找每个关键词相当于对每个关键词从头到尾扫描一次原文最坏复杂度约O(n * m)其中n是原文长度m是关键词总长度。我实际测试过一段 10 万字符的日志文本配 20 个随机关键词单次标注耗时在几毫秒到十几毫秒之间肉眼完全感知不到。真正到上百万字符级别时indexOf内部的优化包括快速跳过也能扛住因为用户不太会在 UI 主线程直接渲染上百万字符的文本。如果某天你真的遇到性能瓶颈——比如要做全文检索、上千个关键词就要考虑升级数据结构。常见方案是用Trie或Aho-Corasick自动机把多关键词匹配的复杂度降下来。但这是另一个话题了绝大多数场景用基础indexOf就够。过早优化是折腾自己的常见原因先把功能跑通让 Profile 数据说话。4.2 大小写与 Unicode 的隐藏裂坑前面提到用toLowerCase()做大小写不敏感匹配。这里有一个比较偏的坑toLowerCase()对绝大多数拉丁字符是一对一映射长度不变索引可以安全映射但对个别 Unicode 字符小写化后长度会变长。比如İU0130拉丁大写 I 带一点在部分实现下toLowerCase()会变成i̇两个字符。一旦碰到这种字符小写字符串的下标就和原字符串对不上了高亮位置会整体错位。实用建议是如果你的产品主要处理中文、英文、数字直接用toLowerCase()没问题如果是多语言场景更稳妥的方案是放弃大小写不敏感这个需求或者在边缘情况做特殊处理。这不是我自己发明的结论是 Unicode 大小写映射的客观行为。实际开发中中英文场景占绝对大头所以我在项目里保留了这个坑但没为它做过度设计。另一个容易被忽视的是换行符。原文里如果包含\nindexOf查找时换行就是一个普通字符不影响命中Span 渲染时换行也正常生效。但如果你把结果输出成 HTML 再丢给 RichText\n在 HTML 里会被压缩成空格高亮段和非高亮段的换行位置可能全部变形。这是为什么我更推荐 Span的又一个理由。4.3 多关键词的优先级与不同颜色前面合并区间用的是取大不取小一视同仁地把所有关键词命中并成一个整体。如果产品需求是第一个关键词用红色第二个关键词用蓝色那合并逻辑就不能偷懒了。我这里再给一个扩展思路合并时候重叠区间不是简单取大end而是要根据关键词的优先级决定谁覆盖谁。最朴素的做法是给每个区间打上关键词标记在合并时当start相同、重叠时优先级高的关键词的区间压过优先级低的。设计取舍上我的建议是先想清楚产品是否需要区分关键词再决定是否上复杂度。大部分搜索场景只要命中高亮即可区分颜色反而会让页面花里胡哨。如果一个需求确实要求区分那也不要绕开区间重叠处理直接在HighlightSegment里多加一个字段keywordIndex在渲染时查表取颜色。扩展成本其实很小。5. 常见问题与排查技巧实录5.1 高亮标签被当作文本显示出来症状用 Span 渲染一切正常但有人一不小心走了RichText路线结果页面上直接显示出了span源代码。原因很简单你给RichText传入的字符串里的span标签没有被作为 HTML 解析。排查方向确认组件用的是RichText而不是Text——Text默认会把 HTML 标签当普通文本渲染这是两者最容易混淆的地方。如果你一定要在Text里实现分段高亮就用Span子组件别指望它解析标签。5.2 相邻关键词被合并成一个大高亮块如果两个关键词本来不想连在一起但因为相邻合并逻辑把它们连成了整块看起来就像吞掉了中间的空格或符号。这种情况多半是start last.end中的等号导致的。把条件改成r.start last.end即可但注意这样会带来新的边界区间 [0,3) 和 [3,6)也就是完全相邻但中间没有字符按新逻辑不会合并切分后是两个独立高亮段中间没有任何间隙。如果你希望在视觉上拉开距离可以考虑在普通段里强制拼一个空格但我不推荐这种方式因为会污染原文数据。5.3 大小写匹配时高亮偏移如果你发现高亮的是har而不是Harm同时原文里刚好有特殊 Unicode 字符先怀疑是不是toLowerCase改写了字符串长度。最简单的验证方法打印一下lowerSource.length和source.length如果长度不一致基本就是这类边缘问题。中英文场景可以直接忽略多语言场景建议先做字符级匹配或者弃用大小写不敏感。5.4 关键词为空字符串导致卡死这是最容易踩却最隐蔽的 bug用户输入一个空关键词或者关键词本身就是全角空格indexOf()会直接返回0然后while循环永远推进不了界面直接卡死。解决方案就是进入循环前做trim()和空值判断我在示例代码里已经写上了。这个检查看着不起眼实际上能避免一次严重的线上事故。建议不仅在工具函数里做在 UI 层调用前也做一次双保险。5.5 高亮结果在列表复用中串色在List或ForEach中渲染多个结果项时如果把segments数组直接存在每个 item 对象里并复用了组件状态有可能出现上一页的高亮样式残留在下一页的情况。原因是State数组的引用没有更新或者keyGenerator生成重复 key 导致组件复用异常。我的经验是给每个列表项的ForEach提供一个稳定唯一的 key并且在搜索回调里保证segments是全新数组而不是对旧数组的原地修改。markKeywords内部每次都新建result数组天然满足这个要求。6. 实操心得这套方案的扩展玩法6.1 从命中区间直接做摘要截取搜索结果往往只显示前后若干字传统做法是先截取再高亮高亮容易切到一半。用我的方案可以先对全文做markKeywords得到所有命中区间然后按区间前后展开一定长度截取拼接这样高亮词一定完整出现在摘要里。这个功能只需要在合并后的merged数组上多做一步区间扩展和二次切割代码量很小。具体思路假设摘要前后各展示 50 个字符拿到merged区间后对每个区间做start - 50到end 50的截取再把截取片段拼起来中间用省略号隔开。注意截取时不能把半个代理对切坏不过 OpenHarmony 上的字符串接口在这一步通常够用中文场景尤其轻松。6.2 从搜索框到日志诊断的复用这个函数不只服务于搜索页。我在调试网络请求时会把返回的关键字段订单号、错误码、签名串用这个函数高亮后输出到日志面板肉眼定位问题快很多。这种内部调试功能不需要 UI 层介入直接输出带 ANSI 转义码的字符串终端里就能看到颜色。另外把关键词按空格拆成多个子词分别高亮再对每个子词做模糊匹配比如去掉末尾助词、在关键词中间允许一个错字在高亮层体现出部分命中这也能以字符串操作为基础叠加上去。文本高亮就这么点事但用对方案之后它的扩展空间其实比想象中大。写在最后的一点个人体会我见过太多项目为了一个看起来不难的功能引入了正则、富文本解析、自绘 Canvas最后维护成本远超收益。纯字符串操作之所以值得推荐不是因为它有多炫技而是它把复杂度摊平了——定位就是indexOf切割就是substring渲染就是Span每一步都能讲清楚出了问题都知道去哪里看。这个可控感是很多高级方案给不了你的。如果你正在 OpenHarmony 上做搜索页或标注功能不妨先跑一遍这套逻辑它大概率能帮你把第一版稳稳落地。
返回列表