
学 Jetpack Compose 最好的方式不是把官方文档从头翻到尾也不是背几个 API 名字而是亲手把一个完整的小功能跑起来。计时器就是一个非常典型的小案例状态要变化、界面要更新、还要借助协程处理异步倒计时逻辑。这篇文章就围绕“制作计时器”这个经典入门项目把 Jetpack Compose 声明式 UI 开发中最核心的状态、重组、副作用协程三个知识点完整讲透。如果你正在学 Compose或者已经有 View 体系的安卓开发经验想快速转入声明式 UI 开发这篇文章很适合你。文中除了完整可运行的代码还会把容易踩坑的地方单独拿出来讲比如协程重复启动、旋转屏幕状态丢失、计时精度误差等。1. Jetpack Compose 与声明式 UI 核心概念1.1 Jetpack Compose 是什么Jetpack Compose 是 Google 在 2021 年正式推出的 Android 原生 UI 开发工具包用于在 Kotlin 语言中构建界面。它不是一个类似 WebView 的跨端框架也不是单纯的布局工具而是用一套全新的编程思想替代了原来基于 XML 加 View 的界面开发模式。在传统安卓开发中我们需要在res/layout里写 XML 布局再用findViewById找到控件手动设置文本、监听点击事件。当界面数据变化时还要主动调用setText、setVisibility等方法去更新控件。这种模式下界面状态和代码逻辑是分离的项目一复杂代码里容易出现大量“找控件、改控件”的样板代码。Compose 的做法完全不同界面直接用 Kotlin 函数描述函数的参数就是界面需要的数据。开发者在代码里用Composable标注一个普通函数在这个函数内部描述“界面应该长成什么样”而不是一步步告诉系统“先创建 Button再设置文本再添加监听”。这种开发方式就叫“声明式 UI”你声明界面的状态Compose 负责在状态变化时自动更新界面。1.2 声明式 UI 与传统命令式 UI 的差异为了更清楚地说明声明式 UI 的优势我整理了一个对比表格这里面的差异也是很多开发者刚接触 Compose 时最强烈的感受。对比维度传统 View 体系Jetpack Compose界面描述方式XML 布局文件Kotlin 可组合函数控件获取findViewById / ViewBinding不需要获取控件实例状态更新手动调用 setText、setVisibility 等方法状态变化后自动重组界面代码结构布局与逻辑分离布局、逻辑、状态集中在可组合函数中复用方式自定义 View / include 布局直接抽取可组合函数预览调试Layout EditorPreview 注解实时预览从表格可以看出Compose 把开发者从繁琐的控件操作中解放了出来。你不需要关心“这个 TextView 在哪个位置它现在的文本应该改成什么”只需要关注“当前界面的数据状态是什么”。数据变了界面自动跟着变。1.3 为什么计时器是 Compose 入门首选案例很多初学者学完 Compose 基础语法后不知道下一步该练什么。做个登录页面大多是在堆控件做个列表又涉及到 ViewModel 和网络请求。计时器这个案例则刚刚好状态变化频繁能让你直观感受到“状态驱动界面重组”的过程。涉及异步逻辑需要用到延迟、协程、副作用 API这是 Compose 必学知识点。界面元素不多只需要文本、按钮、进度展示适合专注核心知识。后续可以扩展出暂停、重置、自定义进度环、后台通知等功能学习空间很大。所以本文选择的实战项目就是计时器用最精简但完整的方式带你走一遍 Compose 应用开发的核心流程。2. 环境准备与版本说明2.1 推荐开发环境做 Compose 开发推荐环境如下。需要注意的是不同版本的 Android Studio 和 Gradle 会有细微差异建议以你本地的实际情况为准不要盲目照抄版本号。操作系统Windows / macOS / Linux 均可本文不依赖特定系统。开发工具Android Studio建议使用较新的稳定版本旧版本可能不支持新版 Compose 编译器插件。JDK 版本建议 11 或 17Android Studio 自带的 JBRJetBrains Runtime一般已经满足要求。Android SDK建议安装 API 35 或更高版本最低支持版本不强制普通项目最低支持到 Android 5.0 以上即可。如果你之前没有安装过安卓开发环境可以在 Android Studio 的 SDK Manager 中一键安装 SDK Platform、Build Tools 和模拟器镜像。整个过程比较常规这里不展开。2.2 新建 Compose 项目打开 Android Studio 后通过File - New - New Project创建新项目。在模板列表里选择Empty Activity注意部分版本中会区分“Empty Activity”和“Empty Activity (Compose)”如果你的 Android Studio 版本较新默认模板已经是 Compose 项目。新建项目时建议设置好 Application Id 和项目名。本文示例使用包名com.example.composetimer你可以换成自己的包名。项目创建完成后目录结构大致如下app/src/main/java/com/example/composetimer/ MainActivity.kt app/src/main/res/ values/ strings.xml themes.xml与普通 View 项目不同Compose 项目里没有res/layout布局文件MainActivity通过setContent直接加载可组合函数。2.3 依赖配置解析Compose 项目需要在app/build.gradle.kts中开启 Compose 构建开关并添加相关依赖。下面这段配置是核心配置思路如果你的版本号不同保持模板默认值即可。// app/build.gradle.kts plugins { id(com.android.application) id(org.jetbrains.kotlin.android) // Kotlin 2.0 之后Compose 编译器插件随 Kotlin 版本发布 id(org.jetbrains.kotlin.plugin.compose) } android { namespace com.example.composetimer compileSdk 35 defaultConfig { applicationId com.example.composetimer minSdk 24 targetSdk 35 versionCode 1 versionName 1.0 } buildFeatures { compose true } } dependencies { // 使用 Compose BOM 统一管理 Compose 相关版本 val composeBom platform(androidx.compose:compose-bom:2024.06.00) implementation(composeBom) implementation(androidx.core:core-ktx:1.13.0) implementation(androidx.activity:activity-compose:1.9.0) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.compose.ui:ui-tooling-preview) debugImplementation(androidx.compose.ui:ui-tooling) }几个重点buildFeatures { compose true }必须开启否则项目无法识别Composable注解。Compose BOM 是一个版本控制平台引入它之后下面的ui、material3不需要写具体版本号BOM 会自动统一。Kotlin 2.0 及以上建议使用org.jetbrains.kotlin.plugin.compose插件。如果项目用的是 Kotlin 1.9.x则需要在android节点下配置composeOptions指定 Compose 编译器版本。activity-compose提供setContent扩展函数ui-tooling-preview提供Previewui-tooling是预览和布局检查器的调试依赖。依赖配置完成后先点一次 Sync让 Gradle 下载对应依赖再继续写代码。如果你在同步过程中报错多半是网络问题或版本不兼容优先检查 Gradle JDK 版本和代理设置也可以尝试把 compileSdk 降到模板默认值。3. Compose 核心概念状态、重组与协程3.1 可组合函数的最小示例Compose 界面由一个个可组合函数组成。最简单的可组合函数长这样Composable fun Greeting(name: String) { Text(text Hello $name) }Composable告诉 Compose 编译器“这个函数是用来生成界面的”。Text是 Compose 提供的文本组件相当于传统 View 系统中的 TextView。可组合函数有几个特点函数名首字母大写这是一个约定便于在代码中区分普通函数和可组合函数。函数可以接收参数参数就是该组件显示所需的数据。函数内部可以继续调用其他可组合函数从而组合出复杂界面。这种“函数组合”的模式就是 Compose 名字的由来。3.2 State 与 remember状态驱动的重组可组合函数本身没有记忆能力。函数执行结束后局部变量都会丢失。如果我们需要让界面上的数字在点击后变化就需要借助State对象。看一个最简单的计数器示例Composable fun Counter() { var count by remember { mutableStateOf(0) } Button(onClick { count }) { Text(点击次数$count) } }这里的核心知识是mutableStateOf(0)创建了一个可变的 State 对象它内部保存着当前的数值。remember的作用是让这个 State 对象在重组后仍然保留。没有remember每次函数重新执行数值都会重置回 0。by是 Kotlin 的委托语法配合var count使用后读写count就相当于读写 State 内部的值。当count的值发生变化时Compose 会重新执行使用到这个 State 的可组合函数这个重新执行的过程叫做“重组”。重组是 Compose 最核心的运行机制。开发者不需要手动调用刷新方法状态一变界面自动更新这就是声明式 UI 的直观体现。注意remember保存的状态无法跨越 Activity 重建。比如手机旋转屏幕Activity 销毁重建remember中的状态就丢了。如果想要状态在配置变化后仍然保留要使用rememberSaveable。本文的计时器项目就会用到这一点。3.3 协程与 LaunchedEffect在组合中执行异步逻辑界面函数里不建议直接写无限循环或延迟操作因为可组合函数会因为状态变化被反复执行如果每次执行都启动一个协程很容易出现协程泄漏。Compose 提供了副作用 API 来管理这类操作其中最常用的是LaunchedEffectComposable fun TimerDemo() { var seconds by remember { mutableStateOf(0) } LaunchedEffect(Unit) { while (true) { delay(1000L) seconds } } Text(text 已运行 $seconds 秒) }LaunchedEffect有两个参数第一个参数是 key当 key 发生变化时Compose 会取消旧的协程重新启动一个新协程。第二个参数是协程体在协程体内部可以调用delay等挂起函数。上面例子中的LaunchedEffect(Unit)表示只要这个可组合函数进入组合就启动协程离开组合时协程自动取消。这样就不会因为函数重组而重复启动多个协程。计时器项目的倒计时逻辑正是基于LaunchedEffect的自动取消机制来实现“开始”和“暂停”的。3.4 生命周期与安全性Compose 可组合函数的生命周期相对简单但也需要开发者注意以下几点可组合函数进入组合时副作用 API 开始工作。可组合函数因状态变化而重组时如果 key 没变协程不会重启。可组合函数离开组合或者 key 变化时旧协程会被自动取消。不要在可组合函数中直接使用GlobalScope.launch那样会创建不受生命周期管理的协程容易造成内存泄漏。理解了这些概念之后我们开始进入实战环节把计时器完整做出来。4. 完整实战制作一个可用的计时器4.1 功能拆解我们实现的计时器要具备以下功能默认倒计时时长为 10 分钟。使用“分:秒”格式显示当前剩余时间。点击“开始”按钮开始倒计时按钮文字变为“暂停”。点击“暂停”按钮暂停倒计时按钮文字恢复为“开始”。点击“重置”按钮恢复初始时间。倒计时结束时自动停止并显示“00:00”。用环形进度条直观展示剩余时间比例。为了控制篇幅我们把功能集中在一个TimerScreen可组合函数中但把时间格式化和环形进度单独拆成函数方便后续复用。4.2 编写入口 MainActivityMainActivity是整个应用的入口。在 Compose 项目中它只负责加载主题和设置根可组合函数。package com.example.composetimer import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { TimerScreen() } } } }setContent是activity-compose提供的扩展函数它会把传入的可组合函数设置成 Activity 的内容。MaterialTheme为界面提供 Material 3 主题样式里面可以嵌套整个应用界面。4.3 实现倒计时状态与逻辑接下来是计时器的核心逻辑状态定义和倒计时循环。先看代码再逐行解释。Composable fun TimerScreen(totalSeconds: Int 10 * 60) { var remainingSeconds by rememberSaveable { mutableStateOf(totalSeconds) } var isRunning by rememberSaveable { mutableStateOf(false) } LaunchedEffect(isRunning) { if (isRunning) { while (remainingSeconds 0 isRunning) { delay(1000L) remainingSeconds-- } if (remainingSeconds 0) { isRunning false } } } }这里有几个设计上的考虑第一使用rememberSaveable而不是remember保存状态。这样在手机旋转屏幕、Activity 重建时剩余秒数和运行状态都能保留下来体验更接近真实应用。第二isRunning是倒计时的“开关”。LaunchedEffect(isRunning)的 key 是isRunning。点击开始后isRunning变为 true协程启动点击暂停后isRunning变为 false旧协程被取消倒计时停止。不需要手动调用 cancel 方法Compose 在 key 变化时自动完成。第三循环条件中同时判断remainingSeconds 0 isRunning。注意这里写的isRunning读的是 State 的最新快照值虽然变量本身在闭包中看起来没有变化但通过by委托读取时实际会读取 State 内部的最新值。这样做的目的是当用户点击暂停时协程能更早地退出而不是再等一秒。第四倒计时结束后把isRunning设置为 false。这样界面上的按钮会自动从“暂停”恢复为“开始”同时LaunchedEffect(isRunning)因为 key 变化会重新执行但新协程的isRunning为 false不会继续倒计时。4.4 实现界面布局与按钮状态逻辑准备好之后再补上界面。计时器的界面结构是纵向居中布局上面是环形进度和数字下面是两个按钮。Composable fun TimerScreen(totalSeconds: Int 10 * 60) { var remainingSeconds by rememberSaveable { mutableStateOf(totalSeconds) } var isRunning by rememberSaveable { mutableStateOf(false) } LaunchedEffect(isRunning) { if (isRunning) { while (remainingSeconds 0 isRunning) { delay(1000L) remainingSeconds-- } if (remainingSeconds 0) { isRunning false } } } val progress if (totalSeconds 0) 0f else remainingSeconds.toFloat() / totalSeconds.toFloat() Column( modifier Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { TimerRing(progress progress, timeText formatTime(remainingSeconds)) Spacer(modifier Modifier.height(48.dp)) Row(horizontalArrangement Arrangement.spacedBy(20.dp)) { Button( onClick { isRunning !isRunning }, modifier Modifier.height(52.dp) ) { Text(if (isRunning) 暂停 else 开始, fontSize 18.sp) } OutlinedButton( onClick { isRunning false remainingSeconds totalSeconds }, modifier Modifier.height(52.dp) ) { Text(重置, fontSize 18.sp) } } } }布局说明如下Column是纵向容器类似LinearLayout的垂直方向。fillMaxSize()让容器铺满屏幕padding(24.dp)保证内容不贴边。horizontalAlignment控制子项水平居中verticalArrangement控制子项在垂直方向居中排列。Spacer用于在环形进度和按钮之间撑开一段垂直间距。Row是横向容器horizontalArrangement Arrangement.spacedBy(20.dp)表示子项之间间隔 20dp。Button是 Material 3 的填充按钮“开始 / 暂停”用Button“重置”用OutlinedButton这样视觉上更有主次。重置按钮的 onClick 顺序有讲究先设置isRunning false再设置remainingSeconds totalSeconds。如果反过来旧协程在取消之前有可能读到新的剩余时间导致重置后又被减一秒。虽然这种情况比较微妙但写成“先停止再恢复初值”更安全。倒计时结束后我希望按钮能从“暂停”恢复为“开始”这依赖前面说的协程末尾isRunning false。如果不加这一句倒计时虽然显示 00:00按钮却仍显示“暂停”用户再点一次数字又从 00:00 重新开始跑明显不合理。4.5 用 Canvas 绘制环形进度环形的进度展示是很多入门教程里不会细讲的部分但它特别能体现 Compose 自定义绘制的能力。用Canvas画两个圆弧即可实现一个灰色底环一个彩色进度环。Composable fun TimerRing(progress: Float, timeText: String, modifier: Modifier Modifier) { Box( modifier modifier.size(240.dp), contentAlignment Alignment.Center ) { Canvas(modifier Modifier.fillMaxSize()) { val strokeWidth 16.dp.toPx() val arcSize Size( width this.size.width - strokeWidth, height this.size.height - strokeWidth ) val topLeft Offset(strokeWidth / 2f, strokeWidth / 2f) drawArc( color Color(0xFFE8EAF6), startAngle 0f, sweepAngle 360f, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth, cap StrokeCap.Round) ) drawArc( color Color(0xFF3F51B5), startAngle -90f, sweepAngle 360f * progress, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth, cap StrokeCap.Round) ) } Text( text timeText, fontSize 44.sp, fontWeight FontWeight.Bold, color Color(0xFF3F51B5) ) } }Canvas内部的绘制代码中几个参数值得注意drawArc用来绘制圆弧。第一个圆弧是灰色底环角度从 0 到 360相当于一个完整的圆。第二个圆弧是彩色进度环startAngle -90f表示从顶部开始画sweepAngle 360f * progress根据剩余时间比例确定扫过的角度。useCenter false表示只画弧线而不是扇形配合Stroke就可以得到环形效果。StrokeCap.Round让圆弧端点是圆头的视觉上更柔和。通过topLeft和arcSize来缩小圆弧区域避免笔画宽度超出 Canvas 边界。Box是一个可以叠加子组件的容器。contentAlignment Alignment.Center让 Canvas 和文本重叠居中从而实现“数字在圆环中心”的效果。再补充时间格式化函数。这里要注意String.format依赖默认 Locale为了避免不同语言环境下数字格式异常建议显式传入Locale.US。fun formatTime(totalSeconds: Int): String { val safeSeconds if (totalSeconds 0) 0 else totalSeconds val minutes safeSeconds / 60 val seconds safeSeconds % 60 return String.format(Locale.US, %02d:%02d, minutes, seconds) }这个函数把总秒数转换成mm:ss格式比如 599 秒会输出09:59。最后为了让 Android Studio 的预览面板能看到效果可以添加一个带Preview的可组合函数Preview(showBackground true) Composable fun TimerScreenPreview() { MaterialTheme { TimerScreen() } }Preview注解让 Android Studio