说来惭愧,这篇《第14篇:数据分析与可视化》,我拖了差不多两周才动笔。不是没东西写,是这个模块做的时候实在折腾——HarmonyOS NEXT刚上车那会儿,生态里的图表方案几乎让人无从下手。Web组件里套ECharts不优雅,第三方原生图表库又少得可怜,最后我自己用Canvas手搓了一套折线图、柱状图和仪表盘,从大田实测一路跑到现在,表现一直很稳。
这篇教程是《高高种地》这个HarmonyOS智慧农业管理应用开发系列的一部分,适合两类人看:一是想在鸿蒙应用里加图表但找不到合适方案的开发者,二是做智慧农业产品、想把传感器数据真正呈现给用户的产品和研发。我会把可视化选型的思路、数据预处理、绘制原理、页面联动和踩过的坑全部整理出来,直接照着做就能把第一版数据分析页搭起来。
1. 为什么智慧农业App非做数据分析不可
1.1 数据采集只是起点,分析才是价值
传感器布下去之后,最怕的就是“坟头数据”——采了一堆,没人看。很多智慧农业项目最后沦为摆设,问题往往出在数据没有变成信息,更没有变成决策。
举个最简单的例子:一个温室里装了土壤湿度传感器,App上只有一个数字“43%”,用户看到这个数字能干嘛?他不知道该不该浇水。但如果给他一条土壤湿度24小时趋势线,再叠加过去几天的灌溉记录,他立刻就能判断:湿度从60%一路掉到43%,按这个速率,今晚9点前就该补一次水。这就是数据分析和可视化真正的价值——把藏在数字里的规律翻译成人话。
所以在整个应用里,我把这个模块放在了核心位置:设备管理、告警、远程控制都是“操作层”,数据分析与可视化是“决策层”。没有这一层,前面所有传感器布设的努力都会打折扣,用户只会把App当成一个带数字的遥控器。
1.2 种地人该怎么看这些图表
做农业可视化之前,我给自己列了一张“图表需求清单”,后来发现这张清单比任何技术方案都重要。它决定了你后端接口要返回什么字段、前端要画什么图。
| 图表类型 | 数据维度 | 种地人关心的问题 |
|---|---|---|
| 折线图 | 土壤湿度、温度、光照随时间变化 | 趋势怎么样,该不该浇水,温度是否异常 |
| 柱状图 | 灌溉量、施肥量、收获量对比 | 一周里哪天操作最多,哪块地产量高 |
| 仪表盘 | 实时环境指标 | 现在适不适合作物生长,要不要调整设备 |
做智慧农业App,别急着搞花哨的大屏。先想清楚三个问题:用户是谁(种植户还是管理员),他想做什么决策(浇不浇水、开不开风机),需要哪些数据支撑(趋势、对比还是实时值)。这三个答案直接决定了你画什么图。我见过不少项目上来就做炫酷大屏,可用户真正天天打开看的,还是那几个简单的趋势图。
1.3 分析模块在应用架构里的位置
整个应用的简化架构是这样的:设备层是土壤湿度、温湿度、光照传感器和灌溉控制器;接入层通过IoT平台统一接入设备、上报遥测数据;服务层提供云端数据存储与查询接口;应用层是HarmonyOS端的设备管理、告警和控制页面。数据分析模块嵌在应用层里,不是一个独立页面,而是主页面里的一个Tab。
这样的位置安排是刻意的。农业用户没有那么多耐心翻菜单,数据分析作为最高频查看的内容,入口一定要浅。从主界面点一下就能看到总览卡片,再点一下进入趋势图,整个路径不超过两步。
2. 可视化方案选型:在HarmonyOS NEXT上画图表的三条路
2.1 三条路子的对比
刚接触HarmonyOS NEXT时,我下意识照老安卓的习惯找图表库,结果发现生态完全不一样。这里把实际趟过的三条路对比一下:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Web组件 + ECharts | 图表类型极其丰富,文档多、案例多 | WebView性能开销,与原生数据交互别扭,包体大 | 已有Web资产、复杂大屏项目 |
| 第三方原生图表库(如MPAndroidChart移植版等) | 开箱即用,接口类似安卓习惯 | 鸿蒙适配不完善,升级有兼容性风险,定制能力有限 | 快速原型、非关键页面 |
| Canvas自绘 | 完全原生、轻量、可控性强,包体增加几乎为零 | 需要自己处理绘制细节,开发量略大 | 图表类型固定、数据量可控的业务场景 |
我最终选了Canvas自绘。三条路都实际试过:Web组件方案在低端机上切换页面有明显白屏感,第三方库又担心升级兼容性。而且我们的真实需求没那么多花活,折线、柱状、仪表盘,三种图打天下,自绘是性价比最高的。
2.2 自绘前必须想清楚的三件事
选Canvas自绘之前,我建议你先回答三个问题,回答完了再决定,别一头扎进去。
第一,图表类型会不会频繁变。如果产品想做成“表单项配置式”的可视化大屏,那自绘的工作量会失控,不如上ECharts。但如果需求很明确,只有固定几种图,自绘是稳的。
第二,数据量有多大。我们的历史记录最多展示几千个点,一次绘制毫秒级完成。但如果要一次画几十万点,Canvas的CPU绘制性能会吃紧,那是另一个维度的问题,需要做降采样或者走离屏Canvas。
第三,交互要求有多高。自绘方案做缩放、点击高亮这些交互,都得自己实现。我们这里只做“切换时间范围”“刷新数据”这类基础交互,自绘完全扛得住。想清楚这三点再动工,免得被方案拖住。
2.3 HarmonyOS Canvas组件的基本用法
鸿蒙的Canvas组件用起来很直白:创建Canvas并绑定一个CanvasRenderingContext2D上下文,然后通过上下文调用绘制方法就可以了。
@Entry @Component struct ChartDemo { // 创建渲染上下文,开抗锯齿 private settings: RenderingContextSettings = new RenderingContextSettings(true) private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) build() { Column() { Canvas(this.context) .width('100%') .height(220) .backgroundColor('#FFFFFF') .onReady(() => { // onReady 表示画布已经准备好,在这里开始绘制 this.drawDemo() }) } .width('100%') .height('100%') .padding(16) } drawDemo(): void { const ctx = this.context ctx.fillStyle = '#1677FF' ctx.font = '16vp sans-serif' ctx.textAlign = 'center' ctx.fillText('Hello HighHigh Farming', ctx.width / 2, 40) } }有几个点特别容易踩:必须在onReady回调里绘制,过早调用会报错;组件尺寸为0时绘制也不生效,所以Canvas要撑满父容器;创建RenderingContextSettings时那个true是抗锯齿开关,必须开着,不然折线和文字边缘会毛刺。基础搞清楚,后面画图就不慌了。
3. 数据准备:传感器数据怎么变成图表能用的数据
3.1 数据模型设计
可视化不是拿到原始数据就画,前面的数据准备决定图表质量。在分析页面,我定义了一个统一的数据结构——每个传感器采集点,就是一条“生长快照”。
export interface SensorRecord { timestamp: number; // 采集时间,Unix毫秒 soilMoisture: number; // 土壤体积含水量,单位 % temperature: number; // 空气温度,单位 ℃ humidity: number; // 空气相对湿度,单位 % lightIntensity: number; // 光照强度,单位 lux } export interface AggregatedPoint { timeLabel: string; // 展示用的时间标签,比如 "07-18 14:00" avg: number; // 平均值 min: number; // 最小值 max: number; // 最大值 }这个结构覆盖了温室大棚最常见的四类环境指标。实际项目里你还可以扩展CO₂浓度、土壤EC值、pH值等字段,思路完全一样。接口设计时建议把原始记录和聚合结果分开返回,原始记录用于下拉刷新时增量同步,聚合结果用于图表展示,这两者的数据量和查询频率差异很大。
3.2 数据清洗:先把“脏数”剔掉再画图
传感器数据往往没那么干净。田间地头网络抖动、传感器偶发漂移,都会产生异常值。如果直接画图,效果就是折线图里一根尖刺,Y轴范围被拉爆,趋势线被压成一条“地平线”。
我的清洗规则很简单:超出量程的直接剔除,比如土壤湿度不可能小于0或大于100;连续突变超过阈值的标记为可疑,比如土壤湿度前一秒20%、下一秒80%,这种跳变基本是异常;采集时间戳乱序的做排序处理。
function cleanRecords(records: SensorRecord[]): SensorRecord[] { const valid = records.filter(r => r.soilMoisture >= 0 && r.soilMoisture <= 100 && r.temperature >= -20 && r.temperature <= 60 && r.humidity >= 0 && r.humidity <= 100 && r.lightIntensity >= 0 && r.lightIntensity <= 200000 ) valid.sort((a, b) => a.timestamp - b.timestamp) return valid }这步看着简单,但特别容易被跳过。我最早做原型时偷懒跳过了清洗,结果自动计算Y轴范围时,一条异常尖刺把整条曲线压成了一条线条,趋势根本看不清。从那以后,清洗成了每次拉取数据后的固定第一步。这里我多说一句:清洗规则宁可保守,不可激进。只是标记可疑而不删除,让前端在展示时用灰色虚线连接,比直接删点更稳妥。
3.3 聚合:从海量原始点到好看的趋势线
传感器的采集频率一般是每5分钟一条,一天288条原始记录,一个月就是8000多条。直接画8000个点,信息密度过高,用户反而看不清趋势,交互流畅性也会下降。
聚合的本质,跟种地人“晒谷子装筐”一个道理:把一吨谷子装成若干筐、称重、记录,而不是一颗一颗去数。聚合分组如下:按小时看当天趋势,显示24个点;按天看近一周趋势,显示7个点;按周看一个月趋势,显示4到5个点。
聚合时同时保留平均、最小、最大值。主曲线画平均值,上下浮动带用最大最小值,这样能还原数据波动范围,比单画一条平均线信息量大多了。
function aggregateBy(records: SensorRecord[], granularity: 'hour' | 'day' | 'week'): AggregatedPoint[] { const map = new Map<string, SensorRecord[]>() records.forEach(r => { const d = new Date(r.timestamp) let key: string if (granularity === 'hour') { key = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}` } else if (granularity === 'day') { key = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}` } else { // 按周聚合,用周一作为一周起点 const day = d.getDay() || 7 const monday = d.getDate() - day + 1 key = `${d.getFullYear()}-${d.getMonth() + 1}-${monday}` } const group = map.get(key) ?? [] group.push(r) map.set(key, group) }) return Array.from(map.entries()) .map(([key, group]) => { const avg = group.reduce((s, r) => s + r.soilMoisture, 0) / group.length const min = Math.min(...group.map(r => r.soilMoisture)) const max = Math.max(...group.map(r => r.soilMoisture)) return { timeLabel: key, avg, min, max } }) .sort((a, b) => a.timeLabel.localeCompare(b.timeLabel)) }注意按周聚合时,d.getDay() || 7这种写法:鸿蒙的JavaScript引擎和标准JS一致,周日getDay返回0,要归一化成7,否则周一起点会算错,整整偏移一天。这种小细节,写的时候不觉得,出了bug才头疼。
3.4 平滑:让折线不那么“扎手”
聚合之后还有一步锦上添花的操作:移动平均。传感器数据即便清洗过,也可能有环境噪声,做一次5点移动平均,折线会平滑很多。
function movingAverage(values: number[], window: number = 5): number[] { const result: number[] = new Array(values.length).fill(0) const half = Math.floor(window / 2) for (let i = 0; i < values.length; i++) { const start = Math.max(0, i - half) const end = Math.min(values.length - 1, i + half) let sum = 0 for (let j = start; j <= end; j++) { sum += values[j] } result[i] = sum / (end - start + 1) } return result }但这里有一个原则必须守住:平滑只用于“看趋势”,不能用于“做决策”。报警阈值判断一定要基于原始值或清洗后的真实值,不能基于平滑值,否则真实突变会被抹平,该报警的时候报警反而延迟。你在后端做阈值告警时,也千万别把移动平均数据当作判断依据。这是我在调试时实际吃过亏的地方,有一次湿度突变导致告警没触发,排查半天才发现是平滑逻辑把尖峰削掉了。
4. 核心图表实现:亲手画出三种农业常用图
4.1 先搭好绘制画布的公共骨架
三个图都要用到坐标轴,所以我先抽了一个公共方法,负责画网格线、Y轴刻度和X轴标签。这个骨架写好了,后面加新图表就是套模板。
private drawAxes( ctx: CanvasRenderingContext2D, w: number, h: number, minY: number, maxY: number, labels: string[] ): { left: number; right: number; top: number; bottom: number } { const padding = { left: 48, right: 20, top: 24, bottom: 36 } const innerW = w - padding.left - padding.right const innerH = h - padding.top - padding.bottom const gridCount = 4 // 横向网格线 ctx.strokeStyle = '#EEEEEE' ctx.lineWidth = 1 for (let i = 0; i <= gridCount; i++) { const y = padding.top + (innerH * i) / gridCount ctx.beginPath() ctx.moveTo(padding.left, y) ctx.lineTo(w - padding.right, y) ctx.stroke() } // Y轴刻度值 const range = maxY - minY || 1 ctx.fillStyle = '#909399' ctx.textAlign = 'right' ctx.textBaseline = 'middle' ctx.font = '12vp sans-serif' for (let i = 0; i <= gridCount; i++) { const y = padding.top + (innerH * i) / gridCount const value = maxY - (range * i) / gridCount ctx.fillText(value.toFixed(0), padding.left - 8, y) } // X轴标签 ctx.textAlign = 'center' ctx.textBaseline = 'top' const step = labels.length > 1 ? innerW / (labels.length - 1) : 0 labels.forEach((label, index) => { ctx.fillText(label, padding.left + step * index, h - padding.bottom + 10) }) return padding }几个细节:网格先画、折线后画,这样折线能压在网格线上方,视觉上清楚很多;Y轴刻度用textBaseline='middle',对齐统一;X轴标签如果太多,可以隔一个取一个,避免文字互相挤压。农业里X轴标签常用的格式是“07-18 14:00”,一小时粒度放24个标签太挤,我实际会每6个取一个显示。
4.2 折线图:土壤湿度与温度趋势
折线图是农业应用里使用频率最高的图,核心逻辑就是“数据点映射到像素点”,然后用moveTo和lineTo连线。
private drawLineSeries( ctx: CanvasRenderingContext2D, values: number[], minY: number, maxY: number, color: string ): void { const padding = { left: 48, right: 20, top: 24, bottom: 36 } const w = ctx.width const h = ctx.height const innerW = w - padding.left - padding.right const innerH = h - padding.top - padding.bottom const range = maxY - minY || 1 if (values.length < 2) return ctx.beginPath() ctx.strokeStyle = color ctx.lineWidth = 2.5 ctx.lineJoin = 'round' ctx.lineCap = 'round' for (let i = 0; i < values.length; i++) { const x = padding.left + (innerW * i) / (values.length - 1) const y = padding.top + innerH - ((values[i] - minY) / range) * innerH if (i === 0) { ctx.moveTo(x, y) } else { ctx.lineTo(x, y) } } ctx.stroke() }这段代码大概是整篇教程里最核心的几十行。理解它,其他图表都是类似思路的变体。页面里先计算当前数据的minY和maxY,设置上下边距,传给drawAxes和drawLineSeries。我实际把土壤湿度画成绿色曲线,温度画成橙色曲线,用一个小图例区分。为了突出当前值,我还在最后一点画一个小圆点并显示具体数值,这个“最后一点标注”在农业场景特别实用,用户一眼就能看到此刻的湿度水平。
4.3 柱状图:一周灌溉量对比
柱状图适合做“对比”,我用来展示一周内每天的灌溉量、施肥量。柱子间距和顶端数值标注,是最影响视觉体验的部分。
private drawBarChart( ctx: CanvasRenderingContext2D, values: number[], labels: string[], color: string ): void { const padding = { left: 48, right: 20, top: 36, bottom: 36 } const w = ctx.width const h = ctx.height const innerW = w - padding.left - padding.right const innerH = h - padding.top - padding.bottom const maxValue = Math.max(...values, 1) const slotWidth = innerW / values.length values.forEach((value, index) => { const barWidth = slotWidth * 0.6 const x = padding.left + slotWidth * index + (slotWidth - barWidth) / 2 const barHeight = (value / maxValue) * innerH const y = padding.top + innerH - barHeight // 柱子 ctx.fillStyle = color ctx.beginPath() ctx.roundRect(x, y, barWidth, barHeight, 4) ctx.fill() // 顶部数值 ctx.fillStyle = '#303133' ctx.textAlign = 'center' ctx.textBaseline = 'bottom' ctx.font = '12vp sans-serif' ctx.fillText(value.toFixed(0), x + barWidth / 2, y - 6) // 底部标签 ctx.textBaseline = 'top' ctx.fillStyle = '#909399' ctx.fillText(labels[index], x + barWidth / 2, h - padding.bottom + 10) }) }roundRect是API 12之后Canvas里新增的绘制圆角矩形方法,比手动拼Path省事很多。柱状图细节全在间距上:槽宽占空比建议0.5到0.7,太宽显得笨重,太窄显得拥挤。顶端的数值我一般保留0位小数,农业场景里的灌溉量、浇水量用整数就够,不用把小数点到“升”级别,反而增加阅读负担。
4.4 仪表盘:当前环境状态一眼看清
仪表盘适合展示“实时单一指标”,比如当前光照强度、当前土壤湿度是否处于适宜区间。用户不需要对比和趋势,只要一眼看到“现在怎么样”。
private drawGauge( ctx: CanvasRenderingContext2D, value: number, min: number, max: number, label: string, color: string ): void { const w = ctx.width const h = ctx.height const cx = w / 2 const cy = h * 0.78 const radius = Math.min(w / 2 - 24, h * 0.58) const ratio = Math.max(0, Math.min(1, (value - min) / (max - min))) // 底色环 ctx.strokeStyle = '#F0F0F0' ctx.lineWidth = 18 ctx.lineCap = 'round' ctx.beginPath() ctx.arc(cx, cy, radius, Math.PI, Math.PI * 2) ctx.stroke() // 进度环 ctx.strokeStyle = color ctx.beginPath() ctx.arc(cx, cy, radius, Math.PI, Math.PI + Math.PI * ratio) ctx.stroke() // 数值 ctx.fillStyle = '#303133' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.font = '32vp sans-serif' ctx.fillText(value.toFixed(0), cx, cy - 20) ctx.fillStyle = '#909399' ctx.font = '14vp sans-serif' ctx.fillText(label, cx, cy + 22) }我特别把ratio做了Math.max(0, Math.min(1, ...))的夹取。为什么?传感器偶尔会读到超出标称范围的值,比如土壤湿度瞬时报105%,如果不夹取,进度环会画过头,视觉上非常难看。这种“脏数据防御”在绘图代码里尤其重要,远比事后去修数据源头方便。仪表盘的下方我还会放一行小字提示适宜区间,比如“土壤湿度适宜范围 20%~40%”,这样用户看到指针位置的同时就知道偏高了还是偏低了。
4.5 组装页面:三张图同一屏
页面组装时,我用一个Scroll容器把三块内容串起来,上面放“数据总览卡片”,下面依次放折线图、柱状图、仪表盘。为了让刷新时不闪烁,每个图表外面都包一个固定高度容器,用状态变量控制图表数据重绘。
这里有一个关键设计:图表组件内部只负责“拿到数据画图”,不负责“去哪拿数据”。数据通过构造参数传入,这样图表组件可以复用,页面层只管数据逻辑。这也是我在前面十几篇里反复强调的分层思路——整地和育苗分开搞,出了问题才好排查。Chart组件只接收values、labels、colors这些纯展示字段,任何网络请求、聚合逻辑都不放进组件里。
5. 页面联动与数据刷新:让图表“活”起来
5.1 时间粒度切换
图表不能是死的。用户今天在温室里想看当天土壤湿度走势,明天想看一周灌溉对比,所以“按小时、按天、按周”的切换是刚需。我实现了一个简单的分段控制器,三个按钮分别对应日、周、月,点击后页面重新调用聚合函数,更新图表数据,触发布局更新。
@State range: 'hour' | 'day' | 'week' = 'hour' private onRangeChange(newRange: 'hour' | 'day' | 'week'): void { this.range = newRange // 拉取当前时间窗口的原始数据 const raw = getRawRecords(this.deviceId, this.currentWindow) // 按新粒度聚合 this.chartData = aggregateBy(raw, newRange) }这里有个经验:切换粒度之前,先把原始数据一次性拉下来,在前端做聚合,而不是每次切换都请求后端。后端聚合虽然省流量,但交互会有明显延迟。对一个可能在地里、网络不稳定的农业应用来说,本地聚合、离线缓存是更稳的选择。请求接口时我通常只拉最近30天的原始数据,再早的数据通过另一个懒加载接口按需获取。
5.2 自动刷新与下拉刷新
农业数据的实时性要求其实不算高,但也不能让用户手动刷新点一天。我这里做了两层刷新:一层是定时自动刷新,进入页面后启动定时器,每60秒拉一次最新传感器数据,再重绘图表;另一层是下拉刷新,用户有即时需求时,下拉页面强制同步一次云端。
private timerId: number = -1 aboutToAppear(): void { this.loadData() this.timerId = setInterval(() => { this.loadData() }, 60_000) } aboutToDisappear(): void { clearInterval(this.timerId) } private loadData(): void { // 从数据源拉取并更新chartData }注意,clearInterval一定要写在aboutToDisappear里,否则页面退出后定时器还在跑。这个问题我在开发中踩过:应用退出后再进来,发现有两个定时器同时刷新,图表数据反复跳变。页面级的资源清理,一定要养成习惯。这里我再补充一个优化:定时拉取时带上一个请求序列号,如果上一次请求还没返回,就放弃本次请求,避免弱网环境下请求堆积。
下拉刷新的实现,我用Refresh组件包住Scroll,回调里重新请求最新数据,结束后把刷新状态置回false。
Refresh({ refreshing: this.isRefreshing }) { this.pageContent() } .onRefresh(() => { this.loadData() setTimeout(() => { this.isRefreshing = false }, 500) })刷新成功的回调里,我一般会做一个简短的Toast提示“数据已更新”,同时让总览卡片的数字有一个轻微的跳动动画,用户能直观感受到数据在变,不至于以为下拉没反应。
5.3 总览卡片和图表联动
页面上方放了一张“数据总览卡片”,显示当前温度、湿度、光照、土壤湿度的实时值,并用一个小三角或颜色标识与上一小时的变化方向。这个卡片不只是装饰,它是图表的索引——点击卡片上的某一项,下面的折线图立即切换到对应指标。
联动实现不复杂:卡片项绑定点击事件,点击后把selectedMetric状态变量改成对应指标,折线图组件根据这个变量选择绘制数据序列。用HarmonyOS的状态管理,这个联动是自动通知刷新,不需要手动调绘制函数。
@State selectedMetric: 'soilMoisture' | 'temperature' | 'lightIntensity' = 'soilMoisture' // 卡片点击 private onMetricTap(metric: 'soilMoisture' | 'temperature' | 'lightIntensity'): void { this.selectedMetric = metric }这里我建议读者一定用“状态驱动绘制”的思路,不要“手动调绘制”。鸿蒙的@State和@Prop装饰器天生就是做这个的,数据变了组件自动重绘,写起来干净,也不容易出状态不同步的bug。我第一次做联动的时候走了弯路,在点击回调里手动调子组件的绘制方法,结果发现页面滚动后绘制错乱,改成状态驱动后问题消失。
6. 常见问题与排查心得
6.1 Canvas 不显示内容
这个坑理论上应该早就避开,但每次换新设备还是可能遇到。归纳起来无非四类原因,我把排查思路整理成了表格:
| 现象 | 原因 | 排查与解决 |
|---|---|---|
| 画布空白,页面有背景 | 绘制代码没在onReady里执行 | 确认绘制逻辑放在onReady回调内 |
| 画布宽高为0 | Canvas没撑满父容器或父容器尺寸为0 | 给Canvas设置固定高度,父容器要有确定尺寸 |
| 只看到文字看不到线 | strokeStyle不是合法色值,或lineWidth为0 | 检查颜色格式,确认lineWidth大于0 |
| 图形闪烁、部分不显示 | 绘制逻辑里混入了异步任务 | 绘制逻辑保持同步,数据更新走状态变量 |
排查Canvas问题最快的办法,是先在画布中心画一个全屏色块,如果能显示说明基础没问题,再逐行注释代码定位到具体绘制方法。我在开发时用这种二分法定位过一次“折线后半段不显示”的怪问题,最后发现是数据数组里有NaN,绘制循环到那里就中断了。
6.2 折线图在真机上模糊
真机高刷屏、高分屏上,如果不处理像素密度,绘出来的图会发虚。鸿蒙的Canvas坐标默认按vp(虚拟像素)计算,但底层物理像素和vp不是1:1。解决办法是创建RenderingContextSettings时开启抗锯齿,并适当放大绘制细节。
private settings: RenderingContextSettings = new RenderingContextSettings(true)括号里的true就是抗锯齿开关。我在模拟器上从没遇到过模糊问题,一到真机就发现了。做鸿蒙图表,真机调试是必须的,别只看预览器。另外,文字大小建议都用vp单位,不要用固定px,否则不同屏幕密度下字号会忽大忽小。
6.3 定时刷新造成内存泄漏和电量消耗
页面不可见时,定时器仍然工作,白白消耗电量和内存。除了上面说的aboutToDisappear里清理定时器,还建议在页面切入后台时暂停刷新,回到前台再恢复。这里需要用到页面生命周期回调,配合鸿蒙的页面可见性监听。
不过最简单有效的手段,仍然是“进页面开、出页面关”这个铁律。哪怕只是切到下一个Tab,也把定时器停了。农业用户在田里手机电量本来就紧张,不该让一个隐藏页面的后台定时器把电耗光。
6.4 数据为空时页面发白
刚接入设备、还没产生历史数据时,图表区是空的。如果不处理,页面就是一片死白,用户第一反应是应用坏了。我的处理方式是:每个图表组件在数据为空时,显示一个居中提示“暂无可展示数据”,总览卡片显示“--”,而不是0。0和没有数据在农业语义里完全不同,0可能意味着传感器故障,需要明确区分。
这里还有一个实用细节:空态提示要给用户一个“去刷新”的按钮。农业用户到地里看数据,网络可能不稳定,拉取失败或为空时,一个明确的重试入口,比任何提示文案都实在。把空态当一等公民对待,这个意识比图表代码本身更重要。
说实话,数据分析与可视化这个模块,在整个《高高种地》系列里不是技术含量最高的,但它是用户价值最直接的。做了半天的设备接入、数据采集,最后能不能在巴掌大的屏幕上让种地人看懂“今天到底该不该浇水”,全靠这块。折腾这一段,我最大的体会是:千言万语不如一张图,但一张好图的背后,是数据清洗、聚合、坐标系映射这一整套基本功。希望这篇教程能帮你把第一张图顺利画出来,并且画得又快又稳。下一篇我准备写离线缓存与弱网同步,那是农业App在田里能不能站稳的关键,到时候接着聊。