Flutter数据可视化:fl_chart图表库的高级应用

Flutter数据可视化:fl_chart图表库的高级应用

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何使用fl_chart构建美观、交互式的财务数据可视化图表。

项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。

引言

数据可视化是现代应用的重要特性,特别是对于财务管理类应用。用户需要直观地了解自己的收支状况、消费趋势和资产分布。优秀的数据可视化不仅能帮助用户更好地理解数据,还能提升应用的专业性和用户粘性。

fl_chart是Flutter生态中最受欢迎的图表库之一,它提供了丰富的图表类型、流畅的动画效果和灵活的自定义选项。在BeeCount项目中,我们使用fl_chart构建了完整的财务数据分析功能,包括趋势图、饼图、柱状图等多种图表类型。

fl_chart核心特性

丰富的图表类型

  • 线性图(LineChart): 展示数据趋势变化
  • 柱状图(BarChart): 对比不同类别数据
  • 饼图(PieChart): 显示数据占比分布
  • 散点图(ScatterChart): 展示数据相关性
  • 雷达图(RadarChart): 多维度数据对比

强大的交互能力

  • 触摸交互: 点击、长按、滑动等手势支持
  • 动态更新: 数据变化时的流畅动画
  • 自定义样式: 完全可定制的视觉效果
  • 响应式设计: 适配不同屏幕尺寸

财务数据分析架构

数据模型设计

dart 复制代码
// 统计数据基类
abstract class ChartData {
  final DateTime date;
  final double value;
  final String label;

  const ChartData({
    required this.date,
    required this.value,
    required this.label,
  });
}

// 日收支统计
class DailyStats extends ChartData {
  final double income;
  final double expense;
  final double net;

  const DailyStats({
    required DateTime date,
    required this.income,
    required this.expense,
    required this.net,
  }) : super(
          date: date,
          value: net,
          label: '',
        );

  factory DailyStats.fromTransaction(List<Transaction> transactions, DateTime date) {
    double income = 0;
    double expense = 0;

    for (final tx in transactions) {
      if (isSameDay(tx.happenedAt, date)) {
        switch (tx.type) {
          case 'income':
            income += tx.amount;
            break;
          case 'expense':
            expense += tx.amount;
            break;
        }
      }
    }

    return DailyStats(
      date: date,
      income: income,
      expense: expense,
      net: income - expense,
    );
  }
}

// 分类统计
class CategoryStats extends ChartData {
  final String categoryName;
  final int transactionCount;
  final Color color;

  const CategoryStats({
    required DateTime date,
    required double value,
    required this.categoryName,
    required this.transactionCount,
    required this.color,
  }) : super(
          date: date,
          value: value,
          label: categoryName,
        );
}

// 月度趋势
class MonthlyTrend extends ChartData {
  final int year;
  final int month;
  final double income;
  final double expense;

  const MonthlyTrend({
    required this.year,
    required this.month,
    required this.income,
    required this.expense,
  }) : super(
          date: DateTime(year, month),
          value: income - expense,
          label: '$year年$month月',
        );
}

数据处理服务

dart 复制代码
class AnalyticsService {
  final BeeRepository repository;

  AnalyticsService(this.repository);

  // 获取指定时间范围的日统计数据
  Future<List<DailyStats>> getDailyStats({
    required int ledgerId,
    required DateTimeRange range,
  }) async {
    final transactions = await repository.getTransactionsInRange(
      ledgerId: ledgerId,
      range: range,
    );

    final Map<DateTime, List<Transaction>> groupedByDate = {};
    for (final tx in transactions) {
      final date = DateTime(tx.happenedAt.year, tx.happenedAt.month, tx.happenedAt.day);
      groupedByDate.putIfAbsent(date, () => []).add(tx);
    }

    final List<DailyStats> result = [];
    DateTime current = DateTime(range.start.year, range.start.month, range.start.day);
    final end = DateTime(range.end.year, range.end.month, range.end.day);

    while (!current.isAfter(end)) {
      final dayTransactions = groupedByDate[current] ?? [];
      result.add(DailyStats.fromTransaction(dayTransactions, current));
      current = current.add(const Duration(days: 1));
    }

    return result;
  }

