欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

目录

前言:跨生态开发的新机遇

在移动开发领域,我们总是面临着选择与适配。今天,你的Flutter应用在Android和iOS上跑得正欢,明天可能就需要考虑一个新的平台:HarmonyOS(鸿蒙)。这不是一道选答题,而是很多团队正在面对的现实。

Flutter的优势很明确——写一套代码,就能在两个主要平台上运行,开发体验流畅。而鸿蒙代表的是下一个时代的互联生态,它不仅仅是手机系统,更着眼于未来全场景的体验。将现有的Flutter应用适配到鸿蒙,听起来像是一个“跨界”任务,但它本质上是一次有价值的技术拓展:让产品触达更多用户,也让技术栈覆盖更广。

不过,这条路走起来并不像听起来那么简单。Flutter和鸿蒙,从底层的架构到上层的工具链,都有着各自的设计逻辑。会遇到一些具体的问题:代码如何组织?原有的功能在鸿蒙上如何实现?那些平台特有的能力该怎么调用?更实际的是,从编译打包到上架部署,整个流程都需要重新摸索。
这篇文章想做的,就是把这些我们趟过的路、踩过的坑,清晰地摊开给你看。我们不会只停留在“怎么做”,还会聊到“为什么得这么做”,以及“如果出了问题该往哪想”。这更像是一份实战笔记,源自真实的项目经验,聚焦于那些真正卡住过我们的环节。

无论你是在为一个成熟产品寻找新的落地平台,还是从一开始就希望构建能面向多端的应用,这里的思路和解决方案都能提供直接的参考。理解了两套体系之间的异同,掌握了关键的衔接技术,不仅能完成这次迁移,更能积累起应对未来技术变化的能力。

混合工程结构深度解析

项目目录架构

当Flutter项目集成鸿蒙支持后,典型的项目结构会发生显著变化。以下是经过ohos_flutter插件初始化后的项目结构:

my_flutter_harmony_app/
├── lib/                          # Flutter业务代码(基本不变)
│   ├── main.dart                 # 应用入口
│   ├── components/               # 组件目录
│   │   └── gantt_chart.dart      # 甘特图组件
│   └── utils/                    # 工具类目录
├── 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 实时预览 效果展示
在这里插入图片描述

运行到鸿蒙虚拟设备中效果展示
在这里插入图片描述

功能代码实现

甘特图组件设计与实现

1. 数据模型设计

甘特图组件的核心是数据模型,我们需要设计两个主要的模型:GanttTask(任务)和GanttDependency(任务依赖关系)。

// 甘特图任务模型
class GanttTask {
  final String id;
  final String name;
  final DateTime startDate;
  final DateTime endDate;
  final Color color;
  bool isSelected;

  GanttTask({
    required this.id,
    required this.name,
    required this.startDate,
    required this.endDate,
    this.color = Colors.blue,
    this.isSelected = false,
  });

  Duration get duration => endDate.difference(startDate);
}

// 任务依赖关系模型
class GanttDependency {
  final String sourceId;
  final String targetId;

  GanttDependency({
    required this.sourceId,
    required this.targetId,
  });
}

2. 甘特图组件实现

甘特图组件使用StatefulWidget实现,因为它需要管理任务的选中状态。

// 甘特图组件
class GanttChartComponent extends StatefulWidget {
  const GanttChartComponent({super.key});

  
  State<GanttChartComponent> createState() => _GanttChartComponentState();
}

class _GanttChartComponentState extends State<GanttChartComponent> {
  // 示例数据
  final List<GanttTask> _tasks = [
    GanttTask(
      id: '1',
      name: '需求分析',
      startDate: DateTime(2024, 1, 1),
      endDate: DateTime(2024, 1, 5),
      color: Colors.blue,
    ),
    // 其他任务...
  ];

  // 任务依赖关系
  final List<GanttDependency> _dependencies = [
    GanttDependency(sourceId: '1', targetId: '2'),
    // 其他依赖关系...
  ];

