欢迎加入开源鸿蒙跨平台社区:
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.yamllib/main.dartimport 'package:fl_chart/fl_chart.dart';,这才是正确方向。不是把标题写成 Flutter,代码就会自动投胎成 Flutter。


七、核心实现思路

本项目的核心流程如下:

  1. pubspec.yaml 中添加 fl_chart
  2. main.dart 中引入第三方库;
  3. 定义消费分类数据模型;
  4. 定义一周消费数据模型;
  5. 使用 PieChart 展示分类占比;
  6. 使用 BarChart 展示一周消费趋势;
  7. 使用 ListView 展示消费明细;
  8. 使用按钮切换不同月份的模拟数据;
  9. 使用 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
  • 每个 PieChartSectionDatavalue 是否大于 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 构建消费分类饼图;
  • 使用 PieChartDataPieChartSectionData 配置饼图;
  • 使用 BarChart 构建一周消费柱状图;
  • 使用 BarChartDataBarChartGroupDataBarChartRodData 配置柱状图;
  • 使用 setState() 实现月份数据切换;
  • 使用 Flutter Material 组件构建完整页面;
  • 将项目运行到 OpenHarmony 设备或模拟器中。

这个项目虽然只是一个基础消费统计应用,但完整展示了 Flutter for OpenHarmony 项目中第三方库的使用流程。

后续可以在这个基础上继续扩展,例如:

  • 添加真实记账输入;
  • 添加消费记录删除;
  • 添加消费分类编辑;
  • 添加月度预算提醒;
  • 添加折线图展示月度趋势;
  • 添加年度统计;
  • 添加本地数据保存;
  • 添加暗色主题;
  • 添加导出账单;
  • 添加多设备同步。

整体来看,fl_chart 可以帮助 Flutter 开发者快速实现数据可视化页面。通过这个项目,可以理解 Flutter for OpenHarmony 中第三方库依赖配置、图表组件使用和页面状态更新之间的基本关系。

Logo

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

更多推荐