  // 获取分类统计数据
  Future<List<CategoryStats>> getCategoryStats({
    required int ledgerId,
    required DateTimeRange range,
    required String type, // 'income' or 'expense'
  }) async {
    final transactions = await repository.getCategoryStatsInRange(
      ledgerId: ledgerId,
      range: range,
      type: type,
    );

    final Map<String, CategoryStatsData> categoryMap = {};
    
    for (final tx in transactions) {
      final categoryName = tx.categoryName ?? '未分类';
      final existing = categoryMap[categoryName];
      
      if (existing == null) {
        categoryMap[categoryName] = CategoryStatsData(
          categoryName: categoryName,
          totalAmount: tx.amount,
          transactionCount: 1,
          color: _getCategoryColor(categoryName),
        );
      } else {
        existing.totalAmount += tx.amount;
        existing.transactionCount += 1;
      }
    }

    return categoryMap.values
        .map((data) => CategoryStats(
              date: range.start,
              value: data.totalAmount,
              categoryName: data.categoryName,
              transactionCount: data.transactionCount,
              color: data.color,
            ))
        .toList()
      ..sort((a, b) => b.value.compareTo(a.value));
  }

  // 获取月度趋势数据
  Future<List<MonthlyTrend>> getMonthlyTrends({
    required int ledgerId,
    required int year,
  }) async {
    final List<MonthlyTrend> trends = [];

    for (int month = 1; month <= 12; month++) {
      final range = DateTimeRange(
        start: DateTime(year, month, 1),
        end: DateTime(year, month + 1, 1).subtract(const Duration(days: 1)),
      );

      final monthStats = await repository.getMonthStats(
        ledgerId: ledgerId,
        range: range,
      );

      trends.add(MonthlyTrend(
        year: year,
        month: month,
        income: monthStats.income,
        expense: monthStats.expense,
      ));
    }

    return trends;
  }

  Color _getCategoryColor(String categoryName) {
    // 为不同分类分配固定颜色
    final colors = [
      Colors.red.shade300,
      Colors.blue.shade300,
      Colors.green.shade300,
      Colors.orange.shade300,
      Colors.purple.shade300,
      Colors.teal.shade300,
      Colors.amber.shade300,
      Colors.indigo.shade300,
    ];
    
    final index = categoryName.hashCode % colors.length;
    return colors[index.abs()];
  }
}

收支趋势图实现

基础线性图组件

dart 复制代码
class IncomeExpenseTrendChart extends ConsumerWidget {
  final DateTimeRange dateRange;
  final int ledgerId;

  const IncomeExpenseTrendChart({
    Key? key,
    required this.dateRange,
    required this.ledgerId,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final dailyStatsAsync = ref.watch(dailyStatsProvider(DailyStatsParams(
      ledgerId: ledgerId,
      range: dateRange,
    )));

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  '收支趋势',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                PopupMenuButton<String>(
                  onSelected: (value) {
                    // 处理时间范围选择
                  },
                  itemBuilder: (context) => [
                    const PopupMenuItem(value: '7d', child: Text('最近7天')),
                    const PopupMenuItem(value: '30d', child: Text('最近30天')),
                    const PopupMenuItem(value: '90d', child: Text('最近90天')),
                  ],
                  child: const Icon(Icons.more_vert),
                ),
              ],
            ),
            const SizedBox(height: 16),
            
            SizedBox(
              height: 280,
              child: dailyStatsAsync.when(
                data: (stats) => _buildChart(context, stats),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildChart(BuildContext context, List<DailyStats> stats) {
    if (stats.isEmpty) {
      return const Center(
        child: Text('暂无数据'),
      );
    }

    final theme = Theme.of(context);
    final colors = BeeTheme.colorsOf(context);

    return LineChart(
      LineChartData(
        gridData: FlGridData(
          show: true,
          drawHorizontalLine: true,
          drawVerticalLine: false,
          horizontalInterval: _calculateInterval(stats),
          getDrawingHorizontalLine: (value) => FlLine(
            color: theme.colorScheme.outline.withOpacity(0.2),
            strokeWidth: 1,
          ),
        ),
        
        titlesData: FlTitlesData(
          show: true,
          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          
          bottomTitles: AxisTitles(
            sideTitles: SideTitles(
              showTitles: true,
              reservedSize: 30,
              interval: _getBottomInterval(stats),
              getTitlesWidget: (value, meta) => _buildBottomTitle(
                context,
                stats,
                value.toInt(),
              ),
            ),
          ),
          
          leftTitles: AxisTitles(
            sideTitles: SideTitles(
              showTitles: true,
              interval: _calculateInterval(stats),
              reservedSize: 60,
              getTitlesWidget: (value, meta) => _buildLeftTitle(
                context,
                value,
              ),
            ),
          ),
        ),
        
        borderData: FlBorderData(show: false),
        
        minX: 0,
        maxX: stats.length.toDouble() - 1,
        minY: _getMinY(stats),
        maxY: _getMaxY(stats),
        
        lineBarsData: [
          // 收入线
          LineChartBarData(
            spots: _createSpots(stats, (stat) => stat.income),
            isCurved: true,
            color: colors.income,
            barWidth: 3,
            isStrokeCapRound: true,
            dotData: FlDotData(
              show: true,
              getDotPainter: (spot, percent, barData, index) =>
                  FlDotCirclePainter(
                radius: 4,
                color: colors.income,
                strokeWidth: 2,
                strokeColor: Colors.white,
              ),
            ),
            belowBarData: BarAreaData(
              show: true,
              color: colors.income.withOpacity(0.1),
            ),
          ),
          
          // 支出线
          LineChartBarData(
            spots: _createSpots(stats, (stat) => stat.expense),
            isCurved: true,
            color: colors.expense,
            barWidth: 3,
            isStrokeCapRound: true,
            dotData: FlDotData(
              show: true,
              getDotPainter: (spot, percent, barData, index) =>
                  FlDotCirclePainter(
                radius: 4,
                color: colors.expense,
                strokeWidth: 2,
                strokeColor: Colors.white,
              ),
            ),
          ),
        ],
        
        lineTouchData: LineTouchData(
          enabled: true,
          touchTooltipData: LineTouchTooltipData(
            tooltipBgColor: theme.colorScheme.surface,
            tooltipBorder: BorderSide(
              color: theme.colorScheme.outline,
            ),
            tooltipRoundedRadius: 8,
            getTooltipItems: (touchedSpots) => _buildTooltipItems(
              context,
              touchedSpots,
              stats,
              colors,
            ),
          ),
          touchCallback: (FlTouchEvent event, LineTouchResponse? touchResponse) {
            // 处理触摸事件
            if (event is FlTapUpEvent && touchResponse?.lineBarSpots != null) {
              final spot = touchResponse!.lineBarSpots!.first;
              final dayStats = stats[spot.spotIndex];
              _showDayDetails(context, dayStats);
            }
          },
        ),
      ),
    );
  }

  List<FlSpot> _createSpots(List<DailyStats> stats, double Function(DailyStats) getValue) {
    return stats.asMap().entries.map((entry) {
      return FlSpot(entry.key.toDouble(), getValue(entry.value));
    }).toList();
  }

  double _calculateInterval(List<DailyStats> stats) {
    if (stats.isEmpty) return 100;
    
    final maxValue = stats
        .map((s) => math.max(s.income, s.expense))
        .reduce(math.max);
    
    if (maxValue <= 100) return 50;
    if (maxValue <= 1000) return 200;
    if (maxValue <= 10000) return 2000;
    return 5000;
  }

  double _getBottomInterval(List<DailyStats> stats) {
    if (stats.length <= 7) return 1;
    if (stats.length <= 14) return 2;
    if (stats.length <= 30) return 5;
    return 10;
  }

  Widget _buildBottomTitle(BuildContext context, List<DailyStats> stats, int index) {
    if (index < 0 || index >= stats.length) return const SizedBox.shrink();
    
    final date = stats[index].date;
    final text = DateFormat('MM/dd').format(date);
    
    return SideTitleWidget(
      axisSide: meta.axisSide,
      child: Text(
        text,
        style: TextStyle(
          color: Theme.of(context).colorScheme.onSurfaceVariant,
          fontSize: 12,
        ),
      ),
    );
  }

  Widget _buildLeftTitle(BuildContext context, double value) {
    return Text(
      _formatAmount(value),
      style: TextStyle(
        color: Theme.of(context).colorScheme.onSurfaceVariant,
        fontSize: 12,
      ),
    );
  }

  String _formatAmount(double amount) {
    if (amount.abs() >= 10000) {
      return '${(amount / 10000).toStringAsFixed(1)}万';
    }
    return amount.toStringAsFixed(0);
  }

  List<LineTooltipItem?> _buildTooltipItems(
    BuildContext context,
    List<LineBarSpot> touchedSpots,
    List<DailyStats> stats,
    BeeColors colors,
  ) {
    return touchedSpots.map((LineBarSpot touchedSpot) {
      const textStyle = TextStyle(
        color: Colors.white,
        fontWeight: FontWeight.bold,
        fontSize: 14,
      );
      
      final dayStats = stats[touchedSpot.spotIndex];
      final date = DateFormat('MM月dd日').format(dayStats.date);
      
      if (touchedSpot.barIndex == 0) {
        // 收入线
        return LineTooltipItem(
          '$date\n收入: ${dayStats.income.toStringAsFixed(2)}',
          textStyle.copyWith(color: colors.income),
        );
      } else {
        // 支出线
        return LineTooltipItem(
          '$date\n支出: ${dayStats.expense.toStringAsFixed(2)}',
          textStyle.copyWith(color: colors.expense),
        );
      }
    }).toList();
  }

  void _showDayDetails(BuildContext context, DailyStats dayStats) {
    showModalBottomSheet(
      context: context,
      builder: (context) => DayDetailsSheet(dayStats: dayStats),
    );
  }

  double _getMinY(List<DailyStats> stats) {
    if (stats.isEmpty) return 0;
    return math.min(0, stats.map((s) => math.min(s.income, s.expense)).reduce(math.min)) * 1.1;
  }

  double _getMaxY(List<DailyStats> stats) {
    if (stats.isEmpty) return 100;
    return stats.map((s) => math.max(s.income, s.expense)).reduce(math.max) * 1.1;
  }
}

分类支出饼图实现

交互式饼图组件

dart 复制代码
class CategoryExpensePieChart extends ConsumerStatefulWidget {
  final DateTimeRange dateRange;
  final int ledgerId;

  const CategoryExpensePieChart({
    Key? key,
    required this.dateRange,
    required this.ledgerId,
  }) : super(key: key);

  @override
  ConsumerState<CategoryExpensePieChart> createState() => _CategoryExpensePieChartState();
}

class _CategoryExpensePieChartState extends ConsumerState<CategoryExpensePieChart>
    with SingleTickerProviderStateMixin {
  int touchedIndex = -1;
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
    _animationController.forward();
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final categoryStatsAsync = ref.watch(categoryStatsProvider(CategoryStatsParams(
      ledgerId: widget.ledgerId,
      range: widget.dateRange,
      type: 'expense',
    )));

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '支出分类',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 16),
            
            SizedBox(
              height: 300,
              child: categoryStatsAsync.when(
                data: (stats) => _buildChart(context, stats),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
              ),
            ),
            
            const SizedBox(height: 16),
            categoryStatsAsync.maybeWhen(
              data: (stats) => _buildLegend(context, stats),
              orElse: () => const SizedBox.shrink(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildChart(BuildContext context, List<CategoryStats> stats) {
    if (stats.isEmpty) {
      return const Center(
        child: Text('暂无支出数据'),
      );
    }

    // 只显示前8个分类,其余归为"其他"
    final displayStats = _prepareDisplayStats(stats);
    final total = displayStats.fold(0.0, (sum, stat) => sum + stat.value);

    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return PieChart(
          PieChartData(
            pieTouchData: PieTouchData(
              touchCallback: (FlTouchEvent event, pieTouchResponse) {
                setState(() {
                  if (!event.isInterestedForInteractions ||
                      pieTouchResponse == null ||
                      pieTouchResponse.touchedSection == null) {
                    touchedIndex = -1;
                    return;
                  }
                  touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
                });
              },
            ),
            
            borderData: FlBorderData(show: false),
            sectionsSpace: 2,
            centerSpaceRadius: 60,
            
            sections: displayStats.asMap().entries.map((entry) {
              final index = entry.key;
              final stat = entry.value;
              final isTouched = index == touchedIndex;
              final percentage = (stat.value / total * 100);
              
              return PieChartSectionData(
                color: stat.color,
                value: stat.value,
                title: '${percentage.toStringAsFixed(1)}%',
                radius: (isTouched ? 110.0 : 100.0) * _animation.value,
                titleStyle: TextStyle(
                  fontSize: isTouched ? 16.0 : 14.0,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                  shadows: [
                    Shadow(
                      color: Colors.black.withOpacity(0.5),
                      blurRadius: 2,
                    ),
                  ],
                ),
                badgeWidget: isTouched ? _buildBadge(stat) : null,
                badgePositionPercentageOffset: 1.2,
              );
            }).toList(),
          ),
        );
      },
    );
  }

  Widget _buildBadge(CategoryStats stat) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: stat.color,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.white, width: 2),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.2),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Text(
        '¥${stat.value.toStringAsFixed(0)}',
        style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
          fontSize: 12,
        ),
      ),
    );
  }

  Widget _buildLegend(BuildContext context, List<CategoryStats> stats) {
    final displayStats = _prepareDisplayStats(stats);
    
    return Column(
      children: displayStats.asMap().entries.map((entry) {
        final index = entry.key;
        final stat = entry.value;
        final isHighlighted = index == touchedIndex;
        
        return AnimatedContainer(
          duration: const Duration(milliseconds: 200),
          margin: const EdgeInsets.symmetric(vertical: 2),
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          decoration: BoxDecoration(
            color: isHighlighted
                ? stat.color.withOpacity(0.1)
                : Colors.transparent,
            borderRadius: BorderRadius.circular(8),
            border: isHighlighted
                ? Border.all(color: stat.color.withOpacity(0.3))
                : null,
          ),
          child: Row(
            children: [
              Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                  color: stat.color,
                  shape: BoxShape.circle,
                ),
              ),
              const SizedBox(width: 12),
              
              Expanded(
                child: Text(
                  stat.categoryName,
                  style: TextStyle(
                    fontWeight: isHighlighted
                        ? FontWeight.w600
                        : FontWeight.normal,
                  ),
                ),
              ),
              
              Column(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Text(
                    '¥${stat.value.toStringAsFixed(2)}',
                    style: TextStyle(
                      fontWeight: FontWeight.w600,
                      color: isHighlighted
                          ? stat.color
                          : Theme.of(context).colorScheme.onSurface,
                    ),
                  ),
                  Text(
                    '${stat.transactionCount}笔',
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ],
          ),
        );
      }).toList(),
    );
  }

  List<CategoryStats> _prepareDisplayStats(List<CategoryStats> stats) {
    if (stats.length <= 8) return stats;

    final topStats = stats.take(7).toList();
    final othersValue = stats.skip(7).fold(0.0, (sum, stat) => sum + stat.value);
    final othersCount = stats.skip(7).fold(0, (sum, stat) => sum + stat.transactionCount);

    if (othersValue > 0) {
      topStats.add(CategoryStats(
        date: DateTime.now(),
        value: othersValue,
        categoryName: '其他',
        transactionCount: othersCount,
        color: Colors.grey.shade400,
      ));
    }

    return topStats;
  }
}

