EN

Android 中的“后台无效动画“行为分析

字数统计: 2.7k阅读时长: 9 min
2019/10/24
loading

这篇文章记录的是 2019 年遇到的后台动画问题:应用界面不可见了,进程里却仍有动画工作在执行。但是当用户知道一个 App 退到后台之后还在持续做无效的动画,而这个动画完全是无意义的,而且用户还不知道他在做动画,消耗用户那可怜的电量的时候,轻则被多任务杀掉,禁止后台运行,重则直接卸载。

这个问题容易被忽略。当时我使用 Systrace ,在多开应用后退回桌面,左右滑动并抓取 Systrace ,发现部分已离开的应用仍在频繁执行动画回调。复查时应固定操作和应用版本,并记录界面可见性,不能把“打开很多应用”当成必要或充分条件。

这里讨论的是界面已不可见、业务也不再需要,却仍持续推进的动画。它会增加不必要的工作;但进程还活着不等于能无限制使用 CPU,现代系统还可能冻结 cached 进程。往 Choreographer 提交动画回调的入口很多,最终要结合具体调用栈、播放状态和库版本定位,不能仅凭一个 animation slice 判定原因

版本说明(2026-10-13 技术复核): 原案例与截图来自 2019 年,不代表网易新闻、QQ 音乐或 Lottie 当前版本的表现。下文补充 Lottie Android 6.7.1 的实现、窗口/生命周期边界和 Android 11 / API 30 起的 cached-app freezer。原始完整 Trace、应用版本和功耗测试未提供,不能把历史 CPU 图当成电量测量。

下面我们就以两个实例,从技术的角度来看一下事件发生时候的情况和原因,希望看到这篇文章的开发者,检查一下自己的应用是否有这个问题,有则改之,无则恭喜

实例 - 网易新闻

当时我用了一会儿网易新闻,返回桌面,再一边滑动桌面一边抓 Systrace。我看到应用界面已经离开前台;下面图中则能直接看到进程 com.netease.newsreader.activity(PID 30962)持续出现 doFrame。

历史网易新闻进程持续出现 doFrame

放大选中的一帧,能看到 animation 区间,没有可见的 input/traversal 区间。这只描述该选区,不是判定应用在后台的充分条件:无输入、无 View 更新的前台帧也可能如此。图中还有与 Chrome 相关的轨道,不能把整段进程活动都算给同一个动画库。

选中 doFrame 中的 animation 及 Chrome 相关活动

CPU Slices 统计的选区约 3.636 s,reader.activity 这一行累计 422.163 ms、680 个运行切片,位于当前可见排序首行。这里的 680 不是动画数、回调次数或掉帧数;按名称聚合还需要继续核对线程/进程归属。CPU 运行时间也不是直接的耗电量,更不能把所有时间归到某个 animation callback。

3.636s 选区中 reader.activity 的 422.163ms 和 680 个 CPU 切片

我接着看 MethodTrace,发现 ValueAnimator 更新进入 com.airbnb.lottie...onAnimationUpdate,再推进图层和 invalidation。它把调查范围缩小到了一个 Lottie 更新路径;截图没有给出具体库版本、对象身份和可见性状态,不能据此断言所有 Lottie 版本有同一缺陷。MethodTrace 的时间也受插桩影响。

ValueAnimator 经 Lottie onAnimationUpdate 推进图层的历史方法栈

实例 - QQ音乐

接着换 QQ 音乐,我还是启动应用后返回桌面,滑动桌面并采集。历史进程 com.tencent.qqmusic(PID 19330)的轨道显示持续帧活动:

历史 QQ 音乐进程的持续帧活动

MethodTrace 中,同一段 AnimationHandler 工作里有三段 ValueAnimator/Lottie 更新调用。它提示有多个更新需要调查,但没有对象标识就不能仅凭三段栈精确断言“三个不同动画实例”;更不能据此跨应用比较耗电严重程度。

同一段 AnimationHandler 下三段 ValueAnimator 与 Lottie 更新

放大图中的 10.73 ms 是选中混淆方法 com.airbnb.lottie.aw.b 在这次插桩记录里的区间,不是后台功耗或可直接外推的生产环境开销。

历史 Lottie 方法选区的 10.73ms 插桩时间

这种问题也可能出现在普通属性动画中。下面的栈包括 ObjectAnimator、View.setRotation、invalidate,以及后面的 TraversalRunnable/performTraversals;它本身就说明“后台动画一定没有 traversal”不能作为通用规则。

普通属性动画更新 rotation,并触发 invalidate 和 traversal

根本原因

这些案例的排查方向是:动画仍在被续订、推进,而它所属界面可能已经不可见,或者业务已经不需要它。需进一步区分应用没有传递停止意图、历史库的可见性处理问题、父容器/窗口状态变化,以及回收复用或异步加载后的重新启动。

Choreographer按已登记的回调调度工作,不会因为“App 在后台”就统一删除所有 input/draw、只保留 animation。没有 traversal slice 只能说明该处没观察到相应 View 工作,不证明所有 Surface、WebView 或 native 渲染都停止,也不证明动画计算必然没有业务用途。QQ 音乐在后台播放音频是另一类功能,不能因为暂停装饰动画就停止所有后台工作。

