欢迎加入开源鸿蒙跨平台社区: 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 实时预览 效果展示
在这里插入图片描述

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

目录

功能代码实现

WeightChart 主组件

WeightChart 是一个 StatefulWidget,负责管理体重记录数据、计算统计信息、处理用户交互以及布局展示。

核心功能实现

1. 数据管理
// 体重记录数据
List<Map<String, dynamic>> weightRecords = [
  {'date': '1月1日', 'weight': 65.5},
  {'date': '1月2日', 'weight': 65.2},
  {'date': '1月3日', 'weight': 64.8},
  {'date': '1月4日', 'weight': 64.5},
  {'date': '1月5日', 'weight': 64.2},
  {'date': '1月6日', 'weight': 63.8},
  {'date': '1月7日', 'weight': 63.5},
];

double newWeight = 63.0;
bool showAddForm = false;
bool showDetails = false;
int selectedIndex = -1;
2. 数据计算方法
// 添加新体重记录
void addWeightRecord() {
  setState(() {
    String today = '1月${weightRecords.length + 1}日';
    weightRecords.add({
      'date': today,
      'weight': newWeight,
    });
    showAddForm = false;
    newWeight = 63.0;
  });
}

// 计算体重变化趋势
double calculateTrend() {
  if (weightRecords.length < 2) return 0;
  double firstWeight = weightRecords[0]['weight'];
  double lastWeight = weightRecords[weightRecords.length - 1]['weight'];
  return lastWeight - firstWeight;
}

// 计算平均体重
double calculateAverageWeight() {
  if (weightRecords.isEmpty) return 0;
  double sum = 0;
  for (var record in weightRecords) {
    sum += record['weight'];
  }
  return sum / weightRecords.length;
}

// 计算最大和最小体重
Map<String, double> calculateMinMaxWeight() {
  if (weightRecords.isEmpty) return {'min': 0, 'max': 0};
  double min = weightRecords[0]['weight'];
  double max = weightRecords[0]['weight'];
  for (var record in weightRecords) {
    if (record['weight'] < min) min = record['weight'];
    if (record['weight'] > max) max = record['weight'];
  }
  return {'min': min, 'max': max};
}

// 选择图表上的点
void selectPoint(int index) {
  setState(() {
    selectedIndex = index;
    showDetails = true;
  });
}
3. 布局结构