月度对比柱状图

响应式柱状图组件

dart 复制代码
class MonthlyComparisonBarChart extends ConsumerWidget {
  final int year;
  final int ledgerId;

  const MonthlyComparisonBarChart({
    Key? key,
    required this.year,
    required this.ledgerId,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final monthlyTrendsAsync = ref.watch(monthlyTrendsProvider(MonthlyTrendsParams(
      ledgerId: ledgerId,
      year: year,
    )));

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  '$year年月度对比',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                Row(
                  children: [
                    _buildLegendItem(context, '收入', BeeTheme.colorsOf(context).income),
                    const SizedBox(width: 16),
                    _buildLegendItem(context, '支出', BeeTheme.colorsOf(context).expense),
                  ],
                ),
              ],
            ),
            const SizedBox(height: 16),
            
            SizedBox(
              height: 300,
              child: monthlyTrendsAsync.when(
                data: (trends) => _buildChart(context, trends),
                loading: () => const Center(child: CircularProgressIndicator()),
                error: (error, _) => Center(
                  child: Text('加载失败: $error'),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildLegendItem(BuildContext context, String label, Color color) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          width: 12,
          height: 12,
          decoration: BoxDecoration(
            color: color,
            borderRadius: BorderRadius.circular(2),
          ),
        ),
        const SizedBox(width: 6),
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    );
  }