  // 布局参数
  final double _taskHeight = 40;
  final double _taskPadding = 8;
  final double _chartWidth = 600;
  final double _chartHeight = 300;
  final double _taskNameWidth = 120;
  final double _timeAxisHeight = 60;

  // 选中状态
  GanttTask? _selectedTask;

  // 计算时间范围
  DateTime get _minDate {
    return _tasks.map((task) => task.startDate).reduce((a, b) => a.isBefore(b) ? a : b);
  }

  DateTime get _maxDate {
    return _tasks.map((task) => task.endDate).reduce((a, b) => a.isAfter(b) ? a : b);
  }

  // 处理任务点击
  void _selectTask(GanttTask task) {
    setState(() {
      if (_selectedTask == task) {
        _selectedTask = null;
        task.isSelected = false;
      } else {
        if (_selectedTask != null) {
          _selectedTask!.isSelected = false;
        }
        _selectedTask = task;
        task.isSelected = true;
      }
    });
  }

  
  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: _chartWidth + _taskNameWidth + 40,
              height: _chartHeight + _timeAxisHeight + 40,
              child: SingleChildScrollView(
                scrollDirection: Axis.horizontal,
                child: Container(
                  width: _chartWidth + _taskNameWidth + 40,
                  child: CustomPaint(
                    painter: GanttChartPainter(
                      tasks: _tasks,
                      dependencies: _dependencies,
                      minDate: _minDate,
                      maxDate: _maxDate,
                      taskHeight: _taskHeight,
                      taskPadding: _taskPadding,
                      chartWidth: _chartWidth,
                      taskNameWidth: _taskNameWidth,
                      timeAxisHeight: _timeAxisHeight,
                      onTaskTap: _selectTask,
                    ),
                  ),
                ),
              ),
            ),
          ),
          const SizedBox(height: 24),

          // 选中信息
          if (_selectedTask != null)
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: _selectedTask!.color.withAlpha(30),
                borderRadius: BorderRadius.circular(8),
                border: Border.all(
                  color: _selectedTask!.color,
                  width: 2,
                ),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '任务信息',
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text('任务名称: ${_selectedTask!.name}'),
                  Text('开始时间: ${_selectedTask!.startDate.toString().split(' ')[0]}'),
                  Text('结束时间: ${_selectedTask!.endDate.toString().split(' ')[0]}'),
                  Text('持续时间: ${_selectedTask!.duration.inDays} 天'),
                ],
              ),
            ),

          const SizedBox(height: 16),

          // 操作按钮
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    if (_selectedTask != null) {
                      _selectedTask!.isSelected = false;
                      _selectedTask = null;
                    }
                    for (final task in _tasks) {
                      task.isSelected = false;
                    }
                  });
                },
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.deepPurple,
                ),
                child: const Text('重置选中'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

3. 甘特图画布绘制器

甘特图的可视化部分使用CustomPaintCustomPainter实现,我们需要创建一个GanttChartPainter类来处理绘制逻辑。

// 甘特图画布绘制器
class GanttChartPainter extends CustomPainter {
  final List<GanttTask> tasks;
  final List<GanttDependency> dependencies;
  final DateTime minDate;
  final DateTime maxDate;
  final double taskHeight;
  final double taskPadding;
  final double chartWidth;
  final double taskNameWidth;
  final double timeAxisHeight;
  final Function(GanttTask) onTaskTap;
  
  // 记录上次点击的任务,防止重复触发
  GanttTask? _lastTappedTask;

  GanttChartPainter({
    required this.tasks,
    required this.dependencies,
    required this.minDate,
    required this.maxDate,
    required this.taskHeight,
    required this.taskPadding,
    required this.chartWidth,
    required this.taskNameWidth,
    required this.timeAxisHeight,
    required this.onTaskTap,
  });

  
  void paint(Canvas canvas, Size size) {
    // 绘制时间轴
    _drawTimeAxis(canvas, size);

    // 绘制任务名称和任务条
    _drawTasks(canvas, size);

    // 绘制依赖关系
    _drawDependencies(canvas, size);
  }

  // 绘制时间轴
  void _drawTimeAxis(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey
      ..strokeWidth = 1;

    // 绘制时间轴背景
    canvas.drawRect(
      Rect.fromLTWH(taskNameWidth, 0, chartWidth, timeAxisHeight),
      Paint()..color = Colors.grey.withAlpha(20),
    );

    // 绘制时间轴刻度和标签
    final totalDays = maxDate.difference(minDate).inDays;
    const interval = 5; // 每5天一个刻度

    for (int i = 0; i <= totalDays; i += interval) {
      final x = taskNameWidth + (i / totalDays) * chartWidth;
      final date = minDate.add(Duration(days: i));

      // 绘制刻度线
      canvas.drawLine(
        Offset(x, timeAxisHeight - 10),
        Offset(x, timeAxisHeight),
        paint,
      );

      // 绘制日期标签
      final textPainter = TextPainter(
        text: TextSpan(
          text: '${date.month}/${date.day}',
          style: TextStyle(
            fontSize: 10,
            color: Colors.grey[600],
          ),
        ),
        textDirection: TextDirection.ltr,
      )..layout();

      textPainter.paint(
        canvas,
        Offset(x - textPainter.width / 2, timeAxisHeight - 30),
      );
    }

    // 绘制时间轴底部边框
    canvas.drawLine(
      Offset(taskNameWidth, timeAxisHeight),
      Offset(taskNameWidth + chartWidth, timeAxisHeight),
      Paint()..color = Colors.grey..strokeWidth = 2,
    );
  }

  // 绘制任务名称和任务条
  void _drawTasks(canvas, size) {
    for (int i = 0; i < tasks.length; i++) {
      final task = tasks[i];
      final y = timeAxisHeight + i * (taskHeight + taskPadding) + taskPadding;

      // 绘制任务名称
      final textPainter = TextPainter(
        text: TextSpan(
          text: task.name,
          style: TextStyle(
            fontSize: 14,
            color: Colors.black,
            fontWeight: task.isSelected ? FontWeight.bold : FontWeight.normal,
          ),
        ),
        textDirection: TextDirection.ltr,
        maxLines: 1,
        ellipsis: '...',
      )..layout(maxWidth: taskNameWidth - 16);

      textPainter.paint(
        canvas,
        Offset(8, y + (taskHeight - textPainter.height) / 2),
      );

      // 计算任务条位置和宽度
      final totalDays = maxDate.difference(minDate).inDays;
      final taskStartDays = task.startDate.difference(minDate).inDays;
      final taskDurationDays = task.duration.inDays;

      final x = taskNameWidth + (taskStartDays / totalDays) * chartWidth;
      final width = (taskDurationDays / totalDays) * chartWidth;

      // 绘制任务条
      final taskPaint = Paint()
        ..color = task.isSelected ? task.color.withAlpha(200) : task.color.withAlpha(150);

      final taskRect = Rect.fromLTWH(x, y, width, taskHeight);
      canvas.drawRRect(
        RRect.fromRectAndRadius(taskRect, Radius.circular(4)),
        taskPaint,
      );

      // 绘制任务条边框
      if (task.isSelected) {
        canvas.drawRRect(
          RRect.fromRectAndRadius(taskRect, Radius.circular(4)),
          Paint()
            ..color = Colors.black
            ..strokeWidth = 2
            ..style = PaintingStyle.stroke,
        );
      }
    }
  }

  // 绘制依赖关系
  void _drawDependencies(canvas, size) {
    final paint = Paint()
      ..color = Colors.grey[400]!
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    for (final dependency in dependencies) {
      final sourceTask = tasks.firstWhere(
        (task) => task.id == dependency.sourceId,
        orElse: () => tasks[0],
      );
      final targetTask = tasks.firstWhere(
        (task) => task.id == dependency.targetId,
        orElse: () => tasks[0],
      );

      final sourceIndex = tasks.indexOf(sourceTask);
      final targetIndex = tasks.indexOf(targetTask);

      if (sourceIndex != -1 && targetIndex != -1) {
        final totalDays = maxDate.difference(minDate).inDays;
        final sourceEndX = taskNameWidth +
            ((sourceTask.endDate.difference(minDate).inDays) / totalDays) * chartWidth;
        final targetStartX = taskNameWidth +
            ((targetTask.startDate.difference(minDate).inDays) / totalDays) * chartWidth;

        final sourceY = timeAxisHeight +
            sourceIndex * (taskHeight + taskPadding) +
            taskPadding +
            taskHeight / 2;
        final targetY = timeAxisHeight +
            targetIndex * (taskHeight + taskPadding) +
            taskPadding +
            taskHeight / 2;

        // 绘制连接线
        final path = Path();
        path.moveTo(sourceEndX, sourceY);
        path.lineTo(sourceEndX + 10, sourceY);
        path.lineTo(sourceEndX + 10, (sourceY + targetY) / 2);
        path.lineTo(targetStartX - 10, (sourceY + targetY) / 2);
        path.lineTo(targetStartX - 10, targetY);
        path.lineTo(targetStartX, targetY);
        canvas.drawPath(path, paint);

        // 绘制箭头
        final arrowPath = Path();
        arrowPath.moveTo(targetStartX, targetY);
        arrowPath.lineTo(targetStartX - 6, targetY - 3);
        arrowPath.lineTo(targetStartX - 6, targetY + 3);
        arrowPath.close();
        canvas.drawPath(arrowPath, Paint()..color = Colors.grey[400]!);
      }
    }
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // 只有当旧绘制器的数据与当前不同时才重绘
    if (oldDelegate is GanttChartPainter) {
      // 检查任务列表是否相同
      if (oldDelegate.tasks.length != tasks.length) return true;
      
      // 检查任务是否有变化
      for (int i = 0; i < tasks.length; i++) {
        if (oldDelegate.tasks[i].isSelected != tasks[i].isSelected) {
          return true;
        }
      }
      
      // 其他数据检查...
      return false;
    }
    return true;
  }

  
  bool hitTest(Offset position) {
    // 检查是否点击了任务条
    for (int i = 0; i < tasks.length; i++) {
      final task = tasks[i];
      final y = timeAxisHeight + i * (taskHeight + taskPadding) + taskPadding;

      final totalDays = maxDate.difference(minDate).inDays;
      final taskStartDays = task.startDate.difference(minDate).inDays;
      final taskDurationDays = task.duration.inDays;

      final x = taskNameWidth + (taskStartDays / totalDays) * chartWidth;
      final width = (taskDurationDays / totalDays) * chartWidth;

      final taskRect = Rect.fromLTWH(x, y, width, taskHeight);

      if (taskRect.contains(position)) {
        // 只有当点击的任务与上次不同时才触发回调
        if (_lastTappedTask != task) {
          _lastTappedTask = task;
          onTaskTap(task);
        }
        return true;
      }
    }
    
    // 如果没有点击任务条,重置上次点击的任务
    _lastTappedTask = null;
    return false;
  }
}

4. 主页面集成

在主页面中,我们需要导入并使用甘特图组件。

import 'package:flutter/material.dart';
import 'components/gantt_chart.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(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      debugShowCheckedModeBanner: false,
      home: const MyHomePage(title: 'Flutter for openHarmony'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  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 GanttChartComponent(),
            const SizedBox(height: 40),
          ],
        ),
      ),
    );
  }
}

