茶器艺科智造HarmonyOS应用实战-55-Canvas每50ms重算整条轮廓,参数没变也在耗CPU:把几何缓存和动画相位分开

切片预览要有轻微呼吸感,动画真正变化的只是相位:椭圆半径叠加一个正弦偏移,路径角度随 phase 转动。茶器艺科智造当前每 50ms 调用一次 drawFrame(),却在每帧重新生成杯体轮廓、扫描最大半径,并反复插值固定高度的半径。参数静止时,这些几何结果没有变化。把模型几何、屏幕投影和动画相位拆成三层,就能在保持画面的同时减少重复计算,并为性能验证建立清晰计数。

Canvas几何缓存主题封面

1. 实际问题:20fps名义节奏驱动了整套几何流程

SlicePreviewCanvas.ets 使用 setInterval,间隔 50ms;每次把 phase 增加 0.03 后调用 drawFrame()。50ms 对应名义 20 帧每秒,但系统调度、页面负载和后台策略都会影响实际频率,本文没有把它当成设备实测帧率。

private phase: number = 0
private timerId: number = -1

private startLoop(): void {
  this.stopLoop()
  this.timerId = setInterval((): void => {
    this.phase += 0.03
    this.drawFrame()
  }, 50) as number
}

private stopLoop(): void {
  if (this.timerId >= 0) {
    clearInterval(this.timerId)
    this.timerId = -1
  }
}

参数 Watch、onDidBuild、Canvas ready/appear 和区域变化也会触发绘制;onDisAppear 会停止定时器。当前生命周期有基本启停,但每次绘制内部没有几何缓存。

2. 源码定位:drawFrame每次都生成pts并扫描rMax

drawFrame() 先读取画布尺寸与杯体高度,然后调用 buildLatheProfilePoints(this.cupLathe),再调用 maxLatheWallRadiusMm(pts)。随后轮廓线取 57 个采样点,切片取 28 个采样点;打开路径视图时,14 条线各自又查询 12% 和 88% 高度。

const layersDraw = 28
const hMm = this.cupLathe.heightMm
const pts = buildLatheProfilePoints(this.cupLathe)
const rMaxMm = maxLatheWallRadiusMm(pts)

const profSteps = 56
for (let i = 0; i <= profSteps; i++) {
  const yMm = (i / profSteps) * hMm
  const rMm = interpOuterRadiusAtYMm(
    yMm,
    pts,
    hMm,
    this.cupLathe.diameterMm
  )
}

for (let k = 0; k < layersDraw; k++) {
  const yMm = (k / (layersDraw - 1)) * hMm
  const rMm = interpOuterRadiusAtYMm(
    yMm,
    pts,
    hMm,
    this.cupLathe.diameterMm
  )
}

静态计数为:不开路径时每帧 57+28=85 次插值;开路径时再加 14×2=28 次,共 113 次。每次插值会遍历轮廓点查找区间。计数说明重复工作存在,却不能直接换算成 CPU 百分比或耗电量。

当前逐帧重算与缓存后绘制流程

3. 先划依赖边界:什么变化才需要重建几何

buildLatheProfilePoints() 实际读取 heightMm、diameterMm、mouthMm、bottomMm、waistPct、presetKey,并对结果应用 waveLevel。这七个字段决定模型轮廓。currentLayertotalLayers 只决定哪条椭圆高亮;showPaths 只决定是否画路径;phase 只决定脉动和路径角度。

模型几何键
  heightMm, diameterMm, mouthMm, bottomMm,
  waistPct, waveLevel, presetKey
  -> pts, rMaxMm, 57个轮廓半径, 28个切片半径,
     路径12%与88%半径

屏幕投影键
  canvas width, canvas height, 模型几何revision
  -> cx, baseY, stackH, maxRpx, heightStretch,
     可选的屏幕坐标数组

逐帧状态
  phase, currentLayer / totalLayers, showPaths
  -> 正弦偏移、高亮判断、路径旋转、实际绘制

这张依赖图决定失效策略。把 currentLayer 放进几何键会造成滑动时无谓重建;漏掉 presetKey 或 waveLevel 则会复用错误轮廓。缓存优化首先是正确性设计,其次才是性能技巧。

4. 建议数据结构:用值签名,不依赖对象引用

cupLathe 是可观察对象,同一个对象实例内的字段会变化,因此不能用对象引用作为缓存键。建议创建不可变的值签名,并保存模型 revision。对数值应先做有限性与业务规范化,再生成稳定 key;不要随意舍入,否则两个真实不同参数可能碰撞。

