ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

HarmonyOS实战:基于ArkUI Canvas的温湿度监测环形图开发

HarmonyOS实战:基于ArkUI Canvas的温湿度监测环形图开发 直接开始动手做这个第22课的时候我本来以为就是照着文档把柱状图改个圆环造型结果真正把温湿度监测界面拆开做下来才发现这里面的细节远比想象中多。尤其是数据卡片和环形图这两块前者牵扯到ArkUI的布局层级和状态管理习惯后者牵扯到Canvas绘制的底层逻辑和性能取舍两个点单独拎出来都能写一篇长文。这篇内容我不会照着官方文档念而是按我实际踩坑的顺序来梳理尽量把我们做界面时那些“文档里没写但你一定会遇到”的细节讲透。1. 项目背景与核心需求拆解1.1 这个界面到底要解决什么问题很多新手学到这里会有一个误区觉得温湿度监测界面就是一个“放几个数字画个圆圈”的静态页面。但真实场景下这个界面通常出现在智能家居、环境监控、农业大棚这类项目里它的核心诉求是用户扫一眼就能知道当前环境是否正常。所以这个界面真正要解决的不是“如何画一个好看的圆”而是三个层层递进的问题信息可读性温度、湿度、采集时间、设备状态这些数据如何在有限的屏幕里一目了然。状态直观性温度和湿度哪个偏高、哪个偏低、是否在舒适区间能不能不用看数字就能感知。动态更新能力数据不是写死的需要定期刷新UI能不能平滑跟着变。也就是说数据卡片负责回答“现在是多少”环形图负责回答“在不在合理范围”刷新机制负责让这两者一直保持有效。第22课放在整个系列的这个位置其实就是在教你把“数据展示”从静态列表升级成“可视化监控”这个思路后面的项目基本都会复用。1.2 技术方案选型为什么用Canvas自绘而不是现成图表库我在动手之前先查了一圈生态现阶段HarmonyOS Next的第三方图表库相对有限有些还在适配阶段用起来心里没底。与其在依赖里折腾不如直接用ArkUI自带的Canvas组件自己画一个环形图。自己画环形图的好处有三个包体积可控不会因为一个环形图引入整个图表库对于学习项目来说逻辑更透明。样式完全自定义渐变、圆角、动画曲线、中心文字排版想怎么调就怎么调自由度极高。方便理解绘制原理环形图的本质就是Canvas上的圆弧组合搞清楚角度计算方式以后换成仪表盘、进度环、饼图都是同一个思路。这套方案的代价是需要自己处理角度计算和动画细节但考虑到这是学习课这一点点额外成本恰恰是价值所在。ArkUI的Canvas组件在API 9之后已经比较稳定我用的DevEco Studio版本是5.0.0API版本是12整个流程做下来没有任何兼容性障碍。2. 界面布局与数据卡片设计2.1 整体页面结构规划先想清楚页面长什么样再动手写代码能少走很多弯路。我设计的页面结构是上下两段式顶部标题栏和当前时间让用户明确知道“这是什么时候的数据”。中部环形图区域左右两侧各放置一个圆形小徽标分别用颜色代表温度和湿度。底部两张数据卡片一张显示温度详情一张显示湿度详情卡片内包含数值、单位、变化趋势和状态提示。这种上下结构的优点是很直观环形图做视觉焦点数据卡片做信息补充主次分明。用户第一眼看到颜色和圆环的饱满程度能立刻判断大致状态需要精确数值时再看下半部分的卡片。页面整体用Column布局再用Scroll包起来防止小屏设备出现内容溢出。等等实际测试下来发现一屏能放下所有内容所以我最后没用Scroll但保留了外层弹性布局让内容在竖屏状态下水平居中、上下分布合理。2.2 数据卡片的组件拆解数据卡片是ArkUI里很典型的组合式UI我把它拆成了几个层级来设计外层容器使用Card组件设置圆角16背景色根据页面主题走深色或浅色关键是设置阴影elevation让卡片有悬浮感。卡片头部左侧放一个圆点图标表示数据类型温度用橙色湿度用蓝色右侧放类型名称和“正常/偏高/偏低”的状态标签。卡片主体大号数字显示当前值单位放在数字的右上角字重和字号要拉开层次比如数字用32fp单位用14fp这样扫一眼就能读出数据。卡片底部一行小字显示采集时间以及上次数据对比上升/下降箭头这些细节非常提升质感。这里有一个重要的ArkUI组件细节Card组件在API 12里可以直接设置圆角和背景色但如果你在Card内部设置了子组件的backgroundColor会出现圆角被覆盖的问题。我当时也踩了这个坑解决办法是给Card设置clip(true)让子组件的内容不会突破圆角边界。代码骨架大概是这样Component export struct EnvCard { Prop title: string Prop value: string Prop unit: string Prop status: string Prop statusColor: string Prop iconBg: string build() { Card() { Row() { // 左侧类型图标 Circle({ width: 10, height: 10 }) .fill(this.iconBg) Column() { Text(this.title) .fontSize(14) .fontWeight(FontWeight.Medium) Text(this.status) .fontSize(12) .fontColor(this.statusColor) } .alignItems(HorizontalAlign.Start) .margin({ left: 8 }) Blank() // 右侧数值 Row() { Text(this.value) .fontSize(32) .fontWeight(FontWeight.Bold) Text(this.unit) .fontSize(14) .margin({ left: 4, bottom: 8 }) } } .width(100%) .padding(18) } .width(90%) .height(100) .borderRadius(16) .elevation(2) .clip(true) .margin({ bottom: 12 }) } }2.3 页面状态管理与数据模型在搞清楚了视觉效果之后我们要开始认真设计这个页面的数据模型。这个界面虽然看起来简单但涉及到实时数据刷新、图表重绘等多个功能模块如果没有一个清晰的数据模型后续开发过程会变得一团糟。考虑到当前的项目规模和使用场景我决定采用一个相对轻量但层次分明的数据管理方案。这次我把UI拆成了4个子组件每个子组件关注的逻辑都比较单一这对于日常维护来说非常方便。在数据模型的基础上我还定义了几个核心的状态变量来控制页面的显示逻辑。其中环境数据对象负责存储所有温度湿度相关的数据而是否正在加载、当前温度对应的颜色以及当前湿度对应的颜色则分别控制着不同UI元素的动态表现。这里我选择用颜色直接映射状态是为了让环形图和数据卡片的状态展示保持完全一致避免出现视觉上的割裂感。interface EnvData { temperature: number humidity: number time: string } State envData: EnvData { temperature: 26.5, humidity: 56, time: 09:45:20 } State isLoading: boolean true State tempColor: string #FF9500 State humiColor: string #00A8FF这里顺便说一下我的一个心得状态变量不要铺得太满能通过计算得到的值就不要额外存一份。比如温度和湿度对应的颜色完全可以通过方法动态计算返回不需要每个值都开一个State。我之所以把这两个颜色单独存成状态是因为后续环形图和数据卡片都要引用它们为了保持同步把它们提升到页面级别更省事。数据更新的核心逻辑在下一大节会展开这里先埋一个伏笔——所有刷新动作本质上都是修改envData这一个状态对象ArkUI的响应式系统会自动通知依赖它的子组件重新渲染这也是声明式UI框架方便的地方。3. 环形图核心绘制逻辑3.1 Canvas绘制原理与角度计算环形图本质上就是在Canvas上画两个同心圆外圆用浅灰色打底再在外圆上叠一个从某个角度开始的彩色圆弧这个圆弧的长度比例就代表当前数据占满量程的比例。但这里有个很容易搞混的基本知识点Canvas的坐标系统和数学坐标系不一样。数学里0度在正右方角度逆时针增加但Canvas里的弧度是顺时针计算的0度在3点钟方向Math.PI在9点钟方向Math.PI * 1.5在12点钟方向。如果我们要让圆环从12点钟方向开始画第一笔那么起始角度就要设置成-Math.PI / 2也就是-90度。假设温度的量程是-20 ~ 50当前温度是26.5度那么实际占比就是ratio (26.5 - (-20)) / (50 - (-20)) 46.5 / 70 ≈ 0.664这个比例再换算成弧长就是总共2π弧度乘以比例sweepAngle 2 * Math.PI * 0.664 ≈ 4.17 rad然后绘制的时候以-Math.PI / 2为起点扫描4.17弧度就到了当前位置。这个计算逻辑是所有圆环、仪表盘、扇形图的基础建议大家一定要亲手推导一遍不要直接抄公式。我最初直接照搬网上Android自定义View里的写法把起始角设成0结果圆环始终从右侧3点钟开始画跟设计稿完全不匹配就是这个原因。3.2 环形图的完整绘制代码与参数说明在HarmonyOS Next里使用Canvas有两种方式一种是声明式的Canvas(this.context)配合onReady拿到绘制上下文CanvasRenderingContext2D另一种是更简单的Canvas 上下文对象。我用的是前者因为后续需要做动画上下文对象可以随时调用绘制API。首先在组件里声明一个上下文对象private settings: RenderingContextSettings new RenderingContextSettings(true) private context: CanvasRenderingContext2D new CanvasRenderingContext2D(this.settings)然后在build()里放Canvas组件Canvas(this.context) .width(240) .height(240) .onReady(() { this.drawGauge() })核心绘制函数如下我加了详细注释private drawGauge(): void { const ctx this.context const width 240 const height 240 const centerX width / 2 const centerY height / 2 const radius 100 const lineWidth 18 // 清空画布 ctx.clearRect(0, 0, width, height) // 1. 绘制背景圆环底层浅灰色 ctx.beginPath() ctx.strokeStyle #2A2A2E ctx.lineWidth lineWidth ctx.lineCap round ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) ctx.stroke() // 2. 计算温度比例 const minTemp -20 const maxTemp 50 const ratio (this.envData.temperature - minTemp) / (maxTemp - minTemp) const startAngle -Math.PI / 2 const sweepAngle ratio * Math.PI * 2 // 3. 绘制进度圆弧 ctx.beginPath() ctx.strokeStyle this.tempColor ctx.lineWidth lineWidth ctx.lineCap round ctx.arc(centerX, centerY, radius, startAngle, startAngle sweepAngle) ctx.stroke() // 4. 中心文字温度数值 ctx.beginPath() ctx.fillStyle #E5E5EA ctx.font bold 32vp sans-serif ctx.textAlign center ctx.textBaseline middle ctx.fillText(this.envData.temperature.toFixed(1) °C, centerX, centerY - 10) // 5. 中心辅助文字 ctx.beginPath() ctx.fillStyle #8E8E93 ctx.font 14vp sans-serif ctx.fillText(环境温度, centerX, centerY 25) }这里有几个参数值得单独拿出来说lineWidth 18这个值决定了圆环的粗细。太细的话在手机屏幕上看起来会显得没分量太粗的话中间留给文字的空间会被压缩。18vp配合100半径是我测试下来比较舒适的视觉比例。lineCap round设置成圆头圆环的端点会有一个半圆收尾视觉上更柔和。但这个也有个小问题就是当进度为0%时圆头会让起始点看起来像一个小圆点如果不想这样可以改成butt。startAngle -Math.PI/2这是为了保证起点在12点钟方向而不是3点钟方向更符合仪表类控件的视觉习惯。3.3 动画效果与刷新优化静态的环形图能展示数据但缺少一个关键体验——变化感。如果数据从20度跳到30度画布直接重画用户是一点感知都没有的会觉得“图表没更新”。所以我在绘制逻辑外面套了一个简单的动画过程。思路是利用requestAnimationFrame做逐帧插值private animProgress: number 0 private targetProgress: number 0 private animateTo(target: number, duration: number 600): void { this.targetProgress target const start this.animProgress const diff target - start const animate (timestamp: number) { const progress Math.min((timestamp - (this.animStart || timestamp)) / duration, 1) this.animProgress start diff * this.easeOutCubic(progress) this.drawGauge() if (progress 1) { this.animStart timestamp requestAnimationFrame(animate) } else { this.animProgress target } } this.animStart 0 requestAnimationFrame(animate) } private easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3) }如果暂时不想做动画也可以在一次刷新中直接把this.animProgress赋值为新比例然后调用this.drawGauge()重绘。但有了动画之后环形图看起来会高级不少所以这个几百行代码的“额外功”还是值得的。这里有一个性能上需要注意的点Canvas重绘不要放在build()里做。因为build()会被状态变量反复触发如果每次触发都去画图会造成无意义的性能损耗。正确做法是在状态变量的Watch回调中手动触发重绘这样数据变化时只在需要的时机去绘制更新。typescript State Watch(onDataChanged) envData: EnvData ... onDataChanged(propName: string): void { this.updateGauge() } 注意使用Watch时如果多个属性同时变化回调可能会触发多次。为了减少不必要的绘制可以在回调里加一个setTimeout之类的去抖或者简单粗暴地判断一下当前值和上次绘制的值是否一致。4. 数据刷新与生命周期管理4.1 温湿度数据的采集与模拟真实的温湿度数据肯定是从传感器模块获取的比如IoT设备通过蓝牙、WiFi把传感器数据传给手机App再解析、展示。但在第22课这个阶段我们还没有联调硬件所以大多数情况下会用模拟数据来驱动界面。模拟数据要注意一点不能用完全随机的数那样图表看起来太“跳”真实传感器是有惯性的。我给温度设定了一个基准值每次刷新在基准值附近微调例如控制波动范围在正负0.5度之间private simulateSensorData(): EnvData { const baseTemp 26 (Math.sin(Date.now() / 10000) * 2) const baseHumidity 52 (Math.cos(Date.now() / 15000) * 3) return { temperature: parseFloat(baseTemp.toFixed(1)), humidity: parseFloat(baseHumidity.toFixed(1)), time: this.getCurrentTime() } } private getCurrentTime(): string { const now new Date() const h now.getHours().toString().padStart(2, 0) const m now.getMinutes().toString().padStart(2, 0) const s now.getSeconds().toString().padStart(2, 0) return ${h}:${m}:${s} }加上正弦波动后温度不会来回乱跳看起来就像真实传感器在缓慢变化UI演示效果好很多。4.2 定时刷新与UI更新数据刷新我用setInterval写在aboutToAppear生命周期里aboutToAppear(): void { this.timer setInterval(() { this.envData this.simulateSensorData() }, 3000) }每隔3秒更新一次数据这个频率对温湿度监测场景来说是比较合理的。太快了没必要太慢了用户会感觉“卡”。更新时直接重新赋值this.envData这个对象是个整体ArkUI能识别到引用变化从而触发依赖它的子组件刷新。这里有个关于状态对象更新的小细节值得展开。如果是修改this.envData.temperature ...这种嵌套属性ArkUI在双向绑定的场景下能响应但在一部分属性观察的场景下可能不会触发UI更新。稳妥起见我在更新时永远创建一个新对象this.envData { ...this.envData, temperature: newTemp, humidity: newHumidity, time: newTime }展开运算符创建的新对象引用地址发生了变化能确保所有的观察者都被通知到。这个习惯在后面对接真实蓝牙数据时尤其重要因为蓝牙回调过来的数据往往是一个一个字段地拼装如果不注意整体赋值界面很容易“卡住不刷新”。4.3 生命周期与资源清理定时器如果不清理页面在退出后仍然在后台执行轻则浪费资源重则导致服务泄漏。这个必须在aboutToDisappear里清掉aboutToDisappear(): void { if (this.timer) { clearInterval(this.timer) this.timer null } }这是所有定时任务的标准套路。还有requestAnimationFrame如果动画还在进行时页面退出了也要取消掉。不过在我的实际体验里清掉定时器后没有手动取消动画帧其实影响不大因为页面销毁时对应的Canvas上下文也会一起销毁再调用绘制方法会直接报错。为了避免这种报错我通常会在生命周期回调里加一个状态标志private isActive: boolean true aboutToDisappear(): void { this.isActive false ... } // 在动画帧回调开头 if (!this.isActive) return这个标志位相当于一个“安全开关”能有效防止页面销毁后UI还在尝试更新导致的崩溃属于经验型技巧建议养成习惯。5. 常见问题与排查技巧实录5.1 真机与模拟器的采坑记录我刚开始是在模拟器里跑的一切正常数据卡片和环形图的动画都流畅。切换真机测试后发现两个问题第一个是字体大小不一样。模拟器里32fp看起来刚好真机上却显得偏小。这是因为不同设备的dpi不同同样的vp在不同屏幕上实际渲染的物理尺寸会有差异。解决方法是不要只依赖字号多配合layoutWeight、constraintSize这些弹性布局手段。第二个是Canvas在真机上偶发第一次不显示。排查下来发现问题出在onReady回调触发时Canvas的宽度高度还没有测量完毕导致绘制的坐标超出了有效范围。解决方案是给Canvas设置固定的width和height或者用onAreaChange拿到实际尺寸后再触发绘制。我在代码里直接用固定240x240简单粗暴但稳。第三个问题比较隐蔽状态变量更新后Canvas没有自动重绘。因为Canvas本身不是一个“纯展示控件”它的内容是通过context画上去的状态变量变化只会触发build()里的组件树更新但不会自动去执行drawGauge()。很多新手卡在这里半天找不到原因。记住Canvas内容变了必须手动调用重绘方法这是Canvas体系跟普通Text、Image组件最大的不同。5.2 绘制不刷新、参数归零等常见问题速查整理一下我做这个项目时遇到的典型问题按出现频率排序问题现象根本原因解决办法环形图不更新状态值变了但没调用绘制函数在数据变化的Watch回调里手动drawGauge()圆环起点在右边而不是顶部起始角没有偏移起始角设为-Math.PI / 2圆环画满一圈后模糊有锯齿lineCap用了默认值且没有开启抗锯齿RenderingContextSettings(true)开启抗锯齿中心文字显示位置偏移textBaseline没有设置显式设置textBaseline middle定时器页面退出后仍然运行没有在aboutToDisappear中清理清理定时器并置空卡片圆角被内部背景色覆盖内部子组件背景色顶开了圆角外层设置clip(true)数据更新太快图闪烁没有做去抖或间隔过短刷新间隔拉到2~3秒或加渐变动画真机上Canvas首帧空白onReady时机绘制时尺寸未就绪固定Canvas宽高或延迟一帧绘制这里顺便提一嘴“参数归零”这件事。我在调试时发现温度比例算出来是NaN排查很久发现原因是数据模型里默认温度是undefinedparseFloat格式化后变成了NaN导致sweepAngle计算失败。解决办法是给数据模型设置一个安全默认值和解析函数里增加isNaN判断private safeNumber(value: number, fallback: number): number { return isNaN(value) ? fallback : value }这种防御性写法在真实开发中很常用因为你永远不知道从硬件回调过来的数据会不会调皮地变成undefined。5.3 与状态管理联动时的注意点最后再说一个跟状态管理有关的坑。因为ArkUI的State是单次观察在对象整体赋值时没有问题但如果你用了Observed和ObjectLink装饰的类模型对类内部的属性做修改时需要确保属性本身被Observed监听了。我在第一次实现时把EnvData定义成了一个class并加了Observed然后在子组件里用ObjectLink接收结果发现温度变化后卡片文字更新了但环形图不更新。原因就是环形图的进度是通过方法计算的而方法依赖的属性没有被子组件观察。解决办法有两个要么把计算逻辑提升到父组件里算好了直接把比例传下去要么在子组件里也监听对应的属性变化再手动调绘制。在实际项目里我更推荐前者也就是“子组件尽量做纯展示能传入的值就不要自己算”。这样不仅好排查问题也方便复用。6. 实操总结与可复用的经验心得这个第22课做下来我最大的收获不是会画环形图了而是建立了一套“从数据到可视化”的完整思维链路先想清楚数据怎么来、怎么存、怎么更新再去规划界面怎么展示、怎么反馈。环形图只是这条链路的一个出口而已数据卡片、状态颜色、动画反馈全都服务于同一份数据这也是为什么我强烈建议你在动手前先把数据模型设计好。另外有一个小技巧做完环形图之后剩下的画半圆仪表盘、多段进度环核心只是把角度计算稍微改一下整个drawGauge()方法复制过去改几个参数就能用。别把视觉组件想得太玄乎本质都是角度加弧长的问题。后面如果要继续扩展可以考虑这几个方向接入真实的温湿度传感器数据、增加历史曲线图、数据异常时加震动或声音提醒、把界面搬上服务卡片。到那时候你会发现第22课打好的数据模型和绘制基础基本都能直接复用这也是我总觉得学习鸿蒙做小项目特别有积累感的原因——每一课的东西最后都能拼成一个大项目。最后分享一个小细节。做这个界面时我苦于不知道选什么颜色搭配后来干脆直接去看了智能家居App的截图发现大多数用的是蓝橙组合温度暖色、湿度冷色视觉上不容易混用户也能凭直觉理解。这个配色策略看起来简单但在信息可视化里是非常经典的“热冷双数据”配色方案比随机挑两个颜色省心得多。我希望读到这里的你也能顺手记下这个思路下次设计类似的双数据界面直接拿来用就行。
返回列表