1. 从“为什么不用 ArkTS 原生”说起:工具类App在OpenHarmony上的选型逻辑
1.1 一个每天都在发生的真实需求
这个项目的起点其实非常朴素:团队在 OpenHarmony 设备上做内部效率工具,每天都要面对接口联调、日志排查和配置整理。你从网页控制台复制一段接口返回,或者从数据库工具里导出一条记录,第一件事就是找个地方把它格式化,才能看清结构。可偏偏当时的 OpenHarmony 设备上没有顺手可用的离线 JSON 格式化工具,在线工具要么依赖浏览器,要么需要网络,而调试环境经常是断网的,更别提接口数据里偶尔带着内部 token,你也不想随便粘到第三方网页上。
所以我想做一个“软件开发助手”App,把开发者日常高频使用的小功能集中在一起,第一个模块就选了 JSON 格式化。决定用 Flutter 来写,是因为团队本身就有 Flutter 技术栈,Android 端和桌面端已经有成熟的代码可以复用,而 OpenHarmony 生态里又恰好有可用的 Flutter 适配分支。我不需要单独维护一套 ArkTS 原生界面,就能让同一个输入框、同一套解析逻辑跑在 OpenHarmony 上。这篇内容就是把我在这个项目里从选型、实现、平台通道调试到性能优化的完整过程记录下来,给正在做同类跨端工具,或者准备在 OpenHarmony 上落地 Flutter 应用的开发者一个参考。
有人可能会问:OpenHarmony 不是有自己的原生开发框架吗,为什么非要用 Flutter?这个问题的答案要分两层看。如果 App 的核心是深度系统能力,比如多窗格交互、系统级服务托管、复杂动画编排,那 ArkTS 原生确实更合适;但工具类 App 的核心价值在“输入、处理、反馈”这条链路上,UI 复杂度不高,逻辑密度却很高。用跨端框架能把解析、状态管理、测试逻辑都集中在 Dart 层,原生侧只需要薄薄一层桥接,开发效率高很多。
1.2 Flutter 在 OpenHarmony 上的适配现状与选型对比
在真正动手之前,我先把 OpenHarmony 上的 Flutter 适配情况摸了一遍。目前可用的 Flutter for OpenHarmony 是社区维护的适配分支,它在保留 Flutter 上层框架的同时,把 Engine 的事件循环、纹理渲染、平台通道都挂到了 OHOS 的 Ability 生命周期之上。日常使用的flutter create支持生成 ohos 平台工程,Dart 侧代码基本不用改,构建产物是 HAP 包,可以通过签名后安装到设备上。
但这套分支和官方 Flutter SDK 的版本跟进节奏不完全一致,所以我的建议是:不要盲目升级 Flutter 版本,先确认目标能力在你的设备系统版本上可用,再决定要不要跟着主分支走。我在这个项目里就是用适配分支的稳定版本,配合 DevEco Studio 对应的 SDK 版本,整个开发过程没有遇到框架层面的阻塞问题。
做选型对比时,我拉了一张表,把几种可能的实现方式放在一起看:
| 方案 | 开发周期 | 技能栈复用 | 系统能力接入 | 包体积 | 离线能力 | 跨端价值 |
|---|---|---|---|---|---|---|
| ArkTS 原生 | 中 | 仅 OHOS 生态 | 最直接 | 较小 | 好 | 弱,Android/桌面要重写 |
| Flutter for OHOS | 短 | Dart/Flutter 复用 | 通过 Platform Channel | 较大 | 好 | 强,一套代码多端覆盖 |
| 在线 Web 工具 | 极短 | 无 | 依赖浏览器 | 无 | 差 | 弱,且数据安全风险高 |
对工具类 App 来说,“跨端价值”这一项对我的决策权重最高。同一个 JSON 格式化器,我在 Android、Windows 桌面和 OpenHarmony 三个端共用核心逻辑,维护成本就是一份代码。包体积确实会比原生大一些,但作为开发者工具,这种体积换来的迭代速度是可以接受的。
2. 需求拆解与页面结构设计:先把用户输入路径画出来
2.1 功能矩阵:哪些该做,哪些坚决不做
动手写 UI 之前,我没有急着堆页面,而是先把“用户拿到这个工具后,会走一条什么样的路径”完整画了一遍。JSON 格式化工具最常见的使用场景有三个:粘贴一段 JSON,格式化后阅读;把压缩过的单行 JSON 变成可读结构;把美化后的内容压缩回单行方便传输。围绕这三条路径,我把功能收敛成以下矩阵:
- 格式化(美化):把压成一行或乱糟糟的 JSON 变成带缩进的层次结构。
- 压缩:把带空白的 JSON 去掉换行和多余空格,生成紧凑单行文本。
- 语法校验:输入非法 JSON 时,返回明确错误信息和位置提示。
- 复制/清空:一键复制结果、一键清空输入,这是工具类 App 的肌肉记忆。
- 字符统计:显示字符数、行数、耗时,方便快速判断数据规模。
- 示例数据:内置一段有代表性的 JSON,方便新用户快速体验。
功能上我刻意没做“JSON 转 XML”“JSON 对比”“JSON 转 YAML”这类横向扩展。原因很简单,核心工具的价值在于精准和响应速度,塞进太多外围功能会让主路径失去焦点。后续如果要做,也应该以“独立的二级工具”形式新增,而不是堆在同一个页面上。
2.2 页面状态机与布局
页面布局我采用上下分栏:上方是输入区,下方是输出区,中间放操作按钮。输入区和输出区都支持滚动,格式化结果按行渲染。窄屏设备上这个布局最不容易踩误触问题,阅读结构时也不会因为键盘弹起而频繁跳动。
整个页面其实是一个状态机,核心状态包括:空输入、解析中、解析成功、解析失败。状态迁移如下:
empty -> parsing -> success -> error -> 回到 editing我用一个枚举ToolState表示当前状态,再用ValueNotifier<ToolState>监听变化。输入内容变化时,不立刻整体切换状态,而是根据内容长度决定走“即时格式化”还是“防抖后格式化”。解析中的状态会展示一个轻量进度提示,解析失败则切换到错误面板——但输入内容不会被清空,用户还能继续编辑修正。
2.3 状态管理选型:为什么用 Controller + ValueNotifier 就够了
很多 Flutter 项目一上来就套 Bloc 或 Riverpod,但工具类页面其实不需要那么重的状态管理层。我的判断标准很简单:这个页面是否存在多组件共享的复杂业务状态?对于单个 JSON 格式化页面,输入框的 TextEditingController、解析结果的 ValueNotifier、当前 ToolState 的 ValueNotifier,这三个东西就已经覆盖了所有状态流转。
如果非要用 Bloc,你会发现绝大多数 Event 只有一种:输入变化。这种场景下 Bloc 反而会引入样板代码,让页面逻辑分散在 event/state 两个文件中。我在项目里坚持“局部状态用局部方案,全局状态才上全局框架”这条原则,后面在 Windows 端也要复用同一套逻辑时,这个轻量状态层直接就能带走,不需要额外适配。
3. 自研 JSON 词法解析器:格式化、压缩与错误定位的真正原理
3.1 先想清楚“格式化”到底在做什么
一开始我也想过直接用jsonDecode解析成Map再手动拼格式化输出,但很快就发现这条路有几个绕不开的问题。第一,jsonDecode只能告诉你“JSON 解析失败”,定位不到第几行第几列,对工具类 App 来说这是致命的体验缺陷;第二,解析后的Map会丢失键的顺序吗?Dart 的普通 Map 在多数实现里保留插入顺序,但数字类型会被强转成 int/double,如果 JSON 里有一个超过2^53的大整数,格式化后再变回字符串就可能丢精度;第三,格式化输出时我们需要控制缩进大小、换行策略、key 排序(可选的),纯靠反序列化结果再拼接,等于做了两次无意义的转换。
所以我把核心解析器改成了自研的词法分析方案。JSON 格式本身没有歧义,它本质上就是一组 token 的嵌套序列:左花括号、右花括号、左方括号、右方括号、冒号、逗号,以及字符串、数字、布尔值、null。格式化要做的事情非常清晰:按 token 顺序重新排列,遇到{、[就缩进加一层,遇到}、]就缩进退一层,在必要的 token 之间插入换行和空格。
3.2 Tokenizer 的具体实现细节
我实现的 tokenizer 是字符级扫描的状态机。核心逻辑不复杂,但有几个容易写错的地方,我直接把关键实现列出来:
class JsonTokenizer { final String source; int _pos = 0; int _line = 1; int _col = 1; JsonTokenizer(this.source); List<Token> tokenize() { final tokens = <Token>[]; while (_pos < source.length) { final ch = _peek(); if (_isWhitespace(ch)) { _advance(); continue; } if ('{}[]:,'.contains(ch)) { tokens.add(_pushStructure(ch)); _advance(); continue; } if (ch == '"') { tokens.add(_readString()); continue; } if (ch == '-' || _isDigit(ch)) { tokens.add(_readNumber()); continue; } if (source.startsWith('true', _pos)) { tokens.add(Token(TokenType.boolean, 'true', _line, _col)); _pos += 4; _col += 4; continue; } if (source.startsWith('false', _pos)) { tokens.add(Token(TokenType.boolean, 'false', _line, _col)); _pos += 5; _col += 5; continue; } if (source.startsWith('null', _pos)) { tokens.add(Token(TokenType.nullValue, 'null', _line, _col)); _pos += 4; _col += 4; continue; } throw JsonFormatException('无法识别的字符', _line, _col); } return tokens; } }这里最容易踩坑的是字符串读取。JSON 字符串里的引号是可以被转义的,比如"{\"name\":\"x\"}",里面的\"不能当作字符串结束。所以读取字符串时必须维护一个 inEscape 状态:遇到反斜杠时,下一个字符直接跳过并作为字符串内容的一部分。另外,换行符在 JSON 字符串内理论上是不允许的,但很多实际数据里会混入,我在处理时会把这种情况定位成“字符串内非法换行”错误,而不是简单吞掉。
数字 token 的处理也比预想中麻烦。JSON 数字允许负数、小数、科学计数法,比如1.23e-4。我按字符连续读取,直到遇到非数字字符为止,然后交给一个严格校验函数,避免把1.2.3这种非法数字当成合法值。这样处理之后,核心JsonFormatter就变成了一个简单的 token 遍历器:
String format() { final buffer = StringBuffer(); var indent = 0; Token previous; for (final token in _tokens) { switch (token.type) { case TokenType.openBrace: case TokenType.openBracket: _newlineAndIndent(buffer, indent); buffer.write(token.value); indent++; break; case TokenType.closeBrace: case TokenType.closeBracket: indent--; _newlineAndIndent(buffer, indent); buffer.write(token.value); break; case TokenType.comma: buffer.write(','); _newlineAndIndent(buffer, indent); break; case TokenType.colon: buffer.write(': '); break; default: buffer.write(token.value); } } return buffer.toString(); }格式化之外,压缩模式更简单:把所有 token 拼接起来,结构字符之间不加任何空白,字符串内部保持原样。注意压缩时不能简单replaceAll掉空白,因为字符串内的空格和换行是有意义的,必须走完整 token 扫描流程,这也解释了为什么我坚持自研而不是正则一把梭。
3.3 错误定位与宽容处理策略
JSON 解析器最容易被用户骂的就是“不知道错在哪”。我在 tokenizer 里维护了_line和_col,遇到异常时直接抛出带位置信息的JsonFormatException。前端收到异常后,会把错误信息展示在输出区顶部,并在输入框对应行号上用红线标出——这个定位功能在实际使用中非常受用,远超一般在线工具只能提示“parse error”的体验。
关于语法宽容度,我做过一个有意思的决定:严格遵守 JSON 标准,不做尾逗号兼容、单引号兼容、注释兼容。原因在于,开发者工具的输入往往来自后端或者配置文件,如果工具“太聪明”地接受了非标准 JSON,用户反而会疑惑为什么这种写法别人不认。宽容策略必须做成显式选项,而不是默认行为,目前我把 JSON5 支持放在后续扩展清单里,但会用独立的解析入口,不污染默认严格模式。
4. 剪贴板、文件选择与控制面:OpenHarmony 平台通道适配实录
4.1 MethodChannel 在 ohos 侧的注册流程
跨端工具最绕不开的就是原生能力接入。在 Android 端你可能写过MainActivity里的MethodChannel,但在 OpenHarmony 上这套流程有差异。Flutter for OpenHarmony 的插件机制要求你在ohos/entry/src/main/ets/下面新建一个插件类,实现FlutterPlugin接口,并在onAttach时完成通道注册。
我定义的通道名是dev.sharp.toolbox/clipboard,Dart 侧调用逻辑是这样的:
static const _channel = MethodChannel('dev.sharp.toolbox/clipboard'); Future<String?> readClipboard() async { try { return await _channel.invokeMethod('readClipboard'); } on PlatformException catch (e) { return null; } }原生侧用 ArkTS 实现 MethodCallHandler,读取 pasteboard 后通过result.success(text)回传。这里要特别注意的是线程:OHOS 的系统服务调用有些是异步的,你不能在 MethodChannel handler 里直接用同步 API,否则会出现 UI 线程卡顿甚至死锁。我的做法是调用pasteboard.getData()时加上await,拿到结果再回传。
4.2 剪贴板读取的权限与失败兜底
剪贴板权限在 OpenHarmony 上有自己的状态机,不同系统版本对后台读取剪贴板的管理策略不一样。实测下来,如果 App 处于前台,系统弹权限后基本都能正常读取;但如果你从桌面直接拉起工具再读取剪贴板,个别设备会出现“读取结果为空”的情况。这不是代码 bug,而是系统对剪贴板访问的时机限制。
所以我的 UI 层做了一个重要的兜底逻辑:打开页面时读取一次剪贴板,如果读不到,不会弹任何错误,而是把输入框留空,让用户手动粘贴。同时提供一个“从剪贴板导入”按钮,用户点击后再主动触发一次读取。这样即使自动导入失败,用户仍然有一条明确的手动路径。实测下来,这类兜底比纠结权限弹窗的样式有用得多。
4.3 文件选择器:不是每个设备都给你一个稳定路径
除了粘贴 JSON,开发者还经常要直接打开一个.json文件。在 OpenHarmony 上,文件选择器是通过拉起系统的文件管理 Ability 完成的,你得到的不是一个普通文件路径,而是一个uri。这个uri可能带权限作用域,你不能直接把它当成本地路径去File.open。
我的做法是拿到 uri 后,复制内容到 App 缓存目录再读取,这样能避免权限失效。整体链路是:Dart 侧调用openFile-> ohos 侧拉起文件选择器 -> 用户选完文件 -> 返回 uri -> 原生侧读取文本内容 -> 转成字符串回传 Flutter。选中的临时文件在下次启动时清理,避免缓存膨胀。
4.4 EventChannel 的补充场景
在做剪贴板能力时,我还顺带设计了一个可选事件通道:监听系统剪贴板变化,如果用户在外面复制了新的 JSON,回到 App 时自动提示“检测到新的剪贴板内容,是否导入”。这个需求听起来很酷,但我最终把它做成了可选开关,而且默认关闭。
原因是剪贴板监听在部分 OpenHarmony 设备上并不会稳定推送事件,甚至会因为频繁监听带来额外的电量消耗。EventChannel 适用的场景是持续、高频、事件驱动的数据流,比如传感器数据、日志输出流,而剪贴板这种低频变化用主动读取加手动触发已经完全够了。工具类 App 要克制“能用事件就不用轮询”的冲动,稳定性永远是第一位的。
5. 大文本 JSON 的性能与体验优化:从 300ms 到流畅滚动的关键点
5.1 解析放到后台 isolate:compute 的边界要摸清楚
JSON 格式化本身并不会有巨大的计算量,但如果输入是一份几百 KB 甚至上 MB 的接口报文,主线程同步解析依然会造成掉帧。Flutter 里最直接的优化方案就是compute,把格式化函数丢到后台 isolate 执行:
final result = await compute(formatJson, rawInput);这里有一个很容易被忽略的细节:compute在传递字符串参数和返回值时,都会发生一次内存拷贝。如果输入是 5MB 的 JSON,这份数据至少要跨 isolate 边界两到三次,内存开销会明显上升。所以我的策略是设置一个阈值:小于 1MB 的输入直接主线程格式化,超过 1MB 才走 isolate。实测下来,对于常见的日志级报文(几十 KB),直接格式化根本没有可感知的延迟,没必要每次都用 compute。
超过 2MB 的文本,我还会做另一个优化:先做语法校验,再做格式化,两道工序分开,避免在解析中途发现错误时已经浪费了大量格式化时间。这里面的取舍是,工具类 App 的响应性比绝对吞吐量更重要。
5.2 渲染层优化:虚拟列表与延迟美化
格式化结果如果是一个超长字符串,直接塞进Textwidget 会有两个问题:第一,Text内部为了布局会构建完整的文本布局对象,超过一定行数后内存消耗陡增;第二,用户滚动时很难看到行号,定位困难。
我最终采用按行渲染的方式:把格式化后的字符串用split('\n')拆成行数组,放进ListView.builder,用懒加载只渲染可视区域的行。行号作为每行前面的灰色前缀。第一次split本身有 O(n) 开销,但换来的是滚动时每帧只构建十几行 widget 的流畅体验,对工具类场景非常值。
5.3 自动格式化的防抖与超时保护
输入框的每一次键盘变化都会触发格式化,如果不加控制,粘贴一段 500KB 文本时会连续触发几十次解析,明显卡顿。我用 500ms 防抖把自动格式化收紧:停止输入半秒后才触发解析,用户快速粘贴时只会触发一次。超过 1MB 的文本我干脆停掉自动格式化,改成手动点击“格式化”按钮才执行,并在界面上给出提示。
还有一个场景容易漏掉:某些 JSON 字符串包含极端深层嵌套,比如一万层括号,格式化后产生的缩进空格数量可能超过整个文本长度,导致输出异常膨胀。我在 tokenizer 里加了嵌套深度计数器,超过 512 层直接报“嵌套过深”错误,避免格式化结果把页面撑爆。
6. 从本地Demo到可分发安装包:构建、签名与回归清单
6.1 HAP 打包与签名流程上的注意事项
Flutter for OpenHarmony 的构建流程和官方 Flutter 有一些差异。我在本地跑通流程后发现,生成 ohos 平台工程之后,需要先确保 DevEco Studio 里能打开ohos目录下的原生工程,然后配置签名证书、指纹信息,最后才能执行 HAP 打包。签名配置不对,安装到设备上会提示校验失败。
这里记录一个我踩过的坑:Debug 模式下用 DevEco 的自动签名很顺畅,但切换到 Release 构建时,如果flutter build hap --release和 DevEco 的签名配置没有同步,打出来的包装上后会闪退。排查下来发现是签名证书里的指纹和 module 配置不一致。解决方式是全部在 DevEco 的signingConfigs里配置统一证书,并保证构建命令使用的工程能正确读取到这个配置。不要图省事在 Debug 包上长期开发,真机验证一定要用 Release 包。
6.2 回归测试用例:开发者会把你能想到的输入都喂给你
JSON 格式化工具是典型的“输入边界极多”的 App,我把回归测试用例整理成了下面这张表,每次发版前在模拟器和真机上各跑一遍:
| 用例 | 输入 | 预期结果 |
|---|---|---|
| 空串 | 空输入 | 不触发格式化,显示提示 |
| 非法 JSON | {"a":} | 错误面板展示,且有行列号 |
| 普通嵌套 | {"a":{"b":[1,2]}} | 正确缩进,结构清晰 |
| 字符串内特殊字符 | {"s":"{\"x\":1}"} | 字符串内部大括号不被误判,输出正确 |
| Unicode | {"name":"中文"} | 原样保留 |
| 极大数字 | {"big":12345678901234567890} | 不转换成 double,保留原文 |
| 深层嵌套 | 512 层以上 | 提示“嵌套过深”,不卡死 |
| 压缩输出 | 美化后结果再压缩 | 与输入语义一致 |
这张表看着简单,但每一条背后都有对应的代码逻辑。字符串内特殊字符的用例,对应的是 tokenizer 里的转义处理;极大数字的用例,对应的是数字 token 按文本存储而不是直接转 int;深层嵌套的用例,对应的是深度计数器保护。测试用例和实现逻辑其实是互相锚定的。
6.3 记录过的三个 bug 与修复过程
第一个 bug 是剪贴板读取卡顿。最初我在 MethodChannel 里用同步方式读剪贴板,结果在真机上出现 200ms 以上的停顿,UI 线程几乎卡死。改成异步获取后问题消失。
第二个 bug 是错误定位不准。最初用jsonDecode解析失败时,异常里只有 offset,没有行号列号。我一度尝试根据 offset 反推行列,但遇到超过一行的字符串时总是不对。后来换了自研 tokenizer,从源头记录行列,问题彻底解决。这个 bug 直接推动了我把格式化器完全重写。
第三个 bug 是长文本渲染卡顿。第一版直接把格式化结果塞进SelectableText,滚动时明显掉帧。后来改成ListView.builder按行渲染,并把行号从内容中拆出,滚动顺滑了很多,也顺带解决了用户反馈的“无法定位行号”的问题。
写在最后:工具类 App 的“扎实”比“华丽”更重要
如果你准备在 OpenHarmony 上做类似的小工具,我建议你先画一遍用户输入路径,再去选状态管理和平台通道方案。这个项目做到后面,最有价值的反而不是某个炫酷动画或高级框架,而是把“输入、处理、反馈”这条链路磨扎实:粘贴时不会卡,错误能定位,大文本不崩,剪贴板读不到还有手兜底。
最后分享一个我的真实体会:JSON 格式化这类功能,写成 Demo 很容易,做得让人愿意每天用却很难。所有看起来“不过如此”的细节——错误行号、数字精度、防抖阈值、Release 签名、剪贴板兜底——都是在真机上一遍遍试出来的。工具类 App 的护城河不在代码量,在于你对用户各种手滑、异常输入的预判有多少。