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

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。这七个字段决定模型轮廓。currentLayer 和 totalLayers 只决定哪条椭圆高亮;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. 验证矩阵、故障排查与证据边界
| 操作 | frameCount | geometryBuildCount | projectionBuildCount | 画面预期 |
|---|---|---|---|---|
| 初次显示 | 增长 | 1 | 1 | 完整轮廓 |
| 静止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() 每帧调用 buildLatheProfilePoints 和 maxLatheWallRadiusMm、固定 57/28 次采样、路径开启时额外 28 次相同高度插值,以及 Canvas 生命周期入口。这些是 live 源码事实。
值签名、模型/投影缓存、采样数组、统计字段和验证矩阵都是本文建议,未改动参考工程。本文没有运行构建、自动化基准、真机 Canvas 或系统性能工具,因此没有 CPU 降幅、帧时或电量结论。静态调用次数说明有可消除的重复计算;方案是否带来有意义的收益、缓存是否增加内存压力以及画面是否逐像素一致,仍需用同参数截图、计数器和目标设备轨迹验证。
更多推荐
所有评论(0)