直接开始动手做这个第22课的时候,我本来以为就是照着文档把柱状图改个圆环造型,结果真正把温湿度监测界面拆开做下来才发现,这里面的细节远比想象中多。尤其是数据卡片和环形图这两块,前者牵扯到ArkUI的布局层级和状态管理习惯,后者牵扯到Canvas绘制的底层逻辑和性能取舍,两个点单独拎出来都能写一篇长文。这篇内容我不会照着官方文档念,而是按我实际踩坑的顺序来梳理,尽量把我们做界面时那些“文档里没写但你一定会遇到”的细节讲透。
1. 项目背景与核心需求拆解
1.1 这个界面到底要解决什么问题
很多新手学到这里会有一个误区,觉得温湿度监测界面就是一个“放几个数字+画个圆圈”的静态页面。但真实场景下,这个界面通常出现在智能家居、环境监控、农业大棚这类项目里,它的核心诉求是:用户扫一眼就能知道当前环境是否正常。
所以这个界面真正要解决的,不是“如何画一个好看的圆”,而是三个层层递进的问题:
- 信息可读性:温度、湿度、采集时间、设备状态,这些数据如何在有限的屏幕里一目了然。
- 状态直观性:温度和湿度哪个偏高、哪个偏低、是否在舒适区间,能不能不用看数字就能感知。
- 动态更新能力:数据不是写死的,需要定期刷新,UI能不能平滑跟着变。
也就是说,数据卡片负责回答“现在是多少”,环形图负责回答“在不在合理范围”,刷新机制负责让这两者一直保持有效。第22课放在整个系列的这个位置,其实就是在教你把“数据展示”从静态列表升级成“可视化监控”,这个思路后面的项目基本都会复用。
1.2 技术方案选型:为什么用Canvas自绘而不是现成图表库
我在动手之前先查了一圈生态,现阶段HarmonyOS Next的第三方图表库相对有限,有些还在适配阶段,用起来心里没底。与其在依赖里折腾,不如直接用ArkUI自带的Canvas组件自己画一个环形图。
自己画环形图的好处有三个:
- 包体积可控:不会因为一个环形图引入整个图表库,对于学习项目来说,逻辑更透明。
- 样式完全自定义:渐变、圆角、动画曲线、中心文字排版,想怎么调就怎么调,自由度极高。
- 方便理解绘制原理:环形图的本质就是Canvas上的圆弧组合,搞清楚角度计算方式以后,换成仪表盘、进度环、饼图都是同一个思路。
这套方案的代价是需要自己处理角度计算和动画细节,但考虑到这是学习课,这一点点额外成本恰恰是价值所在。ArkUI的Canvas组件在API 9之后已经比较稳定,我用的DevEco Studio版本是5.0.0,API版本是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,排查很久发现原因是数据模型里默认温度是undefined,parseFloat格式化后变成了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的截图,发现大多数用的是蓝橙组合,温度暖色、湿度冷色,视觉上不容易混,用户也能凭直觉理解。这个配色策略看起来简单,但在信息可视化里是非常经典的“热冷双数据”配色方案,比随机挑两个颜色省心得多。我希望读到这里的你也能顺手记下这个思路,下次设计类似的双数据界面,直接拿来用就行。