interface SliceGeometryKey {
  heightMm: number
  diameterMm: number
  mouthMm: number
  bottomMm: number
  waistPct: number
  waveLevel: number
  presetKey: string
}

function geometryKeyOf(
  cup: ChaqiCupLatheParams
): string {
  const values: Array<number | string> = [
    cup.heightMm,
    cup.diameterMm,
    cup.mouthMm,
    cup.bottomMm,
    cup.waistPct,
    cup.waveLevel,
    cup.presetKey
  ]
  return values.join('|')
}

若页面允许 NaN 或 Infinity 进入参数,应在状态入口规范化,而不是让 join 把非法值稳定缓存。若参数是连续滑条,几何会在用户拖动时频繁失效,这是正确行为;防抖重建可以改善拖动性能,但会让预览滞后,需要产品决定。

5. 一次重建,缓存所有固定采样

模型 key 变化时才生成 pts、最大半径和三组采样。路径当前在 14 次循环中反复查询同样的 12%/88% 高度,缓存两个值就够。数组由缓存所有者独占,绘制阶段不修改。

interface SliceGeometryCache {
  key: string
  revision: number
  points: LatheProfPt[]
  maxRadiusMm: number
  profileRadiiMm: number[]
  layerRadiiMm: number[]
  pathInnerRadiusMm: number
  pathOuterRadiusMm: number
}

private rebuildGeometryIfNeeded(): void {
  const key = geometryKeyOf(this.cupLathe)
  if (this.geometryCache !== undefined &&
      this.geometryCache.key === key) {
    return
  }

  const pts = buildLatheProfilePoints(this.cupLathe)
  const hMm = this.cupLathe.heightMm
  const diameterMm = this.cupLathe.diameterMm
  const radiusAt = (u: number): number =>
    interpOuterRadiusAtYMm(
      u * hMm,
      pts,
      hMm,
      diameterMm
    )

  this.geometryRevision += 1
  this.geometryCache = {
    key,
    revision: this.geometryRevision,
    points: pts,
    maxRadiusMm: maxLatheWallRadiusMm(pts),
    profileRadiiMm: sample01(56, radiusAt),
    layerRadiiMm: sample01(27, radiusAt),
    pathInnerRadiusMm: radiusAt(0.12),
    pathOuterRadiusMm: radiusAt(0.88)
  }
}

示例中的 sample01(lastIndex, fn) 应返回从 0 到 lastIndex 共 lastIndex+1 个值,所以 56 对应 57 点、27 对应 28 点。明确参数语义可以避免 off-by-one。若库函数返回的 pts 后续会被改写,应在缓存构造时复制或冻结。

几何缓存、投影缓存与动画相位结构图

6. 绘制阶段只读缓存,让phase负责动画

drawFrame() 先确保缓存有效,再从数组取半径。轮廓和切片的 u 仍由索引计算;phase 只出现在视觉偏移。这样参数不变的连续帧不会调用轮廓生成和插值函数。

private drawFrame(): void {
  if (this.wPx <= 0 || this.hPx <= 0) {
    return
  }
  this.rebuildGeometryIfNeeded()
  const cache = this.geometryCache
  if (cache === undefined) {
    return
  }

  this.clearCanvas()
  const projection = this.projectionOf(
    cache.revision,
    this.wPx,
    this.hPx
  )

  this.drawProfile(
    cache.profileRadiiMm,
    cache.maxRadiusMm,
    projection
  )
  this.drawLayers(
    cache.layerRadiiMm,
    cache.maxRadiusMm,
    projection,
    this.phase,
    this.currentLayer,
    this.totalLayers
  )
  if (this.showPaths) {
    this.drawPaths(
      cache.pathInnerRadiusMm,
      cache.pathOuterRadiusMm,
      cache.maxRadiusMm,
      projection,
      this.phase
    )
  }
}

是否缓存屏幕坐标要看测量结果。模型插值通常比乘加投影更值得先缓存;如果 Canvas 尺寸不变,进一步缓存 profile 的 px/py 和切片基础 rx/y 也合理。不要一开始保存每个可能的 phase 结果,那会增加内存并失去动画连续性。

7. 失效入口:参数、尺寸、绘制状态各走各的路

onPropsChange() 当前覆盖 totalLayers、currentLayer、showPaths 等 prop,同时 cupLathe 由 ObjectLink 变化触发构建/绘制。建议统一调用 drawFrame(),由值 key 判断几何是否失效;尺寸变化只清投影缓存;phase 变化两者都不清。