本次开发中容易遇到的问题

1. 点击焦点闪烁问题

问题描述

在开发甘特图组件时,点击任务条会出现焦点闪烁的现象,影响用户体验。

原因分析

  • 点击事件被重复触发,导致任务的选中状态在选中和未选中之间快速切换。
  • 每次点击都会触发组件的重绘,即使数据没有变化。

解决方案

  • GanttChartPainter类中添加_lastTappedTask变量,记录上次点击的任务,防止重复触发点击事件。
  • 优化shouldRepaint方法,只有当任务的选中状态发生变化时才重绘组件,减少不必要的重绘。

2. 任务依赖关系绘制问题

问题描述

任务依赖关系的连接线绘制不正确,特别是当任务之间的时间间隔较大时。

原因分析

  • 依赖关系的计算逻辑没有考虑到任务的实际时间范围。
  • 连接线的路径绘制不够平滑,影响视觉效果。

解决方案

  • 正确计算任务的开始和结束位置,确保连接线能够准确连接两个任务。
  • 使用Path类绘制平滑的连接线,并添加箭头指示依赖方向。

3. 时间轴刻度计算问题

问题描述

时间轴的刻度计算不正确,导致任务条的位置和宽度与实际时间不符。

原因分析

  • 时间轴的总宽度计算错误,没有考虑到任务的实际时间范围。
  • 刻度间隔设置不合理,导致时间标签显示不清晰。

