我最早接触 OpenHarmony 设备是因为一个车间排产看板项目。产线上的平板要实时展示每个工位的任务排期,横屏显示、按周滚动、支持双指缩放——说白了就是个甘特图。当时团队里没有人写过 ArkUI,但 Flutter 用了好几年,听说 Flutter for OpenHarmony 的社区版本已经能跑起来了,就咬咬牙定了这个技术方案。过程比预想的曲折,中间踩了不少坑,最后整体效果还算满意。这篇就把从环境准备到组件落地的完整链路整理出来,包括数据模型设计、时间轴算法、绘制层实现、手势交互和真机调试中的注意事项。
内容适合三类人看:要在 OpenHarmony 设备上做管理类/看板类应用的开发者,想了解 Flutter 跨端到 OpenHarmony 生态落地细节的移动端工程师,以及需要从零手写甘特图组件的 Flutter 开发者。我会把选型逻辑、核心算法、绘制细节和实测中遇到的问题尽量摊开说,不写太多虚的东西。
1. 项目背景:为什么要在 OpenHarmony 上做甘特图
1.1 业务需求是怎么来的
车间排产看板这类场景,数据形态天然适合甘特图:横轴是时间,纵轴是工位或任务,每个任务条在时间轴上的位置和长度直接代表排期区间,进度条展示完成比例。我们最初也考虑过用表格加日历的折中方案,但生产调度人员反馈说直观性不够——他们要看的是“一眼就知道某个时间段哪个工位在做什么”,以及“拖动时间轴看后续几周的排期”,这正好是甘特图最擅长的。
所以需求其实很简单:横屏平板设备上,左侧显示任务名称列表,右侧显示按时间展开的甘特图主体,支持双指缩放时间轴、左右拖拽平移、点击任务条查看详情、任务分组折叠。同时数据量不算小,单日排产任务可能在数百条量级,滚动和缩放要足够流畅。
1.2 技术选型对比:Flutter 并不是唯一选项
在 OpenHarmony 上做这个功能,摆在我面前的主要有三条路:用 ArkUI 原生开发、用 Flutter for OpenHarmony、用 React Native for OpenHarmony。三条路我都简单评估过。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| ArkUI 原生 | 系统级支持、性能最好、无跨层开销 | 团队学习成本高、生态组件少 | 对性能极致敏感、团队熟悉 ArkTS |
| Flutter for OpenHarmony | UI 一致性好、跨端复用能力极强、渲染自绘可控 | 社区维护版本滞后于上游、个别平台能力需走平台通道 | 已有 Flutter 技术栈、需要多端复用 |
| React Native for OpenHarmony | JS 生态丰富、热更新方便 | 复杂自绘场景性能不如 Flutter 可控 | 偏业务逻辑、不依赖高频自绘 |
我当时选 Flutter 的核心原因有三个。第一,团队有现成的 Flutter 经验和一套通用的图表/排期类组件,直接复用能把工期压缩很多。第二,甘特图的绘制本质上是一块自定义画布,Flutter 的 CustomPaint 在自绘复杂图形时非常顺手。第三,OpenHarmony 官方对 Flutter 的移植一直在推进,从最初的 Demo 阶段到现在已经具备工程化落地的条件。
1.3 Flutter for OpenHarmony 的社区进展
这里要先说清楚一个事实:Flutter for OpenHarmony 不是 Flutter 官方直接支持的平台,而是 OpenHarmony 社区(主要是 sig_flutter 组)基于 Flutter 引擎做的 OpenHarmony 平台适配。其核心思路是把 Flutter 的渲染层接入 OpenHarmony 的图形栈,同时通过 Platform Channel 机制让 Flutter 能调用 OpenHarmony 的底层能力。
在我做这个项目的阶段,社区版本已经支持了包括文本输入、网络请求、平台通道在内的大部分常用能力。虽然还不是“开箱即用”的官方状态,但做甘特图这种自定义绘制类组件,反而受平台插件生态影响较小,因为主要逻辑都在 Dart 层和 Canvas 绘制层,不太依赖第三方插件。这也算是一种“歪打正着”的适配选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:Flutter for OpenHarmony 的开发链路准备
2.1 工具链版本组合
搭建 Flutter for OpenHarmony 的开发环境,比普通 Flutter 开发要多一个环节:你不仅要装 Flutter SDK,还要装 OpenHarmony 的 SDK 和 DevEco Studio 用于打包、签名、真机调试。版本组合非常关键,我最初就是用错了版本组合导致编译反复失败。
我最终稳定使用的组合是:
- DevEco Studio 4.0 及以上版本(必须包含 OpenHarmony SDK)
- OpenHarmony SDK API 10 或更高版本
- Flutter SDK 使用社区维护的 OpenHarmony 分支(从 gitee 仓库拉取,不是官方 flutter 仓库)
- Java JDK 17(DevEco Studio 自带的 JBR 也行,但命令行编译时建议单独配置)
- hdc 工具(OpenHarmony 的命令行调试工具,对应 adb 的角色)
这里有个容易踩的坑:Flutter for OpenHarmony 的 SDK 分支更新很快,有些功能只有特定 commit 才有。建议固定到社区 release 版本,不要追最新 commit,除非你愿意每天面对编译错误。
2.2 创建工程与集成方式
Flutter 在 OpenHarmony 上的工程组织方式有两种,体验差别很大。
第一种是纯 Flutter 工程,类似 Android/iOS 的 Flutter 工程结构,入口是 Dart 代码,OpenHarmony 只是承载壳。这种方式跑通最快,适合验证原型和纯 Flutter 功能的开发。如果你只是做甘特图组件本身,用这种方式最省事。
第二种是在 OpenHarmony 工程中集成 Flutter Module,把 Flutter 页面作为 OpenHarmony 应用的一部分。这种方式更贴近真实项目,因为一个完整的 OpenHarmony 应用肯定有 ArkUI 页面、系统能力调用等,甘特图只是其中一屏。缺点是需要同时理解 ArkTS 工程和 Flutter 工程的构建流程,配置复杂度高不少。
我当时因为要快速验证渲染效果,先用了纯 Flutter 工程把组件跑通,后来又抽了一个下午把 Flutter Module 集成进正式的 OpenHarmony 工程。如果你们项目从零开始,我建议直接用第二种方式,省得后面还要迁移。
2.3 真机连接、运行与调试
连真机调试的时候,最大的问题是设备识别。OpenHarmony 设备通过 hdc 连接,和 adb 的使用习惯类似,但命令不完全一样。
常用命令:
bash复制# 查看已连接的设备列表,能看到 devudid 和 serial
hdc list targets
# 安装 hap 包
hdc install entry-default-signed.hap
# 查看设备日志(类似 adb logcat)
hdc hilog
# 截图,排查布局问题时很好用
hdc shell snapshot_display -f /data/local/tmp/screen.png
hdc file recv /data/local/tmp/screen.png ./screen.png
我在 RK3568 开发板上调试时,遇到过 hdc list targets 能识别设备但 install 一直超时的情况,后来发现是设备端的 hdc daemon 版本和宿主端不匹配,把开发板的固件升级到配套版本后问题解决。如果你们也遇到安装超时、应用启动闪退这类诡异问题,优先检查设备和 SDK 的版本配套关系。
3. 数据模型与时间轴算法:决定组件能不能用的地基
3.1 任务数据结构设计
甘特图组件的核心是数据模型,模型设计得好不好,直接决定后面绘制和交互的复杂度。我设计的任务模型包含基础字段、时间字段、进度字段、层级字段和自定义扩展字段。
dart复制import 'package:flutter/material.dart';
class GanttTask {
final String id;
final String name;
final DateTime startTime;
final DateTime endTime;
final double progress; // 0.0 ~ 1.0
final Color color;
final GanttTask? parent;
final List<GanttTask> children;
final bool collapsed;
final Map<String, dynamic> extra; // 业务扩展字段
GanttTask({
required this.id,
required this.name,
required this.startTime,
required this.endTime,
this.progress = 0,
this.color = Colors.blue,
this.parent,
List<GanttTask>? children,
this.collapsed = false,
Map<String, dynamic>? extra,
}) : children = children ?? [],
extra = extra ?? {};
bool get hasChildren => children.isNotEmpty;
}
这个模型有几个设计点想展开说明。第一,用 parent 指针而不是仅靠 children 嵌套,是因为绘制时经常需要判断某个任务的层级深度来决定缩进量,有父指针可以 O(1) 拿到。第二,progress 用 0~1 的双精度,而不是 0~100 的整数,方便进度从服务端下发时直接映射,不用来回转换。第三,extra 字段很重要,因为排产看板场景里任务往往还需要挂接工单号、操作员、设备编号等业务字段,如果模型不允许扩展,后面每加一个字段都要改核心类。
3.2 时间轴刻度与像素换算
甘特图所有视觉元素最终都要落到像素坐标上,其中最关键的就是时间到 x 坐标的换算。这个逻辑放在单独的 GanttTimeScale 类里管理,包括缩放级别、时间范围、像素换算和刻度生成四个职责。
dart复制class GanttTimeScale {
DateTime viewportStart;
DateTime viewportEnd;
double pixelsPerDay; // 每天对应的像素宽度
GanttTimeScale({
required this.viewportStart,
required this.viewportEnd,
required this.pixelsPerDay,
});
double xForDate(DateTime date) {
return date.difference(viewportStart).inDays * pixelsPerDay +
(date.hour / 24) * pixelsPerDay;
}
DateTime dateForX(double x) {
final days = x / pixelsPerDay;
return viewportStart.add(Duration(milliseconds: (days * 24 * 3600 * 1000).round()));
}
double get viewportWidth =>
viewportEnd.difference(viewportStart).inDays * pixelsPerDay;
}
xForDate 和 dateForX 这两个函数是互逆的,前者用于绘制任务条,后者用于点击命中检测和拖拽平移,一定要保证精度一致。我这里用 days 乘上一天的毫秒数来算,避免直接用 Duration.inDays 取整导致小时信息丢失。
刻度生成是另一个关键点。不同缩放级别下,时间轴上显示的刻度应该自适应变化:当天数跨度大、每天像素小的时候,刻度以周为单位;放大到每天能显示几百像素时,刻度细化到天甚至半天。我实现了一个自适应刻度算法,核心逻辑是先根据 pixelsPerDay 计算刻度间距的目标像素范围(120~200 像素),然后从候选刻度集合里选出最合适的级别。
dart复制enum GanttTickLevel { day, week, month }
GanttTickLevel resolveTickLevel() {
if (pixelsPerDay >= 80) return GanttTickLevel.day;
if (pixelsPerDay >= 12) return GanttTickLevel.week;
return GanttTickLevel.month;
}
实测下来,当 pixelsPerDay 达到 80 以上时显示日刻度,12~80 之间显示周刻度,12 以下显示月刻度,视觉上是比较舒服的。你可以根据实际屏宽和字号微调阈值。
3.3 层级与分组折叠
项目排期场景里任务往往有父子层级,比如“总装线”下面有多个“工位”,工位下面又有具体“工序”。甘特图组件需要支持展开和折叠,折叠后子任务不显示,父任务的进度条聚合显示所有子任务的进度均值。
折叠逻辑比较简单,就是把树形结构拍平成显示列表:
dart复制List<GanttTask> _flattenTasks(List<GanttTask> tasks, int depth) {
final result = <GanttTask>[];
for (final task in tasks) {
result.add(task);
if (task.hasChildren && !task.collapsed) {
result.addAll(_flattenTasks(task.children, depth + 1));
}
}
return result;
}
绘制时根据任务在扁平列表中的顺序递增行号,同时通过 parent 指针向上遍历算出缩进层级。父任务的 progress 聚合要在数据层做,不要在绘制层做,否则每次重绘都要遍历整棵树。
4. 用 CustomPaint 绘制甘特图核心视图
4.1 整体布局拆解:任务列表与时间面板
甘特图的整体布局可以拆成三个区域:左上角固定大小的表头区、左侧任务名称列、右侧时间网格区。表头区和任务名称列在水平方向固定,时间网格区随着缩放和平移水平滚动;任务名称列和任务行在垂直方向同步滚动。
我可以直接用 CustomPaint 把三个区域一次性全部画出来,也可以组合 Widget:左侧任务列表用 ListView,右侧时间网格用 CustomPaint。两种方案我都试过,最终选了全 CustomPaint 方案。原因是双指缩放和拖拽平移时,左侧列表和右侧网格需要严格同步滚动,如果分成两个组件,要反复同步滚动偏移,状态管理非常繁琐。而全 CustomPaint 只需要在 paint 方法里处理一个 offset 就行。
布局参数:任务名称列固定宽度 200(按屏宽 1280 横屏算,大约占 15%),表头区高度 48(用来显示时间刻度),任务行高固定 40。这些参数放在 GanttStyle 类里统一管理,方便不同屏幕尺寸自适应。
4.2 画任务条与进度条
绘制任务条时,最核心的是拿到任务的 x 坐标和宽度。x 坐标通过 GanttTimeScale.xForDate 得到,宽度通过结束时间与开始时间的差值换算得到。这里要注意:任务条的像素宽度至少要有 6~8 像素,否则在时间跨度大、缩放级别小的情况下,短任务几乎看不见。我在绘制时加了一个最小可见宽度约束。
dart复制void _drawTaskBar(Canvas canvas, GanttTask task, double rowY, GanttStyle style) {
final startX = timeScale.xForDate(task.startTime);
final endX = timeScale.xForDate(task.endTime);
var width = endX - startX;
if (width < style.minBarWidth) {
width = style.minBarWidth;
}
// 任务条背景
final barRect = RRect.fromRectAndRadius(
Rect.fromLTWH(startX, rowY + style.barVerticalPadding, width, style.rowHeight - 2 * style.barVerticalPadding),
Radius.circular(style.barRadius),
);
canvas.drawRRect(barRect, Paint()..color = task.color.withOpacity(0.3));
// 进度条覆盖
final progressWidth = width * task.progress;
if (progressWidth > 1) {
final progressRect = RRect.fromRectAndRadius(
Rect.fromLTWH(startX, rowY + style.barVerticalPadding, progressWidth, style.rowHeight - 2 * style.barVerticalPadding),
Radius.circular(style.barRadius),
);
canvas.drawRRect(progressRect, Paint()..color = task.color);
}
}
进度条用半透明背景加实心进度覆盖的方式绘制,视觉上比较清晰。如果任务有子任务且处于折叠状态,父任务的进度条可以画粗一点并加深颜色,帮助用户区分层级。
4.3 文字绘制与溢出处理
文字绘制是甘特图最容易显“业余”的地方。我用 TextPainter 绘制任务名称,在左侧任务列区域显示,超长时显示省略号;在时间网格内如果有空间,也绘制任务名称。
dart复制void _drawTaskName(Canvas canvas, GanttTask task, double rowY, double nameColumnWidth, GanttStyle style) {
final textPainter = TextPainter(
text: TextSpan(
text: task.name,
style: style.taskNameStyle,
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '...',
)..layout(maxWidth: nameColumnWidth - style.namePadding * 2);
textPainter.paint(
canvas,
Offset(style.namePadding, rowY + (style.rowHeight - textPainter.height) / 2),
);
}
实测发现,OpenHarmony 真机上中文字体渲染和模拟器有明显差别,某些字重下字体看起来很虚。后来我在样式里显式指定了字体族为 HarmonyOS Sans,在 OpenHarmony 上这样才能获得系统字体的最优渲染效果。
5. 手势交互:缩放、拖拽与点击命中的实现细节
5.1 双指缩放与时间轴映射
甘特图的缩放交互核心在于:双指捏合时,要以手指中心为锚点,保持锚点对应的时间点不变,同时改变 pixelsPerDay。如果不处理锚点,缩放时视角会乱跳,体验很差。
实现思路是:在 onScaleStart 记录当前的 scale 基准值和时间轴起始日期,在 onScaleUpdate 里根据累计 scale 计算新的 pixelsPerDay,然后反向推算出新的 viewportStart,保证锚点时间不变。
dart复制double _basePixelsPerDay = 0;
double _baseScale = 1.0;
DateTime _baseViewportStart = DateTime.now();
void _handleScaleStart(ScaleStartDetails details) {
_basePixelsPerDay = _timeScale.pixelsPerDay;
_baseScale = 1.0;
_baseViewportStart = _timeScale.viewportStart;
}
void _handleScaleUpdate(ScaleUpdateDetails details) {
final focalPoint = details.localFocalPoint;
final anchorDate = _timeScale.dateForX(focalPoint.dx);
final newPixelsPerDay = (_basePixelsPerDay * details.scale / _baseScale)
.clamp(minPixelsPerDay, maxPixelsPerDay);
_timeScale = GanttTimeScale(
viewportStart: anchorDate.subtract(Duration(microseconds: ((focalPoint.dx / newPixelsPerDay) * 24 * 3600 * 1000000).round())),
viewportEnd: anchorDate.add(Duration(microseconds: (((size.width - focalPoint.dx) / newPixelsPerDay) * 24 * 3600 * 1000000).round())),
pixelsPerDay: newPixelsPerDay,
);
_timeScale.pixelsPerDay = newPixelsPerDay;
setState(() {});
}
这个逻辑的关键点在于:先算出锚点日期,再根据新的 pixelsPerDay 反推 viewportStart 和 viewportEnd,保证锚点日期映射到屏幕上的像素位置不变。pixelsPerDay 需要做上下限约束,否则用户可以把时间轴缩放到完全不可用的程度。我设的最小值是 1(一天只有 1 像素),最大值是 200(一天 200 像素),实际体验比较合理。
5.2 拖拽平移与边界约束
拖拽平移用 onPanUpdate 处理,在时间轴方向移动时改变 viewportStart。这里有个细节:甘特图左侧有任务名称列,用户按住任务名称列拖动时,应该滚动垂直方向而不是平移时间轴。手势区域需要做分离,时间网格区域响应水平平移,任务名称列只响应垂直滚动。
dart复制void _handlePanUpdate(DragUpdateDetails details) {
// 只在时间网格区域响应平移
if (details.globalPosition.dx < nameColumnWidth) return;
final deltaDays = details.delta.dx / _timeScale.pixelsPerDay;
final newStart = _timeScale.viewportStart.add(Duration(microseconds: (deltaDays * 24 * 3600 * 1000000).round()));
// 边界约束:不能拖出整体时间范围太远
if (newStart.isBefore(globalStart.subtract(Duration(days: 30))) ||
newStart.isAfter(globalEnd)) return;
setState(() {
_timeScale = GanttTimeScale(
viewportStart: newStart,
viewportEnd: newStart.add(viewDuration),
pixelsPerDay: _timeScale.pixelsPerDay,
);
});
}
这里用 Duration 的微秒计算来避免浮点误差积累,平移较长距离后时间不会出现明显偏差。
5.3 点击命中与选中态
点击命中检测的思路是反向映射:在手势的 onTapUp 里,获取点击位置坐标,先判断点击发生在左侧任务列还是右侧时间网格。如果在左侧,根据 y 坐标除以行高算出任务行号;如果在右侧,还需要通过 dateForX 算出点击对应的时间点,然后判断该任务的时间区间是否覆盖这个时间点。
dart复制void _handleTapUp(TapUpDetails details) {
final local = details.localPosition;
final rowIndex = (local.dy / style.rowHeight).floor();
if (rowIndex < 0 || rowIndex >= _visibleTasks.length) return;
final task = _visibleTasks[rowIndex];
if (local.dx < nameColumnWidth) {
_onTaskTap?.call(task);
return;
}
final tapDate = _timeScale.dateForX(local.dx - nameColumnWidth);
if (!task.startTime.isAfter(tapDate) && task.endTime.isAfter(tapDate)) {
_onTaskTap?.call(task);
}
}
点击到任务之后,需要刷新选中态。我通过一个 ValueNotifier<GanttTask?> 管理当前选中任务,绘制层监听这个 notifier,选中任务时给任务条加高亮边框。用 ValueNotifier 而不是 setState 的原因是可以局部刷新生效区域,不会触发整棵树重建。
5.4 手势冲突与滚动嵌套
甘特图组件在真实项目里往往不是独立页面,可能嵌在 Column 里和其他板块一起滚动。这种情况下手势竞争会比较麻烦:用户在时间网格上水平拖动时,到底谁消费这个手势?
Flutter 的手势竞技场(GestureArena)默认情况下,横向拖拽和纵向滚动的识别需要一个消歧过程。我的解决方案是:在时间网格区域用一个自定义的 RawGestureDetector,只注册水平方向的 drag 和 scale 手势;对于垂直方向的拖动事件,直接向上传递给外层滚动容器。如果外层也是 Flutter 控件,用 GestureDetector 的 behavior 和 HitTestBehavior 调整即可。
dart复制RawGestureDetector(
gestures: {
ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
(instance) {
instance
..onStart = _handleScaleStart
..onUpdate = _handleScaleUpdate
..onEnd = _handleScaleEnd;
},
),
PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(),
(instance) {
instance
..onUpdate = _handlePanUpdate;
},
),
},
child: ...,
)
如果在 OpenHarmony 设备上运行的效果还是抢手势,可以在 onPanUpdate 里判断:如果水平位移量大于竖直位移量,就认为自己应该接管(通过调 requestDisallowInterceptTouchEvent 类似的机制阻止父级拦截)。但 Flutter 层没有直接暴露这个方法,我是通过 PlatformChannel 调用 OpenHarmony 的原生接口实现的,这里属于特殊场景,普通项目用不到。
6. 性能优化与真机踩坑实录
6.1 可视区裁剪:别把整张图画完
甘特图最忌讳的做法是把所有任务都画一遍,即使任务落在可视区之外。数据量达到几千条时,全量绘制会把 GPU 带宽和 CPU 时间浪费在看不见的像素上。
我实现了一个可见任务筛选逻辑:先根据当前 viewportStart 和 viewportEnd 算出可视时间范围,再遍历扁平任务列表,只保留时间区间与可视范围有交集的任务参与绘制。同时根据垂直方向的滚动偏移量,只绘制屏幕高度范围内的行。
dart复制List<GanttTask> _getVisibleTasks({
required List<GanttTask> allTasks,
required DateTime viewportStart,
required DateTime viewportEnd,
required double verticalOffset,
required double viewportHeight,
}) {
final firstVisibleRow = (verticalOffset / style.rowHeight).floor().clamp(0, allTasks.length);
final visibleRowCount = (viewportHeight / style.rowHeight).ceil() + 1;
final result = <GanttTask>[];
for (var i = firstVisibleRow; i < allTasks.length && i < firstVisibleRow + visibleRowCount; i++) {
final task = allTasks[i];
if (task.endTime.isBefore(viewportStart) || task.startTime.isAfter(viewportEnd)) continue;
result.add(task);
}
return result;
}
这个优化效果非常明显。在 RK3568 这种中低端设备上,全量绘制 5000 条任务在缩放时掉帧严重,裁剪之后基本能稳定在 60 帧。
6.2 RepaintBoundary 与局部刷新
甘特图组件还有一个隐藏的重绘问题:任务进度的定时刷新。排产看板里的任务进度会周期性从服务端拉取(比如每 30 秒),如果每次更新都触发整个 CustomPaint 重绘,开销很大。
解决方案是拆分成多个 CustomPaint 层:时间网格背景层只画网格线,任务层只画任务条,表头层只画时间刻度。进度更新时只更新任务层。
CustomPaint 层的刷新控制通过自定义 CustomPainter 的 shouldRepaint 实现:
dart复制class GanttTaskPainter extends CustomPainter {
final GanttTimeScale timeScale;
final List<GanttTask> tasks;
final double verticalOffset;
final GanttTask? selectedTask;
@override
bool shouldRepaint(GanttTaskPainter oldDelegate) {
return oldDelegate.timeScale.pixelsPerDay != timeScale.pixelsPerDay ||
oldDelegate.timeScale.viewportStart != timeScale.viewportStart ||
oldDelegate.verticalOffset != verticalOffset ||
oldDelegate.selectedTask != selectedTask ||
oldDelegate.tasks != tasks;
}
}
同时在 CustomPaint 外包一层 RepaintBoundary,避免 RepaintBoundary 之外的元素跟着一起重绘。这里有一点要提醒:RepaintBoundary 不是越多越好,每个边界都会增加合成层的内存开销,只包裹真正需要隔离的绘制区域才是正确的用法。
6.3 在 OpenHarmony 真机上踩过的三个坑
坑一:字体渲染差异。同样一套代码,在 Android 模拟器上中文显示正常,在 OpenHarmony 真机上部分字重渲染发虚。排查了一圈,发现是 OpenHarmony 对默认字体回退的处理和 Android 不同。显式设置 fontFamily 为 HarmonyOS Sans 后解决。如果你们遇到中文显示异常,先检查字体族,不要一上来就怀疑 Canvas 绘制有问题。
坑二:Canvas 部分 API 在真机上性能落差明显。Flutter 的 Canvas 封装依赖 Skia 引擎,OpenHarmony 对 Skia 的适配程度影响实际渲染性能。我在 RK3568 上测试发现,drawRRect 的绘制性能比同级别的 Android 设备差了约 20%。针对这个情况,我把任务条圆角矩形的圆角半径调小了一点,同时把进度条改成用 drawRect 加 drawRRect 组合的方式,性能有轻微改善。如果项目要求帧率非常稳定,建议在目标设备上提前做性能摸底测试。
坑三:PlatformChannel 的类型映射。甘特图需要读取设备的某些系统信息,我通过 MethodChannel 调用 OpenHarmony 原生代码。OpenHarmony 侧的返回结果如果包含 Map 或 List,类型映射方式和 Android 有细微差别。特别是 Map 的 key,Android 侧返回字符串时 Dart 侧拿到的就是一个普通的 String,OpenHarmony 侧如果处理不当,可能出现奇怪的索引错误。我最后采用的稳妥做法是:所有平台通道的数据都用 JSON 字符串传输,虽然性能有轻微损失,但避免了类型映射带来的混沌问题。
6.4 当前方案的进一步优化方向
目前组件已经能满足单日数百条任务的排产看板需求,但后续如果要支撑大数据量或者更复杂交互,还有几个方向可以演进。
一是绘制缓存。任务条在没有缩放和平移时,可以缓存到 PicturePrerender 或者 RepaintBoundary 里,只有手势变化时才重新生成。这个优化在低端设备上提升明显。
二是虚拟化数据源。现在任务列表是内存中一个全量 List,后续如果对接实时数据流,可以改成懒加载模式,只加载可视时间范围内的任务数据。
三是交互扩展。比如拖拽任务条修改排期时间、拖动任务条调整前后顺序、右键菜单等,这些都可以在现有数据模型和绘制层基础上增量实现,不用改动核心结构。
最后分享一点经验
项目做完之后,我的一个明显体会是:Flutter for OpenHarmony 已经过了“能不能跑”的阶段,现在的问题是“怎么跑得稳”。甘特图这种高频自绘组件,恰好能把跨端渲染的真实情况暴露得很彻底——字体、性能、手势、平台通道,每个环节都可能给你“惊喜”。
我建议准备上这个技术路线的团队,拿到开发板后先花一天时间做一次渲染性能摸底,跑几个高压力场景(大量图形重绘、大列表滚动、平台通道高频调用),确认可接受后再排期开发。另外真机调试过程中准备一个 PID 清单,把 DevEco Studio、hdc、Flutter SDK 分支、OpenHarmony 固件的版本都记下来,出问题时能快速定位是哪一个环节的版本漂移导致的问题。这些经验说起来简单,但每次都能在关键时候省下半天排查时间。
