一、项目介绍
1. 背景与动机
在现代移动应用中,加载动画不仅能向用户传达“正在努力加载中”的信号,缓解等待焦虑,还能通过视觉效果提升品牌识别度与用户体验。无论是网络请求、复杂计算、还是页面过渡,都离不开加载状态的优雅表达。本项目旨在演示如何在 Android 中从零实现多种常见的加载动画:
-
系统原生 ProgressBar:基于
indeterminateDrawable
的自定义风格 -
属性动画:通过
ObjectAnimator
、ValueAnimator
实现自定义 View 动画 -
帧动画:基于
AnimationDrawable
的帧序列加载 -
AnimatedVectorDrawable:矢量动画,兼容性好、文件轻量
-
Lottie 动画:基于 Airbnb Lottie 库,支持 After Effects 导出 JSON 动画
并封装为易用的组件与工具类,使业务层仅需一行调用,即可实现高质感加载动画。
2. 功能需求
-
多种加载样式:圆形旋转、淡入淡出、波浪、心跳、渐变环
-
可配置:颜色、尺寸、速度、插值器、循环次数
-
组件化:封装为
LoadingView
,支持 XML 属性与代码动态配置 -
简易集成:只需在布局中添加
<com.example.loading.LoadingView/>
-
Lifecycle 感知:自动在
onAttachedToWindow
开启动画,在onDetachedFromWindow
停止 -
性能优化:硬件加速、资源复用、避免过度重绘
二、相关知识
-
View 动画 vs 属性动画 vs 帧动画
-
View Animation(补间动画):旧版
ScaleAnimation
/RotateAnimation
,仅改 UI 表现,不改变属性 -
Property Animation(属性动画):API 11+,通过
ObjectAnimator.ofFloat(view,"rotation",0,360)
直接修改属性 -
Frame Animation:
AnimationDrawable
逐帧切换图片,适合复杂帧序列
-
-
AnimatedVectorDrawable
-
基于矢量资源(
vector.xml
),用<animated-vector>
配合<objectAnimator>
对路径或属性做动画 -
文件小、可缩放,无像素损失
-
-
Lottie
-
Airbnb 开源,支持将 After Effects 动画导出为 JSON,在原生端渲染
-
支持无缝循环、动态替换颜色、图层控制
-
-
Canvas 绘制与自定义 View
-
通过重写
onDraw(Canvas)
,结合ValueAnimator
驱动invalidate()
实现自绘动画 -
可绘制圆环、波浪、心形等自定义形状
-
-
插值器(Interpolator)
-
LinearInterpolator
:匀速 -
AccelerateDecelerateInterpolator
:先加速再减速 -
BounceInterpolator
、OvershootInterpolator
:弹跳、回弹
-
三、实现思路
本文将按照由浅入深的顺序,依次实现以下几种加载动画,并最终封装:
-
系统 ProgressBar
-
自定义
indeterminateDrawable
:一个旋转的矢量圆环
-
-
属性动画 + 自定义 View
-
LoadingView1
:一个手写的圆环旋转动画
-
-
AnimatedVectorDrawable
-
LoadingView2
:在 XML 中定义animated-vector
,在 View 中直接调用setImageDrawable()
-
-
Lottie
-
LoadingView3
:集成 Lottie 库,加载*.json
动画文件
-
-
统一封装
-
LoadingView
:通过 XML 属性app:loadingType="progress|custom|vector|lottie"
选择实现 -
动画生命周期管理、API 暴露
start()
,stop()
-
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.loading"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget="1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'com.airbnb.android:lottie:6.0.0'
}
五、整合代码
// =======================================================
// 文件: res/values/attrs.xml
// 描述: LoadingView 的自定义属性,type、color、size、speed
// =======================================================
<resources>
<declare-styleable name="LoadingView">
<!-- loading 类型: 0=ProgressBar,1=CustomView,2=Vector,3=Lottie -->
<attr name="lv_type" format="integer"/>
<!-- 颜色 -->
<attr name="lv_color" format="color"/>
<!-- 大小 dp -->
<attr name="lv_size" format="dimension"/>
<!-- 速度: ms per cycle -->
<attr name="lv_duration" format="integer"/>
<!-- Lottie 文件名 -->
<attr name="lv_lottieFile" format="string"/>
</declare-styleable>
</resources>
// =======================================================
// 文件: res/drawable/progress_ring.xml
// 描述: 矢量圆环,用于 ProgressBar drawable
// =======================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="100" android:viewportHeight="100">
<group android:pivotX="50" android:pivotY="50">
<path
android:name="ring"
android:fillColor="@android:color/transparent"
android:strokeColor="?attr/colorPrimary"
android:strokeWidth="10"
android:pathData="M50,10 A40,40 0 1,1 49.99,10"/>
</group>
</vector>
// =======================================================
// 文件: res/drawable/anim_progress.xml
// 描述: ProgressBar 的 indeterminateAnimationList
// =======================================================
<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/progress_ring"
android:pivotX="50%" android:pivotY="50%"/>
// =======================================================
// 文件: LoadingView.kt
// 描述: 统一封装 LoadingView,根据 type 加载不同实现
// =======================================================
package com.example.loading
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.withStyledAttributes
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
class LoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
companion object {
const val TYPE_PROGRESS = 0
const val TYPE_CUSTOM = 1
const val TYPE_VECTOR = 2
const val TYPE_LOTTIE = 3
}
private var type = TYPE_PROGRESS
private var color = 0xFF000000.toInt()
private var sizePx = 100
private var duration = 1000
private var lottieFile: String? = null
// 子 View
private var progressBar: ImageView? = null
private var customView: CustomLoadingView? = null
private var vectorView: ImageView? = null
private var lottieView: LottieAnimationView? = null
init {
context.withStyledAttributes(attrs, R.styleable.LoadingView) {
type = getInt(R.styleable.LoadingView_lv_type, TYPE_PROGRESS)
color = getColor(R.styleable.LoadingView_lv_color, color)
sizePx = getDimensionPixelSize(R.styleable.LoadingView_lv_size, sizePx)
duration = getInt(R.styleable.LoadingView_lv_duration, duration)
lottieFile = getString(R.styleable.LoadingView_lv_lottieFile)
}
when (type) {
TYPE_PROGRESS -> initProgressBar()
TYPE_CUSTOM -> initCustom()
TYPE_VECTOR -> initVector()
TYPE_LOTTIE -> initLottie()
}
}
private fun initProgressBar() {
progressBar = AppCompatImageView(context).apply {
setImageResource(R.drawable.anim_progress)
(drawable as? android.graphics.drawable.AnimatedRotateDrawable)?.start()
}
addView(progressBar, LayoutParams(sizePx, sizePx))
}
private fun initCustom() {
customView = CustomLoadingView(context).apply {
setColor(color); setDuration(duration)
start()
}
addView(customView, LayoutParams(sizePx, sizePx))
}
private fun initVector() {
vectorView = AppCompatImageView(context).apply {
setImageResource(R.drawable.progress_ring)
val avd = drawable as android.graphics.drawable.AnimatedVectorDrawable
avd.registerAnimationCallback(object: android.graphics.drawable.AnimatedVectorDrawable.AnimationCallback(){
override fun onAnimationEnd(drawable: android.graphics.drawable.Drawable?) { avd.start() }
})
avd.start()
}
addView(vectorView, LayoutParams(sizePx, sizePx))
}
private fun initLottie() {
lottieView = LottieAnimationView(context).apply {
lottieFile?.let { setAnimation(it) }
repeatCount = LottieDrawable.INFINITE
playAnimation()
}
addView(lottieView, LayoutParams(sizePx, sizePx))
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
customView?.stop()
(vectorView?.drawable as? android.graphics.drawable.AnimatedVectorDrawable)?.stop()
(progressBar?.drawable as? android.graphics.drawable.AnimatedRotateDrawable)?.stop()
lottieView?.cancelAnimation()
}
}
// =======================================================
// 文件: CustomLoadingView.kt
// 描述: 自定义 Property Animation 圆环旋转 View
// =======================================================
package com.example.loading
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator
class CustomLoadingView @JvmOverloads constructor(
ctx: Context, attrs: AttributeSet? = null
) : View(ctx, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeWidth = 8f
}
private val rectF = RectF()
private var sweepAngle = 0f
private var startAngle = 0f
private var duration = 1000
private val animator = ValueAnimator.ofFloat(0f, 360f).apply {
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener {
startAngle = it.animatedValue as Float
sweepAngle = 90f
invalidate()
}
}
fun setColor(c: Int) { paint.color = c }
fun setDuration(d: Int) { duration = d; animator.duration = d.toLong() }
fun start() { animator.start() }
fun stop() { animator.cancel() }
override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) {
super.onSizeChanged(w, h, ow, oh)
val pad = paint.strokeWidth/2
rectF.set(pad, pad, w-pad, h-pad)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawArc(rectF, startAngle, sweepAngle, false, paint)
}
}
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 示例页面,使用四种 LoadingView
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:padding="24dp"
android:gravity="center"
android:layout_width="match_parent" android:layout_height="match_parent">
<com.example.loading.LoadingView
android:layout_margin="8dp"
app:lv_type="0" app:lv_color="#FF4081" app:lv_size="64dp"/>
<com.example.loading.LoadingView
android:layout_margin="8dp"
app:lv_type="1" app:lv_color="#3F51B5"
app:lv_size="64dp" app:lv_duration="800"/>
<com.example.loading.LoadingView
android:layout_margin="8dp"
app:lv_type="2" app:lv_size="64dp"/>
<com.example.loading.LoadingView
android:layout_margin="8dp"
app:lv_type="3" app:lv_size="80dp"
app:lv_lottieFile="lottie/loading.json"/>
</LinearLayout>
六、代码解读
-
自定义属性 (
attrs.xml
)-
lv_type
决定使用哪种加载方式:系统ProgressBar
、自定义 View、Animated Vector、Lottie; -
其它属性如
lv_color
、lv_size
、lv_duration
、lv_lottieFile
支持在 XML 中灵活配置。
-
-
系统 ProgressBar 方案
-
progress_ring.xml
定义了一个半开的圆环矢量; -
anim_progress.xml
将其包装成AnimatedRotateDrawable
自动旋转; -
在
LoadingView
中用ImageView
展示并启动旋转动画。
-
-
自定义 Property Animation 方案
-
CustomLoadingView
重写onDraw
,用drawArc
绘制一个固定角度的弧段; -
通过
ValueAnimator
不断更新startAngle
,实现旋转效果;
-
-
AnimatedVectorDrawable 方案
-
直接在代码中将
progress_ring
当作AnimatedVectorDrawable
使用; -
注册
AnimationCallback
循环播放;
-
-
Lottie 方案
-
依赖 Lottie 库,加载指定 JSON 动画文件;
-
设置
repeatCount = INFINITE
并playAnimation()
;
-
-
统一封装
LoadingView
-
构造时读取属性并创建对应子 View;
-
在
onDetachedFromWindow
中停止动画,防止内存泄露; -
业务层仅需在布局中添加一行即可使用四种动画。
-
七、性能与优化
-
硬件加速
-
矢量和属性动画在硬件加速下渲染效率高,无需额外设置;
-
-
资源复用
-
AnimatedRotateDrawable
与AnimatedVectorDrawable
均可复用动画对象; -
ValueAnimator
在CustomLoadingView
中作为成员变量,仅创建一次;
-
-
局部重绘
-
自定义 View 调用
invalidate()
时默认全屏重绘,可考虑invalidate(rect)
限定区域;
-
-
Lottie 最优化
-
合理拆分 JSON 动画,移除无用图层,减少渲染开销;
-
只在可见时播放,离屏时
cancelAnimation()
。
-
八、项目总结与拓展
本文深入讲解了 Android 中加载动画的多种实现方式,并通过 LoadingView
组件将它们统一封装,极大降低业务层集成成本。可根据产品需求,在不同场景下快速切换加载样式。
拓展方向
-
更多动画:比如波浪、心跳、圆点脉冲等;
-
交互引导:结合加载动画与指引气泡,引导用户首次使用;
-
Compose 重构:使用
Canvas
与animate*AsState
迁移到 Jetpack Compose; -
主题适配:支持夜间模式、品牌色切换;
-
性能监测:结合 Systrace 或 GPU Overdraw Checker 监测动画性能瓶颈。
九、FAQ
-
Q:AnimatedVectorDrawable 支持哪些 API?
A:Android 5.0+ 原生支持,低版本可用支持库androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
。 -
Q:Lottie JSON 文件从哪儿来?
A:可从 LottieFiles 下载,或在 After Effects 中用 Bodymovin 插件导出。 -
Q:如何在列表中复用 LoadingView?
A:在 RecyclerView Adapter 中直接在 item 布局里引入LoadingView
,自动启动/停止动画。 -
Q:自定义 View 性能卡顿怎么办?
A:减少绘制复杂度,使用PathMeasure
预计算,启用硬件加速。 -
Q:如何动态切换加载类型?
A:在LoadingView
中提供setType()
方法,销毁旧子 View 并重新initXXX()
。