27、SurfaceView与动画:属性动画与SurfaceView、帧动画与SurfaceView、动画与渲染冲突解决、硬件加速

说实话,SurfaceView 和动画的搭配,是很多 Android 开发者的痛点。我自己带团队的时候,经常看到有人把动画写得卡成 PPT,或者画面疯狂闪烁。今天咱们就把这块硬骨头啃下来。

属性动画与 SurfaceView

属性动画(ValueAnimator / ObjectAnimator)本质上是不断改变某个对象的属性值,然后触发重绘。但 SurfaceView 的渲染机制和普通 View 不一样——它有自己的独立 Surface,在子线程里画图。

这就带来一个问题:你没法直接用 ObjectAnimator 去改变 SurfaceView 的绘制内容。因为 SurfaceView 的绘制是你在 Canvas 上画出来的,不是通过 setX/setY 这种属性驱动的。

核心思路:用属性动画驱动数值变化,然后在动画回调里手动触发 SurfaceView 的绘制。

我习惯的做法是这样的:

// 创建一个属性动画,驱动一个 float 值从 0 到 1
ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
animator.setDuration(2000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        float progress = (float) animation.getAnimatedValue();
        // 把 progress 传给 SurfaceView 的绘制线程
        mySurfaceView.setProgress(progress);
        // 通知 SurfaceView 重绘
        mySurfaceView.requestRender();
    }
});
animator.start();

这里有个细节:requestRender() 是我自己封装的方法,内部会往绘制线程的 Handler 发一个消息,触发 Canvas 绘制。千万别直接在动画回调里调用 lockCanvas(),那会阻塞 UI 线程。

小技巧:如果动画需要和 SurfaceView 的帧率同步,可以用 Choreographer 来驱动。我在做视频播放器的弹幕动画时就这么干过,效果很丝滑。

帧动画与 SurfaceView

帧动画,说白了就是快速切换图片。普通 View 用 AnimationDrawable 就能搞定,但 SurfaceView 不行——它没有 Drawable 的概念。

那怎么办?自己实现帧序列的切换逻辑。

我曾经在做一个直播礼物特效时,需要播放 60 帧的 PNG 序列。直接加载到内存?不现实,OOM 警告。我的方案是:

  1. 用 BitmapFactory.Options 的 inSampleSize 做采样加载
  2. 在绘制线程里按时间间隔切换 Bitmap
  3. 用 LRU 缓存管理已加载的帧
public class FrameAnimator {
    private List<String> framePaths;
    private int currentFrame = 0;
    private long frameDuration = 33; // 30fps
    private long lastFrameTime = 0;
    
    public void onDraw(Canvas canvas, long currentTime) {
        if (currentTime - lastFrameTime >= frameDuration) {
            currentFrame = (currentFrame + 1) % framePaths.size();
            lastFrameTime = currentTime;
            // 加载当前帧的 Bitmap
            Bitmap bitmap = loadFrame(currentFrame);
            canvas.drawBitmap(bitmap, 0, 0, null);
        }
    }
}

注意:帧动画的 Bitmap 回收时机要把握好。我见过有人每帧都 new Bitmap,结果内存暴涨。建议用对象池或者复用 Bitmap。

动画与渲染冲突解决

这是最让人头疼的部分。SurfaceView 的渲染在子线程,属性动画在主线程。两个线程同时操作同一个资源,不出问题才怪。

常见的冲突场景:

  • 动画改变 SurfaceView 的位置,同时绘制线程在画图
  • 动画缩放 SurfaceView,导致 Canvas 裁剪区域变化
  • SurfaceView 的 Surface 被销毁时,动画还在回调

我的解决思路是「加锁 + 状态检查」:

// 在 SurfaceView 的绘制线程中
synchronized (lock) {
    if (!isSurfaceValid) return;
    Canvas canvas = surfaceHolder.lockCanvas();
    // 绘制内容
    surfaceHolder.unlockCanvasAndPost(canvas);
}

// 在动画回调中
synchronized (lock) {
    // 更新位置/缩放等属性
    // 这些属性会在绘制线程中被读取
}

嗯,这里要注意:锁的粒度要尽量小。我早期犯过一个错,把整个绘制流程都锁住了,结果动画回调等锁等了几十毫秒,界面直接卡住。

最佳实践:用 volatile 关键字标记共享状态,配合 AtomicInteger 做计数器。这样大部分场景不需要显式加锁,性能更好。

硬件加速

SurfaceView 默认就是硬件加速的——它用的是独立的 SurfaceFlinger 图层。但动画部分呢?属性动画操作的是 View 的属性,这部分走的是软件绘制还是硬件加速?

说白了,SurfaceView 本身是硬件加速的,但它的子 View(如果有的话)不一定。我建议:

  • 如果动画只改变 SurfaceView 的位置/大小,用 View.setTranslationX / setScaleX 等属性,这些是硬件加速友好的
  • 如果动画需要改变绘制内容,在 SurfaceView 的 Canvas 上自己画,用 OpenGL 或者 Canvas 的硬件加速 API

举个例子,我做了一个缩放动画:

// 硬件加速的缩放
ObjectAnimator.ofFloat(surfaceView, "scaleX", 1f, 2f).start();
ObjectAnimator.ofFloat(surfaceView, "scaleY", 1f, 2f).start();

// 注意:SurfaceView 的缩放不会影响它内部的绘制内容
// 绘制内容需要自己在 Canvas 上做 Matrix 变换

你想想看,如果 SurfaceView 本身在缩放,但绘制线程还在画原始尺寸的内容,那画面就会变形。所以绘制线程里也要同步做 Matrix 变换。

经验之谈:用 HardwareLayer 可以提升动画性能。调用 surfaceView.setLayerType(View.LAYER_TYPE_HARDWARE, null),但要注意内存开销。我一般在动画开始时开启,动画结束后切回软件层。

最后,我画了一张图,帮你理清 SurfaceView 和动画的关系:

SurfaceView 动画架构图 主线程(UI Thread) 属性动画 ValueAnimator / ObjectAnimator 帧动画 Bitmap 序列切换 硬件加速 HardwareLayer / OpenGL 子线程(Render Thread) SurfaceView 绘制 lockCanvas() / unlockCanvasAndPost() Canvas 绘制 / Matrix 变换 渲染冲突 锁机制 / volatile / 状态检查 驱动 冲突 核心:动画驱动数值变化 → 子线程根据数值绘制 → 同步机制解决冲突

这张图把主线程的动画和子线程的绘制分开了。你仔细看中间的箭头——动画驱动绘制,但两者之间需要同步机制。这就是整个章节的核心逻辑。

总结一下:SurfaceView 做动画,关键是把动画的「数值变化」和「绘制逻辑」解耦。属性动画只管算数值,绘制线程只管画图。中间用线程安全的机制传递数据。硬件加速能帮你提升性能,但要注意 SurfaceView 本身的硬件加速和动画的硬件加速是两回事。

好了,这一章的内容就到这里。如果你在实际项目中遇到 SurfaceView 动画的问题,欢迎交流。