3、View动画之帧动画:AnimationDrawable的使用、帧动画的优化与内存管理

帧动画,说白了就是「翻书动画」。你把一张张图片按顺序排好,快速切换播放,人眼就感觉画面动起来了。在Android早期,帧动画是很多App的标配——加载中的小菊花、点赞的跳动效果、甚至一些简单的游戏角色动作,都用它来实现。

我个人习惯把帧动画叫做「穷人的动画方案」。为什么这么说?因为它实现简单,但代价不小。你想想看,每一帧都是一张完整的图片,播放起来就是不停地解码、加载、显示。内存压力可想而知。

AnimationDrawable 的基本用法

先来看看怎么用。Android 提供了 AnimationDrawable 这个类,专门用来管理帧动画。使用方式有两种:XML 定义和代码创建。

XML 方式定义

res/drawable 目录下创建一个 XML 文件,比如 loading_animation.xml

<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="false">
    <item android:drawable="@drawable/frame_01" android:duration="100" />
    <item android:drawable="@drawable/frame_02" android:duration="100" />
    <item android:drawable="@drawable/frame_03" android:duration="100" />
    <item android:drawable="@drawable/frame_04" android:duration="100" />
    <item android:drawable="@drawable/frame_05" android:duration="100" />
</animation-list>

这里 oneshot 控制是否只播放一次。设为 false 就会循环播放。每个 itemduration 单位是毫秒,100ms 就是每秒10帧,算是一个比较流畅的节奏。

然后在布局里引用:

<ImageView
    android:id="@+id/loading_view"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/loading_animation" />

最后在代码里启动:

val loadingView = findViewById<ImageView>(R.id.loading_view)
val animDrawable = loadingView.drawable as AnimationDrawable
animDrawable.start()

嗯,这里要注意:start() 不能在 onCreate 里直接调用。因为 AnimationDrawable 在窗口还未完全初始化时无法正常播放。我建议在 onWindowFocusChanged 或者使用 View.post 来启动。

代码方式创建

有时候图片资源是动态的,比如从网络下载的帧序列,那就得用代码创建:

val animDrawable = AnimationDrawable().apply {
    addFrame(ContextCompat.getDrawable(this@MainActivity, R.drawable.frame_01), 100)
    addFrame(ContextCompat.getDrawable(this@MainActivity, R.drawable.frame_02), 100)
    addFrame(ContextCompat.getDrawable(this@MainActivity, R.drawable.frame_03), 100)
    isOneShot = false
}

loadingView.setImageDrawable(animDrawable)
animDrawable.start()

两种方式本质一样,XML 更清晰,代码更灵活。我个人偏向用 XML 定义静态帧动画,代码方式留给动态场景。

帧动画的内存问题

帧动画最大的坑,就是内存。我曾经在一个项目中用帧动画做引导页的过渡效果,总共30帧,每帧是一张1080p的PNG图片。你猜怎么着?直接 OOM 了。

为什么会这样?因为 AnimationDrawable 在加载时,会把所有帧的 Drawable 对象都创建出来,并持有它们的引用。每一帧都是一张位图,30帧就是30张位图同时存在内存里。对于高清图片,这个开销非常恐怖。

⚠️ 注意: AnimationDrawable 不会自动回收未显示的帧。所有帧从创建到动画结束,一直驻留在内存中。如果你有20帧以上,每帧超过200KB,总内存就可能超过4MB。对于低端机,这已经是危险信号了。

我整理了一个简单的内存对比表,帮你直观感受一下:

帧数 单帧大小(PNG) 解码后内存占用 风险等级
10帧 50KB 约2MB
20帧 100KB 约8MB
30帧 200KB 约24MB
60帧 500KB 约120MB 极高(必OOM)

注意,这里的「解码后内存占用」远大于PNG文件大小。因为PNG是压缩格式,解码成位图后,每个像素占4字节(ARGB_8888)。一张1080x1920的图片,解码后就是 1080 × 1920 × 4 ≈ 8MB。30帧就是240MB。你说崩不崩?

优化策略:我踩过的坑和解决方案

既然问题这么明显,那怎么优化?我这些年总结了几条实战经验。

策略一:减少帧数和缩小尺寸

