作者:vivo 互联网大前端团队 - Xu Jie
本文从 Android 渲染系统内核出发,系统拆解 Native、Lottie、PAG、SurfaceView、TextureView五种动效方案的底层原理,通过多维度量化测试建立性能评估模型,结合实战场景提供可落地的优化策略与选型体系,帮助开发者突破动效性能瓶颈。
1分钟看图掌握核心要点👇

一、引言
随着Android设备硬件性能的提升,用户对动效的流畅度、复杂度要求日益提高。从简单的View属性动画到复杂的品牌营销动效,不同场景下的技术选型直接影响应用的性能表现(如帧率、内存占用)和开发效率。然而,多数开发者仅停留在会用层面,缺乏对渲染机制、性能瓶颈的深度理解,导致动效场景出现卡顿、OOM、兼容性问题。
本文将从 内核原理 → 量化测试 → 优化实践 →工程落地 四个维度,构建完整的动效技术知识体系,核心解决三大问题:
- 不同动效方案的底层渲染逻辑差异何在?
- 如何通过量化指标精准评估动效性能?
- 如何结合场景实现动效的高性能落地?
二、动效渲染系统内核基础
要理解动效性能差异,需先掌握Android渲染系统的核心机制 ------ 这是所有动效方案的底层依赖。
2.1 渲管线核心流程
Android渲染系统遵循 CPU → GPU →屏幕 的流水线模型,关键链路如下:
关键链路
应用层绘制指令(Canvas/OpenGL)
→ CPU 预处理(布局计算、绘制指令封装)
→ RenderThread 调度
→ GPU 渲染(顶点着色、片元着色、纹理采样)
→ FrameBuffer 帧缓冲 → VSYNC 信号同步 → 屏幕显示
这个流程涉及下面几个概念:
- Canvas:这个就是程序员第一时间接触到的自定义控件用到的画布,也接触到了哪种写法性能不好,该怎么优化;
- VSYNC机制:屏幕的垂直同步刷新信号,Android设备主流刷新率是 60Hz (16.67ms/帧)、90Hz (11.11ms/帧)、120Hz (8.33ms/帧),VSYNC决定了屏幕每秒最多能刷新多少帧,是所有动画/绘制的时间基准,无VSYNC则动画必卡顿/撕裂;16.67ms(60fps)触发一次屏幕刷新,动效帧必须在该周期内完成渲染,否则会导致掉帧;
- RenderThread:Android 5.0+ 引入的独立渲染线程,负责将应用层绘制指令转化为GPU可执行的操作,避免阻塞主线程;
- 硬件加速:默认开启(API 14+),将Canvas绘制指令转化为OpenGL ES调用GPU承担大部分渲染工作,性能提升 3~5 倍。
- Choreographer:Android 系统的帧调度总管家,唯一职责就是深度绑定VSYNC信号,所有View绘制 (onDraw)、属性动画 (ValueAnimator)、界面刷新 (invalidate) 的帧回调,都由Choreographer统一调度,确保所有绘制/动画的帧都严格对齐VSYNC信号,无掉帧、无撕裂、无多余绘制。
为了方便探究,我们选择一个60Hz的手机做后面一系列的验证。
2.2 动效合格的判断标准
2.2.1 资源的加载时长
动效资源的加载时长是指:从触发动效资源加载(如调用setAnimation()、load()方法)开始,到动效资源完全解析、解码、初始化完成,达到可首次播放状态的总时长。
本文不是去探讨资源加载是在主线程还是子线程,只关注加载时长本身,所以后面都默认在主线程加载资源的耗时作为分析标准。
资源加载的几种类型:动效载体文件(Lottie JSON/JSONZ、PAG文件、帧序列图、矢量资源)、依赖资源(动效中的位图、字体、渐变素材)。
2.2.2 每帧刷新时间 16.67ms
本质:这是屏幕硬件的固定刷新间隔,由手机屏幕刷新率决定,和动效、App、系统软件无关。
性质:固定不变、不可修改(除非切换屏幕刷新率,比如从60Hz切到120Hz,刷新时间就变成8.3ms)。它是一个时间窗口------ 系统必须在这个窗口内完成一帧的绘制、提交,才能保证这一帧被正常显示,不出现掉帧。
核心作用:作为动效性能评估的合格标尺,规定了每帧绘制的最长允许耗时,超过这个时间窗口,就会触发掉帧。为了能够从代码角度直观评估刷帧是否在规定时间内,我们可以使用下面的代码在动效执行的时候给予监听。
kotlin
private var lastFrameTime: Long = 0
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (lastFrameTime != 0L) {
val frameInterval = (frameTimeNanos - lastFrameTime) / 1_000_000f
android.util.Log.d(TAG, "【VSYNC帧间隔】= $frameInterval ms")
}
lastFrameTime = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
}
private fun verifyChoreographerAndVSYNC() {
// 核心API:注册Choreographer的帧回调,绑定VSYNC信号
Choreographer.getInstance().postFrameCallback(frameCallback)
// 跟随Choreographer/VSYNC的动画
执行动画的代码...
}
若打印的结果如下:
makefile
2026-01-1919:57:38.58710712-10712 NativeAnim...tivity_TAG com.ne.animationproject D 【VSYNC帧间隔】= 16.244213 ms
说明帧间隔严格稳定在16.67ms左右(60Hz 屏幕),无任何波动,证明Choreographer 的帧回调完全绑定VSYNC信号,你的动画也是符合要求的。
需要说明的是,后面的动效效果验证过程中,会对比硬件加速开启和关闭对动效性能的影响,同时会去观察刷帧是否正常,以Lottie动效举例,在播放相同动效文件前提下,硬件加速开启和关闭对刷帧的频率是不影响的。
lottie动效-关闭硬件加速:
ini
2026-02-0219:58:09.49614588-14588 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.06199528 threadName = Thread[main,5,main] <078>
2026-02-0219:58:09.51214588-14588 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.58ms, 进度=0.06 threadName = Thread[main,5,main] <080>
2026-02-0219:58:09.51414588-14588 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.07754053 threadName = Thread[main,5,main] <081>
2026-02-0219:58:09.54914588-14588 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=33.18ms, 进度=0.08 threadName = Thread[main,5,main] <086>
2026-02-0219:58:09.54914588-14588 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.10866043 threadName = Thread[main,5,main] <087>
2026-02-0219:58:09.57214588-14588 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.60ms, 进度=0.11 threadName = Thread[main,5,main] <092>
lottie动效-开启硬件加速:
ini
2026-02-0219:55:04.70413911-13911 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.26404682 threadName = Thread[main,5,main] <127>
2026-02-0219:55:04.72513911-13911 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.58ms, 进度=0.26 threadName = Thread[main,5,main] <132>
2026-02-0219:55:04.72513911-13911 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.27959943 threadName = Thread[main,5,main] <133>
2026-02-0219:55:04.72913911-13911 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.59ms, 进度=0.28 threadName = Thread[main,5,main] <135>
2026-02-0219:55:04.73013911-13911 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.29515955 threadName = Thread[main,5,main] <136>
2026-02-0219:55:04.76113911-13911 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.59ms, 进度=0.30 threadName = Thread[main,5,main] <141>
结论:刷帧的频率是不变的,一直维持在16.67ms,如下图所示

