Jetpack Compose声明式UI实战:从零手写一个计时器

发布时间:2026/9/2 1:41:15
Jetpack Compose声明式UI实战:从零手写一个计时器 15-Jetpack Compose安卓声明式UI开发从零手写一个计时器刚开始接触 Jetpack Compose 的时候相信很多朋友都有类似的感受看官方文档时觉得“声明式 UI”这个概念很好理解可真到自己动手写界面的时候又会卡在状态管理、重组机制、协程调用这些细节上尤其是做一个带倒计时功能的计时器既要处理时间刷新又要把进度环画出来还要考虑暂停、重置这些交互看似简单的功能实际写起来并没有想象中那么顺利。这篇文章就围绕一个完整的“制作计时器”实战项目展开。我们会从声明式 UI 的核心概念入手逐步完成环境配置、界面编写、倒计时逻辑、进度条绘制和问题排查。整个过程不依赖第三方库只使用 Jetpack Compose 提供的状态、协程和绘制 API适合有一定 Kotlin 基础、正在学习 Compose 的安卓开发者也适合想通过一个小项目把声明式 UI 思路真正落到代码里的初学者。学完这篇文章你可以掌握 Compose 中Composable函数的用法、State和重组机制、remember状态保存、LaunchedEffect协程启动方式以及 Canvas 绘制进度环的基本套路。更重要的是你会理解为什么声明式 UI 开发中“状态驱动界面”这句话如此重要。1. 为什么要用 Jetpack Compose 制作计时器1.1 计时器为什么适合作为 Compose 入门项目计时器这个项目小但五脏俱全。它需要界面展示、按钮交互、定时刷新、状态切换、自定义绘制几乎覆盖了 Compose 开发中最常用的一整套知识。如果只是写一个静态页面你很难体会到 Compose 和传统 View 体系的区别但如果一上来就做电商首页、视频列表这种大型页面又会因为涉及大量架构问题而影响对基础概念的理解。计时器刚好卡在中间代码量不大但能完整展示一个由状态驱动界面变化的开发流程。在传统 View 体系中要实现倒计时效果一般会用CountDownTimer或者Handler不断更新TextView的文本。这种方式本质上是“命令式”的开发者需要主动告诉控件“你现在要把文字改成什么”。而 Compose 的写法完全相反我们只需要定义“状态是什么样界面就应该长什么样”剩下的交给 Compose 自动处理。1.2 什么是声明式 UI先看一个非常直观的对比。传统 View 写法通常是这样的TextView textView findViewById(R.id.tv_time); textView.setText(01:00);而 Compose 的写法是这样的Text( text formattedTime, style MaterialTheme.typography.headlineLarge )区别在于在 Compose 中formattedTime是一个状态变量。当这个变量的值发生变化时使用它的Text会自动重新执行界面自然就刷新了。开发者不需要手动调用任何setText方法。这就是“声明式 UI”的核心思路我们声明界面在不同状态下的样子状态变化后框架负责把界面更新到最新状态。1.3 计时器涉及的核心知识点做一个计时器你会用到下面这些 Compose 核心概念概念在计时器中的作用Composable函数定义界面结构比如文字、按钮、画布State/mutableStateOf保存剩余时间、运行状态等可变数据remember让状态在重组时不丢失LaunchedEffect启动协程实现每秒钟倒计时的效果Canvas绘制进度圆环事件处理点击开始、暂停、重置按钮时更新状态把这几个概念彻底搞清楚你在 Compose 开发中的地基就基本打牢了。2. 环境准备与版本说明2.1 开发工具准备在开始写代码之前需要先准备好开发环境。操作系统Windows / macOS / Linux 均可开发工具Android Studio 最新稳定版语言版本Kotlin 1.9 或更高版本构建工具Gradle 8.x最低支持系统版本建议minSdk 24目标版本建议compileSdk 34或你的项目实际使用的版本如果你已经安装了 Android Studio可以在Settings - Plugins中确认 Kotlin 插件状态。新版本的 Android Studio 默认会内置 Kotlin 和 Jetpack Compose 支持不需要额外安装插件。2.2 创建项目打开 Android Studio选择New Project - Empty Activity项目名称可以设置为ComposeTimer包名使用你自己的域名反写例如com.example.composetimer。创建完成后Android Studio 会自动生成一个基于 Compose 的模板项目。模板中已经包含MainActivity.ktui/theme/目录下的主题相关文件build.gradle.kts配置文件如果你的 Android Studio 版本比较老创建项目时没有自动选择 Compose 模板也可以手动在build.gradle.kts中添加依赖。2.3 build.gradle.kts 基础配置下面给出一个典型的 Compose 项目配置文件以 Groovy DSL 为例// 文件路径app/build.gradle plugins { id com.android.application id org.jetbrains.kotlin.android } android { namespace com.example.composetimer compileSdk 34 defaultConfig { applicationId com.example.composetimer minSdk 24 targetSdk 34 versionCode 1 versionName 1.0 } buildFeatures { compose true } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget 17 } } dependencies { def composeBom platform(androidx.compose:compose-bom:2024.06.00) implementation composeBom implementation androidx.core:core-ktx:1.13.1 implementation androidx.lifecycle:lifecycle-runtime-ktx:2.8.3 implementation androidx.activity:activity-compose:1.9.0 implementation androidx.compose.ui:ui implementation androidx.compose.ui:ui-graphics implementation androidx.compose.ui:ui-tooling-preview implementation androidx.compose.material3:material3 }如果你使用的是 Kotlin 2.0 及以上版本还需要注意 Compose 编译器的配置方式。Kotlin 2.0 开始Compose 编译器插件已经合并到 Kotlin 插件中使用方式如下plugins { id com.android.application id org.jetbrains.kotlin.android id org.jetbrains.kotlin.plugin.compose }版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。如果你的项目使用的是 KTSKotlin DSL格式需要把上面的 Groovy 语法转换成对应的 Kotlin 语法。3. 声明式 UI 核心概念拆解在写计时器代码之前有必要先理解几个核心概念。这些概念不仅计时器要用以后写任何 Compose 应用都会用到。3.1 Composable 可组合函数在 Compose 中每个界面元素都是一个带Composable注解的普通函数。函数可以接收参数返回值不直接是界面对象而是描述界面的“说明”。Composable fun Greeting(name: String) { Text(text 你好$name) }Greeting函数接收一个name参数输出一段文本。函数内部可以调用其他Composable函数这种组合方式就是“可组合”这个名字的由来。3.2 State 与重组Compose 界面是状态驱动的。我们用mutableStateOf创建一个状态对象var count by remember { mutableStateOf(0) }这里的count就是一个状态变量。当count的值发生变化时所有读取了这个变量的可组合函数都会自动重新执行这个过程叫“重组”。需要特别注意的是只有状态变化count变化才可能触发重组。普通 Kotlin 变量并不会触发界面刷新。3.3 remember 的作用remember的作用是让状态在重组时不被重置。如果不使用remember函数每次重组都会重新执行里面的初始化代码那状态就永远只能停留在初始值了。使用remember后Compose 会记住这个值在不同重组之间保持它的存在。var count by remember { mutableStateOf(0) } // 重组后 count 不会归零另外还有一个更进阶的rememberSaveable它不仅能跨重组保存状态还能在 Activity 被系统销毁重建时保存状态后面我们会提到。3.4 LaunchedEffect 与协程Compose 中做倒计时这类周期任务最常用的方式是LaunchedEffect。它的作用是在可组合函数进入组合时启动一个协程并在指定参数变化时取消旧协程并重新启动。LaunchedEffect(isRunning) { while (isRunning) { delay(1000L) // 更新剩余时间 } }这里有一个很关键的设计LaunchedEffect(isRunning)的 key 是isRunning。当isRunning从false变为true时协程重新启动开始倒计时当isRunning变为false时协程被取消倒计时停止。3.5 事件回调按钮点击在 Compose 中通过onClick回调处理Button(onClick { isRunning !isRunning }) { Text(if (isRunning) 暂停 else 开始) }点击按钮后isRunning状态改变界面自动重组按钮文字也会随之变化。整个过程不需要手动刷新任何控件。4. 完整实战用 Jetpack Compose 制作一个计时器现在进入核心环节我们一步步搭建这个计时器应用。4.1 设计目标和效果先明确我们要实现的功能显示一个 60 秒倒计时点击“开始”按钮倒计时开始点击“暂停”按钮倒计时暂停点击“重置”按钮恢复成 60 秒界面中央显示剩余时间时间外圈显示进度环剩余时间越少进度环越短整体效果就是一个非常典型的计时器 App。4.2 项目结构我们会在MainActivity.kt中编写主要代码并把界面拆分成以下几个函数便于阅读和维护MainActivityActivity 入口TimerScreen计时器主界面负责管理状态TimerCircle绘制进度环并显示剩余时间formatTime时间格式化工具函数最终目录结构大致如下app/src/main/java/com/example/composetimer/ ├── MainActivity.kt └── ui/theme/ui/theme目录由模板自动生成我们不需要做任何修改。4.3 编写时间格式化工具先写一个简单的函数把毫秒数格式化为MM:SS格式// 文件路径app/src/main/java/com/example/composetimer/MainActivity.kt fun formatTime(millis: Long): String { val totalSeconds millis / 1000 val minutes totalSeconds / 60 val seconds totalSeconds % 60 return String.format(%02d:%02d, minutes, seconds) }假设传入61000毫秒计算结果就是01:01。如果希望显示毫秒级精度可以改成下面这种格式fun formatTimeWithMillis(millis: Long): String { val totalSeconds millis / 1000 val minutes totalSeconds / 60 val seconds totalSeconds % 60 val millisPart millis % 1000 / 100 return String.format(%02d:%02d.%d, minutes, seconds, millisPart) }计时器一般不需要显示毫秒所以我们使用前面那个版本。4.4 主界面代码在MainActivity.kt中完整代码如下package com.example.composetimer import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.composetimer.ui.theme.ComposeTimerTheme import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ComposeTimerTheme { TimerScreen() } } } } Composable fun TimerScreen() { val totalTime 60_000L var remainTime by remember { mutableStateOf(totalTime) } var isRunning by remember { mutableStateOf(false) } LaunchedEffect(isRunning) { while (isRunning) { delay(1000L) remainTime - 1000L if (remainTime 0L) { remainTime 0L isRunning false } } } val progress if (totalTime 0L) 0f else remainTime.toFloat() / totalTime Column( modifier Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { TimerCircle( progress progress, remainTime remainTime ) Spacer(modifier Modifier.height(48.dp)) Row( horizontalArrangement Arrangement.spacedBy(24.dp) ) { Button( onClick { isRunning !isRunning } ) { Text(if (isRunning) 暂停 else 开始) } OutlinedButton( onClick { isRunning false remainTime totalTime } ) { Text(重置) } } } } Composable fun TimerCircle(progress: Float, remainTime: Long) { Box( contentAlignment Alignment.Center ) { Canvas( modifier Modifier.size(220.dp) ) { val strokeWidth 12.dp.toPx() val diameter size.minDimension - strokeWidth val topLeft Offset( (size.width - diameter) / 2f, (size.height - diameter) / 2f ) val arcSize Size(diameter, diameter) // 底部浅灰色底环 drawArc( color Color.LightGray, startAngle 0f, sweepAngle 360f, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth, cap StrokeCap.Round) ) // 彩色进度环 drawArc( color MaterialTheme.colorScheme.primary, startAngle -90f, sweepAngle 360f * progress, useCenter false, topLeft topLeft, size arcSize, style Stroke(width strokeWidth, cap StrokeCap.Round) ) } Text( text formatTime(remainTime), style MaterialTheme.typography.headlineLarge, fontWeight FontWeight.Bold ) } } Preview(showBackground true) Composable fun TimerPreview() { ComposeTimerTheme { TimerScreen() } }4.5 核心代码解释这段代码中最关键的是LaunchedEffect和Canvas两个部分这里逐一解释。LaunchedEffect 倒计时逻辑LaunchedEffect(isRunning) { while (isRunning) { delay(1000L) remainTime - 1000L if (remainTime 0L) { remainTime 0L isRunning false } } }当isRunning为true时协程启动每隔 1000 毫秒将remainTime减去 1000 毫秒。当remainTime小于等于 0 时将剩余时间设置为 0同时把isRunning改为false这样循环就会退出按钮文字也会自动变回“开始”。这里需要注意while (isRunning)中的isRunning是状态变量协程每次循环都会读取它的最新值。所以你点击“暂停”按钮时isRunning变成false协程下一次循环判断条件不满足自然退出。Canvas 进度环drawArc方法用于绘制圆弧。我们画了两条圆弧底层圆弧颜色为浅灰色从 0 度到 360 度画一个完整的圆。上层圆弧颜色为主题色从 -90 度开始扫过360f * progress度。这里startAngle -90f是为了让进度环从顶部开始而不是从 3 点钟方向开始。Stroke(width strokeWidth, cap StrokeCap.Round)指定了线宽和圆角线帽这样绘制出来的进度环两端是圆润的视觉效果更好。4.6 运行与验证将项目同步到手机或模拟器后点击运行按钮。你会看到以下效果界面正中显示一个圆形进度环环中间显示01:00点击“开始”按钮进度环开始逐渐缩短时间开始倒计时点击“暂停”按钮倒计时暂停点击“重置”按钮时间和进度环恢复初始状态如果一切正常恭喜你第一个 Jetpack Compose 计时器应用已经完成了。5. 深入进阶使用 ViewModel 管理计时器状态如果只是做一个演示项目把状态直接写在TimerScreen里完全够用。但在真实项目中我们更推荐把业务逻辑和界面分离使用ViewModel管理状态。这样做的原因主要有三个状态不会在界面重组时丢失界面旋转时状态仍然保留逻辑可以独立测试5.1 添加 ViewModel 依赖在app/build.gradle中添加implementation androidx.lifecycle:lifecycle-viewmodel-compose:2.8.35.2 编写 TimerViewModelpackage com.example.composetimer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class TimerViewModel : ViewModel() { private val _remainTime MutableStateFlow(60_000L) val remainTime: StateFlowLong _remainTime.asStateFlow() private val _isRunning MutableStateFlow(false) val isRunning: StateFlowBoolean _isRunning.asStateFlow() private var timerJob: Job? null private val totalTime 60_000L fun start() { if (_isRunning.value) return _isRunning.value true timerJob viewModelScope.launch { while (_remainTime.value 0) { delay(1000L) _remainTime.value - 1000L } _isRunning.value false } } fun pause() { _isRunning.value false timerJob?.cancel() } fun reset() { _isRunning.value false timerJob?.cancel() _remainTime.value totalTime } }MutableStateFlow是 Kotlin 协程库中的状态容器配合StateFlow可以在 Compose 中直接观察。相比普通的remember { mutableStateOf() }ViewModel 中的状态生命周期更长更适合承载业务数据。5.3 在 Compose 中收集 StateFlow使用 ViewModel 时界面代码需要配合collectAsState()或collectAsStateWithLifecycle()来订阅状态变化。Composable fun TimerScreen(viewModel: TimerViewModel viewModel()) { val remainTime by viewModel.remainTime.collectAsState() val isRunning by viewModel.isRunning.collectAsState() val progress if (remainTime 0L) 0f else remainTime.toFloat() / 60_000f Column( modifier Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { TimerCircle(progress progress, remainTime remainTime) Spacer(modifier Modifier.height(48.dp)) Row(horizontalArrangement Arrangement.spacedBy(24.dp)) { Button(onClick { viewModel.start() }) { Text(if (isRunning) 暂停 else 开始) } OutlinedButton(onClick { viewModel.reset() }) { Text(重置) } } } }注意这里按钮的onClick和之前的写法不太一样。开始按钮触发的逻辑取决于当前是否在运行如果正在运行点击应该暂停如果已停止点击应该开始。因此 ViewModel 中应该提供一个综合的toggle()方法fun toggle() { if (_isRunning.value) { pause() } else { start() } }然后在界面中调用onClick { viewModel.toggle() }。这样逻辑更清晰界面代码更简单。6. 常见问题与排查思路在实际开发过程中新手经常会遇到下面这些问题。这里整理了一个排查清单帮助大家快速定位。问题现象常见原因解决思路点击按钮后界面没有变化状态变量没有使用mutableStateOf或没有加by remember检查状态声明是否写成var count by remember { mutableStateOf(...) }倒计时一次跳好几秒多个LaunchedEffect同时运行或多次点击开始了多个协程确保LaunchedEffect的 key 正确或者使用 ViewModel 统一管理协程旋转屏幕后计时状态丢失remember只能跨重组保存状态不能跨 Activity 重建保存改用rememberSaveable或 ViewModel倒计时不够精确时间越走越慢delay只是延迟执行不保证精确间隔系统调度会有误差使用SystemClock.elapsedRealtime()计算真实经过时间详见下一节编译报错Compose compiler 与 Kotlin 版本不兼容Kotlin 版本和 Compose 编译器版本不匹配使用 Kotlin 2.0 及以上版本时添加org.jetbrains.kotlin.plugin.compose插件Canvas 绘制的圆环位置偏移没有处理好尺寸计算使用size.minDimension计算直径并通过topLeft居中6.1 编译报错Compose compiler 版本不兼容这是一个非常常见的问题。早期Compose 编译器需要单独指定版本并且与 Kotlin 版本严格对应。从 Kotlin 2.0 开始Compose 编译器插件随 Kotlin 一起发布你只需要在plugins中添加id org.jetbrains.kotlin.plugin.compose如果仍然遇到版本相关的编译错误可以先检查项目的 Gradle 和 Kotlin 插件版本然后同步项目。6.2 状态不刷新有时候你会发现明明修改了变量但界面没有变化。最常见的原因是使用了普通变量而不是mutableStateOf。// 错误写法 var count 0 Text(text count: $count) Button(onClick { count }) // 正确写法 var count by remember { mutableStateOf(0) } Text(text count: $count) Button(onClick { count })只有mutableStateOf创建的状态对象才能触发 Compose 重组。普通 Kotlin 变量不会让界面更新。6.3 精确计时的实现前面我们用delay(1000L)实现倒计时这种方法在大多数场景下够用但如果用户长时间运行累积误差可能比较明显。更精确的做法是记录开始时间再用当前时间减去开始时间换算剩余时。Composable fun PreciseTimerScreen() { val totalTime 60_000L var remainTime by remember { mutableStateOf(totalTime) } var isRunning by remember { mutableStateOf(false) } var startTime by remember { mutableStateOf(0L) } LaunchedEffect(isRunning) { if (isRunning) { startTime SystemClock.elapsedRealtime() while (isRunning) { val elapsed SystemClock.elapsedRealtime() - startTime remainTime (totalTime - elapsed).coerceAtLeast(0L) if (remainTime 0L) { isRunning false } delay(100L) // 刷新频率可以更高 } } } // 界面代码略 }注意暂停后重新开始时需要把已过去的时间扣除不能直接从总时间重新计算。通常需要一个initialRemainTime字段记录暂停时剩余的时间var remainBeforePause by remember { mutableStateOf(totalTime) } fun onPause() { remainBeforePause remainTime isRunning false } LaunchedEffect(isRunning) { if (isRunning) { startTime SystemClock.elapsedRealtime() while (isRunning) { val elapsed SystemClock.elapsedRealtime() - startTime remainTime (remainBeforePause - elapsed).coerceAtLeast(0L) if (remainTime 0L) { isRunning false } delay(100L) } } }7. 最佳实践与工程建议计时器项目虽小但通过它可以提炼出一套适用于整个 Compose 开发的最佳实践。这里分享几个在真实项目中比较重要的建议。7.1 状态提升与状态容器在 Compose 中如果某个状态需要被多个组件共享就应该把状态提升到它们的共同父级。比如TimerScreen中的remainTime和isRunning被TimerCircle和按钮共同使用所以放在TimerScreen这一层。当业务逻辑变复杂时更推荐把状态和逻辑放进 ViewModel。ViewModel 不依赖界面天然支持单元测试而且和 Compose 的状态系统配合得很好。推荐的层级是Activity / Fragment ↓ ViewModel (持有业务状态) ↓ Composable (调用 viewModel.collectAsState() 获取状态) ↓ 子 Composable (只负责展示和回调)7.2 rememberSaveable 与状态恢复普通remember保存的状态在 Activity 被系统回收后可能丢失。如果状态比较简单且不想引入 ViewModel可以使用rememberSaveablevar remainTime by rememberSaveable { mutableStateOf(60_000L) } var isRunning by rememberSaveable { mutableStateOf(false) }rememberSaveable会在 Activity 重建时恢复状态。不过它需要状态可以被序列化保存到 Bundle 中对于复杂的自定义对象可能需要自定义Saver。7.3 避免在 Composable 中执行耗时操作Composable 函数可能在主线程执行也可能因为重组而频繁调用。不要在 Composable 函数体中执行 IO 操作、网络请求或复杂计算。耗时操作应该放入协程或者放在 ViewModel 中通过viewModelScope执行。7.4 使用字符串资源界面中直接写中文文字虽然方便但不推荐在生产项目中这样做。更好的做法是把字符串放到res/values/strings.xml中然后通过stringResource()获取!-- 文件路径app/src/main/res/values/strings.xml -- resources string namestart开始/string string namepause暂停/string string namereset重置/string /resourcesText(if (isRunning) stringResource(R.string.pause) else stringResource(R.string.start))这样方便日后做多语言适配也避免字符串散落在代码中。7.5 关注生命周期与协程安全如果直接在 Composable 中使用rememberCoroutineScope()启动协程协程的生命周期会绑定到组合的声明周期。当 Composable 退出组合时协程会被自动取消。这一点在使用协程时非常方便但也要注意如果你创建了一个不通过rememberCoroutineScope管理的协程它可能会在界面销毁后继续执行造成内存泄漏。使用 ViewModel 时viewModelScope是跨界面存活的必须小心在onCleared()中取消不需要的任务。不过大多数情况下倒计时任务在 ViewModel 清除时自动取消即可。7.6 UI 测试Compose 提供了createComposeRule来编写界面测试。对于计时器可以这样测试开始按钮的功能RunWith(AndroidJUnit4::class) class TimerScreenTest { get:Rule val composeTestRule createComposeRule() Test fun click_start_button_shows_pause_text() { composeTestRule.setContent { TimerScreen() } composeTestRule .onNodeWithText(开始) .performClick() composeTestRule .onNodeWithText(暂停) .assertIsDisplayed() } }这种测试可以直接验证用户点击后的界面表现不需要启动完整的应用。8. 总结与下一步学习路线通过这个计时器项目我们完整走了一遍 Jetpack Compose 开发流程核心收获有三个。第一理解了声明式 UI 的思想。Compose 中的界面由状态驱动状态变化后框架自动更新界面开发者不需要手动操作控件。第二掌握了 Compose 基础 API 的使用。包括Composable函数、mutableStateOf、remember、LaunchedEffect、Canvas等这些是 Compose 开发最常用的基础能力。第三建立了 Compose 项目的基本工程意识。把业务逻辑放进 ViewModel把状态和界面分离把字符串抽取到资源文件这些习惯会让你在写更大的项目时受益无穷。如果你已经掌握了本文的内容下一步可以尝试这些方向给计时器增加更多预设时长例如 1 分钟、3 分钟、5 分钟切换增加通知提醒或震动反馈在时间归零时提示用户把进度环扩展成可点击拖拽的元素做成计时设置控件学习 Compose Navigation把计时器页面和设置页面串联起来深入学习 Compose 动画 API为进度环增加平滑过渡效果计时器只是一个很小的起点但它的背后是 Compose 状态管理、协程、绘制、测试一整条知识链。把这套链路走通后面的路就会顺畅很多。如果这篇文章对你有帮助可以把它收藏备用。也建议你把这篇文章中的代码按照自己的理解重新敲一遍而不是直接复制运行动手实践对理解 Compose 非常重要。