  Widget _buildChart(BuildContext context, List<MonthlyTrend> trends) {
    if (trends.isEmpty) {
      return const Center(
        child: Text('暂无数据'),
      );
    }

    final theme = Theme.of(context);
    final colors = BeeTheme.colorsOf(context);
    final maxValue = trends
        .map((t) => math.max(t.income, t.expense))
        .reduce(math.max);

    return BarChart(
      BarChartData(
        alignment: BarChartAlignment.spaceAround,
        maxY: maxValue * 1.2,
        
        gridData: FlGridData(
          show: true,
          drawHorizontalLine: true,
          drawVerticalLine: false,
          horizontalInterval: _calculateInterval(maxValue),
          getDrawingHorizontalLine: (value) => FlLine(
            color: theme.colorScheme.outline.withOpacity(0.2),
            strokeWidth: 1,
          ),
        ),
        
        titlesData: FlTitlesData(
          show: true,
          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
          
          bottomTitles: AxisTitles(
            sideTitles: SideTitles(
              showTitles: true,
              getTitlesWidget: (value, meta) {
                final month = value.toInt() + 1;
                return SideTitleWidget(
                  axisSide: meta.axisSide,
                  child: Text(
                    '${month}月',
                    style: TextStyle(
                      color: theme.colorScheme.onSurfaceVariant,
                      fontSize: 12,
                    ),
                  ),
                );
              },
            ),
          ),
          
          leftTitles: AxisTitles(
            sideTitles: SideTitles(
              showTitles: true,
              reservedSize: 60,
              interval: _calculateInterval(maxValue),
              getTitlesWidget: (value, meta) {
                return Text(
                  _formatAmount(value),
                  style: TextStyle(
                    color: theme.colorScheme.onSurfaceVariant,
                    fontSize: 12,
                  ),
                );
              },
            ),
          ),
        ),
        
        borderData: FlBorderData(show: false),
        
        barGroups: trends.asMap().entries.map((entry) {
          final index = entry.key;
          final trend = entry.value;
          
          return BarChartGroupData(
            x: index,
            barRods: [
              BarChartRodData(
                toY: trend.income,
                color: colors.income,
                width: 12,
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(4),
                ),
                backDrawRodData: BackgroundBarChartRodData(
                  show: true,
                  toY: maxValue * 1.2,
                  color: theme.colorScheme.surfaceVariant.withOpacity(0.3),
                ),
              ),
              BarChartRodData(
                toY: trend.expense,
                color: colors.expense,
                width: 12,
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(4),
                ),
              ),
            ],
            barsSpace: 4,
          );
        }).toList(),
        
        barTouchData: BarTouchData(
          enabled: true,
          touchTooltipData: BarTouchTooltipData(
            tooltipBgColor: theme.colorScheme.surface,
            tooltipBorder: BorderSide(
              color: theme.colorScheme.outline,
            ),
            tooltipRoundedRadius: 8,
            getTooltipItem: (group, groupIndex, rod, rodIndex) {
              final trend = trends[groupIndex];
              final isIncome = rodIndex == 0;
              final amount = isIncome ? trend.income : trend.expense;
              final label = isIncome ? '收入' : '支出';
              
              return BarTooltipItem(
                '${trend.month}月\n$label: ¥${amount.toStringAsFixed(2)}',
                TextStyle(
                  color: isIncome ? colors.income : colors.expense,
                  fontWeight: FontWeight.bold,
                ),
              );
            },
          ),
        ),
      ),
    );
  }

  double _calculateInterval(double maxValue) {
    if (maxValue <= 1000) return 200;
    if (maxValue <= 10000) return 2000;
    if (maxValue <= 100000) return 20000;
    return 50000;
  }

  String _formatAmount(double amount) {
    if (amount >= 10000) {
      return '${(amount / 10000).toStringAsFixed(1)}万';
    }
    return '${amount.toStringAsFixed(0)}';
  }
}