解决方案

  • 正确计算任务的时间范围,确保时间轴能够覆盖所有任务的开始和结束时间。
  • 设置合理的刻度间隔,确保时间标签显示清晰易读。

4. 组件性能优化问题

问题描述

当任务数量较多时,甘特图组件的性能会下降,特别是在滚动和点击操作时。

原因分析

  • 每次操作都会触发整个组件的重绘,即使只有部分内容需要更新。
  • 绘制逻辑不够高效,没有利用缓存机制。

解决方案

  • 优化shouldRepaint方法,只有当数据发生变化时才重绘组件。
  • 考虑使用RepaintBoundary包裹组件,减少不必要的重绘。
  • 对于大量任务的场景,可以考虑使用虚拟化技术,只绘制可见区域的任务。

总结本次开发中用到的技术点

1. Flutter核心技术

1.1 自定义组件开发

  • 使用StatefulWidgetStatelessWidget构建组件
  • 利用CustomPaintCustomPainter实现自定义绘制
  • 掌握组件的生命周期管理和状态管理

1.2 布局与滚动

  • 使用SingleChildScrollView实现横向滚动
  • 利用ContainerColumnRow等布局组件构建界面
  • 掌握BoxDecoration实现组件的样式美化

1.3 手势与交互

  • 实现hitTest方法处理点击事件
  • 利用setState更新组件状态
  • 掌握手势检测和事件处理机制

