ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter for OpenHarmony 实战:正则测试器从环境搭建到渲染性能优化

Flutter for OpenHarmony 实战:正则测试器从环境搭建到渲染性能优化

一个做开发助手类工具的朋友跟我聊起,说他在 OpenHarmony 设备上调正则特别痛苦,手机上没有趁手的测试工具,电脑上写完规则又没法直接拿到真机环境验证。我当时正在用 Flutter 给某跨端引擎组做内部工具,就顺手把“正则表达式测试器”做成了一个独立模块。做完之后发现,这个看似不起眼的小功能,反而是验证 Flutter 在 OpenHarmony 上渲染、输入、文本处理能力的最好试金石。这篇文章就把完整实现过程拆开讲一遍,包括环境搭建、核心架构、高亮渲染、分组捕获,以及我在真机上踩过的一串兼容性坑。如果你正准备在 OpenHarmony 上用 Flutter 做工具类 App,或者只是想知道正则测试器这类工具到底怎么实现,这篇应该能给你省不少时间。

1. 为什么拿“正则测试器”给 Flutter for OpenHarmony 探路

很多人在接触 OpenHarmony 应用开发时,第一反应是找一套现成的 UI 框架或者直接上原生 ArkUI。但我的场景不太一样:团队里已经有一套成熟的 Flutter 业务代码,希望尽可能复用到 OpenHarmony 设备上。在这种情况下,选一个边界清晰、交互密集、又涉及大量文本渲染的小功能作为试点,比一上来就搬整个业务要稳妥得多。正则表达式测试器正好满足这些条件。

1.1 开发助手里最刚需也最容易被忽视的小工具

先说说这个工具解决的是什么问题。日常写正则的时候,大家最常见的流程是:打开一个在线测试网站,输入表达式,输入测试文本,看匹配结果,然后不断调整规则。这个流程在 PC 上没问题,但在 OpenHarmony 平板、开发板这类设备上就很不方便。首先设备上不一定有浏览器,其次就算有浏览器,在线工具对剪贴板的支持、对中文文本的展示、对超大文本的渲染都未必理想。

更重要的一点是,正则调试是典型的高频迭代场景。表达式写错一个字符,匹配结果就完全不一样。你需要在表达式输入框、测试文本输入框、结果预览区之间来回切换视线。如果这个工具做得顺手,调试效率能翻倍;做得不顺,还不如回到 PC 上改。

所以我把“正则表达式测试器”定位成开发助手里的小而美模块,它的核心任务只有一个:让用户在 OpenHarmony 设备上,像在 PC 上一样高效地验证正则规则。

1.2 选它的三个理由:边界清、交互密、渲染杂

这个功能虽然看起来小,但技术点很密集。我当初选它作为 Flutter for OpenHarmony 的探路项目,主要基于三点考虑。

第一,边界清晰。正则测试器的输入是表达式和测试文本,输出是匹配结果和分组信息,逻辑链路非常明确。这意味着我可以把大部分精力花在验证 Flutter 在 OpenHarmony 上的表现,而不是纠结业务规则。

第二,交互密度够。工具类 App 最大的特点就是输入框多、按钮多、状态变化频繁。正则测试器有表达式输入、文本输入、匹配模式切换(大小写敏感、多行模式、点匹配换行等)、替换预览、收藏夹,这些交互几乎覆盖了 Flutter 在移动端常见的控件类型。通过这个项目,能一次性验证多行文本框、单选开关、列表、弹窗在 OpenHarmony 上的兼容性。

第三,渲染路径足够复杂。正则结果要高亮显示在文本里,这意味着我需要把一段普通文本拆分成多个带不同样式的片段,用 RichText 渲染。而 RichText 背后是 TextPainter、TextSpan、InlineSpan 这一整套文本渲染体系。这套体系在 Android 和 iOS 上早已经过验证,但在 OpenHarmony 的适配引擎里表现如何,只有实测才知道。

1.3 功能边界:先做什么,不做什么

