鸿蒙开发入门指南:鸿蒙canvas实操------快速掌握自定义图表组件
-
- 一、为什么需要自己画图表?
- [二、自定义图表引擎:CanvasController 的核心实现](#二、自定义图表引擎:CanvasController 的核心实现)
-
- [2.1 把数据转换成坐标点](#2.1 把数据转换成坐标点)
- [2.2 贝塞尔曲线平滑连接](#2.2 贝塞尔曲线平滑连接)
- [2.3 绘制点和标签](#2.3 绘制点和标签)
- [三、完成基础天气卡片:列表 + 曲线 + 交互](#三、完成基础天气卡片:列表 + 曲线 + 交互)
- 四、15天预报卡片:双曲线叠加
- 总结
大家好,我是木斯佳。今天聊一个实际的问题:在鸿蒙上想画个折线图,结果发现图表库少得可怜。不像前端有 ECharts、AntV、Highcharts 随便挑,如果有自定义需求,鸿蒙生态在这块基本靠自力更生。
那怎么办?自己画呗。正好我最近研究了一个鸿蒙天气组件的实现,里面有一套非常完整的 Canvas 曲线绘制方案。这篇文章就拆开给大家看看,怎么从零到一封装一个可复用的天气图表组件。

一、为什么需要自己画图表?
先说说背景。目前鸿蒙生态里专门做图表的库确实不多,就算有,也不一定适配你的业务场景。比如天气应用里的温度曲线,需求其实挺明确的:
- 横轴是时间(小时/天)
- 纵轴是温度值
- 需要平滑曲线
- 支持点击显示具体数值
- 多曲线叠加(最高温/最低温)
这种需求,与其找第三方库凑合,不如自己封装一套。而且封装好了,空气质量趋势、股票行情、用电数据都能复用。
在开始写代码之前,先理清楚架构。这个天气组件把图表能力分成了三层:
业务页面(Home)
↓
图表组件(UIHours / UIDays)
↓
绘图引擎(CustomCanvas + CanvasController)
- 业务页面:只管传入数据和布局
- 图表组件:负责组装列表和曲线,处理点击交互
- 绘图引擎:负责所有 Canvas 绘制逻辑(坐标转换、贝塞尔曲线、标签渲染)
这样分层的好处是:绘图引擎写好之后,24小时天气和15天预报都能复用,不用写两遍绘制代码。
二、自定义图表引擎:CanvasController 的核心实现
先看最底层的 CanvasController,它是整个图表能力的核心。
2.1 把数据转换成坐标点

温度数据是数值,画到 Canvas 上需要转成坐标。核心算法如下:
ts
public generateTemperaturePoints(
temperatureArray: number[],
intervalLength: number, // 每个点的间距
canvasHeight: number,
minTemperature: number,
maxTemperature: number,
): PointWithTemp[] {
const points: PointWithTemp[] = [];
const temperatureRange = maxTemperature - minTemperature;
for (let i = 0; i < temperatureArray.length; i++) {
// X 坐标:间隔中点位置
const x = intervalLength / 2 + i * intervalLength;
// Y 坐标:根据温度值映射到 Canvas 高度
const normalizedValue = (temperatureArray[i] - minTemperature) / temperatureRange;
const y = canvasHeight - normalizedValue * canvasHeight;
points.push({ x, y, temp: temperatureArray[i] });
}
return points;
}
逻辑不复杂:X 轴等距分布,Y 轴按温度比例计算。minTemperature 和 maxTemperature 是为了保证不同温度范围都能正确映射到 Canvas 高度上。
2.2 贝塞尔曲线平滑连接
如果直接把点连成折线,效果太生硬。这里用了贝塞尔曲线做平滑处理:
ts
private _drawCurve(ctx: CanvasRenderingContext2D, temps: PointWithTemp[]): void {
// 计算贝塞尔控制点
const bezierControls = this._calcBezierControls(
extendedPoints[i - 1],
extendedPoints[i],
extendedPoints[i + 1],
extendedPoints[i + 2],
);
ctx.bezierCurveTo(
bezierControls.C1.x, bezierControls.C1.y,
bezierControls.C2.x, bezierControls.C2.y,
extendedPoints[i + 1].x, extendedPoints[i + 1].y,
);
}
贝塞尔曲线的核心思想是:用前后两个点的位置来计算当前段的控制点,让曲线经过每个数据点,同时保持平滑。具体算法这里不展开,感兴趣的可以去看 _calcBezierControls 的实现。
2.3 绘制点和标签

曲线画完之后,还要在每个数据点上画圆点,并标出温度值:
ts
private _drawPointsAndLabels(ctx: CanvasRenderingContext2D, temps: PointWithTemp[]): void {
for (const point of temps) {
// 画圆点
ctx.beginPath();
ctx.arc(point.x, point.y, this._holeRadius, 0, Math.PI * 2);
ctx.fill();
// 画标签
if (this._labelPosition !== 'none') {
const labelText = Math.round(point.temp) + '°';
const labelY = this._labelPosition === 'top'
? point.y - 20
: point.y + 20;
ctx.fillText(labelText, point.x, labelY);
}
}
}
labelPosition 参数很实用:最高温曲线标签在上方,最低温在下方,避免重叠。
三、完成基础天气卡片:列表 + 曲线 + 交互
CanvasController 只管绘图逻辑,那谁来触发绘制?答案是 CustomCanvas 组件:
ts
@ComponentV2
export struct CustomCanvas {
@Require @Param controller: CanvasController;
@Require @Param list: number[];
@Param index: number = 0;
private context: CanvasRenderingContext2D | null = null;
@Monitor('list')
drawAgainWhenListChange() {
this.points = this.controller.generateTemperaturePoints(
this.list, this.itemWidth, this.canvasHeight, this.min, this.max
);
this.controller.draw(this.context!, this.points, this.index);
}
@Monitor('index')
drawAgainWhenIndexChange() {
this.controller.draw(this.context!, this.points, this.index);
}
build() {
Canvas(this.context)
.width('100%')
.height(this.canvasHeight)
.onReady(() => {
this.context = this.context;
this.drawAgainWhenListChange();
})
}
}
这个组件做了三件事:
- 监听
list变化,数据变了就重新计算坐标点并重绘 - 监听
index变化,选中项变了就重绘高亮效果 - 通过
@Monitor装饰器自动响应变化,不用手动调用
前端视角 :这有点像 React 的 useEffect,依赖变了就重新执行。
有了底层绘图能力,上层组件就好写了。UIHours 的布局很有意思:
ts
Scroll() {
Stack() {
// 1. 温度曲线(底层)
CustomCanvas({
controller: this.controller,
list: this.temps,
max: this.maxTemp,
min: this.minTemp,
index: this.curIndex,
})
// 2. 列表覆盖层(上层)
List() {
ForEach(this.weathers, (item, index) => {
this.itemBuilder(index)
})
}
// 3. 选中提示气泡
if (this.showText) {
Text(this.showText).position({
left: this.curIndex * this.itemWidth,
top: 曲线位置计算,
})
}
}
}
注意这里的层级:曲线在最底下,列表在上层透明覆盖。用户点击列表的某一项时,更新 curIndex,然后:
- 曲线重新绘制,高亮对应的数据点
- 气泡移动到对应位置
- 列表项本身也高亮
这种「数据驱动 + 分层绘制」的方式,比把所有逻辑塞在一起清晰得多。
四、15天预报卡片:双曲线叠加

15天预报比24小时复杂一点,因为要同时显示最高温和最低温两条曲线。
实现方式很简单:叠两个 CustomCanvas:
ts
Stack() {
// 最高温曲线(标签在上方)
CustomCanvas({
controller: this.maxController,
list: this.maxTemps,
labelPosition: 'top',
curveColor: '#FBB460'
})
// 最低温曲线(标签在下方)
CustomCanvas({
controller: this.minController,
list: this.minTemps,
labelPosition: 'bottom',
curveColor: '#3F7EF7'
})
// 列表覆盖层
List() { ... }
}
两个 Canvas 共用同一套 X 轴坐标,通过不同的 labelPosition 和 curveColor 区分。第一列「昨天」还用虚线做了视觉弱化,细节处理得不错。
所有天气卡片都包在 CardContainer 里:
ts
@ComponentV2
export struct CardContainer {
@Param title: string = '';
@BuilderParam content: () => void;
build() {
Column() {
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.height(56)
.margin({ left: 16 })
if (this.content) {
this.content()
}
}
.width('100%')
.backgroundColor($r('sys.color.comp_background_primary'))
}
}
使用的时候:
ts
CardContainer({ title: '24小时天气预报' }) {
UIHours({ weathers: this.vm.hourlyWeathers })
}
这个小封装虽然简单,但保证了所有卡片标题样式一致,后续加新卡片也只需套一层。
数据变化时,图表要自动重绘。这个组件通过 HomeVM 来管理状态:
ts
@ObservedV2
export class HomeVM {
@Trace hourlyWeathers: HourlyWeather[] = [];
@Trace dailyWeathers: DailyWeather[] = [];
@Monitor('location')
private _change() {
WeatherUtils.getHourlyWeathers(this.location.code)
.then(res => this.hourlyWeathers = res);
WeatherUtils.getDailyWeathers(this.location.code)
.then(res => this.dailyWeathers = res);
}
}
@Monitor 装饰器监听 location 变化,位置变了就重新拉数据。数据更新后,@Trace 会自动触发 UI 刷新,CustomCanvas 里的 @Monitor('list') 又会自动重绘曲线。
整个链路是:位置变化 → 数据拉取 → 列表更新 → 曲线重绘 ,全程自动化。
实际开发中需要注意:
1、CanvasController 配置不同的颜色、标签位置、虚线样式,就能适配 24 小时和 15 天两种场景,不用写两遍。
2、选中项、数据列表、曲线重绘全部通过状态变化触发,不需要手动调用绘制方法。
3、鸿蒙的 @Monitor 装饰器非常实用------依赖变了就自动执行逻辑,省去手动调用的麻烦。
总结
这个天气组件的实现给了我们一个很好的范本:
CanvasController封装绘图算法(坐标转换、贝塞尔曲线、标签渲染)CustomCanvas桥接数据和 Canvas 生命周期UIHours/UIDays组装列表和曲线,处理交互CardContainer统一视觉风格
这套分层不止能做天气曲线,空气质量趋势、股价走势、用电数据监控,换换数据和样式就能复用。
如果你正在鸿蒙上做类似的可视化需求,不妨参考这个思路:把绘图能力抽成独立引擎,上层组件只负责数据映射和交互。这样既解决了图表库缺失的问题,也给自己攒了一套可复用的轮子。
