[鸿蒙从零到一] ArkUI Canvas 绘制实战:坐标、路径、交互与性能优化
前言
ArkUI 的声明式组件适合搭建按钮、列表和表单,但遇到趋势图、签名板、仪表盘、标注层等高度定制的视觉效果时,继续堆叠普通组件往往会让结构复杂、性能不可控。Canvas 提供了一块可编程画布,让应用可以按像素级规则绘制图形,同时保留 ArkUI 的状态驱动与事件体系。
本文以一个可交互的健康趋势图为主线,从画布初始化、坐标换算和路径绘制讲起,再加入触摸命中、离屏缓存、像素密度适配和组件封装。示例使用 ArkTS、ArkUI 与 Stage 模型,接口细节请以项目当前 SDK 的类型定义为准。
一、什么时候应该使用 Canvas
Canvas 不是普通布局组件的替代品。选择它之前,可以先判断需求是否具备以下特征:
- 图形由大量线段、曲线、网格或粒子组成。
- 元素位置需要通过数据和数学公式动态计算。
- 多个图形共享同一坐标系,需要整体缩放或平移。
- 视觉内容比组件语义更重要,例如图表、签名、涂鸦和图片标注。
文本输入、可访问按钮、复杂表单仍应优先使用标准组件。Canvas 内的图形默认没有独立组件语义,也不能天然参与 ArkUI 布局。实际项目常采用混合结构:Canvas 负责主视觉,标准组件负责标题、筛选、操作和无障碍入口。
二、理解 Canvas 与绘图上下文
Canvas 是画布组件,CanvasRenderingContext2D 是绘图命令的执行上下文。上下文通常由 RenderingContextSettings 创建,并在 onReady 回调中进行首次绘制。
@Component
struct BasicCanvas {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings)
build() {
Canvas(this.context)
.width('100%')
.height(240)
.backgroundColor('#FFFFFF')
.onReady(() => {
this.draw()
})
}
private draw(): void {
this.context.clearRect(0, 0, this.context.width, this.context.height)
this.context.fillStyle = '#2563EB'
this.context.fillRect(24, 24, 120, 64)
}
}
onReady 很重要:画布完成布局后才能取得可靠尺寸。不要在组件字段初始化阶段直接读取宽高并绘制,因为此时布局可能尚未完成。
每次重绘前调用 clearRect,可以避免旧帧残留。若画布背景透明,清除后会露出组件背后的内容;如果希望固定底色,应在清除后主动填充背景。
三、坐标系与像素密度
画布原点位于左上角,x 轴向右,y 轴向下。业务数据常使用“值越大位置越高”的数学坐标,因此绘制图表时需要反转 y 轴映射。
假设绘图区顶部为 top,高度为 plotHeight,数据范围为 minValue 到 maxValue,数据值到画布坐标的换算如下:
private valueToY(value: number, minValue: number, maxValue: number,
top: number, plotHeight: number): number {
const range = Math.max(maxValue - minValue, 1)
const ratio = (value - minValue) / range
return top + plotHeight * (1 - ratio)
}
布局尺寸与物理像素不是同一个概念。高密度屏幕上,如果底层位图像素不足,斜线和文字可能发虚。项目中应结合 px2vp、vp2px 或当前显示密度统一计算尺寸,并在真实设备上检查细线效果。尤其要避免一部分参数使用 vp、另一部分参数直接当 px,导致命中区域和视觉位置不一致。
建议在绘图组件内部明确一种坐标单位:业务层只传数据,所有布局尺寸和触摸坐标都在同一个边界内完成转换。
四、绘制网格、折线和数据点
下面定义趋势图使用的数据结构:
interface TrendPoint {
label: string
value: number
}
interface CanvasPoint {
x: number
y: number
sourceIndex: number
}
绘制顺序会直接影响最终效果。通常先画背景和网格,再画数据区域、折线与数据点,最后绘制选中标记和文字。后绘制的内容会覆盖先绘制的内容。
private drawGrid(width: number, height: number): void {
const left = 44
const right = 16
const top = 20
const bottom = 36
const plotWidth = width - left - right
const plotHeight = height - top - bottom
this.context.beginPath()
this.context.strokeStyle = '#E5E7EB'
this.context.lineWidth = 1
for (let row = 0; row <= 4; row++) {
const y = top + plotHeight * row / 4
this.context.moveTo(left, y)
this.context.lineTo(left + plotWidth, y)
}
this.context.stroke()
}
路径绘制应遵循 beginPath、路径命令、stroke 或 fill 的顺序。忘记 beginPath 会让新路径与旧路径连接,产生难以定位的多余线段。
private buildPoints(data: TrendPoint[], width: number, height: number): CanvasPoint[] {
const left = 44
const right = 16
const top = 20
const bottom = 36
const plotWidth = width - left - right
const plotHeight = height - top - bottom
const values = data.map((item: TrendPoint) => item.value)
const minValue = Math.min(...values) - 2
const maxValue = Math.max(...values) + 2
return data.map((item: TrendPoint, index: number) => ({
x: left + plotWidth * index / Math.max(data.length - 1, 1),
y: this.valueToY(item.value, minValue, maxValue, top, plotHeight),
sourceIndex: index
}))
}
private drawLine(points: CanvasPoint[]): void {
if (points.length === 0) {
return
}
this.context.beginPath()
this.context.moveTo(points[0].x, points[0].y)
points.slice(1).forEach((point: CanvasPoint) => {
this.context.lineTo(point.x, point.y)
})
this.context.strokeStyle = '#2563EB'
this.context.lineWidth = 3
this.context.lineJoin = 'round'
this.context.lineCap = 'round'
this.context.stroke()
points.forEach((point: CanvasPoint) => {
this.context.beginPath()
this.context.arc(point.x, point.y, 4, 0, Math.PI * 2)
this.context.fillStyle = '#FFFFFF'
this.context.fill()
this.context.strokeStyle = '#2563EB'
this.context.lineWidth = 2
this.context.stroke()
})
}
数据为空时必须提前返回,避免 Math.min、数组下标和除法产生无效值。只有一个点时,x 轴分母也需要兜底。
五、用贝塞尔曲线让折线更平滑
直接连接数据点得到的是折线。若产品希望使用平滑曲线,可以用三次贝塞尔曲线连接相邻点。一个实用的简化方案是把相邻点中点作为控制区域:
private drawSmoothLine(points: CanvasPoint[]): void {
if (points.length < 2) {
this.drawLine(points)
return
}
this.context.beginPath()
this.context.moveTo(points[0].x, points[0].y)
for (let index = 1; index < points.length; index++) {
const previous = points[index - 1]
const current = points[index]
const middleX = (previous.x + current.x) / 2
this.context.bezierCurveTo(
middleX, previous.y,
middleX, current.y,
current.x, current.y
)
}
this.context.strokeStyle = '#2563EB'
this.context.lineWidth = 3
this.context.stroke()
}
平滑并不等于更准确。曲线可能在相邻数据之间产生视觉上的峰值或谷值,金融、医疗等强调精确读数的场景应谨慎使用。可以保留数据点,并在详情中展示原始值,避免用户把插值曲线误认为真实采样。
六、绘制渐变面积
趋势图常在曲线下方填充渐变色。实现时先沿曲线创建路径,再连接到绘图区底部形成闭合区域,最后调用 fill。
private drawArea(points: CanvasPoint[], bottom: number): void {
if (points.length < 2) {
return
}
this.context.beginPath()
this.context.moveTo(points[0].x, bottom)
this.context.lineTo(points[0].x, points[0].y)
points.slice(1).forEach((point: CanvasPoint) => {
this.context.lineTo(point.x, point.y)
})
this.context.lineTo(points[points.length - 1].x, bottom)
this.context.closePath()
const gradient = this.context.createLinearGradient(0, 20, 0, bottom)
gradient.addColorStop(0, 'rgba(37, 99, 235, 0.28)')
gradient.addColorStop(1, 'rgba(37, 99, 235, 0.02)')
this.context.fillStyle = gradient
this.context.fill()
}
面积层应在折线之前绘制,否则半透明填充可能改变线条颜色。save() 和 restore() 可以隔离透明度、变换、裁剪等上下文状态,复杂绘制方法最好成对使用,避免一个图层污染后续图层。
七、加入触摸命中与选中态
Canvas 内的数据点不是独立组件,点击事件只能从画布整体获得。收到触摸坐标后,需要自行判断离哪个数据点最近。
@State private selectedIndex: number = -1
private canvasPoints: CanvasPoint[] = []
private findNearestPoint(x: number, y: number): number {
const hitRadius = 20
let nearest = -1
let nearestDistance = Number.MAX_VALUE
this.canvasPoints.forEach((point: CanvasPoint) => {
const dx = point.x - x
const dy = point.y - y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance <= hitRadius && distance < nearestDistance) {
nearest = point.sourceIndex
nearestDistance = distance
}
})
return nearest
}
视觉点半径可能只有 4px,但触摸命中半径应更大,保证操作容错。事件中取得的位置必须与绘图使用同一坐标系;如果 Canvas 外层存在缩放、偏移或滚动,还要确认事件坐标是局部坐标还是窗口坐标。
Canvas(this.context)
.width('100%')
.height(260)
.onReady(() => this.redraw())
.onTouch((event: TouchEvent) => {
if (event.type !== TouchType.Down || event.touches.length === 0) {
return
}
const touch = event.touches[0]
this.selectedIndex = this.findNearestPoint(touch.x, touch.y)
this.redraw()
})
选中后可绘制竖向辅助线、放大的数据点和数值标签。不要只靠颜色表达选中状态,还可以改变点的尺寸或增加描边,以兼顾色觉差异。
八、把重绘收敛成单一入口
Canvas 不会因为普通状态变化自动重放所有绘图命令。数据、尺寸、主题或选中态改变后,需要主动调用统一的重绘入口。
private redraw(): void {
const width = this.context.width
const height = this.context.height
if (width <= 0 || height <= 0) {
return
}
this.context.clearRect(0, 0, width, height)
this.context.fillStyle = '#FFFFFF'
this.context.fillRect(0, 0, width, height)
this.drawGrid(width, height)
this.canvasPoints = this.buildPoints(this.data, width, height)
this.drawArea(this.canvasPoints, height - 36)
this.drawSmoothLine(this.canvasPoints)
this.drawSelection(this.selectedIndex)
}
将绘制顺序集中起来有三个好处:图层关系清晰、状态变化不会漏画、性能分析有稳定入口。各子方法尽量只负责绘制,不修改业务状态。
如果父组件异步更新数据,可以通过 @Prop、@Link 或观测机制接收变化,并在数据真正变化时重绘。避免在 build() 中无条件绘图,因为组件重建频率可能高于数据变化频率。
九、性能优化:减少每帧工作量
Canvas 性能问题通常不是某条 API 太慢,而是每次重绘做了太多重复计算。可以从以下方向优化:
- 静态网格、背景与坐标轴只在尺寸或主题变化时重建。
- 数据点坐标在数据或画布尺寸变化时计算并缓存。
- 高频移动事件合并到帧节奏,不要每个事件都完整重绘。
- 只更新变化区域时使用局部清除,但要正确处理抗锯齿边缘和阴影范围。
- 大数据集先抽样或聚合,画布宽度只有几百像素时,无需绘制数万个点。
- 避免在绘制循环中创建大量临时对象、格式化字符串或读取持久化数据。
对复杂静态图层,可以使用离屏 Canvas 预先绘制,再通过 drawImage 合成到主画布。离屏缓存会增加内存占用,适合“生成成本高、复用次数多”的内容,而不是所有图层都缓存。
性能验证应观察真实设备上的帧率、CPU 和内存,不要只凭模拟器观感。还要测试应用进入后台再返回、横竖屏切换、窗口尺寸变化和深浅色切换后的重绘行为。
十、封装可复用绘图组件
一个可维护的 Canvas 组件应把业务数据、样式配置和绘制实现分开。调用方只传入数据和事件,不需要知道坐标换算细节。
interface TrendChartStyle {
lineColor: string
gridColor: string
textColor: string
lineWidth: number
}
@Component
struct TrendChart {
@Prop data: TrendPoint[]
@Prop style: TrendChartStyle
onPointSelected?: (point: TrendPoint) => void
// 上下文、缓存坐标与绘制方法由组件内部维护
}
建议划分以下职责:
- 数据层负责提供有意义的业务值,不包含画布坐标。
- 布局层负责边距、绘图区和尺寸变化。
- 映射层负责把业务值转换成画布坐标。
- 绘制层按固定顺序输出各图层。
- 交互层负责命中检测,并通过回调上报业务事件。
这样既能单独测试坐标映射和命中算法,也能在不改业务层的情况下替换视觉样式。
十一、常见问题排查
画布空白
确认绘制发生在 onReady 之后,宽高大于零,颜色不是透明色,路径最终调用了 stroke 或 fill。还要检查 Canvas 是否被父容器裁剪或覆盖。
图形越来越深
通常是重绘前没有清空画布,半透明内容被重复叠加。应在完整重绘入口调用 clearRect,再重新绘制背景与所有图层。
路径意外连接
不同图形之间缺少 beginPath,或上下文状态没有通过 save、restore 隔离。为每个独立路径显式开启新路径。
点击位置偏移
绘制坐标与事件坐标使用了不同单位,或者外层组件存在偏移和缩放。打印画布局部坐标和数据点坐标,确认二者位于同一坐标系。
旋转或缩放后布局异常
画布尺寸发生变化,但缓存坐标仍基于旧尺寸。监听尺寸变化,失效布局缓存并重新映射数据。
十二、工程实践清单
- 标准组件能表达的界面优先使用标准组件。
- 所有绘制集中到统一重绘入口,并明确图层顺序。
- 数据坐标与画布坐标分离,换算逻辑可独立测试。
- 空数据、单点、极值相同和超大数据量都有兜底。
- 触摸命中范围大于视觉点,并统一坐标单位。
- 高频事件按帧合并,静态计算结果按依赖缓存。
- 深浅色、字体缩放、窗口变化和后台恢复均验证重绘。
- 关键数据提供文本等价信息,不让 Canvas 成为无障碍黑盒。
总结
ArkUI Canvas 的核心并不是记住多少绘图 API,而是建立清晰的绘制管线:先确定画布尺寸和坐标系,再把业务数据映射成路径,按图层顺序绘制,最后用统一坐标完成交互命中。
当重绘入口、数据映射、缓存边界和事件上报被明确分层后,Canvas 就不再是一段难以维护的命令集合,而会成为可测试、可复用的自定义图形组件。面对图表、签名、标注等需求,也能在视觉自由度与工程稳定性之间取得平衡。