为技术教程文章《HarmonyOS7 用 Canvas 画柱状图并不难:CanvasBarChart

前言

为技术教程文章绘制一张 sketch-notes 风格的流程图,主题是“CanvasBarChart

一提到数据可视化,很多人第一反应就是找图表库。这个思路没问题,但有些场景下,你未必需要一整套库。比如只是做一个轻量周报图、设备状态面板、运营数据卡片,自己用 Canvas 画一个简单柱状图,反而更灵活,依赖也更轻。

问题是,图表跟普通图形不一样。它不是“画几个柱子”就完了,还涉及坐标区域、留白、标签、刻度、数值位置。这也是为什么不少人一开始觉得图表很难。

为技术文章绘制一张 sketch-notes 风格的框架图,主题是“Canvas 柱状图的布局与数据

这个案例正好给了一个很好的起点:手写一张最基础的柱状图。

这页案例的重点是什么

它不是要做一个专业图表库,而是把最基础的数据图表达清楚。里面已经包含了柱状图最关键的几块结构:

  • 数据数组和标签数组
  • 画布内边距
  • Y 轴和底部基线
  • 网格线与刻度文字
  • 柱子本身
  • 顶部数值和底部标签

你把这些关系弄明白,以后换成折线图、面积图、进度图,思路都能迁移。

完整代码

import { DEMO_BG_COLOR, DEMO_CARD_COLOR, DEMO_THEME_COLOR, DEMO_SUBTEXT_COLOR } from './types'

interface PaddingConfig {
  top: number
  bottom: number
  left: number
  right: number
}

@Entry
@Component
struct CanvasBarChartBasics {
  @State isShow: boolean = true
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private data: number[] = [65, 80, 45, 90, 55, 75, 60]
  private labels: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']

  build() {
    Column() {
      if (this.isShow) {
        Scroll() {
          Column({ space: 16 }) {
            Text('Canvas 柱状图').fontSize(16).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)

            Column() {
              Text('每周数据统计').fontSize(13).fontColor('#4D96FF').margin({ bottom: 8 })
              Column() {
                Canvas(this.context)
                  .onReady(() => {
                    const w = 380
                    const h = 220
                    const padding: PaddingConfig = { top: 20, bottom: 40, left: 40, right: 20 }
                    const chartW = w - padding.left - padding.right
                    const chartH = h - padding.top - padding.bottom
                    const barW = chartW / this.data.length * 0.6
                    const gap = chartW / this.data.length * 0.4

                    this.context.clearRect(0, 0, w, h)

                    this.context.strokeStyle = '#E0E0E0'
                    this.context.lineWidth = 1
                    this.context.beginPath()
                    this.context.moveTo(padding.left, padding.top)
                    this.context.lineTo(padding.left, padding.top + chartH)
                    this.context.lineTo(w - padding.right, padding.top + chartH)
                    this.context.stroke()

                    for (let i = 0; i <= 4; i++) {
                      const y = padding.top + chartH * (1 - i / 4)
                      this.context.strokeStyle = '#F0F0F0'
                      this.context.beginPath()
                      this.context.moveTo(padding.left, y)
                      this.context.lineTo(w - padding.right, y)
                      this.context.stroke()

                      this.context.font = '10px sans-serif'
                      this.context.fillStyle = '#AAAAAA'
                      this.context.fillText('' + 25 * i, padding.left - 30, y + 3)
                    }

                    const colors = ['#4D96FF', '#6BCB77', '#FFD93D', '#FF6B6B', '#9B59B6', '#FFA500', '#4ECDC4']
                    for (let i = 0; i < this.data.length; i++) {
                      const x = padding.left + (chartW / this.data.length) * i + gap / 2
                      const barH = (this.data[i] / 100) * chartH
                      const y = padding.top + chartH - barH

                      this.context.fillStyle = colors[i]
                      this.context.fillRect(x, y, barW, barH)

                      this.context.font = '10px sans-serif'
                      this.context.fillStyle = '#666666'
                      this.context.textAlign = 'center'
                      this.context.fillText('' + this.data[i], x + barW / 2, y - 5)
                      this.context.fillText(this.labels[i], x + barW / 2, padding.top + chartH + 18)
                    }
                  })
                  .width('100%')
                  .height(240)
                  .backgroundColor('#FAFAFA')
                  .borderRadius(8)
              }
            }
            .backgroundColor(DEMO_CARD_COLOR)
            .padding(12)
            .borderRadius(12)

            Column({ space: 6 }) {
              Text('数据详情').fontSize(14).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)
              Row() {
                ForEach(this.labels, (label: string, _: number) => {
                  Text(label).fontSize(11).fontColor('#666666').layoutWeight(1).textAlign(TextAlign.Center)
                })
              }.width('100%')
              Row() {
                ForEach(this.data, (val: number, _: number) => {
                  Text('' + val).fontSize(13).fontColor('#1A1A1A').fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)
                })
              }.width('100%')
            }
            .width('100%')
            .backgroundColor('#E8F4FD')
            .padding(14)
            .borderRadius(12)
          }
          .width('100%')
        }
      }

      Text('CanvasBarChartBasics - 绘制每周柱状图')
        .fontSize(12)
        .fontColor(DEMO_SUBTEXT_COLOR)
        .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(DEMO_BG_COLOR)
    .padding(16)
  }
}