但是也存在不是这个值的情况,一般是卡顿丢帧导致的,如下图所示

2.2.3 单帧真实绘制耗时
本质:每帧的绘制时间是指动效完成一帧内容绘制、提交的实际执行耗时,从接收到VSYNC信号开始绘制到绘制完成并提交给GPU/SurfaceFlinger为止的总时间。
**性质:**动态波动、受多种因素影响,没有固定值。影响它的因素包括:动效复杂度(图层多少、路径是否复杂)、设备性能(中低端机vs高端机)、当前系统负载(是否有其他App抢占资源)、绘制优化程度(是否减少Draw Call、避免离屏渲染)。
**核心作用:**反映动效绘制的真实性能开销,是需要被优化、被验证是否符合标尺要求的对象。
2.3 动效渲染的核心瓶颈点
在这里我们只考虑性能方面,那么对于性能衡量的3个标准有了,总结一下动效执行的核心瓶颈集中在:
- CPU瓶颈:绘制指令计算(如复杂路径解析)、帧数据解码(如GIF LZW解压)、对象频繁创建;
- GPU瓶颈:纹理上传(如Bitmap转GPU纹理)、复杂图层混合、大量顶点数据处理;
- 链路瓶颈:跨线程通信(如主线程与RenderThread同步)、资源加载(如JSON/Bitmap解析)。
瓶颈点,也是要突破的技术难点,下面就通过Android常见的几种动效方案的原理介绍,说明每种动效有什么优劣势。
三、四种动效方案的实现原理深度解析
3.1 Native动效
Native 动效按照常使用的类型,可以概括为下面几类:
- **视图动画 (View Animation):**基于
Transformation 矩阵的视觉变换(仅改变绘制效果) - **帧动画 (Frame Animation):**逐帧播放图片序列(类似GIF)
- **属性动画(Property Animation):**基于ValueAnimator和Property接口,直接操作对象属性(如translationX、alpha)
- **Canvas自定义绘制:**基于onDraw(),通过Canvas API逐帧绘制图形/路径
- **其他特殊动画:**物理动画 (Physics-based Animation)、转场动画(Transition- Animation)、矢量动画 (VectorDrawable Animation)
以上动画归结起来如下:
- **相同点:**所有动画都是为了实现UI动态效果,依赖Android系统的帧同步机制,遵循统一的播放控制逻辑
- **不同点:**核心差异在于是否修改对象真实属性、驱动原理和性能开销
3.1.1 验证视图动画和属性动画的区别是View的属性是否变化
kotlin
/**
* 验证原理(核心重点,最容易理解的优化点):
* 一、View动画(TranslateAnimation/ScaleAnimation等):
* 1. 本质:修改的是 View的绘制矩阵(AnimationMatrix),只改变「视觉上的绘制位置/大小」,不改变View的真实属性(translationX/left/top等)
* 2. 开销:每次onDraw都会执行「矩阵变换计算」,CPU需要把原始坐标 × 变换矩阵,得到绘制坐标,有固定开销
* 3. 致命问题:动画结束后,View会瞬间回弹到原始位置,因为真实属性没变;且点击事件还是响应原始位置
* 二、属性动画(ObjectAnimator/ValueAnimator):
* 1. 本质:直接修改 View的「真实绘制属性」(translationX/alpha/scaleX等),这些属性是View的成员变量
* 2. 开销:硬件加速下,这些属性是GPU的「渲染状态参数」,无需CPU矩阵变换,绘制时直接用属性值,零额外开销
* 3. 优势:动画结束后,View停留在目标位置,真实属性同步修改,点击事件响应新位置
*/
private fun verifyViewAnimVsPropAnim(){
android.util.Log.d(TAG, "===== 开始验证:View动画 VS 属性动画 =====")
// View动画:平移动画,从0到500,执行矩阵变换
val viewAnim = TranslateAnimation(0f, 500f, 0f, 0f).apply {
duration = 3000
fillAfter = true// 动画结束后保持视觉状态,但真实属性不变
}
viewAnimView.startAnimation(viewAnim)
Handler(Looper.getMainLooper()).postDelayed({
android.util.Log.d(TAG, "View动画执行中,真实translationX = ${viewAnimView.translationX}") // 打印:0.0 → 真实属性没变
}, 500)
// 属性动画:平移动画,从0到500,直接修改translationX属性
val propAnim = ObjectAnimator.ofFloat(propAnimView, "translationX", 0f, 500f).apply {
duration = 3000
}
propAnim.start()
Handler(Looper.getMainLooper()).postDelayed({
android.util.Log.d(TAG, "属性动画执行中,真实translationX = ${propAnimView.translationX}") // 打印:持续变化 → 真实属性已改
}, 500)
// 动画结束后,点击两个View,能看到:红色View(View动画)点击新位置无响应,蓝色View(属性动画)点击新位置有响应
}
打印的结果如下:
ini
2026-01-2215:23:29.14426874-26874 NativeAnim...tivity_TAG com.ne.animationproject D ===== 开始验证:View动画 VS 属性动画 =====
2026-01-2215:23:29.64826874-26874 NativeAnim...tivity_TAG com.ne.animationproject D View动画执行中,真实translationX = 0.0
2026-01-2215:23:29.64826874-26874 NativeAnim...tivity_TAG com.ne.animationproject D 属性动画执行中,真实translationX = 31.812086
结论:
- View 动画:仅修改绘制矩阵,真实属性不变
- 属性动画:直接修改 View 的translationX真实属性
3.1.2 工作原理解读
属性动画修改translationX等属性后,最终要通过GPU合成显示到屏幕上,而CPU和GPU是两个独立的硬件单元,拥有各自独立的内存(CPU内存/堆内存、GPU显存),因此存在固有的数据同步流程:
- CPU修改View的属性值后,会更新RenderNode(GPU纹理的描述信息)中的变换参数;
- CPU需要将更新后的RenderNode参数从CPU内存拷贝到GPU显存(跨硬件数据传输);
- GPU读取显存中的参数,更新纹理变换,完成合成显示;
这个 CPU更新参数→拷贝到GPU显存的流程,是异构计算的固有开销,无法避免。
属性动画的一些限制存在下面几点:
(1)单View、单属性动画下,同步开销极低(纳秒/微秒级),几乎无感知,不会影响帧率;
(2)多View、多属性同步动画(如网格布局所有Item 同时做入场动画)下,大量RenderNode参数需要同步,跨硬件拷贝的开销会累积,导致:
- CPU端出现轻微的 "拷贝等待",帧耗时小幅增加;
- GPU端出现 "参数等待",合成延迟轻微升高,极端情况下出现帧间隔波动;
(3)低端设备上,硬件总线带宽较低,跨硬件拷贝的开销更明显,可能出现轻微的动画 "拖影"。而"拖影"其实就是掉帧等原因引起的。
"拖影"无法根除的原因:
该开销源于CPU与GPU的硬件独立性,只要依赖GPU加速显示,就必须进行跨硬件数据同步,除非放弃硬件加速(转而使用CPU软件绘制,开销更高),因此是固有且无法根除的。
上面这些理论内容还是比较抽象的,下面看下具体的表述,不管是View(视图)动画,还是属性动画,都需要使用到Canvas进行绘制,这一点从下面的profile的CPU Hotspots可以确认。

