首页 / 资讯中心 / 文章详情

Jetpack Compose 重组原理与实践:状态读取、跳过规则和性能排查

Jetpack Compose 重组原理与实践:状态读取、跳过规则和性能排查 ★ FEATURED ARTICLE
写 Compose 时很容易遇到这样的疑问点击一个按钮为什么某个函数又执行了父组件重组子组件会不会全部重组明明用了remember为什么列表还是卡这些问题可以用一条主线回答Compose 记录 UI 在哪里读取了可观察状态状态变化后让相关作用域重新执行并在可能时跳过输入未变化的调用。重组Recomposition是更新 UI 描述的过程不等于重建 Activity也不等于整屏重新测量和绘制。版本说明本文以现代 Android Compose、Kotlin 2.x 的 Compose Compiler 为背景。跳过规则会受编译器版本、Strong Skipping 配置和参数稳定性影响具体项目应以编译器报告与实际性能分析为准。代码片段只展示相关逻辑省略常规 import、依赖和主题设置。一、先把三个阶段分开组合、布局、绘制Compose 更新屏幕大体经过三个阶段阶段做什么典型代码Composition组合执行Composable描述 UI 结构与参数Text(text title)、if (loading) ...Layout布局测量和摆放节点Row的尺寸、Modifier.offset { ... }Draw绘制生成像素内容drawBehind { ... }、文本与图形绘制首次执行组合叫初始组合状态改变后相关组合代码再次执行叫重组。可观察状态变化使读取它的作用域失效重新执行必要的组合代码需要时重新布局需要时重新绘制这张图描述的是常见完整路径。实际更新可能只进入布局或绘制如果状态只在布局阶段读取Compose 可以重新摆放节点而不重组如果只在绘制阶段读取则可能只重绘。讨论“页面刷新了几次”之前先问变化发生在哪个阶段。一个最小例子ComposablefunCounterScreen(){varcountbyrememberSaveable{mutableIntStateOf(0)}Column{Text(点击了$count次)Button(onClick{count}){Text(1)}}}点击按钮时count改变读取count的组合代码需要更新。rememberSaveable让计数跨重组保留并在可保存的前提下应对配置变化和系统恢复它不是“阻止重组”的工具。二、重组从哪里来Snapshot 记录状态读取Compose 常用的可观察状态包括mutableStateOf、mutableIntStateOf、mutableStateListOf以及由Flow转成的 ComposeState。当组合代码读取这些状态时Snapshot 系统会记录读取关系某个 State 的值 ↓ 被读取 某个重组作用域 ↓ State 发生有效变化 该作用域失效随后在合适的时机重新执行例如ComposablefunProfile(name:String,notifications:StateInt){Text(你好$name)NotificationBadge(countnotifications.value)}这里notifications.value的读取位置决定了相应的失效范围。Compose Compiler 会建立可重启的组合组编译器和运行时能利用这些组定位需要重新执行的代码。不要把源码中每一行都理解成一个独立作用域也不要假设某个函数的所有后代一定一起重组。2.1 状态必须真的“可观察”下面的普通变量不会因为自身改变就通知 Composevarcount0ComposablefunBrokenCounter(){Button(onClick{count}){Text($count)}}count改了 Kotlin 变量但 Compose 没收到状态变化通知。正确做法是把它放进 Compose State或从StateFlow等状态源收集。2.2 值相等时不一定产生有效变化mutableStateOf默认使用结构相等策略。将状态设置为与旧值相等的新值通常不会触发依赖它的重组vartitlebymutableStateOf(首页)title首页// 与旧值相等通常不会使读取者失效这与remember是两回事状态策略决定一次写入是否被视为变化remember决定某个计算结果在同一组合位置上是否复用。2.3 原地修改普通集合是常见陷阱varnamesbymutableStateOf(mutableListOf(Ada))names.add(Lin)// 修改的是同一个 List 对象State 本身没有重新赋值界面可能不会按预期更新。可以使用不可变列表并重新赋值varnamesbymutableStateOf(listOf(Ada))namesnamesLin需要细粒度可观察集合时可使用mutableStateListOf()。无论选哪一种都要避免让外部代码悄悄修改 Compose 不知道的对象内部字段。三、父组件重组子组件会怎样先看一个直觉上容易误判的例子ComposablefunDashboard(userName:String,unreadCount:Int){Column{UserHeader(nameuserName)UnreadBadge(countunreadCount)}}ComposablefunUserHeader(name:String){Text(name)}ComposablefunUnreadBadge(count:Int){Text(未读$count)}如果unreadCount变化Dashboard的调用方可能重新执行进而重新调用Dashboard。但UserHeader的name没变时其函数体可能被跳过UnreadBadge的count改变需要更新。这里要区分两个动作父作用域重新执行走到了子组件的调用位置。子组件函数体实际重新执行。前者发生不代表后者必然发生。Compose Compiler 在满足条件时可以把第二步跳过反过来即使参数不变子组件自己读取的 State 改变也可能直接使它重组。什么是“跳过”跳过Skipping指框架复用上次组合的结果不执行这次可组合函数的函数体。它不是把整个页面冻结也不是保证子树永远不更新子节点如果有自己失效的重组作用域仍然可以独立更新。重组的精确范围属于编译器与运行时的实现细节。业务代码应追求正确的状态边界和稳定的数据流而不是依赖“某次点击一定只调用一个函数”这样的偶然表现。四、稳定性如何影响跳过规则Compose 需要判断参数“有没有变化”。所谓稳定stable核心是一份契约对象的可观察变化能被 Compose 获知比较结果可信相同输入在 UI 语义上可复用。常见的基本类型、String等通常容易判断。普通ListT、带可变公开属性的自定义类编译器未必能证明稳定。参数示例通常怎么理解实务建议Int、Boolean、String通常稳定正常传递即可data class UiModel(val id: Long, val title: String)属性看似不可变但实际判定还取决于类型、模块与编译器查看报告不靠肉眼猜测ListUiModelKotlinList接口不保证底层永远不变避免原地修改需要时使用稳定的不可变集合方案class UiModel(var title: String)改字段不一定通知 Compose改为可观察状态或不可变值对象4.1 Strong Skipping 改变了什么现代 Compose Compiler 提供 Strong Skipping强跳过。在 Kotlin2.0.20起它默认开启。启用后大多数可重启的可组合函数即使有不稳定参数也可以成为可跳过函数稳定参数一般按equals比较不稳定参数一般按引用身份比较。编译器还会对部分 lambda 做自动记忆化。这带来两个重要结论“参数不稳定所以子组件一定每次都执行”已不是通用规则。对不稳定参数反复创建新对象仍可能让跳过失效因为新引用与旧引用不同。例如每次组合都构造新的列表ComposablefunNotesScreen(notes:ListNote){valvisiblenotes.filter{!it.archived}NotesList(visible)}filter每次返回新列表。它不仅重复计算也可能让接收visible的组件看到一个新引用。若列表来自业务状态优先在 ViewModel/Repository 中产出最终的 UI 列表局部纯计算可根据输入用remember(notes) { ... }缓存但要理解输入自身的更新规则。4.2Stable、Immutable不是性能开关ImmutabledataclassNoteUi(valid:Long,valtitle:String)注解向编译器声明契约不会把真实的可变对象变成不可变对象。如果对象内部数据悄悄变化而 Compose 不知道错误注解可能造成该更新时被跳过最终出现陈旧 UI。先设计清楚数据不可变性再考虑注解并用编译器报告验证效果。也不要因为某个函数在调试日志中执行了几次就立刻大面积添加Immutable。额外对象分配、昂贵计算、频繁布局或绘制也可能才是卡顿的主因。五、同样是 State在不同阶段读取代价不同“在哪里读状态”与“读了什么状态”一样重要。Compose 会尽可能从读取发生的阶段重新开始。5.1 在组合阶段读取ComposablefunTitle(title:StateString){Text(texttitle.value)}title.value在组合中读取改变时相关组合作用域失效。5.2 在布局阶段读取ComposablefunMovingBox(offsetY:StateInt){Box(Modifier.offset{IntOffset(x0,yoffsetY.value)})}offset { ... }的 lambda 在布局放置阶段运行。适合高频位置变化避免为了位置值重新执行不相关的组合代码。这里的值单位是像素若业务状态是Dp需要正确换算。5.3 在绘制阶段读取ComposablefunColorTile(color:StateColor){Box(Modifier.size(80.dp).drawBehind{drawRect(color.value)})}color.value在绘制 lambda 内读取。颜色变化时通常只需要使绘制失效而不用重新执行ColorTile的组合函数体。真实更新仍可能受其他状态读取和组件实现影响不能仅凭这段代码推断整个页面没有布局工作。5.4 事件回调里的读取Button(onClick{println(counter.value)}){Text(读取计数)}回调在用户点击时执行不是在组合期间读取counter.value。单靠这次读取不会让按钮文本随着计数变化。若文字需要显示计数必须在组合中读取并传给Text。六、三个经常被误用的状态工具6.1remember保存同一组合位置上的结果valformatterremember(locale){NumberFormat.getNumberInstance(locale)}remember在 key 不变且该调用位置仍保留时复用对象locale变化时重新计算。它不负责持久化组件离开组合后记忆结果会被忘记。需要在配置变化或进程恢复后保存合适的小型 UI 状态时用rememberSaveable业务数据通常放在 ViewModel/Repository。remember也不会阻止外围函数重组。它只避免每次重新创建这个对象或重新计算这一小段结果。6.2derivedStateOf高频输入、低频输出ComposablefunArticleList(items:ListArticle){vallistStaterememberLazyListState()valshowBackToTopbyremember{derivedStateOf{listState.firstVisibleItemIndex0}}Box{LazyColumn(statelistState){items(items,key{it.id}){article-ArticleRow(article)}}if(showBackToTop)BackToTopButton()}}滚动位置可能频繁变化但“是否已经离开第一项”只在边界切换时变化。derivedStateOf能让下游关心派生结果而非每一次输入变化。它本身有开销对firstName lastName这种每次输入变化都要更新的简单字符串一般无需额外包一层。6.3key稳定的是组合身份LazyColumn{items(notes,key{note-note.id}){note-NoteRow(note)}}没有稳定 key 时列表插入、删除或重排可能让位置与原来的内容错位导致行内remember状态跟着位置走。以业务 ID 作为 key可让 Compose 识别“这是同一条数据移动了位置”。key解决的是身份问题不是承诺该行永远不重组。行内读取的状态变化、参数变化仍会触发更新key 必须在同一列表范围内唯一且稳定。七、重组与副作用组合函数可能反复执行组合函数负责描述 UI。它可能执行多次、被跳过甚至一次组合计算最终没有提交。因此网络请求、数据库写入、导航、监听器注册等副作用不能直接放在组合函数体里ComposablefunWrongDetail(userId:String,repository:UserRepository){repository.refresh(userId)// 危险重组时可能重复请求Text(用户详情)}按生命周期选择 EffectComposablefunUserDetail(userId:String,repository:UserRepository){LaunchedEffect(userId,repository){repository.refresh(userId)}Text(用户详情)}LaunchedEffect进入组合时启动key 改变时取消旧协程并启动新协程离开组合时取消。这里把repository也作为 key是为了它被替换时使用新实例。其他常用边界API适合什么与重组的关系SideEffect将成功提交后的 Compose 状态同步到外部对象每次成功重组后执行DisposableEffect注册监听器并在 key 变化/离开时清理建立与组合生命周期对称的资源边界rememberUpdatedState长寿命 Effect 中读取最新回调或值不为每次回调变化重启 EffectLaunchedEffect与组合/key 绑定的协程工作key 变化会重启对这些 API 的完整用法可接着阅读项目已有的 Jetpack Compose Effect 指南。八、一个真实优化场景搜索列表输入时卡顿假设页面持有大量笔记用户每输入一个字UI 都在组合阶段过滤、排序ComposablefunSearchScreen(notes:ListNote,query:String){valresultnotes.filter{queryinit.title}.sortedByDescending{it.updatedAt}LazyColumn{items(result,key{it.id}){note-NoteRow(note)}}}这里要拆开看三种成本输入变化使SearchScreen必须更新因为查询结果确实会变。过滤和排序在组合函数里运行列表越大单次计算越贵。每次生成新列表和新对象引用可能增加子组件参数变化与内存分配。可先把搜索结果作为 ViewModel 的 UI 状态输出valvisibleNotes:StateFlowListNoteUicombine(notesFlow,queryFlow){notes,query-notes.asSequence().filter{query.isBlank()||it.title.contains(query,ignoreCasetrue)}.sortedByDescending{it.updatedAt}.map{it.toUi()}.toList()}.stateIn(viewModelScope,SharingStarted.WhileSubscribed(5_000),emptyList())页面通过collectAsStateWithLifecycle()收集结果再交给带稳定 key 的列表。数据量很大时下一步可能是数据库索引、分页或全文检索把过滤搬出组合并不会神奇地消除 O(n) 扫描。优化应从实际慢的环节开始。九、怎样观察和诊断重组9.1 先看症状再看次数某个小组件重组十次可能没有任何用户可感知问题一次昂贵的排序、测量或绘制却可能直接掉帧。先确认卡顿、输入延迟、滚动抖动或电量异常再定位原因。9.2 Android Studio 工具Layout Inspector 可观察 Compose 层级与重组/跳过计数适合定位异常频繁的节点。System Trace、Macrobenchmark 等工具可看帧耗时判断瓶颈是在组合、布局、绘制、主线程阻塞还是其他工作。Compose Compiler 的稳定性与可跳过性报告可帮助确认参数和函数的编译器判定具体配置路径随 Kotlin/Compose Compiler 插件版本变化。这些数值受构建模式、工具附加和编译器优化影响适合比较同一环境下的变化不宜把某个计数当成跨版本性能指标。9.3 调试日志要理解执行时机ComposablefunDebugCard(title:String){SideEffect{Log.d(ComposeTrace,DebugCard committed:$title)}Text(title)}SideEffect在成功提交后运行适合临时观察某段组合是否提交它不是生产性能监控也不能凭日志证明每个像素都重绘了。排查完成后移除调试日志。十、几条值得记住的边界常见说法更准确的理解“状态变了整个页面会重组”Compose 定位相关读取作用域并可跳过输入未变化的调用“重组就是重绘”组合、布局、绘制是不同阶段失效可以从不同阶段开始“加remember就不会重组”remember复用值不阻止其所在作用域重新执行“List不稳定子组件一定重组”Strong Skipping 的行为还取决于编译器版本和引用身份“Immutable能让代码更快”注解是契约标错可能让 UI 显示旧数据“重组次数越少越好”正确更新优先关注掉帧和真正昂贵的工作Compose 重组的设计目标是让开发者声明“当前状态对应什么 UI”运行时负责增量更新。写代码时最可靠的做法是保持状态单向流动、按需读取、让列表身份稳定、把副作用放到正确生命周期中性能不理想时再用工具定位具体阶段和具体工作量。参考资料Android DevelopersThinking in ComposeAndroid DevelopersState and Jetpack ComposeAndroid DevelopersPhases of Jetpack ComposeAndroid DevelopersStability in ComposeAndroid DevelopersStrong SkippingAndroid DevelopersSide effects in ComposeAndroid DevelopersLists and grids
阅读完成 · 觉得有帮助?
咨询建站