做工具类模块最忌讳的就是一上来就想做得大而全。我第一期只定了三个核心功能点:一是实时匹配与高亮,输入正则和测试文本后,所有匹配结果即时在正文中着色;二是分组捕获展示,把每个匹配项的子分组列出来,支持点击定位到正文;三是常用表达式收藏,方便把验证好的规则保存下来复用。

实时替换预览、正则语法提示、表达式历史记录这些属于二期甚至三期的事情。这样做的好处是,每一个功能点都能在 OpenHarmony 真机上得到充分验证,遇到适配问题时可以快速定位是引擎的问题还是业务代码的问题。我个人的建议是,如果你也是第一次在 OpenHarmony 上跑 Flutter,千万不要第一个项目就上全功能,先挑一个核心链路打透,后面扩展会顺手很多。

2. 环境搭建阶段:先把 Flutter 跑在 OpenHarmony 上

这一节可能是很多人最先卡住的地方。坦白讲,Flutter 官方稳定通道并不直接支持 OpenHarmony 作为目标平台,你需要使用社区维护的适配分支或者自编译引擎。这就带来一个非常现实的问题:用哪个版本、跟哪个 Flutter 版本对齐、怎么把工程跑起来。

2.1 工具链选型:官方稳定通道之外的适配分支

我当时调研下来,社区里比较常见的做法是使用某开源适配工程,它相当于把 Flutter Engine 的某些平台层实现移植到了 OpenHarmony。这个工程有三个版本需要对齐:Flutter Framework 版本、Dart SDK 版本、Engine 版本。三者必须严格对应,任意一个版本错位都会导致编译失败或者运行期崩溃。

我的具体做法是,先确认适配工程的主分支对应的 Flutter 版本号,然后直接用flutter --version查看当前环境,确保本地 Flutter SDK 与适配工程要求的版本一致。如果本地版本偏新或者偏旧,建议用版本管理工具切换,不要直接改代码硬扛。

这里有个很关键的细节:不要以为从仓库拉下来就能直接跑。适配工程的构建脚本通常会要求你指定 OpenHarmony SDK 的路径,也就是ohos_sdk的安装目录。你需要先在 OpenHarmony 官方社区下载对应版本的 SDK 包,然后在环境变量里配置DEVECO_SDK_HOME或者直接在工程配置里指定路径。这一步如果漏了,后面运行时会报各种莫名其妙的找不到设备错误。

2.2 项目初始化和 target 配置的细节

环境对齐之后,初始化 Flutter 工程本身没有太多特殊之处,核心区别在于 target 平台的配置。

常规 Flutter 工程是在android、ios这些目录里生成对应平台代码,而 OpenHarmony 适配工程会生成一个类似ohos的目录。你要做的第一步是确认这个目录存在。如果创建工程后没有看到,大概率是 Flutter 模板里没有注册 OpenHarmony 平台,需要检查flutter config里是否启用了对应平台,或者手动往工程里添加 OpenHarmony 的 platform 文件夹。

另一个容易出问题的地方在pubspec.yaml。适配工程的 Flutter SDK 路径是自定义的,因此一些依赖包在解析时可能找不到兼容版本。我的做法是尽量少引第三方包,尤其是涉及原生插件调用的包。正则测试器这个项目里,我几乎没有用到任何 plugin,全部用 Dart 原生 API 和 Flutter 基础控件完成。这不仅减少了编译期的坑,也降低了运行期原生调用的不确定性。

2.3 构建与签名:最容易卡住的隐性门槛

如果只是构建 Debug 包,流程相对简单,因为 Debug 模式走的是 JIT 和热重载,对签名要求不高。但你要往真机上装 App,或者想验证 Release 模式的性能,就会遇到签名的问题。

OpenHarmony 应用安装包需要签名,而签名工具和证书申请流程跟 Android 很不一样。我在这个环节卡了将近半天,现象是构建产物能生成,但用工具安装到开发板上时提示校验失败。后来排查发现,问题是构建时没有把签名证书配置到工程里,默认使用了测试签名,而测试签名对应的设备白名单里不包含我这台开发板。

解决方案不复杂:去 OpenHarmony 应用市场的开发者后台申请调试证书,然后把证书文件路径填入构建配置。但这里面有个容易忽略的点,就是证书的 profile 文件里绑定的是设备的 UUID,不是产品型号。也就是说,换一台开发板就必须重新生成调试证书。如果你手头有多台设备,这个坑几乎必踩。

另外一个构建层面的细节是 CPU 架构。OpenHarmony 设备从 ARM 到 x86 都有,构建 release 包时要根据目标设备架构建对应的产物。我当时用的开发板是 ARM64,默认构建产物就能跑通,但如果测试机是模拟器或者 x86 架构的盒子,就需要在构建参数里显式指定 target architecture。

提示:首次跑通 OpenHarmony 构建链路后,建议把所有版本号、环境变量、证书路径整理成一个 check-list 文档。这个工程环境非常敏感,隔一段时间再看,你自己都可能忘掉当时是怎么配的。

3. 正则匹配核心:从输入到匹配结果的完整链路

环境跑通之后,真正的业务逻辑开发就开始了。这个模块的核心链路是:读取表达式和测试文本,构造规则对象,执行匹配,把结果转换成界面可消费的模型。看起来简单,但这里面的边界情况非常多,稍不注意就会出现界面卡死或者匹配结果错乱。

3.1 RegExp 封装:先处理参数,再谈匹配

Dart 自带的RegExp类是核心工具,但直接裸用RegExp在工具类场景下不够安全。我封装了一层RegexEngine,专门处理三件事:表达式合法性检查、匹配模式参数映射、超长文本保护。

class RegexEngine { final String pattern; final bool caseSensitive; final bool multiLine; final bool dotAll; final bool unicode; RegExp? _compiled; RegexEngine({ required this.pattern, this.caseSensitive = true, this.multiLine = false, this.dotAll = false, this.unicode = false, }); RegExp? get compiled { if (_compiled != null) return _compiled; if (pattern.isEmpty) return null; try { _compiled = RegExp( pattern, caseSensitive: caseSensitive, multiLine: multiLine, dotAll: dotAll, unicode: unicode, ); return _compiled; } catch (e) { return null; } } bool get isValid => compiled != null; List<RegExpMatch> allMatches(String input, {int maxMatches = 10000}) { final re = compiled; if (re == null || input.isEmpty) return []; final result = <RegExpMatch>[]; var start = 0; while (start <= input.length && result.length < maxMatches) { final match = re.matchAsPrefix(input, start); if (match == null) { final next = re.firstMatch(input.substring(start)); if (next == null) break; result.add(next); start += next.end; } else { if (match.end == start && match.start == match.end) { // 处理空匹配,防止死循环 if (start < input.length) { start++; } else { break; } } else { result.add(match); start = match.end; } } } return result; } }

这段代码里有两个设计是容易忽略的。第一是maxMatches上限,正则里如果写.*这类贪婪表达式,在长文本上可能匹配出成千上万个结果,如果没有上限控制,高亮渲染会直接卡死 UI。第二是空匹配保护,像a*这种模式在任意位置都可能匹配到空字符串,如果不做处理,循环会陷入死循环。这是正则工具最容易踩的坑。

3.2 输入防抖与异步化的取舍

正则测试器的核心体验是实时匹配,也就是用户每敲一个字符,匹配结果就要刷新一次。但正则匹配在某些极端表达式下非常耗时,如果每次按键都同步计算,UI 会明显卡顿。

我采用了 300 毫秒防抖策略:用户停止输入 300 毫秒后,才重新计算结果。这个时间阈值是实测下来的平衡点,低于 200 毫秒会导致输入法联想时频繁触发匹配,高于 500 毫秒会让实时反馈变得迟钝。

Timer? _debounce; void onPatternChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _runMatch(); }); }

这里还有一个容易被忽略的性能点:不要把匹配计算放在 UI isolate 里做重活。虽然正则匹配通常很快,但万一用户粘贴了一段几 MB 的日志文本,匹配操作就可能阻塞 UI。我后来把匹配计算切到了compute或Isolate.run里,匹配完成后回到主 isolate 更新界面。不过这个优化有一个前提:传入 isolate 的参数必须是可序列化的。表达式、模式标志、测试文本这些都是基础类型,完全没问题。

3.3 匹配结果的统一数据模型与空匹配保护

渲染层不需要关心正则是怎么匹配的,它只关心“匹配到了哪些区间、每个区间对应哪个分组”。所以我定义了一个扁平的数据模型:

class MatchEntry { final int index; final int start; final int end; final String matchedText; final List<GroupEntry> groups; MatchEntry({ required this.index, required this.start, required this.end, required this.matchedText, required this.groups, }); } class GroupEntry { final int groupIndex; final String? name; final int start; final int end; final String? value; }

用这个模型,界面渲染高亮只需要遍历MatchEntry列表,无需关心每个匹配对应的正则结构。分组面板也只需要读取groups字段即可。把所有逻辑集中在这套模型上,可以让业务代码、匹配引擎、UI 层彻底解耦。

空匹配的问题在模型层也要处理一次。a*这种表达式在文本中可能匹配出大量空字符串片段,如果全量展示,那界面就全是高亮色块,毫无意义。我的处理方式是:当匹配到的文本为空字符串时,仍然在列表中保留,但不参与高亮着色,只在需要时通过特殊标记展示。这样既能正确反映正则的匹配行为,又不会破坏视觉可读性。

3.4 替换预览与常用表达式收藏

替换预览这个功能在二期我才加上,但数据结构在一期已经预留了。替换逻辑其实就是把匹配结果的每个区间用替换字符串替换掉,关键难点在于反向引用,比如$1、$2要展开为对应分组捕获的文本。Dart 的replaceAll对反向引用的支持有限,所以我手动实现了替换逻辑:从文本末尾往前遍历匹配项,逐个替换区间,避免正向替换导致错位。

常用表达式收藏则是一个典型的本地存储场景。我没有引入数据库,直接用文件存储 JSON 数组,每条记录包含表达式、模式设置、备注、收藏时间。OpenHarmony 的文件路径和 Android 略有不同,但 Flutter 的path_provider适配工程基本都能支持,拿到应用文档目录后写入即可。这个功能的耗时不长,但对工具类 App 来说价值很大,用户调好的正则可以随时复用。

4. 实时高亮渲染:TextSpan 模型和匹配结果如何对齐

正则测试器的视觉核心就是实时高亮。这块本质上不是一个复杂问题,但要做到在长文本、多匹配、中文混排、OpenHarmony 真机上表现稳定,需要考虑的事情比想象中多。

4.1 高亮的本质是给文本分段着色

在 Flutter 里实现文本的高亮,核心方式是构造TextSpan树,给需要高亮的片段附加不同的TextStyle。具体逻辑是:把测试文本按匹配结果的边界切分成多段,未命中的片段用默认样式,命中的片段用高亮样式,然后把它们依次放进一个TextSpan(child: [...])中。

List<InlineSpan> buildHighlightSpans({ required String text, required List<MatchEntry> matches, }) { final spans = <InlineSpan>[]; var cursor = 0; for (final match in matches) { if (match.start < cursor || match.end > text.length) continue; if (match.start >= text.length) break; if (match.start > cursor) { spans.add(TextSpan( text: text.substring(cursor, match.start), style: normalStyle, )); } if (match.end > match.start) { spans.add(TextSpan( text: text.substring(match.start, match.end), style: highlightStyle, )); } cursor = match.end; } if (cursor < text.length) { spans.add(TextSpan( text: text.substring(cursor), style: normalStyle, )); } return spans; }

这段代码的逻辑很简单,但我要特别提醒一个边界问题:如果匹配列表没有按起始位置排序,或者两个匹配项之间存在重叠,高亮区间就会错乱。我在封装匹配引擎时已经保证了匹配项是递增排序的,但如果你直接在业务层拼TextSpan,建议加一个防御性的排序和去重逻辑。

4.2 空匹配和零宽匹配的视觉处理

零宽匹配,也就是匹配到的内容长度为 0,在TextSpan里会直接表现为一段空文本。如果直接忽略,用户看不到任何反馈;如果渲染一个空 span,又没有任何视觉意义。我最初的做法是忽略所有零宽匹配,结果调试一个(?=...)前瞻表达式时,发现匹配项列表里有几个空匹配,界面上却完全没有提示,用户会误以为正则不生效。

后来我改成了这样的策略:高亮正常长度匹配项,零宽匹配单独渲染为一种特殊标记,在匹配项列表里用“@位置”的方式展示,比如@15(零宽)。这样既不会污染文本视图,又能让用户感知到零宽匹配的存在。

4.3 点击匹配项定位到正文:TextPainter 的字段计算

分组面板和匹配列表里每一项都对应正文的一个区间。用户点击列表项时,正文应该滚动到对应位置,并且高亮闪烁一下。这个交互在 Android 上可以用Scrollable.ensureVisible搭配GlobalKey实现,但 OpenHarmony 适配分支对这个 API 的支持有时不完整,尤其是嵌套滚动场景下定位会偏。

我的绕行方案是使用TextPainter来计算指定字符的屏幕偏移。核心思路是:对高亮后的TextSpan调用TextPainter.layout,然后通过getOffsetForCaret拿到目标字符的 Offset,再把这个 Offset 换算到滚动视图中。

void scrollToMatch(int matchStart) { final textPainter = TextPainter( text: buildHighlightSpans(text: _text, matches: _matches), textDirection: TextDirection.ltr, textScaler: TextScaler.noScaling, )..layout(maxWidth: _textViewportWidth); final offset = textPainter.getOffsetForCaret( TextPosition(offset: matchStart), Rect.zero, ); _scrollController.animateTo( offset.dy - 80, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, ); }

这一招的关键是布局宽度必须与真实渲染宽度一致,否则算出来的位置会偏。我通过LayoutBuilder拿到最内层文本容器的宽度,再传给TextPainter.layout,保证了计算结果和实际渲染一致。

4.4 长文本下的渲染性能控制

正则测试器用久了,用户难免会粘贴大段日志进去。比如几十万字符的文本,匹配结果可能有几千个。这种情况下如果仍然一次性构建上万个TextSpan,即使渲染层不崩溃,滑动起来也会明显掉帧。

我的优化方案是:高亮渲染采用懒加载策略,只渲染可视区域附近的匹配项。具体做法是监听滚动位置,动态调整matches传入的区间范围,让TextSpan只包含视口上下各 2000 字符范围内的匹配项。这样即使用户粘贴了 1 MB 文本,实际参与渲染的始终只有屏幕上那部分。

代价是极少数屏幕外的高亮在滚入视口前会有短暂的空档,肉眼几乎察觉不到。实测在 OpenHarmony 开发板上,开启懒加载后滚动的流畅度提升非常明显,这应该是所有文本密集型工具类 App 都要考虑的问题。

5. 分组捕获面板:交互、层级与命名分组的处理

正则表达式测试器里,分组捕获是仅次于高亮的核心功能。用户不仅想知道匹配到了什么,还想知道每个捕获组分别拿到了什么。分组面板的设计直接决定了这个工具的专业度。

5.1 分组信息的数据结构

前面已经定义了GroupEntry模型,这里再说说数据填充的过程。Dart 的RegExpMatch暴露了groupCount、group(index)、groupName系列接口。遍历时需要注意:group(0)是整体匹配,group(1)到group(n)才是捕获组。

List<GroupEntry> extractGroups(RegExpMatch match, RegExp re) { final groups = <GroupEntry>[]; final names = re.groupNames; // 返回可迭代的命名分组名列表 final nameSet = {...?names}; for (var i = 0; i <= match.groupCount; i++) { final value = match.group(i); if (value == null) continue; final start = match.start; // 每个分组的偏移位置需要额外计算 final groupStart = re.firstMatch(match.input) == null ? -1 : _computeGroupOffset(match, re, i); groups.add(GroupEntry( groupIndex: i, name: nameSet.contains(i) ? names!.elementAt(i) : null, start: groupStart, end: groupStart + value.length, value: value, )); } return groups; }

事实上,Dart 的RegExpMatch并没有直接暴露每个分组在原始文本中的 start 和 end,而只有 group 的值。要拿到精确的偏移,一种可行的方法是在构造匹配时用带固定模式的分组,结合match.start推断;更稳妥的方式是直接使用RegExpMatch.start配合自己写的分组定位函数。我在项目里用了一个折中方案:RegExpMatch提供了start和end属性,这一般指整个匹配项的位置;对于分组的位置,Dart 的 RegExp 实际并没有完整暴露,因此我用match.group(i)的值,再根据此前匹配的边界推算。如果你的场景对分组偏移要求不高,只展示分组的值就够用了。

5.2 命名分组与嵌套分组的展示策略

正则里嵌套分组很常见,比如((a)|(b)),这种情况下分组编号是固定的:最外层左括号是分组 1,内层的两个括号依次是分组 2 和分组 3。展示时我采用了缩进式列表,编号按深度排列,命名分组用“名称(编号)”的方式标记。

这里有一个交互细节:分组面板里的每一项都应该显示该分组匹配到的具体文本,如果分组未参与匹配(比如(a|b)中 b 分支未命中),则显示为灰色“未参与匹配”。这个状态要单独用布尔值表示,不能简单用空字符串代替,因为空字符串也可能是合法的匹配结果。

5.3 点击分组联动正文高亮

分组面板和正文联动是我在一期就实现的功能。用户点击某个分组项时,正文滚动到该分组对应的位置,并且给该分组区间套一层特殊的高亮样式(比如紫色背景),与整体匹配的黄色高亮区分开。

这个联动的技术难点在于分组区间的定位。前面提到 Dart 对分组偏移支持有限,我的绕行方案是在匹配循环里记录一个表:每个分组项在正则第几次迭代中产生、对应哪个 MatchEntry 的下标区间。点击时直接查表拿到正文偏移,避免每次都重新匹配一遍。

这块是测试器里体验提升最明显的地方。没有联动之前,用户在分组面板看到一个捕获值,还要在正文里费力找位置;有了联动之后,整个工具的使用效率完全不一样。

6. 实测踩坑:OpenHarmony 上最典型的几个适配问题

这一节是全文的重头戏。前面讲的架构和代码在标准 Flutter 环境里都能跑通,但一旦换上 OpenHarmony 适配分支,真实设备上会遇到一堆诡异问题。有些问题在模拟器里完全复现不了,只有真机才会触发。我把最典型的几个问题完整复盘一遍,你能从根因推导里头学到排查思路,这个比直接抄修复代码更有价值。

6.1 输入框光标乱跳:从现象到根因的排查链路

现象是:在表达式输入框里打字时,光标会不定时跳到文本末尾,有时候输入一个字,光标跳走然后又自动回来,导致输入的内容顺序错乱。这个 bug 在调试正则时极其致命,因为你连表达式都输不完整,更别说测试匹配了。

我先怀疑是 TextField 的 controller 和 setState 冲突导致重建。检查之后发现代码里没有任何地方在输入过程中重建 TextField。然后怀疑是防抖定时器触发了 rebuild,导致输入框状态被重置。但取消定时器后问题依旧。

接着我把输入框简化成一个最普通的 TextField,在真机上跑,问题依然存在。这时候我基本确定是 OpenHarmony 输入法与 Flutter 文本输入通道的兼容性问题。确切地说,是输入法在每次按键时上报的 editing state 与 OpenHarmony 自身的输入框光标状态不同步,导致 Flutter 在重建 EditingState 时把光标位置强制复位。

绕行方案是:给 TextField 设置一个稳定的Key,同时在onChanged里手动维护 controller 的 selection,利用TextEditingController的value.copyWith把正确的光标位置写回去。这种方法不能 100% 消除问题,但配合软件盘设置,实测基本接近正常输入体验。如果你的设备上光标乱跳特别严重,还可以考虑用自绘输入框,完全绕开系统输入法通道,但这个成本很高,不是万不得已不建议。

6.2 复制粘贴和剪贴板时序问题

正则测试器的文本输入框天然需要频繁复制粘贴测试数据。但我在 OpenHarmony 上发现,应用启动后第一次从外部复制文本,再切回 App 粘贴,剪贴板内容经常是空的,需要锁屏再解锁或者重新打开一次 App 才能读到。

这个问题的根因是 Flutter 适配分支在 OpenHarmony 上获取剪贴板内容的异步时序和系统剪贴板服务的状态没有对齐。简单说,就是剪贴板内容还没有真正进入 Flutter 的缓存,业务代码就已经开始读了。

排查过程中我试过Clipboard.getData、Clipboard.hasStrings,都是偶发返回空。最终的处理是加了一个“剪贴板重试”策略:当读取为空时,间隔 200ms 重试,最多重试 5 次。同时在 App 侧维护一个“上次剪贴板内容”的缓存,重试仍然为空就回退到缓存,避免用户无感知丢失数据。这个方案不算优雅,但在兼容层尚未修补的阶段,是投入产出比最高的做法。

这个坑再次验证了一个经验:在 OpenHarmony 这个生态上做 Flutter 工具类应用,凡是涉及原生系统能力的调用,都要优先假设“第一次调用可能失败”,然后在业务层做好重试和兜底。不要依赖官方文档里写的“保证可用”。

6.3 字体度量差异导致的高亮偏移

这个坑相当隐蔽。现象是:文本内容存在中英文混排时,高亮区间偶尔会错位,具体表现是某个中文字后半截没有高亮,或者高亮覆盖到了下一个字符。

一开始我以为是匹配结果计算错误,把正则引擎的所有输出都打日志验证了一遍,结果匹配区间完全正确。然后又怀疑是字体渲染差异,因为中文字体和英文字体的宽度度量在 OpenHarmony 和 Android 上不同,但TextSpan的 text 切分是按字符来的,理论上不会出现半个字符的高亮。

最后定位到根因:是TextStyle里height和letterSpacing的设置导致字形渲染溢出了原定的字符边界。我在高亮样式里顺手设置了height: 1.5和letterSpacing: 0.2,这两个参数在某些字体组合上会让文本画笔的范围超出字符的 layout 宽度,视觉上就出现了高亮偏移。

修复方案是:高亮样式和普通样式使用完全相同的TextStyle基础属性,只改变颜色和背景色。任何影响字形布局的参数,都不能在局部样式中单独设置。这是一个非常典型的 Flutter 文本渲染陷阱,强烈建议你在做任何高亮功能时记住:高亮样式只能改颜色,不要动布局参数。

6.4 滚动性能与内存占用优化

最后是滚动性能问题。长文本滚动卡顿在前面的“懒加载渲染”部分已经解决了一部分,但真机上还有一个内存问题:构建上万个TextSpan时,即便只渲染一部分,如果旧的TextSpan没有被及时释放,内存占用会持续走高。

我做的优化有两步。第一步是把匹配结果列表和对应的高亮TextSpan缓存起来,以 20 万字符为分界,超过长度才启用分批渲染。第二步是绘制控件用RepaintBoundary包裹,避免高亮层变化时触发整页重绘。

这里有一个值得分享的心得:在 OpenHarmony 真机上调试性能问题时,不要只看帧率,要同时关注内存曲线。很多“卡顿”其实是内存持续增长导致的 GC 频繁触发。你用DevEco Studio里的性能分析工具抓一轮内存曲线,比猜优化点高效得多。正则测试器项目里,我就因为只盯着帧率,浪费了整整一个下午去优化一个本来就不存在的布局问题。

一些实操中的体会

如果你也想在 OpenHarmony 上用 Flutter 做工具类 App,我的建议是先花两天时间把环境链路真正跑通,尤其是签名和目标架构这两个隐性门槛。跑通之后再开始写业务代码,这时候的心态完全不一样。正则测试器这个项目做完之后,我最大的感受是:正则表达式测试器看起来是个小工具,但它把输入、渲染、文本处理、状态管理、平台兼容性全部串了一遍,做完它之后,我对 Flutter 在 OpenHarmony 上的调试技巧、性能分析的熟练度都有了质的提升。后面我把这套匹配模型和分组联动逻辑直接复用到另一个日志分析工具里,几乎没改什么代码。这种积累出来的通用组件,才是做工具类项目最值钱的部分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进