资讯详情

资讯详情

Android Jetpack Compose入门:从View迁移到声明式UI的核心实践

前阵子有个做Android开发的老同事问我手头维护的一个两年多的项目还在大量使用传统View体系新起的业务模块到底要不要切到Jetpack Compose。我的回答很直接如果是全新功能模块别犹豫直接用如果是改造老页面挑那些交互密集、状态复杂的页面先试点积攒经验再逐步扩大。这篇《Android Jetpack之Compose入门(一)》就是基于我自己从零接触Compose开始到在几个业务模块中落地的完整经验写成的希望能帮你少走我曾经走过的弯路。文章的核心会围绕三件事展开Compose到底解决了什么、环境配置如何一次到位、第一个可交互界面该怎么从零写出来。如果你已经写过一两个简单Compose页面但总对状态、重组、列表key这些概念没有把握可以重点看后半部分的踩坑复盘。我会尽量把话说得浅一点但不至于失去深度适合从View体系刚转过来的Android开发者也适合刚接触Compose不久的新人。先从最实际的问题说起我们为什么要从View体系迁移到Compose1. 我为什么建议新项目直接选Compose而不是继续用View这几年下来我对View体系最大的感受不是它做不到某些效果而是它在组织复杂交互时代码会无可避免地走向失控。很多团队都有过这样的体验一个页面有多个数据源用户信息、消息列表、网络状态、权限变更都在影响UI传统写法要靠一堆回调、findViewById、adapter.notifyDataSetChanged和手动visibility切换来维持同步。只要有一个分支漏写了界面就会跟实际状态不一致。1.1 View体系状态不同步的困局我印象很深的一次事故是某个线上页面在按钮点击后触发了接口请求失败分支里只更新了错误提示文案却忘了清空之前的列表数据。用户看到的结果是旧列表残留加一条错误提示状态和界面完全对不上。这类问题在View体系里太常见了因为数据的更新链路和UI的更新链路是分开的中间的同步动作全部依赖开发者手动保证人一多、逻辑一复杂总会有漏网之鱼。Compose把这条链路合并成一条。你不再命令某个控件去改内容而是描述页面在某个状态之下应该长什么样。状态一变该消失的消失该出现的出现框架会替你处理那些琐碎的更新动作。旧数据没清空这种问题在Compose里几乎不可能再发生因为你根本不存在“忘记清空控件”的机会界面始终由最新状态直接推导出来。1.2 Compose适合什么场景不适合什么场景以我自己的实践经验来看Compose最适合的是业务逻辑复杂、状态多、交互反馈频繁的页面比如信息流列表、带实时校验的表单、数据看板这类页面。这些页面在View体系里要维护大量同步代码在Compose里只需要把状态建模清楚界面表达会变得非常直接。但Compose也不是万能的。如果你的项目还在用非常老旧的第三方UI定制库没有升级计划并且团队没有人熟悉Compose那我劝你不要急着全量迁移。更稳妥的做法是新模块用Compose老页面保持原样通过ComposeView和AndroidView互相嵌入来过渡。先把一两页跑顺团队积累起信心和排错能力再慢慢扩大范围。提示ComposeView可以嵌进传统XML布局AndroidView也可以在Compose里承载原生控件。这是从View体系平滑过渡的关键机制团队迁移压力大时可以先从单个页面试点。2. 版本号对齐环境搭建里最容易翻车的一环很多人学Compose不是被代码难住的而是被环境配置劝退的。报错信息五花八门根源多半是工具链版本不匹配。我这里给出一套目前比较稳定的配置方案按这个配置创建项目大部分情况能一次通过。2.1 一套稳定开箱即用的Gradle配置方案以当前较新的稳定版本为例假设你用的是Gradle Kotlin DSL// 项目级 build.gradle.kts plugins { id(com.android.application) version 8.5.0 apply false id(org.jetbrains.kotlin.android) version 2.0.0 apply false id(org.jetbrains.kotlin.plugin.compose) version 2.0.0 apply false }// app模块 build.gradle.kts plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(org.jetbrains.kotlin.plugin.compose) } android { namespace com.example.mycompose compileSdk 34 defaultConfig { applicationId com.example.mycompose minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { compose true } } dependencies { val composeBom platform(androidx.compose:compose-bom:2024.06.00) implementation(composeBom) implementation(androidx.compose.ui:ui) implementation(androidx.compose.ui:ui-tooling-preview) implementation(androidx.compose.material3:material3) implementation(androidx.activity:activity-compose:1.9.0) debugImplementation(androidx.compose.ui:ui-tooling) }这个配置里最大的亮点是使用了Compose BOM。BOM的全称是Bill of Materials它本身不包含代码只负责声明一组互相兼容的库版本。你不需要再手动指定ui、material3各自的版本号BOM会自动统一。我自己在项目里最怕的就是五花八门的版本号有了BOM之后绝大多数依赖冲突都消失了。2.2 为什么Compose编译器插件值得重点检查在Kotlin还没出2.0之前Compose项目要写一段额外的composeOptions配置指定kotlinCompilerExtensionVersion。从Kotlin 2.0开始Compose编译器插件已合并进Kotlin仓库和Kotlin版本一起发布所以你需要在plugins里单独声明org.jetbrains.kotlin.plugin.compose。这个插件的作用是在编译阶段扫描你的可组合函数生成重组所需的内部代码。这里有一个容易让人抓狂的细节Compose编译器插件版本必须与Kotlin版本严格一致。Kotlin是2.0.0compose插件就也得是2.0.0Kotlin升到2.1.0compose插件也要同步。差一个补丁版本编译时都可能跳出找不到某个编译器内部类的错误。如果还在用老式composeOptions配置新版编译器甚至会直接提示不再支持。每次升级Kotlin版本前我建议先查一下配套的Compose插件版本再把项目级和模块级的plugins一起改掉。2.3 常见环境报错的速查表下面这张表我整理了自己踩过和帮同事排查过的几个高频环境问题可以收藏备用报错现象根因解决方式Compose compiler plugin required忘记应用org.jetbrains.kotlin.plugin.compose插件在项目级和模块级plugins中都加上该插件Failed to resolve androidx.compose.material3:material3BOM依赖未生效确认已添加composeBOM依赖并用implementation(composeBom)引入某些Compose API编译报错Kotlin版本与Compose编译器插件版本不一致将compose插件版本改为与Kotlin版本完全一致E/AndroidRuntime IllegalStateException有关Compose内部状态同一个可组合函数在多个位置被调用且状态定义位置不当检查状态是否定义在正确的、唯一的组合位置中3. 声明式UI与重组机制先看懂这两个核心概念再动手环境配好了先别急着堆功能建议先把两个核心概念理解透因为它们直接决定你后面写出来的代码质量。这两个概念是声明式UI和重组机制。3.1 声明式UI与命令式UI的差别到底在哪传统View体系里如果你要根据一个boolean值来控制文本的显示和隐藏你通常会写类似这样的代码if (showText) { textView.text 欢迎回来 textView.visibility View.VISIBLE } else { textView.visibility View.GONE }这段代码的特点是你主动“命令”控件去做一系列步骤。界面稍微复杂一点这种命令式代码就会变得像一串面条因为每个控件都可能有自己的分支和依赖。在Compose里同样的效果只需要表达结果if (showText) { Text(欢迎回来) }当showText变成false时框架会自动让文本消失你不需要手动设置visibility。这就是声明式的含义你负责描述界面应该长什么样框架负责在状态变化时把界面变成那样。3.2 重组机制Compose怎么知道界面该更新那“框架自动更新”到底是怎么实现的这就涉及到重组。可以把Compose理解成一个剧场你为每一场戏提供剧本剧本里写着“如果状态是A就显示正派主角如果状态是B就显示反派配角”。当状态从A变到B时剧场会自动按照新剧本重新演出不需要你亲自上台把演员一个个换下去。在技术层面当可组合函数读取了某个状态对象Compose会在这两者之间建立订阅关系。状态一旦发生变化Compose就能精确定位到哪些可组合函数受到了影响并将它们标记为“需要重新执行”。这个重新执行的过程就是重组。重组并不会把整个页面从头到尾全部重跑一遍Compose内部有智能跳过机制只重新执行真正读取了变化状态的那部分代码。3.3 mutableStateOf与remember状态系统的最小单元有了声明式UI和重组的概念基础接下来认识状态API就轻松多了。Compose最基础的状态创建方法是mutableStateOf而remember负责让状态跨越重组仍然保留。Composable fun Counter() { var count by remember { mutableStateOf(0) } Column { Text(当前计数$count) Button(onClick { count }) { Text(加一) } } }count被remember包裹之后即使Counter函数因其他原因发生重组count的值也不会被重置为0。如果把remember去掉函数每次重组都会重新执行mutableStateOf(0)于是计数永远停留在初始值。用一句话来记remember就是Compose里的保险柜它保证你在组合过程中创建的变量不会因为函数重跑而丢失。注意如果你用了var count by remember { mutableStateOf(0) }这种写法文件顶部必须import androidx.compose.runtime.getValue和setValue。少了这两个委托方法的import编译会报错很多新手会卡在这里。4. 从空模板到可交互界面亲手搭一个Compose登录页概念讲完开始动手。我用一个登录表单页作为例子带你从Activity接入开始到完成带输入框、按钮、错误提示和列表的完整界面。4.1 在Activity里挂载ComposesetContent与ComposeView新建项目时选择Empty Activity模板然后MainActivity里只需要做一件事把Compose界面挂载进去。class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyComposeTheme { LoginScreen() } } } }如果你的老项目已经有一套XML布局又想在某个区域试水Compose可以用ComposeView包裹后添加到原有布局中val composeView ComposeView(this).apply { setContent { MyComposeTheme { LoginScreen() } } } findViewByIdFrameLayout(R.id.container).addView(composeView)这种嵌入方式能让团队在完全不动原有架构的前提下先在一个页面里试用Compose。我发现很多团队就是这个切入点开始积累经验的风险很小效果却很直观。4.2 布局三件套Column、Row、Box的选型逻辑Compose最常用的三个基础布局是Column、Row、Box。它们分别对应纵向排列、横向排列和层叠排列类比传统View体系的话Column约等于LinearLayout的verticalRow约等于LinearLayout的horizontalBox约等于FrameLayout。我见过不少新手在选择布局时犹豫不决其实只要记住一条经验先看主方向是纵向还是横向再用是否需要重叠来决定Box。下面的登录页骨架就用Column作为主容器因为表单控件的排列方向是自上而下。Composable fun LoginScreen() { Column( modifier Modifier.fillMaxSize().padding(16.dp), verticalArrangement Arrangement.Center, horizontalAlignment Alignment.CenterHorizontally ) { Text( text 欢迎回来, style MaterialTheme.typography.headlineMedium ) Spacer(modifier Modifier.height(24.dp)) OutlinedTextField( value account, onValueChange { account it }, label { Text(账号) }, singleLine true, modifier Modifier.fillMaxWidth() ) Spacer(modifier Modifier.height(12.dp)) OutlinedTextField( value password, onValueChange { password it }, label { Text(密码) }, visualTransformation PasswordVisualTransformation(), singleLine true, modifier Modifier.fillMaxWidth() ) Spacer(modifier Modifier.height(24.dp)) Button( onClick { login() }, modifier Modifier.fillMaxWidth() ) { Text(登录) } if (errorMessage ! null) { Spacer(modifier Modifier.height(12.dp)) Text( text errorMessage, color MaterialTheme.colorScheme.error ) } } }这个布局代码和XML的巨大区别在于页面结构一览无余没有id绑定没有findViewById代码顺序基本就是视图在屏幕上的顺序。你可以一眼从代码里看出页面从上到下依次是标题、账号输入框、密码输入框、登录按钮和错误提示。4.3 登录页状态设计从输入框到错误提示上面代码里的account、password、errorMessage需要在LoginScreen中定义。状态设计其实就是在问自己哪些数据可能会变哪些变化需要界面做出响应Composable fun LoginScreen() { var account by remember { mutableStateOf() } var password by remember { mutableStateOf() } var errorMessage by remember { mutableStateOfString?(null) } fun login() { if (account.isBlank() || password.length 6) { errorMessage 账号不能为空密码至少6位 } else { errorMessage null // 这里可以替换为真实的网络请求逻辑 } } }三个状态分别对应三个界面变化输入框内容变化、密码输入变化、错误提示显隐变化。remember在这里保证这些值在重组中不被重置比如用户输到一半界面因其他原因触发重组输入框里的文字不会突然消失。4.4 LazyColumn列表与key参数的重要性登录页之后很多界面都会涉及列表。Compose里创建列表最常用的组件是LazyColumn它的定位类似于RecyclerView但用法更简洁Composable fun ChatList(messages: ListString, onMessageClick: (String) - Unit) { LazyColumn( modifier Modifier.fillMaxSize(), contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items(items messages, key { it }) { message - MessageItem(text message, onClick { onMessageClick(message) }) } } }这里最值得关注的是items方法里的key参数。key是每个条目的稳定标识用来让重组或更新时正确定位到对应条目。如果数据会增删移动给key就显得尤为重要。不给key时Compose会退化为按位置索引来更新条目中间插入或删除一项后面的所有条目都可能因为位置错位而被重新创建造成无谓的性能损耗严重时还会出现我后面要讲的“空白条目”诡异现象。5. 入门最容易踩的坑状态生命周期、列表key和View思维惯性这一部分把我在实际项目里遇到过的、帮别人排查过的经典问题归类列出。每一个问题背后都有清晰的逻辑链条理解了链条以后遇到类似情况就能自己定位。5.1 状态写在条件分支里导致remember被重置很多初学者会在条件分支里创建状态比如if (showExtra) { var temp by remember { mutableStateOf() } TextField(value temp, onValueChange { temp it }) }这段代码初看没什么问题但实际运行后会发现当showExtra从true变成false再变回true时temp里的内容变成了空字符串。原因在于remember的工作机制它只在组合中的固定位置保存状态。当条件分支被移出组合时remember记录的状态会被一并丢弃下次分支重新进入组合状态就重新初始化了。这类问题的定位方式很简单在状态变化前后打日志确认remember的生命周期是否跟你预期一致。解决方案通常是把state提升到条件分支的上级作用域让状态在条件分支被移除时依然保存在组合中。5.2 列表不设key引发的“诡异空白条目”之前有位开发找我排查一个聊天页面的问题用户发出一条新消息后列表里总会出现一条空白条目看起来就像数据多了一个空内容。最初的排查方向是数据库查询到了空字符串但打日志后数据完全正常。后来把目光转向LazyColumn的更新机制才发现问题出在列表没设key。新消息插入到列表最前面时其余条目全部发生偏移重组。其中某一条恰好是图片消息条目在重组过程中尺寸测量没有及时完成最终被绘制阶段渲染成空白。给items方法加上稳定的key后问题彻底消失。这个案例说明只要列表数据有动态增删或顺序调整key就是必需品。如果数据本身有稳定的业务id直接用id如果没有可以基于数据内容字节生成稳定的hash值但要注意哈希冲突的可能性更稳妥的是为每条数据维护一个不重复的本地索引。5.3 用View习惯去手动刷新界面思路纠正习惯View体系的开发者接手Compose后最容易犯的错误是试图在回调里手动“刷新”界面比如写一句LoginScreen()以为像以前调用自定义View一样重新绘制。但Compose里的可组合函数不是一个普通的View对象它只是一个界面的描述。真正被框架运行的组合与重组流程本身不由开发者直接调用。正确的做法永远是修改可组合函数所读取的状态。在点击事件里改变状态框架检测到变化后会自动安排重组。还是拿登录按钮举例点击回调里只需要给errorMessage赋予新值界面上的错误提示自然会显示出来。你要把精力放在状态建模上而不是放在如何驱动界面更新这件事上。关键经验在Compose世界里开发者只负责管理状态不需要管理界面。谁修改了状态状态变化会自动驱动相关UI更新。6. 一次完整踩坑复盘登录页疯狂重组定位全过程为了让上面的知识串联起来我把一次线上问题的排查过程完整复盘一遍。某个表单页带着自动查询功能用户输入账号时系统会实时从本地数据库搜索匹配的地址信息并填充到下方列表。上线后发现一个现象输入任何一个字符整个页面都肉眼可见地卡顿。6.1 初步排查先排除数据库查询的嫌疑接到反馈后第一反应是本地数据库查询太慢于是给查询方法加了耗时统计日志。结果表明单次查询耗时不到2毫秒根本不至于造成卡顿。接下来怀疑是滑动列表导致问题但用户卡顿的场景是输入字符不是滑动列表这个方向也不对。于是把注意力转向Compose的重组机制。6.2 断点定位确认重组范围超出预期我在Android Studio里给LoginScreen、地址列表区域、输入框区域分别加了断点和println标记。输入一个字符后观察执行情况发现每输入一个字符不仅输入框对应的可组合函数被重新执行整个LoginScreen的内容块都被执行了一遍底部按钮等无关区域也全部发生了重组。顺着代码往上找立刻定位到根因地址搜索结果的状态被定义在LoginScreen内部查询逻辑写在onValueChange里。每次输入变化都会更新这个状态而所有读取该状态的子组件全部被标记为需要重组。更糟糕的是输入框和地址列表共享同一个状态作用域导致输入一个字符整个页面都跟着重新渲染。6.3 修复方案状态下放、函数细分、防抖修复分两步。第一步把输入框状态和地址搜索结果状态分别放到更细粒度的可组合函数里让它们形成独立的重组作用域第二步给查询逻辑加上300毫秒的防抖避免用户快速连续输入时频繁触发无意义的查询。Composable fun LoginScreen() { var account by remember { mutableStateOf() } var addressSuggestions by remember { mutableStateOfListString(emptyList()) } LaunchedEffect(account) { delay(300) addressSuggestions searchLocalAddress(account) } AccountInput( account account, onAccountChange { account it } ) AddressSuggestionList( suggestions addressSuggestions ) }这里LaunchedEffect会监听account变化每次变化后重新执行代码块但开头先delay 300毫秒实现了一种简洁的防抖效果。用户停止输入300毫秒后才会真正发起搜索。同时AccountInput和AddressSuggestionList各自只读取自己需要的状态Compose就能精确定位到只有AccountInput需要重组AddressSuggestionList和页面其他部分都不受影响。这个案例也是从入门到进阶的一个转折点函数拆分得足够细状态边界足够清晰重组范围就会足够小很多性能问题会自然消失。7. 学完这一篇之后我建议你这样继续走到这里你已经具备了Compose入门阶段最重要的能力搭出带输入框、按钮、列表和错误提示的界面理解状态和重组的联动关系在出问题时能自己定位是不是状态作用域太大或是漏了key。如果接下来想继续深入我建议按下面几个方向展开。第一个方向是主题与设计系统把项目里散落的颜色、字体、圆角统一到MaterialTheme的语义中让设计规范在代码层面落地。第二个方向是动画Compose里的animate*AsState和Animatable配合度非常高用很少的代码就能实现复杂交互动效。第三个方向是自定义布局通过LayoutModifier和SubcomposeLayout可以创造专属于业务的独特布局容器这一点是传统View体系很难优雅实现的。根据我个人经验入门阶段最有效的进步方式不是从头到尾啃官方文档而是跑通一个示例后立刻改造到自己的项目里。比如你把官方示例里的列表改成自己业务的聊天记录把计数器改成购物车数量把主题换成自己产品的配色过程中遇到的报错和卡顿才是真正让你变强的素材。Compose这个框架最让人舒服的地方在于当状态建模想清楚以后界面逻辑会非常干净希望这篇《Android Jetpack之Compose入门(一)》能成为你顺畅上手的起点。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →