ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Kotlin手写一个时钟App:从Handler到协程的Android实战

用Kotlin手写一个时钟App:从Handler到协程的Android实战 1. 为什么我会在2025年写一个老掉牙的时钟App先说结论这个项目不是教你造一个花里胡哨的表盘而是用最少的代码把Android开发的核心链路完整跑通。我见过太多人学Kotlin时卡在语法都认识、一写项目就懵的状态时钟App恰好是一个五脏俱全的最小样本里面有UI布局、有定时刷新、有生命周期处理、有状态保存甚至能引申到协程和性能优化。很多初学者上来就盯着需求文档发呆——界面上要显示什么、每秒要变什么、App退到后台怎么办。这些问题在时钟这个场景里全部变得具体而好理解。你可以把它当成自己第一个能装进手机的真东西而不只是练习题。这个项目适合这几类人刚学完Kotlin基础语法想找个完整项目练手的新手从Java转Kotlin想快速熟悉Android现代开发方式的开发者需要一个干净、无依赖、能随时改造的基础模板做二次开发的效率党。我用Kotlin XML布局不用Compose还有一个实际考量Compose虽然新但很多公司的存量项目还是View体系学XML布局并没有过时。时钟项目用传统View体系写一遍你对ConstraintLayout、TextView、Handler、Service这些老伙计的默契度会完全不一样。而且这个项目不需要任何第三方库纯手写适合所有人直接复现。另外提醒一句如果你看到的教程还在教Thread runOnUiThread来刷时间可以直接关掉。那套写法能跑但已经不是现代Android开发的习惯方式了。我在下文会给出更优的替代方案。2. 开工前的准备把Android Studio调成顺手的状态2.1 版本选择与首次配置开发这个项目需要Android Studio和JDK。我用的是Android Studio最新稳定版JDK 17。如果你电脑上有旧项目的配置也不用慌Android Studio可以为单个项目单独指定JDK版本。新建项目时选Empty Views Activity这里有个坑新版Studio默认会给你生成EdgeToEdge相关的代码让界面内容延伸到系统状态栏下面。时钟App界面简单你可以保留它但后面布局时要注意给状态栏留出空间如果你不想处理这些直接在themes.xml里把windowActionBar和windowNoTitle配置好用传统方式也能做得很清爽。语言选择Kotlin最低支持API选24或26都行——如果只是自己用选26能少处理一些兼容问题比如通知权限的运行时请求。我在测试机上用的是API 30的模拟器和API 34的真机整个项目不需要特殊权限所以兼容性上没有任何负担。2.2 Gradle文件里值得关注的几个点项目创建后build.gradle.ktsModule级别里面有几处可以顺手调校compileSdk和targetSdk保持一致我用的34开启viewBinding这个开关能省掉一堆findViewById样板代码对新手尤其友好。在android块里加buildFeatures { viewBinding true }依赖方面这个项目只需要标准库和AndroidX。新模板会带appcompat和constraintlayout这两样就够了。lifecycle相关的扩展库我会在后文协程小节提到怎么取舍。2.3 模拟器还是真机时钟App涉及时间刷新和息屏行为强烈建议用真机调试哪怕是个老旧手机。模拟器有两个问题一是时间源是宿主机秒针跳动模拟没有问题但省电策略、后台限制这类行为模拟得不够真实二是模拟器默认息屏逻辑和真机有差异你很难复现息屏后再次点亮时钟是否正确跳转这个经典坑。我自己的习惯是开发阶段用模拟器跑布局跑逻辑和生命周期用真机。每次修改后直接Run到设备上两三秒钟看到效果迭代效率高。3. 界面设计不靠设计稿靠ConstraintLayout把布局讲清楚3.1 布局文件的层次打开activity_main.xml我先删除模板自带的Hello World和居中约束从零搭一个清爽的布局。时钟App的界面就三块顶部可以放一个标题中间是巨大的时间文字底部放两个按钮——开始秒表和重置倒计时之类的扩展功能。我这个项目的UI思路很简单一块深色背景、白色时间、下方一排功能按钮。界面要传达一眼看到时间的信息所以字体要大、对比度要高。至于毛玻璃、渐变、粒子效果等你的App跑通核心逻辑之后再锦上添花。用ConstraintLayout做根容器时间文字放在正中间需要的注意事项是不要硬编码layout_width和layout_height为wrap_content后再手调margin而是学会用约束链Chain来居中组件组。比如秒表和重置按钮要水平排布在底部将两个按钮互相约束左边按钮的layout_constraintEnd_toStartOf指向右边按钮右边按钮的layout_constraintStart_toEndOf指向左边按钮再把整个链约束到父容器底部居中就能自适应不同屏幕宽度。我的布局关键代码TextView android:idid/tvTime android:layout_widthwrap_content android:layout_heightwrap_content android:textSize64sp android:textStylebold android:fontFamilysans-serif-light android:textColor#FFFFFF app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toTopOfid/btnStart app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent /这里有一个细节用fontFamilysans-serif-light而不是默认字体数字会显得细长干净观感差很多。同一个布局里不要让它上下约束到父容器正中就好因为底部还有按钮时间会显得偏左或偏右。如果你希望时间更居中可以先把底部按钮的组约束好再让tvTime的layout_constraintBottom_toTopOf指向按钮组顶部的guideline或直接指向按钮的顶部约束这样视觉舒服很多。3.2 ViewBinding让代码干净开启ViewBinding后在MainActivity里这样引用布局private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.tvTime.text 00:00:00 }这里要提醒一个小坑如果你的布局文件名是activity_main.xml绑定的类名就是ActivityMainBinding。如果哪天你重命名了布局重新Build一下Studio才会生成新的Binding类。遇到过几次明明加了布局却找不到Binding的报错都是增量编译没刷新导致的Build - Clean Project能解决。4. 核心逻辑千万别用ThreadrunOnUiThread来刷新时间4.1 三种刷新方案背后的是非时钟App最核心的动作是每秒刷新一次文字。实现方案有很多但每种方案的坑都不一样方案一Thread runOnUiThread。这是最老的教程写法开一个子线程死循环每秒切回主线程更新UI。缺点很明显主线程和子线程频繁切换代码里到处是runOnUiThread而且没有处理App进入后台时停止刷新的逻辑白白耗电。方案二Handler postDelayed。这个方案比Thread干净很多利用主线程的Looper消息队列定时发送更新消息代码量不大也是我现在推荐的入门写法。方案三协程 delay。更现代可读性更强配合生命周期感知避免内存泄漏。但对于刚接触Kotlin的新手协程本身又是一个需要理解的概念容易和Activity的onDestroy纠缠不清。我建议初学者先用方案二写通逻辑然后改造到方案三这两步之间能体会到一种很重要的进阶感从手动管理到框架替你管理。这个项目里我会先用Handler演示再用协程重构两个版本都看得到。4.2 Handler的第一版实现基本写法private val handler Handler(Looper.getMainLooper()) private val updateRunnable object : Runnable { override fun run() { binding.tvTime.text getCurrentTime() handler.postDelayed(this, 1000) } } private fun getCurrentTime(): String { return SimpleDateFormat(HH:mm:ss, Locale.getDefault()).format(Date()) } override fun onResume() { super.onResume() handler.post(updateRunnable) } override fun onPause() { super.onPause() handler.removeCallbacks(updateRunnable) }我强调一个关键设计把postDelayed放在onResume里启动在onPause里移除回调。这样App退到后台时时钟不会继续在后台空转回到前台时会重新启动刷新。很多新手把handler.post扔在onCreate里然后就没管过结果是Activity不可见时消息还在队列里不断抛既耗电又可能在极端场景下导致内存泄漏。另一个细节是SimpleDateFormat的创建开销。时钟每秒调用一次getCurrentTime()如果每次都new SimpleDateFormat在小项目里看不到性能问题但这是一个坏习惯。我把它定义成成员变量或直接上ThreadLocal。这里我选择最简单的方式定义成private val。4.3 显示日期和周几只显示时分秒太单调了我顺手加了日期和周几。这是很多人忽略的体验细节。日期用同一套SimpleDateFormat只是pattern不同private val dateFormat SimpleDateFormat(yyyy年MM月dd日 EEEE, Locale.CHINESE)EEEE在中文环境下会输出星期三这类完整星期名。如果你想要周三这种简称用E。时间文字的排版上我让日期作为小一号的文字放在时间下方颜色用半透明白和主时间形成层次。界面里再加入一个tvDate逻辑相同onResume里一起刷新。4.4 状态保存旋转屏幕别闪回00:00时钟App还有个看似不重要但问的人很多的问题旋转屏幕后时间文字会短暂地回到初始值再跳变。原因在于Activity重建tvTime的文字被重新初始化成00:00:00或空。解决办法有两种第一种是保存状态新手最容易理解override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(time, binding.tvTime.text.toString()) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) savedInstanceState.getString(time)?.let { binding.tvTime.text it } }第二种更彻底但也更花哨在onCreate里判断savedInstanceState ! null时直接刷新一次时间用新值覆盖旧值这样用户根本看不出闪变。我实际项目里用的是第二种思路——在onResume里每次都强行刷新一次文字紧随其后才启动定时器所以重建后最多闪一帧就恢复正常肉眼几乎不可见。这个取舍供你参考。5. 从Handler到协程的重构理解生命周期是关键5.1 为什么值得用协程改写Handler版本在功能上没有任何问题代码也够简短。但如果你把App的功能做复杂比如同时刷新时钟、跑秒表、做倒计时Handler的回调嵌套会越来越难看。逻辑分散在不同Runnable里互相间还要通信改一个功能容易牵一发动全身。协程的好处是你可以在一个代码块里顺序地写循环等待-更新UI逻辑读起来像同步代码底层的线程调度交给框架。这对于有时间类逻辑的App是天然契合的。5.2 用lifecycleScope改写在MainActivity里配合lifecycleScope是最安全的做法因为lifecycleScope.launch启动的协程会在onDestroy时自动取消不需要你手动管理。代码如下override fun onResume() { super.onResume() lifecycleScope.launch { while (isActive) { binding.tvTime.text getCurrentTime() binding.tvDate.text getCurrentDate() delay(1000) } } }注意这里isActive是协程作用域里的一个标志位如果协程被取消了比如Activity销毁循环会自动退出。这比Handler的removeCallbacks省事得多。如果你把delay(1000)理解成我在这里挂起1秒不阻塞主线程那么整个逻辑就通了——主线程不会被while循环卡死因为每一轮循环里都有一个非阻塞的挂起点。使用lifecycleScope之前需要在build.gradle.kts中添加依赖implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.8.7)如果不加这个依赖Studio会提示找不到lifecycleScope。额外说一句新版Android Studio新建的Kotlin项目模板通常不会自动包含lifecycle-runtime-ktx所以千万不要跳过这一步。5.3 协程版本容易踩的一个坑很多人在协程版本里会误写lifecycleScope.launch { while (true) { binding.tvTime.text ... delay(1000) } }理论上一旦协程取消delay(1000)内部会抛出CancellationException退出循环所以while (true)不会导致协程真的永不结束。但是为了代码意图更清楚最好还是用while (isActive)。别问为什么问就是某次我在某种特殊场景下比如协程取消发生在第一次delay之前遇到过循环没有被立刻终止的诡异行为后来改成isActive就再没出过问题。6. 把这个小项目做大秒表、倒计时与息屏刷新6.1 秒表功能精度问题与你无关加一个秒表功能是很多人的下一步。但这里有个误区秒表计时如果只靠delay(1000)刷新显示出来的是每秒跳一次的假秒表一旦中间有卡顿就会累计误差。更靠谱的做法是记录起始时间戳每次刷新时用当前时间减去起始时间来计算经过的秒数而不是每次加1。前者是绝对时间后者是相对计数面对系统调度延迟前者永远准。核心思路private var startTime 0L fun startStopwatch() { startTime System.currentTimeMillis() lifecycleScope.launch { while (isActive) { val elapsed System.currentTimeMillis() - startTime binding.tvTime.text formatElapsed(elapsed) delay(100) } } }这里把刷新间隔从1000毫秒改成100毫秒配合毫秒级格式化就能做出带两位小数的秒表。这种记录时间戳而非累加计数的思想在我后来做倒计时、番茄钟甚至定时任务时都反复用到。6.2 息屏后时间是否正确是个经典大坑很多人做完时钟后发现把手机锁屏等一会儿再解锁时间文字跳过了中间的几秒。原因很简单屏幕关闭后系统可能会暂停应用进程的调度Doze模式delay并不精确它只是至少等待这么多时间并没有保证到点准时触发。解决方案是在解锁时强制刷新一次。这可以通过生命周期回调实现——onResume里本就有一行刷新代码如果我们在onStart和onResume里每次都刷新那解锁回到前台时会重新读取当前系统时间自然就修正了。还有一种情况更隐蔽——亮屏但没解锁此时onResume不会触发。某些平板场景下时钟可能停留在锁屏界面的时间不更新。如果遇到这种需求可以注册ACTION_SCREEN_ON和ACTION_USER_PRESENT广播在收到广播时刷新。这个项目里我用不到但值得知道有这回事。6.3 倒计时别在onPause里重置状态倒计时的逻辑其实和秒表相反设定一个结束时间戳当前时间 倒计时长每轮刷新时计算结束时间戳与当前时间的差值。这样即使中间出现调度延迟倒计时总数仍然是准的——最多显示的进程慢但不会出现倒计时走快了的情况。代码骨架private var endTime 0L fun startCountdown(durationMs: Long) { endTime System.currentTimeMillis() durationMs lifecycleScope.launch { while (isActive System.currentTimeMillis() endTime) { val remaining endTime - System.currentTimeMillis() binding.tvTime.text formatRemaining(remaining) delay(100) } binding.tvTime.text 00:00:00 } }这里有个细节倒计时结束后要显示00:00:00。很多人的实现是在循环里判断remaining 0就break然后忘记清除旧文字导致界面停留在00:00:01。代码示例里我在循环外统一把文字归零这是一种循环外收尾的写法比在循环内判断后再更新更不容易出错。6.4 底部两个按钮的联动状态界面上的秒表和重置按钮需要处理点击开始后再点击会怎样的边界。我的做法是维护一个isRunning状态秒表运行时按钮文字变为暂停再点变成继续重置按钮只有在秒表停止时才可点。这些UI状态管理如果不提前设计写起来会越写越乱。具体实现private var isStopwatchRunning false private var accumulatedTime 0L private var segmentStart 0L fun onToggleStopwatch() { if (isStopwatchRunning) { accumulatedTime System.currentTimeMillis() - segmentStart isStopwatchRunning false } else { segmentStart System.currentTimeMillis() isStopwatchRunning true startStopwatchDisplay() } }这个设计把累计时间和当前段起始时间分开不会因为按钮被反复暂停和继续而丢失总计时。每次刷新时显示的是accumulatedTime (当前时间 - segmentStart)仅在运行时。把这个公式想清楚秒表的暂停/继续功能就彻底理解了。7. 打包与细节优化从能跑到像样7.1 App图标与名称很多人做完功能后败在了最后一公里——图标还是机器人默认图标App名称还是MainActivity的label。改起来很简单在res/mipmap里准备自己的图标然后在AndroidManifest.xml里的application节点修改icon和labellabel支持直接写字符串application android:labelstring/app_name android:iconmipmap/ic_launcher如果你想要自适应图标Adaptive Icon在API 26以上设备上可以只用mipmap-anydpi-v26里的ic_launcher.xml定义前景和背景连切图都省了。时钟App的图标随便用个纯色背景加一个文字时就很有辨识度。7.2 屏幕常亮与省电策略作为一个时钟App很多用户希望它一直亮屏立在桌面。这个需要FLAG_KEEP_SCREEN_ON在onCreate里加一行window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)有这个Flag后不需要申请WAKE_LOCK权限系统会在屏幕触摸无操作后依然保持亮屏这是最轻量的常亮方案。但注意这个Flag会让屏幕耗电量明显上升。如果你做的是带秒表功能的App可以做成秒表运行时才常亮平时不常亮的动态控制用clearFlags去掉即可。7.3 字体大小适配用sp作为TextView的字号单位是Android的规矩因为它能跟随系统字体缩放。但在时钟这种满屏数字的场景里如果用户把系统字体调到特大布局可能直接挤爆。我建议时间文字用sp但外层布局的minHeight或padding不要写死留出伸缩空间。更稳妥的方案是给tvTime加autoSizeTextTypeuniform让文字根据可用空间自动缩放TextView android:idid/tvTime android:textSize64sp android:autoSizeTextTypeuniform android:autoSizeMinTextSize24sp android:autoSizeMaxTextSize96sp /注意autoSizeTextType需要配合TextView有足够的宽度/高度约束才能生效所以别把TextView放在没有约束的布局里。7.4 通知栏显示时间与App的定位如果你希望即便用户切到别的App也能在通知栏看到刚才的时间比如倒计时还剩多久那就需要NotificationService。但这里我必须泼一盆冷水如果只是时钟这一个功能完全没必要上Service。通知栏的时钟是系统的自带能力你做一个App去通知栏显示时间既重复又容易被用户卸载。倒计时场景则另当别论。如果倒计时在App退到后台后还想提醒最广为人知的方案是WorkManager或AlarmManager而不是自己开一个常驻Service。现在的Android系统对后台Service限制很严格开一个前台Service必须有可见通知用户体验不好。AlarmManager的setExactAndAllowWhileIdle可以做精确的定时提醒配合BroadcastReceiver在指定时刻弹通知这个方案我在做倒计时扩展时换过一遍实测可靠。7.5 多语言与格式化我默认使用Locale.getDefault()来格式化时间这样中文用户会看到上午/下午在12小时制里英文用户看到AM/PM。但时钟App现在很多走24小时制为了稳妥我直接用HH:mm:ss24小时制而不是hh:mm:ss12小时制。如果你做的是欧美向的App记得改成h:mm:ss a并处理好上午下午。8. 我踩过的坑和给你的最终建议8.1 自动化测试这个项目真不用很多教程会告诉你写单元测试。但一个时钟App核心逻辑是格式化时间字符串和时间戳差转成显示文本这些确实可以抽出来做成纯函数测试。不过我实际做的时候发现比写测试更重要的是先想清楚哪些逻辑和Android系统绑定。把formatElapsed()这种纯函数抽出来将来无论是写测试还是换UI框架都会轻松很多。我把纯函数放到了独立的TimeFormatter.kt文件里object TimeFormatter { fun formatElapsed(elapsedMs: Long): String { val totalSeconds elapsedMs / 1000 val hours totalSeconds / 3600 val minutes (totalSeconds % 3600) / 60 val seconds totalSeconds % 60 val millis elapsedMs % 1000 return String.format(%02d:%02d:%02d.%03d, hours, minutes, seconds, millis) } }这个对象不依赖Context随时可以跑测试。如果你想推荐给别人抄作业这个文件可以直接复制。8.2 Handler和协程最终我建议你都会虽然我在第5章推荐了协程但不要急着把Handler扔进垃圾桶。Handler在Android里的地位依然重要——很多系统回调、View的post、Choreographer的帧回调都基于消息循环机制。学时钟项目时你先把Handler写明白能理解主线程消息队列这个概念今后遇到View.postDelayed、HandlerThread这些高级用法都能快速上手。协程则是现代Kotlin项目躲不开的基本功。从时钟这个项目开始接触lifecycleScope理解挂起与恢复然后慢慢接触flow这条路我不认为会走弯路。8.3 给学习路径的一句话总结我个人的实操体会是时钟App是一个刚刚好的复杂度项目。它难到能让你碰到生命周期、UI刷新、线程切换这些真问题又简单到可以一个人两三个晚上做完。做完之后不要停立刻加上秒表和倒计时你就会自然遇到状态管理和精确计时这两个进阶课题。这个时候你才真正从会写Kotlin迈向了会做Android App。最后再分享一个小技巧这个项目维护一个Git仓库每完成一个小功能就提交一次。回头看你的提交历史能清晰看到自己的思考变化——这是我在教别人入门时反复强调的习惯。
返回列表