接手鸿蒙版天气应用的图表重构之前,我一直以为“画个折线图”是最不值得讨论的需求。但真当需求落到面前——温度要能看出昼夜趋势,降水量要能一眼分辨哪个时段有雨,湿度要能和温度对照着看,还要在鸿蒙设备上跑得流畅——我才发现这事远比想象中复杂。
第一版我尝试用自绘Canvas把所有元素从零画出来,坐标轴、刻度、网格、手势、Tooltip全手搓,折腾了两周,效果依然不理想。后来换成Flutter生态里的fl_chart,三天就上了线,还顺手解决了动画和触摸交互。这篇文章我把自己在选型、分图、接入、调优和踩坑上的完整思路整理出来,给准备做同类天气数据可视化,或者正在鸿蒙应用里做Flutter图表层的朋友当一个参考。
1. 鸿蒙版天气应用,为什么绕回来用Flutter画图表
1.1 第一个被否掉的方案:自绘Canvas
最开始看到鸿蒙版天气应用的需求,我脑子里冒出来的方案是直接走原生自绘。平台的原生绘制能力确实够强,理论上什么都能画,但问题是:你需要画的不是一个静态图,而是带坐标轴、动态刻度、触摸回调、Tooltip、切换动画的交互图表。
如果走自绘,我要自己维护的东西会非常长。横向的日期刻度怎么根据屏幕宽度自动抽稀?纵向的区间怎么让温度在-5℃到40℃之间显示得合理?手指按到折线附近时,怎么算出最近的采样点并弹出提示?城市切换时,新旧数据怎么补间动画?
这些问题每一个都不难,但加起来就是一个完整图表库的工作量。为了一个天气页面去重造这些轮子,性价比太低了。
1.2 fl_chart的定位恰好符合“够用且可控”
后来我转向了Flutter图表库,主要看中三件事:
第一,Flutter图表代码在鸿蒙端可以直接嵌入运行。这个项目本身有跨端需求,未来其他端也要用同一套UI,用Flutter画图表能让视觉和交互保持一致,不至于每端重写一遍。
第二,fl_chart对常用图表类型的覆盖非常精准。它提供折线图、柱状图、饼图、散点图等,天气场景需要的就集中在这几种,不用引入一个庞大复杂的商业图表组件,库体积和定制成本都可控。
第三,动画和触摸是现成的。fl_chart内置了数据变化时的动画过渡和触摸回调。这个在自绘方案中非常耗时,在它这里是默认能力。
我也不太建议一上来就追全功能图表方案,很多功能用不上,反而增加包体积和版本冲突风险。fl_chart的代码量适中,读得懂源码,真遇到问题改起来也快。
1.3 一个关键判断:图表库选型要跟着数据特征走
选型之前,我先列了一张表,把三种数据和候选图型做了一次匹配:
| 数据 | 数据类型 | 关注重点 | 适合图型 |
|---|---|---|---|
| 温度 | 连续值 | 昼夜变化趋势、极值 | 折线图 |
| 降水量 | 离散事件 | 有雨没雨、峰值大小 | 柱状图 |
| 湿度 | 百分比 | 与温度对照的干湿变化 | 折线图/双Y轴 |
用这个表能看到,真正制约选型的不是“哪个图更漂亮”,而是数据本身的属性。温度的连续特性决定了它用折线表达趋势最自然,降水的离散特性用柱状高度来对比“量级”最直观,湿度作为一个百分比指标放在第二Y轴能跟温度在同一时间轴下对比。
想清楚这一点之后,fl_chart里该用哪两个组件就已经明确了。
2. 图表不是越炫越好:三种天气数据该怎么拆
2.1 温度:连续变量,折线图是唯一合理选项
温度是典型的连续变量。从早上到夜里,温度的变化是平滑渐进的过程,不会在某个小时突然从20℃跳到30℃。这种数据要表达的语义是“趋势”,而折线图恰恰是把点的位置用时间轴串联,让上升、下降、波动的过程一目了然。
我一开始也想过用柱状图展示每小时温度。画出来之后发现,柱子的高度虽然能看出数值高低,但“趋势”被柱子之间的割裂感削弱了,尤其是一天中温度曲线来回波动的部分,用户不容易第一时间看出来“夜里一直在降温”。换成折线图之后,视觉通道从“逐个比较高度”变成了“扫视整体走向”,信息读取效率明显提升。
在fl_chart里,折线图对应LineChart组件,核心是把时间点和温度值转成FlSpot。比如早上8点24.5℃,生成FlSpot(8, 24.5)。FlSpot的x是时间索引,y是温度值,单位统一用摄氏度保存,展示时再决定是否带小数。
2.2 降水量:离散事件,柱状图更能体现“有没有、有多大”
降水量的数据特征和温度完全不同。它不是一条连续变化的线,而是“某段时间有没有下雨、下多大”。用柱状图展示,每个小时一根柱子,柱高代表雨量,零降水时柱子高度为0,一眼就能看到哪几个时段降雨集中。
这里有一个我自己栽过跟头的点:降水量不适合用折线图。如果把有雨时段和无雨时段用折线连起来,曲线会在0值附近来回上下穿梭,视觉上似乎暗示“雨量在平滑变化”,但实际并没有这样的物理意义。用户在查看小时降雨时,更想知道的是“这一小时有没有下”和“哪一小时雨最大”,柱状图的离散块状表达正好匹配这个心理模型。
在fl_chart里,柱状图对应BarChart组件。每个小时一组数据,用BarChartGroupData包裹,柱子高度用BarChartRodData的toY表示。再把相邻柱子的间距调小一点,视觉上就会有一个“雨量随时间分布”的整体感。
2.3 湿度:百分比指标,与温度共用一张双Y轴图
湿度本质上也是连续变量,但它和温度有个明显的差异:量纲不同。温度在-10℃到40℃之间波动,湿度在0%到100%之间波动,如果都放在同一个Y轴,数值小的那一方会被压成一条几乎水平的线。
我最后采用的是“温度+湿度双Y轴组合图”。温度走左侧Y轴,湿度走右侧Y轴。因为fl_chart的LineChart默认只支持一个数据坐标系,但可以通过右侧标题配置来模拟第二个Y轴:把湿度值映射到右侧刻度区间,再给湿度折线指定一个不同的颜色和透明度,这样两条线在时间轴上完全对齐,又能区分各自的量纲。
老实说,双Y轴不是必须的。如果你只有一个竖屏单列卡片,温度和湿度分开两张图放上下排列,也完全可行。但我在实际使用中发现,用户看天气时往往想同时感知“温度在升、湿度在降”这种关联变化,共用一张双Y轴图能把这种关联性更紧凑地表达出来。
3. 接入fl_chart,三步把它跑起来
3.1 依赖与版本:不要随便追最新
在项目的pubspec.yaml里添加依赖:
dependencies: fl_chart: ^0.66.0版本号我建议选一个稳定的大版本,不需要追求最新。fl_chart迭代不算慢,大版本之间偶尔会有API命名调整,比如部分配置项从oldStyle迁移到新写法。如果你的项目还用了其他Flutter图表组件,要注意依赖树是否冲突。我实际接入时发现,只要Flutter版本不低于项目整体要求,这个库的兼容性还比较省心。
在鸿蒙设备上跑还有一个建议:优先使用当前稳定的Flutter SDK版本。图表组件对渲染管线没有太多特殊依赖,但跨端工程通常已经集成了很多插件,保持Flutter版本稳定能减少很多莫名其妙的编译问题。
3.2 第一个图表:温度折线
先看一个最精简的温度折线图配置:
LineChart( LineChartData( minY: 0, maxY: 40, gridData: FlGridData( show: true, drawVerticalLine: false, ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) => Text('${value.toInt()}°'), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) => Text(_hourLabel(value.toInt())), ), ), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), lineBarsData: [ LineChartBarData( spots: _tempsSpots, isCurved: true, colors: [Color(0xFF3B82F6)], barWidth: 2.5, dotData: FlDotData(show: false), belowBarData: BarAreaData( show: true, color: Color(0xFF3B82F6).withValues(alpha: 0.1), ), ), ], ), )这段配置有几个地方值得注意:
minY和maxY不要直接依赖数据自动范围。天气图表如果是从0到40固定区间,用户就能凭直觉判断当前温度大概处于一天中的什么水平;如果每次数据一来区间就跟着变,反而会误导用户对冷暖的感知。
isCurved: true让折线变得平滑,符合温度连续变化的物理直觉。belowBarData给曲线下方加一个低透明度的填充,这是天气类应用很通用的做法,让趋势图更有“面积感”。
数据转换部分是这样的:
List<FlSpot> _buildTempSpots(List<HourlyWeather> hours) { return List.generate(hours.length, (i) { final temp = hours[i].temperature; return FlSpot(i.toDouble(), double.parse(temp.toStringAsFixed(1))); }); }这里有个细节:toStringAsFixed(1)把温度限制到一位小数,避免因为浮点误差在图表左侧显示一堆乱七八糟的数字。我们在测试时见过22.300000000000004这种值,有的版本会把Y轴刻度渲染成丑陋的浮点长尾巴。
3.3 降水柱状图与湿度曲线的接入
降水柱状图的配置比折线图更直接:
BarChart( BarChartData( maxY: 30, alignment: BarChartAlignment.spaceAround, gridData: FlGridData(show: true), borderData: FlBorderData(show: false), barTouchData: BarTouchData( enabled: true, touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final mm = rod.toY; return BarTooltipItem( '${group.x}时\n${mm.toStringAsFixed(1)}mm', const TextStyle(color: Colors.white), ); }, ), ), barGroups: List.generate(hours.length, (i) { final precip = hours[i].precipitation; return BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: precip, width: 6, borderRadius: BorderRadius.circular(2), color: Color(0xFF38BDF8), ), ], ); }), ), )alignment: BarChartAlignment.spaceAround让每个小时的柱子居中对齐,看起来更整齐。降水量单位是毫米,柱子的toY直接取原值。0降水时柱子高度也确实是0,这个后面会专门讲怎么处理“零值消失”的观感问题。
湿度曲线的接入和温度折线几乎一样,区别只在两条线共用一个LineChart。代码上把温度线放在lineBarsData的第一个元素,湿度线放在第二个元素,右侧标题单独显示百分比:
rightTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 44, getTitlesWidget: (value, meta) => Text('${value.toInt()}%'), ), ),湿度线的颜色我用浅橙色,透明度降到70%左右,这样和主温度线叠在一起时不会抢视觉重点。两条线如果都用同样的颜色和粗细,用户会很难分清哪条是温度哪条是湿度,这是组合图最容易犯的错。
4. 真正耗时的是“好看+好用”的打磨
4.1 触摸和Tooltip:数字要跟手
图表接入跑通之后,我花了最多时间的不是画数据,而是把Tooltip体验磨到满意。天气图表有个特点:数据点密集,24个小时温度折线和每小时降水柱状图,靠视觉看只能掌握“大概”,用户想了解具体时刻的数值,很自然会去触摸图表。
fl_chart的触摸体验默认已经不错,但有几个参数值得仔细调。
一个是LineTouchData的回调。我想让用户触摸折线附近时,自动把最近的采样点高亮,并显示对应小时和温度。实现上是用touchCallback拿到FlTouchEvent和LineTouchResponse,从response?.touchedSpotIndex取出最新的索引,再更新当前选中状态:
lineTouchData: LineTouchData( touchCallback: (event, response) { if (response == null || response.touchedSpotIndex == null) return; final index = response.touchedSpotIndex!; setState(() => _selectedHourIndex = index); }, touchTooltipData: LineTouchTooltipData( getTooltipColor: (touchedSpot) => Colors.black87, getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { final index = spot.x.toInt(); return LineTooltipItem( '${_hourLabel(index)} ${spot.y.toStringAsFixed(1)}°', TextStyle(color: Colors.white), ); }).toList(); }, ), ),还有一点:Tooltip默认会出现在手指上方,当手指滑到屏幕最右侧时,Tooltip的文字很容易超出屏幕边缘,被截断。我用的处理方式是给图表外层的容器在右侧留出一段安全边距,同时限制Tooltip文字宽度,必要时换行。这个看着是小问题,但用户实际使用时会频繁遇到。
4.2 动画与刷新策略:别让重绘拖垮滚动
天气图表在进入页面和切换城市时,都会经历一次数据更新。fl_chart是带有动画特性的图表组件,配置Duration后,新旧数据之间会自动补间,从旧曲线平滑过渡到新曲线。这个动画效果我很推荐保留,因为它能给用户一个视觉上的“定位”过程,而不是让数据突然跳变。
但动画也有代价。如果页面里同时有折线图、柱状图、湿度曲线,而且父组件每次滚动都触发setState,图表会频繁重绘,在鸿蒙设备的中低端机型上会明显卡顿。
我的优化策略有三点:
第一,把图表数据计算和图表组件本身分离。原始数据转换成FlSpot列表、生成BarGroups这些操作,只在实际数据变化时执行,结果存到State里,不每次都重新计算。
第二,图表数据不变时,用const构造图表。如果数据已经确定,const LineChart(LineChartData(...))可以减少重复构建。如果必须传入动态变量,在父组件里用shouldRepaint逻辑手动拦截,数据没变化就返回false。
第三,控制点数和装饰项的渲染开销。FlSpot数量按每小时一个点足够,不需要分钟级。折线图的点如果不需要托盘,可以设置FlDotData(show: false)。网格线和刻度的透明度降低一些,也能明显减少绘制压力。
4.3 在鸿蒙设备上的实际帧率与内存观察
我在鸿蒙设备的低配机型上做过简单实测。单独的LineChart或BarChart,10秒动画期间帧率稳定在55到60帧。页面里同时挂三个图表组件,并且上下滚动时,帧率会有所下降,但视觉上没有明显掉帧。
比较明显的问题是内存。每个图表组件内部都有独立的Painter和动画控制器,如果页面频繁进出,或者在不必要的时候重建整个图表树,内存会快速上涨。我的规避办法是在页面销毁时把图表组件从树中移除,并且不持有多余的动画控制器引用。进入页面时,用animateOnAppear播放入场动画,退出时直接销毁,不保留后台动画任务。
5. 两周实测下来的坑,至少能省你一个周末
5.1 刻度小数失控:22.300000001℃
现象出现在第一版上线后。温度折线图左侧的Y轴刻度偶尔会出现一串很长的小数,比如22.300000000000004。坐标轴上出现这种数字,视觉上非常业余,用户也会直观觉得数据不可靠。
排查链路大概是这样的:
首先怀疑是数据源的问题,打印接口返回的原始温度和FlSpot里的y值,发现接口数据是正常的浮点数,转换后也确实保留了精度。继续调试后发现,根因是fl_chart在计算坐标轴刻度时会基于数据范围做插值。当minY和maxY是直接根据数据计算出来的浮点边界时,内部插值产生的刻度值就会带上浮点噪声。
解决办法是显式规定坐标轴边界和刻度间隔。温度图固定minY为0、maxY为40,Y轴刻度按5度一步生成,这样刻度值永远都是整数。如果业务上确实需要动态边界,就把边界值用roundToDouble()先取整,避免不规则小数。
5.2 柱状图无缝对应,但零值“消失”了
降水柱状图接入后遇到一个观感问题:某一天完全没有降雨,整排柱子全部高度为0,页面上只留下一根根看不见的柱子在时间位置上“凭空对齐”,视觉上非常奇怪。
我先试着把柱子的最小高度设为1,结果1mm的“视觉雨量”反而误导用户,看起来似乎下过雨。反复调整后,采用的方案是:零值时画一根低透明度、只有格线高度的极细占位柱,同时在柱状图上方用一个图标加文字表示“今日无降水”。占位柱的透明度很低,用户不会误读成降雨,但能保持时间轴的连续性。
5.3 Tooltip在屏幕边缘被截断
这是触摸体验里最容易被忽略的问题。手指滑到图表最右侧时,Tooltip默认出现在手指上方偏右的位置,文字很容易超出屏幕边界。
解决方式比较朴素:在FlTitlesData里为右边距预留更多空间,同时限制Tooltip文字的最大宽度,超过最大宽度就换行。组合图表里,两个Tooltip项并排出现,文字一长很容易撞在一起,我给Tooltip背景设置了较深的半透明色,并给文字设置了白底色反差,这样即使重叠也能看清内容。
5.4 热重载后图表错乱,别信热重载的“暂时性”
开发阶段最坑的一件事:修改代码保存后热重载,图表页面有时会变成一片空白,或者坐标轴和折线错位,看起来像数据没传进去。一开始我以为是fl_chart的Bug,反复检查后发现,这是热重载后旧图表实例仍在执行动画导致的竞态。
解决办法是避免用热重载后的瞬时状态判断问题。如果图表刷新异常,先冷启动应用看一次,通常冷启动一切正常。开发策略上,我把图表组件的创建放在一个有ValueKey的子树里,数据源变化时通过重建Key强制重新挂载图表,保证动画状态和数据源始终同步。
5.5 小屏设备的排版挤压
在鸿蒙设备的小屏机型上,温度和湿度双Y轴组合图会遇到左侧和右侧刻度文字互相挤压的问题。左边要显示温度“24°”,右边要显示湿度“62%”,中间还要留足折线绘制区域,屏幕一窄,整个图表就显得特别局促。
我最后通过三个调整解决了:左侧Y轴reservedSize设为40,右侧设为44,给刻度文字足够的空间;底部的时间刻度只显示偶数小时,减少文字重叠;折线图左右两侧不用边框,用网格背景替代,省下不少像素。
如果屏幕实在太小,我建议放弃双Y轴,直接上下排列温度和湿度两张图。牺牲一点关联性,换来的却是一目了然的可读性,这在移动端场景里更值得。
说回这几天折腾下来的整体感受:用fl_chart做天气图表,最难的不是把线画出来,而是理解数据该以什么样的形式被用户读取。折线图要留住温度的趋势感,柱状图要尊重降水的离散性,双Y轴要处理好湿度与温度的量纲差异。这些想清楚了,fl_chart就是一个趁手的工具,配置起来很快,动画和触摸也够成熟。
我自己的习惯是:每次拿到新图表需求,先画一版最简单的静态图,确认数据映射无误,再一点点加网格、填充、动画这些装饰。最早动手画折线图时不要追求一步到位,从一个能看清数值的LineChart开始,后面的事情都不会太难。