news 2026/10/2 4:29:14

纯字符串操作实现文本关键词高亮:从扫描到Span渲染的全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯字符串操作实现文本关键词高亮:从扫描到Span渲染的全流程解析

做社区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字段,渲染时根据索引去颜色表里取色即可,核心扫描和合并算法一行都不用改。这种通过数据字段承载展示差异的设计,比写一堆分支条件灵活得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 4:28:55

Python statistics模块全面教程:均值、中位数、方差与回归分析

Python入门&#xff1a;Python3 statistics模块全面学习教程手上有一堆数字&#xff0c;比如一个班的期末成绩、门店一周的销售流水&#xff0c;或者传感器采回的温湿度值&#xff0c;你想快速算个平均分、中位数、方差&#xff0c;看看数据集中程度。多数人的第一反应是打开Ex…

作者头像 李华
网站建设 2026/10/2 4:28:50

无侵入APM怎么选?SkyWalking统一指标、日志与链路的实战经验

1. 被指标、日志、链路三张皮折磨之后&#xff0c;我为什么选了 SkyWalking1.1 故障定位等于做拼图&#xff0c;这才是监控最大的内耗做后端开发这几年&#xff0c;团队最痛苦的不是系统不稳定&#xff0c;而是每次线上出问题时&#xff0c;都在几个完全割裂的系统里来回横跳。…

作者头像 李华
网站建设 2026/10/2 4:28:40

OpenPlanner:开源TSN规划器解决时间确定性落地难题

简介&#xff1a;OpenPlanner是一个面向工业物联网与实时通信领域的开源TSN&#xff08;时间敏感网络&#xff09;规划器&#xff0c;主要服务于嵌入式系统工程师、网络协议开发者及算法研究人员&#xff0c;解决TSN网络中确定性调度难、时序保障弱等核心问题&#xff0c;适用于…

作者头像 李华
网站建设 2026/10/2 4:28:33

PHP8.5配置单元测试Mock数据怎么生成

前言写单元测试时最典型的几种症状&#xff1a;测试跑一次绿、跑两次红&#xff0c;因为造的数据里带随机值&#xff1b;一个"单元测试"启动了半天&#xff0c;因为它偷偷连了数据库和第三方支付接口&#xff1b;以及 mock&#xff08;模拟对象&#xff09;写了一大堆…

作者头像 李华
网站建设 2026/10/2 4:28:18

Simscape四旋翼可视化仿真搭建方法、避坑要点与控制器对接完整指南

从0搭建四旋翼Simscape可视化仿真&#xff1a;一次踩坑记录与完整方案先说个情境&#xff0c;很多刚接触四旋翼仿真的同学都会经历这个过程&#xff1a;PID调了半天&#xff0c;曲线图里姿态角变化很漂亮&#xff0c;但永远不知道自己控制的这个“抽象积分器”长什么样。我自己…

作者头像 李华
网站建设 2026/10/2 4:28:18

现代C++宏定义实战:从预处理原理到企业级应用

这几年写C项目&#xff0c;从嵌入式裸机到后端服务&#xff0c;宏定义算是我用得最多也最谨慎的一个工具。不少人对宏的印象停留在“用#define替换一个常量”&#xff0c;但真正到了企业级项目里&#xff0c;宏要承担的东西远不止这些——它可以是代码生成器、编译期分支开关、…

作者头像 李华