现代系统边界: Android 11 / API 30 起支持 cached-app freezer。在支持并启用的设备上,符合条件的 cached 进程被冻结后不能继续执行 CPU 工作,直到解冻。并非每个退到后台的进程都会立即进入该状态;冻结也不能替代正确的播放状态管理。采集时要记录进程状态,避免把“进程存在”“界面不可见”和“正在运行”混为一谈。

开发建议

原文引用的是 Lottie issue #987,于 2018-10-31 提出,2019-02-03 关闭。报告者描述循环动画在 View 隐藏或 Activity paused 后仍运行,并怀疑只依赖 detach 清理不够。它是历史问题报告,关闭日期本身不证明所有版本和集成都已修复。

原来的 onVisibilityChanged() 子类补丁不宜直接照搬:它遗漏字段声明,还可能在连续两次“不可见”通知中把恢复标记从 true 清成 false;单看参数 visibility 也不能表达宿主生命周期、祖先/窗口状态和用户主动暂停。与库自身的暂停/恢复逻辑叠加后,还可能出现重复控制。

Lottie 6.7.1 已经做了什么

本次核对 6.7.1 发布版,源码固定到提交 c8addcb776f6cd940abaf55ae6b471e55b626eb9:

  • LottieDrawable.setVisible()会在变为不可见且 animator 正在运行时暂停,并记录后续恢复动作;重新可见时按已保存动作处理。实现专门防止连续不可见通知清掉恢复意图。
  • LottieAnimationView使用 View/ImageView 的可见性传递,pauseAnimation() 委托给 Drawable,并关闭 autoplay;不要假设当前版本还只有旧 issue 所说的 detach 清理。
  • LottieValueAnimator.pauseAnimation()移除 Choreographer FrameCallback。Drawable 的暂停还会清理待加载后的播放任务,并在不可见时清除待恢复动作;所以“现在不在播放”不总等于“没有待执行的播放请求”。

这些是所查版本的实现,不是“升级后任何集成都自动正确”的保证。可见性传递还受系统和 target SDK 兼容路径影响,参见 Android 16 ImageView 实现。自己使用 Drawable、自定义容器、Compose 或不同版本时,应核对对应适配路径。

把播放意图与临时暂停分开

对不再可见的装饰动画,暂停通常是正确动作。但不要机械地“onPause 停一切、onResume 播一切”:Activity 在多窗口中 paused 时仍可能可见。以可见性为依据的工作通常要结合 onStart/onStop;Fragment 则跟随其 View 生命周期,销毁 View 后解除相关引用和监听。

建议由一个地方管理动画是否应该继续:

  1. 业务/用户是否仍请求播放,动画是否已经自然结束或主动取消。
  2. 宿主生命周期是否允许,以及 View、祖先、窗口、attach 状态是否满足显示需求。isShown() 也不是屏幕像素实际可见性的完整证明,不能检测一切遮挡。
  3. 动画资源是否已准备好;加载完成、列表复用、页面切换时重新计算,不能让过期的加载回调把动画启动回来。

下面是执行既有决策的函数示例,不是完整生命周期组件;shouldAnimate 必须由上述状态共同决定,并在状态变化时更新。单次动画完成后也应清除业务播放意图,避免随后一次状态更新把它重新播起:

1
2
3
4
5
6
7
8
9
// Call on the main thread whenever the owning screen's playback policy changes.
private void syncAnimationPlayback(LottieAnimationView view, boolean shouldAnimate) {
if (!shouldAnimate) {
// Also clear a pending play/resume request, even if not currently running.
view.pauseAnimation();
} else if (!view.isAnimating()) {
view.resumeAnimation();
}
}

pauseAnimation() 用于暂时停在当前进度,resumeAnimation() 从当前位置继续;从头播放、自然结束和主动取消要按业务区分。不要把 isAnimating() 当成唯一的播放意图,也不要反复无条件 resumeAnimation()。调用应在主线程,并核对实际使用的 Lottie 版本及其事件语义。

如何确认修复有效

固定应用/库/系统版本,分别验证返回桌面、页面切换、父容器隐藏、窗口不可见、列表回收复用,以及后台期间资源加载完成。再返回可见状态,确认该恢复的能继续,用户已暂停或已结束的不会自动重播;多窗口和画中画按产品规则单独验证。

用同等操作和时间窗口对比目标线程的调度运行时间、动画调用栈和回调次数,检查自发续订是否停止;需要评价电量时另做受控功耗测试。没有 animation slice、没有采到线程或被冻结,都不能单独证明生命周期逻辑已修好。

本文其他地址

由于博客留言交流不方便,点赞或者交流,可以移步本文的知乎或者掘金页面
知乎 - Android 中的“后台无效动画“行为分析
掘金 - Android 中的“后台无效动画“行为分析

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球:付费社群已于 2026 年停止运营,不再招收新成员;此处保留历史介绍。

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

CATALOG
  1. 1. 实例 - 网易新闻
  2. 2. 实例 - QQ音乐
  3. 3. 根本原因
  4. 4. 开发建议
    1. 4.1. Lottie 6.7.1 已经做了什么
    2. 4.2. 把播放意图与临时暂停分开
    3. 4.3. 如何确认修复有效
  5. 5. 本文其他地址
  6. 6. 关于我 && 博客