鸿蒙开发入门指南:鸿蒙canvas实操——快速掌握自定义图表组件

鸿蒙开发入门指南:鸿蒙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 轴按温度比例计算。minTemperaturemaxTemperature 是为了保证不同温度范围都能正确映射到 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();
      })
  }
}

这个组件做了三件事:

  1. 监听 list 变化,数据变了就重新计算坐标点并重绘
  2. 监听 index 变化,选中项变了就重绘高亮效果
  3. 通过 @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 轴坐标,通过不同的 labelPositioncurveColor 区分。第一列「昨天」还用虚线做了视觉弱化,细节处理得不错。

所有天气卡片都包在 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 统一视觉风格

这套分层不止能做天气曲线,空气质量趋势、股价走势、用电数据监控,换换数据和样式就能复用。

如果你正在鸿蒙上做类似的可视化需求,不妨参考这个思路:把绘图能力抽成独立引擎,上层组件只负责数据映射和交互。这样既解决了图表库缺失的问题,也给自己攒了一套可复用的轮子。

相关推荐
光锥智能3 小时前
华为MateBook 14 鸿蒙版发布,体验全面升维
华为·harmonyos
UnicornDev3 小时前
【HarmonyOS 6】练习记录页面 UI 设计
ui·华为·harmonyos·arkts·鸿蒙
浮芷.4 小时前
生命科学数据视界防御:基于鸿蒙Flutter陀螺仪云台与三维体积光栅的视轴锁定架构
flutter·华为·架构·开源·harmonyos·鸿蒙
浮芷.4 小时前
微观搜打撤:基于鸿蒙flutter的内存快照算法的局内外状态隔离与高阶背包系统设计
算法·flutter·华为·开源·harmonyos·鸿蒙
浮芷.4 小时前
东方修仙模拟器:基于 鸿蒙Flutter 状态机与 CustomPainter 的境界跃升与天劫渲染架构
科技·flutter·华为·架构·开源·harmonyos·鸿蒙
民乐团扒谱机4 小时前
基于ArkTS与端云协同的鸿蒙智慧校园助手——项目报告(AIGC预警⚠️)
华为·aigc·harmonyos
互联网散修4 小时前
鸿蒙实战:运动健康类应用核心组件——语音播报模块设计与实现
华为·harmonyos·tts·语音播报
想你依然心痛4 小时前
HarmonyOS 6智能家居实战:基于悬浮导航与沉浸光感的“光影智家“全屋智能控制系统
华为·智能家居·harmonyos·智能控制·悬浮导航·沉浸光感
雪芽蓝域zzs5 小时前
uni-app x 中使用 UTS 语言实现兼容鸿蒙的加密
华为·uni-app·harmonyos