那我们可以直接来看Canvas绘制动画的性能表现如何,就知道不管是视图动画还是属性动画,存在什么优缺点了。
3.1.3 瓶颈在哪
再来看下硬件加速对Canvas绘制的作用(绘制10000个圆)
kotlin
/**
* 验证原理(Android绘制的核心优化点):
* 1. 硬件加速开启时(默认开启):View的onDraw中的Canvas → 实际是「GLES20Canvas」的实例
* 所有canvas.drawCircle/drawRect/drawBitmap 等绘制指令 → 直接翻译成OpenGL ES 2.0的GPU绘制指令
* 光栅化(将矢量图形转像素点)的过程由GPU完成,跳过了CPU的软件光栅化步骤,效率提升10~100倍
* 2. 硬件加速关闭时:Canvas → 实际是「SoftwareCanvas」的实例
* 所有绘制指令都是CPU软件计算,光栅化也由CPU完成,效率极低,绘制复杂图形会卡顿
* 3. 验证方式:反射+类型判断 打印Canvas真实类型 + 绘制大量图形对比耗时
*/
private fun verifyHardwareAccelerateCanvas(){
android.util.Log.d(TAG, "===== 开始验证:硬件加速Canvas类型验证 =====")
// 1. 硬件加速【开启】状态下,打印Canvas真实类型(默认开启)
performanceView.post {
performanceView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
// 设置绘制回调
performanceView.onDrawFinish = { canvasType, costTime ->
android.util.Log.d(TAG, "硬件加速开启 → Canvas真实类型 = LAYER_TYPE_HARDWARE")
android.util.Log.d(TAG, "硬件加速开启 → 绘制耗时 = $costTime ms")
}
// 触发系统绘制流程(关键:必须用invalidate请求重绘)
performanceView.needDrawBigTask = true
performanceView.invalidate()
}
// 2. 验证【硬件加速关闭】状态(延迟1秒确保第一次绘制完成)
performanceView.postDelayed({
performanceView.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
performanceView.onDrawFinish = { canvasType, costTime ->
android.util.Log.d(TAG, "硬件加速关闭 → Canvas真实类型 = LAYER_TYPE_SOFTWARE")
android.util.Log.d(TAG, "硬件加速关闭 → 绘制耗时 = $costTime ms")
}
performanceView.needDrawBigTask = true
performanceView.invalidate()
}, 1000)
}
硬件加速开启情况下打印的结果如下:
ini
2026-02-0220:24:45.09017488-17488 NativeAnim...tivity_TAG com.ne.animationproject D 硬件加速关闭 → Canvas真实类型 = View.LAYER_TYPE_SOFTWARE
2026-02-0220:24:45.09017488-17488 NativeAnim...tivity_TAG com.ne.animationproject D 硬件加速关闭 → 绘制耗时 = 34 ms threadName = Thread[main,5,main]
结论:可以看到,硬件加速可以降低动画的绘制时间,即使开启了硬件加速,仍然存在一些弊端,无法做到快速的绘制完成一帧,可以看到每帧的绘制耗时超过了16.67ms,达到了20ms,出现了掉帧问题。
另外,Canvas绘制动画时是运行在主线的,可以通过下面这张图看到:
这里使用Canvas绘制圆的时候,是绘制了10000个圆,硬件加速开启情况下,我们改为100个圆,看下绘制时间:
ini
2026-02-0220:25:47.29317488-17488 NativeAnim...tivity_TAG com.ne.animationproject D 硬件加速开启 → Canvas真实类型 = View.LAYER_TYPE_HARDWARE
2026-02-0220:25:47.29317488-17488 NativeAnim...tivity_TAG com.ne.animationproject D 硬件加速开启 → 绘制耗时 = 20 ms threadName = Thread[main,5,main]
结论:可以看到,硬件加速开启情况下,绘制100个圆的时间完全是满足要求的,无掉帧问题。
到这里:总结一下,属性动画的瓶颈主要还是渲染时长问题,为了解决这个问题,开启硬件加速可以提升native动画的绘制性能,但是当做复杂动画的时候,可能会出现掉帧问题;动画简单的时候,不会出现掉帧问题。
3.2 Lottie 动效
Lottie被称为JSON驱动的矢量渲染引擎,核心是它渲染的动画素材是 矢量图形,而非位图(像素图),且渲染过程完全由 JSON 动画描述文件驱动,无需依赖像素资源。
概念说明
- 矢量图形:用数学公式&几何指令描述图形(如直线、曲线、矩形、圆的参数)
- 位图:用像素点矩阵描述图形(每个像素记录颜色、位置)
- 举个例子:绘制一个半径100px的红色圆:
- 矢量图形:只记录 形状=圆、圆心=(x,y)、半径=100、颜色=红色 这几个参数,体积仅几十字节;
- 位图:需要记录100px半径圆内所有像素的颜色值,分辨率越高体积越大,1080P下可能达几十 KB。
lottie动画有自己的优势,关键有两个:
3.2.1 跨平台
Lottie 的矢量渲染引擎之所以能跨 Android、iOS、前端等平台,核心是它依赖的是各平台标准的矢量绘制能力:
- Android 端:基于 Canvas 或 OpenGL 的矢量绘制 API;
- iOS 端:基于 Core Graphics 或 Metal 的矢量绘制 API;
- 前端:基于 SVG 或 Canvas 的矢量绘制 API。
3.2.2 可以实现复杂的动画
因为是矢量图,矢量图的绘制过程大致有三步:
- 解析JSON中的矢量动画参数
- 逐帧计算矢量图形的位置/形状/颜色
- 通过Canvas/OpenGL绘制到屏幕
lottie动画可以支持复杂路径动画、渐变、蒙版(基于矢量参数),基于此,很多开发工程师和交互设计师都会选择使用lottie动画。但是,lottie动画有没有什么缺点呢?这一点其实在很多开发工程师分析应用ANR的时候会进行思考,那么我们就做一下验证和剖析。
3.2.3 工作原理解读
Lottie 的核心是跨平台动画描述→原生渲染,架构分为三层:
1.解析层
解析AE导出的JSON文件,构建LottieComposition
(包含图层、关键帧、特效信息),Lottie-
Composition不是简单的 "数据容器",而是动画元数据&执行上下文的综合体,核心字段:
javascript
// 源码简化版
publicclassLottieComposition {
publicfinal Map<Long, Layer> layers; // 图层ID→图层映射(支持图层引用)
publicfinal List<Keyframe<?>> keyframes; // 全局关键帧池(复用关键帧数据)
publicfinalfloat frameRate; // 动画帧率(AE导出的原始帧率,如30/60fps)
publicfinalfloat startFrame; // 动画起始帧
publicfinalfloat endFrame; // 动画结束帧
publicfinal Size bounds; // 动画画布尺寸(决定渲染视口)
publicfinal Map<String, LottieImageAsset> images; // 位图资源映射(ImageLayer用)
publicfinal Map<String, LottieFont> fonts; // 字体资源映射(TextLayer用)
}
2.场景层
基于Composition构建Layer树(ShapeLayer/
ImageLayer/TextLayer), Lottie 6.0 支持的Layer类型覆盖 AE 核心图层,且每种Layer有专属渲染逻辑:

