最近在开发 OpenHarmony 应用时遇到一个很实际的需求:要在统计页面里展示一组数据趋势,说白了就是画柱状图。OpenHarmony 生态目前还在成长期,图表组件不像 Web 端或者 Android 端那么丰富,现成的库要么是 Web 移植过来的、依赖太重,要么还没有针对 ArkUI 做适配。折腾一圈之后,我决定直接基于 Canvas 写一个轻量柱状图组件,顺便把柱状图叠加折线图、点击高亮、动态刷新这些常见场景也一并实现了。这篇博文就完整记录一下我的实现思路、关键代码和踩坑过程,给同样需要在 OpenHarmony 里做数据可视化的朋友参考。
整个过程涉及 ArkTS 语法、Canvas 绘制、坐标映射计算、动画刷新和命中检测,不算难,但细节比较多。如果你是刚接触 OpenHarmony 应用开发,或者正纠结怎么在 HarmonyOS 应用里画图表,这篇文章应该能帮你省不少时间。我会把方案选型、数据模型设计、绘制流程、交互处理和问题排查都讲清楚,代码片段可以直接抄进自己的工程里改。
1. 整体设计与方案选型
做 OpenHarmony 原生图表,摆在前面的路有三条:引用第三方图表库、使用 ArkUI 自带的组件组合实现、通过 Canvas 自绘。我当时把这三条路都踩了一遍,最后才确定用 Canvas 自绘。这里把我的思考过程写出来,方便你结合实际场景做判断。
1.1 三条技术路线的对比
先说第三方图表库。OpenHarmony 上常见的方案是找 OpenHarmony 版本的 MPAndroidChart 移植库,或者用一些 Web 技术栈封装的图表组件包。优点是功能齐全,曲线图、饼图、雷达图都有现成接口。但实际接入就会发现坑很多:部分移植库停留在早期 API 版本,编译期报一堆类型错误;有些库依赖 WebView 渲染,包体积直接多出十几兆,而且滚动列表里嵌套 WebView 的体验并不好;最关键的是,项目要的是“柱状图 + 折线图 + 点击高亮”这种定制度比较高的效果,通用库在样式细节上反而绑手绑脚。
再说 ArkUI 自带的组件组合,比如用 Progress、Column、Row 拼出一个柱状图。这种方式适合数据量极小、不需要坐标轴的场景,比如几根进度条比大小。一旦涉及精确的坐标轴刻度、网格线、多系列分组、点击交互,用布局组件拼出来的图基本不可维护,每一处间距都靠魔法数字调,改个数据就要重新算一遍。
最后是 Canvas 自绘。ArkUI 的 Canvas 组件底层是原生渲染引擎,性能和稳定性都有保障;绘制接口和 Web Canvas 高度类似,前端转过来的开发者几乎没有学习成本;整个图表就是一个 Canvas + 一个绘制函数,不引入任何第三方依赖,包体积零增加。缺点也很明显,所有细节都要自己处理,从坐标轴计算到文字对齐,但这恰恰是可控性最强的方案。
1.2 为什么我最终选择 Canvas 自绘
说句实在话,如果项目要展示的是非常复杂的金融 K 线图或者大规模时序数据,我也建议去找成熟库,硬造轮子不划算。但大多数应用里的数据展示需求,其实就是几组柱状图、几条折线,数据量顶天几十个点。这种场景下,自己写的组件大概只需要两三百行代码,换来的是完全可控的样式和交互,维护起来清清楚楚。
另外一个决定性因素是 ArkTS 语言本身对第三方 JS 库的支持还有限制。很多 npm 包在纯 TypeScript 环境里能跑,一放进 ArkTS 工程就报“不支持解构赋值”或者“动态属性访问受限”之类的编译错误。与其花时间解决第三方库的兼容性问题,不如直接基于官方 Canvas 接口开发,这是 OpenHarmony 默认支持的能力,编译和运行都不会出幺蛾子。
1.3 组件能力边界定义
动手之前,我先明确了这个柱状图组件需要支持的能力范围:支持单系列和多系列柱状图、支持柱状图与折线图叠加展示、Y 轴自动计算刻度、柱体带入场动画、支持点击或悬停高亮并显示数据详情、支持数据源动态更新后自动重绘。这些能力对应到实际业务上,基本覆盖了日报统计、流量趋势、销售对比这些常见场景。确定好边界之后,后面写代码就不会东一榔头西一棒子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 绘制柱状图前的基础准备
画图之前,有几个准备工作必须先做好:工程环境、数据模型、坐标换算逻辑。很多人一上来就写绘制代码,结果写到一半发现数据结构不合理、Canvas 尺寸拿不到,回头再改就非常痛苦。基础这部分我建议认真看完。
2.1 开发环境与工程结构
我的开发环境是 DevEco Studio 4.0 及以上版本,SDK 使用 API 9 或 API 10 均可,下面代码用到的接口在两个版本上都验证过。创建工程时选择“Empty Ability”模板,语言选择 ArkTS。工程结构上,建议单独建一个 components 目录存放图表组件,和页面逻辑解耦。
code复制entry/src/main/ets/
├── components/
│ └── BarChartComponent.ets
├── model/
│ └── ChartDataModel.ets
├── pages/
│ └── Index.ets
BarChartComponent.ets 负责所有绘制和交互,ChartDataModel.ets 定义数据结构,Index.ets 是使用组件的示例页面。这样分层的目的是让图表组件可以独立复用,换项目时直接拷贝三个文件就行。
2.2 数据模型设计
数据模型是整个图表的地基。我一开始图省事,直接把数据作为二维数组传进组件,结果多系列和折线叠加的时候到处都是魔法索引,读代码的人根本不知道 data[0][2] 是什么意思。后来老老实实设计了数据类。
typescript复制// ChartDataModel.ets
export class BarData {
// 每个柱子的分类名称,显示在X轴上
label: string = '';
// 多系列柱状图的值,索引对应系列编号
values: number[] = [];
// 折线图数据,不传则只画柱状图
lineValue?: number;
}
export class ChartDataSet {
// 系列名称,用于图例和提示框
seriesNames: string[] = [];
// 每个系列对应的颜色
seriesColors: string[] = [];
// 分类数据
items: BarData[] = [];
}
这里的关键设计是把“分类”和“系列”分开。柱状图的横向维度是分类,比如一周的七天;纵向维度是系列,比如“本周收入”和“上周收入”。折线图数据挂在每个分类下面,和柱状图共享 X 轴位置,这样叠加绘制时坐标天然对齐,不会出现折线点和柱子对不上的问题。
数据模型设计好了,绘制逻辑就可以完全基于这个模型来写。后面如果要加堆叠柱状图,只需要把 values 的语义从“并列”改成“累加”,绘制函数里的计算逻辑稍微调整即可。
2.3 坐标系换算:从数据值到像素坐标
柱状图绘制的核心,是把数据值映射到 Canvas 的像素坐标。这个映射关系搞不明白,后面所有绘制都是乱的。
我先给图表定义一个绘制区域,也就是去掉四周留白之后的有效区域。Canvas 的实际宽度用 chartWidth 表示,实际高度用 chartHeight 表示,四周分别留出 paddingLeft、paddingRight、paddingTop、paddingBottom。那么坐标系的原点就在 (paddingLeft, chartHeight - paddingBottom),也就是左下角。
然后是 Y 轴的比例尺。柱状图一般从 0 开始,所以 Y 轴的映射公式是:
code复制scaleY = plotHeight / maxValue
barHeight = value * scaleY
barY = chartHeight - paddingBottom - barHeight
其中 plotHeight = chartHeight - paddingTop - paddingBottom。maxValue 不能直接用数据里的最大值,最好向上取整成一个“好看”的数字,比如数据最大值是 83,那 maxValue 取 100,这样 Y 轴刻度是 0、20、40、60、80、100,看起来更规整。向上取整的计算我放在后面代码里。
X 轴的分桶逻辑稍微复杂一点。假设有 n 个分类,每个分类占一段宽度,总有效宽度为 plotWidth = chartWidth - paddingLeft - paddingRight。如果每组柱子的总宽度为 groupWidth,那么每个分类的中心点 X 坐标是:
code复制groupWidth = plotWidth / n
centerX = paddingLeft + groupWidth * index + groupWidth / 2
多系列时,每个系列柱子的宽度按比例分配,比如系列数量为 seriesCount,柱子间距为 barGap,单根柱子宽度为:
code复制barWidth = (groupWidth - barGap * (seriesCount - 1)) / seriesCount
第 s 个系列的柱子左边缘 X 坐标为:
code复制barX = paddingLeft + groupWidth * index + barGap * s + barWidth * s
这些换算公式是柱状图的地基,代码实现时无非要处理浮点数精度,但思路就是这个思路。建议你把公式写在注释里,下次维护的时候不用重新推导。
3. 核心代码实现:一步一步画出柱状图
基础都准备完了,接下来进入正题:用代码把柱状图画出来。我会按照页面骨架、坐标轴与网格、柱体绘制、动画、折线叠加的顺序来讲。每一段代码都是经过实际运行验证的,你可以直接往工程里贴。
3.1 页面骨架与 Canvas 初始化
Canvas 组件在 ArkUI 里用起来和 Web 端很像,先创建 CanvasRenderingContext2D 对象,再绑定到 Canvas 组件上。注意 onReady 回调是 Canvas 初始化完成的标志,只有在这个回调之后才能拿上下文绘制。
typescript复制// BarChartComponent.ets
@Entry
@Component
struct BarChartPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private chartWidth: number = 0;
private chartHeight: number = 320;
build() {
Column() {
Canvas(this.context)
.width('100%')
.height(this.chartHeight)
.onReady(() => {
this.chartWidth = this.context.width;
this.drawChart();
})
}
.width('100%')
.padding(12)
}
drawChart() {
// 绘制逻辑
}
}
注意一个容易踩的坑:context.width 只有在 onReady 之后才有正确值,如果你在 aboutToAppear 里提前访问,拿到的往往是 0。另外 Canvas 的宽高如果设置成 '100%',要在 onReady 里再去读取实际像素宽度,我当时就是漏了这一步,结果柱状图全部挤在左上角。
RenderingContextSettings(true) 的布尔参数表示开启抗锯齿,不传或者传 false 的话,斜线和文字边缘会有明显的锯齿感,尤其在真机上非常难看。
3.2 坐标轴与网格绘制
柱子之前,先把坐标系画出来。一个完整的柱状图坐标系包括 X 轴、Y 轴、Y 轴刻度和水平网格线。网格线建议用虚线,视觉上比实线轻盈,不会抢数据本身的风头。
先计算 Y 轴的最大值和刻度间隔。
typescript复制calculateMaxValue(): number {
let max = 0;
this.data.items.forEach(item => {
item.values.forEach(v => {
if (v > max) max = v;
});
if (item.lineValue !== undefined && item.lineValue > max) {
max = item.lineValue;
}
});
// 向上取整到“好看”的数字
if (max <= 0) return 1;
const magnitude = Math.pow(10, Math.floor(Math.log10(max)));
const normalized = max / magnitude;
let niceMax: number;
if (normalized <= 1) niceMax = 1;
else if (normalized <= 2) niceMax = 2;
else if (normalized <= 5) niceMax = 5;
else niceMax = 10;
return niceMax * magnitude;
}
这段代码的逻辑是:先找到数据中的最大值,然后把它规范化成一个 1、2、5、10 的倍数。比如最大值是 83,那么 magnitude 是 10,normalized 是 8.3,落入 <= 10 分支,niceMax 返回 100;最大值是 47,则返回 50。这样 Y 轴刻度就是 0、10、20、30、40、50,非常整齐。
有了 maxValue 之后,就可以画网格和坐标轴了。
typescript复制drawGrid() {
const plotLeft = this.paddingLeft;
const plotTop = this.paddingTop;
const plotWidth = this.chartWidth - this.paddingLeft - this.paddingRight;
const plotHeight = this.chartHeight - this.paddingTop - this.paddingBottom;
const maxValue = this.currentMaxValue;
const ctx = this.context;
const tickCount = 5;
ctx.strokeStyle = '#E5E6EB';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
for (let i = 0; i <= tickCount; i++) {
const value = maxValue * i / tickCount;
const y = plotTop + plotHeight - (value / maxValue) * plotHeight;
ctx.beginPath();
ctx.moveTo(plotLeft, y);
ctx.lineTo(plotLeft + plotWidth, y);
ctx.stroke();
// 绘制Y轴刻度文字
ctx.fillStyle = '#666666';
ctx.font = '10vp sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), plotLeft - 8, y);
}
// 恢复实线
ctx.setLineDash([]);
// 绘制X轴和Y轴
ctx.strokeStyle = '#C0C4CC';
ctx.beginPath();
ctx.moveTo(plotLeft, plotTop + plotHeight);
ctx.lineTo(plotLeft + plotWidth, plotTop + plotHeight);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(plotLeft, plotTop);
ctx.lineTo(plotLeft, plotTop + plotHeight);
ctx.stroke();
}
ctx.setLineDash([4, 4]) 是设置虚线间隔,画完网格之后一定要调用 setLineDash([]) 恢复实线,否则接下来画的坐标轴也会变成虚线,我当时就因为这个排查了半天。Y 轴刻度文字的 textAlign 和 textBaseline 也建议显式设置,默认值在不同平台上差异很大,不设置的话文字位置会飘。
3.3 柱体绘制:多系列分组
坐标轴画完后,柱子就是一个循环里计算矩形位置的事了。多系列时注意柱子的分组对齐,我用的是按分类分组、组内系列并排的布局。
typescript复制drawBars(progress: number) {
const plotLeft = this.paddingLeft;
const plotBottom = this.chartHeight - this.paddingBottom;
const plotWidth = this.chartWidth - this.paddingLeft - this.paddingRight;
const plotHeight = this.chartHeight - this.paddingTop - this.paddingBottom;
const maxValue = this.currentMaxValue;
const ctx = this.context;
const n = this.data.items.length;
const groupWidth = plotWidth / n;
const seriesCount = this.data.seriesNames.length;
const barGap = 6;
const barWidth = Math.max((groupWidth - barGap * (seriesCount - 1)) / seriesCount, 2);
for (let i = 0; i < n; i++) {
const item = this.data.items[i];
const centerX = plotLeft + groupWidth * i + groupWidth / 2;
for (let s = 0; s < seriesCount; s++) {
const value = item.values[s] || 0;
const barHeight = (value / maxValue) * plotHeight * progress;
const barX = centerX - groupWidth / 2 + barGap * s + barWidth * s;
const barY = plotBottom - barHeight;
ctx.fillStyle = this.data.seriesColors[s];
// 绘制圆角矩形
const radius = 4;
ctx.beginPath();
ctx.moveTo(barX, barY + radius);
ctx.arcTo(barX, barY, barX + radius, barY, radius);
ctx.arcTo(barX + barWidth, barY, barX + barWidth, barY + radius, radius);
ctx.arcTo(barX + barWidth, plotBottom, barX + barWidth - radius, plotBottom, radius);
ctx.arcTo(barX, plotBottom, barX, plotBottom - radius, radius);
ctx.closePath();
ctx.fill();
}
}
}
这里用了 arcTo 来画圆角矩形,顶部圆角半径设为 4vp。圆角的好处是视觉上更柔和,尤其是贴近“轻量级图表”的风格。progress 参数用来控制动画进度,计算柱高时乘上这个系数,就实现了柱子从 0 长到目标高度的入场动画。多系列时,如果数据里某个值是 undefined 或者缺失,用 || 0 兜底,避免绘制时计算出 NaN。
实际使用时,你可能会遇到柱子太窄的问题。当分类数量很多、比如超过 10 个时,按比例算出来的柱子宽度会小于 1vp,绘制出来基本看不清。这种情况下我建议强制最小宽度,同时缩小柱子间隙。如果还是挤不下,就该考虑横向滚动或者把图表的宽度改成分页模式了。
3.4 入场动画实现
柱状图没有动画就像页面缺了灵魂。OpenHarmony 里做 Canvas 动画可以用 requestAnimationFrame 全局函数,它的回调和 Web 端一样,每帧触发一次。
typescript复制startAnimation() {
const duration = 600;
const startTime = Date.now();
const that = this;
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeOutCubic 缓动
const eased = 1 - Math.pow(1 - progress, 3);
that.currentProgress = eased;
that.context.clearRect(0, 0, that.chartWidth, that.chartHeight);
that.drawGrid();
that.drawBars(eased);
if (progress < 1) {
requestAnimationFrame(animate);
}
}
animate();
}
动画的原理很简单:每帧算出当前进度,用缓动函数把线性时间映射成非线性的动画进度,然后重绘整个图表。easeOutCubic 是“先快后慢”的效果,柱子开始长势很猛,最后平稳停住,观感比较自然。
需要注意的是 that.context.clearRect 要清掉整块画布,否则重绘时旧帧会残留。我见过有人只清柱子区域不清网格区域,结果动画过程中网格线叠了好几层,越画越粗。清空后要先画网格再画柱子,保证网格在柱子底层。
动画时长我建议 400-700ms 之间,太短显得仓促,太长用户会等得不耐烦。另外在数据刷新时,我一般会直接把 currentProgress 重置为 0 再调 startAnimation,让每次数据变化都有一次完整的生长动画,用户能直观感知数据变了。
3.5 折线图叠加到柱状图上
前面提到项目里还需要“柱状图叠加折线图”的能力,这个需求在业务里其实很常见,比如柱状图展示每日销量,折线图展示目标达成率。实现方式是在同一套坐标系里,把每个分类的 lineValue 连成一条折线。
typescript复制drawLine(progress: number) {
const plotLeft = this.paddingLeft;
const plotTop = this.paddingTop;
const plotWidth = this.chartWidth - this.paddingLeft - this.paddingRight;
const plotHeight = this.chartHeight - this.paddingTop - this.paddingBottom;
const maxValue = this.currentMaxValue;
const n = this.data.items.length;
const groupWidth = plotWidth / n;
const ctx = this.context;
ctx.strokeStyle = '#F56C6C';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const item = this.data.items[i];
if (item.lineValue === undefined) continue;
const centerX = plotLeft + groupWidth * i + groupWidth / 2;
const lineY = plotTop + plotHeight - (item.lineValue / maxValue) * plotHeight * progress;
if (i === 0 || item.lineValue === undefined) {
ctx.moveTo(centerX, lineY);
} else {
ctx.lineTo(centerX, lineY);
}
}
ctx.stroke();
// 画数据点圆点
for (let i = 0; i < n; i++) {
const item = this.data.items[i];
if (item.lineValue === undefined) continue;
const centerX = plotLeft + groupWidth * i + groupWidth / 2;
const lineY = plotTop + plotHeight - (item.lineValue / maxValue) * plotHeight * progress;
ctx.beginPath();
ctx.arc(centerX, lineY, 3, 0, Math.PI * 2);
ctx.fillStyle = '#F56C6C';
ctx.fill();
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.stroke();
}
}
折线连接用的是直线,没有做贝塞尔曲线平滑。原因很简单:业务数据强调真实性,平滑曲线会在相邻点之间产生“过度圆滑”的假象,给人一种数据经过拟合的错觉。如果未来确实需要平滑曲线,可以考虑用 ctx.quadraticCurveTo,但要注意控制控制点偏移量,否则曲线容易穿到柱子底下。
折线颜色和柱状图系列颜色要有明显区分。我习惯给折线用亮一点的暖色,比如红色系,这样即使柱子颜色也是暖色,也能靠饱和度区分开。数据点上的白色描边是一个小技巧,当折线和柱子重叠时,白色描边能把圆点从背景色里“抠”出来,避免由于遮挡导致折线丢失。
4. 交互与动态更新
图表画出来只是第一步,用户还需要和数据产生交互。这章重点讲点击高亮、悬停显示数据详情和数据动态刷新。这也是很多人在论坛里问得最多的问题,包括“鼠标点那儿在哪儿显示柱状图”,本质上就是点击命中检测的需求。
4.1 点击高亮与数据弹窗
Canvas 的点击交互思路是:记录每个柱子的矩形区域,点击时遍历这些区域,判断点击坐标落在哪个柱子上,然后做高亮并触发回调。关键代码如下。
typescript复制// 记录所有柱子的点击区域
private hitRegions: HitRegion[] = [];
buildHitRegions() {
this.hitRegions = [];
const plotLeft = this.paddingLeft;
const plotBottom = this.chartHeight - this.paddingBottom;
const plotHeight = this.chartHeight - this.paddingTop - this.paddingBottom;
const maxValue = this.currentMaxValue;
const n = this.data.items.length;
const groupWidth = (this.chartWidth - this.paddingLeft - this.paddingRight) / n;
const seriesCount = this.data.seriesNames.length;
const barGap = 6;
const barWidth = Math.max((groupWidth - barGap * (seriesCount - 1)) / seriesCount, 2);
for (let i = 0; i < n; i++) {
for (let s = 0; s < seriesCount; s++) {
const value = this.data.items[i].values[s] || 0;
const barHeight = (value / maxValue) * plotHeight;
const barX = plotLeft + groupWidth * i + barGap * s + barWidth * s;
const barY = plotBottom - barHeight;
this.hitRegions.push({
index: i,
series: s,
x: barX,
y: barY,
width: barWidth,
height: barHeight
});
}
}
}
onCanvasClick(event: ClickEvent) {
const x = event.x;
const y = event.y;
for (let i = 0; i < this.hitRegions.length; i++) {
const region = this.hitRegions[i];
if (x >= region.x && x <= region.x + region.width &&
y >= region.y && y <= region.y + region.height) {
this.selectedIndex = region.index;
this.selectedSeries = region.series;
this.drawChartWithSelected();
break;
}
}
}
这里有一个细节值得注意:event.x 和 event.y 是在 Canvas 组件坐标系内的坐标,单位是 vp,而 context.width 返回的也是 vp 单位。OpenHarmony 的 Canvas 接口在设计时已经统一了单位,所以不需要做像素比换算。放在 Web 端,这里还得乘上 devicePixelRatio,在 OpenHarmony 里省了一步,算是比较友好。
高亮绘制是在 drawBars 里增加一个判断,如果当前柱子的 index 和 series 匹配 selectedIndex 与 selectedSeries,就换一个更深一点的颜色,并且画一个外边框强调。
数据弹窗我用的是 Canvas 直接绘制一个带圆角的小浮层,内容就是“分类名 + 系列名 + 数值”。这里不需要用 ArkUI 的 Popup 组件,因为浮层要跟随柱子位置,用 Canvas 绘制最跟手,位置计算也最简单。
4.2 悬停显示:桌面端鼠标跟随
如果你的应用要跑在 Windows 或者 x86 模拟器上,鼠标悬停显示数据的场景也很常见。ArkUI 的 Canvas 组件支持 onHover 事件,用法和 onClick 类似,区别是 onHover 的回调参数里有一个 isEnter 布尔值,用来区分鼠标进入还是离开。
我在实现悬停提示时,选择在鼠标经过每个分类区域时,把当前分类的所有系列数据显示在一个显眼的位置。具体做法是在 onHover 回调里先判断鼠标坐标落在哪个分类桶内,然后更新一个状态变量,触发重绘,在 Canvas 顶部绘制一行文字,类似“周一:本周 120,上周 95”。
这里有个排序问题需要提前处理:多个分类桶边界处的柱子很容易误触。我的建议是命中的判定条件不要用“左边界 <= x <= 右边界”,而是算到分类中心点的距离,距离哪个中心点近就选中哪个分类。这样鼠标在两个分类交界处摆动时,不会出现高亮状态疯狂横跳的尴尬局面。
4.3 数据动态刷新
业务系统里的数据是活的,图表也要跟着变。我的做法是把 data 声明为 @Prop 或者 @State,外部页面修改数据后,在 onDataChange 里重新构建命中区域并启动动画。
typescript复制@Prop data: ChartDataSet;
@State selectedIndex: number = -1;
@State selectedSeries: number = -1;
@State currentProgress: number = 0;
aboutToAppear() {
this.currentProgress = 0;
}
onDataChange() {
this.currentProgress = 0;
this.buildHitRegions();
this.startAnimation();
}
外部页面只需要这样做:
typescript复制this.chartData.items[0].values[0] = 200;
this.chartComponent.onDataChange();
这种“数据驱动重绘”的模式,比直接操作 Canvas 重画要优雅得多。因为每次重绘前都会重置进度并启动动画,用户能清楚地感知到哪一个数据点发生了变化。如果你希望数据变化时不要动画,直接把 currentProgress 设为 1 再调用绘制函数即可。
5. 常见问题与排查技巧实录
最后这部分,我把开发过程中遇到的各种问题和对应的排查思路整理出来。有些问题折腾了我一整天,希望你看完之后能绕开这些坑。
5.1 画面渲染异常的排查
“OpenHarmony 画面渲染异常”是很多初学者的噩梦,具体表现形式有:柱状图不显示、只显示一半、动画过程中出现残影、文字发虚。我总结下来主要有三个原因。
第一个原因是 Canvas 的 onReady 时机没把握好。在 onReady 之前调用 drawChart,拿到的 context.width 是 0,所有坐标计算全是 NaN,自然什么都画不出来。解决办法是加一个 isReady 标志位,所有绘制入口都先判断这个标志,只有 onReady 之后才放行。
第二个原因是动画重绘时没有清空画布。requestAnimationFrame 每帧都在画,不清空的话上一帧的残留就会和当前帧叠加,表现就是柱子越画越宽、颜色越来越深。解决办法就是在每帧开头调用 context.clearRect(0, 0, chartWidth, chartHeight)。
第三个原因是 RenderingContextSettings(true) 没有开启抗锯齿,导致文字边缘出现明显的锯齿。这个问题在低分辨率模拟器上尤其明显,看久了眼睛很不舒服。把构造参数改成 true 之后,渲染质量立刻提升一个档次。
5.2 x86 模拟器上调试的几个注意点
很多开发者在 x86 模拟器上跑 OpenHarmony 应用时会遇到和真机表现不一致的问题。比如在真机上动画很流畅,模拟器上却一卡一卡的;在真机上文字显示清晰,模拟器上却模糊。
第一个原因是模拟器默认的渲染模式是软件渲染,性能比真机的 GPU 硬件加速差很多。如果你的图表数据量比较大,建议把 Canvas 的 RenderingContextSettings 的第二个参数也设置为 true,这个参数表示启用离屏渲染,可以在软件渲染模式下减少闪烁。不过也要注意,离屏渲染会占用额外的内存,数据量不大时反而有负担,酌情使用。
第二个原因是模拟器的窗口缩放比例会导致 Canvas 逻辑尺寸和物理像素不一致,表现就是柱状图边界发虚。解决思路是把 Canvas 的宽度设为可被 2 整除的数值,并开启抗锯齿。实际调下来的效果是,发虚问题会大幅缓解,但不可能完全消除,毕竟模拟器不是真机。
第三个原因是模拟器的输入事件坐标和 Canvas 内部坐标可能有偏移。在模拟器上测试点击高亮时,如果发现点击位置和柱子位置有偏差,先检查一下窗口缩放比例,再检查是否有父容器加了外边距或者安全区。我建议在调试交互逻辑时先在真机上跑一遍,模拟器只做功能验证。
5.3 多系列柱状图标签错位问题
多系列时 X 轴标签(分类名)应该显示在整个分组的正下方居中,而不是某个具体柱子的正下方。这个错误我犯过一次,当时直接在循环里顺手把标签画在第 0 个柱子的位置,结果分类一多,标签全部向左偏。
正确的做法是标签的 X 坐标用 centerX = plotLeft + groupWidth * i + groupWidth / 2,也就是分组的中心点,而不是某个柱子的左边缘。画标签之前记得设置 textAlign = 'center',否则文字会以中心点往右延伸。
多系列还有一个隐藏问题:某个系列的数值为 0 时,柱高为 0,点击区域的高度的也是 0,点击该柱子位置时永远无法命中。我的处理办法是给 hitRegions 中高度为 0 的柱子设置一个最小高度 6vp,保证可点击区域存在。这在业务上意味着“用户点击空白柱子位置也能看到该柱子的数据为 0”,体验反而更好。
5.4 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 柱状图完全不显示 | onReady 前调用绘制函数 |
加 isReady 标志位,在 onReady 后绘制 |
| 柱状图显示在左上角 | Canvas 宽高未取到实际值 | 在 onReady 里读取 context.width |
| 动画残影、柱子叠影 | 每帧未清空画布 | 在绘制函数开头调用 clearRect |
| 文字锯齿严重 | RenderingContextSettings 未开启抗锯齿 |
构造参数设为 true |
| 点击高亮位置偏移 | 模拟器缩放比例或父容器边距影响 | 真机验证,检查父容器布局 |
| X 轴标签错位 | 标签 X 坐标用了柱子而非分组中心 | 改用 centerX 计算,设置 textAlign='center' |
| 折线点与柱子不对齐 | 数据模型未共享 X 轴坐标 | 折线和柱状图共用 centerX 计算 |
| 数值为 0 的柱子点不到 | 命中区域高度为 0 | 给命中区域设置最小高度 |
写在最后
这个图表组件从零到能用在项目里,前后花了我大约两天时间。中间最大的阻碍其实不是绘制逻辑本身,而是对 Canvas 生命周期和数据状态管理的理解。把 onReady 时机、状态刷新时序和坐标映射理顺之后,后面的工作基本都是顺水推舟。
个人经验是,如果你也打算在 OpenHarmony 里做柱状图,第一版不要追求大而全,先把单系列柱状图跑通,再逐步加多系列、折线叠加、动画和交互。每加一个特性就重新验证一遍之前的绘制逻辑,这样问题出现时很容易定位到是哪一步引入的。柱状图组件做完之后,后续想扩展成饼图或者雷达图,核心的坐标映射和命中检测思路都是通用的,等于一次学会了 OpenHarmony Canvas 绘制的整套方法论。
