Flutter for OpenHarmony 实战:实现一个柱状图
在移动开发领域,我们总是面临着选择与适配。今天,你的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 实时预览 效果展示
运行到鸿蒙虚拟设备中效果展示
目录
功能代码实现
FlHorizontalBarChart 组件实现
FlHorizontalBarChart 是一个基于 fl_chart 库实现的柱状图组件,具有以下特性:
- 支持自定义标题和数据
- 实现点击交互效果
- 包含图例说明
- 响应式布局设计
核心代码实现
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
class FlHorizontalBarChart extends StatefulWidget {
final String title;
final List<Map<String, dynamic>> data;
final double? width;
final double? height;
const FlHorizontalBarChart({
Key? key,
required this.title,
required this.data,
this.width,
this.height,
}) : super(key: key);
State<FlHorizontalBarChart> createState() => _FlHorizontalBarChartState();
}
class _FlHorizontalBarChartState extends State<FlHorizontalBarChart> {
int? _touchedIndex;
String _clickInfo = '';
// 颜色列表
final List<Color> colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.red,
Colors.yellow,
Colors.teal,
Colors.pink,
];
void _onBarTap(BarTouchResponse? response) {
if (response != null && response.spot != null) {
setState(() {
_touchedIndex = response.spot!.touchedBarGroupIndex;
final dataItem = widget.data[_touchedIndex!];
_clickInfo = '点击了: ${dataItem['name']} (${dataItem['value']})';
});
// 显示点击信息提示
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.data[_touchedIndex!]['name']}: ${widget.data[_touchedIndex!]['value']}'),
duration: const Duration(seconds: 1),
),
);
}
}
Widget build(BuildContext context) {
// 计算最大值用于Y轴缩放
double maxValue = 0;
for (var item in widget.data) {
final value = item['value'] as double? ?? (item['value'] as int).toDouble();
if (value > maxValue) {
maxValue = value;
}
}
// 向上取整到最近的100的倍数
maxValue = (maxValue / 100).ceil() * 100;
return Container(
width: widget.width ?? double.infinity,
height: widget.height ?? 400,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 16),
Expanded(
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
barTouchData: BarTouchData(
touchCallback: (event, response) => _onBarTap(response),
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final dataItem = widget.data[groupIndex];
return BarTooltipItem(
'${dataItem['name']}: ${dataItem['value']}',
const TextStyle(color: Colors.white),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < widget.data.length) {
return Text(
widget.data[value.toInt()]['name'],
style: const TextStyle(fontSize: 12),
);
}
return const Text('');
},
interval: 1,
reservedSize: 80,
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Text(
'\$value',
style: const TextStyle(fontSize: 12),
);
},
interval: maxValue / 5,
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: true,
drawHorizontalLine: false,
verticalInterval: maxValue / 5,
),
borderData: FlBorderData(
show: true,
border: const Border(
bottom: BorderSide(),
left: BorderSide(),
),
),
maxY: maxValue,
barGroups: widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final value = data['value'] as double? ?? (data['value'] as int).toDouble();
final isTouched = index == _touchedIndex;
final barWidth = isTouched ? 25.0 : 20.0;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: value,
color: colors[index % colors.length],
width: barWidth,
borderRadius: BorderRadius.circular(4),
),
],
);
}).toList(),
),
),
),
if (_clickInfo.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_clickInfo,
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
),
],
),
);
}
}
组件参数说明
| 参数 | 类型 | 必填 | 描述 |
|---|---|---|---|
| title | String | 是 | 柱状图标题 |
| data | List<Map<String, dynamic>> | 是 | 柱状图数据,每个元素包含 name 和 value 字段 |
| width | double? | 否 | 组件宽度,默认充满父容器 |
| height | double? | 否 | 组件高度,默认 400 |
使用方法
FlHorizontalBarChart(
title: '访问来源分析',
data: [
{'value': 335, 'name': '直接访问'},
{'value': 310, 'name': '邮件营销'},
{'value': 234, 'name': '联盟广告'},
{'value': 135, 'name': '视频广告'},
{'value': 1548, 'name': '搜索引擎'}
],
)
主页面集成
主页面使用 MyHomePage 组件实现,集成了两个 FlHorizontalBarChart 实例,分别展示访问来源分析和产品销售分布数据。
核心代码实现
import 'package:flutter/material.dart';
import 'package:aa/widgets/fl_horizontal_bar_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> {
// 柱状图数据
final List<Map<String, dynamic>> barData1 = [
{'value': 335, 'name': '直接访问'},
{'value': 310, 'name': '邮件营销'},
{'value': 234, 'name': '联盟广告'},
{'value': 135, 'name': '视频广告'},
{'value': 1548, 'name': '搜索引擎'}
];
final List<Map<String, dynamic>> barData2 = [
{'value': 120, 'name': 'A产品'},
{'value': 200, 'name': 'B产品'},
{'value': 150, 'name': 'C产品'},
{'value': 80, 'name': 'D产品'},
{'value': 70, 'name': 'E产品'}
];
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 20),
// 第一个柱状图
FlHorizontalBarChart(
title: '访问来源分析',
data: barData1,
),
const SizedBox(height: 40),
// 第二个柱状图
FlHorizontalBarChart(
title: '产品销售分布',
data: barData2,
),
const SizedBox(height: 40),
const Text(
'交互说明:点击柱状图查看详细信息',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 40),
],
),
),
);
}
}
本次开发中容易遇到的问题
-
依赖管理问题
- 问题:添加 fl_chart 依赖后,需要确保依赖版本与 Flutter 版本兼容,否则可能导致编译失败或运行时错误
- 解决方案:在 pubspec.yaml 中指定合适的 fl_chart 版本,如 ^0.64.0,并执行
flutter pub get命令更新依赖
-
数据格式问题
- 问题:柱状图数据中的 value 字段类型需要为 double,直接使用 int 类型可能导致类型错误
- 解决方案:在构建 BarChartRodData 时,对 int 类型的 value 进行类型转换,如
(value as int).toDouble()
-
布局适配问题
- 问题:在不同屏幕尺寸下,柱状图和标签的布局可能出现异常,特别是在小屏幕设备上
- 解决方案:使用 Expanded 组件和响应式布局,确保在不同屏幕尺寸下的良好显示效果,同时为组件提供可自定义的宽高参数
-
交互响应问题
- 问题:使用 fl_chart 库时,BarTouchData 的 touchCallback 方法签名与预期不符,可能导致编译错误
- 解决方案:使用适配的回调方式,通过匿名函数转换参数格式,确保回调函数能正确接收和处理事件参数
-
Y轴刻度计算问题
- 问题:柱状图的 Y 轴刻度需要根据数据最大值动态计算,否则可能导致刻度显示不合理
- 解决方案:遍历数据计算最大值,然后向上取整到合适的刻度值,确保所有数据都能在图表中完整显示
总结本次开发中用到的技术点
-
Flutter 状态管理
- 使用 StatefulWidget 和 setState 管理组件状态,实现柱状图点击交互效果
- 通过状态变量 _touchedIndex 跟踪当前点击的柱状图
-
Flutter 布局系统
- 使用 Container、Column、Expanded 等布局组件构建响应式界面
- 运用 BoxDecoration 实现卡片阴影效果,提升视觉体验
-
fl_chart 图表库
- 基于 fl_chart 库实现柱状图渲染
- 配置 BarChartData、BarChartGroupData 等参数实现自定义柱状图效果
- 使用 BarTouchData 实现柱状图点击交互和提示信息
-
数据处理
- 使用 List<Map<String, dynamic>> 存储和管理柱状图数据
- 实现数据最大值计算和 Y 轴刻度动态调整
-
用户交互
- 实现柱状图点击事件处理,显示详细信息
- 使用 ScaffoldMessenger 显示 SnackBar 提示信息
-
代码组织
- 采用组件化开发方式,将柱状图封装为独立的 FlHorizontalBarChart 组件
- 分离业务逻辑和 UI 展示,提高代码可维护性
-
跨平台适配
- 选择跨平台兼容性好的 fl_chart 库,避免平台特定依赖
- 确保代码在 Flutter 支持的所有平台(包括 OpenHarmony)上正常运行
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)