这次我们直接用 Jetpack Compose 写一个能跑起来的安卓计时器。项目目标很明确:不碰 XML 布局,全程用声明式 UI 完成界面、状态、定时刷新和控制逻辑,最后打包 APK 装到手机上验证。
如果你已经看过不少 Compose 教程,但对“状态驱动界面刷新”还没形成直觉,这个计时器项目就是最合适的练手案例。它比 To-Do 列表更贴近真实需求,涉及到协程、状态保存、重组、点击事件、动态文本格式化,做一遍之后,Compose 的核心机制基本就通了。
本文会从零开始,带你完成项目创建、Gradle 配置、计时器界面编写、状态管理、运行调试和 APK 导出。代码全部是可直接复制的完整示例,不搞半成品。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 开发框架 | Jetpack Compose,声明式 UI |
| 项目类型 | 安卓原生应用,单 Activity |
| 核心功能 | 秒表计时、开始/暂停/重置、动态文本刷新 |
| 界面实现 | 纯 Compose,无 XML 布局 |
| 状态管理 | remember、rememberSaveable、mutableLongStateOf |
| 定时刷新 | Kotlin 协程 + LaunchedEffect + delay |
| 最低运行环境 | Android 5.0(API 21)以上,Android Studio 模拟器或真机 |
| 构建产物 | Debug APK / Release APK |
| 是否支持 API | 本地应用,无远程接口,可通过 Compose 封装本地服务 |
| 是否支持批量任务 | 本身不支持,可结合 WorkManager 扩展周期任务 |
| 适合场景 | Compose 新手练习、状态管理理解、计时器类工具开发 |
这里要特别说明一点:Compose 不是“不用写逻辑”,而是把“界面如何根据状态变化”这件事交给了框架。你只需要定义状态,界面会自动重组。这也是声明式 UI 和传统 findViewById 写法最大的区别。
2. 适用场景与使用边界
这个计时器项目适合以下读者:
- 刚接触 Compose,想知道一个完整应用怎么从零搭建的开发者。
- 已经会用 XML + View 体系写安卓,想对比声明式 UI 和命令式 UI 差异的开发者。
- 需要一个“小而完整”的实战项目,用来理解状态、重组、协程这几个概念的初学者。
- 准备用 Compose 做毕设、课程设计或内部工具,需要先跑通最小原型的开发者。
能解决的问题:
- 理解 Compose 中状态如何驱动界面刷新。
- 掌握 LaunchedEffect 的生命周期绑定方式。
- 掌握协程中 delay 执行定时任务的写法。
- 掌握 Button、Text、Card 等基础组件的组合方式。
- 掌握 APK 构建和真机安装流程。
不适合的场景:
- 生产级秒表应用:缺少后台运行、锁屏计时、系统通知、精确到毫秒的计时精度。
- 多模块大型项目:本文只做一个单模块示例,不涉及 Navigation、Hilt、Room。
- 需要远程同步数据的场景:没有接后端,也没有数据库。
这里要强调合规边界:
- 如果后续要把这个项目发布到应用商店,需要确认应用图标、名称、素材不侵犯第三方权益。
- 如果扩展网络功能、上传用户数据,需要在隐私政策中明确说明,并取得用户授权。
- 不要拿这个项目去套壳发布,更不要加入任何获取用户隐私的代码。
- 项目中使用的时间、版本号等依赖,以你本机 Android Studio 实际解析结果为准。
3. 环境准备与前置条件
开始之前,先把环境确认一遍。下面给出一套通用检查清单,具体版本以你本机为准。
3.1 必需软件
| 软件 | 用途 | 版本建议 |
|---|---|---|
| Android Studio | 开发 IDE | 建议使用较新的稳定版 |
| JDK | 编译 Java/Kotlin 代码 | JDK 17 或更高 |
| Android SDK | 编译安卓应用 | 建议 API 34 或更高 |
| Gradle | 构建工具 | 由 Android Studio 自动管理 |
安装完成后,建议先创建一个 Empty Activity 项目确认环境没问题,再继续下面的步骤。
3.2 硬件要求
- 开发机:8GB 内存以上。Compose 编译和模拟器运行同时进行时,内存占用会明显升高,16GB 更从容。
- 真机或模拟器:Android 5.0 以上。如果电脑配置一般,可以直接用真机调试,避免模拟器卡顿。
3.3 磁盘空间
- Android Studio 安装后默认占 3GB 以上。
- Gradle 依赖下载,首次构建需要较大的网络传输量,建议预留 10GB 空间。
- 构建缓存会随项目增多逐渐膨胀,建议定期清理。
3.4 网络环境
- 首次同步 Gradle 依赖需要访问 Maven 仓库。
- 如果依赖下载慢,可以在项目的
settings.gradle.kts中配置国内镜像仓库。 - 如果使用多个仓库,注意仓库顺序会影响解析速度,一般把国内镜像放在前面。
4. 项目创建与 Gradle 配置
这一节直接操作。先创建一个新项目,再改配置。
4.1 创建 Empty Activity 项目
打开 Android Studio,选择 New Project,模板选择 Empty Activity,填写项目信息:
- Name:TimerCompose
- Package name:例如
com.example.timercompose - Language:Kotlin
- Minimum SDK:API 21 或更高
创建完成后,Android Studio 会自动生成以下核心文件:
MainActivity.kt:应用入口。ui/theme/目录:主题相关文件。build.gradle.kts:模块构建文件。AndroidManifest.xml:应用配置。
4.2 确认 Compose 依赖
打开app/build.gradle.kts,确认包含 Compose 相关配置。下面是一份通用模板,版本号需要按你项目的实际版本调整:
android { namespace = "com.example.timercompose" compileSdk = 34 defaultConfig { applicationId = "com.example.timercompose" minSdk = 21 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.core:core-ktx:1.13.1") 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") }注意一个常见问题:Compose 插件的 Kotlin 版本和项目 Kotlin 版本必须匹配。如果打开项目后报错提示 Kotlin 版本冲突,优先检查libs.versions.toml或根目录build.gradle.kts中的 Kotlin 版本号。
4.3 同步项目
点击 Android Studio 右上角的 Sync Now,等待依赖下载完成。第一次同步会花几分钟,看到BUILD SUCCESSFUL再继续。
5. 计时器界面实现
接下来进入核心代码。我们要实现的效果:
- 屏幕中央显示计时时间,格式为
MM:SS。 - 下方三个按钮:开始、暂停、重置。
- 点击“开始”后,计时器每秒递增。
- 点击“暂停”后,时间停止变化。
- 点击“重置”后,时间归零。
5.1 基础版:remember 管理状态
先写一个最直接的版本,用remember保存计时秒数,用LaunchedEffect执行循环更新。
package com.example.timercompose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { TimerApp() } } } } } @Composable fun TimerApp() { var elapsedSeconds by remember { mutableLongStateOf(0L) } var isRunning by remember { mutableStateOf(false) } LaunchedEffect(isRunning) { while (isRunning) { delay(1000) elapsedSeconds++ } } Column( modifier = Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = formatTime(elapsedSeconds), fontSize = 64.sp, fontFamily = FontFamily.Monospace ) Spacer(modifier = Modifier.height(32.dp)) Button( onClick = { isRunning = !isRunning } ) { Text(if (isRunning) "暂停" else "开始") } Spacer(modifier = Modifier.height(12.dp)) Button( onClick = { elapsedSeconds = 0L isRunning = false } ) { Text("重置") } } } fun formatTime(totalSeconds: Long): String { val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 return String.format("%02d:%02d", minutes, seconds) }这段代码需要导入androidx.compose.runtime.mutableStateOf。运行后,点击“开始”按钮,计时器会从00:00开始每秒递增。
这里有一个关键机制,后面排查问题也会撞到:
LaunchedEffect(isRunning)会在isRunning变为true时启动协程。while (isRunning)不是死循环卡死界面,因为delay(1000)会挂起协程,不阻塞主线程。- 每次
elapsedSeconds++改变状态,就会触发Text重组,界面自动更新。
5.2 为什么用 mutableLongStateOf 而不是 mutableStateOf
对于 Int、Long 这类基本类型,Compose 提供了专用状态类型:
mutableIntStateOfmutableLongStateOfmutableFloatStateOf
如果用mutableStateOf<Long>也可以,但底层会做装箱,性能上多了无谓的对象分配。在计时器这种每秒更新一次的场景,差别不大;但如果你做高频动画,就应该用基本类型专用状态。
5.3 动态文本格式化
formatTime函数用了%02d补齐两位数。这样:
- 0 秒显示为
00:00 - 65 秒显示为
01:05 - 600 秒显示为
10:00
如果直接用elapsedSeconds.toString(),你会看到0、1、2这样的数字,看起来不像计时器。文本显示和业务逻辑分离,是 Compose 中比较推荐的做法。
6. 状态保存与配置变更
上面代码有一个隐藏问题:如果旋转屏幕,Activity 重建,remember状态会丢失,计时器归零。
解决方式是使用rememberSaveable。
var elapsedSeconds by rememberSaveable { mutableLongStateOf(0L) } var isRunning by rememberSaveable { mutableStateOf(false) }把这两行替换到TimerApp()中。这样旋转屏幕后,时间和运行状态会被系统保存并恢复。
需要注意:
rememberSaveable只能保存可以放入 Bundle 的数据。Long、Boolean、String 都可以。- 如果以后要保存自定义数据类,需要自定义 Saver。
- 进程被系统杀死后,
rememberSaveable不一定能恢复,持久化需要 ViewModel + SavedStateHandle 或 DataStore。
对计时器项目,rememberSaveable已经够用。
7. 增加“加时间”按钮,理解重组细节
我们在界面上再加一个按钮,每次点击给计时器增加 10 秒。这个按钮的意义在于:验证“状态变化一定会触发重组”。
Button( onClick = { elapsedSeconds += 10L } ) { Text("加 10 秒") }放在“重置”按钮下方即可。点击后,中央文本会立刻从00:05跳到00:15,即使计时器没有运行。这说明:界面显示完全由状态决定,和“是否在跑”无关。
这里有一个值得测试的小实验:把elapsedSeconds改成局部普通变量,不交给remember,看看点击按钮后界面是否会变化。结果是不会。这就是命令式 UI 和声明式 UI 的核心差异:
- 命令式 UI:你需要手动调用
textView.text = ...。 - 声明式 UI:你只需要改变状态,框架负责重组。
8. 协程定时任务与生命周期
Compose 中有多个执行协程的入口,它们的区别直接决定代码会不会出问题。
| API | 绑定范围 | 适合场景 |
|---|---|---|
| LaunchedEffect | Composable 生命周期 | 进入组合时启动,离开组合时取消 |
| rememberCoroutineScope | 当前 Composable | 在事件回调中启动协程 |
| DisposableEffect | Composable 生命周期 | 需要手动清理资源 |
计时器用LaunchedEffect最合适,因为:
- 进入界面自动开始。
- 离开界面自动取消,不会泄漏。
- 依赖
isRunning变化时,旧协程取消,新协程启动。
如果是点击按钮后执行一次性协程,比如请求网络、写入文件,就要用rememberCoroutineScope:
val scope = rememberCoroutineScope() Button(onClick = { scope.launch { // 模拟耗时任务 delay(2000) } }) { Text("执行任务") }不要把LaunchedEffect当作万能入口。如果写成:
LaunchedEffect(Unit) { while (true) { delay(1000) elapsedSeconds++ } }这个循环永远不停止,即使界面已经不可见,协程也不会自动取消,直到 Composable 离开组合。
9. 真机运行与 APK 导出
代码写完后,验证有两种方式。
9.1 模拟器运行
Android Studio 自带模拟器。选择设备后点击 Run 按钮,等待构建完成。
如果模拟器启动缓慢,常见原因:
- 电脑未开启硬件加速。
- 系统镜像版本过高。
- 内存不足。
建议第一次用较低 API 级别的镜像,比如 API 30,启动速度会更快。
9.2 真机调试
- 手机开启开发者选项和 USB 调试。
- USB 连接电脑。
- 手机弹窗选择“允许 USB 调试”。
- Android Studio 识别到设备后,点击 Run。
运行后会直接安装 Debug 版 APK。
9.3 导出 APK
需要安装到其他手机,或者发给别人测试时,可以构建 APK 文件。
使用 Android Studio 菜单:
- 选择 Build > Build Bundle(s) / APK(s) > Build APK(s)。
- 构建完成后,右下角会弹出通知。
- 点击通知中的 locate,打开 APK 目录。
如果使用命令行构建,在项目根目录执行:
./gradlew assembleDebug生成的 APK 路径为:
app/build/outputs/apk/debug/app-debug.apk把一个 Debug APK 发给别人安装时,建议说明:这是测试包,可能存在日志输出和调试信息。
10. 功能测试与效果验证
代码写完只是第一步。下面给出一套验证流程,可以作为项目自测清单。
10.1 基础计时测试
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 启动应用 | 界面显示 00:00,按钮显示“开始” |
| 2 | 点击“开始” | 文本每秒递增,按钮变为“暂停” |
| 3 | 等待 65 秒 | 文本显示 01:05 |
| 4 | 点击“暂停” | 文本停止变化 |
| 5 | 点击“重置” | 文本变为 00:00,按钮变为“开始” |
10.2 状态恢复测试
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 点击“开始”,计时到 00:10 | 时间持续递增 |
| 2 | 旋转屏幕或切换深色模式 | 时间和运行状态保留 |
| 3 | 点击“暂停” | 状态正常切换 |
如果你没有使用rememberSaveable,旋转屏幕后时间会归零。这是第 6 节内容的主要验证点。
10.3 手动加时测试
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 点击“加 10 秒”5 次 | 时间显示 00:50 |
| 2 | 点击“开始” | 从 00:50 继续递增 |
| 3 | 点击“重置” | 时间归零 |
10.4 失败排查清单
| 问题现象 | 可能原因 | 排查方式 |
|---|---|---|
| 点击开始无反应 | isRunning 状态没生效 | 确认按钮 onClick 是否更新了 isRunning |
| 文本不刷新 | 变量没有用 remember 包装 | 检查是否写成了普通变量 |
| 每秒跳 2 次 | 多个 LaunchedEffect 同时运行 | 检查代码中是否重复添加 LaunchedEffect |
| 旋转屏幕时间归零 | 没有使用 rememberSaveable | 替换状态声明 |
| 按钮点击闪退 | 导入错误或依赖缺失 | 查看 Logcat 堆栈 |
11. 接口能力与批量任务扩展
很多教程讲到这里就结束了,但如果你想把计时器做成真实工具,还需要考虑“接口”和“批量任务”。Compose 界面本身没有远程 API,但你可以给计时器封装一个本地状态服务,方便后续扩展。
11.1 本地服务封装
把计时逻辑从 Composable 中抽出来,改成可测试的类:
class StopwatchState { var elapsedSeconds by mutableLongStateOf(0L) private set var isRunning by mutableStateOf(false) private set fun start() { isRunning = true } fun pause() { isRunning = false } fun reset() { isRunning = false elapsedSeconds = 0L } fun addSeconds(seconds: Long) { elapsedSeconds += seconds } }在 Composable 中创建:
val stopwatch = remember { StopwatchState() } LaunchedEffect(stopwatch.isRunning) { while (stopwatch.isRunning) { delay(1000) stopwatch.elapsedSeconds++ } }这样做的好处:
- 逻辑脱离 UI,方便写单元测试。
- 后续接 Room 数据库、通知、小组件时,不用改界面代码。
- 多个界面共享同一个计时状态时,可以把
StopwatchState放进 ViewModel。
11.2 API 接口接入思路
如果你的计时器要和后端同步,例如“把计时结果上传到服务器”,可以用 Retrofit 或 Ktor Client。接口路径不是固定的,这里只给一个通用示例:
data class TimerRecord( val taskName: String, val elapsedSeconds: Long, val finishedAt: Long ) interface TimerApi { @POST("api/timer/records") suspend fun uploadRecord(@Body record: TimerRecord): Response<Unit> }调用端放在rememberCoroutineScope中:
val scope = rememberCoroutineScope() Button(onClick = { scope.launch { val record = TimerRecord("测试任务", elapsedSeconds, System.currentTimeMillis()) // 调用上传接口,这里需要替换为实际后端地址 // val response = timerApi.uploadRecord(record) } }) { Text("上传记录") }这里必须提醒:真实接入网络接口前,要确认后端地址、鉴权方式、数据加密方式,不能在代码中硬编码生产环境密钥。
11.3 批量任务扩展
“批量任务”在计时器场景中可以理解为:多个计时任务按队列依次执行。比如:
- 任务 A 计时 25 分钟。
- 休息 5 分钟。
- 任务 B 计时 25 分钟。
此时需要引入队列状态:
data class TimerTask( val name: String, val durationSeconds: Long ) class TaskQueue { val tasks: List<TimerTask> = listOf( TimerTask("任务A", 25 * 60), TimerTask("休息", 5 * 60), TimerTask("任务B", 25 * 60) ) }每个任务结束后自动切换到下一个。实际开发中,建议把队列逻辑放进 ViewModel + StateFlow,而不是写在 Composable 里。
如果需要后台周期任务,比如“每天定时提醒”,可以用 WorkManager:
val workRequest = PeriodicWorkRequestBuilder<ReminderWorker>(1, TimeUnit.DAYS).build() WorkManager.getInstance(context).enqueue(workRequest)WorkManager 适合系统级定时调度,但注意最小周期是 15 分钟,不能像普通协程那样精确到秒。
12. 资源占用与性能观察
Compose 应用在调试阶段,可以通过 Android Studio 自带的 Profiler 观察性能和资源占用。
12.1 显存和内存
安卓应用没有“显存”概念,主要是内存和 GPU 渲染资源:
- Compose 会使用 GPU 加速绘制。
- 文字、Button 等组件在大多数设备上都能流畅运行。
- 计时器每秒刷新一次文本,对现代手机压力很小。
- 如果你开了大量动画、模糊、阴影,才会出现明显的 GPU 负载。
观察方式:
- 打开 Android Studio,选择 View > Tool Windows > Profiler。
- 运行计时器应用。
- 在 Profiler 面板中查看 Memory 和 Energy 曲线。
12.2 重组性能
Compose 的核心优化点是“避免不必要的重组”。你可以通过日志验证界面重组次数。
在TimerApp中加一行:
@Composable fun TimerApp() { var elapsedSeconds by remember { mutableLongStateOf(0L) } var isRunning by remember { mutableStateOf(false) } println("TimerApp recompose, elapsed=$elapsedSeconds") ... }运行后 Logcat 会输出大量日志。每次状态变化都会打印一次,这属于正常现象。如果发现没有状态变化也频繁打印,就要检查是否有父级 Composable 被强制重组。
12.3 避免在主线程做耗时操作
协程的delay不会阻塞主线程。如果你换成Thread.sleep(1000),界面会直接卡死。
这个坑在 Compose 项目中很常见。计时器项目里,只用delay,不要用Thread.sleep。
12.4 降低资源占用的建议
- 不要用全局变量或
object保存大对象。 - 计时器的状态放在
remember或 ViewModel 中,不要放在静态单例里。 - 如果需要长时间计时,建议在
onStop中暂停,在onStart中恢复。 - 不要创建重复的
LaunchedEffect,避免多个协程同时累加状态。
13. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 项目同步失败,依赖下载不了 | 网络问题或仓库被墙 | 查看 Gradle 日志 | 配置国内镜像仓库 |
| Kotlin 版本与 Compose 插件不匹配 | 版本号冲突 | 检查根目录 build.gradle.kts | 统一 Kotlin 和 Compose 编译器版本 |
| 模拟器启动后黑屏 | 系统镜像或硬件加速问题 | 查看模拟器设置 | 换 API 更低的镜像或使用真机 |
| 点击按钮应用崩溃 | 空指针或依赖缺失 | 查看 Logcat | 检查代码中引用是否完整导入 |
| 文本一直显示 00:00 | 状态没有正确更新 | 检查是否用了 remember | 确认 onClicks 更新的是同一个状态 |
| 启动后无法安装到手机 | 手机未开启 USB 调试 | 检查手机设置 | 打开开发者选项,允许 USB 调试 |
| 构建时提示 namespace 错误 | AGP 版本和项目配置不一致 | 检查 app/build.gradle.kts | 设置正确的 namespace |
| 旋转屏幕后计时器重置 | 没有使用 rememberSaveable | 检查状态声明 | 替换为 rememberSaveable |
| 按钮点击后界面卡住 | 主线程执行了耗时操作 | 查看 Profiler | 改用协程 |
14. 最佳实践与项目扩展
14.1 工程化组织建议
对于继续深入学习 Compose 的同学,建议把项目按以下目录组织:
app/src/main/java/com/example/timercompose/ ├── MainActivity.kt ├── data/ │ └── StopwatchState.kt ├── ui/ │ ├── theme/ │ │ ├── Color.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── TimerScreen.kt └── viewmodel/ └── TimerViewModel.kt早期练习可以全部写在 MainActivity 里,但项目变大后一定要拆分。
14.2 代码可维护性建议
- 把
formatTime抽成工具函数,加单元测试。 - 把
StopwatchState抽成类,加单元测试。 - 把界面组件拆分成
TimerScreen、TimerControls、TimerDisplay。 - 使用 Material3 主题,不要手动写死颜色。
14.3 扩展方向
这个计时器项目可以继续扩展的方向很多:
- 添加时间选择器,预设 5 分钟、10 分钟、25 分钟。
- 添加通知功能,计时结束时发送系统通知。
- 添加桌面小组件,不用打开应用就能看到计时状态。
- 添加录音功能,记录任务名称和用时。
- 添加数据持久化,保存历史记录。
- 接后端接口,实现多设备同步。
- 使用 Navigation 实现多页面跳转。
扩展时优先做的事:
- 把计时逻辑从 Composable 中抽到 ViewModel,用
StateFlow暴露状态。 - 引入
ViewModel+SavedStateHandle解决进程重建问题。 - 使用 Room 保存历史记录。
14.4 合规提醒
最后再说一次合规边界:
- 计时器本身不涉及敏感权限,如果后续加通知功能,需要在
AndroidManifest.xml中声明通知权限,并在代码中动态申请。 - 如果加录音、定位、网络功能,必须明确告知用户,并且只在必要场景下使用。
- 不要在你的项目里集成来路不明的 SDK,尤其是涉及账号、支付、隐私信息的。
- 发布应用前,确认所有字体、图标、素材都有合法授权。
- 如果复用开源代码,查看开源协议,按协议要求保留版权声明。
15. 总结与下一步
这个计时器项目做下来,你真正掌握的不是“计时器”本身,而是 Compose 的整套开发闭环:创建项目、配置依赖、定义状态、编写界面、协程定时、状态保存、构建 APK、真机调试。这一套流程,比单纯看 API 文档有用得多。
最值得先验证的是第 5 节的remember状态驱动机制:先跑通开始/暂停/重置,再改成rememberSaveable,理解两者差异。最容易踩的坑就是协程重复启动和状态没保存,遇到问题先看 Logcat,再看自己的状态声明。
接下来的学习路径,建议按以下顺序:
- 用 ViewModel 重构计时器,把状态管理从 Composable 中抽离。
- 用 StateFlow 替代 Compose 内部状态,观察 UI 与逻辑解耦后的变化。
- 接 Room 保存历史计时记录。
- 加 Navigation,做一个多页面应用。
- 试试 Material3 的自定义主题,让界面更接近生产项目。
把这几个步骤走完,Compose 的入门阶段就算真正结束了。建议把本文的关键代码片段收藏备用,尤其是LaunchedEffect和rememberSaveable这两段,后面写其他应用大概率还会用到。