Widget build(BuildContext context) {
  double trend = calculateTrend();
  double averageWeight = calculateAverageWeight();
  Map<String, double> minMax = calculateMinMaxWeight();

  return Container(
    padding: const EdgeInsets.all(16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        // 标题
        Text(
          '体重变化图表',
          style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                fontWeight: FontWeight.bold,
              ),
        ),
        const SizedBox(height: 24),

        // 体重统计信息
        Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: Colors.grey[50],
            borderRadius: BorderRadius.circular(16),
            border: Border.all(
              color: Colors.grey[200]!,
              width: 2,
            ),
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              // 当前体重
              Column(
                children: [
                  Text(
                    '当前体重',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                  Text(
                    '${weightRecords.isNotEmpty ? weightRecords.last['weight'] : 0} kg',
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                          fontWeight: FontWeight.bold,
                        ),
                  ),
                ],
              ),
              // 变化趋势
              Column(
                children: [
                  Text(
                    '变化趋势',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                  Text(
                    '${trend > 0 ? '+' : ''}$trend kg',
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                          color: trend < 0 ? Colors.green : Colors.red,
                          fontWeight: FontWeight.bold,
                        ),
                  ),
                ],
              ),
              // 平均体重
              Column(
                children: [
                  Text(
                    '平均体重',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                  Text(
                    '${averageWeight.toStringAsFixed(1)} kg',
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                          fontWeight: FontWeight.bold,
                        ),
                  ),
                ],
              ),
            ],
          ),
        ),

        const SizedBox(height: 32),

        // 体重图表
        Container(
          height: 300,
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: Colors.grey.withOpacity(0.1),
                blurRadius: 12,
                offset: const Offset(0, 4),
              ),
            ],
          ),
          child: Stack(
            children: [
              // 网格背景
              Container(
                child: Column(
                  children: List.generate(6, (index) {
                    return Expanded(
                      child: Row(
                        children: List.generate(8, (colIndex) {
                          return Expanded(
                            child: Container(
                              decoration: BoxDecoration(
                                border: Border(
                                  right: colIndex < 7
                                      ? BorderSide(color: Colors.grey[100]!)
                                      : BorderSide.none,
                                  bottom: index < 5
                                      ? BorderSide(color: Colors.grey[100]!)
                                      : BorderSide.none,
                                ),
                              ),
                            ),
                          );
                        }),
                      ),
                    );
                  }),
                ),
              ),

              // 体重曲线
              if (weightRecords.isNotEmpty)
                CustomPaint(
                  painter: WeightChartPainter(
                    weightRecords: weightRecords,
                    minMax: minMax,
                    selectedIndex: selectedIndex,
                  ),
                  child: Container(),
                ),

              // 图表标签
              Positioned(
                bottom: 0,
                left: 0,
                right: 0,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: weightRecords.asMap().entries.map((entry) {
                    int index = entry.key;
                    var record = entry.value;
                    return GestureDetector(
                      onTap: () => selectPoint(index),
                      child: Container(
                        padding: const EdgeInsets.all(4),
                        child: Text(
                          record['date'],
                          style: Theme.of(context).textTheme.bodySmall,
                        ),
                      ),
                    );
                  }).toList(),
                ),
              ),
            ],
          ),
        ),

        // 详细信息
        if (showDetails && selectedIndex >= 0 && selectedIndex < weightRecords.length)
          Container(
            margin: const EdgeInsets.only(top: 16),
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
              borderRadius: BorderRadius.circular(12),
              border: Border.all(
                color: Theme.of(context).colorScheme.primary,
                width: 2,
              ),
            ),
            child: Column(
              children: [
                Text(
                  '详细信息',
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                ),
                const SizedBox(height: 8),
                Text(
                  '日期: ${weightRecords[selectedIndex]['date']}',
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                Text(
                  '体重: ${weightRecords[selectedIndex]['weight']} kg',
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                if (selectedIndex > 0)
                  Text(
                    '变化: ${(weightRecords[selectedIndex]['weight'] - weightRecords[selectedIndex - 1]['weight'] > 0 ? '+' : '')}${(weightRecords[selectedIndex]['weight'] - weightRecords[selectedIndex - 1]['weight']).toStringAsFixed(1)} kg',
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: weightRecords[selectedIndex]['weight'] < weightRecords[selectedIndex - 1]['weight']
                              ? Colors.green
                              : Colors.red,
                        ),
                  ),
              ],
            ),
          ),

        const SizedBox(height: 32),

        // 添加记录按钮
        ElevatedButton(
          onPressed: () {
            setState(() {
              showAddForm = !showAddForm;
            });
          },
          style: ElevatedButton.styleFrom(
            padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(24),
            ),
            backgroundColor: Theme.of(context).colorScheme.primary,
          ),
          child: Text(
            showAddForm ? '取消' : '添加记录',
            style: Theme.of(context).textTheme.titleMedium?.copyWith(
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
          ),
        ),

        // 添加记录表单
        if (showAddForm)
          Container(
            margin: const EdgeInsets.only(top: 16),
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(16),
              boxShadow: [
                BoxShadow(
                  color: Colors.grey.withOpacity(0.1),
                  blurRadius: 12,
                  offset: const Offset(0, 4),
                ),
              ],
            ),
            child: Column(
              children: [
                Text(
                  '添加新体重记录',
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                ),
                const SizedBox(height: 16),
                TextField(
                  keyboardType: TextInputType.number,
                  onChanged: (value) {
                    setState(() {
                      newWeight = double.tryParse(value) ?? 63.0;
                    });
                  },
                  decoration: InputDecoration(
                    labelText: '体重 (kg)',
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(12),
                    ),
                    filled: true,
                    fillColor: Colors.grey[50],
                  ),
                ),
                const SizedBox(height: 16),
                ElevatedButton(
                  onPressed: addWeightRecord,
                  style: ElevatedButton.styleFrom(
                    padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(24),
                    ),
                    backgroundColor: Theme.of(context).colorScheme.secondary,
                  ),
                  child: Text(
                    '保存记录',
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: Colors.white,
                          fontWeight: FontWeight.bold,
                        ),
                  ),
                ),
              ],
            ),
          ),
      ],
    ),
  );
}