图表性能优化

数据缓存策略

dart 复制代码
class ChartDataCache {
  static final Map<String, CachedData> _cache = {};
  static const Duration cacheExpiration = Duration(minutes: 5);

  static Future<T> getOrCompute<T>(
    String key,
    Future<T> Function() computation,
  ) async {
    final cached = _cache[key];
    
    if (cached != null &&
        DateTime.now().difference(cached.timestamp) < cacheExpiration) {
      return cached.data as T;
    }

    final result = await computation();
    _cache[key] = CachedData(
      data: result,
      timestamp: DateTime.now(),
    );

    return result;
  }

  static void clearCache() {
    _cache.clear();
  }

  static void clearExpired() {
    final now = DateTime.now();
    _cache.removeWhere((key, value) =>
        now.difference(value.timestamp) >= cacheExpiration);
  }
}

class CachedData {
  final dynamic data;
  final DateTime timestamp;

  CachedData({
    required this.data,
    required this.timestamp,
  });
}

响应式数据更新

dart 复制代码
// 使用 Riverpod 的自动缓存和失效机制
final dailyStatsProvider = FutureProvider.family.autoDispose<List<DailyStats>, DailyStatsParams>(
  (ref, params) async {
    final analytics = ref.watch(analyticsServiceProvider);
    
    // 监听相关数据变化,自动失效缓存
    ref.listen(currentLedgerIdProvider, (prev, next) {
      if (prev != next) {
        ref.invalidateSelf();
      }
    });

    return analytics.getDailyStats(
      ledgerId: params.ledgerId,
      range: params.range,
    );
  },
);