datalabels 是图表的数据骨架

案例先定义了两组数组:

private data: number[] = [65, 80, 45, 90, 55, 75, 60]
private labels: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']

这就是图表最基本的输入。

data 决定柱子的高度,labels 决定底部每根柱子对应的说明。很多人一开始就急着画,其实真正应该先想清楚的是:数据结构长什么样。

如果输入结构乱,后面坐标、标签、颜色都会跟着乱。

为什么一定要先留 padding

这句是全篇里最容易被低估的代码:

const padding: PaddingConfig = { top: 20, bottom: 40, left: 40, right: 20 }

图表不是填满整块画布就完事。你必须给坐标轴、刻度、标签、顶部数值留空间。

所以这里把画布总尺寸 wh 再减去内边距,得到真正的图表可用区域:

const chartW = w - padding.left - padding.right
const chartH = h - padding.top - padding.bottom

这个思路特别重要。以后你做任何可视化,几乎都要先从“总空间”和“绘图区”拆开。

坐标轴和基线怎么画

案例用路径画了左侧轴线和底部基线:

this.context.beginPath()
this.context.moveTo(padding.left, padding.top)
this.context.lineTo(padding.left, padding.top + chartH)
this.context.lineTo(w - padding.right, padding.top + chartH)
this.context.stroke()

这里其实把图表的边界感一下子立起来了。即使你还没画柱子,用户也能感觉到“这是个数据区域”。

很多轻量图表不一定要完整包围框,但底线和一条纵向参考轴通常很有必要。

网格线为什么很关键

案例里用循环画了五条水平网格线:

for (let i = 0; i <= 4; i++) {
  const y = padding.top + chartH * (1 - i / 4)
  this.context.moveTo(padding.left, y)
  this.context.lineTo(w - padding.right, y)
}

它的作用不是装饰,而是帮助读数。

没有网格线时,用户只能看出哪根柱子高、哪根柱子低;有了刻度线和左侧数值,他才更容易估出这是 50、75 还是接近 100。

数据图的本质不是“好看”,而是“能读”。

柱宽和间距怎么分配

案例把每一列的总空间拆成两部分:

const barW = chartW / this.data.length * 0.6
const gap = chartW / this.data.length * 0.4

这是一种非常实用的比例分配法。每组位置 60% 给柱子,40% 给空隙。这样柱子不会挤在一起,也不会显得太散。

这类写法的好处是,数据项数量变了,整体仍然能保持相对平衡。

柱子的高度怎么从数据映射出来

真正的数据映射就在这两句:

const barH = (this.data[i] / 100) * chartH
const y = padding.top + chartH - barH

第一句把数据值映射成柱高。

第二句决定柱子从哪里开始画。因为 Canvasy 轴是向下增长的,所以柱子要想“从底部往上长”,就必须先算出顶部坐标。

这一步是很多人最容易算反的地方。

顶部数值和底部标签为什么值得保留

案例里每根柱子都画了两个文字:

this.context.fillText('' + this.data[i], x + barW / 2, y - 5)
this.context.fillText(this.labels[i], x + barW / 2, padding.top + chartH + 18)

这两个信息点其实很关键。

顶部数值让用户不必用眼睛估计;底部标签让用户知道这一列到底代表哪一天。它们一上,图表就从“可视块”变成了“可阅读的数据表达”。

颜色数组为什么也值得单独设计

案例给每根柱子配了不同颜色:

const colors = ['#4D96FF', '#6BCB77', '#FFD93D', '#FF6B6B', '#9B59B6', '#FFA500', '#4ECDC4']

这适合演示效果,但真实业务里要看场景。

如果强调每一天是独立分类,多色可以;如果强调同一维度的连续数据,统一色系往往更专业。比如一组销售额柱子通常不必七种颜色全上。

这个案例怎么改成业务图表

稍微改一下,这页就能变成很多业务组件。

比如:

  • labels 换成设备编号,做设备监测面板
  • labels 换成月份,做月度趋势卡片
  • data 接成接口返回值,做真实报表模块
  • 把颜色策略改成阈值色,做异常预警图

也就是说,这个案例已经不是玩具了,它其实是个可复用的最小图表骨架。

常见坑提醒

第一个坑,是没有先减掉 padding,结果标签和柱子全贴边。

第二个坑,是 textAlign 改成 center 后忘了恢复,不过这个案例后续没有再写别的内容,所以暂时没出问题。

第三个坑,是数据最大值写死为 100。如果真实数据不是这个量级,需要先做动态最大值计算。

第四个坑,是直接把太多标签塞到底部,文字会重叠。数据一多,布局策略要变。

写在最后

柱状图这件事,看起来像是图形问题,实际上更像空间和数据关系问题。只要你把绘图区、比例映射、标签位置和信息层级想清楚,用 Canvas 画一张清爽的图并不难。

这一页特别值得反复练,因为它已经把 Canvas 从“画图”推进到了“表达数据”。这一步走通,后面的应用场景会一下子宽很多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