private onPropsChange(): void {
  // total/current/showPaths 变化通常只需重绘
  this.drawFrame()
}

private onCanvasAreaChange(
  widthPx: number,
  heightPx: number
): void {
  if (widthPx === this.wPx &&
      heightPx === this.hPx) {
    return
  }
  this.wPx = widthPx
  this.hPx = heightPx
  this.projectionCache = undefined
  this.drawFrame()
}

private onAnimationFrame(): void {
  this.phase = (this.phase + 0.03) %
    (Math.PI * 2)
  this.drawFrame()
}

phase 取模避免数值长期增大,但改变后的瞬间应保持视觉连续。onDidBuild() 可能在频繁状态更新中被多次调用,应确认是否确有必要;即便保留,缓存也能避免几何重建。startLoop 仍应保证只有一个定时器,onDisAppear 继续 stop。

若动画不可见,应暂停定时器;如果系统提供减少动态效果设置,还应允许关闭脉动并只在参数变化时绘制。本文重点是缓存,不把可见性协议的全部细节重复到这一篇。

8. 用计数和分段耗时验证,不只看“感觉更流畅”

建议为调试构建增加计数器:frameCount、geometryBuildCount、interpolationCount、projectionBuildCount,以及每段累计耗时。参数静止 10 秒时,目标是 frameCount 增长而 geometryBuildCount 保持 1;改变一个几何参数后只增加 1;改变 currentLayer 不增加几何次数。

interface SliceRenderStats {
  frameCount: number
  geometryBuildCount: number
  interpolationCount: number
  projectionBuildCount: number
  geometryTotalMs: number
  drawTotalMs: number
}

private rebuildGeometryIfNeeded(): void {
  if (this.cacheKeyMatches()) {
    return
  }
  const startedAt = Date.now()
  this.buildGeometryCache()
  this.stats.geometryBuildCount += 1
  this.stats.geometryTotalMs +=
    Date.now() - startedAt
}

Date.now() 精度可能不足以测单帧微小片段,可用目标平台支持的高精度计时接口;示例只表达分段方法。日志要按窗口汇总,例如每 10 秒一条,不能每帧打印而污染被测性能。

9. 验证矩阵、故障排查与证据边界

操作frameCountgeometryBuildCountprojectionBuildCount画面预期
初次显示增长11完整轮廓
静止10秒持续增长保持1保持1脉动继续
修改高度增长+1+1或按实现轮廓更新
修改presetKey增长+1+1杯型切换
修改waveLevel增长+1+1肌理变化
切换currentLayer增长不变不变高亮移动
切换showPaths增长不变不变路径显隐
改变Canvas尺寸增长不变+1比例正确
页面离开停止不变不变无后台绘制
再次进入且参数未变恢复可复用或按生命周期重建一次按尺寸无旧尺寸残留
现象优先核对可能原因处理方向
静止时仍重建几何key 与 build 计数Watch 直接清了缓存由值签名决定失效
改预设后轮廓不变key 字段漏掉 presetKey覆盖函数实际读取字段
改波纹后复用旧结果key 字段漏掉 waveLevel加入模型签名
调层数时几何次数暴涨依赖分类totalLayers 被放进模型 key仅重绘高亮
缩放后画面偏移投影 revision尺寸变化未清投影单独失效屏幕层
优化后路径半径错误12%/88%采样sample01 索引语义混乱为采样数量写断言
CPU未明显下降分段耗时和其他动画瓶颈在 Canvas 绘制或星空用轨迹继续定位
页面离开仍计数timerId生命周期未停止核对 onDisAppear 与可见性

本文静态核对了当前 50ms interval、phase 增量、drawFrame() 每帧调用 buildLatheProfilePointsmaxLatheWallRadiusMm、固定 57/28 次采样、路径开启时额外 28 次相同高度插值,以及 Canvas 生命周期入口。这些是 live 源码事实。

值签名、模型/投影缓存、采样数组、统计字段和验证矩阵都是本文建议,未改动参考工程。本文没有运行构建、自动化基准、真机 Canvas 或系统性能工具,因此没有 CPU 降幅、帧时或电量结论。静态调用次数说明有可消除的重复计算;方案是否带来有意义的收益、缓存是否增加内存压力以及画面是否逐像素一致,仍需用同参数截图、计数器和目标设备轨迹验证。

Logo

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

更多推荐