在实际的 Android 声明式 UI 开发中,Jetpack Compose 带来的不只是“代码写界面”的体验变化,更是一套从状态管理、重组机制到组合项生命周期的全新思维方式。很多初学者跟着视频教程做计时器,代码能跑起来,但稍微改一个需求,比如增加暂停、恢复、重置,或者把计时器封装成可复用组件,就会卡住。原因往往不是 Kotlin 语法不熟,而是没有理解 Compose 的状态驱动模型:界面不是通过“调用函数主动修改控件”来更新的,而是通过“状态变化触发重组”自动反映到界面上。
这篇文章以一个计时器项目为主线,从 Compose 的核心概念讲起,再逐步完成一个具备开始、暂停、恢复、重置能力的计时器,并解释每一段代码背后的设计意图。整体内容既适合刚接触 Compose 的 Android 开发者,也适合已经写过基础界面、想加深对状态和生命周期理解的读者。
1. 先理解 Jetpack Compose 的声明式编程逻辑
学习 Compose 之前,需要先分清传统 View 体系和 Compose 之间的本质差异。很多人写 Compose 代码时仍习惯用“找控件、改控件”的思维方式,结果写出大量生硬代码,甚至出现界面不更新、状态不同步的问题。
1.1 命令式 UI 和声明式 UI 的区别
传统的 Android View 体系是命令式 UI。开发者通过findViewById拿到控件对象,再调用setText、setVisibility、setOnClickListener等方法手动修改界面状态。这种模式的问题在于,当界面状态变多、交互变复杂时,状态变更与 UI 更新之间的对应关系会变得难以维护。比如一个页面里有按钮、文本、列表、loading 状态,每次状态变化都要手动控制多个控件,很容易漏掉某一部分。
Jetpack Compose 是声明式 UI。开发者只需要描述“当前状态应该呈现什么界面”,至于状态变化后怎么更新,由 Compose 框架自动完成。用一句话概括:传统写法是“把文本改成 1”,Compose 写法是“当文本状态为 1 时,界面显示 1;当状态变为 2 时,界面自动显示 2”。
这种模式下的核心思路是:
- 界面是状态的函数,给定一份状态,就得到一份界面。
- 状态变化后,Compose 会重新执行相应组合函数,生成新的界面。
- 开发者不需要手动比较新旧界面差异,也不需要逐控件更新。
1.2 组合函数与重组机制
在 Compose 中,用@Composable注解标记的函数称为组合函数。组合函数不能直接像普通函数那样理解成“执行一次就结束”,它具备参与重组的能力。
当一个函数内部读取了某个mutableStateOf状态值时,Compose 会记录这种读取关系。之后该状态值发生变化,Compose 会找到读取该状态的所有组合函数,并重新执行这些函数。这个重新执行的过程叫重组。
例如下面这段代码:
@Composable fun TimerScreen() { var seconds by remember { mutableStateOf(0) } Text(text = "当前秒数:$seconds") }当seconds从 0 变为 1 时,Compose 不会重建整个 Activity,也不会重新执行所有界面代码,而是只重组读取了seconds的部分。这种精确更新是 Compose 性能设计的关键。
这里有一个容易忽略的点:remember的作用是让状态在重组后仍然保留。如果没有remember,重组时mutableStateOf会重新创建,状态值就会被重置。remember的常见使用方式是配合mutableStateOf,但两者的职责不同:
| 工具 | 职责 |
|---|---|
mutableStateOf | 创建可观察状态,状态变化能触发重组 |
remember | 在重组期间保存状态,避免状态丢失 |
rememberSaveable | 在 Activity 重建或进程被系统回收后保存状态 |
1.3 Compose 计时器项目需要掌握的最小概念集
做一个计时器,至少需要掌握以下概念:
@Composable组合函数。remember和mutableStateOf管理界面状态。LaunchedEffect处理协程任务,比如每秒更新一次计时。Button、Text等基础组件。Modifier调整布局和样式。
把这些概念组合起来,才能写出一个状态正确、行为稳定的计时器。如果只照着视频敲代码,不弄清楚这些概念,后续做暂停、恢复、重置时就会无从下手。
2. 环境准备:Compose 项目的最小配置
编写 Compose 代码前,建议先确认开发环境。这里以 Android Studio 和 Gradle Kotlin DSL 为例,给出一个可运行的 Compose 项目配置。
2.1 开发环境要求
| 项目 | 建议要求 |
|---|---|
| IDE | Android Studio Hedgehog 或更高版本 |
| JDK | JDK 17 |
| Android Gradle Plugin | 8.2 或更高 |
| Kotlin | 1.9 或更高 |
| Compose Compiler | 与 Kotlin 版本匹配 |
| 最低 SDK | 24 左右即可,视实际兼容要求调整 |
如果原始教程使用的是旧版本,比如 Kotlin 1.8 和 AGP 7.x,落地前要确认版本匹配。Compose Compiler 需要和 Kotlin 版本严格对应,否则会出现编译错误,比如This version of the Compose Compiler requires Kotlin version X。
2.2 模块级 build.gradle.kts 配置
创建一个空应用项目后,在app/build.gradle.kts中配置 Compose 相关选项:
android { namespace = "com.example.timer" compileSdk = 34 defaultConfig { applicationId = "com.example.timer" 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 { val composeBom = platform("androidx.compose:compose-bom:2024.06.00") implementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") 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") }关键点说明:
buildFeatures { compose = true }用于开启 Compose 支持。- Compose BOM 用于统一管理 Compose 相关库的版本,避免一个个指定版本。
activity-compose提供setContent入口,让 Activity 可以直接承载 Compose 界面。
2.3 AndroidManifest 和 MainActivity 入口
如果项目是新建的,AndroidManifest.xml中正常声明 MainActivity 即可。这里要关注的是 Activity 内部如何把 Compose 界面挂载到窗口。
package com.example.timer import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.example.timer.ui.theme.TimerTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { TimerTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { TimerScreen() } } } } }在这个入口代码里,setContent是 Activity 和 Compose 世界的连接点。它接收一个组合函数,并把这个组合函数的内容作为界面展示出来。整个计时器项目的后续开发都围绕TimerScreen展开。
3. 用状态驱动完成计时器的核心逻辑
计时器的核心逻辑不复杂:一个变量记录已经过去的秒数,一个开关控制是否在计数,一个循环每秒增加一次计数。但放到 Compose 中,要处理的不只是逻辑本身,还包括状态如何保存、如何启动任务、如何停止任务。
3.1 定义一个保存计时状态的函数
先定义一个rememberTimerState函数,用mutableStateOf保存当前秒数和运行状态。
@Composable fun rememberTimerState(): TimerState { return remember { TimerState() } } class TimerState { var seconds by mutableStateOf(0) private set var isRunning by mutableStateOf(false) private set fun start() { isRunning = true } fun pause() { isRunning = false } fun reset() { seconds = 0 isRunning = false } fun tick() { seconds++ } }这里把状态封装进TimerState类,有几个好处:
- 界面层不需要关心计时逻辑具体怎么改状态。
- 多个组合函数可以共享同一个
TimerState实例。 - 后续如果需要增加“记录圈数”“存储历史记录”等功能,只需要扩展
TimerState。
remember在这里的作用是保证进入重组后,TimerState实例仍然是同一个,不会因为重组而重新创建。
3.2 用 LaunchedEffect 处理每秒计数
Compose 中不应该直接用while(true)加Thread.sleep去更新状态,因为组合函数可能因为重组被取消或重新执行,直接在组合函数里启动线程会造成重复启动或内存泄漏。
正确的做法是使用LaunchedEffect。它会在组合函数进入组合时启动一个协程,并在组合函数退出组合或被取消时自动取消协程。
@Composable fun TimerScreen(state: TimerState) { LaunchedEffect(state.isRunning) { while (state.isRunning) { delay(1000) state.tick() } } Text( text = formatTime(state.seconds), style = MaterialTheme.typography.displayLarge ) }这段代码里的LaunchedEffect(state.isRunning)很关键。当state.isRunning从 false 变为 true 时,LaunchedEffect会重新启动,协程开始循环计时。当state.isRunning变为 false 时,LaunchedEffect会取消上一个协程并重新启动,但因为while (state.isRunning)条件已经不满足,所以循环会直接结束。
为什么这里要用state.isRunning作为 key,而不是不传 key?如果不传 key,LaunchedEffect只在组合函数首次进入组合时启动一次,后续即使state.isRunning变化,协程仍然运行,无法通过暂停按钮停止计数。用state.isRunning作为 key,可以让协程的生命周期跟随运行状态变化。
formatTime函数用于把秒数格式化成00:00或00:00:00:
fun formatTime(totalSeconds: Int): String { val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 val seconds = totalSeconds % 60 return if (hours > 0) { String.format("%02d:%02d:%02d", hours, minutes, seconds) } else { String.format("%02d:%02d", minutes, seconds) } }3.3 完整界面:显示时间与操作按钮
接下来把显示时间、开始按钮、暂停按钮、重置按钮组合到一个界面上。
@Composable fun TimerScreen(state: TimerState) { LaunchedEffect(state.isRunning) { while (state.isRunning) { delay(1000) state.tick() } } Column( modifier = Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = formatTime(state.seconds), style = MaterialTheme.typography.displayLarge, modifier = Modifier.padding(bottom = 32.dp) ) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { if (!state.isRunning) { Button(onClick = { state.start() }) { Text(if (state.seconds > 0) "继续" else "开始") } } else { Button(onClick = { state.pause() }) { Text("暂停") } } Button(onClick = { state.reset() }) { Text("重置") } } } }这段代码中,界面会根据state.isRunning自动显示“开始/继续”或“暂停”。整个过程中,没有手动调用任何“更新控件”的方法,界面变化完全由状态驱动。
注意按钮文案的逻辑:当seconds大于 0 且当前未运行时,显示“继续”;否则显示“开始”。这个细节说明状态之间是有关联的,设计状态时不能只用一个isRunning布尔值,还要考虑开始和继续的场景。
4. 把状态保存到 ViewModel:避免旋转屏幕丢数据
上面的实现能在普通场景下运行,但有一个明显问题:旋转屏幕时,Activity 会重建,remember保存的状态会丢失。remember只能在组合过程中保留状态,无法跨 Activity 重建保留。
解决这个问题通常有两种方式:
- 使用
rememberSaveable保存可序列化的状态。 - 使用
ViewModel保存状态,让状态独立于界面生命周期。
对于计时器这种需要持续运行的场景,推荐使用ViewModel。
4.1 创建 TimerViewModel
class TimerViewModel : ViewModel() { var seconds by mutableStateOf(0) private set var isRunning by mutableStateOf(false) private set fun start() { isRunning = true } fun pause() { isRunning = false } fun reset() { seconds = 0 isRunning = false } fun tick() { seconds++ } }此时不需要remember,因为ViewModel实例的生命周期由 ViewModelStore 管理,Activity 重建后,同一个 ViewModel 实例仍然存活。
4.2 在 Activity 中获取 ViewModel
Compose 中可以使用viewModel()获取 ViewModel 实例:
import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun TimerRoute( viewModel: TimerViewModel = viewModel() ) { val state = viewModel TimerScreen(state) }TimerScreen的逻辑可以保持不变,因为它只依赖state接口。这样做的收益是:界面和状态源解耦,测试时可以传入自定义 state,生产环境可以直接用 ViewModel。
4.3 进程被系统回收时的处理
ViewModel能解决 Activity 重建问题,但无法解决进程被系统回收的问题。如果应用进入后台后被系统杀掉,重新打开时 ViewModel 会重新创建,计时秒数仍会丢失。
如果业务要求“进程被杀后继续计时”,需要在onStop或onSavedInstanceState中保存上次计时时间和运行状态,恢复时计算已经过去的时间。这个方案会增加复杂度,一般只在正式产品中需要。
学习阶段建议循序渐进:
- 先掌握
remember。 - 再理解
ViewModel。 - 最后处理进程恢复。
不要一上来就把所有状态都塞进持久化方案,那样会模糊 Compose 学习的重点。
5. 从学习示例到生产级计时器的差距
视频教程中的计时器通常是演示LaunchedEffect和mutableStateOf的最小案例。如果按真实产品标准看,还需要补齐不少细节。
5.1 计时精度问题
上面示例使用delay(1000)每秒加 1。真实运行中,delay不是严格精确的,它受系统调度、主线程负载影响。长时间运行后,计时器可能偏差几秒甚至几十秒。
改进方式是不依赖累加次数,而是记录开始时间和累计时间:
class TimerViewModel : ViewModel() { private var startTime: Long = 0L private var accumulatedTime: Long = 0L var elapsedMillis by mutableStateOf(0L) private set fun start() { startTime = SystemClock.elapsedRealtime() isRunning = true } fun pause() { accumulatedTime += SystemClock.elapsedRealtime() - startTime isRunning = false elapsedMillis = accumulatedTime } fun tick() { elapsedMillis = accumulatedTime + (SystemClock.elapsedRealtime() - startTime) } }这种方式下,哪怕delay因为主线程繁忙被推迟执行,最终计算出的时间仍然是准确的时间间隔。
5.2 界面刷新频率
delay(1000)每秒刷新一次界面,对于秒级显示足够。如果显示毫秒,比如“00:00.5”,就需要把刷新间隔改成 50 或 100 毫秒。刷新频率越高,重组次数越多,对性能和耗电影响越大。
生产项目建议:
- 秒级显示时,每秒刷新一次。
- 毫秒级显示时,每 100 毫秒刷新一次,并通过
derivedStateOf或字符串格式化避免不必要的重组。 - 不需要显示时间时,暂停或停止计时刷新。
5.3 生命周期与资源释放
LaunchedEffect会把协程绑定到组合生命周期。如果 Activity 进入后台,组合没有退出,协程会继续运行并每秒更新状态,这会造成不必要的计算和电量消耗。
LifecycleResumeEffect或repeatOnLifecycle可以按生命周期控制计时行为。简单做法是:
- 在
onStart恢复计时。 - 在
onStop暂停计时并保存累计时间。
实际项目中,通常还需要考虑:
- 屏幕息屏时是否暂停。
- 用户切到后台再回来时,界面显示的时间是否准确。
- 是否需要在通知栏显示计时状态。
这些属于产品需求,需要和普通的 Compose 状态管理逻辑配合处理。
6. 常见问题排查:计时器为什么不更新、停不下来
学习 Compose 计时器时,最常见的问题集中在状态丢失、协程重复启动、界面不更新和按钮状态不正确这四类。下面按现象、原因、检查方式、解决方案整理。
6.1 界面不更新,Text 一直显示 0
可能原因:
- 没有使用
mutableStateOf,只是普通 Kotlin 变量。 - 使用了
remember { mutableStateOf(0) },但读取时没有用by委托。 LaunchedEffect没有正确触发,协程没有运行。
检查方式:
- 查看状态属性是否由
mutableStateOf构造。 - 检查是否使用了
val seconds by remember { mutableStateOf(0) }。 - 在
tick()中输出日志,确认每秒是否调用。
解决方案:
var seconds by remember { mutableStateOf(0) }不要这样写:
var seconds = remember { mutableStateOf(0) }第一种写法通过by委托读取seconds的当前值,可以直接seconds++;第二种写法读取到的是MutableState对象本身,界面显示时也要写seconds.value,而且seconds++无法作用到MutableState上。
6.2 按钮点了开始,计时器没有反应
可能原因:
LaunchedEffect的 key 没有使用isRunning,协程启动后没有感知到状态变化。- 点击按钮修改了状态,但协程中的条件没有变。
常见错误写法:
LaunchedEffect(Unit) { while (true) { delay(1000) state.tick() } }这种写法的问题非常明显:不管isRunning是 true 还是 false,协程都会一直运行。点击暂停按钮后,协程仍然继续计时。
推荐写法:
LaunchedEffect(state.isRunning) { while (state.isRunning) { delay(1000) state.tick() } }6.3 旋转屏幕后计时归零
场景:点击开始后旋转屏幕,计时从 0 开始。
这种情况说明状态保存得不够。如果使用的是remember,旋转屏幕后组合函数重新创建,remember无法跨 Activity 重建保留状态,需要改成rememberSaveable或ViewModel。
rememberSaveable的示例:
var seconds by rememberSaveable { mutableStateOf(0) }但rememberSaveable只能保存可放入 Bundle 的类型,如果状态比较复杂,比如包含 List、自定义对象,需要注意序列化问题。推荐在真实项目中使用ViewModel。
6.4 暂停后继续计时,时间跳跃
现象:暂停时显示 00:10,继续后几秒内突然跳到 00:20。
原因:LaunchedEffect在isRunning变为 false 时会取消协程,但如果旧的协程没有被真正取消,或者多个协程同时运行,就会出现重复tick()。
检查方式:
- 在
tick()中加入日志,观察每秒是否调用了多次。 - 检查是否在多个位置创建了
LaunchedEffect。
解决方案:
- 确保
LaunchedEffect的 key 是isRunning。 - 避免同一个组合函数中多个
LaunchedEffect同时操作同一个状态。 - 状态更新使用
+= 1,而不是依赖外部传入的累计值。
6.5 常见问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Text 一直显示 0 | 状态不是mutableStateOf,或读取方式错误 | 检查状态声明和读取代码 | 使用by委托读取mutableStateOf |
| 点击开始无反应 | LaunchedEffectkey 错误或缺少 key | 检查 key 是否设置为isRunning | LaunchedEffect(state.isRunning) |
| 旋转屏幕归零 | 仅使用remember | 尝试旋转屏幕复现 | 改用rememberSaveable或ViewModel |
| 暂停后时间乱跳 | 多个协程同时更新状态 | 在tick()中打印日志 | 用 key 控制协程生命周期,避免重复启动 |
| 时间越来越慢 | 使用delay(1000)累加秒数 | 长时间运行对比系统时间 | 改为基于SystemClock.elapsedRealtime()计算耗时 |
7. 把计时器扩展成可复用组件
当计时器逻辑稳定后,可以把它封装成独立组件,供多个页面或项目复用。封装时要注意状态与界面分离。
7.1 定义可复用状态接口
interface TimerState { val seconds: Int val isRunning: Boolean fun start() fun pause() fun reset() }TimerViewModel实现这个接口。组合函数只依赖TimerState接口,不直接依赖 ViewModel,这样测试时可以传入 fake 实现,也方便替换为其他状态源。
7.2 封装通用计时器组件
@Composable fun TimerDisplay( state: TimerState, modifier: Modifier = Modifier ) { LaunchedEffect(state.isRunning) { while (state.isRunning) { delay(1000) if (state is TimerViewModel) { state.tick() } } } Column(modifier = modifier) { Text(text = formatTime(state.seconds)) if (!state.isRunning) { Button(onClick = { state.start() }) { Text(if (state.seconds > 0) "继续" else "开始") } } else { Button(onClick = { state.pause() }) { Text("暂停") } } Button(onClick = { state.reset() }) { Text("重置") } } }这里的写法有一个取舍:直接判断state is TimerViewModel会让组件依赖具体实现,不够抽象。更合理的方案是在TimerState接口中增加tick()方法,但那样会暴露内部实现细节。封装时可以根据项目实际需要决定:
- 如果组件只服务一个页面,可以直接使用 ViewModel。
- 如果组件要跨项目复用,可以抽象
tick(),但要在接口文档里说明它仅供内部协程调用。
7.3 在业务页面中使用组件
业务页面只需要创建 ViewModel,并传入计时器组件:
@Composable fun WorkoutScreen( viewModel: TimerViewModel = viewModel() ) { Column { Text("训练计时") TimerDisplay(state = viewModel) } }这样设计后,计时逻辑可以复用到倒计时、正计时、番茄钟、健身旁路计时等场景,业务页面不需要重复实现。
8. 从计时器项目延伸到 Compose 学习路径
计时器项目虽然简单,但覆盖了 Compose 开发中最重要的几个核心点:状态、重组、协程、生命周期。完成这个项目后,建议按以下顺序继续深入。
8.1 解锁状态管理进阶
计时器里只用到单个状态对象,真实项目中通常会有多个状态源。后续可以学习:
StateFlow和Flow在 Compose 中的使用。collectAsStateWithLifecycle收集异步状态。derivedStateOf根据其他状态计算派生状态,减少不必要的重组。rememberCoroutineScope在非组合协程作用域中启动任务。
8.2 掌握列表和复杂布局
计时器界面比较简单,只有 Column 和 Row。真实项目会遇到 LazyColumn、LazyRow、嵌套滚动、动态高度等布局问题。建议在计时器基础上增加“历史记录”列表,学习LazyColumn和items的使用。
8.3 理解 Compose 性能优化
计时器每秒触发一次重组,性能压力不大,但已经能观察到重组的行为。可以继续学习:
- 如何通过
@Stable和@Immutable标记减少重组范围。 rememberUpdatedState解决长时间协程读取旧值的问题。Modifier.animateContentSize和Crossfade等动画 API。
8.4 学习路径清单
| 阶段 | 学习目标 | 练习方式 |
|---|---|---|
| 第一阶段 | 掌握状态和重组 | 用mutableStateOf做一个计数器 |
| 第二阶段 | 掌握协程生命周期 | 用LaunchedEffect做计时器 |
| 第三阶段 | 掌握状态提升 | 把计时器状态提升到父组件 |
| 第四阶段 | 掌握 ViewModel | 用 ViewModel 保存计时器状态 |
| 第五阶段 | 掌握列表 | 给计时器增加历史记录列表 |
| 第六阶段 | 掌握持久化 | 保存最后计时时间并恢复 |
9. 最佳实践:做好一个 Compose 计时器的关键原则
计时器虽然小,但能反映一个人对 Compose 基础的理解程度。下面几条原则值得在实际项目中长期坚持。
9.1 状态放在最合理的层级
计时器的状态如果放在TimerScreen内部,页面退出后状态就丢失。如果放在 ViewModel,Activity 重建后仍能保留。如果多个页面共享同一个计时器,状态还应继续提升到 Activity 或应用级共享层。状态层级越高,生命周期越长,但也越难管理。
推荐顺序:
- 单个页面使用
rememberSaveable或页面级 ViewModel。 - 多个页面共享使用 Activity 级 ViewModel。
- 应用级共享使用单例或依赖注入容器。
9.2 不要在高频组合函数中做耗时操作
计时器每秒重组一次,如果组合函数里直接做文件读写、网络请求、大量计算,会在主线程产生明显卡顿。正确做法是把耗时操作放在协程或后台线程中,只在组合函数中读取最终状态。
9.3 用日志辅助理解重组
刚开始学习 Compose 时,可以打印日志观察重组次数:
@Composable fun TimerDisplay(state: TimerState) { LaunchedEffect(Unit) { println("TimerDisplay recomposed") } // ... }要说明的是,LaunchedEffect(Unit)只在首次进入组合时执行,不能用来精确统计重组次数。更准确的方式是调用println("recompose")直接放在组合函数体中,但生产环境不要保留这种代码。
9.4 状态变量命名要体现业务含义
seconds比time更清晰,isRunning比running更准确,accumulatedTime比total更容易理解。计时器项目规模小,命名影响不明显,但到大型项目中,含糊的命名会让状态流变混乱。
9.5 持续运行的任务要关注电量
计时器如果常驻后台,每秒唤醒一次 CPU 和更新 UI,对电量影响不可忽略。生产项目通常需要:
- 后台时不刷新界面。
- 需要提醒时使用前台服务。
- 时间计算基于系统时间戳,而不是依赖循环次数。
10. 结语
Jetpack Compose 的声明式 UI 模式改变了 Android 界面的开发方式。计时器项目小,却完整覆盖了状态、重组、协程、生命周期这些核心概念。从remember和mutableStateOf开始,到LaunchedEffect控制每秒计时,再到用 ViewModel 解决旋转屏幕丢状态,这一步一步走下来的过程,比单纯抄代码更有价值。
实际项目中,计时器通常会遇到更复杂的需求:倒计时、暂停恢复、后台保活、多计时器切换、通知栏提醒。建议先把这个基础计时器做稳定,再把状态管理、生命周期、持久化方案逐步加入。对新手而言,把这一条技术链路理解清楚,后续学习 Compose 的列表、动画、MVVM 架构都会顺畅很多。