我是兰瓶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 合并要点

  1. 按绘制状态分桶:同一 strokeStyle/fillStyle/lineWidth 合并到一桶;
  2. 路径拼接:能连线就别断线,beginPath→多次 moveTo/lineTo→一次 stroke;
  3. 排序消差:尽量让状态切换发生在批次边界(ctx.save/restore 成本可观);
  4. 禁用阴影/渐变的细碎切换:一旦开了“昂贵特性”,合并价值打折。

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 像素对齐三原则

  1. 1px 线整数 + 0.5 像素,避免跨像素采样模糊;
  2. 奇偶像素:根据 lineWidth 选择 +0.5 或不偏移;
  3. 缩放时:先按 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;
  }
}

如果你要“真·海量点”:把折线改为三角扇顶点缓冲配合 CanvasdrawVertices(若有)或引入 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 验证:关闭缓存与合并作对照

(未完待续)

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