简介:面向Android开发者的序列帧动画控件Android-ImageFrame,专注解决大量图片连续播放时的内存峰值与卡顿问题,适合游戏、加载动画及交互反馈等场景,也是中高级开发者研究内存优化的良好范例。资源共289个文件,包含222张png序列帧素材、40个xml配置或资源文件、10个java核心源码以及gradle构建脚本等,压缩包约10.64MB,工程目录清晰,便于定位控件实现与示例。已有873位开发者学习下载。控件通过内存复用机制只保留当前显示帧的图片数据,切换下一帧时重用已解码内存,显著降低内存峰值;同时支持本地文件与资源目录两种数据来源,并提供了简洁API控制播放速度与循环次数。压缩包内附完整源码,读者可研读Mr-wangyong-ImageFrame-7881873工程,深入理解延迟加载与缓存复用策略,掌握优化序列帧动画加载性能的实践方法。
1. 序列帧动画的省内存难题:为什么 60 帧 1080P 能吃掉 480MB 内存
做直播礼物、引导动画、开屏特效时,设计师给的素材通常是整套序列帧 PNG,播放器把帧图按顺序切完形成动画,这就是 Android-ImageFrame 这类控件要管的活。它要解决的核心问题只有一个:怎么让几十上百帧的大图动画在中端机上播完而不被系统杀掉。一帧 1920×1080、ARGB_8888 格式的位图固定占 8MB 内存,60 帧就是 480MB,这个数字还没算页面自身的开销,手机播到一半基本就会因内存压力被回收。省内存的做法不是砍帧数、砍分辨率,而是让帧图按需解码、内存池化复用、缓存按 LRU 淘汰,峰值占用只保留即将显示的那一小段序列。适合帧数多、节奏固定、希望点开瞬间流畅播完、又不值得为此引入视频播放方案的业务场景。
2. 把序列帧的内存账算明白:分辨率、色彩格式与采样率如何决定峰值占用
序列帧的一切优化都围绕一张公式展开:位图内存 = 宽 × 高 × 每像素字节数。1920×1080 的 PNG 文件可能只有 200KB,解码后变成 8MB,因为 PNG 是压缩存储,解码后按像素展开,文件大小跟内存没有直接关系。很多开发者第一次在 Profiler 里看到内存飙升,第一反应是“我一个小动画怎么可能吃几百兆”,算完这张公式就明白了。所以评估一个序列帧控件是否省内存,本质是看它有没有控制公式里的三个变量:显示尺寸、色彩格式、帧缓存数量。
2.1 位图内存公式与 inSampleSize 的降采样逻辑
BitmapFactory.Options 里的 inSampleSize 按整数倍跳过像素解码。inSampleSize=2 时,宽高各减半,内存变成四分之一;inSampleSize=4 时只剩十六分之一。做法是解码前先用 inJustDecodeBounds=true 读一次宽高,根据 View 的目标尺寸算出采样率,再真正解码。这里的 targetWidth 和 targetHeight 不要直接填 View 的 dp 数值,要转成像素再乘 1.5 的安全余量,因为低采样率下细线和文字容易闪边,留余量能减少画面瑕疵。采样率同时要取 2 的幂次,1.8 取 2、3.2 取 4,这样解码器内部走最快路径。
BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inJustDecodeBounds = true; BitmapFactory.decodeFile(path, opts); int sampleSize = 1; while (opts.outWidth / sampleSize > targetWidth || opts.outHeight / sampleSize > targetHeight) { sampleSize *= 2; } opts.inJustDecodeBounds = false; opts.inSampleSize = sampleSize; Bitmap frame = BitmapFactory.decodeFile(path, opts);这段代码是标准的大图加载套路。第一次 decodeFile 只读文件头,不分配像素内存;第二次才算真正解码。inJustDecodeBounds 阶段几乎没有内存开销,可以对每一帧都先探测再解码。真正解码时如果配合 inBitmap 复用,还要保证复用位图宽高不小于新解码尺寸,否则系统会静默忽略 inBitmap 而不是报错,这个坑在第 5 章还会展开。先留一个印象:采样率和 inBitmap 是配套工作的,只设其中一个,内存优化效果会少一大半。
2.2 三种播放方案对比:全量预加载、按需解码、按需解码+复用
实际项目里常见的序列帧播放方案有三种。第一种是全量预加载:进入页面把所有帧 decode 进内存,播放时 setImageBitmap 直接切,切换零延迟,但内存峰值等于帧数乘以单帧内存,60 帧 1080P 就是 480MB,只适合帧数不超过十几帧、分辨率偏低的按钮动效。第二种是按需解码:播放到第 i 帧才 decode 第 i 帧,驻留内存只有当前帧,但每次解码都重新分配超大位图,GC 频繁触发,解码耗时一旦超过帧间隔就直接掉帧,低端机上体验很差,只适合帧间隔很大的场景。第三种是把按需解码、LRU 缓存、inBitmap 复用组合起来:缓存池记住最近播过的帧,循环播放时命中缓存直接返回;缓存淘汰的帧不丢给 GC,而是放回复用队列,当作下一次解码的 inBitmap 缓冲,内存峰值约等于缓存上限加当前正在解码的一帧。
| 方案 | 峰值内存 | 首帧延迟 | 循环播放表现 | 适合场景 |
|---|---|---|---|---|
| 全量预加载 | 帧数×单帧内存 | 低 | 最佳 | 帧数少、分辨率低 |
| 按需解码 | 单帧+解码瞬间峰值 | 高 | 每帧都解码,容易卡 | 帧间隔大的动画 |
| 按需解码+复用 | 缓存上限+单帧 | 中 | 命中缓存不掉帧 | 帧数多、通用场景 |
第三种方案唯一的新增成本是缓存池和复用队列的代码复杂度,换来的是内存从线性增长变成有上限的常量。中端机上如果动画超过 40 帧,几乎只能选第三种;素材小、帧数低于 20 帧,直接全量预加载反而更简单。
2.3 RGB_565、ARGB_8888 与 Hardware Bitmap 的取舍边界
每像素字节数是公式中的第二个乘法因子。ARGB_8888 占 4 字节,RGB_565 占 2 字节,换成 565 直接省一半内存,代价是 alpha 通道丢失,半透明边缘会发黑发紫,白底不透明的动画可以放心用,透明通道素材就得掂量一下。还有一种常见做法是先按 ARGB_8888 解码,再画到 RGB_565 目标位图上,首帧瞬间的峰值内存没省,但常驻内存降了一半,适合首帧内存能扛、循环播放内存想降的场景。
另一个选择是 Hardware Bitmap。API 26 之后可以按硬件位图解码,位图只存在 GPU 显存,Java 堆和 native 堆都看不到它,播放 60 帧曲线也不会涨。但它有两个硬限制:CPU 侧不能读像素,不能参与 inBitmap 复用。序列帧如果只是绘制不改写,可以拿它做单独通道,但控件的复用池逻辑要绕过它。整体取舍是:内存压力在 Java 堆就选 inBitmap 复用;设备显存小的机型要考虑 GPU 压力,谨慎启用硬件位图。
3. 从零搭一个省内存播放内核:缓存池、按需解码与帧率驱动分工
控件内部拆成三块:FrameCachePool 管已解码帧缓存和复用队列;FrameDecoder 负责从 assets 里按需解码;PlaybackEngine 决定什么时候切帧、切到哪一帧。职责边界清楚后,内存策略只改 Cache,帧率策略只改 Engine,解码策略只改 Decoder,各自可以单独测试和 Profile。这也是这个方案适合长期维护的原因,而不是把一堆逻辑塞进自定义 View 的 onDraw 里。
3.1 FrameCachePool:LruCache 管显式缓存,淘汰帧进复用队列
缓存池是省内存的核心。LruCache 的容量按字节而不是帧数计算,不同分辨率设备上的帧数表现一致;容量上限由 PlayConfig 传入。当缓存达到上限,最久未使用的帧被淘汰,entryRemoved 回调里把 Bitmap 放进复用队列而不是丢掉,只要它还是 mutable 的。复用队列用 ArrayDeque,先进先出,最近淘汰的帧优先复用,内存碎片少一些。
public class FrameCachePool { private final LruCache<Integer, Bitmap> cache; private final ArrayDeque<Bitmap> reusablePool; public FrameCachePool(int maxSizeBytes) { cache = new LruCache<Integer, Bitmap>(maxSizeBytes) { @Override protected int sizeOf(Integer key, Bitmap value) { // 用像素内存计算容量,而不是按帧数量 return value.getByteCount(); } @Override protected void entryRemoved(boolean evicted, Integer key, Bitmap oldValue, Bitmap newValue) { // 淘汰的帧不丢给 GC,送进复用队列当 inBitmap 缓冲 if (oldValue.isMutable()) { reusablePool.offer(oldValue); } } }; reusablePool = new ArrayDeque<>(); } public Bitmap get(int index) { return cache.get(index); } public void put(int index, Bitmap bitmap) { cache.put(index, bitmap); } public Bitmap obtainReusable() { return reusablePool.poll(); } public void clear() { cache.evictAll(); while (!reusablePool.isEmpty()) { reusablePool.poll().recycle(); } } }sizeOf 用 getByteCount 而不是 getRowBytes,因为 getByteCount 已经包含 padding 和全分辨率占用,按它统计容量更贴近设备真实内存消耗。API 19 之前没有 getByteCount,旧项目用 getRowBytes() * getHeight() 代替。复用队列里的 Bitmap 不能直接拿来绘制,它们是解码缓冲;当缓存命中时优先返回缓存里的帧,只有缓存 miss 才走解码。clear 方法必须在 View 销毁时调用,否则几十张大位图全滞留到 GC 才被回收。
3.2 FrameDecoder:inSampleSize 和 inBitmap 组合使用
解码器的工作没有太多花样:确认帧文件路径,按全局采样率解码,尽量用复用池的缓冲。inBitmap 能不能生效,关键在 API 版本和尺寸匹配。API 19 之前要求复用位图宽高和新解码尺寸完全一致;API 19 之后要求新图尺寸不超过复用位图即可。所以在 decode 之前要主动检查宽高,不满足就直接放弃复用,避免系统静默忽略。
public Bitmap decodeFrame(int index) { String path = getFramePath(frameDir, index); // 先读边界,序列帧素材尺寸一致时,可以缓存这份 bounds BitmapFactory.Options bounds = new BitmapFactory.Options(); bounds.inJustDecodeBounds = true; BitmapFactory.decodeFile(path, bounds); Bitmap reusable = cachePool.obtainReusable(); BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inPreferredConfig = Bitmap.Config.ARGB_8888; opts.inSampleSize = sampleSize; opts.inMutable = true; if (reusable != null && !reusable.isRecycled()) { // 按采样率计算实际解码尺寸,复用位图必须比它大,否则 inBitmap 被忽略 int decodedWidth = (bounds.outWidth + sampleSize - 1) / sampleSize; int decodedHeight = (bounds.outHeight + sampleSize - 1) / sampleSize; if (reusable.getWidth() >= decodedWidth && reusable.getHeight() >= decodedHeight) { opts.inBitmap = reusable; } } Bitmap frame = BitmapFactory.decodeFile(path, opts); if (frame == null) { // 解码失败时回收复用缓冲,去掉 inBitmap 重试一次 if (opts.inBitmap != null && !opts.inBitmap.isRecycled()) { opts.inBitmap.recycle(); } opts.inBitmap = null; frame = BitmapFactory.decodeFile(path, opts); } return frame; }这里有一个并发隐患:复用队列里的 Bitmap 如果还在屏幕上绘制,解码线程却把它拿去做 inBitmap,解码完成后画面内容会被写坏。规避手段是双缓冲,引擎层维护 front 和 back 两个槽位,永远不把正在显示的 frontFrame 归还给复用队列;只有 backFrame 被替换下来之后,才允许解码线程拿它去解码。这个约定看起来简单,实际工程里八成的帧动画 bug 都出在这里,要么少了归还时机,要么把显示中的帧提前复用,导致画面闪动或花屏。
3.3 PlaybackEngine:Choreographer 对齐帧间隔,双缓冲避免解码卡帧
播放引擎用 Choreographer 对齐垂直同步,比线程 sleep 稳。sleep 方式在系统刷新率不是 60Hz、或者 GC 停顿频繁时会产生累计误差;Choreographer 的 doFrame 跟界面刷新同步,每次回调都拿到系统刷新时间戳,误差可以忽略。引擎内部维护两个引用:frontFrame 是当前显示,backFrame 是解码线程刚准备好的下一帧,doFrame 里做交换和 invalidate,然后请求解码 currentIndex+1。
private final Choreographer.FrameCallback frameCallback = new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { if (!isPlaying) return; long nowMs = System.currentTimeMillis(); if (nowMs - lastSwapMs >= frameIntervalMs) { // 交换前后缓冲,backFrame 已经是解码好的下一帧 Bitmap tmp = frontFrame; frontFrame = backFrame; backFrame = tmp; lastSwapMs = nowMs; currentIndex = frontFrameIndex; invalidate(); decoder.requestDecode(currentIndex + 1); } Choreographer.getInstance().postFrameCallback(this); } };交换只是引用赋值,不拷贝像素,成本可以忽略。requestDecode 是异步的,解码完成后回调里把结果放进 backFrame 槽位。双缓冲的额外成本是一张位图的常驻内存,换来的是解码不阻塞 UI。帧间隔小于 50ms 的动画建议用双缓冲;帧间隔大于 100ms 的低帧率动画,单缓冲加按键触发解码就够,可以省掉一张大图内存。
还有一个细节:循环播放时首尾相接,第 0 帧和第 N-1 帧在视觉上未必连续,尤其是渐变类素材。常见做法是循环点加一帧过渡,或者在尾帧播完后不立即接第 0 帧,先停 1 到 2 帧时长,观感更像自然衔接。这个属于播放体验调优,和内存无关,但用户反馈“动画结尾闪一下”时优先查这里。
4. 把控件接入页面:资源命名、调用方式与三组必调参数
内核写完之后,接入层决定这个控件能不能被业务方顺畅使用。资源组织、命名规则、API 设计是小控件最容易滑向“能用但难用”的三个点。我一般把方案固定成:帧图统一放 assets/frames 目录,文件名三位零填充,播放器只接收目录名和帧数,其余参数全部由 PlayConfig 一个对象传入。
4.1 帧图命名与索引映射:三位零填充与 assets 目录组织
帧图放 assets 而不是 drawable 有实际原因:assets 可以按子目录组织大量文件,drawable 按文件名平铺,帧数超过一两百张时资源定位变慢;assets 里的文件用 AssetManager 按路径直接读流,配合 decodeStream 解码更快。命名用 frame_000.png 到 frame_059.png 这种三位零填充,另一个不能忽略的是用 Locale.US,避免某些语言环境的数字格式化差异导致文件名拼接错误。
public static String getFramePath(String dir, int index) { // 帧数超过 100 时自然扩展为三位数,索引从 0 开始 String name = String.format(Locale.US, "frame_%03d.png", index); return dir + "/" + name; }如果你的素材命名不统一,比如设计师给的是 01.png、02.png、10.png 混排,需要初始化时扫描一次目录,按文件名排序后建立 index 到实际文件名的映射数组。扫描发生在首帧加载前,只执行一次,数组很小,对首帧启动耗时影响可以忽略。但排序必须用自然排序而不是字符串排序,否则 10 会排在 2 前面,播放顺序乱了之后动效完全不对。
打包前最好加一道尺寸一致性检查。序列帧素材中途只要有一帧尺寸不同,inBitmap 复用就失效,内存曲线会突然冒一个尖峰。我习惯用一段脚本扫所有帧的宽高,发现不一致直接报错:
from PIL import Image import glob last_size = None for path in sorted(glob.glob("frames/frame_*.png")): w, h = Image.open(path).size if last_size is None: last_size = (w, h) elif (w, h) != last_size: raise SystemExit(f"size mismatch: {path}")这个脚本在 CI 或本地打包前跑一次,比在真机上等 OOM 再发现划算得多。后面所有解码逻辑都建立在“所有帧尺寸一致”这个前提下,省掉很多分支判断。
4.2 最小可用工程:布局、配置与启停 API
布局侧就是一个自定义 View,不写解码逻辑,不写缓存逻辑,只接收 Bitmap 切帧。XML 里的 scaleType 走 fitCenter,保证不同分辨率的帧图等比缩放居中显示。
<com.example.lib.widget.ImageFrameView android:id="@+id/image_frame_view" android:layout_width="240dp" android:layout_height="240dp" android:scaleType="fitCenter" />调用侧把参数统一装进 PlayConfig,然后 start:
ImageFrameView.PlayConfig config = new ImageFrameView.PlayConfig.Builder() .setFrameDir("frames") .setFrameCount(60) .setTargetWidth(360) .setTargetHeight(360) .setCacheSizeBytes(30 * 1024 * 1024) .setFrameIntervalMs(33) .setLoop(true) .build(); imageFrameView.setPlayConfig(config); imageFrameView.start();targetWidth/targetHeight 填的是像素而不是 dp。在 240dp 的 View 里,主流设备的实际像素可能是 720 到 1080,填 360 意味着采样率取 2,内存降到四分之一,画面锯齿在多数设备上看不出来。frameIntervalMs 的 33ms 对应约 30fps,节奏型礼物动画足够;要求高可以用 16ms,但解码压力翻倍,缓存容量也要跟着调。start 方法内部会先做首帧预解码,完成后再注册 Choreographer 回调,所以调用方体验是点开即播,没有白屏。
4.3 三组必调参数:缓存容量、采样率与预解码帧数
| 参数 | 建议取值 | 调参方向 |
|---|---|---|
| 采样率 | 1 / 2 / 4 | 每上升一级内存降为 1/4,同时清晰度下降 |
| 缓存容量 | 5~20 帧或半个循环 | 覆盖循环前半段,容量越大内存线性上升 |
| 预解码帧数 | 1~3 帧 | 首帧展示越快,预解码帧数越高,启动耗时略增 |
| 帧间隔 | 33ms / 50ms / 66ms | 大图动画建议 50ms,比强行 16ms 更流畅 |
缓存容量最容易被误解为越大越好。容量大到能装下全部帧,就退化成全量预加载,内存和第一种方案没有区别;容量太小,循环播放时每轮全部 miss,又退化成按需解码。经验值是缓存帧数等于帧数的一半,再多只有边际收益。如果内存预算紧张,优先降采样率而不是降缓存帧数,因为采样率影响所有帧,缓存帧数只影响循环命中率。
预解码帧数建议固定为 3。首帧用于立即显示,第 1 帧和第 2 帧用于前几次切帧时不需要等待解码。再多对用户没有感知,反而推迟首帧出现的时间。还有一点容易漏:assets 里的帧图在打包时要压缩。PNG 在打包时不压缩的话 APK 体积会很难看,建议导出前统一把背景合并、分辨率压到实际显示尺寸的 1.5 倍左右,体积和首帧解码都能受益。
5. 序列帧播放避坑指南:内存上涨、首帧白屏、掉帧与释放失效
这一章集中写踩过的坑,每条按现象、原因、解决三个步骤来。都是真机 Profile 里看得到的问题,不是玄学。
5.1 内存不降反升:inBitmap 静默失效是头号元凶
现象:Memory Profiler 里播放过程中内存曲线一路上涨,没有回落迹象,播放到一半开始频繁 GC,界面卡顿,看起来像是每帧都 new 了一个 Bitmap。
原因:inBitmap 被系统静默忽略。最常见是复用池里取出的 Bitmap 宽高小于新解码尺寸,比如第一帧按 targetWidth 360 解码,但某帧素材分辨率更大,系统检测到复用位图装不下,不会报错,直接把 inBitmap 当作没设置。另一种可能:复用池里的 Bitmap 被 recycle 了,但回收后又没人从队列里移除,解码器拿到的是一张已回收位图,解码直接失败或重新分配。
解决:decode 前严格检查宽高和回收状态,decode 后打点验证。
Log.d("FrameDebug", "index=" + index + " inBitmap=" + (opts.inBitmap != null) + " size=" + frame.getByteCount());打点日志在 API 26 以上还能进一步确认复用是否真的发生,看 Bitmap 的地址或 Native 内存变化。如果日志显示 inBitmap 一直为 null,就检查复用池维护逻辑,通常是在 clear 时把 Bitmap recycle 后又放进队列,导致池里全是废对象。inBitmap 不是加了就有用,必须配合日志验证,把它当黑匣子是要吃大亏的,这是血泪经验。
5.2 首帧白屏:预解码没有真正阻塞启动流程
现象:点击按钮后动画区域空白 1 到 2 秒,然后突然从中间某一帧开始播放;偶尔是黑屏闪一下再进入动画。
原因:setPlayConfig 和 start 之间没有预解码,start 直接注册 Choreographer 回调,第一帧拿到的 Bitmap 是 null。View 已经先绘制了一遍空内容,白屏就这样出现了。
解决:start 内部必须等待首帧解码完成才注册回调。实现上不要在主线程等锁,那会把卡顿放大;正确做法是预解码线程完成后通过 Handler 回主线程注册回调。预解码第 0 帧的同时,可以并行预解码第 1 帧和第 2 帧,这样前三次切帧全部命中缓存,用户感知就是点开即播。
// start() 内部示意:预解码完成后才注册播放回调 decodeExecutor.execute(() -> { Bitmap first = decoder.decodeFrame(0); cachePool.put(0, first); handler.post(() -> { frontFrame = first; Choreographer.getInstance().postFrameCallback(frameCallback); }); });注意这里不能用 CountDownLatch 在 start 方法里 await,否则主线程被锁住,页面卡死;回调切回主线程注册才是正解。
5.3 低端机掉帧:解码耗时超帧间隔,双缓冲也救不了
现象:帧间隔设置 33ms,真机 Profile 只有 20fps,动画像慢动作;中端机上越播越卡,后半段直接跳帧。
原因:单帧解码耗时 40 到 80ms 是先天的,双缓冲把等待解码的延迟藏住了,但如果解码线程每帧耗时都超过帧间隔,无论缓冲怎么换,backFrame 永远来不及就位。加上复用池反复分配和回收,GC 的 stop-the-world 会进一步放大掉帧。
解决:动态降级。每累积 20 帧统计一次平均解码耗时,超过帧间隔的 60% 就把采样率调大一档。中端机自动从 1x 降到 2x,帧率回稳;旗舰机始终用 1x,清晰度不损失。这个策略比固定采样率实用,因为同一套动画在不同设备上的解码能力差别很大,固定参数只能迁就最低端设备,旗舰机体验被白白牺牲。
if (frameIndex % 20 == 0) { long avgDecodeMs = decoder.getAvgDecodeMs(); if (avgDecodeMs > frameIntervalMs * 0.6) { decoder.increaseSampleSize(); // 动态把采样率调大一档 } }5.4 播放结束内存没释放:静态引用和缓存池生命周期问题
现象:动画播完、页面退出,回到桌面看到内存还是高位数百 MB,再次进入页面卡顿明显,多次进出后流畅度明显下降。
原因:静态变量或单例持有了 ImageFrameView 或 PlaybackEngine,缓存池和复用队列里的 Bitmap 都没有被回收。另一种更隐蔽的原因:View 的 onDetachedFromWindow 里只做了停止播放,没有调用 cachePool.clear()。LruCache 本身不立即释放,里面的 Bitmap 还占着 native 堆。
解决:生命周期绑定到 View:onAttachedToWindow 注册、onDetachedFromWindow 释放。release 方法按顺序执行:移除 Choreographer 回调、取消正在进行的解码任务、清空 front/back 槽位、cachePool.clear()。复用队列里的 Bitmap 全部 recycle,所有引用置 null。
public void release() { isPlaying = false; Choreographer.getInstance().removeFrameCallback(frameCallback); decoder.cancelAll(); cachePool.clear(); frontFrame = null; backFrame = null; }release 要设计成幂等,多次调用不崩溃。View 树的不确定性很容易导致 onDetachedFromWindow 被回调两次,第二次进来时所有对象可能已经置空,加个 isReleased 标志位兜底最省事。
6. 用 Profile 验证内存收益,再补两个更狠的降内存技巧
验证核心是确认缓存池和复用确实生效,而不是靠感觉。固定参数:采样率 1x、缓存 8 帧、播放一轮 30 帧不循环,录制播放过程的内存曲线。正常曲线是阶梯式上升到缓存上限后走平;循环播放时曲线在一个区间内波动,而不是持续上升。如果曲线每帧跳动且不回落,说明 inBitmap 没生效,按 5.1 排查;播放结束曲线不下降,说明 release 没把 Bitmap 交还系统,按 5.4 排查。
降内存还有两个更狠的技巧。第一个是 Hardware Bitmap。API 26 以上把 inPreferredConfig 设为 Bitmap.Config.HARDWARE,位图只存在 GPU 显存,Java 堆和 native 堆曲线几乎不动,适合不透明度高、只绘制不改写的动画。注意它不能参与 inBitmap 复用,复用池要跳过它;设备显存小的机型要谨慎,GPU 内存不足时会触发额外拷贝,反而更卡。
第二个技巧是把素材从 PNG 序列换成 WebP 序列帧。帧与帧之间背景差异小的动画,PNG 无论如何优化都有体积和 IO 上限。用有损 WebP,同等视觉质量下体积只有 PNG 的五分之一到十分之一,解码后内存不变,但解码速度和 IO 压力都改善。有一个模拟项目 X 里接了 80 张 1080P PNG 的礼物特效,先转成 640×640 WebP 序列,再配合采样率 1x,内存峰值从 320MB 降到 40MB 左右,千元机上保住了 60fps。注意不能只换扩展名,要用工具真正转成有损 WebP,解码时仍按 ARGB_8888 配置,否则透明通道会出现绿边。
解决完内存问题之后,序列帧依然是 Android 上表现力最稳定的动画方案之一。我的习惯是接每个新需求先拿 10 帧素材做最小播放测试,用 Profile 确认采样率和缓存参数合理,再交到 UI 手里验收,这一步至少避开八成的返工。参数不是复制粘贴就能通用的,不同分辨率和帧数组合要重新调一遍。希望帮到你。
本文还有配套的精品资源,点击获取