ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Jetpack Compose动画实战:状态驱动UI动效与性能优化

Jetpack Compose动画实战:状态驱动UI动效与性能优化 Jetpack Compose 动画实战让你的 UI 动起来做 Android 开发这几年我踩过 View 系统动画的不少坑写一长串ObjectAnimator、AnimationSet还要手写插值器动效稍微复杂一点就容易在屏幕旋转时崩掉。转 Jetpack Compose 之后最大的感受就是动画终于不再是“附加项”而是 UI 的一部分。你只要改一个状态界面自己会过渡、会移动、会消失整个过程自然得就像它天生该这样。这篇文章我会从动画的核心思路说起把animate*AsState、Animatable、AnimatedVisibility、AnimatedContent、updateTransition这些 API 一个个拆开讲最后带一套完整的可运行代码专门聊聊多动画编排、手势动画、列表增删动画这些实战场景以及我踩过的坑。无论你是刚接触 Compose 的入门者还是已经写了半年觉得动画体系太散、不知道怎么串起来的开发者这篇文章应该都能给你一点启发。1. 为什么 Compose 动画和以前完全不是一回事1.1 先忘掉 View 系统的老思路在讲 Compose 动画怎么做之前得先聊清楚它和传统 View 动画的本质区别。View 时代我们写动画核心操作是“控制一个对象的变化过程”。比如ObjectAnimator.ofFloat(view, alpha, 0f, 1f)你要盯着那个view对象手动指定属性名、起始值、结束值还要让它跑起来。动画的执行是命令式的我说要做这件事你立刻去做。这种模式在简单场景下够用但一旦涉及“状态切换”就要自己管理很多中间变量。比如按钮从“未收藏”变成“收藏”需要同时改图标、改文字、改颜色、改背景你要为每个属性分别创建动画还要处理互相之间的时序。写多了就有点像绣花一针一针全得自己来稍不留神就漏了一针。Compose 的动画重构了这套逻辑它把动画看作是“状态的变化”。你不需要告诉 Compose“先变透明度再变位移”你只要声明“这个状态是这样”然后在代码里改变状态的数值Compose 的动画系统会自动把旧值过渡到新值。1.2 状态驱动才是核心Compose 里的 UI 本质是状态的函数UI f(state)。动画也是一样你定义的动画值本身就是一个可观察的状态。它不断变化UI 就不断重组表现出来就是你看到的那段流畅动画。举个例子。写一个“收藏按钮”用户点一下心形图标缩放变大再变回正常大小同时填充颜色从灰色变成红色。用 View 方式要写两个动画互相监听用 Compose 方式是这样Composable fun FavoriteButton(isFavorite: Boolean, onClick: () - Unit) { val scale by animateFloatAsState( targetValue if (isFavorite) 1.2f else 1f, animationSpec spring(dampingRatio Spring.DampingRatioMediumBouncy), label favoriteScale ) val color by animateColorAsState( targetValue if (isFavorite) Color.Red else Color.Gray, label favoriteColor ) Icon( imageVector if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, contentDescription null, tint color, modifier Modifier .graphicsLayer { scaleX scale scaleY scale } .clickable { onClick() } ) }看到区别了吗isFavorite一旦变scale和color会自动开始过渡而且spring让它带一点回弹效果。你完全不需要关注动画“跑到了哪一步”只要管好isFavorite这个状态就行。理解这一点之后整个 Compose 动画体系在你眼里就不再是零散的 API 大杂烩它们都是在围绕“状态过渡”做文章。1.3 三个动画层级值、可见性、内容Compose 官方把动画库分成了好几个模块但实战中我习惯按“影响范围”分为三层值动画针对单个值做过渡比如透明度从 0 到 1、尺寸从 50dp 到 100dp、颜色从灰到红。animate*AsState和Animatable都属于这一层是使用频率最高的。可见性动画控制某个 Composable 的出现和消失比如弹窗、提示条、列表项展开收起。AnimatedVisibility就是干这个的它能在“加入视图树”和“移出视图树”这两个时刻自动补入场/退场动画。内容切换动画界面从一个内容切换到另一个内容比如 Tab 切换、页面跳转、数字滚动。AnimatedContent、Crossfade、updateTransition属于这一层。这三层可以独立使用也可以组合。比如一个列表项先是AnimatedVisibility控制展开/收起展开后再用animateDpAsState让内部高度平滑过渡这就把两层动画串起来了。理解这个分层你拿到一个需求时就能快速判断该用哪个 API怎么组织代码。2. 基础动画 API 实战拆解2.1 单值动画animate*AsState 系列animate*AsState系列是入门最友好的 API也是实际项目里用得最多的。官方提供了浮点、颜色、尺寸、偏移、边距等常用类型animateFloatAsState、animateColorAsState、animateDpAsState、animateOffsetAsState、animateSizeAsState、animateValueAsState自定义类型。它们的用法高度一致传入一个目标值targetValue返回一个当前值目标值改变时当前值会自动从旧值插值到新值。支持用animationSpec指定动画规格比如tween、spring、keyframes。这个系列最典型的应用场景就是“点击开关”。比如做一个小卡片点击后展开更多信息高度从 100dp 变到 200dp透明度变化背景色变化Composable fun ExpandableCard(expanded: Boolean, content: Composable ColumnScope.() - Unit) { val background by animateColorAsState( targetValue if (expanded) Color(0xFFE3F2FD) else Color.White, label cardBackground ) val cornerRadius by animateDpAsState( targetValue if (expanded) 16.dp else 4.dp, label cardCorner ) Column( modifier Modifier .fillMaxWidth() .background(background) .clip(RoundedCornerShape(cornerRadius)) .clickable { /* 切换 expanded */ } .padding(16.dp) ) { content() } }注意这里有个细节background和clip都用了动画值点击后背景色和圆角会同时开始过渡。这种“多个单值动画并行”的做法很常见Compose 会把它们放在同一个重组周期里处理不会出现一个先变一个后变的错乱感。animationSpec参数值得单独说说。tween(durationMillis, delayMillis, easing)适合标准过渡spring适合带弹性的交互keyframes适合需要中间节点的动画。比如你想让圆角“先快速变、再放慢”可以用keyframesval radius by animateDpAsState( targetValue if (expanded) 24.dp else 4.dp, animationSpec keyframes { durationMillis 400 16.dp at 150 with FastOutLinearInEasing 24.dp at 400 }, label cardCornerKeyframe )提示keyframes里的at时间必须是递增的最后一个时间要和durationMillis一致否则会有奇怪的行为。2.2 可见性动画AnimatedVisibilityAnimatedVisibility解决的是“一个组件要不要出现”的动画问题。它的用法非常直白Composable fun TipBar(showTip: Boolean) { AnimatedVisibility( visible showTip, enter fadeIn() slideInVertically { -it }, exit fadeOut() shrinkVertically() ) { Text(这是一条提示信息, modifier Modifier.background(Color.Yellow).padding(8.dp)) } }enter和exit都是EnterTransition/ExitTransition类型可以通过组合多种效果。我用得最多的是fadeIn、slideInVertically、expandVertically这几种expandIn可以从某个锚点展开scaleIn适合做图片预览。这里有一个特别容易踩的坑如果页面里还有其他可见性变化或者父容器在做布局动画AnimatedVisibility的“消失”过程有时会被跳过。比如你同时用animateDpAsState把高度缩小到 0又让AnimatedVisibility退场两个动画互相干扰就会出现“闪一下就没”的鬼畜效果。这类问题我放在第 4 节详细讲先记住一个原则同一个组件的出现/消失动画尽量只在AnimatedVisibility里做不要和外层布局动画重复叠加。AnimatedVisibility还有一种进阶用法就是配合MutableTransitionState做初始可见性动画val transitionState remember { MutableTransitionState(false) } transitionState.targetState true AnimatedVisibility( visibleState transitionState, enter expandVertically() fadeIn(), exit shrinkVertically() fadeOut() ) { ... }这样组件首次进入组合时也会从头播放一次入场动画而不是瞬间显示。做列表首次加载、引导页动画时非常有用。2.3 内容切换AnimatedContent 与 updateTransitionAnimatedContent解决的是“同一个位置内容从 A 换成 B”的动画。最常用的场景是 Tab 切换Composable fun TabContent(tab: TabType) { AnimatedContent( targetState tab, transitionSpec { (fadeIn(tween(300)) slideInHorizontally { it / 4 }) togetherWith (fadeOut(tween(200)) slideOutHorizontally { -it / 4 }) }, label tabContent ) { currentTab - when (currentTab) { TabType.Home - HomeScreen() TabType.Profile - ProfileScreen() } } }togetherWith是AnimatedContent的transitionSpec专用关键字A togetherWith B表示内容 A 进场时播放 A内容 B 退场时播放 B。这里有个细节content块里的it是当前的targetState也就是currentTab不是动画进度值。如果你需要读取动画进度0f 到 1f可以用transition的currentState和targetState配合animateFloat去拿。但AnimatedContent有个比较头疼的问题如果两个目标状态的 UI 结构差异很大整个内容都会被替换动画看起来像“两页翻过去”。想要更精细地控制就得用updateTransition。updateTransition适合用来管理“同一个组件有多个同时变化的属性”的场景。比如一个按钮在“加载中”“成功”“失败”三种状态之间切换背景色、图标、文字、旋转角都要跟着变。写多个animate*AsState会显得零散用updateTransition可以把它们组织在一个“动画事务”里Composable fun StatusButton(status: Status) { val transition updateTransition(targetState status, label status) val background by transition.animateColor(label bg) { state - when (state) { Status.Loading - Color.Gray Status.Success - Color.Green Status.Failed - Color.Red } } val rotation by transition.animateFloat(label rotation) { state - if (state Status.Loading) 360f else 0f } val iconAlpha by transition.animateFloat(label alpha) { state - if (state Status.Loading) 0.6f else 1f } Box( modifier Modifier .size(48.dp) .background(background, CircleShape) .graphicsLayer { rotationZ rotation } ) { Icon(..., modifier Modifier.alpha(iconAlpha)) } }updateTransition的好处是它帮你管理“从任意状态到任意状态”的完整过渡。不管是 Loading - Success 还是 Failed - Success所有变化都一起开始、一起结束不会出现一个属性已经变完了另一个才开始的情况。3. 进阶实战多动画编排与手势动画3.1 队列执行动画让动画按顺序跑起来很多场景下一个动画要等另一个动画结束再开始而不是同步播放。比如购物车加购数字先飞到购物车图标上然后购物车图标再跳动一下。网上搜“Android 队列执行动画”的需求很多在 View 时代我们要写AnimatorSet.playSequentially在 Compose 里其实思路更简单——用协程顺序执行。把动画值定义成Animatable然后在协程里依次animateToComposable fun AddToCartAnimation() { val scope rememberCoroutineScope() val flyOffset remember { Animatable(0f) } val cartScale remember { Animatable(1f) } Box { // 商品区域 Button(onClick { scope.launch { // 第一步数字上浮并淡出 flyOffset.animateTo( targetValue -200f, animationSpec tween(300, easing FastOutSlowInEasing) ) // 第二步购物车图标弹跳 cartScale.animateTo( targetValue 1.4f, animationSpec tween(150, easing FastOutLinearInEasing) ) cartScale.animateTo( targetValue 1f, animationSpec spring( dampingRatio Spring.DampingRatioMediumBouncy, stiffness Spring.StiffnessMedium ) ) // 第三步偏移复位准备下次动画 flyOffset.snapTo(0f) } }) { Text(加入购物车) } Text( 1, modifier Modifier .graphicsLayer { translationY flyOffset.value } ) // 购物车图标 Icon( imageVector Icons.Filled.ShoppingCart, contentDescription null, modifier Modifier.graphicsLayer { scaleX cartScale.value scaleY cartScale.value } ) } }这里核心就是Animatable。它有一个内部状态你可以通过animateTo启动动画snapTo瞬间跳转stop停止动画。每次animateTo都返回一个Job在协程里它默认会等当前动画完成再继续执行下一行所以天然支持队列。提示Animatable配合rememberCoroutineScope().launch使用最顺手。如果是在LaunchedEffect里做一次性动画也可以但要注意LaunchedEffect的协程会在重组时被取消不适合“用户点击后播放”的场景。3.2 无限循环动画loading 状态的正确打开方式loading 动画是每个应用都绕不开的。用rememberInfiniteTransition可以创建无限重复的动画值最常见的用法是做一个旋转的进度环Composable fun LoadingIndicator() { val infiniteTransition rememberInfiniteTransition(label loading) val rotation by infiniteTransition.animateFloat( initialValue 0f, targetValue 360f, animationSpec infiniteRepeatable( animation tween(800, easing LinearEasing), repeatMode RepeatMode.Restart ), label rotation ) CircularProgressIndicator( progress { 0.3f }, modifier Modifier.graphicsLayer { rotationZ rotation } ) }repeatMode有两个值RepeatMode.Restart是每次从头播放比如 0 到 360 再到 0RepeatMode.Reverse是正反交替比如 0 到 1 再到 0。做滑块动画、呼吸闪烁效果时Reverse非常实用。rememberInfiniteTransition有个性能隐患如果动画作用范围太大或者内部做了复杂计算会导致每帧都重组大片 UI。我见过一个项目在InfiniteTransition的animateFloat里直接Math.sin计算坐标结果列表滑动时卡到 20fps。正确做法是把无限循环动画限制在graphicsLayer或drawBehind等轻量作用域里避免触发大范围布局重组。关于轻量作用域简单解释下graphicsLayer只是改绘制参数不触发测量和布局而Modifier.offset虽然也不算布局但如果你改的是size这种就会触发布局。所以能用graphicsLayer的地方尽量用它。3.3 手势 弹簧动画拖拽回弹效果Compose 里做“按住拖拽、松手回弹”的效果核心是Animatable 手势 API 弹簧动画。代码不复杂但要注意手势事件的消费Composable fun DraggableCard(content: Composable () - Unit) { val offsetX remember { Animatable(0f) } val offsetY remember { Animatable(0f) } val coroutineScope rememberCoroutineScope() Box( modifier Modifier .pointerInput(Unit) { detectDragGestures( onDrag { change, dragAmount - change.consume() coroutineScope.launch { offsetX.snapTo(offsetX.value dragAmount.x) offsetY.snapTo(offsetY.value dragAmount.y) } }, onDragEnd { coroutineScope.launch { offsetX.animateTo( targetValue 0f, animationSpec spring( dampingRatio Spring.DampingRatioNoBouncy, stiffness Spring.StiffnessMedium ) ) offsetY.animateTo( targetValue 0f, animationSpec spring( dampingRatio Spring.DampingRatioNoBouncy, stiffness Spring.StiffnessMedium ) ) } } ) } .graphicsLayer { translationX offsetX.value translationY offsetY.value } ) { content() } }几个关键点拖拽过程中用snapTo而不是animateTo保证手指移动和卡片位移零延迟。change.consume()必须调用否则手势事件还会传给父容器可能出现“卡片移动了列表也滑动了”的问题。松手后用spring回弹Spring.DampingRatioNoBouncy表示不过度回弹Spring.StiffnessMedium保证回弹速度适中。如果你想做那种“甩出去”的感觉可以加大stiffness或者让回弹的目标值根据松手时的速度来计算。如果想让回弹更带感可以稍微加一点点弹跳比如Spring.DampingRatioLowBouncy但注意不要所有地方都用低阻尼不然整个 App 会显得很“活泼”过头用户会觉得晃眼。3.4 列表 item 增删动画列表增删动画在 Compose 里比 View 时代简单太多。LazyColumn/LazyRow的 item 支持直接添加动画Composable fun AnimatedList(items: ListItem) { LazyColumn { items(items, key { it.id }) { item - Row( modifier Modifier .fillMaxWidth() .animateItem() // 新增/删除/移动时自动动画 .clickable { /* 删除 */ } .padding(8.dp) ) { Text(item.title) } } } }新版 Compose 里Modifier.animateItem()会为 item 的插入、移除、移动自动应用动画。移除时 item 会先播放退场动画再真正从列表移除重新排序时会平滑移动到新位置。前提是记得给items指定key否则 Compose 不知道这个 item 是“新加的”还是“原来的”动画会失效或者表现错乱。如果你需要更精细的控制比如自定义插入动画是“从左边滑入”还是“从底部展开”可以给animateItem()传入fadeInSpec、placementSpec等参数。实测下来默认效果已经能应付绝大多数场景。4. 常见问题与排查技巧实录4.1 动画“显示不全”或“闪一下就没”这个可以说是 Compose 动画新手最容易遇到的问题。“动画显示不全”常见的有两种表现动画只播放了一部分就突然结束或者动画在还没播放时就瞬间跳到最终状态。排查看几个方向状态没有保持如果你用remember { mutableStateOf(false) }保存动画开关而组件在动画进行中被重建比如父组件重组导致当前组件退出组合状态会重置动画自然断掉。这种情况建议改用rememberSaveable或把状态提升到 ViewModel。AnimatedVisibility作用域问题AnimatedVisibility的 content lambda 里如果直接引用外部状态当外部状态先变的瞬间visible参数可能已经改变了 content 的结构导致退场动画被“跳过”。我建议退场动画的 UI 结构尽量保持稳定不要依赖正在变化的状态。多个动画互相覆盖同一个值被两个不同的animate*AsState同时驱动时后启动的会覆盖前一个。如果你发现“动画只闪了一下”很可能是某个重组把目标值改回了初始值。出现“闪一下”时我的排查套路是先加日志确认状态是否连续变化再看是否有外部重组强制重置状态最后才怀疑动画 API 本身的问题。大部分情况都不是 API 的问题而是状态管理的问题。4.2 动画卡顿与性能优化Compose 动画卡顿的原因往往不是动画本身而是动画期间每帧都触发了大范围重组。排查方法很直接打开 Layout Inspector 看重组范围或者用Modifier.layoutId配合CompositionLocal观察哪些子组件在动画期间被重新组合。常见优化手段用graphicsLayer代替布局属性Modifier.offset和Modifier.graphicsLayer都能实现位移但前者在动画中更容易触发布局后者只影响绘制。缩放、旋转、透明度同理。只要不影响布局测量一律优先用graphicsLayer。把动画值封装在derivedStateOf里如果一个高开销的 Composable 依赖动画值动画每帧变化都会导致它重组。用derivedStateOf做一次“过滤”比如只在动画值过某个阈值时才更新 UI能显著减少重组频率。拆分组件动画值只传给真正需要它的子组件而不是放在一个巨大的 Column 顶层否则整个 Column 的子树都会跟着重组。谨慎使用key变更key变化会销毁重建组件如果动画期间key变了动画目标组件都不存在了动画自然不流畅。我做过一个图标充电动画刚开始把旋转值放在整个页面顶层结果充电数字每秒重绘 60 次耗电明显。后来把旋转动画收敛到图标自己的组件里页面其他部分完全不受影响。4.3 旋转屏幕后动画状态丢失remember和rememberSaveable的区别在这里显得特别重要。remember在活动重建后会丢失而rememberSaveable能通过 SavedState 机制恢复。动画状态如果是“显式的布尔值”比如是否展开可以用rememberSaveable保存如果是Animatable这种运行时对象不能直接存需要把它的目标值用rememberSaveable保存重建后手动snapTo到目标值Composable fun PersistedAnimatedValue() { var target by rememberSaveable { mutableStateOf(0f) } val animatable remember { Animatable(target) } // 重建后同步 LaunchedEffect(target) { animatable.animateTo(target) } Button(onClick { target if (target 0f) 100f else 0f }) { Text(移动) } }这里有个小技巧Animatable的animateTo如果目标值和当前值一样它会直接跳过不会造成多余动画。所以用LaunchedEffect(target)在初始化时同步一次是安全的。5. 我的实操心得与避坑总结文章最后把自己这几百个日子的动画实战经验浓缩成几条供你参考。第一动画是状态管理的延伸。你在 Compose 里写动画本质是在写状态机。动画写不清楚先回头看状态设计是不是混乱了。animate*AsState用起来顺手是因为它把“状态变化”和“UI 过渡”绑在了一起如果你的状态本身就在乱变再好的动画 API 也救不回来。第二默认动画参数大多够用不要频繁改。spring的默认参数DampingRatioNoBouncy和StiffnessMedium已经比较克制适用于大多数交互。过度调参会让 UI 显得浮躁。我见过有人把所有按钮点击动画都改成LowBouncy看起来像在弹棉花。真正需要个性的时候再针对性地调keyframes不要一开始就搞花活。第三动画性能问题大多数不是动画本身的问题。Compose 动画每一帧都只改几个数值真正的重头开销在于重组。所以遇到卡顿先查重组范围再查布局计算最后再回过头看动画频率。graphicsLayer是你的好帮手但它只解决绘制层的问题解决不了设计层面的“大树重组”。第四队列编排用协程是 Compose 动画最舒服的姿势。Animatable.animateTo天然支持“上一个动画跑完再跑下一个”配合rememberCoroutineScope处理用户触发动画的场景配合LaunchedEffect处理一次性动画场景几乎没有写不出来的编排。第五动画要克制。UI 动画的最高境界是让用户感受到流畅和自然而不是炫技。列表多一条数据时来个平滑插入按钮点击时有一点反馈Tab 切换时不生硬——这些细节会让用户觉得“这 App 很顺”但如果你处处放大动画反而会让用户觉得拖沓。我做过的项目里动画做得好的都不是动画数量多的而是动画与该出现的时刻配合得精准的。Jetpack Compose 的动画体系还在快速迭代新版本不断有更优雅的 API 出来。但只要掌握了“状态驱动 分层使用 协程编排”这三个核心思路后续学什么新 API 都只是换个写法的事。希望这篇文章能帮你把动画这条线捋顺早日写出流畅自然、让人追着用的界面。
返回列表