很多人第一次接触 Jetpack Compose 时,会拿一个计时器项目当入门练习。这个项目表面看只是点按钮、跳数字,但它实际把安卓声明式 UI 的核心链路全走了一遍:状态怎么定义、状态变化怎么触发界面重组、计时循环怎么和 Compose 生命周期绑定。如果你正在学 Jetpack Compose 安卓开发,又想找一个能把“状态管理”讲明白的小案例,计时器比 To-Do 更适合。
下面不按视频逐字稿走,按我实际调试的顺序拆一遍:先确认这个项目到底在讲什么,再对环境依赖,然后从零写核心代码,最后补运行验证和排查思路。
1. 先确认这个计时器到底在教你 Compose 的哪几件事
1.1 为什么计时器适合做声明式 UI 入门
Compose 和传统 View 系统最大的区别是“声明式”。传统写法里,你要先findViewById拿到 TextView,再用setText把数字塞进去。Compose 不是这个思路,它更接近这个公式:
UI = f(state)界面由状态决定,状态变化后,界面自动重组。
计时器天生适合演示这一点。倒计时过程中,“剩余秒数”是一个随时变化的状态,“界面上的 09:58、09:57”只是这个状态的可视化结果。你不需要手动去更新 TextView,只要状态变了,Compose 会自动重新绘制。
我第一次跑这个项目时,最大的感触不是代码简单,而是思路要转过来。如果你还在习惯性去想“怎么给某个控件赋值”,说明你还没真正切到 Compose 的思维模型。
1.2 声明式 UI 在计时器里的两次关键体现
计时器项目里有两次典型的声明式 UI 体现。
第一次是时间文本。Text的内容直接来自remainSeconds格式化后的结果,不单独保存一个字符串变量,也不调用更新方法。
第二次是开始暂停按钮。按钮显示“开始”还是“暂停”,不是靠修改按钮文字,而是靠isRunning这个布尔状态反向推导出来。状态是唯一事实来源,界面只是状态投影。
这两点看着简单,但很多人卡在“状态应该放哪里”这个问题上。计时器小,不敢放错;如果放错了,后续扩展番茄钟、暂停恢复、后台计时都会乱。
1.3 对照检查:你能回答这几个问题,才算真的会
跑通代码之后,建议问自己几个问题:
- 如果不用 Compose,用传统 View 你会怎么写?
- 为什么倒计时数字变化后,界面会自动更新?
- 为什么暂停后再次开始,数字还能继续减少而不是从 0 重新开始?
- 如果旋转屏幕,数字会不会丢?
能解释清楚这几个问题,比多抄一遍代码更有价值。
2. 环境准备:不是把代码拉下来就能编译,版本和依赖先对齐
2.1 建议开发环境
Compose 项目对硬件要求不算高,但环境一致性很重要。我建议你直接用稳定版 Android Studio 新建一个 Compose 模板项目,然后再把计时器代码放进去。
新建项目时选 Empty Activity,模板会自动帮你配好 Compose 相关依赖。如果你是在旧项目里手动加 Compose,要注意两件事:
- 项目模块要开启
buildFeatures.compose = true - Kotlin 版本和 Compose 编译器插件版本需要匹配
这两点不一致,最容易出现编译报错。
如果你的机器配置一般,模拟器会慢一些。可以先降低模拟器分辨率,或者用真机调试。真机调试对 Compose 这类 UI 项目来说,反馈更快,也更容易观察旋转屏幕、退后台这些场景。
2.2 依赖配置:Compose BOM 才是关键
Compose 库非常多,手动一个个写版本号很容易乱。官方推荐用 BOM 统一管理。
这里不写死版本号,因为 Android Studio 新建模板时会自动生成对应版本。你可以把下面的内容当成参考结构:
// 版本以你新建工程自动生成的为准,不要照抄这里的占位写法 implementation(platform("androidx.compose:compose-bom:你的BOM版本")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.activity:activity-compose")BOM 的作用是把 Compose 相关库的版本统一对齐。你只需要指定一个 BOM 版本,下面的 ui、material3 都跟着它走。如果手动混用不同版本,很容易出现“编译能过,运行崩溃”或反过来“运行不崩,但某些组件 API 对不上”的情况。
2.3 第一次运行前检查什么
写代码之前,先确认三件事:
Gradle Sync是否成功- 模拟器或真机系统版本是否满足模板要求
- 项目是否能够直接空跑起来
如果新建项目直接运行就报错,先不要急着写计时器,把环境弄稳再说。我见过很多同学直接拿网上项目源码硬编,结果报错后不知道是环境问题还是代码问题,浪费大量时间。
这个阶段最值得做的一件事,是保持“最小依赖”。不要一开始就加 ViewModel、Room、Hilt、图片加载库。计时器项目本身不需要这些,加进来只会干扰你理解核心逻辑。
3. 从零写一个倒计时:状态、计时循环和界面拆开做
3.1 先定义界面状态
Compose 里的状态,不是一个普通 Kotlin 变量。普通变量变化后,界面不会知道,也不会重新绘制。要用 Compose 可以观察的状态。
下面这两行就是核心状态:
var remainSeconds by rememberSaveable { mutableIntStateOf(10 * 60) } var isRunning by rememberSaveable { mutableStateOf(false) }remainSeconds表示剩余秒数,初始值是 600 秒,也就是 10 分钟。
isRunning表示当前是否在倒计时。
rememberSaveable的作用是跨配置保留状态。旋转屏幕时,Activity 会重建,普通remember会把状态丢掉,而rememberSaveable会把状态保存下来。对计时器这种实时数字场景,旋转屏幕丢状态会非常影响体验。
这里用mutableIntStateOf而不是mutableStateOf(600),是为了减少装箱开销。对学习项目来说差别不大,但养成好习惯没有坏处。
3.2 用 LaunchedEffect 跑计时循环
计时循环不能写在 Composable 函数体里直接 while。因为 Composable 会因为重组反复执行,直接写 while 会创建多个循环,甚至导致协程泄漏。
最稳妥的做法是用LaunchedEffect:
LaunchedEffect(isRunning) { while (isRunning && remainSeconds > 0) { delay(1000) remainSeconds-- } if (remainSeconds <= 0) { isRunning = false } }LaunchedEffect(isRunning)的意思是:当isRunning变化时,协程会重新启动。开始计时,协程启动;暂停时,isRunning变为 false,协程被取消;再次开始时,新的协程又启动。
这里的关键是delay(1000)。它让协程每秒钟挂起一次,然后执行remainSeconds--。delay不会阻塞主线程,这也是为什么页面不会卡死。
如果不理解LaunchedEffect的 key 参数,很容易写出这种问题:
LaunchedEffect(Unit) { while (isRunning) { delay(1000) remainSeconds-- } }这种方式的问题在于,isRunning只是在循环条件里读取,协程不会因为isRunning变化而取消或重启。暂停后,循环虽然会退出,但再次开始时,协程已经结束了,倒计时不会恢复。
所以 key 要绑在isRunning上,而不是Unit。
3.3 按钮和文本显示,只负责把状态变成界面
核心逻辑写好之后,界面就非常简单了。Compose 不需要你管理控件实例,只需要声明“根据当前状态显示什么”。
下面是一个最小完整示例:
@Composable fun TimerScreen() { val totalSeconds = 10 * 60 var remainSeconds by rememberSaveable { mutableIntStateOf(totalSeconds) } var isRunning by rememberSaveable { mutableStateOf(false) } LaunchedEffect(isRunning) { while (isRunning && remainSeconds > 0) { delay(1000) remainSeconds-- } if (remainSeconds <= 0) { isRunning = false } } Column( modifier = Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = formatTime(remainSeconds), fontSize = 56.sp, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(24.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Button(onClick = { isRunning = !isRunning }) { Text(if (isRunning) "暂停" else "开始") } OutlinedButton(onClick = { isRunning = false remainSeconds = totalSeconds }) { Text("重置") } } } } fun formatTime(totalSeconds: Int): String { val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 return "%02d:%02d".format(minutes, seconds) }Button的点击事件里只改状态,不直接操作任何 UI 控件。重置按钮做的事情是:停掉计时,把remainSeconds恢复成初始值。
这就是声明式 UI 的核心感觉:界面是状态的投影,事件只是状态的修改入口。
4. 从能跑到好用:进度、格式、后台暂停和状态保留
4.1 格式化时间与进度条
很多人写计时器时,会直接把秒数显示成“600”,看起来不太直观。更常见的做法是显示成“10:00”这种分钟加秒数的格式。
formatTime就是干这件事的:
fun formatTime(totalSeconds: Int): String { val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 return "%02d:%02d".format(minutes, seconds) }如果你需要显示进度条,可以再加一个LinearProgressIndicator:
LinearProgressIndicator( progress = { remainSeconds / totalSeconds.toFloat() }, modifier = Modifier.fillMaxWidth() )这里注意一点:不同版本的 Material3,LinearProgressIndicator的参数形式可能不同。新版用 lambda,旧版直接传 Float。编译不过时,按 IDE 提示调整即可。
进度条的价值不只是好看。它能直观体现“剩余时间占总量多少”,比单纯看数字更符合计时器场景。
4.2 退到后台怎么办
这个点是很多新手容易踩的坑。
如果什么都不处理,Activity 退到后台后,LaunchedEffect里的协程不一定会立即取消,倒计时可能继续跑。但界面不可见,用户回来后发现时间已经跑完了,体验很不好。
如果要实现“退到后台自动暂停”,可以在 Compose 里监听生命周期:
val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { isRunning = false } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } }ON_STOP对应 Activity 不可见。此时把isRunning设为 false,倒计时就停住了。
这里要区分场景:如果你想要的是一个真正的后台计时器,比如用户切到其他 App,倒计时仍然继续并通知用户,普通 Compose 写法是不够的。那需要前台服务配合通知栏。学习阶段先做“退后台暂停”,更简单也更安全。
4.3 旋转屏幕后状态还在吗
如果用的是remember,旋转屏幕后状态会丢,因为 Activity 重建后整个组合树也重建了。
用rememberSaveable可以解决这个问题。它能利用保存实例状态的机制,把基础类型状态保留下来。
但要注意,rememberSaveable适合保存简单状态。如果你在 ViewModel 里维护了列表、用户对象、复杂配置,不要硬塞给rememberSaveable。该用 ViewModel 的场景还是得用 ViewModel。
4.4 要不要换成 ViewModel
对于计时器入门项目,不换 ViewModel 也能跑。
但如果你想继续做番茄钟,或者给计时器加历史记录、多任务列表,那 ViewModel 就更有优势。ViewModel 可以在 Activity 重建时保留数据,也比较适合放业务逻辑。
简单判断标准是:
- 状态只属于当前界面,且生命周期很短,可以用
rememberSaveable - 状态需要在配置变更后长期保留,或者被多个界面共享,用 ViewModel
- 状态需要写入数据库或参与复杂计时逻辑,也用 ViewModel
不要一上来就把所有东西都放进 ViewModel。小项目先把 Compose 的状态和重组搞清楚,再逐步分层。
5. 运行验证和常见问题排查:先看现象,再查状态,最后改参数
5.1 最小验证路径
写完代码后,不要急着加新功能。先按下面这些步骤验证一遍:
- 编译并启动,界面显示
10:00 - 点“开始”,每秒减 1
- 点“暂停”,数字停在当前值
- 点“重置”,数字恢复
10:00 - 倒计时到 0,按钮回到“开始”状态
- 旋转屏幕,数字不丢
- 退到后台再回来,倒计时停止
这些都不通过,说明核心逻辑还没稳。任何一项不通过,都优先排查状态定义和LaunchedEffect的使用。
5.2 常见报错和排查顺序
很多报错看着吓人,但根因其实很集中。
| 现象 | 优先排查 | 说明 |
|---|---|---|
| 编译报错,提示 Kotlin 和 Compose 插件版本不匹配 | 检查 Kotlin 插件、Compose 编译器插件、BOM 版本 | 新建工程一般没问题,手动集成时最常见 |
| 界面不刷新 | 检查是否用了普通Int而不是mutableIntStateOf | 普通变量变化不会触发重组 |
| 点开始没反应 | 检查LaunchedEffect的 key 是什么 | key 不用isRunning,协程可能只启动一次 |
| 旋转屏幕后数字归零 | 检查是否用了rememberSaveable | remember不能跨 Activity 重建保存 |
| 退后台还继续走 | 检查生命周期监听 | 如果确实要后台走,需要服务 |
| 进度条不显示 | 检查progress是否在 0f 到 1f 之间 | 除零、负值、越界都会异常 |
排查顺序也建议固定下来:
- 先看能不能编译,能不能启动
- 再看点按钮时状态有没有变化
- 再看界面有没有重组
- 最后才查具体参数和样式问题
很多人一遇到问题就改参数,水平下不去。正确做法是先确定“问题发生在哪一层”:状态层、事件层还是界面显示层。
5.3 资源占用和耗电提醒
计时器本身的资源占用很小。delay(1000)是挂起而不是忙等,CPU 不会持续高占用。
但如果你的代码里写了while (true)并且没有delay,那主线程会被占死,界面直接卡住。看到界面卡住时,先检查有没有死循环,不要先怀疑模拟器配置。
如果你做了后台计时,还要考虑耗电和系统限制。普通退后台后持续运行的长任务,可能会被系统回收。这也是我在前面强调“学习阶段先做退后台暂停”的原因之一。
6. 我的整理建议:计时器真正的学习重点不是界面
6.1 先跑通,再拆代码
我自己做这种项目时,习惯先跑一个最小版本,再加功能。
最小版本可以只有:
- 一个 Text 显示剩余时间
- 一个 Button 开始暂停
跑通之后,再加重置、进度条、生命周期处理。每加一个功能都单独编译运行一次,这样出了问题,能很快定位到刚加的代码。
不要一上来就复制完整代码,然后运行成功就以为自己会了。要把每个变量删掉试试,看界面行为会变成什么样,这才算理解。
6.2 生产化前要补的清单
如果这个计时器项目不是为了学习,而是要变成一个真正可用的功能,至少要补这些:
- 时间基准从
SystemClock.elapsedRealtime()计算,避免delay累积误差 - 状态放进 ViewModel,配合
StateFlow管理 - 加生命周期处理或前台服务,明确“后台是否继续计时”
- 加无障碍描述,让屏幕阅读器能读出当前时间
- 加 UI 测试,至少覆盖开始、暂停、重置三个动作
这些不一定在入门阶段全做,但你要知道边界在哪里。
delay实现计时在 Demo 里没问题,但如果做番茄钟,长时间跑下来会出现漂移。这个不是 Compose 的问题,而是计时策略的问题。生产场景更合适的做法,是用真实时间戳计算结束时间,而不是每秒减 1。
6.3 值得继续做的扩展方向
计时器跑稳之后,可以继续做这些方向:
- 番茄钟:25 分钟工作,5 分钟休息
- 多计时器:同时记录多个任务
- 历史记录:把每次完成时间存到 Room
- 通知:计时结束后发一条通知
- 自定义时长:用户输入分钟数
每个方向都会让你碰到新的 Compose 知识点,比如列表、导航、对话框、持久化。
最后留一个我自己的判断:Jetpack Compose 计时器项目最值得你记住的不是 API,而是“状态变化驱动界面重组”这件事。你把这句话吃透了,后面学列表、表单、动画都会顺很多。