最直接的办法。如果你的动画是循环的,比如旋转加载,其实8到12帧就够了。人眼对24fps以上就感觉流畅,12fps在大多数场景下也能接受。另外,图片尺寸不要用原始分辨率。ImageView 显示多大,图片就缩放到多大。用 BitmapFactory.OptionsinSampleSize 做降采样。

策略二:使用 BitmapPool 复用内存

这是比较高级的做法。原理很简单:不再为每一帧创建新的 Bitmap,而是从池子里取一块复用。播放完一帧后,把 Bitmap 还回池子,下一帧继续用同一块内存。

我曾在项目中用 LruCache 实现过一个简单的 BitmapPool:

class FrameBitmapPool(maxSize: Int) {
    private val cache = LruCache<Int, Bitmap>(maxSize)

    fun get(width: Int, height: Int, config: Bitmap.Config): Bitmap? {
        // 从缓存中取一个可复用的 Bitmap
        val cached = cache.get(width * height)
        if (cached != null && cached.width == width && cached.height == height) {
            cache.remove(width * height)
            return cached
        }
        return null
    }

    fun put(bitmap: Bitmap) {
        if (bitmap.isMutable) {
            cache.put(bitmap.width * bitmap.height, bitmap)
        }
    }
}

然后在播放帧动画时,每一帧解码前先从池子里取,解码后把旧的还回去。这样内存占用就从「帧数 × 单帧大小」降到了「2到3帧的大小」。效果非常明显。

💡 小技巧: 如果你的帧动画是固定序列,可以考虑把所有帧合并成一张「雪碧图」(Sprite Sheet)。然后用 Canvas 裁剪显示不同区域。这样只需要一张大图,内存占用降到最低。不过实现起来稍微复杂一些。

策略三:用 SurfaceViewTextureView 替代 ImageView

对于帧数很多、分辨率很高的动画,AnimationDrawable 本身就不太合适了。我建议切换到 SurfaceView 配合 MediaPlayer 播放视频,或者用 TextureView 配合 OpenGL 渲染。视频编码的压缩率远高于 PNG 序列,而且硬件解码效率更高。

举个例子,一个60帧的1080p动画,如果用 PNG 序列,解码后内存占用接近500MB。但如果编码成 H.264 视频,文件大小可能只有几MB,播放时内存占用也就几十MB。差距巨大。

帧动画的适用场景

说了这么多问题,那帧动画到底该不该用?我的看法是:适合短小、低帧数、低分辨率的场景

  • ✅ 适合: 加载指示器(8帧以内)、按钮点击反馈(5帧以内)、小图标动画(16x16或24x24像素)
  • ❌ 不适合: 全屏引导动画、复杂角色动作、长时间循环播放

我记得有一次,产品经理要求做一个全屏的「撒花」动画,用帧动画实现。我算了一下,需要60帧高清图,内存根本扛不住。后来我用粒子系统 + 属性动画重新实现,效果更好,内存占用不到10MB。所以,选对工具很重要。

知识体系总览

下面这张图总结了帧动画的核心知识点和优化路径,你可以对照着梳理自己的思路:

帧动画知识体系 AnimationDrawable XML 定义 代码创建 内存占用过高 优化策略 减少帧数 / 缩小尺寸 BitmapPool 复用 SurfaceView / 视频 适用场景:短小、低帧数、低分辨率

避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路:

  • 不要在 onCreate 里直接 start() —— 动画不会播放,而且不会报错。你调试半天还以为代码写错了。用 View.post 或者 onWindowFocusChanged 启动。
  • 注意 oneshot 的默认值 —— XML 里如果不写 android:oneshot,默认是 false(循环播放)。如果你只想播一次,记得显式设为 true
  • 动画结束后记得 stop() —— 特别是循环动画,如果不停止,它会一直占用 CPU 和内存。我曾经在 Fragment 的 onDestroyView 里忘记停止动画,导致内存泄漏。
  • 图片资源放在 nodpi 目录 —— 帧动画的图片如果放在 mdpihdpi 等目录,系统会根据屏幕密度自动缩放。这会导致不同设备上图片大小不一致,而且缩放过程额外消耗内存。放在 nodpi 可以避免这个问题。

帧动画虽然简单,但用好了是利器,用不好就是炸弹。记住一句话:能用代码算出来的动画,就别用图片堆。下一章我们会聊属性动画,那才是真正灵活高效的方案。


公众号:蓝海资料掘金营,微信 deep3321