尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Jetpack Compose从零实现安卓计时器:状态驱动UI与协程实战

用Jetpack Compose从零实现安卓计时器:状态驱动UI与协程实战 这次我们直接用 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.0API 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填写项目信息NameTimerComposePackage name例如com.example.timercomposeLanguageKotlinMinimum SDKAPI 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如果用mutableStateOfLong也可以但底层会做装箱性能上多了无谓的对象分配。在计时器这种每秒更新一次的场景差别不大但如果你做高频动画就应该用基本类型专用状态。5.3 动态文本格式化formatTime函数用了%02d补齐两位数。这样0 秒显示为00:0065 秒显示为01:05600 秒显示为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绑定范围适合场景LaunchedEffectComposable 生命周期进入组合时启动离开组合时取消rememberCoroutineScope当前 Composable在事件回调中启动协程DisposableEffectComposable 生命周期需要手动清理资源计时器用LaunchedEffect最合适因为进入界面自动开始。离开界面自动取消不会泄漏。依赖isRunning变化时旧协程取消新协程启动。如果是点击按钮后执行一次性协程比如请求网络、写入文件就要用rememberCoroutineScopeval 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:054点击“暂停”文本停止变化5点击“重置”文本变为 00:00按钮变为“开始”10.2 状态恢复测试步骤操作预期结果1点击“开始”计时到 00:10时间持续递增2旋转屏幕或切换深色模式时间和运行状态保留3点击“暂停”状态正常切换如果你没有使用rememberSaveable旋转屏幕后时间会归零。这是第 6 节内容的主要验证点。10.3 手动加时测试步骤操作预期结果1点击“加 10 秒”5 次时间显示 00:502点击“开始”从 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): ResponseUnit }调用端放在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: ListTimerTask listOf( TimerTask(任务A, 25 * 60), TimerTask(休息, 5 * 60), TimerTask(任务B, 25 * 60) ) }每个任务结束后自动切换到下一个。实际开发中建议把队列逻辑放进 ViewModel StateFlow而不是写在 Composable 里。如果需要后台周期任务比如“每天定时提醒”可以用 WorkManagerval workRequest PeriodicWorkRequestBuilderReminderWorker(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暴露状态。引入ViewModelSavedStateHandle解决进程重建问题。使用 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这两段后面写其他应用大概率还会用到。
返回列表