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 就会循环播放。每个 item 的 duration 单位是毫秒,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张位图同时存在内存里。对于高清图片,这个开销非常恐怖。
我整理了一个简单的内存对比表,帮你直观感受一下:
| 帧数 | 单帧大小(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.Options 的 inSampleSize 做降采样。
策略二:使用 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帧的大小」。效果非常明显。
策略三:用 SurfaceView 或 TextureView 替代 ImageView
对于帧数很多、分辨率很高的动画,AnimationDrawable 本身就不太合适了。我建议切换到 SurfaceView 配合 MediaPlayer 播放视频,或者用 TextureView 配合 OpenGL 渲染。视频编码的压缩率远高于 PNG 序列,而且硬件解码效率更高。
举个例子,一个60帧的1080p动画,如果用 PNG 序列,解码后内存占用接近500MB。但如果编码成 H.264 视频,文件大小可能只有几MB,播放时内存占用也就几十MB。差距巨大。
帧动画的适用场景
说了这么多问题,那帧动画到底该不该用?我的看法是:适合短小、低帧数、低分辨率的场景。
- ✅ 适合: 加载指示器(8帧以内)、按钮点击反馈(5帧以内)、小图标动画(16x16或24x24像素)
- ❌ 不适合: 全屏引导动画、复杂角色动作、长时间循环播放
我记得有一次,产品经理要求做一个全屏的「撒花」动画,用帧动画实现。我算了一下,需要60帧高清图,内存根本扛不住。后来我用粒子系统 + 属性动画重新实现,效果更好,内存占用不到10MB。所以,选对工具很重要。
知识体系总览
下面这张图总结了帧动画的核心知识点和优化路径,你可以对照着梳理自己的思路:
避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路:
- 不要在
onCreate里直接start()—— 动画不会播放,而且不会报错。你调试半天还以为代码写错了。用View.post或者onWindowFocusChanged启动。 - 注意
oneshot的默认值 —— XML 里如果不写android:oneshot,默认是false(循环播放)。如果你只想播一次,记得显式设为true。 - 动画结束后记得
stop()—— 特别是循环动画,如果不停止,它会一直占用 CPU 和内存。我曾经在 Fragment 的onDestroyView里忘记停止动画,导致内存泄漏。 - 图片资源放在
nodpi目录 —— 帧动画的图片如果放在mdpi、hdpi等目录,系统会根据屏幕密度自动缩放。这会导致不同设备上图片大小不一致,而且缩放过程额外消耗内存。放在nodpi可以避免这个问题。
帧动画虽然简单,但用好了是利器,用不好就是炸弹。记住一句话:能用代码算出来的动画,就别用图片堆。下一章我们会聊属性动画,那才是真正灵活高效的方案。
公众号:蓝海资料掘金营,微信 deep3321