ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用计时器入门Jetpack Compose:理解状态、重组与副作用

用计时器入门Jetpack Compose:理解状态、重组与副作用 如果想上手 Jetpack Compose 但又不知道从哪里开始我建议先别急着做新闻列表、笔记 App 这类“大数据量”项目先做一个计时器。很多人看了一圈中文讲解视频真正自己动手写时第一个卡住的地方往往不是布局而是“界面怎么随着数据自动变”。比如计时器每一秒要刷新数字按钮要能开始、暂停、重置问题就来了这个“自动变”到底是怎么发生的这个计时器项目恰好能把 Jetpack Compose 最核心的一套机制——状态、重组、副作用——全部串起来。把这件事弄明白后面再看任何 Compose 界面思路都会顺很多。这篇文章不会只讲“怎么写”重点讲“为什么这样写”以及真正落地时容易踩的坑。1. 声明式UI的真正变化不是“不用写XML”1.1 从命令式改界面到状态驱动界面传统安卓开发里界面长什么样通常由 XML 布局文件决定业务代码里再做数据填充。比如有一个显示时间的TextViewTextView android:idid/timeText android:layout_widthwrap_content android:layout_heightwrap_content /在 Activity 或 Fragment 里你得先找到这个控件再设置文本val timeText findViewByIdTextView(R.id.timeText) timeText.text 00:05问题在于当界面状态变多比如按钮是否可用、进度条是否显示、文本颜色是否变化代码里就会出现大量“根据当前条件去改某个控件”的逻辑。每改一次就要操作一个控件。界面越复杂这种命令式修改就越容易失控。Compose 换了一种思路。它不要求你手动找到控件再赋值而是让你描述“当前状态是什么”界面会根据状态自动匹配外观。同样一段显示时间的逻辑Compose 里是这样Text( text formattedTime, style MaterialTheme.typography.displayLarge )这里没有findViewById没有text ...的赋值动作。只要formattedTime变了界面就会自动重新显示新的文本。1.2 “界面是状态的一个函数”业界经常用一句话概括声明式 UIUI f(state)。意思就是界面是状态的一个函数。给定同样的状态界面一定渲染成同样的结果状态变了界面自动跟着变。这句话听起来抽象放到计时器里就非常具体。计时器的核心状态只有一个剩余毫秒数。界面上的所有东西——显示“00:58”还是“00:57”、按钮显示“开始”还是“暂停”、重置按钮能不能点——全部由这一个状态推导出来。不需要你去操作某个控件你只需要更新状态Compose 自己知道该刷新哪里。这才是声明式 UI 和传统 View 体系的本质差异。很多人以为 Compose 的卖点只是“用代码写布局不用 XML 了”。如果只停留在这一层那 Compose 就是一个更好用的布局工具。真正让 Compose 值得学的地方是它把界面从“被操作的对象”变成了“状态的投影”。1.3 为什么计时器是最好的入门项目计时器适合入门的理由不是因为它简单而是因为它把所有核心概念压缩进了一个足够小的范围。你想想看购物 App 有列表、有网络请求、有图片加载、有路由跳转任何一个环节出问题都会干扰你对 Compose 本身的理解。而计时器只有几个元素一个文本、两个按钮、一个时间状态。但它拥有完整的状态变化链路用户点击“开始”状态开始流动。每一秒剩余时间减少。界面每一秒自动刷新。点击“暂停”流动停止。点击“重置”状态回到起点。这个链路恰好能把“状态驱动界面”这件事完整展示出来。一旦你理解了这个循环后面遇到任何 Compose 项目都会用同一个思维框架去分析状态是什么、状态在哪里改、界面从哪里读。2. 先搞懂三个基础概念状态、重组、副作用2.1 状态UI只认一个事实来源Compose 里的“状态”是一个可观察的数据容器。最常用的创建方式是mutableStateOfvar timeLeft by remember { mutableStateOf(60_000L) }拆开理解60_000L是初始值表示计时器的总时长是 60 秒。mutableStateOf把这个值包装成可观察状态。remember让这个值在重组之后仍然保留而不是每次界面刷新都被重置。by是 Kotlin 的委托语法让你可以像操作普通变量一样读写它。这里最核心的点是“状态是唯一事实来源”。界面上的数字不是单独存储在TextView里的而是从timeLeft这个状态推导出来的。你改了timeLeft界面自己会跟着变你不改它界面永远不会自己乱动。2.2 重组Compose怎么知道该更新谁当timeLeft发生改变时Compose 不会重新创建整个界面而是触发一次“重组”。重组的意思是Compose 会重新执行那些读取了变化状态的 Composable 函数只更新受影响的区域。这是声明式 UI 在工程上真正成立的原因。如果不做范围控制每次状态一变就重建整个页面性能会非常差。Compose 的智能之处在于它跟踪每个 Composable 读取了哪些状态。你在某个Text里读了timeLeft那timeLeft变化时就只重新执行这个Text所在的最小范围。这带来一个很有用的心智状态“向上放”界面“按需刷”。比如一个列表项里的点赞数应该在每个列表项内部读取对应状态而整个页面的主题色应该在更上层读取。这样状态变化时Compose 知道该刷哪一块不会整棵重组。2.3 remember、rememberSaveable 和 LaunchedEffect 各管什么这三个 API 是新手最容易混的。remember负责在内存中保留状态只要这个 Composable 还活在组合里状态就不丢。但如果 Activity 因为旋转屏幕被系统销毁重建整个组合会丢掉remember里存的东西会跟着消失。rememberSaveable更进一步。它会把状态写入 Bundle在 Activity 被系统重建时恢复。注意不是所有类型都能直接存但基本类型、字符串、Bundle 支持的类型都可以。计时器里的Long和Boolean没问题。LaunchedEffect负责启动一个和组合生命周期绑定的协程。它接收一个 key当 key 发生变化时旧的协程被取消新的协程启动当 Composable 离开组合时协程也会被取消。这三个概念在计时器里会同时用到后面写代码时你就能看到它们各自的位置。3. 用一个可运行的计时器把概念串起来3.1 前置准备与项目结构先用 Android Studio 新建一个项目模板选择“Empty Activity”确保是新版的 Compose 模板。不同版本的 Studio 生成的模板会有些差异但核心是一致的工程里已经配置好了 Compose 相关依赖入口是一个MainActivity里面通过setContent设置界面。如果模板没问题直接把下面代码写进MainActivity或单独抽出一个 Composable 文件即可。这里先不引入 ViewModel先把最小可运行流程跑通。别急着把界面做得花哨先让整个链路跑起来。最小可用流程跑通后再一个一个加按钮、加逻辑会容易很多。3.2 界面层把状态变成文本和按钮Composable fun TimerScreen() { var timeLeft by rememberSaveable { mutableStateOf(60_000L) } var isRunning by rememberSaveable { mutableStateOf(false) } LaunchedEffect(isRunning) { if (!isRunning) returnLaunchedEffect while (timeLeft 0) { delay(1000L) timeLeft - 1000L } isRunning false } val formattedTime formatTime(timeLeft) Column( modifier Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { Text( text formattedTime, style MaterialTheme.typography.displayLarge ) Spacer(modifier Modifier.height(32.dp)) Row( horizontalArrangement Arrangement.spacedBy(16.dp) ) { Button( onClick { isRunning !isRunning } ) { Text(if (isRunning) 暂停 else 开始) } Button( onClick { timeLeft 60_000L isRunning false }, enabled isRunning || timeLeft ! 60_000L ) { Text(重置) } } } } fun formatTime(millis: Long): String { val totalSeconds millis / 1000 val minutes totalSeconds / 60 val seconds totalSeconds % 60 return String.format(%02d:%02d, minutes, seconds) }这个代码里有几个关键落点rememberSaveable保证了旋转屏幕时剩余时间和运行状态不会丢。你点开始后转一下屏幕数字还在继续走而不是回到 60 秒。LaunchedEffect(isRunning)是整个计时逻辑的发动机。isRunning是 true 时协程启动每秒把timeLeft减 1000isRunning变成 false 时协程被取消倒计时停止。不需要额外写“取消协程”的代码因为 LaunchedEffect 自己会处理。界面的核心是Text不直接操作任何控件它只从formattedTime读取结果。formattedTime又从timeLeft推导而来。整条数据流是单向的。3.3 逻辑层倒计时到底该怎么算上面这个版本足够跑通但严格来说它有一个精度问题delay(1000L)并不能保证精确到 1000 毫秒一次。如果系统繁忙、主线程卡顿协程恢复的时间可能会延迟几毫秒甚至更多。长时间跑下来倒计时的误差会累积。更好的做法是“基于绝对时间戳”而不是“每秒减一次”LaunchedEffect(isRunning) { if (!isRunning) returnLaunchedEffect val endTime System.currentTimeMillis() timeLeft while (timeLeft 0) { delay(100L) timeLeft (endTime - System.currentTimeMillis()).coerceAtLeast(0L) } isRunning false }这里的关键变化是进入循环前先计算一个endTime也就是计时结束的绝对时间点。循环里每次只计算“现在离endTime还剩多少毫秒”然后更新timeLeft。这样做有几个好处即使某次delay晚了一点timeLeft仍然是基于真实时钟计算出来的不会累积误差。暂停后再次开始时endTime会基于当前剩余的timeLeft重新计算逻辑不会乱。精度由delay间隔控制。想秒级刷新就用delay(1000L)想要更平滑就用更短的间隔。实际体验上秒级显示用 100 到 200 毫秒的间隔会比较自然。不过要注意delay(100L)意味着每秒重组合 10 次对计时器这种简单页面完全没问题但如果你在同一个 Composable 里做了大量耗时计算就要考虑拆出去或者用derivedStateOf优化。这里先不展开。3.4 单次跑通后先做这三步验证很多教程讲到这里就结束了但实际用起来没有这么简单。我建议你在最小代码跑通之后立刻做三件事点“开始”等 5 秒点“暂停”再点“开始”确认剩余时间没有跳变。旋转屏幕确认计时器没有回到 60 秒也没有重新从 0 开始。把手机切到后台再切回来确认界面状态还在。这三件事分别对应状态保存、协程生命周期、进程持久化三个层面的问题。每一件都值得单独想清楚因为它们才是真实使用里会遇到的边界。4. 只跑通还不够稳定性、边界和排查4.1 旋转屏幕计时器为什么会“重置”先说一个经典现象如果你用的是remember而不是rememberSaveable旋转屏幕后计时器会直接回到初始状态。原因很简单旋转屏幕会导致 Activity 重建旧的组合被销毁新的组合从零开始。remember只保证在同一个组合生命周期内保留状态并不能跨过 Activity 销毁。而rememberSaveable会把状态写进保存实例状态的 Bundle重建后恢复回来。所以凡是需要跨配置变更保留的界面状态都应该用rememberSaveable。但这也有一个限制它能保存的类型有限。如果你有一个自定义数据类要保存就得自己实现Saver或者把状态提升到 ViewModel 里。这也是为什么很多真实项目会把“计时状态”放进 ViewModel而不是只靠rememberSaveable。ViewModel 本身就能跨配置变更存活而且不会受到 Bundle 类型限制。界面层只负责收集状态不负责保存状态。4.2 应用退到后台计时为什么可能不准协程并不保证一定在后台继续执行。如果应用退到后台系统可能冻结进程、降低优先级甚至直接杀掉进程。LaunchedEffect里的delay在进程被冻结期间不会触发。如果你做的是一个纯粹的前台计时器比如用户在页面上看倒计时问题不大。如果这是一个番茄钟用户在倒计时期间切到微信、去回消息回来发现时间没走体验就很差。这时候就需要重新思考“计时”的职责归属。一个工程上更可靠的方案是把结束时间存下来最好是存一份持久化的时间戳应用回到前台时重新计算剩余时间而不是依赖后台协程在跑。所以“把endTime存在SavedStateHandle或本地存储里前台恢复时重新计算”往往是比协程一直跑更稳的方案。简单说界面里的协程适合驱动 UI 刷新不适合承担“保证时间流逝”这类偏业务逻辑的职责。排查界面异常时先确定是哪一层的问题数据没变还是界面没刷新还是协程根本没有运行。顺序错了很容易在一堆无关代码里打转。4.3 一个实用的排查顺序如果你写的计时器不按预期工作按这个顺序排查大多数问题都能定位。第一看现象。是点击后完全没有反应还是时间在跳但跳得不对还是旋转屏幕后状态丢了现象不同排查方向完全不同。第二看状态读取位置。界面上有没有在读timeLeft如果你在Text里写死了formatTime(60_000L)那状态再变也不会刷新。这是新手最常见的错误之一数据确实改了但界面根本没人读它。第三看状态保存方式。旋转屏幕后重置查remember和rememberSaveable用对了没有用了 ViewModel 就查 ViewModel 是否在重建。第四看协程是否活着。LaunchedEffect的 key 是什么key 一变协程会被取消并重启。如果你把timeLeft写进 key那么每次时间更新协程都会被取消再重建无限循环但时间却不正常如果你把按钮的点击事件和LaunchedEffect的启动条件混在一起也会出现逻辑混乱。第五看时间算法。倒计时越来越慢检查是不是用了“每次 delay 后减固定值”的累积误差方案暂停后重新开始跳了一下检查暂停前是否已经过了非整数秒导致timeLeft不是 1000 的整数倍再减 1000 时对不上。这套排查顺序可以复用到很多 Compose 问题上先界面、再状态、再副作用、再算法。不要一上来就怀疑是不是 Compose 有 bug。大多数时候问题出在数据和副作用之间的连接方式上。4.4 性能每秒刷新一次会不会过度重组很多人会担心每秒更新一次timeLeft是不是会让整个界面重组导致性能问题Compose 的选择性重组机制在这里非常有用。如果只有读取timeLeft的那个Text在状态变化时重组其他部分不读这个状态就不会跟着执行。所以要尽量避免在 Composable 的顶层统一读状态然后把大段 UI 都写在下面。更好的做法是把显示时间的部分拆成独立 Composable让只读formattedTime的那一小块单独接收状态变化。Composable fun TimeDisplay(timeMillis: Long) { Text( text formatTime(timeMillis), style MaterialTheme.typography.displayLarge ) }这样状态变化时只有TimeDisplay会重组。按钮、布局、其他静态内容都不会被触发。这是 Compose 性能心智里很重要的一条状态读取范围越小重组范围越小不会引起性能问题的前提是你没有把整棵界面绑到一个高频变化的状态上。5. 计时器之后下一步怎么走5.1 把状态从界面提升到ViewModel对于只有一个界面的小计时器来说rememberSaveable够用。一旦你要把这个计时器接入真实业务比如番茄钟、任务记录、通知提醒界面层直接管理状态就撑不住了。工程化的下一个步骤是引入 ViewModel用StateFlow承载状态class TimerViewModel : ViewModel() { private val _timeLeft MutableStateFlow(60_000L) val timeLeft: StateFlowLong _timeLeft.asStateFlow() private var job: Job? null fun start() { if (job?.isActive true) return job viewModelScope.launch { while (_timeLeft.value 0) { delay(1000L) _timeLeft.value - 1000L } } } fun stop() { job?.cancel() } fun reset() { job?.cancel() _timeLeft.value 60_000L } }界面层变成这样Composable fun TimerScreen(viewModel: TimerViewModel viewModel()) { val timeLeft by viewModel.timeLeft.collectAsState() // ... }这样做的好处是状态不再依赖组合生命周期旋转屏幕不会丢逻辑移出了界面层按钮的onClick只调用 ViewModel 的方法测试也可以针对 ViewModel 单独写不需要 UI。但也要明白一个边界ViewModel 是配置变更的幸存者不是进程死亡的幸存者。如果应用进程被杀ViewModel 里的东西一样会丢。要跨进程保存还是需要持久化方案。5.2 给计时器加动画理解Compose的动画设计计时器完成后一个很有意思的进阶方向是动画。比如秒表指针平滑转动、数字翻转、进度环逐渐减少这些都可以用 Compose 动画 API 实现。animateFloatAsState可以把一个值的变化变成平滑过渡Animatable可以手动控制动画过程。它们和状态系统是天然配合的状态变化是“目标值”动画是“从当前值到目标值的过程”。如果你理解了计时器里的状态和重组再看动画 API 就不会觉得是在学另一套系统。动画只是状态的“过渡表现”。5.3 一条从入门到工程的路径回到最开始的问题这个项目做完真正学到的是什么我觉得是这条链路用最小例子理解状态驱动 UI然后逐步加入边界处理最后把状态和逻辑从界面剥离。整个过程不只是在做一个计时器而是在建立一个“如何组织 Compose 界面”的思维框架。你可以沿着这个路径继续往下走把倒计时改成正计时理解状态初始值和更新方向的变化。增加多个预设时长比如 1 分钟、5 分钟、25 分钟理解状态如何驱动界面配置。加入进度环理解 Canvas 和动画。把计时状态迁移到 ViewModel理解生命周期和架构分层。加入持久化结束时间在 App 回到前台后重新计算理解真实后台需求。每走一步你都会加深一层对 Compose 的理解。一个计时器虽然小但它足够撑起一整条从基础到工程化的学习曲线。回到主判断Compose 真正改变的不是“写界面不用 XML”而是状态和界面之间的关系。UI f(state)这句话在你做完计时器后会变得非常具体。计时器这个项目就是理解这句话成本最低、收益最直接的起点。
返回列表