做了这么久Flutter,OS往OpenHarmony上迁移还是头一遭。这次趁着做一个开发助手类App,把JSON格式化工具作为第一个子功能完整落地了一遍。核心就一句话:用Flutter在一台OpenHarmony设备上实现输入JSON、点击格式化、输出缩进结果,同时支持压缩、排序键、错误提示和历史记录。
这套东西看起来简单,真正放到OpenHarmony上做的时候,涉及的环境适配、平台通道和打包问题比想象中多。这篇文章把我从环境搭建到功能实现的完整过程、踩过的坑和代码都整理出来,给准备在OpenHarmony上用Flutter做工具类App的开发者一个参考。不光是JSON复制粘贴那点事,而是整套“跨平台框架适配国产OS”的开发节奏,希望对你有点用。
1. 先把需求聊透:这个App到底要解决什么
1.1 为什么是Flutter,为什么是OpenHarmony
很多团队选择Flutter是因为一份代码跑多个平台。OpenHarmony的生态还在成长期,直接写ArkUI也行,但如果我们已经有Flutter的组件和工具库,用Flutter来开发工具类App能省下不少工作量。尤其是JSON格式化这种偏工具性质的功能,核心逻辑在Dart里写好,后面在Android和Windows上也能复用。而且OpenHarmony官方周边已经有flutter_flutter的fork,社区一直有人在维护,跑基础UI和插件没有大问题。
选Flutter还有一个非常现实的原因:Dart的dart:convert库内置了JSON解析和编码能力,不需要引入第三方依赖。在OpenHarmony这种插件生态还不算完整的平台上,少一个依赖就少一个坑。我用的是基于Flutter 3.22的OpenHarmony分支,做一些文本处理、状态管理和简单动画完全够用。
1.2 功能范围与边界
一个“开发助手App”听起来很宽泛,但落到JSON格式化工具上,功能边界一开始就要划清楚。我做的是下面这几个核心点:
- JSON文本格式化,也就是缩进美化
- JSON文本压缩,把空格和换行全部去掉
- 自动排序对象键,按字母顺序输出
- 解析错误提示,最好能定位到第几行第几列
- 历史记录,方便回看最近处理过的内容
- 一键复制到剪贴板,支持深色模式
不做的功能也很重要:不做JSON编辑器里的语法高亮,不做上传下载,不做云同步。工具类App的价值是快,不是全。把范围控制住,整个项目从开发到测试都清晰很多。
1.3 技术选型的几个备选方案
在OpenHarmony上做这个工具,其实不止Flutter一条路。我简单对比过三个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯ArkTS原生开发 | 官方生态,工具链完善 | 无法复用已有Flutter组件和Dart逻辑 |
| Flutter for OpenHarmony | 一套代码多端复用,开发速度快 | 部分插件需要适配,引擎体积较大 |
| 内置网页/webview实现 | 前端资源丰富 | 性能差,交互僵硬,不适合本地工具 |
我最终选了第二个方案。因为身边已经有Flutter写的工具库,JSON解析和界面组件都是现成的,迁移到OpenHarmony只是把平台通道打通。如果你是从零开始,且只面向OpenHarmony一个平台,用ArkTS原生其实更稳;但如果你想同时维护Android和OpenHarmony,Flutter的性价比就体现出来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:OpenHarmony上的Flutter开发环境
2.1 准备两套SDK:Flutter与OpenHarmony
OpenHarmony版的Flutter SDK不是从flutter.dev下载的,而是需要拉取OpenHarmony SIG维护的flutter_flutter仓库,并且把对应版本的flutter_engine放进来。我当时的具体操作是:
bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git
git checkout 3.22
然后在flutter_flutter/bin目录下配置PATH,确保输入flutter命令时使用的是这个分支而不是标准Flutter。如果你本机已经装了标准Flutter,建议改一下环境变量,避免两套SDK混用。我在配置时用了一个小技巧:把OpenHarmony版的flutter路径放在系统PATH最前面,然后用flutter --version验证版本号里带不带-ohos字样。
OpenHarmony本身的工具链需要安装DevEco Studio,里面包含了SDK、工具链和模拟器。安装完后,最好把DevEco Studio里的hdc命令也加到PATH里,后面调试真机要用。
2.2 创建工程并切换到ohos平台
环境准备好后,创建工程的命令和普通Flutter略有不同。我用的命令是:
bash复制flutter create --platforms ohos json_formatter
如果当前Flutter分支不支持ohos参数,也不要慌,可以创建一个普通工程,然后在根目录手动添加ohos文件夹,并把OpenHarmony侧的模板文件放进去。OpenHarmony的工程文件结构跟Android很不一样:build-profile.json5负责签名和模块配置,ohos目录下才是真正的接入层。
创建完工程后,我习惯先把pubspec.yaml里的依赖锁好。你不需要额外引入JSON解析库,但状态管理和本地存储大概率要用。这个项目里我只加了两个依赖:
yaml复制provider: ^6.1.0
shared_preferences: ^2.2.0
provider用来管理输入和输出的状态,shared_preferences用来存历史记录。为什么要用provider不用bloc?因为这个工具的状态逻辑相对简单,用provider的ChangeNotifier就够了,代码量比bloc少一半,排查问题也容易。你要是习惯bloc或cubit,完全可以替换,这个项目里不影响。
2.3 真机运行前的关键配置
OpenHarmony真机调试比Android麻烦一点,主要卡在签名上。我的经验是:先用DevEco Studio新建一个空工程,打开Project Structure里的自动签名,让IDE帮你生成一对测试证书。然后在Flutter工程的ohos目录下,把签名配置同步到build-profile.json5里。
简单来说,build-profile.json5里有signingConfigs字段,需要指定p12证书文件、密码以及证书链文件。如果你只是本地调试,用DevEco Studio自动生成的签名就够了,不需要申请正式的发布证书。
配置完成后连上设备,执行:
bash复制flutter run --device-id 你的设备id
第一次编译会花很久,因为要同时编译Flutter引擎和OpenHarmony侧代码。如果你的设备出现过flutter: process info: com.example.toh或者类似字样,说明引擎已经跑起来了,这时候可以开始在终端里看日志。
3. JSON格式化工具的核心逻辑实现
3.1 解析、格式化、压缩的完整代码
JSON格式化工具最核心的东西其实不复杂:读入字符串,解析成对象,再按指定格式序列化回去。Dart的dart:convert包一气呵成。
最简单的格式化函数长这样:
dart复制import 'dart:convert';
String? formatJson(String raw) {
try {
final dynamic data = jsonDecode(raw);
const JsonEncoder encoder = JsonEncoder.withIndent(' ');
return encoder.convert(data);
} on FormatException catch (e) {
return 'JSON解析失败:${e.message}';
}
}
压缩的逻辑更短:
dart复制String? minifyJson(String raw) {
try {
final dynamic data = jsonDecode(raw);
return jsonEncode(data);
} on FormatException catch (e) {
return 'JSON解析失败:${e.message}';
}
}
你可能注意到了,格式化函数里有个JsonEncoder.withIndent(' '),这个会生成两个空格的缩进。如果你想要四个空格或者Tab缩进,把参数换成'\t'就行。实际使用中我发现,OpenHarmony里图形界面的复制粘贴很容易带入不可见字符,比如零宽空格或者全角冒号,这种情况jsonDecode会直接抛异常。所以我在业务层加了异常兜底,不要让用户看到红屏。
3.2 保留字段顺序的自定义排序方案
格式化只是基础,开发中更常见的需求是给JSON键排序。比如你从接口返回拿到一大段JSON,键的顺序乱糟糟的,想按字母序整理一下。JsonEncoder本身不提供排序能力,但我们可以先递归处理Map。
这里有个坑:Dart的普通Map默认按插入顺序迭代,所以想排序必须用SplayTreeMap。下面是我踩过坑后写出来的排序函数:
dart复制import 'dart:collection';
dynamic sortJson(dynamic value) {
if (value is Map) {
final SplayTreeMap<String, dynamic> sorted = SplayTreeMap();
(value as Map).forEach((key, val) {
sorted[key.toString()] = sortJson(val);
});
return sorted;
} else if (value is List) {
return value.map((e) => sortJson(e)).toList();
} else {
return value;
}
}
SplayTreeMap的默认比较器就是字符串字母序,所以键会从a排到z。嵌套的Map因为递归调用了sortJson,所以子对象也会重新排序。使用的时候先sortJson(jsonDecode(raw))再交给JsonEncoder.withIndent,就能得到键有序的格式化结果。
这个方法在多平台都一样,不依赖任何OpenHarmony特定API,所以后面我在Windows桌面上复用逻辑时,完全零成本。
3.3 错误定位与用户提示的设计
jsonDecode抛出的FormatException会包含偏移量offset,但这个偏移量是相对于字符串开头的字符序号,不是用户习惯的行列号。直接展示给用户看,体验很差。我写了一个换算函数:
dart复制String getErrorPosition(String raw, FormatException e) {
final int? offset = e.offset;
if (offset == null) return e.message;
int line = 1;
int column = 1;
final int end = offset < raw.length ? offset : raw.length;
for (int i = 0; i < end; i++) {
if (raw.codeUnitAt(i) == 10) {
line++;
column = 1;
} else {
column++;
}
}
return '第 $line 行,第 $column 列:${e.message}';
}
这个函数遍历到出错位置之前的所有字符,遇到换行符就增加行号,重置列号。对于百万字符以内的JSON完全可以承受,再大的话建议放到后台isolate里去算。
我还在界面上做过一个贴心设计:当解析失败时,输出区不显示错误文本,而是在输入框下方弹出一行红色提示。这样用户能直接看到输入附近哪里有问题,不会把错误JSON和格式化后的结果混在一起。
4. 界面交互与用户体验细节
4.1 输入区与输出区布局技巧
这个App的主界面没有菜单,没有侧滑栏,就是一个面板。我采用了上下分割布局:上方是输入框,下方是输出内容。在横屏或者大屏设备上,也可以切换成左右分栏。核心布局代码大概是这样:
dart复制Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
maxLines: null,
expands: true,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(hintText: '粘贴 JSON 到这里'),
),
),
const VerticalDivider(width: 1),
Expanded(
child: SingleChildScrollView(
child: SelectableText(_output),
),
),
],
)
注意TextField要同时设置maxLines: null和expands: true,不然容易在OpenHarmony上出现输入区高度不撑满的问题。我最初只写了expands: true没设maxLines: null,结果键盘弹起来时整个布局崩掉。
输出区用SelectableText而不是Text,是为了让用户可以直接光标选择复制,而不一定非得点“复制”按钮。这个细节虽然小,但在开发场景里很实用。
4.2 历史记录、复制与分享的落地实现
历史记录我用shared_preferences存储,每次点击格式化按钮时,把原始输入和格式化结果拼成一个条目,最多存20条。
dart复制Future<void> saveHistory(String raw, String result) async {
final prefs = await SharedPreferences.getInstance();
List<String> list = prefs.getStringList('history') ?? [];
list.insert(0, '$raw ---> $result');
if (list.length > 20) list = list.sublist(0, 20);
await prefs.setStringList('history', list);
}
这里有个小坑:OpenHarmony上shared_preferences如果版本太旧,可能没有适配setStringList。我遇到过编译通过但运行时没任何反应的情况,日志里也不报错。后来我查了插件源码,发现是接口映射缺失。解决办法是直接把历史记录序列化成JSON字符串存到setString里,或者等插件升级。我最后选择了把列表转成jsonEncode再存,顺便也契合了App的主题。
一键复制就简单很多:
dart复制import 'package:flutter/services.dart';
Future<void> copyToClipboard(String text) async {
await Clipboard.setData(ClipboardData(text: text));
}
OpenHarmony的Flutter对Clipboard.setData支持得还可以,至少在API 9以上的设备上都能正常工作。如果遇到复制失败,我建议用平台通道去调用系统剪贴板,不过这个场景比较少见。
4.3 深色模式和字体缩放适配
作为开发工具,很多人半夜也会打开用,深色模式必须做。我直接监听系统的ThemeMode.system,然后在MaterialApp里给亮色和暗色各配了一套颜色方案:
dart复制MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.system,
)
真正的坑在字体缩放。OpenHarmony设备跟手机一样,用户可以调整系统字体大小。如果界面里的按钮文字跟着系统字体变大,布局就会挤成一团。我后来给核心按钮的textScaleFactor固定成1.0,强制不让它跟随系统缩放:
dart复制Text(
'格式化',
style: const TextStyle(fontSize: 16),
textScaleFactor: 1.0,
)
对于输出区,我反而让它可以跟随系统缩放,这样视力不好的用户能看得更清楚。工具类App应该在“界面稳定”和“可访问性”之间做取舍,这个细节我调了好几版。
5. 实战中踩过的坑与排查记录
5.1 中文乱码与UTF-8编码问题
在真机上第一次格式化带中文的JSON时,输出区域显示的全是乱码。我当时第一反应是引擎没加载中文字体,后来发现不是。问题出在我用了String.fromCharCodes去处理字节数组,而OpenHarmony的文件读取默认可能是UTF-8编码,我把字节流错误地按单字节解码了。
正确的做法是:如果是从文件里读取JSON,一定要显式指定解码方式。
dart复制import 'dart:io';
final String content = await File(path).readAsString();
readAsString()默认按UTF-8解码,没问题。如果你非要自己读字节,就用utf8.decode(bytes)。另外,在通过平台通道传String给Dart时,OpenHarmony侧的Java/Kotlin层返回的字符串已经是UTF-16,Dart接收时天然支持,只要你别手动转字节数组,乱码问题基本不会出现。
5.2 大JSON卡顿与性能优化
格式化一个几百KB的JSON,在UI线程直接跑,界面会卡到掉帧。十几MB的JSON甚至会让App看起来像死了一样。这个问题在Android上也很常见,但在OpenHarmony上表现得尤其明显,因为OpenHarmony的图形栈和Flutter引擎之间的帧调度开销更大。
我的解决办法是让格式化逻辑跑在后台isolate里。Flutter的compute函数可以创建独立线程,并且不会阻塞UI。
dart复制import 'package:flutter/foundation.dart';
Future<String?> formatInBackground(String raw) {
return compute(formatJson, raw);
}
注意formatJson必须是一个顶层函数,不能是类里的实例方法。我刚接触compute时容易在这个地方犯糊涂,如果传了实例方法,会直接报“illegal argument”的错。改好之后,即使是4MB的JSON,界面也依然能流畅滑动,只是格式化结果略等几秒。
5.3 插件无法编译的平台通道问题
OpenHarmony的Flutter插件体系目前还没有完全规范化。我用shared_preferences时遇到编译错误,原因是插件的原生部分没有注册到OpenHarmony的ohos模块中。报错信息和Android插件注册机制很像,但OpenHarmony侧的注册文件不是MainActivity.kt,而是entry/src/main/ets/entryability/EntryAbility.ets。需要手动把插件的包名和初始化逻辑加进去。
如果你遇到某个纯Dart插件还行,但原生插件一编译就崩的情况,可以先停用插件,换成本地存储文件的方式来实现。我这个项目里最终就放弃shared_preferences,改用path_provider_ohos或直接写到应用私有目录下的history.json。对于工具类App,把历史记录存在本地文件里和存在SharedPreferences里没有本质区别,反而少了一层适配风险。
5.4 打包时Gradle插件报错等杂项
因为是Flutter工程,默认会带android目录。我在打包OpenHarmony时并不会用到Android侧,但偶尔改动pubspec.yaml后,Flutter会自动尝试构建Android工程,然后抛出一堆Gradle报错,比如“you are applying flutter's main gradle plugin imperatively using the apply script”。这类报错是Android侧的老问题,和OpenHarmony本身没关系。
如果你想省心,可以把android目录整个删掉,只保留ohos和lib目录。我一开始舍不得删,总觉得万一以后要复用。实际上Flutter创建OpenHarmony工程时,ohos目录已经包含了完整的平台接入层,多留一个Android目录只会拖慢依赖解析。
6. 打包上机与下一步计划
6.1 OpenHarmony应用签名与hap打包
开发调试时用flutter run没有问题,但如果你想在别的设备上安装,还是得打包成hap文件。在OpenHarmony工程的根目录执行:
bash复制hvigorw assembleHap
如果签过名,会在entry/build/default/outputs下生成hap文件。之后用hdc安装:
bash复制hdc install 路径/xxx.hap
我打包时遇到过“signature verification failed”的问题,基本上都是签名信息和build-profile.json5里的证书不匹配。把自动签名关掉再重新开启,让IDE生成一套新的签名配置,就能解决。记住:调式证书和发布证书不能混用。加载到真机时,如果设备日志提示“install sign info inconsistent”,说明当前hap的签名跟设备上安装的调试证书不一致,换台没装过同包名App的设备就好。
6.2 后续可以扩展的点
这个JSON格式化工具只是开发助手App里的第一个功能模块。接下来的规划其实挺明确:
- 增加JSONPath查询,支持根据表达式过滤JSON片段
- 增加JSON转YAML和XML的转换
- 把历史记录加上时间戳和搜索框
- 增加比较功能,把两段JSON差异高亮显示
另外,如果你也准备做类似的工具,建议从一开始就把核心逻辑和UI层分离。我在这个项目里的lib/core/json_utils.dart里放了纯Dart的格式化、压缩、排序和错误定位函数。这些函数不依赖任何Flutter或OpenHarmony库,后续我可以直接搬到命令行工具或者其他平台上。这个决定帮我节省了后面很多测试时间,因为不用每次改界面都重新验证JSON逻辑。
我在实际开发中的体会是,OpenHarmony上的Flutter开发最怕的不是技术难度,而是环境适配问题。很多坑跟代码本身没关系,而是版本不匹配、签名路径不对、插件注册漏了一步。如果你跟我一样是第一次上手,一定要先做最小可行的验证:在OpenHarmony真机上跑起来一个Hello World,确认引擎、签名和设备连接都没问题了,再开始写具体功能。别一上来就拖一整个项目进去,不然会浪费大量时间在环境排查上。
最后再分享一个小技巧:OpenHarmony真机调试时,尽量把hdc的端口固定住,然后配置成静态IP。这样每次连接调试不用重新插拔数据线,adb那边都不一定支持得这么好,hdc在局域网调试这块做得很顺。搭配Flutter的热重载,改UI和在真机上看效果基本零延迟,开发体验比想象中好不少。
