公司动态
Jetpack Compose计时器项目:状态管理与声明式UI核心实践
很多人第一次接触 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 trueKotlin 版本和 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 会创建多个循环甚至导致协程泄漏。最稳妥的做法是用LaunchedEffectLaunchedEffect(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) }如果你需要显示进度条可以再加一个LinearProgressIndicatorLinearProgressIndicator( progress { remainSeconds / totalSeconds.toFloat() }, modifier Modifier.fillMaxWidth() )这里注意一点不同版本的 Material3LinearProgressIndicator的参数形式可能不同。新版用 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协程可能只启动一次旋转屏幕后数字归零检查是否用了rememberSaveableremember不能跨 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而是“状态变化驱动界面重组”这件事。你把这句话吃透了后面学列表、表单、动画都会顺很多。