Flutter for OpenHarmony 实战: 桑基图(Sankey Diagram)生成器
在移动开发领域,我们总是面临着选择与适配。今天,你的Flutter应用在Android和iOS上跑得正欢,明天可能就需要考虑一个新的平台:HarmonyOS(鸿蒙)。这不是一道选答题,而是很多团队正在面对的现实。Flutter的优势很明确——写一套代码,就能在两个主要平台上运行,开发体验流畅。而鸿蒙代表的是下一个时代的互联生态,它不仅仅是手机系统,更着眼于未来全场景的体验。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
目录
前言:跨生态开发的新机遇
在移动开发领域,我们总是面临着选择与适配。今天,你的Flutter应用在Android和iOS上跑得正欢,明天可能就需要考虑一个新的平台:HarmonyOS(鸿蒙)。这不是一道选答题,而是很多团队正在面对的现实。
Flutter的优势很明确——写一套代码,就能在两个主要平台上运行,开发体验流畅。而鸿蒙代表的是下一个时代的互联生态,它不仅仅是手机系统,更着眼于未来全场景的体验。将现有的Flutter应用适配到鸿蒙,听起来像是一个“跨界”任务,但它本质上是一次有价值的技术拓展:让产品触达更多用户,也让技术栈覆盖更广。
不过,这条路走起来并不像听起来那么简单。Flutter和鸿蒙,从底层的架构到上层的工具链,都有着各自的设计逻辑。会遇到一些具体的问题:代码如何组织?原有的功能在鸿蒙上如何实现?那些平台特有的能力该怎么调用?更实际的是,从编译打包到上架部署,整个流程都需要重新摸索。
这篇文章想做的,就是把这些我们趟过的路、踩过的坑,清晰地摊开给你看。我们不会只停留在“怎么做”,还会聊到“为什么得这么做”,以及“如果出了问题该往哪想”。这更像是一份实战笔记,源自真实的项目经验,聚焦于那些真正卡住过我们的环节。
无论你是在为一个成熟产品寻找新的落地平台,还是从一开始就希望构建能面向多端的应用,这里的思路和解决方案都能提供直接的参考。理解了两套体系之间的异同,掌握了关键的衔接技术,不仅能完成这次迁移,更能积累起应对未来技术变化的能力。
混合工程结构深度解析
项目目录架构
当Flutter项目集成鸿蒙支持后,典型的项目结构会发生显著变化。以下是经过ohos_flutter插件初始化后的项目结构:
my_flutter_harmony_app/
├── lib/ # Flutter业务代码(基本不变)
│ ├── main.dart # 应用入口
│ ├── home_page.dart # 首页
│ └── utils/
│ └── platform_utils.dart # 平台工具类
├── pubspec.yaml # Flutter依赖配置
├── ohos/ # 鸿蒙原生层(核心适配区)
│ ├── entry/ # 主模块
│ │ └── src/main/
│ │ ├── ets/ # ArkTS代码
│ │ │ ├── MainAbility/
│ │ │ │ ├── MainAbility.ts # 主Ability
│ │ │ │ └── MainAbilityContext.ts
│ │ │ └── pages/
│ │ │ ├── Index.ets # 主页面
│ │ │ └── Splash.ets # 启动页
│ │ ├── resources/ # 鸿蒙资源文件
│ │ │ ├── base/
│ │ │ │ ├── element/ # 字符串等
│ │ │ │ ├── media/ # 图片资源
│ │ │ │ └── profile/ # 配置文件
│ │ │ └── en_US/ # 英文资源
│ │ └── config.json # 应用核心配置
│ ├── ohos_test/ # 测试模块
│ ├── build-profile.json5 # 构建配置
│ └── oh-package.json5 # 鸿蒙依赖管理
└── README.md
展示效果图片
flutter 实时预览 效果展示
运行到鸿蒙虚拟设备中效果展示
功能代码实现
桑基图(Sankey Diagram)组件开发
1. 数据模型设计
首先,我们需要设计桑基图的数据模型,包括节点(Node)和连接(Link)两个核心类:
// 桑基图数据模型
class Node {
final String id;
final String name;
final double value;
double x;
double y;
double width;
double height;
Color color;
bool isSelected;
Node({
required this.id,
required this.name,
required this.value,
this.x = 0,
this.y = 0,
this.width = 20,
this.height = 0,
this.color = Colors.blue,
this.isSelected = false,
});
}
class Link {
final String sourceId;
final String targetId;
final double value;
Color color;
bool isSelected;
Link({
required this.sourceId,
required this.targetId,
required this.value,
this.color = Colors.grey,
this.isSelected = false,
});
}
2. 组件结构实现
使用StatefulWidget实现桑基图组件,管理节点和连接的状态:
class SankeyDiagramComponent extends StatefulWidget {
const SankeyDiagramComponent({super.key});
State<SankeyDiagramComponent> createState() => _SankeyDiagramComponentState();
}
class _SankeyDiagramComponentState extends State<SankeyDiagramComponent> {
// 示例数据
final List<Node> _nodes = [
Node(id: 'A', name: '源头', value: 100, color: Colors.blue),
Node(id: 'B', name: '处理1', value: 60, color: Colors.green),
Node(id: 'C', name: '处理2', value: 40, color: Colors.yellow),
Node(id: 'D', name: '结果1', value: 30, color: Colors.red),
Node(id: 'E', name: '结果2', value: 30, color: Colors.purple),
Node(id: 'F', name: '结果3', value: 20, color: Colors.orange),
Node(id: 'G', name: '结果4', value: 20, color: Colors.teal),
];
final List<Link> _links = [
Link(sourceId: 'A', targetId: 'B', value: 60, color: Colors.blue.shade300),
Link(sourceId: 'A', targetId: 'C', value: 40, color: Colors.blue.shade200),
Link(sourceId: 'B', targetId: 'D', value: 30, color: Colors.green.shade300),
Link(sourceId: 'B', targetId: 'E', value: 30, color: Colors.green.shade200),
Link(sourceId: 'C', targetId: 'F', value: 20, color: Colors.yellow.shade300),
Link(sourceId: 'C', targetId: 'G', value: 20, color: Colors.yellow.shade200),
];
// 布局参数
final double _nodeWidth = 20;
final double _nodePadding = 10;
final double _diagramWidth = 400;
final double _diagramHeight = 300;
final double _margin = 40;
// 选中状态
Node? _selectedNode;
Link? _selectedLink;
void initState() {
super.initState();
_calculateLayout();
}
// 其他方法...
}
3. 布局算法实现
实现节点布局算法,计算每个节点的位置和大小:
void _calculateLayout() {
// 计算节点位置和大小
// 简化布局:分三列排列节点
final List<List<Node>> columns = [
[_nodes[0]], // 第一列:源头
[_nodes[1], _nodes[2]], // 第二列:处理
[_nodes[3], _nodes[4], _nodes[5], _nodes[6]], // 第三列:结果
];
const double columnWidth = 120;
const double startX = 20;
for (int colIndex = 0; colIndex < columns.length; colIndex++) {
final column = columns[colIndex];
double totalHeight = column.fold(0, (sum, node) => sum + node.value);
double currentY = _margin;
for (final node in column) {
node.x = startX + colIndex * columnWidth;
node.y = currentY;
node.height = (node.value / totalHeight) * (_diagramHeight - _margin * 2);
node.width = _nodeWidth;
currentY += node.height + _nodePadding;
}
}
}
4. 点击事件处理
实现节点和连接的点击选择功能:
void _selectNode(Node node) {
setState(() {
if (_selectedNode == node) {
_selectedNode = null;
node.isSelected = false;
} else {
if (_selectedNode != null) {
_selectedNode!.isSelected = false;
}
_selectedNode = node;
node.isSelected = true;
_selectedLink = null;
for (final link in _links) {
link.isSelected = false;
}
}
});
}
void _selectLink(Link link) {
setState(() {
if (_selectedLink == link) {
_selectedLink = null;
link.isSelected = false;
} else {
if (_selectedLink != null) {
_selectedLink!.isSelected = false;
}
_selectedLink = link;
link.isSelected = true;
_selectedNode = null;
for (final node in _nodes) {
node.isSelected = false;
}
}
});
}
void _handleTap(Offset position) {
// 检查是否点击了节点
for (final node in _nodes) {
if (position.dx >= node.x &&
position.dx <= node.x + node.width &&
position.dy >= node.y &&
position.dy <= node.y + node.height) {
_selectNode(node);
return;
}
}
// 检查是否点击了连接
for (final link in _links) {
final sourceNode = _nodes.firstWhere((n) => n.id == link.sourceId);
final targetNode = _nodes.firstWhere((n) => n.id == link.targetId);
// 简化的连接点击检测
final path = Path();
path.moveTo(sourceNode.x + sourceNode.width, sourceNode.y + sourceNode.height / 2);
path.quadraticBezierTo(
(sourceNode.x + targetNode.x) / 2,
sourceNode.y + sourceNode.height / 2,
targetNode.x, targetNode.y + targetNode.height / 2,
);
// 简化的路径点击检测
final pathMetrics = path.computeMetrics();
for (final metric in pathMetrics) {
final tangent = metric.getTangentForOffset(metric.length / 2);
if (tangent != null) {
final distance = (position - tangent.position).distance;
if (distance < 10) {
_selectLink(link);
return;
}
}
}
}
}
5. 自定义绘制实现
使用CustomPainter实现桑基图的绘制:
class SankeyDiagramPainter extends CustomPainter {
final List<Node> nodes;
final List<Link> links;
SankeyDiagramPainter({
required this.nodes,
required this.links,
});
void paint(Canvas canvas, Size size) {
// 绘制连接
for (final link in links) {
final sourceNode = nodes.firstWhere((n) => n.id == link.sourceId);
final targetNode = nodes.firstWhere((n) => n.id == link.targetId);
final paint = Paint()
..color = link.isSelected ? link.color.withAlpha(200) : link.color.withAlpha(150)
..strokeWidth = 2;
// 绘制贝塞尔曲线连接
final path = Path();
path.moveTo(sourceNode.x + sourceNode.width, sourceNode.y + sourceNode.height / 2);
path.quadraticBezierTo(
(sourceNode.x + targetNode.x) / 2,
sourceNode.y + sourceNode.height / 2,
targetNode.x, targetNode.y + targetNode.height / 2,
);
canvas.drawPath(path, paint);
}
// 绘制节点
for (final node in nodes) {
final paint = Paint()
..color = node.isSelected ? node.color.withAlpha(200) : node.color.withAlpha(150);
canvas.drawRect(
Rect.fromLTWH(node.x, node.y, node.width, node.height),
paint,
);
// 绘制节点边框
final borderPaint = Paint()
..color = node.isSelected ? Colors.black : Colors.grey
..strokeWidth = node.isSelected ? 2 : 1
..style = PaintingStyle.stroke;
canvas.drawRect(
Rect.fromLTWH(node.x, node.y, node.width, node.height),
borderPaint,
);
// 绘制节点标签
final textPainter = TextPainter(
text: TextSpan(
text: node.name,
style: TextStyle(
color: Colors.black,
fontSize: 12,
),
),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(
canvas,
Offset(node.x + node.width + 5, node.y),
);
}
}
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
6. 组件UI实现
构建完整的桑基图组件UI,包括标题、说明、图表和选中信息:
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withAlpha(51),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 标题
const Text(
'桑基图生成器',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.deepPurple,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// 说明文字
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.deepPurple.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'桑基图用于可视化流量或资源转移路径,展示数据从一个节点到另一个节点的流动情况。点击节点或连接可查看详细信息。',
style: TextStyle(
fontSize: 14,
color: Colors.deepPurple,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
// 桑基图
Center(
child: Container(
width: _diagramWidth + _margin * 2,
height: _diagramHeight + _margin * 2,
child: GestureDetector(
onTapUp: (TapUpDetails details) {
_handleTap(details.localPosition);
},
child: CustomPaint(
painter: SankeyDiagramPainter(
nodes: _nodes,
links: _links,
),
),
),
),
),
const SizedBox(height: 24),
// 选中信息
if (_selectedNode != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _selectedNode!.color.withAlpha(30),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedNode!.color,
width: 2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'节点信息',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('名称: ${_selectedNode!.name}'),
Text('值: ${_selectedNode!.value}'),
Text('ID: ${_selectedNode!.id}'),
],
),
),
if (_selectedLink != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _selectedLink!.color.withAlpha(30),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _selectedLink!.color,
width: 2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'连接信息',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('源节点: ${_nodes.firstWhere((n) => n.id == _selectedLink!.sourceId).name}'),
Text('目标节点: ${_nodes.firstWhere((n) => n.id == _selectedLink!.targetId).name}'),
Text('值: ${_selectedLink!.value}'),
],
),
),
const SizedBox(height: 16),
// 操作按钮
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
setState(() {
_calculateLayout();
if (_selectedNode != null) {
_selectedNode!.isSelected = false;
_selectedNode = null;
}
if (_selectedLink != null) {
_selectedLink!.isSelected = false;
_selectedLink = null;
}
for (final node in _nodes) {
node.isSelected = false;
}
for (final link in _links) {
link.isSelected = false;
}
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
),
child: const Text('重置视图'),
),
],
),
],
),
);
}
7. 组件使用方法
在主页面中导入并使用桑基图组件:
import 'package:flutter/material.dart';
import 'components/sankey_diagram.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter for OpenHarmony',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter for OpenHarmony 实战'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const SizedBox(height: 20),
const Text(
'桑基图演示',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
const SankeyDiagramComponent(),
const SizedBox(height: 40),
],
),
),
);
}
}
本次开发中容易遇到的问题
1. 类嵌套问题
问题:在Flutter中,类不能在其他类内部声明。
错误信息:
Error: Classes can't be declared inside other classes.
Try moving the class to the top-level.
解决方案:将Node和Link类移到顶层声明,不要嵌套在其他类内部。
2. 类型引用错误
问题:当类被移到顶层后,原有的嵌套类型引用会失效。
错误信息:
Error: 'SankeyDiagramComponent._SankeyDiagramComponentState' can't be used as a type because 'SankeyDiagramComponent' doesn't refer to an import prefix.
解决方案:更新所有类型引用,直接使用顶层类名,如List<Node>而不是List<SankeyDiagramComponent._SankeyDiagramComponentState.Node>。
3. 无限闪动问题
问题:点击节点时,节点会无限闪动。
原因:在CustomPainter的hitTest方法中直接调用setState,导致每次重绘都会触发点击事件,形成无限循环。
解决方案:使用GestureDetector包裹CustomPaint,通过onTapUp回调处理点击事件,避免在hitTest中直接修改状态。
4. 路径点击检测精度问题
问题:连接(Link)的点击检测不够精确。
解决方案:使用PathMetrics计算路径中点,通过距离判断是否点击了连接,提高点击检测的准确性。
5. 布局计算问题
问题:节点布局不合理,可能会出现重叠或超出边界的情况。
解决方案:使用更合理的布局算法,考虑节点大小、间距和容器边界,确保布局美观合理。
总结本次开发中用到的技术点
1. Flutter核心技术
- StatefulWidget:用于管理桑基图的状态,包括节点和连接的选中状态
- CustomPaint & CustomPainter:用于自定义绘制桑基图的节点和连接
- GestureDetector:用于处理用户点击事件,实现交互式操作
- setState:用于更新组件状态,触发UI重绘
- Path & Canvas:用于绘制贝塞尔曲线连接,实现平滑的视觉效果
- TextPainter:用于在画布上绘制节点标签文字
2. 数据结构与算法
- 数据模型设计:设计了Node和Link类作为桑基图的数据模型
- 布局算法:实现了基于列的节点布局算法,根据节点值计算大小和位置
- 路径计算:使用贝塞尔曲线计算节点间的连接路径
- 点击检测算法:实现了节点和连接的点击检测逻辑
3. UI设计与交互
- 响应式布局:使用Container和SizedBox实现合理的布局结构
- 视觉反馈:通过颜色变化和边框样式提供选中状态的视觉反馈
- 动画效果:通过状态更新实现平滑的UI变化
- 用户体验:添加了说明文字、操作按钮和选中信息展示
4. 性能优化
- 按需重绘:通过CustomPainter的shouldRepaint方法控制重绘时机
- 高效布局:使用fold方法计算列的总高度,优化布局计算
- 事件处理:使用GestureDetector的onTapUp回调,避免不必要的事件处理
5. 代码组织与最佳实践
- 组件化开发:将桑基图封装为独立组件,提高代码复用性
- 分层设计:将数据模型、布局计算、绘制逻辑和UI展示分离
- 命名规范:使用清晰的命名规范,提高代码可读性
- 错误处理:通过合理的代码结构和类型检查避免常见错误
6. 跨平台适配
- Flutter for OpenHarmony:确保代码在HarmonyOS平台上正常运行
- 平台无关性:使用Flutter的跨平台特性,确保代码在不同平台上表现一致
- 资源管理:合理使用颜色和尺寸资源,适应不同平台的显示需求
通过本次开发,我们不仅实现了一个功能完整的桑基图组件,还掌握了Flutter自定义绘制、状态管理和用户交互的核心技术,为后续的跨平台开发积累了宝贵经验。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)