Flutter for OpenHarmony 第三方库实战:使用 fl_chart 构建消费统计图表应用
在移动应用开发中,图表展示是非常常见的功能。例如记账应用、学习统计、运动记录、项目进度、打卡数据、成绩分析等,都需要通过图表让数据更直观。如果只使用普通文字展示数据,用户需要自己在脑子里计算比例和趋势。让用户看一串数字再自己理解变化,本质上就是把产品经理没做完的活扔给用户,多少有点不讲武德。图表可以让数据更容易理解。饼图适合展示分类占比;柱状图适合展示不同时间或不同类别的数据对比;折线图适合展示连
欢迎加入开源鸿蒙跨平台社区:
https://openharmonycrossplatform.csdn.net
项目效果
本文实现的是一个基于 Flutter for OpenHarmony 的消费统计图表应用。项目中使用 Flutter 第三方库 fl_chart 实现饼图和柱状图,用于展示不同消费分类占比和一周消费趋势。
最终运行效果如下:

页面主要包含以下内容:
- 顶部标题栏;
- 本月消费概览卡片;
- 消费分类饼图;
- 一周消费柱状图;
- 消费分类明细列表;
- 切换月份按钮;
- 第三方库使用说明;
- 页面整体采用 Flutter Material 风格布局。
本文重点是演示如何在 Flutter for OpenHarmony 项目中使用 Flutter 第三方库 fl_chart。项目代码写在 lib/main.dart 中,依赖配置写在 pubspec.yaml 中,符合 Flutter for OpenHarmony 第三方库实践方向。
前言
在移动应用开发中,图表展示是非常常见的功能。例如记账应用、学习统计、运动记录、项目进度、打卡数据、成绩分析等,都需要通过图表让数据更直观。
如果只使用普通文字展示数据,用户需要自己在脑子里计算比例和趋势。让用户看一串数字再自己理解变化,本质上就是把产品经理没做完的活扔给用户,多少有点不讲武德。
图表可以让数据更容易理解。例如:
- 饼图适合展示分类占比;
- 柱状图适合展示不同时间或不同类别的数据对比;
- 折线图适合展示连续变化趋势。
本文选择使用 Flutter 第三方库 fl_chart 实现图表展示。它可以快速构建饼图、柱状图、折线图等常见图表,适合用于 Flutter for OpenHarmony 项目中的数据可视化页面。
本项目以“消费统计图表应用”为例,使用 fl_chart 展示消费分类占比和一周消费趋势。
一、项目目标
本次实践主要实现以下目标:
- 创建 Flutter for OpenHarmony 项目;
- 在
pubspec.yaml中添加第三方库fl_chart; - 使用
flutter pub get获取依赖; - 在
lib/main.dart中引入fl_chart; - 使用
PieChart构建消费分类饼图; - 使用
BarChart构建一周消费柱状图; - 实现消费分类明细展示;
- 实现月份数据切换;
- 使用 Flutter Material 组件构建完整页面;
- 将应用运行到 OpenHarmony 设备或模拟器中。
二、技术栈
| 类型 | 内容 |
|---|---|
| 开发方向 | Flutter for OpenHarmony |
| 开发语言 | Dart |
| UI 框架 | Flutter |
| 第三方库 | fl_chart |
| 功能场景 | 数据可视化 / 消费统计 / 图表展示 |
| 核心组件 | PieChart / BarChart / PieChartData / BarChartData |
| 项目入口 | lib/main.dart |
| 依赖配置 | pubspec.yaml |
| 运行平台 | OpenHarmony 设备或模拟器 |
三、为什么选择 fl_chart
在实际开发中,图表组件可以用于很多场景,例如:
- 消费分类统计;
- 学习时长统计;
- 运动数据分析;
- 任务完成率;
- 成绩变化趋势;
- 用户增长数据;
- 销售数据展示;
- 项目进度分析;
- 健康数据记录。
如果自己使用 Flutter 原生组件画图表,需要处理坐标、比例、文字标注、颜色、布局、触摸事件和动画效果。理论上可以实现,但为了画一个柱状图先开始造图表引擎,这种努力很感人,也很浪费生命。
fl_chart 已经封装好了常见图表组件,可以让开发者把重点放在业务数据和页面交互上。
在本项目中,fl_chart 主要完成以下工作:
- 使用饼图展示消费分类占比;
- 使用柱状图展示一周消费趋势;
- 通过图表让消费数据更直观;
- 减少手写图表布局的复杂度;
- 提升页面的数据展示效果。
四、创建 Flutter for OpenHarmony 项目
在已经配置好 Flutter for OpenHarmony 开发环境的前提下,可以创建一个 Flutter 项目。
示例项目名称:
flutter create expense_chart_demo
进入项目目录:
cd expense_chart_demo
项目创建完成后,主要关注两个文件:
expense_chart_demo
├── pubspec.yaml
└── lib
└── main.dart
其中:
| 文件 | 作用 |
|---|---|
| pubspec.yaml | 配置 Flutter 项目依赖 |
| lib/main.dart | 编写 Flutter 页面和业务逻辑 |
五、添加 fl_chart 第三方库
打开项目根目录下的 pubspec.yaml 文件,在 dependencies 中添加 fl_chart。
示例配置如下:
dependencies:
flutter:
sdk: flutter
fl_chart: ^1.2.0
完整结构大致如下:
name: expense_chart_demo
description: A Flutter for OpenHarmony chart demo.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.6.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
fl_chart: ^1.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
添加完成后,在终端执行:
flutter pub get
执行成功后,就可以在 Dart 代码中使用 fl_chart 了。
六、项目结构
本项目主要修改 lib/main.dart 文件:
lib
└── main.dart
本项目不需要编写 OpenHarmony 原生 ArkTS 页面,也不需要修改 Index.ets。
因为这是 Flutter for OpenHarmony 项目,页面主体应该是 Flutter 代码。审核重点会看:
- 是否使用
pubspec.yaml添加 Flutter 第三方库; - 是否在 Dart 文件中
import package; - 是否在
lib/main.dart中实际调用第三方库; - 是否属于 Flutter for OpenHarmony 项目。
看到 pubspec.yaml、lib/main.dart、import 'package:fl_chart/fl_chart.dart';,这才是正确方向。不是把标题写成 Flutter,代码就会自动投胎成 Flutter。
七、核心实现思路
本项目的核心流程如下:
- 在
pubspec.yaml中添加fl_chart; - 在
main.dart中引入第三方库; - 定义消费分类数据模型;
- 定义一周消费数据模型;
- 使用
PieChart展示分类占比; - 使用
BarChart展示一周消费趋势; - 使用
ListView展示消费明细; - 使用按钮切换不同月份的模拟数据;
- 使用
setState()更新图表页面。
第三方库引入代码如下:
import 'package:fl_chart/fl_chart.dart';
饼图核心代码如下:
PieChart(
PieChartData(
sections: _buildPieSections(),
centerSpaceRadius: 44,
sectionsSpace: 2,
),
)
柱状图核心代码如下:
BarChart(
BarChartData(
barGroups: _buildBarGroups(),
),
)
这两段代码是本文的重点,说明项目确实使用了 Flutter 第三方库实现图表展示。
八、main.dart 完整代码
打开文件:
lib/main.dart
将其中内容替换为下面代码:
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const ExpenseChartApp());
}
class ExpenseChartApp extends StatelessWidget {
const ExpenseChartApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Expense Chart Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
useMaterial3: true,
),
home: const ExpenseHomePage(),
);
}
}
class ExpenseCategory {
const ExpenseCategory({
required this.name,
required this.amount,
required this.icon,
required this.color,
});
final String name;
final double amount;
final IconData icon;
final Color color;
}
class WeeklyExpense {
const WeeklyExpense({
required this.day,
required this.amount,
});
final String day;
final double amount;
}
class MonthExpenseData {
const MonthExpenseData({
required this.monthName,
required this.categories,
required this.weeklyExpenses,
});
final String monthName;
final List<ExpenseCategory> categories;
final List<WeeklyExpense> weeklyExpenses;
}
class ExpenseHomePage extends StatefulWidget {
const ExpenseHomePage({super.key});
State<ExpenseHomePage> createState() => _ExpenseHomePageState();
}
class _ExpenseHomePageState extends State<ExpenseHomePage> {
final List<MonthExpenseData> _monthDataList = const [
MonthExpenseData(
monthName: '四月',
categories: [
ExpenseCategory(
name: '餐饮',
amount: 1260,
icon: Icons.restaurant,
color: Colors.orange,
),
ExpenseCategory(
name: '交通',
amount: 380,
icon: Icons.directions_bus,
color: Colors.blue,
),
ExpenseCategory(
name: '学习',
amount: 520,
icon: Icons.menu_book,
color: Colors.deepPurple,
),
ExpenseCategory(
name: '购物',
amount: 760,
icon: Icons.shopping_bag,
color: Colors.pink,
),
ExpenseCategory(
name: '娱乐',
amount: 430,
icon: Icons.movie,
color: Colors.teal,
),
],
weeklyExpenses: [
WeeklyExpense(day: '周一', amount: 120),
WeeklyExpense(day: '周二', amount: 230),
WeeklyExpense(day: '周三', amount: 180),
WeeklyExpense(day: '周四', amount: 360),
WeeklyExpense(day: '周五', amount: 420),
WeeklyExpense(day: '周六', amount: 510),
WeeklyExpense(day: '周日', amount: 300),
],
),
MonthExpenseData(
monthName: '五月',
categories: [
ExpenseCategory(
name: '餐饮',
amount: 980,
icon: Icons.restaurant,
color: Colors.orange,
),
ExpenseCategory(
name: '交通',
amount: 450,
icon: Icons.directions_bus,
color: Colors.blue,
),
ExpenseCategory(
name: '学习',
amount: 880,
icon: Icons.menu_book,
color: Colors.deepPurple,
),
ExpenseCategory(
name: '购物',
amount: 620,
icon: Icons.shopping_bag,
color: Colors.pink,
),
ExpenseCategory(
name: '娱乐',
amount: 260,
icon: Icons.movie,
color: Colors.teal,
),
],
weeklyExpenses: [
WeeklyExpense(day: '周一', amount: 180),
WeeklyExpense(day: '周二', amount: 160),
WeeklyExpense(day: '周三', amount: 300),
WeeklyExpense(day: '周四', amount: 210),
WeeklyExpense(day: '周五', amount: 390),
WeeklyExpense(day: '周六', amount: 460),
WeeklyExpense(day: '周日', amount: 240),
],
),
MonthExpenseData(
monthName: '六月',
categories: [
ExpenseCategory(
name: '餐饮',
amount: 1120,
icon: Icons.restaurant,
color: Colors.orange,
),
ExpenseCategory(
name: '交通',
amount: 330,
icon: Icons.directions_bus,
color: Colors.blue,
),
ExpenseCategory(
name: '学习',
amount: 640,
icon: Icons.menu_book,
color: Colors.deepPurple,
),
ExpenseCategory(
name: '购物',
amount: 930,
icon: Icons.shopping_bag,
color: Colors.pink,
),
ExpenseCategory(
name: '娱乐',
amount: 510,
icon: Icons.movie,
color: Colors.teal,
),
],
weeklyExpenses: [
WeeklyExpense(day: '周一', amount: 260),
WeeklyExpense(day: '周二', amount: 190),
WeeklyExpense(day: '周三', amount: 250),
WeeklyExpense(day: '周四', amount: 310),
WeeklyExpense(day: '周五', amount: 480),
WeeklyExpense(day: '周六', amount: 560),
WeeklyExpense(day: '周日', amount: 350),
],
),
];
int _currentMonthIndex = 0;
int _touchedPieIndex = -1;
MonthExpenseData get _currentMonth {
return _monthDataList[_currentMonthIndex];
}
double get _totalExpense {
double total = 0;
for (final ExpenseCategory item in _currentMonth.categories) {
total += item.amount;
}
return total;
}
double get _averageDailyExpense {
return _totalExpense / 30;
}
ExpenseCategory get _maxCategory {
ExpenseCategory maxItem = _currentMonth.categories.first;
for (final ExpenseCategory item in _currentMonth.categories) {
if (item.amount > maxItem.amount) {
maxItem = item;
}
}
return maxItem;
}
double get _maxWeeklyAmount {
double maxValue = 0;
for (final WeeklyExpense item in _currentMonth.weeklyExpenses) {
if (item.amount > maxValue) {
maxValue = item.amount;
}
}
return maxValue;
}
void _previousMonth() {
setState(() {
if (_currentMonthIndex == 0) {
_currentMonthIndex = _monthDataList.length - 1;
} else {
_currentMonthIndex--;
}
_touchedPieIndex = -1;
});
}
void _nextMonth() {
setState(() {
if (_currentMonthIndex == _monthDataList.length - 1) {
_currentMonthIndex = 0;
} else {
_currentMonthIndex++;
}
_touchedPieIndex = -1;
});
}
String _formatMoney(double value) {
return '¥${value.toStringAsFixed(0)}';
}
String _formatPercent(double value) {
if (_totalExpense == 0) {
return '0%';
}
return '${(value / _totalExpense * 100).toStringAsFixed(1)}%';
}
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('消费统计图表'),
centerTitle: true,
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildOverviewCard(theme),
const SizedBox(height: 16),
_buildPieChartCard(theme),
const SizedBox(height: 16),
_buildBarChartCard(theme),
const SizedBox(height: 16),
_buildCategoryListCard(theme),
const SizedBox(height: 16),
_buildMonthActionCard(theme),
const SizedBox(height: 16),
_buildLibraryCard(theme),
],
),
),
);
}
Widget _buildOverviewCard(ThemeData theme) {
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.pie_chart,
size: 42,
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 18),
Text(
'Flutter for OpenHarmony',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'使用 fl_chart 构建消费分类饼图和一周消费柱状图',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Text(
'${_currentMonth.monthName}消费概览',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 18),
Row(
children: [
_buildStatItem(
theme,
title: '总消费',
value: _formatMoney(_totalExpense),
icon: Icons.payments,
),
_buildStatItem(
theme,
title: '日均',
value: _formatMoney(_averageDailyExpense),
icon: Icons.today,
),
_buildStatItem(
theme,
title: '最高分类',
value: _maxCategory.name,
icon: Icons.trending_up,
),
],
),
],
),
),
);
}
Widget _buildStatItem(
ThemeData theme, {
required String title,
required String value,
required IconData icon,
}) {
return Expanded(
child: Column(
children: [
Icon(
icon,
color: theme.colorScheme.primary,
),
const SizedBox(height: 6),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
title,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Widget _buildPieChartCard(ThemeData theme) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'消费分类占比',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
_currentMonth.monthName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 230,
child: PieChart(
PieChartData(
sections: _buildPieSections(theme),
centerSpaceRadius: 44,
sectionsSpace: 2,
pieTouchData: PieTouchData(
touchCallback: (event, response) {
setState(() {
if (!event.isInterestedForInteractions ||
response == null ||
response.touchedSection == null) {
_touchedPieIndex = -1;
return;
}
_touchedPieIndex =
response.touchedSection!.touchedSectionIndex;
});
},
),
),
),
),
const SizedBox(height: 12),
Text(
_touchedPieIndex == -1
? '点击饼图可以查看分类突出效果'
: '当前选中:${_currentMonth.categories[_touchedPieIndex].name}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
List<PieChartSectionData> _buildPieSections(ThemeData theme) {
return List.generate(_currentMonth.categories.length, (index) {
final ExpenseCategory item = _currentMonth.categories[index];
final bool touched = index == _touchedPieIndex;
final double radius = touched ? 76 : 64;
final double fontSize = touched ? 15 : 12;
return PieChartSectionData(
value: item.amount,
title: _formatPercent(item.amount),
color: item.color,
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
});
}
Widget _buildBarChartCard(ThemeData theme) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'一周消费趋势',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
'单位:元',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 260,
child: BarChart(
BarChartData(
maxY: _maxWeeklyAmount + 120,
barGroups: _buildBarGroups(theme),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 200,
getDrawingHorizontalLine: (value) {
return FlLine(
color: theme.colorScheme.outlineVariant,
strokeWidth: 1,
);
},
),
borderData: FlBorderData(
show: false,
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 38,
interval: 200,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final int index = value.toInt();
if (index < 0 ||
index >= _currentMonth.weeklyExpenses.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_currentMonth.weeklyExpenses[index].day,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
);
},
),
),
),
),
),
),
],
),
),
);
}
List<BarChartGroupData> _buildBarGroups(ThemeData theme) {
return List.generate(_currentMonth.weeklyExpenses.length, (index) {
final WeeklyExpense item = _currentMonth.weeklyExpenses[index];
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: item.amount,
width: 18,
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primary,
),
],
);
});
}
Widget _buildCategoryListCard(ThemeData theme) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'消费分类明细',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 14),
..._currentMonth.categories.map((item) {
final double percent =
_totalExpense == 0 ? 0 : item.amount / _totalExpense;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: item.color.withOpacity(0.10),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: item.color.withOpacity(0.24),
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: item.color.withOpacity(0.16),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
item.icon,
color: item.color,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: percent,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
color: item.color,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
),
],
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatMoney(item.amount),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: item.color,
),
),
const SizedBox(height: 4),
Text(
_formatPercent(item.amount),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
);
}),
],
),
),
);
}
Widget _buildMonthActionCard(ThemeData theme) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _previousMonth,
icon: const Icon(Icons.arrow_back),
label: const Text('上月数据'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _nextMonth,
icon: const Icon(Icons.arrow_forward),
label: const Text('下月数据'),
),
),
],
),
),
);
}
Widget _buildLibraryCard(ThemeData theme) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'第三方库说明',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_buildInfoRow(
theme,
title: '库名称',
value: 'fl_chart',
),
_buildInfoRow(
theme,
title: '配置文件',
value: 'pubspec.yaml',
),
_buildInfoRow(
theme,
title: '导入方式',
value: "import 'package:fl_chart/fl_chart.dart';",
),
_buildInfoRow(
theme,
title: '核心组件',
value: 'PieChart / BarChart / PieChartData / BarChartData',
),
_buildInfoRow(
theme,
title: '应用场景',
value: '消费统计、学习统计、运动记录、任务进度、数据分析',
),
],
),
),
);
}
Widget _buildInfoRow(
ThemeData theme, {
required String title,
required String value,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 82,
child: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}
九、代码实现说明
1. 引入 fl_chart 第三方库
代码开头引入第三方库:
import 'package:fl_chart/fl_chart.dart';
这说明项目确实使用了 Flutter 第三方库,而不是 OpenHarmony 原生库。
本项目中主要使用以下组件:
PieChart
PieChartData
PieChartSectionData
BarChart
BarChartData
BarChartGroupData
BarChartRodData
其中:
| 组件 | 作用 |
|---|---|
| PieChart | 构建饼图 |
| PieChartData | 配置饼图数据 |
| PieChartSectionData | 配置饼图每一块区域 |
| BarChart | 构建柱状图 |
| BarChartData | 配置柱状图数据 |
| BarChartGroupData | 配置柱状图每一组数据 |
| BarChartRodData | 配置柱状图每一根柱子 |
2. 定义消费分类数据模型
项目中定义了消费分类模型:
class ExpenseCategory {
const ExpenseCategory({
required this.name,
required this.amount,
required this.icon,
required this.color,
});
final String name;
final double amount;
final IconData icon;
final Color color;
}
字段说明如下:
| 字段 | 作用 |
|---|---|
| name | 消费分类名称 |
| amount | 消费金额 |
| icon | 分类图标 |
| color | 分类主题色 |
这样可以统一管理餐饮、交通、学习、购物和娱乐等消费分类。
3. 定义一周消费数据模型
项目中定义了一周消费模型:
class WeeklyExpense {
const WeeklyExpense({
required this.day,
required this.amount,
});
final String day;
final double amount;
}
字段说明如下:
| 字段 | 作用 |
|---|---|
| day | 星期名称 |
| amount | 当天消费金额 |
柱状图会根据这些数据生成一周消费趋势。
4. 使用 PieChart 构建饼图
饼图核心代码如下:
PieChart(
PieChartData(
sections: _buildPieSections(theme),
centerSpaceRadius: 44,
sectionsSpace: 2,
),
)
其中:
| 参数 | 作用 |
|---|---|
| sections | 饼图中的各个扇区 |
| centerSpaceRadius | 中间空白区域半径 |
| sectionsSpace | 扇区之间的间距 |
| pieTouchData | 饼图点击交互配置 |
本项目使用饼图展示不同消费分类的占比。
5. 构建饼图扇区
饼图扇区通过下面方法生成:
List<PieChartSectionData> _buildPieSections(ThemeData theme) {
return List.generate(_currentMonth.categories.length, (index) {
final ExpenseCategory item = _currentMonth.categories[index];
return PieChartSectionData(
value: item.amount,
title: _formatPercent(item.amount),
color: item.color,
radius: 64,
);
});
}
每个 PieChartSectionData 对应饼图中的一个分类。
例如:
- 餐饮;
- 交通;
- 学习;
- 购物;
- 娱乐。
value 决定扇区大小,color 决定扇区颜色,title 用来显示百分比。
6. 实现饼图点击突出效果
项目中使用:
pieTouchData: PieTouchData(
touchCallback: (event, response) {
setState(() {
...
});
},
)
当用户点击饼图某一块区域时,会更新 _touchedPieIndex。
然后在 _buildPieSections() 中判断当前区域是否被点击:
final bool touched = index == _touchedPieIndex;
final double radius = touched ? 76 : 64;
如果某个扇区被点击,它的半径会变大,从视觉上突出当前分类。
图表能点一下有反应,这件事对用户体验很重要。否则它就像贴在屏幕上的一张彩色饼,漂亮但没什么灵魂。
7. 使用 BarChart 构建柱状图
柱状图核心代码如下:
BarChart(
BarChartData(
maxY: _maxWeeklyAmount + 120,
barGroups: _buildBarGroups(theme),
),
)
其中:
| 参数 | 作用 |
|---|---|
| maxY | Y 轴最大值 |
| barGroups | 柱状图数据组 |
| gridData | 网格线配置 |
| borderData | 边框配置 |
| titlesData | 坐标轴标题配置 |
本项目使用柱状图展示一周消费趋势。
8. 构建柱状图数据
柱状图数据通过下面方法生成:
List<BarChartGroupData> _buildBarGroups(ThemeData theme) {
return List.generate(_currentMonth.weeklyExpenses.length, (index) {
final WeeklyExpense item = _currentMonth.weeklyExpenses[index];
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: item.amount,
width: 18,
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primary,
),
],
);
});
}
其中:
| 参数 | 作用 |
|---|---|
| x | 当前柱子在 X 轴的位置 |
| toY | 当前柱子的高度 |
| width | 柱子宽度 |
| borderRadius | 柱子圆角 |
| color | 柱子颜色 |
toY 的数值越大,柱子越高。
9. 设置柱状图坐标轴
本项目中使用 titlesData 设置坐标轴文字:
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
...
},
),
),
)
底部 X 轴显示:
周一 周二 周三 周四 周五 周六 周日
左侧 Y 轴显示金额刻度。
这样用户可以更清楚地看出每天消费金额的变化情况。
10. 实现月份切换
页面中提供了“上月数据”和“下月数据”按钮。
上月切换方法:
void _previousMonth() {
setState(() {
if (_currentMonthIndex == 0) {
_currentMonthIndex = _monthDataList.length - 1;
} else {
_currentMonthIndex--;
}
});
}
下月切换方法:
void _nextMonth() {
setState(() {
if (_currentMonthIndex == _monthDataList.length - 1) {
_currentMonthIndex = 0;
} else {
_currentMonthIndex++;
}
});
}
月份变化后,饼图、柱状图、明细列表和统计卡片都会一起更新。
11. 计算总消费和占比
总消费计算:
double get _totalExpense {
double total = 0;
for (final ExpenseCategory item in _currentMonth.categories) {
total += item.amount;
}
return total;
}
占比计算:
String _formatPercent(double value) {
return '${(value / _totalExpense * 100).toStringAsFixed(1)}%';
}
这样可以在饼图和分类明细中显示每个分类的消费占比。
十、运行项目
完成代码后,在终端执行:
flutter pub get
然后连接 OpenHarmony 设备或启动 OpenHarmony 模拟器。
查看设备:
flutter devices
运行项目:
flutter run
如果环境配置正确,应用会运行到 OpenHarmony 设备或模拟器中。
运行成功后,页面会显示“消费统计图表”。用户可以查看消费分类饼图、一周消费柱状图和分类明细,也可以点击按钮切换不同月份的数据。
十一、开发中遇到的问题
1. fl_chart 依赖没有生效
如果代码中出现找不到 fl_chart 的问题,可以检查 pubspec.yaml 中是否添加了:
fl_chart: ^1.2.0
然后重新执行:
flutter pub get
如果还是不行,可以重启编辑器。编辑器有时候像刚睡醒,依赖装好了它还装作没看见,经典软件表演。
2. import 导入报错
如果下面代码报错:
import 'package:fl_chart/fl_chart.dart';
通常有几种原因:
pubspec.yaml中没有添加依赖;- 没有执行
flutter pub get; - YAML 缩进错误;
- 包名写错;
- 编辑器没有刷新依赖。
其中 YAML 缩进最容易出问题。依赖必须写在 dependencies 下面,并且缩进要正确。一个空格能毁掉一天,编程世界真是温柔到残忍。
3. 饼图没有显示
如果饼图没有显示,可以检查:
- 是否正确引入
fl_chart; PieChartData中是否配置了sections;- 每个
PieChartSectionData的value是否大于 0; - 外层是否给了固定高度;
- 页面是否成功运行。
本项目中给饼图设置了高度:
SizedBox(
height: 230,
child: PieChart(...),
)
如果没有给图表足够空间,图表可能无法正常显示。
4. 柱状图没有显示
如果柱状图没有显示,可以检查:
- 是否设置了
barGroups; BarChartRodData中是否设置了toY;maxY是否大于柱子的最大值;- 图表外层是否有高度;
- 是否存在布局约束问题。
本项目中使用:
SizedBox(
height: 260,
child: BarChart(...),
)
给柱状图设置固定显示空间。
5. 坐标轴文字显示异常
如果坐标轴文字过密或显示不完整,可以调整:
reservedSize;fontSize;interval;- 图表高度;
- 底部文字内容长度。
例如左侧 Y 轴使用:
reservedSize: 38
用于给刻度文字留出显示空间。
6. 饼图点击没有反应
如果点击饼图没有突出效果,可以检查:
pieTouchData: PieTouchData(
touchCallback: ...
)
同时检查是否在点击回调中调用了:
setState(() {
_touchedPieIndex = ...;
});
状态变了但不调用 setState(),页面不会刷新。Flutter 不是读心术框架,别用眼神命令它更新 UI。
7. 切换月份后图表没有变化
如果点击按钮后图表没有变化,可以检查月份切换方法中是否更新了:
_currentMonthIndex
并且是否调用了:
setState(() {
...
});
本项目中图表数据来自:
_currentMonth
只要 _currentMonthIndex 更新,图表就会跟着更新。
8. 运行不到 OpenHarmony 设备
如果项目无法运行到 OpenHarmony 设备或模拟器,可以检查:
- Flutter for OpenHarmony 环境是否配置完成;
- 设备是否连接成功;
flutter devices是否能识别设备;- 是否执行了
flutter pub get; - 是否选择了正确的运行设备;
- 项目是否为 Flutter 项目,而不是原生鸿蒙项目。
如果 flutter devices 都识别不到设备,那应该先处理环境问题,而不是盯着图表代码怀疑人生。图表很无辜,至少这次大概率是。
十二、本文和原生鸿蒙项目的区别
本文是 Flutter for OpenHarmony 第三方库实践,不是 OpenHarmony 原生 ArkTS 项目。
主要区别如下:
| 对比项 | 本文写法 | 原生鸿蒙写法 |
|---|---|---|
| UI 技术 | Flutter | ArkUI |
| 主要语言 | Dart | ArkTS |
| 页面入口 | lib/main.dart | Index.ets |
| 依赖配置 | pubspec.yaml | oh-package.json5 |
| 依赖安装 | flutter pub get | ohpm install |
| 第三方库 | fl_chart | OpenHarmony 原生库 |
| 页面组件 | MaterialApp / Scaffold / PieChart / BarChart | @Entry / @Component |
因此本文符合 Flutter for OpenHarmony 第三方库实践方向。
十三、总结
本篇完成了一个基于 fl_chart 的 Flutter for OpenHarmony 消费统计图表应用。项目通过 Flutter 第三方库实现饼图和柱状图,并结合消费分类数据展示了不同消费类型的占比和一周消费趋势。
通过本次实践,我主要完成了以下内容:
- 创建 Flutter for OpenHarmony 项目;
- 在
pubspec.yaml中添加fl_chart依赖; - 使用
flutter pub get获取第三方库; - 在
lib/main.dart中引入fl_chart; - 使用
PieChart构建消费分类饼图; - 使用
PieChartData和PieChartSectionData配置饼图; - 使用
BarChart构建一周消费柱状图; - 使用
BarChartData、BarChartGroupData和BarChartRodData配置柱状图; - 使用
setState()实现月份数据切换; - 使用 Flutter Material 组件构建完整页面;
- 将项目运行到 OpenHarmony 设备或模拟器中。
这个项目虽然只是一个基础消费统计应用,但完整展示了 Flutter for OpenHarmony 项目中第三方库的使用流程。
后续可以在这个基础上继续扩展,例如:
- 添加真实记账输入;
- 添加消费记录删除;
- 添加消费分类编辑;
- 添加月度预算提醒;
- 添加折线图展示月度趋势;
- 添加年度统计;
- 添加本地数据保存;
- 添加暗色主题;
- 添加导出账单;
- 添加多设备同步。
整体来看,fl_chart 可以帮助 Flutter 开发者快速实现数据可视化页面。通过这个项目,可以理解 Flutter for OpenHarmony 中第三方库依赖配置、图表组件使用和页面状态更新之间的基本关系。
更多推荐
所有评论(0)