Skip to content

Android 交互式 Canvas 性能实战

本文记录 Android 蜘蛛纸牌从拖拽约 1 FPS、发牌约 24 FPS,优化到交互稳定接近 60 FPS 的完整方法。它不是“纸牌专用技巧”,而是一套适用于白板、流程图、地图、编辑器和游戏 UI 的排查框架。

先定义可复现问题

不要只观察右上角的瞬时 FPS。固定设备、方向、构建类型和手势,并同时记录平均帧耗时、最慢帧和慢帧数。

蜘蛛纸牌使用一条 5 秒 ADB 滑动,依次覆盖选牌、连续拖动和无效落点回弹:

cd platforms/android/spider
.\verify_drag_performance.ps1

脚本会构建、安装、预热、执行固定手势并解析应用输出的 DRAG_PERF。默认门槛为至少 45 FPS,最慢帧不超过 250 ms;门槛可以按目标设备等级调整:

.\verify_drag_performance.ps1 -MinimumFps 50 -MaximumFrameMs 200 -SkipBuild

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 路径不再有大范围缓存重建,并满足真实交互预算。

提交前检查

  1. 用固定手势复现,不靠主观“好像顺了”。
  2. 同时看平均值和最大值;平均 8 ms 但存在 170 ms 尖峰,体验仍然会卡。
  3. 在 Release/Profile 构建与目标真机上验证,Debug 只用于诊断。
  4. 用原生 Canvas 或最小渲染器做同工作量对照,确认宿主调度和设备上限。
  5. 保留像素或截图回归,确保性能提升不是通过少画、降质或改变布局获得。
  6. 分别测试冷启动、首次交互和预热后的稳定交互。

可运行的实现与脚本位于 platforms/android/spider。通用 API 选择见性能优化教程,常见症状见故障排查与 FAQ。