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数据可视化系统带来了显著价值:
- 用户洞察提升:直观的图表帮助用户理解消费模式
- 使用时长增加:丰富的数据分析提升用户粘性
- 专业印象:美观的图表提升应用专业形象
- 决策支持:数据可视化辅助用户财务决策
结语
数据可视化是现代应用不可或缺的功能,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是一个完全开源的项目,欢迎开发者参与贡献:
- 项目主页 : https://github.com/TNT-Likely/BeeCount
- 开发者主页 : https://github.com/TNT-Likely
- 发布下载 : GitHub Releases
参考资源
官方文档
- fl_chart官方文档 - fl_chart完整使用指南
- Flutter图表选择指南 - Flutter官方图表组件对比
学习资源
- fl_chart示例集合 - 官方示例代码
- 数据可视化最佳实践 - Material Design数据可视化指南
本文是BeeCount技术文章系列的第5篇,后续将深入探讨CSV导入导出、国际化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!