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

作者: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" 的核心性能基线,避免掉帧 / 卡顿。
相关推荐
Csvn1 小时前
【Vue3】Composition API vs Options API —— 什么场景该选哪个
前端
Csvn1 小时前
Vue3 迁移血泪史:v-model 的 .sync 陷阱,90% 升级项目都会踩
前端·vue.js
光影少年1 小时前
js单线程,为什在node环境下的js可以处理高并发请求?
前端·javascript·掘金·金石计划
vim怎么退出1 小时前
Dive into React——事件系统
前端·react.js·源码阅读
KaMeidebaby1 小时前
卡梅德生物技术快报|重组蛋白的表达和纯化:工艺调试全记录:大肠杆菌体系重组蛋白的表达和纯化参数标定(肠激酶轻链案例)
前端·人工智能·算法·数据挖掘·数据分析
Cobyte1 小时前
19.Vue Vapor 的实现原理原来这么简单
前端·javascript·vue.js
郝学胜-神的一滴2 小时前
中级OpenGL教程 009:用环境光告别模型死黑
前端·c++·unity·godot·图形渲染·opengl·unreal
半岛盒子2 小时前
AI Coding方案与事件流(前端)
前端
星栈2 小时前
Makepad 应用如何读文件、调接口、保存数据
前端·rust