做Android界面的人早晚都会遇到这么个需求一个按钮要轻轻弹一下表示被点中一张卡片从底部滑上来一个数字带个动态效果变成另一个数字。这些场景在Android里能用的方案很多但最常用、最好理解、也最值得花时间彻底吃透的是属性动画Property Animation里的ObjectAnimator。这篇文章不打算做成API手册搬运而是尽量把ObjectAnimator背后“为什么能这样”、实际怎么组织代码、会遇到哪些坑讲清楚。不管你是刚接触Android动画的新手还是写过一些自定义View但一直对动画原理有些模糊的同学看完之后应该都能自己写出流畅的入场动画、组合动画和联动效果至少遇到动画不生效、位置跳变、内存泄漏这类问题的时候知道该从哪里下手排查。下面我用一个完整项目的视角来拆解尽量不聊空话。1. 为什么从View动画转向属性动画1.1 View动画的三个痛点早期Android开发普遍用的是补间动画View Animation也就是对View做透明、位移、缩放、旋转这类视觉变化。这东西不是不能用但它在项目里累积到一定程度问题就会暴露出来。第一个痛点是它只改变视觉不改变真实属性。一张卡片从左边移动到右边动画结束后卡片看起来确实在新位置可你去点击卡片响应点击的仍然是原来的位置。对用户来说这就是“东西和手对不上”的错觉。很多老项目的按钮点击区域错位根因就是补间位移动画没做额外处理。第二个痛点是动画结束后的状态维持很别扭。补间动画跑完默认会回到动画前的样子所以你必须设置fillAftertrue才能留在结束帧但即使留在结束帧它也只是“画”在那里控件的实际属性值并没有变。如果你接下来还想基于动画后的状态继续做布局、做事件绑定就会发现自己处理的是“旧世界”的数据。第三个痛点是它只适用于View自带的几种视觉效果。想让背景色渐变、让自定义View里的某个数值变化补间动画基本无能为力。它本质上是在绘制层做变换动不了你业务逻辑里的字段。1.2 属性动画的真实机制属性动画的思路完全不一样它的核心动作是每一帧修改目标对象的某个真实属性值然后触发界面重绘。比如你给一个View做translationX动画在动画运行的每一帧系统都会把translationX属性设为某一个具体的浮点数并调用invalidate()让View重新绘制。这个值是真实存在的动画结束后控件的平移量、透明度、旋转角度都是真实状态。点击区域跟着走业务数据也跟着变不会再出现视觉和逻辑脱节。打个生活化的比方补间动画相当于给原本在A点的箱子拍了张“已经到B点”的照片贴在屏幕上真正的箱子还在A点属性动画则是推着箱子一步一步走到B点走到哪箱子就在哪。后者虽然比前者稍微“重”一点但它是真的在改变世界。还有一个很实用的小知识属性动画的每一帧并不是靠写死的定时器触发而是由系统的垂直同步信号驱动的。开发者设定好动画时长和插值方式系统每16毫秒左右回调一次算出当前进度对应的属性值。所以动画总时长是相对精确的但实际每一帧的间隔会受到主线程负载影响掉几帧并不会导致动画整体时间错乱只是中间过程会相对顺滑或卡顿。1.3 谁最适合把属性动画用起来从我的经验看属性动画适合这几类需求想让控件在改变位置后点击区域同步移动。想让自定义View里的状态字段比如进度、展开比例跟随动画变化。想在一个动画里同时改变多个属性并精确控制先后顺序。想在动画不同阶段做回调比如动画结束后再弹Toast、加载数据。如果你只是做个简单的淡入淡出用属性动画也不亏但如果你想做得复杂、组合多、需要联动用ObjectAnimator这套体系能把代码写得清晰很多。这也是我写这篇文章的核心动机把复杂动效的“地基”说清楚。2. ObjectAnimator的基本用法与核心参数2.1 为什么属性名是字符串ObjectAnimator最基本的使用方式是ObjectAnimator.ofFloat(view, alpha, 0f, 1f).apply { duration 300 start() }很多刚接触的人会好奇为什么第二个参数“alpha”用字符串而不是一个方法引用、一个枚举。这和Android的反射机制有关。ObjectAnimator在背后通过这个字符串拼出属性的getter和setter方法名比如alpha对应getAlpha()和setAlpha()translationX对应getTranslationX()和setTranslationX()。动画启动后它会用getter读取当前值作为动画起点在动画的每一帧用setter写入新计算出来的值。所以属性名不能随便写目标类里必须有对应的方法而且getter和setter的命名要符合AndroidBeans规范。否则大概率会出现两种结果要么在启动时报找不到属性的异常要么动画运行全程都失效界面上看不出任何变化。而一旦你给自定义View写了一个既没有getter也没有setter的字段却又拿着这个字段名去开动画基本就是给自己挖坑。自定义View想要动画某个自有属性标准写法是class ProgressView JvmOverloads constructor(...) : View(...) { var progress: Float 0f set(value) { field value invalidate() } }这里setter里调用invalidate()非常关键因为属性动画只负责改值不负责刷新界面刷新界面是View自己的职责。如果不调invalidate()哪怕progress一直在变化画布上也看不出任何效果。这个细节很多教程没提但实际项目中经常是“动画播了、值变了、画面不动”的元凶。2.2 高频属性盘点ObjectAnimator最常打交道的属性主要集中在View的变换类属性上我整理了一份平时用得比较多的属性名作用取值范围/说明translationX相对原始位置的横向偏移单位像素正右负左translationY相对原始位置的纵向偏移单位像素正下负上rotation绕View中心旋转角度单位度顺时针为正rotationX绕水平轴旋转类似翻牌效果rotationY绕垂直轴旋转卡片翻转效果scaleX水平缩放比例1.0为原始大小scaleY垂直缩放比例1.0为原始大小alpha透明度0完全透明1完全不透明xView最终的横向坐标设置x会连带影响translationXyView最终的纵向坐标设置y会连带影响translationY很多新手分不清x和translationX的区别。简单地说x是控件的绝对位置translationX是在原位置基础上的偏移量两者存在换算关系x 原x坐标 translationX。如果你同时给这两个属性设置动画结果容易互相覆盖所以我一般建议项目里尽量统一用translationX/translationY做位移动画这样和布局位置不冲突后续要回到原点也好处理。2.3 起始值、时长、延迟与重复ObjectAnimator的ofFloat方法支持多种参数形式只写一个值表示从当前值动画到该值。写两个值表示从起始值动画到结束值。写多个值表示动画分多段依次经过列表中每个值。实际项目里我最推荐明确写出起始值和结束值。虽然只写一个值在代码上更简洁但“当前值”这个概念在动画开始前可能受其他动画、代码逻辑影响并不总是你想象的那样。显式写出两个值至少能让结果可预期。时长用setDuration()控制单位毫秒默认是300毫秒。延迟用setStartDelay()控制单位也是毫秒。重复次数用setRepeatCount()重复模式用setRepeatMode()可取值是RESTART和REVERSE前者每次从头播放后者反向播放来回弹。有一个常被误解的点属性动画不像补间动画那样依赖fillAfter来停留在结束状态。因为属性动画执行完毕目标值已经真实写入属性了不需要额外的“保留画面”参数。如果动画结束后画面突然跳回原样通常是启动了一个新动画把当前值又改回去了这个后面排查章节会详细说。3. 组合动画的三种姿势单打独斗的ObjectAnimator只能解决单一属性的变化真实场景里几乎都是一张卡片同时位移、缩放、变透明或者先淡入再弹出。组合动画有三套常用工具各有各的适用场景。3.1 PropertyValuesHolder一个动画改多个属性如果想让同一个目标对象的多个属性同步变化最直接的做法是用PropertyValuesHolderval holderX PropertyValuesHolder.ofFloat(translationX, 0f, 200f) val holderAlpha PropertyValuesHolder.ofFloat(alpha, 0f, 1f) ObjectAnimator.ofPropertyValuesHolder(view, holderX, holderAlpha).apply { duration 400 start() }这样做的好处是多个属性变化由同一个Animator驱动只占用一个时间轴不需要额外同步管理成本和拆分的多次动画相比更低。它的另一个隐含价值是代码聚合性好。一个属性对应一个PropertyValuesHolder要调整参数时一目了然不会像多个动画对象堆在一起时那样混乱。注意同一属性的holder不要重复传否则后一个值可能把前一个覆盖掉容易出现“明明写了位移却只看到透明度变化”的怪问题。3.2 AnimatorSet编排复杂时序当动画之间有先后顺序或者要对多个不同View做协同就需要AnimatorSet。它支持四种编排方式play(A).with(B)A和B同时播。play(A).before(B)A先播B等A结束再播。play(A).after(B)A等B结束再播。play(A).after(long)A延迟指定时间后播。看一个典型例子让卡片先滑入屏幕再做一个轻微的缩放回弹。val slide ObjectAnimator.ofFloat(card, translationY, 400f, 0f) val fade ObjectAnimator.ofFloat(card, alpha, 0f, 1f) val scaleX ObjectAnimator.ofFloat(card, scaleX, 0.8f, 1f) val scaleY ObjectAnimator.ofFloat(card, scaleY, 0.8f, 1f) AnimatorSet().apply { play(fade).with(scaleX).with(scaleY).with(slide) duration 500 interpolator DecelerateInterpolator() start() }如果希望缩放在滑动结束后再触发可以把with改成after。比如AnimatorSet().apply { play(slide).with(fade) play(scaleX).after(slide).with(scaleY) start() }这里有一个我踩过的坑当几个动画作用于同一个View的同一组属性时后启动的动画在start阶段会去读取当前属性值而当前属性值正被前一个动画控制着如果不显式写明起始值可能会出现“卡一下再动”的现象。更安全的方式是每个动画都显式写清楚from和to并把相互冲突的属性拆到不同的时间片段。3.3 ViewPropertyAnimator最省代码的链式方案如果你只想给一个View做一套简单的组合动画ViewPropertyAnimator是最舒服的选择view.animate() .alpha(1f) .translationY(0f) .scaleX(1f) .scaleY(1f) .setDuration(400) .setInterpolator(DecelerateInterpolator()) .start()它的内部实现就是属性动画体系的封装专门针对View做了优化多个属性调用会被合并到一个Animator里处理性能很好。写法上天然就是链式的代码量很小。但要注意ViewPropertyAnimator只适合那种“同一个View多个属性同时变化”的场景。一旦涉及多对象协作、复杂的前后依赖它就不够用了这种情况还是回到AnimatorSet。简单原则动画复杂性低用ViewPropertyAnimator复杂性高用AnimatorSet追求最小对象数用PropertyValuesHolder。3.4 一个可以直接抄的入场动效综合上面的工具写一个比较标准的底部卡片入场动画。假设目标是某个卡片View我需要它从屏幕下方滑入、淡入、同时从0.9倍放大到正常尺寸fun animateCardIn(card: View) { card.alpha 0f card.translationY 400f card.scaleX 0.9f card.scaleY 0.9f card.animate() .alpha(1f) .translationY(0f) .scaleX(1f) .scaleY(1f) .setDuration(500) .setInterpolator(DecelerateInterpolator()) .withEndAction { // 动画结束后的联动逻辑 } .start() }这段代码先手动把初始状态设置好再交给ViewPropertyAnimator去跑。为什么要先手动设置初始值因为如果不先把alpha设为0f动画启动瞬间View可能还是全透明的然后才跳到0f会产生一次闪烁。如果你用ObjectAnimator的两个参数版本系统会自动在动画开始时把属性值设置成起始值视觉上通常没问题但用ViewPropertyAnimator这种“只写终点值”的写法时手动初始化非常有必要。4. 插值器与TypeEvaluator控制动画的节奏与数值映射4.1 插值器是动画的灵魂两个动画时长相同用不用插值器、用什么插值器最终观感天差地别。插值器Interpolator决定了时间进度到动画进度之间的换算规则。举个例子线性插值器LinearInterpolator会让动画匀速前进AccelerateDecelerateInterpolator会在开头和结尾都慢中间快DecelerateInterpolator是开头快、结尾慢很多入场动画用它收尾显得干净舒服BounceInterpolator则在终点附近模拟弹跳OvershootInterpolator会先冲到目标值再回拉一点适合强调感强的效果。我一般按这个思路选场景推荐插值器元素入场DecelerateInterpolator元素退场AccelerateInterpolator需要回弹、俏皮感BounceInterpolator需要过冲强调OvershootInterpolator机械匀速运动LinearInterpolator插值器不会改变动画的时长它改变的只是“时间进度”到“数值进度”的映射。所以即使你用了一个看起来很跳脱的插值器动画总时长依然是你设置的duration。4.2 Evaluator数值类型的关键转换插值器输出的是一个0到1之间的“进度比例”但真实属性值不一定是浮点数。比如alpha属性需要的是float颜色属性需要的是int还有自定义对象类型。把“进度比例”换算成具体属性值的部分就由TypeEvaluator完成。默认情况下ObjectAnimator.ofFloat会用FloatEvaluatorofInt会用IntEvaluator。如果你想做颜色渐变直接用ofInt设初始颜色和结束颜色然后指定ArgbEvaluatorval colorAnim ObjectAnimator.ofInt( view, backgroundColor, Color.WHITE, Color.parseColor(#FF3F51B5) ) colorAnim.setEvaluator(ArgbEvaluator()) colorAnim.duration 600 colorAnim.start()ArgbEvaluator做的事情并不复杂它把颜色拆成ARGB四个通道分别按进度插值再组合成一个int颜色值。没有它的帮忙对int做FloatEvaluator式的线性插值颜色会走得很难看。如果默认的Evaluator满足不了需求比如你需要按贝塞尔曲线路径移动、需要分段跳跃那就自定义一个TypeEvaluator。下面是一个“先停顿再执行”的自定义Evaluator示例class StepEvaluator : TypeEvaluatorInt { override fun evaluate(fraction: Float, startValue: Int, endValue: Int): Int { return if (fraction 0.5f) startValue else endValue } }配合ValueAnimator或ObjectAnimator使用可以让动画前半段停在起始值、后半段瞬间跳到结束值。这种分段逻辑在做步骤条、分帧动画时很有用。4.3 从时间到屏幕的一帧链路把这一整套流程串起来看你就能理解属性动画每一帧发生了什么时钟推进动画进度从0增长到1系统计算出当前帧对应的原始时间比例fraction。fraction传给插值器插值器返回调整后的插值比例。插值比例传给TypeEvaluatorEvaluator结合起始值和结束值算出这一帧的具体属性值。属性值通过setter写入目标对象。如果目标对象是Viewsetter里的invalidate()会触发下一轮绘制。这个链路清晰解释了为什么属性动画能作用于非View对象。你完全可以拿ValueAnimator去驱动一个进度条模型、去控制音频音量、去驱动某个手势动画的中间状态。ObjectAnimator只是ValueAnimator在“属性赋值”这个环节上做了自动化处理的版本它要求目标有对应的setter/getter而ValueAnimator则把每一次计算后的值交给你自己处理灵活度最高。理解这个层级关系后再看一些复杂动效框架的源码就不会觉得晦涩了。5. 动画监听、暂停恢复与生命周期5.1 动画状态回调的正确打开方式给动画添加监听器的标准做法是anim.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { // 动画正常结束或主动取消都会走到这里 } override fun onAnimationCancel(animation: Animator) { // 主动取消时才会走到这里 } })AnimatorListenerAdapter是个空实现适配器可以只重写关心的方法比较省事。这里最值得注意的一点是onAnimationEnd在动画被cancel时也会被调用。如果你在onAnimationEnd里写“动画结束之后才允许点击”而逻辑上又做了取消操作可能会导致状态提前满足条件。所以必要的时候要加一个boolean标志位区分到底是正常结束还是取消结束尤其在处理用户快速点击、页面退出的场景里。5.2 AnimatorUpdateListener的联动价值有些时候你需要的不只是“动画播完了通知我”而是“每一帧我都要拿动画中间值去做点别的事”。比如一个进度条动画希望在进度变化时同步更新文本数字一个拖拽手势希望在松手后能用动画把View“甩”到目标位置。这些场景用AnimatorUpdateListenerval anim ValueAnimator.ofFloat(0f, 100f) anim.addUpdateListener { val value it.animatedValue as Float progressText.text ${value.toInt()} progressBar.progress value.toInt() } anim.start()注意不要在update回调里做耗时操作。它每一帧都会触发发生在UI线程任何过度计算都会直接导致掉帧。如果你要在动画过程中频繁更新复杂布局优先考虑减少刷新范围或者把动画本身拆细。5.3 暂停恢复与页面销毁的关键处理Android 4.4API 19之后动画自带pause()和resume()方法可以在应用退到后台时暂停动画回到前台再继续。但要注意暂停恢复只对“动画对象还在内存里”有效如果页面销毁了、View被回收了单纯暂停已经不够必须主动清理。我最常强调的一点是无限动画一定要有明确的取消路径。像加载转圈、呼吸灯这类repeatCount为INFINITE的动画在Activity或Fragment销毁时如果不调用cancel()动画会一直持有着目标View的引用甚至持续向一个已经不在界面的View写入属性并触发绘制轻则内存泄漏重则影响后续页面的帧率。比较稳妥的习惯是在View的onDetachedFromWindow回调里统一cancel动画或者在Activity的onDestroy里调用动画cancel并清空listener。这样即使动画是无限循环的也能顺着View的生命周期安全退场。6. 性能优化与踩坑经验速查6.1 硬件加速与离屏渲染的取舍属性动画默认会使用硬件加速来合成绘制大部分场景性能没问题。但如果你对一张带阴影、圆角、实时模糊的复杂卡片做连续的alpha或位移动画某些低端机上可能会有些吃力。这时可以考虑在动画开始前给View设置view.setLayerType(View.LAYER_TYPE_HARDWARE, null)把View渲染到一个离屏缓冲区之后的帧画面可以直接复用减少重复绘制的开销。动画结束后要记得恢复view.setLayerType(View.LAYER_TYPE_NONE, null)否则离屏缓冲会一直占着图形内存。如果View本身很大比如全屏级别的画布硬件图层内存占用也是肉眼可见的务必用完即清。这个技巧不是每个动画都需要只有当动画对象复杂、确实出现卡顿感时再考虑否则就是白白加内存开销。6.2 常见问题速查表现象可能原因处理建议动画一点效果都没有属性名拼写错误或目标类没有对应setter/getter优先查logcat里的属性异常给自定义View补getter和setter动画结束位置突然跳回又启动了新动画读取了旧的属性值作为起点新动画显式写明起始值检查AnimatorSet的属性冲突无限动画导致页面卡死或泄漏页面销毁时忘掉cancel无限动画在onDetachedFromWindow或onDestroy里统一cancelonAnimationEnd不触发动画是无限循环状态尚未取消或结束主动cancel时会触发如不需要无限循环避免用INFINITE列表item的动画串味ViewHolder复用导致动画状态残留在bind时重置初始值必要时先结束上一个动画动画播完View变透明了动画只设置了起始到终点的alpha但终值写成了0f检查结束值是否写反用语义明确的两个参数写法6.3 一个完整实战列表卡片入场动效给列表item做入场动画是属性动画最常见的实战场景之一。以RecyclerView为例可以这样实现在onBindViewHolder时把item的初始位置往下平移屏幕高度的一部分透明度设为0然后调用animate()让它滑入。override fun onBindViewHolder(holder: VH, position: Int) { val itemView holder.itemView itemView.alpha 0f itemView.translationY 300f itemView.animate() .alpha(1f) .translationY(0f) .setStartDelay((position % 10) * 50L) .setDuration(350) .setInterpolator(DecelerateInterpolator()) .start() }startDelay按列表位置递增可以让卡片一个接一个地入场有轻微的瀑布感。要注意的事项有两点第一holder复用时itemView可能带着上一个位置的状态所以每次bind都要把初始值重置一遍第二如果用户滑动列表很快新绑定的View会立即执行动画需要在滑动过快时暂停动画或者判断当前item是否需要跳过入场动画只给第一次可见的item播放否则会看到频繁回弹的效果。6.4 几个我养成的动画习惯写到这里把我在实际项目里踩出来的几条经验也一并整理出来。第一动画开始前先想清楚“从哪到哪、多久、什么节奏、结束后做什么”。哪怕只有三行代码我也会在心里过一遍这四件事。很多动画效果好坏的差距不在API掌握程度而在于有没有思考过节奏。两个时长相同的动画一个用Bounce、一个用Linear观感完全不一样这不是技术问题是设计感问题。第二动效结束后要做的UI逻辑不要依赖“动画结束值一定等于某个数”的假设。因为动画可能被取消、被新动画打断、被页面重建。更稳妥的做法是在动画的withEndAction里用目标View的实际属性值做判断而不是用自己定义一个计数变量。第三所有重复使用的动画尽量封装起来。哪怕是几行代码只要项目里多个页面用到同一套入场效果就值得抽成一个扩展函数统一管理duration和interpolator后续要调整节奏时只改一处不会出现“这个页面快一点那个页面慢一点”的割裂感。我在实际开发中遇到最多的一个问题其实不是API用错而是很多人没有意识到动画是异步的。动画start之后立刻执行下一行代码这时候目标View的属性值还停留在刚设置的初始值上。如果下一行逻辑依赖“动画完成后的结果”一定会拿错数据。我记得有一次排查了半天“为什么卡片位置不对”最后发现是代码里start动画后立刻做了一次布局测量拿到的坐标是动画前的数据。这种问题连资深开发者偶尔都会中招唯一的解药就是严格遵守动画回调的行为边界该在withEndAction里做的绝不放动画外面做。ObjectAnimator这套东西第一眼看过去好像只是几个静态方法但真正用熟了以后你会发现它能覆盖从列表入场到页面交互反馈的绝大多数动效需求。希望这篇内容能帮你少走一些弯路。
阅读完成 · 觉得有帮助?