我就问:**帧率抖成“心电图”,你的自绘控件咋敢叫高性能?
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
别拐弯,图形渲染优化的本质就是三件事:少干(批量绘制/命令合并)、提前干(离屏/缓存)、干到点上(文本排版与像素对齐)。本文基于 HarmonyOS/OpenHarmony(ArkTS + Canvas2D 渲染),从渲染模型、批量绘制/命令合并、离屏/缓存策略、文本排版与像素对齐四条线打透,并给出一个高性能自绘图表控件的完整骨架(可抄可改)。目标很简单:把 16.6ms 的预算花得明明白白。⚡️
1. 渲染模型“话糙理不糙”:立即模式 vs 保持模式
-
立即模式(Immediate Mode):每帧
onPaint里用ctx把该画的画完就走。- 优点:简单直接;
- 痛点:全量重绘,一动就“刷屏”。
-
保持模式(Retained Mode):把图元抽象成节点/图层,**脏区(dirty rect)**改变才重画。
- 优点:增量绘制、易做缓存;
- 痛点:需要设计“场景树/图层树”。
实战建议:UI 框架保持,Canvas 局部立即。即:UI 结构按组件树组织,控件内部对静态元素“保持”、对动态元素用“立即 + 脏区”。
2. 批量绘制 / 命令合并:把 300 次 drawLine 变 3 次
2.1 合并要点
- 按绘制状态分桶:同一
strokeStyle/fillStyle/lineWidth合并到一桶; - 路径拼接:能连线就别断线,
beginPath→多次moveTo/lineTo→一次stroke; - 排序消差:尽量让状态切换发生在批次边界(
ctx.save/restore成本可观); - 禁用阴影/渐变的细碎切换:一旦开了“昂贵特性”,合并价值打折。
2.2 命令合并器(ArkTS 伪代码)
// /render/Batcher.ts
type StrokeKey = `${number}:${string}:${number}`; // lineWidth:color:alpha
interface LineSeg { x1: number; y1: number; x2: number; y2: number; }
export class Batcher {
private strokes = new Map<StrokeKey, LineSeg[]>();
private fills: Array<{ color: string; alpha: number; rect: [number,number,number,number] }> = [];
addLine(seg: LineSeg, color: string, lineWidth=1, alpha=1) {
const key: StrokeKey = `${lineWidth}:${color}:${alpha}`;
if (!this.strokes.has(key)) this.strokes.set(key, []);
this.strokes.get(key)!.push(seg);
}
addFillRect(rect: [number,number,number,number], color: string, alpha=1) {
this.fills.push({ rect, color, alpha });
}
flush(ctx: CanvasRenderingContext2D) {
// 填充合并
// 简版:直接循环;进阶可做矩形合并/覆盖裁剪
for (const f of this.fills) {
ctx.globalAlpha = f.alpha;
ctx.fillStyle = f.color;
const [x,y,w,h] = f.rect;
ctx.fillRect(x,y,w,h);
}
// 线段批量 stroke
for (const [key, segs] of this.strokes) {
const [lw, color, alpha] = key.split(':');
ctx.lineWidth = Number(lw);
ctx.strokeStyle = color;
ctx.globalAlpha = Number(alpha);
ctx.beginPath();
// 合并路径
for (const s of segs) {
ctx.moveTo(Math.round(s.x1)+0.5, Math.round(s.y1)+0.5);
ctx.lineTo(Math.round(s.x2)+0.5, Math.round(s.y2)+0.5);
}
ctx.stroke();
}
// 清空
this.strokes.clear(); this.fills = [];
}
}
小技巧:像素对齐见那句
+0.5(下文详述),细线不糊的秘密就这半个像素。
3. 离屏 / 缓存:把重活“提前干、一次干、重复用”
3.1 该缓存谁?
- 静态底图(网格、坐标轴、背景):
PixelMap/ 离屏 Canvas 一次绘制,多帧复用; - 半静态大对象(大文本段、图例):按 DPI+尺寸建缓存,key 包含 scale/主题;
- 昂贵路径(复杂 Path、圆角阴影):
Path2D或自维护顶点缓存。
3.2 离屏缓存骨架
// /render/Cache.ts
type Key = string;
export class SurfaceCache<T> {
private map = new Map<Key, { surf: T; w: number; h: number; t: number }>();
get(key: Key) { return this.map.get(key)?.surf; }
put(key: Key, surf: T, w: number, h: number) { this.map.set(key, { surf, w, h, t: Date.now() }); }
gc(max = 16) { // 最少使用淘汰
if (this.map.size <= max) return;
const arr = [...this.map.entries()].sort((a,b)=>a[1].t-b[1].t);
for (let i=0;i<arr.length-max;i++) this.map.delete(arr[i][0]);
}
}
使用示例:
// 生成网格离屏
function drawGridOffscreen(ctx: CanvasRenderingContext2D, w: number, h: number): ImageBitmap {
const off = new OffscreenCanvas(w, h);
const octx = off.getContext('2d')!;
octx.clearRect(0,0,w,h);
const batch = new Batcher();
for (let x=0; x<=w; x+=10) batch.addLine({x1:x,y1:0,x2:x,y2:h}, '#EDEDED', 1, 1);
for (let y=0; y<=h; y+=10) batch.addLine({x1:0,y1:y,x2:w,y2:y}, '#EDEDED', 1, 1);
batch.flush(octx);
return off.transferToImageBitmap(); // 或 PixelMap
}
注意:ArkTS 环境下没有标准
OffscreenCanvas也没关系,用PixelMap+drawPixelMap达到类似效果;思路一致:离屏画→主屏贴。
3.3 脏区(Dirty Rect)增量刷新
// 在控件 state 更新时,记录最小包围盒
dirty.union(rectOfChangedSeries);
requestAnimationFrame(()=> repaint(dirty.take()));
重绘时用 ctx.save(); ctx.beginPath(); ctx.rect(dirty.x, dirty.y, dirty.w, dirty.h); ctx.clip(); 限定绘制区域。
4. 文本排版与像素对齐:让字和线都“干净利落”
4.1 文本排版要点
- 测量缓存:
measureText非零成本,字符串+字体做 key 缓存宽度; - 分段排版:长段文本先断行,再批量
fillText; - 字体回退:中英混排提前设定
fontFallback,减少布局抖动; - Kerning 与字距:多数场景默认即可,禁用阴影描边的频繁切换。
4.2 像素对齐三原则
- 1px 线:整数 + 0.5 像素,避免跨像素采样模糊;
- 奇偶像素:根据
lineWidth选择+0.5或不偏移; - 缩放时:先按 DPR(设备像素比)放大画布,再按逻辑坐标绘制。
// DPR 适配
function fitDPR(canvas: HTMLCanvasElement | any, ctx: CanvasRenderingContext2D, w: number, h: number) {
const dpr = globalThis.devicePixelRatio || 1;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
5. 实战:高性能自绘图表控件(折线 + 点位 + 网格)
目标:1 万点 / 60fps(中端机)——靠批量线段、离屏网格、视口采样、文本缓存做到稳。
// /chart/HiLineChart.ets (逻辑部分,UI 容器略)
interface Point { x: number; y: number; }
interface Series { id: string; color: string; data: Point[]; visible: boolean; }
export class HiLineChart {
private series: Series[] = [];
private batch = new Batcher();
private gridCache = new SurfaceCache<ImageBitmap>();
private view = { x:0, y:0, w:0, h:0 }; // 逻辑视口
setData(series: Series[]) { this.series = series; }
setView(x:number,y:number,w:number,h:number) { this.view = {x,y,w,h}; }
render(ctx: CanvasRenderingContext2D, w:number, h:number) {
// 1) 贴网格(离屏)
const key = `${w}x${h}@grid`;
let grid = this.gridCache.get(key);
if (!grid) {
grid = drawGridOffscreen(ctx, w, h);
this.gridCache.put(key, grid, w, h);
}
(ctx as any).drawImage(grid, 0, 0);
// 2) 下采样(视口内点简化;可换成 RDP/最大三角形面积法)
for (const s of this.series) {
if (!s.visible) continue;
const simplified = this.downsample(s.data, 2); // 像素阈值
for (let i=1;i<simplified.length;i++) {
const p0 = simplified[i-1], p1 = simplified[i];
this.batch.addLine({ x1:p0.x, y1:p0.y, x2:p1.x, y2:p1.y }, s.color, 1, 1);
}
}
// 3) 一次 stroke 出去
this.batch.flush(ctx);
// 4) 选中标签(文本缓存+像素对齐)
// …略:根据 hover 点绘制文本,注意 measureText 缓存与 0.5 对齐
}
private downsample(data: Point[], px = 2): Point[] {
if (data.length <= 2) return data;
const out: Point[] = [data[0]];
let last = data[0];
for (let i=1;i<data.length;i++) {
const d = Math.hypot(data[i].x - last.x, data[i].y - last.y);
if (d >= px) { out.push(data[i]); last = data[i]; }
}
if (out[out.length-1] !== data[data.length-1]) out.push(data[data.length-1]);
return out;
}
}
如果你要“真·海量点”:把折线改为三角扇顶点缓冲配合
Canvas的drawVertices(若有)或引入WebGL/AGP。Canvas2D 仍可玩到不错的上限,但合理下采样 + 批处理是关键。
6. 性能黑魔法清单(务实版)
- 状态更改最小化:
fillStyle/strokeStyle/globalAlpha/lineWidth批次内固定; - 禁用一切默认阴影:除非确有必要,阴影是 2D 性能杀手;
- 路径复用:
Path2D复用复杂路径(地图、圆角背景); - 裁剪区域:先
clip再绘制,脏区+裁剪双管齐下; - 贴图规则:小图合并成 Atlas,减少多图绘制成本;
- 动画节流:不可见时停
requestAnimationFrame,图层隐藏即暂停; - DPR 管控:超高 DPR 设备(如 3x)场景允许动态降采样(控件内部 scale<1)。
7. 文本与像素案例(对齐演示)
// 1px 网格 + 文本基线居中
function drawAxis(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.save();
ctx.strokeStyle = '#DDD'; ctx.lineWidth = 1;
ctx.beginPath();
// y=0 轴线
ctx.moveTo(0, Math.round(h/2)+0.5);
ctx.lineTo(w, Math.round(h/2)+0.5);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.font = '12px system-ui';
ctx.textBaseline = 'middle'; // 基线居中最稳
for (let x=0; x<w; x+=50) {
ctx.fillText(`${x}`, x, Math.round(h/2)+0.5 - 10);
}
ctx.restore();
}
8. 可观测性与 A/B:优化不是“玄学”
- 帧时间分解:
beforePaint/afterPaint打点,拆出 布局/批处理/绘制耗时; - 脏区命中率:记录“重绘像素/总像素”占比;
- 缓存命中率:网格、文本、Path、Atlas 命中统计;
- A/B:合并器 on/off、离屏 on/off 做实验,比 P95 帧时间与掉帧率。
9. 常见坑 & 反制
| 坑 | 现象 | 解法 |
|---|---|---|
每次 save/restore 成本高 | 帧时间抖动 | 少用深嵌套,改成批次外部管理状态 |
| 线条模糊 | 1px 线变“灰” | 0.5 像素偏移 + DPR 适配 |
| 大图解码卡顿 | 首帧掉帧 | 惰性解码 + 离屏解码 + 占位骨架 |
| 海量点全量绘制 | 60fps→20fps | 下采样/视口采样 + 命令合并 |
| 文本测量过多 | CPU 飙升 | measureText 缓存 + 批量排版 |
10. 上线前 Checklist ✅
- 控件内部“保持+立即”混合:静态离屏、动态立即
- 批处理:状态分桶 + 路径合并 + 一次 stroke
- 脏区 + 裁剪:只画改变区域
- 离屏缓存:网格/图例/重路径;缓存带 DPI/主题 维度
- 文本缓存与断行;
textBaseline: middle、像素对齐 - DPR 适配与可选降采样
- 渲染埋点:帧时间、命中率、缓存率
- A/B 验证:关闭缓存与合并作对照
…
(未完待续)
更多推荐

所有评论(0)