开发注意事项

  1. 状态管理:使用 setState() 管理组件状态,确保数据变化时 UI 能够及时更新。

  2. 数据验证:在添加新体重记录时,使用 double.tryParse() 进行数据验证,避免无效输入导致应用崩溃。

  3. 响应式布局:使用 Theme.of(context) 获取主题样式,确保组件在不同主题下都能正常显示。

  4. 用户交互:通过 GestureDetector 实现图表点的点击交互,提升用户体验。

WeightChartPainter 自定义绘制器

WeightChartPainter 是一个 CustomPainter,负责绘制体重变化曲线和数据点。

核心实现

class WeightChartPainter extends CustomPainter {
  final List<Map<String, dynamic>> weightRecords;
  final Map<String, double> minMax;
  final int selectedIndex;

  WeightChartPainter({
    required this.weightRecords,
    required this.minMax,
    required this.selectedIndex,
  });

  
  void paint(Canvas canvas, Size size) {
    if (weightRecords.isEmpty) return;

    double minWeight = minMax['min'] ?? 0;
    double maxWeight = minMax['max'] ?? 0;
    double weightRange = maxWeight - minWeight;
    if (weightRange == 0) weightRange = 1;

    // 绘制曲线
    Paint linePaint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 3
      ..style = PaintingStyle.stroke;

    // 计算点的位置
    List<Offset> points = [];
    for (int i = 0; i < weightRecords.length; i++) {
      double x = (i / (weightRecords.length - 1)) * (size.width - 40) + 20;
      double y = size.height - 40 - ((weightRecords[i]['weight'] - minWeight) / weightRange) * (size.height - 80);
      points.add(Offset(x, y));
    }

    // 绘制曲线
    Path path = Path();
    path.moveTo(points[0].dx, points[0].dy);
    for (int i = 1; i < points.length; i++) {
      path.lineTo(points[i].dx, points[i].dy);
    }
    canvas.drawPath(path, linePaint);

    // 绘制点
    for (int i = 0; i < points.length; i++) {
      Paint pointPaint = Paint()
        ..color = i == selectedIndex ? Colors.red : Colors.blue
        ..style = PaintingStyle.fill;

      canvas.drawCircle(points[i], i == selectedIndex ? 6 : 4, pointPaint);

      // 绘制选中点的连接线
      if (i == selectedIndex) {
        Paint linePaint = Paint()
          ..color = Colors.red
          ..strokeWidth = 1
          ..style = PaintingStyle.stroke
          ..strokeCap = StrokeCap.round
          ..strokeJoin = StrokeJoin.round
          ..blendMode = BlendMode.srcOver
          ..maskFilter = MaskFilter.blur(BlurStyle.normal, 1.0);

        canvas.drawLine(points[i], Offset(points[i].dx, size.height - 40), linePaint);
      }
    }
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

开发注意事项

  1. 坐标计算:确保图表点的坐标计算准确,特别是在不同屏幕尺寸下的适配。

  2. 性能优化:在 shouldRepaint() 方法中返回 true,确保数据变化时图表能够重新绘制。

  3. 视觉效果:通过不同的颜色和大小区分选中点和普通点,提升用户体验。

  4. 边界处理:当 weightRange 为 0 时,设置默认值 1,避免除以零的错误。

组件使用方法

在首页直接使用

import 'package:flutter/material.dart';
import 'components/weight_chart.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '体重变化图表',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: '体重变化图表'),
    );
  }
}

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(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: const SingleChildScrollView(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const WeightChart(),
            ],
          ),
        ),
      ),
    );
  }
}

使用步骤

  1. 导入组件:在需要使用的文件中导入 weight_chart.dart

  2. 添加到布局:将 WeightChart 组件添加到页面布局中。

  3. 数据管理:组件内部已经实现了数据管理逻辑,无需外部传入数据。

  4. 交互操作

    • 点击图表下方的日期标签查看详细信息
    • 点击 “添加记录” 按钮添加新的体重记录
    • 在表单中输入体重值,点击 “保存记录” 完成添加

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

1. 图表绘制问题

问题描述:在绘制体重变化曲线时,可能会出现曲线不连续或点位置计算错误的情况。

解决方案

  • 确保 weightRecords 不为空再进行绘制
  • 正确计算每个点的坐标,特别是 x 轴和 y 轴的比例
  • 当体重范围为 0 时,设置默认值 1,避免除以零的错误

2. 状态管理问题

