Android 交互式 Canvas 性能实战¶
本文记录 Android 蜘蛛纸牌从拖拽约 1 FPS、发牌约 24 FPS,优化到交互稳定接近 60 FPS 的完整方法。它不是“纸牌专用技巧”,而是一套适用于白板、流程图、地图、编辑器和游戏 UI 的排查框架。
先定义可复现问题¶
不要只观察右上角的瞬时 FPS。固定设备、方向、构建类型和手势,并同时记录平均帧耗时、最慢帧和慢帧数。
蜘蛛纸牌使用一条 5 秒 ADB 滑动,依次覆盖选牌、连续拖动和无效落点回弹:
脚本会构建、安装、预热、执行固定手势并解析应用输出的 DRAG_PERF。默认门槛为至少 45 FPS,最慢帧不超过 250 ms;门槛可以按目标设备等级调整:
GLSurfaceView 直接提交的帧不能只靠 gfxinfo 判断,因此样例在渲染循环内统计手势时长、渲染帧数、输入事件数、平均 CPU 帧耗时、最大 CPU 帧耗时和超过 20 ms 的帧数。
为什么“只有几十张牌”也会掉到 1 FPS¶
真正的成本不是对象数量,而是一次输入让多少缓存失效、需要重新栅格化多大的区域,以及是否在当前帧上传新纹理。
旧实现把整张桌面录成一个大 Picture,再调用 drawPictureRasterized。静止时缓存命中很快,但拖拽会改变牌列、选中态和拖拽层,使整桌纹理失效。手指每移动一次,渲染线程就可能重新绘制并上传接近全屏的纹理;30 ms 以上的首次开销连续出现后,输入和渲染互相排队,最终表现为约 1 FPS。
发牌的另一个尖峰来自错误的缓存边界:牌堆按钮的按压状态和 Undo 可用状态被计入整个顶栏的缓存签名。按下、松开各让大顶栏重建一次;同时,正在缩放的牌背仍走光栅化 Picture,动态变换不断制造缓存 miss。在旧 GLES GPU 上,单次重建曾达到 80–170 ms。
这也解释了为什么原生 Android Canvas 对照实现明显更快:对照组每帧直接使用两个共享 Bitmap 绘制 54 张牌,牌面文字另画,没有大图层缓存失效和运行时纹理上传。差距来自工作量和缓存策略,而不是“原生 Canvas 天生比 WhatsCanvas 快几十倍”。
最终渲染结构¶
一次生成
├── 全屏背景 Image
└── 共享牌面 Atlas Image(52 个牌面单元 + 牌背)
每帧
├── 绘制背景 Image
├── 绘制静态 UI
├── 从同一 Atlas 绘制可见牌
└── 最后绘制拖拽牌、发牌牌和按压反馈
核心原则如下:
- 牌只保存 rank、suit、朝向等逻辑数据,不为 52 张牌各分配一张纹理。
- 牌面在初始化时合成到一张 1024 × 1024 Atlas;运行时每张可见牌只需要一次带源矩形的
drawImage。 - 背景渐变按完整视口坐标生成一次,再全屏绘制。不要先裁剪 Picture 后继续使用原全局渐变坐标,否则径向中心会偏移。
- 手势移动只更新拖拽层。不要因为一个动态元素改变,就失效整张桌面或顶栏。
- 正在缩放、旋转或平移的元素直接从稳定 Image 绘制,不在变化的变换下建立光栅化 Picture 缓存。
- Android 宿主使用
Choreographer对齐 VSYNC,静止时RENDERMODE_WHEN_DIRTY,动画或交互期间逐帧请求绘制。
一个 Atlas 并不意味着把 52 张牌永久复制成 52 个独立资源。共用圆角底板、花色和字体生成过程可以降低初始化代码与内存;合成后只保留一张 GPU 纹理,减少纹理切换。若视觉内容允许,还可以只存共享底板、13 个点数和 4 个花色掩码,在绘制时着色组合。
如何选择缓存边界¶
| 内容 | 推荐方式 | 原因 |
|---|---|---|
| 完全静态、尺寸固定、命令很多 | drawPictureRasterized |
首次成本可被大量稳定帧摊销 |
| 命令较少或经常变化 | drawPicture 或直接绘制 |
避免纹理重建与上传 |
| 大量重复的小图元 | 共享 Image Atlas | 一张纹理、稳定源矩形、低切换成本 |
| 拖拽、缩放、吸附中的元素 | 动态层直接绘制 | 变换不污染静态缓存 |
| 只改变一个局部的复杂场景 | 按组件或小区域缓存 | 把失效范围限制在最小边界 |
drawPictureRasterized 不是通用的“加速开关”。决定使用前至少回答四个问题:缓存能命中多少帧、失效时重画多大、纹理上传多大、重建成本能否在交互前预热。若其中任何一项不确定,先测直接绘制。
帧率正常,动画仍可能不流畅¶
60 FPS 只说明渲染循环有机会按时提交,不代表动作设计合理。旧发牌和吸附动画只有 50–80 ms,理论上也只有 3–5 帧;再叠加很陡的 quintic easing,肉眼就只剩 1–3 个明显位置。吸附还使用反弹,短距离运动会显得跳跃。
样例最终使用:发牌 220 ms、发牌间隔 24 ms、落牌 170 ms、回位 150 ms,统一 easeOutCubic,发牌弧线约 4 px,吸附不反弹。动画时长和 easing 应作为 UI/UX 参数单独验证,不能用缓存优化替代。
验证结果¶
在 Mi MIX 2 参考设备上,同一条 5 秒拖拽最终测得约 59.7–59.8 FPS,平均 CPU 帧耗时约 8.15–8.83 ms,最慢帧约 21.36–23.18 ms。发牌视觉计数恢复为 60 FPS,第二轮发牌仅观察到一个约 23 ms 的边缘慢帧。
原生 Android Canvas 对照组仍有更低的 CPU onDraw 时间,因为它的绘制合同更简单;但两条路径都能持续达到屏幕刷新率。优化目标不是在不同计时边界上追求同一个毫秒数,而是证明 WhatsCanvas 路径不再有大范围缓存重建,并满足真实交互预算。
提交前检查¶
- 用固定手势复现,不靠主观“好像顺了”。
- 同时看平均值和最大值;平均 8 ms 但存在 170 ms 尖峰,体验仍然会卡。
- 在 Release/Profile 构建与目标真机上验证,Debug 只用于诊断。
- 用原生 Canvas 或最小渲染器做同工作量对照,确认宿主调度和设备上限。
- 保留像素或截图回归,确保性能提升不是通过少画、降质或改变布局获得。
- 分别测试冷启动、首次交互和预热后的稳定交互。
可运行的实现与脚本位于 platforms/android/spider。通用 API 选择见性能优化教程,常见症状见故障排查与 FAQ。