欢迎加入开源鸿蒙跨平台社区: 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