2. 图形绘制技术

2.1 Canvas绘制

  • 使用Canvas绘制任务条、时间轴和依赖关系
  • 掌握PaintPathRect等绘制类的使用
  • 实现文本绘制和样式设置

2.2 坐标计算

  • 实现时间到坐标的转换
  • 计算任务条的位置和宽度
  • 处理依赖关系的连接线坐标

3. 数据模型设计

3.1 任务模型

  • 设计GanttTask类表示任务
  • 实现任务的开始时间、结束时间和持续时间计算
  • 管理任务的选中状态

3.2 依赖关系模型

  • 设计GanttDependency类表示任务依赖关系
  • 实现依赖关系的可视化绘制

4. 性能优化技术

4.1 重绘优化

  • 优化shouldRepaint方法,减少不必要的重绘
  • 实现点击事件的防重复触发机制

4.2 计算优化

  • 缓存时间范围计算结果
  • 优化绘制逻辑,提高绘制效率

5. 鸿蒙平台适配

5.1 项目结构适配

  • 了解Flutter项目集成鸿蒙支持后的目录结构
  • 掌握鸿蒙原生层的配置和开发

5.2 平台特性适配

  • 确保组件在鸿蒙平台上的显示效果与Flutter一致
  • 处理平台特有的手势和交互差异

6. 开发工具与实践

6.1 代码组织

  • 采用模块化的代码组织方式
  • 将组件抽离为独立的文件,提高代码复用性

6.2 调试技巧

  • 利用Flutter的热重载功能快速调试
  • 使用print语句和调试工具定位问题

6.3 最佳实践

  • 遵循Flutter的代码风格和最佳实践
  • 编写清晰、可维护的代码
  • 提供详细的注释和文档

通过本次开发,我们成功实现了一个功能完整、交互友好的甘特图组件,并且掌握了一系列Flutter开发和鸿蒙平台适配的技术点。这些技术点不仅适用于甘特图组件的开发,也可以应用于其他类似的可视化组件开发中。

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