flutter_for_openharmonyFillUp -油耗追踪器app实战+花费趋势图实现
这一篇我们只聚焦一件事:把“每次加油花了多少钱”画成一条趋势曲线。你会看到项目里为什么把 x 轴设计成“第 N 次记录”,为什么底部日期要做稀疏显示,以及 tooltip 如何把一个点还原成一条真实记录。为了避免文章讲得很玄,下面所有代码都来自的。每段代码后面我都会紧跟解释。

这一篇我们只聚焦一件事:
把“每次加油花了多少钱”画成一条趋势曲线。
你会看到项目里为什么把 x 轴设计成“第 N 次记录”,
为什么底部日期要做稀疏显示,
以及 tooltip 如何把一个点还原成一条真实记录。
为了避免文章讲得很玄,
下面所有代码都来自 lib/app/fillup_app.dart 的 AnalyticsPage。
每段代码后面我都会紧跟解释。
1. 花费趋势图想解决什么问题
“花费趋势”不是为了炫技。
它对应的是用户非常具体的两个需求:
- 对比
今天这次加油是不是明显比平时贵。 - 定位
价格波动是油价变了,还是加油量变了。
项目里把“花费趋势”单独做成一张卡片,
并且放在统计概览之后。
原因也很直接:
- 概览卡先回答“总体情况”。
- 趋势图再回答“变化过程”。
2. 数据进入图表前:先把记录按时间排好
图表如果不保证时间顺序,
用户会看到“曲线倒着走”。
项目里在 AnalyticsPage 中先生成一个 ordered:
final ordered = List<FillUpEntry>.from(list)..sort((a, b) => a.dateMs.compareTo(b.dateMs));
这里有三个关键点:
- 为什么不直接 sort 原来的 list
因为list是从 controller 里拿出来的临时结果,
但在别的页面也可能会以别的顺序展示。
复制一份再排序,影响面更小。 - 为什么用
dateMs
数据库存的是毫秒时间戳,
排序稳定、比较成本低。 - 为什么按升序
趋势图更符合“从过去到现在”的阅读习惯。
3. 生成 x 轴标签:把时间戳格式化成短日期
曲线的 x 轴在这里被设计成“第 i 条记录”。
但为了让用户知道这些点大概落在哪一天,
底部仍然会显示日期。
项目里这样生成 dateLabels:
final df = DateFormat('MM-dd');
final dateLabels = ordered
.map((e) => df.format(DateTime.fromMillisecondsSinceEpoch(e.dateMs)))
.toList(growable: false);
这段实现背后的取舍:
MM-dd而不是yyyy-MM-dd
底部空间有限,短日期更容易读。growable: false
这里的 labels 只生成一次并用于绘制,
不需要再扩容。- 从
dateMs转 DateTime
不在数据层做格式化,
UI 需要什么格式就自己决定。
4. 标签太多怎么办:showEvery 做“稀疏显示”
当记录很多时,
每个点都显示一个日期会直接挤爆底部。
项目里用了一个很朴素的策略:
final showEvery = (() {
final s = (dateLabels.length / 4).floor();
return s <= 0 ? 1 : s;
})();
读法可以理解成:
- 大概只显示 1/4 的标签
length / 4。 - 至少每隔 1 个显示
列表短的时候不要误伤。
这个策略并不追求完美,
但它足够稳定:
- 记录少时,标签完整。
- 记录多时,标签自动变稀疏。
5. 把每条记录映射成点:costPoints 的构造
fl_chart 的折线图核心输入是 List<FlSpot>。
项目里把 x 轴设计成“序号”,
y 轴是“某个指标值”。
对应真实代码:
final costPoints = <FlSpot>[];
final pricePoints = <FlSpot>[];
final litersPoints = <FlSpot>[];
for (int i = 0; i < ordered.length; i++) {
final e = ordered[i];
costPoints.add(FlSpot(i.toDouble(), e.totalCost));
pricePoints.add(FlSpot(i.toDouble(), e.pricePerLiter));
litersPoints.add(FlSpot(i.toDouble(), e.liters));
}
这里的“设计味道”其实很浓:
- 同一个 i,对应同一次加油
所以花费、油价、油量可以共享同一套 x 轴。 - x 用 double
这是FlSpot的要求,
但也顺便让后续“插值/曲线”更自然。 - totalCost 直接来自数据模型
不在统计页二次计算,
避免liters * price的浮点误差在 UI 层重复出现。
这一段同时生成了三条曲线的点集。
但是本篇先只讲 costPoints。
6. 卡片骨架:标题 + 固定高度的绘制区域
趋势图在 UI 上是一个卡片。
先把容器搭起来,
读起来更清晰。
真实代码如下(从卡片开始摘录):
Card(
child: Padding(
padding: EdgeInsets.all(12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('花费趋势', style: Theme.of(context).textTheme.titleMedium),
SizedBox(height: 12.h),
SizedBox(
height: 220.h,
child: LineChart(
这段代码里你至少应该注意两点:
- 高度固定为
220.h
折线图的可读性强依赖高度。
高度如果跟随内容变化,
在不同机型上会非常难调。 - Column + 标题
让图表不是孤零零一块画布,
用户知道自己在看什么。
7. LineChartData:把曲线“画出来”的最小配置
LineChart 的核心参数是 LineChartData。
项目里把最关键的部分写得很直白:
LineChartData(
minX: 0,
maxX: (dateLabels.length - 1).toDouble(),
lineBarsData: <LineChartBarData>[
LineChartBarData(
spots: costPoints,
isCurved: true,
dotData: const FlDotData(show: false),
barWidth: 3,
color: Theme.of(context).colorScheme.primary,
),
],
gridData: const FlGridData(show: true),
逐行解释一下这些参数为什么这样选:
- minX/maxX
x 的范围明确锁定在0..length-1。
这样getTitlesWidget和 tooltip 的idx才能安全对应回dateLabels/ordered。 spots: costPoints
数据输入就是你第 5 节构造的点。
图表不关心你的业务模型。isCurved: true
曲线更顺滑。
对“花费”这种离散数据来说,
平滑的视觉反馈更友好。dotData: show: false
默认不画点。
点太多会糊成一条“毛毛虫”。
项目选择在触摸时再强调当前点。barWidth: 3
线太细不易读。
线太粗会遮挡波动细节。- 颜色跟主题走
主题色让统计页整体更统一。 gridData: show: true
让用户更容易估算“相差多少”。
8. 只显示底部标题:titlesData 的“克制”
趋势图上如果出现四条坐标轴标题,
反而会变得很吵。
项目里非常克制:
只开底部标题。
真实代码如下:
titlesData: FlTitlesData(
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final idx = value.round();
if (idx < 0 || idx >= dateLabels.length) {
return const SizedBox.shrink();
}
if (idx != 0 && idx != dateLabels.length - 1 && idx % showEvery != 0) {
return const SizedBox.shrink();
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(dateLabels[idx], style: Theme.of(context).textTheme.labelSmall),
);
},
),
),
),
这一段是花费趋势图“可读性”的核心。
我按两个层级拆解:
8.1 为什么只保留 bottomTitles
- 左边标题通常代表 y 值刻度。
但项目里为了干净,
直接把 y 刻度隐藏掉。 - 你依然可以通过 grid + tooltip 精确读数。
8.2 getTitlesWidget 的防御性
这里连续做了两次 SizedBox.shrink():
- 越界直接不画。
- 中间位置再按
showEvery做稀疏。
另外还保留了两个边界标签:
- 第一个点(idx == 0)。
- 最后一个点(idx == length-1)。
这很像“写分页”:
不管怎么省略,
都要让用户知道起点和终点。
9. 边框与网格:用最少元素维持视觉参考
borderData 在项目里直接关掉:
borderData: FlBorderData(show: false),
关掉边框的效果是:
- 卡片自身就是边界。
- 图里多一层框会显得拥挤。
与之相对,
网格线是打开的。
这让图表仍然有“参考线”。
10. 交互:触摸线 + 当前点
花费趋势图如果只是静态图,
最多只能看个大概。
项目里把触摸交互做得比较完整。
真实代码如下(触摸配置摘录):
lineTouchData: LineTouchData(
enabled: true,
handleBuiltInTouches: true,
getTouchedSpotIndicator: (barData, spotIndexes) {
final lineColor = Theme.of(context).colorScheme.outline.withOpacity(0.6);
final dotColor = barData.color ?? Theme.of(context).colorScheme.primary;
return spotIndexes
.map(
(i) => TouchedSpotIndicatorData(
FlLine(color: lineColor, strokeWidth: 1),
FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 4,
color: dotColor,
strokeWidth: 2,
strokeColor: Theme.of(context).colorScheme.surface,
);
},
),
),
)
.toList();
},
这里的“体验点”非常实用:
- 垂直指示线
FlLine让用户知道自己在看哪一个 x。 - 触摸才出现的点
默认不画点,触摸时再画,
既干净又能定位。 - dot 的描边颜色用 surface
避免 dot 在深色背景 tooltip 下“糊掉”。
11. Tooltip:把一个点还原成一条记录
趋势图最关键的,是能回答用户:
“这次花费对应哪次加油?”
项目里 tooltip 不是只显示 y 值。
它还显示:
- 日期(底部同款 label)。
- 里程。
- 油量。
对应真实代码:
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Theme.of(context).colorScheme.inverseSurface,
getTooltipItems: (spots) {
return spots.map((s) {
final idx = s.x.round();
final label = (idx >= 0 && idx < dateLabels.length) ? dateLabels[idx] : '';
final meta = (idx >= 0 && idx < ordered.length) ? ordered[idx] : null;
final ext = meta == null ? '' : '\n${meta.odometer.toStringAsFixed(0)} km • ${meta.liters.toStringAsFixed(2)} L';
return LineTooltipItem(
'$label\n¥${s.y.toStringAsFixed(2)}$ext',
const TextStyle(color: Colors.white),
);
}).toList();
},
),
我建议你重点看 idx 这条链路:
- 点的 x 是
i.toDouble()。 - tooltip 里用
round()还原回i。 - 再用
i去索引dateLabels与ordered。
这也是为什么第 7 节要锁定 minX/maxX。
否则出现“图表可滚动/可缩放”的时候,idx 会出现越界,
tooltip 会变得不可控。
ext 这里用三元表达式也很关键:
- 防止越界时
meta为 null 导致字符串拼接异常。 - tooltip 仍然能显示基础信息。
12. 这张图为什么没有 y 轴刻度
你可能会问:
不显示 y 轴刻度,用户怎么知道大概多少钱?
项目里用的是“组合方案”:
- grid 提供大概参照
让用户看出涨跌幅度。 - tooltip 提供精确读数
点击某次加油,直接显示到两位小数。
这种设计对“花费”特别合适。
因为用户更关心“某次具体值”,
而不是每一个 y 刻度。
13. 小结
这一篇我们把“花费趋势图”拆成四步:
- 先把记录按时间排序得到
ordered - 生成
dateLabels并用showEvery稀疏显示 - 把每条记录映射成
costPoints - 用
LineTouchData + tooltip让每个点都能解释得清楚
下一篇我们继续写“油价/油量趋势”的双曲线,
以及如何让两条曲线在同一张图里保持可读性。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)