class DailyStatsParams {
  final int ledgerId;
  final DateTimeRange range;

  const DailyStatsParams({
    required this.ledgerId,
    required this.range,
  });

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is DailyStatsParams &&
          runtimeType == other.runtimeType &&
          ledgerId == other.ledgerId &&
          range == other.range;

  @override
  int get hashCode => ledgerId.hashCode ^ range.hashCode;
}

图表交互增强

手势操作支持

dart 复制代码
class InteractiveChart extends StatefulWidget {
  final Widget chart;
  final VoidCallback? onRefresh;

  const InteractiveChart({
    Key? key,
    required this.chart,
    this.onRefresh,
  }) : super(key: key);

  @override
  State<InteractiveChart> createState() => _InteractiveChartState();
}

class _InteractiveChartState extends State<InteractiveChart> {
  bool _isRefreshing = false;

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: () async {
        if (widget.onRefresh != null) {
          setState(() {
            _isRefreshing = true;
          });
          
          widget.onRefresh!();
          
          // 模拟刷新延迟
          await Future.delayed(const Duration(milliseconds: 500));
          
          setState(() {
            _isRefreshing = false;
          });
        }
      },
      child: SingleChildScrollView(
        physics: const AlwaysScrollableScrollPhysics(),
        child: AnimatedOpacity(
          opacity: _isRefreshing ? 0.5 : 1.0,
          duration: const Duration(milliseconds: 200),
          child: widget.chart,
        ),
      ),
    );
  }
}

空状态处理

dart 复制代码
class EmptyChartWidget extends StatelessWidget {
  final String message;
  final IconData icon;
  final VoidCallback? onAction;
  final String? actionLabel;

  const EmptyChartWidget({
    Key? key,
    required this.message,
    this.icon = Icons.bar_chart,
    this.onAction,
    this.actionLabel,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            icon,
            size: 64,
            color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
          ),
          const SizedBox(height: 16),
          
          Text(
            message,
            style: Theme.of(context).textTheme.bodyLarge?.copyWith(
              color: Theme.of(context).colorScheme.onSurfaceVariant,
            ),
            textAlign: TextAlign.center,
          ),
          
          if (onAction != null && actionLabel != null) ...[
            const SizedBox(height: 24),
            FilledButton(
              onPressed: onAction,
              child: Text(actionLabel!),
            ),
          ],
        ],
      ),
    );
  }
}

最佳实践总结

1. 数据处理原则

  • 数据分层:原始数据 -> 处理数据 -> 显示数据
  • 缓存策略:合理使用缓存避免重复计算
  • 异步加载:大数据集使用异步处理

2. 性能优化

  • 延迟渲染:复杂图表使用延迟初始化
  • 内存管理:及时清理不需要的数据
  • 动画优化:合理使用动画,避免过度渲染

3. 用户体验

  • 加载状态:提供明确的加载反馈
  • 错误处理:优雅处理数据异常
  • 交互反馈:提供触觉和视觉反馈

4. 视觉设计

  • 颜色一致性:遵循应用主题色彩
  • 可读性:确保文字和图形清晰可见
  • 响应式:适配不同屏幕尺寸

实际应用效果

在BeeCount项目中,fl_chart数据可视化系统带来了显著价值:

  1. 用户洞察提升:直观的图表帮助用户理解消费模式
  2. 使用时长增加:丰富的数据分析提升用户粘性
  3. 专业印象:美观的图表提升应用专业形象
  4. 决策支持:数据可视化辅助用户财务决策

结语

数据可视化是现代应用不可或缺的功能,fl_chart为Flutter开发者提供了强大而灵活的图表解决方案。通过合理的架构设计、性能优化和用户体验考虑,我们可以构建出既美观又实用的数据可视化系统。

BeeCount的实践证明,优秀的数据可视化不仅能提升用户体验,更能为用户创造实际价值,帮助他们更好地理解和管理自己的财务状况。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第5篇,后续将深入探讨CSV导入导出、国际化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!