动效开发不踩坑:几种动效实现方案对比与实战选型

作者:vivo 互联网大前端团队 - Xu Jie

本文从 Android 渲染系统内核出发,系统拆解 Native、Lottie、PAG、SurfaceView、TextureView五种动效方案的底层原理,通过多维度量化测试建立性能评估模型,结合实战场景提供可落地的优化策略与选型体系,帮助开发者突破动效性能瓶颈。

1分钟看图掌握核心要点👇

一、引言

随着Android设备硬件性能的提升,用户对动效的流畅度、复杂度要求日益提高。从简单的View属性动画到复杂的品牌营销动效,不同场景下的技术选型直接影响应用的性能表现(如帧率、内存占用)和开发效率。然而,多数开发者仅停留在会用层面,缺乏对渲染机制、性能瓶颈的深度理解,导致动效场景出现卡顿、OOM、兼容性问题。

本文将从 内核原理量化测试优化实践工程落地 四个维度,构建完整的动效技术知识体系,核心解决三大问题:

  1. 不同动效方案的底层渲染逻辑差异何在?
  2. 如何通过量化指标精准评估动效性能?
  3. 如何结合场景实现动效的高性能落地?

二、动效渲染系统内核基础

要理解动效性能差异,需先掌握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 动效要求

  1. 做一个控件,沿着不同的轨迹绘制三个圆,这三个圆要不断的按照自己的轨迹循环滚动,不能停止
  2. 这三个圆的颜色是可以定制的,可以是任何色值
  3. 这三个圆是长时间持续运行动画,页面存在的情况下,动画不停止
  4. 该动效是在其他视图的底层,作为背景进行展示

示意图大致如下:

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" 的核心性能基线,避免掉帧 / 卡顿。
相关推荐
Ricon组态薄荷糖12 分钟前
Ricon组态系统:工业组件开发指南与实践
前端·后端·物联网
flash俊杰17 分钟前
Electron 桌面应用的进程模型:为什么 fork Next.js standalone
前端
沙蒿同学27 分钟前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持30 分钟前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby30 分钟前
企业级后台管理系统路由设计与最佳实践指南
前端
胡志辉的博客39 分钟前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku1 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
TiDi1 小时前
Pinia优化重复请求
前端
web3d5202 小时前
01-用 Leafletjs 10 分钟搭一张水利一张图(Vue3 + Vite 实战)
前端·javascript
晚安日记wanna2 小时前
Vue2 的 defineProperty 差在哪四层追问筛掉九成候选人
前端·vue.js·面试