问题描述:添加新体重记录后,UI 可能不会及时更新,或者状态管理混乱导致应用崩溃。

解决方案

  • 所有状态变化都通过 setState() 方法进行管理
  • 确保状态变量的初始化和更新逻辑正确
  • 在添加记录后,重置表单状态和输入值

3. 用户交互问题

问题描述:点击图表点时,可能不会显示详细信息,或者点击区域不准确。

解决方案

  • 使用 GestureDetector 包裹每个日期标签,确保点击事件能够正确触发
  • 确保 selectedIndex 的值在有效范围内
  • 点击事件处理函数中,正确更新 selectedIndexshowDetails 状态

4. 数据验证问题

问题描述:用户输入无效的体重值时,可能会导致应用崩溃或数据错误。

解决方案

  • 使用 double.tryParse() 进行数据验证,避免无效输入
  • 设置默认体重值,确保即使输入无效也能正常添加记录
  • 在 UI 中添加适当的提示,引导用户输入有效的体重值

5. 布局适配问题

问题描述:在不同屏幕尺寸下,图表可能会显示不全或布局混乱。

解决方案

  • 使用 Containerpaddingmargin 控制组件间距
  • 使用 ExpandedFlexible 组件实现灵活布局
  • 确保图表容器有固定的高度,避免内容溢出

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

1. Flutter 基础组件

  • StatefulWidget:用于管理有状态的组件,如体重记录数据和表单状态。
  • StatelessWidget:用于构建无状态的 UI 组件,如标题和统计信息。
  • Container:用于布局和样式控制,提供 padding、margin、decoration 等属性。
  • Column 和 Row:用于垂直和水平布局,实现灵活的 UI 结构。
  • Text:用于显示文本信息,支持不同的样式和主题。
  • ElevatedButton:用于添加交互按钮,支持自定义样式和点击事件。
  • TextField:用于用户输入,支持键盘类型设置和输入验证。
  • GestureDetector:用于实现点击、滑动等手势交互。
  • Stack:用于层叠布局,实现图表和网格背景的叠加显示。
  • Positioned:用于在 Stack 中精确定位子组件。
  • SingleChildScrollView:用于处理内容溢出,确保在小屏幕上也能正常显示。

2. 自定义绘制

  • CustomPaint:用于绘制自定义图形,如体重变化曲线。
  • CustomPainter:用于实现具体的绘制逻辑,控制画笔和路径。
  • Canvas:提供绘制操作的画布,支持绘制线条、圆形、路径等。
  • Paint:用于设置画笔的颜色、宽度、样式等属性。
  • Path:用于定义绘制路径,实现曲线和形状的绘制。

3. 状态管理

  • setState():用于更新组件状态,触发 UI 重新构建。
  • 状态变量:用于存储组件的状态信息,如体重记录、表单状态、选中索引等。

4. 数据处理

  • List 操作:用于管理体重记录数据,如添加新记录和遍历计算。
  • Map 操作:用于存储和访问体重记录的日期和体重值。
  • 数学计算:用于计算体重变化趋势、平均体重、最大最小体重等统计信息。
  • 数据验证:用于验证用户输入的体重值,确保数据的有效性。

5. 主题和样式

  • Theme.of(context):用于获取应用的主题样式,确保组件在不同主题下的一致性。
  • ColorScheme:用于获取主题的颜色方案,实现与系统主题的适配。
  • BoxDecoration:用于设置容器的背景、边框、阴影等样式。
  • BorderRadius:用于设置圆角,提升 UI 的视觉效果。
  • BoxShadow:用于添加阴影效果,增强 UI 的层次感。

6. 响应式设计

  • 自适应布局:使用相对单位和弹性布局,确保组件在不同屏幕尺寸下都能正常显示。
  • 主题适配:通过 Theme.of(context) 获取主题样式,确保组件在不同主题下的一致性。
  • 内容溢出处理:使用 SingleChildScrollView 处理内容溢出,确保在小屏幕上也能正常显示。

7. 用户体验

  • 交互反馈:通过颜色变化、动画效果等提供即时的交互反馈。
  • 错误处理:通过数据验证和默认值设置,避免用户操作导致的错误。
  • 视觉层次:通过布局、颜色、阴影等元素,创建清晰的视觉层次,提升用户体验。
  • 信息展示:通过图表、统计信息、详细信息等多种形式,直观展示体重变化数据。

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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