3.渲染层
通过 Canvas/OpenGL 将Layer树逐帧渲染到屏幕,渲染层的核心是将 Layer 树的动态状态转化为屏幕像素,Lottie 6.0 提供Canvas管线和OpenGL管线,且支持离屏渲染。

关键底层逻辑
OpenGL管线会将Layer绘制指令(如drawPath/drawCircle)转化为GPU着色器指令,路径的数学参数传递给GPU,由GPU完成光栅化跳过CPU像素计算); 两种管线共享Layer树的动画状态`,仅绘制阶段的实现不同,上层无感知(符合 "场景层-渲染层" 解耦设计)。
离屏渲染(Offscreen Rendering)
Lottie对蒙版、混合模式、PreCompLayer等场景会触发离屏渲染:
- 原理:先将目标Layer渲染到离屏缓冲区(FrameBuffer),再将缓冲区内容渲染到主屏幕,而非直接绘制到主画布;
- 实现:OpenGL管线通过 FBO(Frame Buffer
Object)实现,Canvas管线通过 Bitmap-
Canvas实现; - 性能影响:离屏渲染会增加缓冲区创建/拷贝开销,复杂蒙版动画可能导致帧率下降(这是Lottie蒙版动画卡顿的核心原因)。
3.2.4 瓶颈在哪
1.JSON解析、图层构建、动画数据预处理等流程的耗时
yaml
2026-01-2316:25:32.32923631-23631 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie JSON解析】耗时: 520ms <815>
2026-01-2316:25:32.32923631-23631 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie信息】总帧数: 31.99, 时长: 1066.0ms <816>
JSON解析以及图层渲染,竟然达到500ms,这是因为JSON 文件为文本格式,解析时需大量字符串处理与对象创建,JSON文本内容大致如下:

现在我们可以回答两个问题:
(1)要是开启硬件加速,JSON文件的解析速度可以提升吗?
结果是不会提速,因为View级硬件加速仅作用于 Lottie 的渲染层(Canvas/OpenGL绘制),与解析层的JSON初始化无关,因此耗时不变;
但是当你给整个Activity设置了硬件加速后,系统层面优化进程的CPU/内存调度,间接释放主线程资源,让JSON解析(主线程执行)更快,因此耗时减少。
经过验证,Activity设置加速后的JSON解析时间减少到200ms。
(2)可以把JSON解析的耗时操作放在子线程吗?
虽然可以在子线程解析JSON文件,但是解析的时间仍然是跟主线程一样,所以仍然没有节省掉解析文件的时间。
2.绘制一帧,真实需要多长时间
直接看硬件加速开启情况下,单帧渲染的时间大致如下:
makefile
2026-02-0220:19:20.05916214-16214 CustomLottie_TAG com.ne.animationproject D Lottie单帧渲染耗时:21 ms <298>
2026-02-0220:19:20.07516214-16214 CustomLottie_TAG com.ne.animationproject D Lottie单帧渲染耗时:11 ms <302>
2026-02-0220:19:20.12516214-16214 CustomLottie_TAG com.ne.animationproject D Lottie单帧渲染耗时:20 ms <308>
可以看到,每帧的渲染耗时,有的会超过16.67ms,有的不会,表现在VSYNC同步帧上面,如下:
ini
2026-02-0220:19:20.06116214-16214 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.9487896 threadName = Thread[main,5,main] <300>
2026-02-0220:19:20.07816214-16214 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.59ms, 进度=0.95 threadName = Thread[main,5,main] <303>
2026-02-0220:19:20.07916214-16214 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.96435076 threadName = Thread[main,5,main] <304>
2026-02-0220:19:20.09516214-16214 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=16.59ms, 进度=0.96 threadName = Thread[main,5,main] <305>
2026-02-0220:19:20.09716214-16214 NativeAnim...tivity_TAG com.ne.animationproject V 【Lottie更新】动画进度: 0.9799048 threadName = Thread[main,5,main] <306>
2026-02-0220:19:20.12716214-16214 NativeAnim...tivity_TAG com.ne.animationproject D 【Lottie帧同步】帧间隔=33.18ms, 进度=0.98 threadName = Thread[main,5,main] <309>
由于有些帧的绘制时间超过16.67ms,所以无法在16.67ms这个时间内完成一帧的刷新,只能在下一次刷新的时候完成上屏,如上面日志的33.18ms所示,对于人眼来说,虽然没有明显感知,但是可以确定的是,json动画文件播放时,是存在掉帧问题的。
总结一下:JSON动画可以依靠硬件加速提升渲染时间,渲染时间在可接受范围内,但是JSON文件的初始化时间却省不了,所以当你有个很复杂的JSON动效文件的时候,比如JSON动画文件里面的图层、元素过多的时候,或者矢量路径等较为复杂的时候,很容易出现掉帧问题,需要慎重考虑其是否会让你引入这个动画文件的页面更加的卡顿,从而导致ANR等问题的产生。
3.3 PAG动效
PAG是腾讯自主研发的一套完整的动画工作流解决方案,助力于将 AE 动画方便快捷的应用于各平台终端。和Lottie、SVGA相比,支持的AE特性更多,支持的平台更广(增加了 mac OS、Windows和Linux),性能方面也做了深层次的优化,支持图层编辑,可以与视频编辑场景紧密结合。目前已经广泛应用于公司内外几十款APP,包括国民级APP 微信、QQ、腾讯视频、QQ 音乐、QQ 空间等,PAG 的流程类似Lottie,设计师使用AE设计好动画以后,通过PAGExporter插件读取AE工程文件,根据具体需求选择矢量导出、BMP预合成、混合导出方式中的一种导出一个PAG二进制文件,客户端对该PAG二进制文件进行解码、渲染,各端共享一套C++实现,平台端只做接口封装,具体的工作流程可以在PAG官网获取。不过PAG需要默认开启硬件加速,否则播放在一些机型上无法播放。
打开一个pag文件,如下图所示,这是一个十六进制表述的二进制文件:

下面我们重点介绍PAG的工作原理。
3.3.1 工作原理解读
PAG 采用 C++内核 + 跨平台 API架构,核心及优势分为:.pag文件是二进制文件,对于同一个动画文件来说,导出.pag相对于导出成JSON格式,体积可以减少50%以上。除此之外,.pag文件采用ProtoBuf序列化,解析速度比JSON 快,打开一个800kb的pag文件,只需要5ms,相对于前面的JSON文件打开需要耗时500ms,速度得到了极大的提升;
makefile
2026-01-2721:30:31.71023458-23458 NativeAnim...tivity_TAG com.ne.animationproject D 【PAG 文件解析】耗时: 5ms <782>
2026-01-2721:30:31.71023458-23458 NativeAnim...tivity_TAG com.ne.animationproject D PAG文件加载完成,总时长:1000000ms <783>
3.3.2 瓶颈在哪
无论是属性动画,还是lottie动画,都需要经过Canvas绘制,而Canvas的绘制指令构建是在CPU端完成的(无论硬件加速与否),Lottie 要绘制矢量路径、图层,必然会调用Canvas的核心绘制 API(如drawPath()、drawColor()、drawBitmap())。
下面是通过CPU Profiler录制方法调用轨迹,直接抓到这些API的调用,且调用栈来自Lottie 相关类,就是Lottie 基于Canvas绘制的最直接证据。

而对于PAG动画来说,前面提到,必须开启硬件加速,并且运行在GPU上,所以就没有Canvas的绘制,那现在要看的就是PAG动画的绘制性能如何。
绘制线程与绘制时间
makefile
2026-02-0411:18:50.2817140-7310 PagViewActivity_TAG com.ne.animationproject D 【PAG绘制】onAnimationUpdate触发, 距离上次耗时: 15.89ms, 线程: tgfx_JNIEnvironment <310>
2026-02-0411:18:50.2937140-7311 PagViewActivity_TAG com.ne.animationproject D 【PAG绘制】onAnimationUpdate触发, 距离上次耗时: 12.07ms, 线程: tgfx_JNIEnvironment <312>
2026-02-0411:18:50.3137140-7309 PagViewActivity_TAG com.ne.animationproject D 【PAG绘制】onAnimationUpdate触发, 距离上次耗时: 14.30ms, 线程: tgfx_JNIEnvironment <314>
2026-02-0411:18:50.3267140-7311 PagViewActivity_TAG com.ne.animationproject D 【PAG绘制】onAnimationUpdate触发, 距离上次耗时: 12.23ms, 线程: tgfx_JNIEnvironment <317>
PAG动画依赖tgfx引擎(腾讯为PAG打造的跨平台2D渲染引擎),而tgfx_JNIEnvironment 线程是PAG动画帧绘制的CPU 端核心工作线程,负责完成每帧动画的前置处理工作,具体职责包括:
- 解析PAG动画当前帧的图层、关键帧、特效数据;
- 计算帧内元素的坐标、旋转、透明度、渐变等属性的插值;
- 构建tgfx引擎可识别的绘制指令(如路径、填充、图层混合等);
- 将绘制指令和纹理数据提交给GPU,等待GPU合成并输出到屏幕。
简单说:这个线程的耗时,是PAG动画单帧总耗时的主要组成部分(占比通常70% 以上),它的耗时超标,直接决定了单帧无法在规定时间内完成处理。
既然运行在子线程,那就不会阻塞UI线程,再来看单帧的绘制耗时,跟上面的Lottie动画输出相同的动画,耗时基本维持在16.67ms以内,这是因为PAG使用了中间数据→局部位图的多级缓存与帧优化:做到了静态帧自动复用,减少了无效计算。
下面是PAG动画绘制一帧的时间示意,在动画重复执行的过程中,绘制时间其实是更短且趋于平稳的。

总结一下:PAG与Lottie性能对比(测试基线:Android15旗舰机/800KB同等复杂度动画文件/连续播放10次取平均值):
- PAG 单帧平均渲染耗时:14.7ms
- Lottie 单帧平均渲染耗时:17.1ms
- 性能提升:(17.1-14.7)/17.1 ≈ 14%
3.4 SurfaceView动效
3.4.1 工作原理解读
SurfaceView的动效实现依赖四个核心组件,它们各司其职,构成完整的绘制闭环,这是理解工作原理的基础:
SurfaceView:承载Surface的容器,嵌入在View层级中,但绘制逻辑与View体系解耦,仅负责在屏幕上预留显示区域。SurfaceView参与主窗口的measure,layout,只不过draw的时候不是在当前主窗口的画布上draw的。在主窗口的画布上绘制的是黑色区域,通过下面这张示意图可以很清晰的知道布局关系

Surface:核心的独立绘制缓冲区,由系统SurfaceFlinger服务管理,拥有自己的像素缓存(显存),不与普通View共享缓冲区。动效的所有绘制内容都渲染在Surface上,这是它与普通View动效的核心区别。
SurfaceHolder:Surface的管理者和代理,提供lockCanvas()/unlockCanvasAndPost()等核心方法,负责缓冲区的锁定、解锁、提交,以及监听Surface的创建、销毁、尺寸变化(通过SurfaceHolder.Callback)
独立绘制线程:非主线程(自定义Thread/HandlerThread),负责执行动效的帧循环绘制,避免阻塞主线程(ANR),这是高性能动效的关键保障。
ini
2026-02-0311:23:43.11510500-12244 AnimationS...ceView_TAG com.ne.animationproject D drawFrame threadName = Thread[Thread-1036,5,main]
2026-02-0311:23:43.13110500-12245 AnimationS...ceView_TAG com.ne.animationproject D drawFrame threadName = Thread[Thread-1037,5,main]
2026-02-0311:23:43.14810500-12246 AnimationS...ceView_TAG com.ne.animationproject D drawFrame threadName = Thread[Thread-1038,5,main]
通过下图可以看得更清晰

通过工作原理,我们可能有这样的想法:如果自定义View需要进行频繁的刷新,或是刷新时数据处理量比较大,我们是不是可以使用SurfaceView来取代View,这时候不用考虑16.67ms的上屏限制了?带着这个疑问,我们做进一步的探究。
3.4.2 瓶颈在哪
SurfaceView的绘制依赖SurfaceFlinger管理的有限缓冲区队列(通常是双缓冲或三缓冲),这是其最核心的性能瓶颈,与编码规范无关,再来看下几个概念:
- Surface的缓冲区数量是固定的(系统默认2~3个),用于实现 "绘制当前帧" 与 "显示上一帧" 的并行执行,避免闪烁;
- 帧循环中,lockCanvas()需要获取空闲缓冲区,unlockCanvasAndPost()需要释放缓冲区并提交给SurfaceFlinger;
- 若绘制速度 > SurfaceFlinger 合成速度(如复杂绘制每帧耗时超过 16ms),缓冲区会被快速占满,后续lockCanvas()会出现阻塞(等待空闲缓冲区)或直接返回null,导致帧循环中断。
性能表现
-
动效卡顿、帧丢失,出现 "跳帧"(比如圆形移动时突然跳过一段距离);
-
绘制线程阻塞,CPU负载异常升高(线程处于 "等待缓冲区" 的阻塞状态,无法执行后续绘制);
-
低端设备上更明显,甚至出现动效 "暂停" 几秒后突然恢复的现象;
-
若缓冲区长期被占用,会触发SurfaceFlinger的异常清理,导致动效闪烁、黑屏。
2026-02-0609:51:16.62515462-20382 AnimationS...ceView_TAG com.ne.animationproject D 单帧绘制耗时:1.81ms / 1806μs | 帧间隔:16.62ms | 实时FPS:60 <849> 2026-02-0609:51:16.64015462-20383 AnimationS...ceView_TAG com.ne.animationproject D drawFrame threadName = Thread-4784 <850> 2026-02-0609:51:16.65915462-20384 AnimationS...ceView_TAG com.ne.animationproject D 单帧绘制耗时:1.71ms / 1708μs | 帧间隔:16.63ms | 实时FPS:60 <853>
可以看到,绘制一帧的时间较短。
要说SurfaceView的瓶颈,在于Surface的创建与销毁完全由系统控制,与View的生命周期不完全同步。例如:
- 当SurfaceView被隐藏(GONE)或屏幕旋转时,系统会销毁旧Surface并创建新 Surface,导致渲染上下文丢失(如 OpenGL 纹理、解码器状态)。
- 重建Surface时需要重新初始化渲染资源(如重新加载纹理、重启解码器),这会导致短暂的黑屏、卡顿,并增加内存开销。
- SurfaceView的SurfaceHolder回调(surface-
Created/surfaceDestroyed)在系统回收Surface时可能延迟调用,甚至不调用。 - 如果开发者在surfaceDestroyed中释放资源(如停止视频解码、释放OpenGL上下文),但回调未被触发,就会导致资源泄漏,最终触发OOM。
3.5 TextureView 动效
为了解决SurfaceView的这些核心问题,Android 4.0(API 14)引入了TextureView,它从底层设计上重构了渲染链路,完美规避了SurfaceView与View生命周期不同步的痛点:
3.5.1 工作原理解读
1.TextureView解决SurfaceView核心问题的底层逻辑
(1)生命周期完全对齐View体系
TextureView是普通View子类,其渲染载体 SurfaceTexture 的创建 / 销毁由开发者完全掌控,而非系统强制干预:
- 当TextureView被隐藏(GONE)、屏幕旋转或进入后台时,SurfaceTexture不会被系统自动销毁,开发者可在onPause/onDestroy等标准View生命周期方法中主动释放资源,避免上下文丢失;
- 即使因内存紧张导致SurfaceTexture被回收,也可通过onSurfaceTextureDestroyed回调精准捕获,不会出现回调延迟/不触发的情况,从根源避免资源泄漏。
(2)渲染上下文持久化,无重建开销
TextureView的OpenGL纹理、解码器状态等渲染上下文,绑定的是SurfaceTexture而非系统级Surface:
- 屏幕旋转、View隐藏/显示时,无需重新初始化纹理、重启解码器,仅需重新绑定SurfaceTexture 即可恢复渲染,彻底解决黑屏、卡顿问题;
- 资源初始化仅需一次,大幅降低内存开销(避免重复创建解码器、加载纹理导致的内存峰值)。
(3)回调机制可靠,资源释放可控
TextureView的SurfaceTextureListener回调
(onSurfaceTextureAvailable/onSurfaceTextureDestroyed)与View生命周期强绑定:
- 回调触发时机精准(如onSurfaceTexture-
Available在TextureView布局完成后立即触发,onSurfaceTextureDestroyed在View销毁前必触发); - 开发者可在onSurfaceTextureDestroyed中安全释放解码器、OpenGL上下文等资源,完全规避回调丢失导致的OOM问题。
2.TextureView 解决问题的同时,保留了高性能渲染能力
- TextureView 并未因解决生命周期问题牺牲性能:
- 它仍基于OpenGL ES纹理渲染,核心渲染逻辑在GPU完成,仅比SurfaceView多一层View层级合成的轻微开销(主流设备上可忽略);
- 对于短视频、普通视频播放等非极致性能场景,TextureView的性能完全满足需求,且开发成本远低于SurfaceView(无需处理线程同步、上下文重建)。
3.5.2 瓶颈在哪
虽然TextureView解决了SurfaceView的核心痛点,但并非所有场景都适用:
- 极致性能场景(如60帧游戏、实时直播推流):SurfaceView仍更优,因其独立图层无View合成开销,GPU占用更低;
- 低版本兼容(Android < 4.0):只能使用SurfaceView,需通过保存渲染上下文快照``延迟销毁资源等hack手段缓解生命周期问题;
- 内存敏感场景:TextureView的SurfaceTexture需手动管理,若忘记释放,仍可能导致内存泄漏(但触发条件远少于SurfaceView)。
四、实战演练
4.1 动效要求
- 做一个控件,沿着不同的轨迹绘制三个圆,这三个圆要不断的按照自己的轨迹循环滚动,不能停止
- 这三个圆的颜色是可以定制的,可以是任何色值
- 这三个圆是长时间持续运行动画,页面存在的情况下,动画不停止
- 该动效是在其他视图的底层,作为背景进行展示
示意图大致如下:



4.2 方案选择
第1步筛选:按照UI要求,这三个圆颜色可以变化,那么对于Lottie动画和PAG动画来说不适合,因为这两种方案都是建立在固定的资源文件上进行播放的,无法修改颜色。所以目前只能使用Native动画里面的Canvas绘制、以及SurfaceView绘制。
第2步筛选:三个圆需要不停的滚动,也就是说动画需要持续执行。另外这个动效是作为背景视图进行展示,所以不应该频繁的占用主线程去刷新,不然会导致前景刷新的掉帧等问题。可以把Canvas实现动画给过滤掉。
第3步筛选:动效的要求是圆圈的绘制,不是特别复杂的视频绘制,所以使用轻量级的TextureView就可以了。
4.3 方案效果
下面基于TextureView实现上面的效果
代码流程大致如下:
scss
// TextureView 实现三色圆轨迹动效核心伪代码
classApertureTextureViewextendsTextureViewimplementsSurfaceTextureListener {
// 1. 初始化参数
private Paint circlePaint;
private List<Circle> circleList; // 三色圆对象
private boolean isAnimRunning = false;
private RenderThread renderThread; // 子线程渲染
// 2. Surface创建回调(初始化)
@Override
publicvoidonSurfaceTextureAvailable(SurfaceTexture surface, int width, int height){
initCircleData(width, height); // 初始化圆的轨迹和颜色
startRenderThread(); // 启动渲染线程
}
// 3. 渲染线程核心逻辑
privateclassRenderThreadextendsThread {
@Override
publicvoidrun(){
long lastFrameTime = System.currentTimeMillis();
while (isAnimRunning) {
// 3.1 计算帧间隔(控制60FPS)
long currentTime = System.currentTimeMillis();
long frameInterval = Math.min(currentTime - lastFrameTime, 50);
lastFrameTime = currentTime;
// 3.2 更新圆的位置(轨迹计算)
updateCirclePosition(frameInterval);
// 3.3 绘制帧
Canvas canvas = lockCanvas();
if (canvas != null) {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
drawAllCircles(canvas); // 绘制三个圆
unlockCanvasAndPost(canvas);
}
// 3.4 帧率控制
long cost = System.currentTimeMillis() - currentTime;
if (cost < 16) {
Thread.sleep(16 - cost);
}
}
}
}
// 4. 生命周期清理
@Override
publicvoidonSurfaceTextureDestroyed(SurfaceTexture surface){
isAnimRunning = false;
renderThread.join(1000); // 等待线程结束
returntrue;
}
}
1. 线程使用情况
仍然是在子线程运行,不会阻塞UI线程:

2. 刷帧的耗时
根据日志可以确认,每帧的绘制时间在10ms以内,不影响上屏:
makefile
2026-02-1217:36:07.69125937-25978 ApertureView com.ne.shareelement D 帧总耗时: 8ms <840>
2026-02-1217:36:07.69125937-25978 ApertureView com.ne.shareelement D ├─ 动画计算: 1ms <841>
2026-02-1217:36:07.69125937-25978 ApertureView com.ne.shareelement D ├─ 渲染绘制: 7ms <842>
2026-02-1217:36:07.69125937-25978 ApertureView com.ne.shareelement D └─ 帧间隔: 16.0ms <843>
2026-02-1217:36:07.70325937-25978 ApertureView com.ne.shareelement D 帧总耗时: 10ms <888>
2026-02-1217:36:07.70325937-25978 ApertureView com.ne.shareelement D ├─ 动画计算: 0ms <889>
2026-02-1217:36:07.70325937-25978 ApertureView com.ne.shareelement D ├─ 渲染绘制: 10ms <890>
2026-02-1217:36:07.70325937-25978 ApertureView com.ne.shareelement D └─ 帧间隔: 16.0ms <891>
3. 效果展示

如果想在该图层上面添加交互,也是可以的。
五、动效方案的选择
动效方案的选择核心是「匹配场景需求 + 平衡性能 / 开发成本」------ 前文通过底层原理拆解和实战验证,已明确各方案的性能瓶颈与适用边界,本节将从选择原则、场景化选型、决策流程、避坑细则 四个维度,给出可直接落地的团队选型规范。
5.1 核心选择原则(优先级排序)
选型需围绕「性能优先、成本次之、体验兜底」的核心逻辑,优先满足以下原则(按优先级从高到低):
- **性能适配性:**动效单帧渲染耗时必须≤屏幕刷新间隔(60Hz 下 16.67ms),避免掉帧 / 卡顿;后台 / 低性能设备需额外验证下限;
- 场景匹配度:动效功能(如颜色定制、跨平台、循环播放)需完全覆盖产品需求,不做 "过度技术选型";
- 开发 / 维护成本:优先选择团队熟悉的技术栈,跨平台方案需评估设计 / 研发协同成本;
- 兼容性 / 稳定性:覆盖目标机型(如 Android 8.0+),避免因生命周期 / 资源管理导致 ANR/OOM;
- 设计还原度:矢量动效需保证设计稿 1:1 还原,位图动效需控制分辨率 / 体积。
5.2 场景化选型指南(细化版)
基于前文验证的性能数据和瓶颈,对各方案的选型维度做精细化补充(表格为团队落地核心参考):

前文 "三色圆轨迹背景动效" 的选型过程,完全匹配上述原则:
- 排除 Lottie/PAG:需动态定制圆的颜色,而 Lottie/PAG 基于固定资源文件,无法满足;
- 排除 Native Canvas:长时循环播放会阻塞主线程,影响前景 UI 刷新;
- 排除 SurfaceView:仅需简单绘制,无需极致性能,且 SurfaceView 生命周期不同步增加维护成本;
- 最终选 TextureView:满足 "子线程渲染 + 生命周期对齐 + 颜色定制 + 长时运行" 核心需求,且单帧耗时≤10ms,符合性能要求。
5.3 动效方案决策流程
为避免选型混乱,建议团队遵循以下标准化决策流程(可直接嵌入研发规范):

总结一下:
- 动效选型核心是「匹配场景 + 平衡性能 / 成本」,优先用最简单的方案满足需求(如 Native 动效覆盖 80% 简单场景);
- 复杂动效优先选 PAG(性能)/Lottie(跨平台),长时运行的背景动效优先选 TextureView;
- 所有方案需验证 "单帧耗时≤16.67ms" 的核心性能基线,避免掉帧 / 卡顿。