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.kts(Module级别)里面有几处可以顺手调校:
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:id="@+id/tvTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="64sp" android:textStyle="bold" android:fontFamily="sans-serif-light" android:textColor="#FFFFFF" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/btnStart" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" />这里有一个细节:用fontFamily="sans-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. 核心逻辑:千万别用Thread+runOnUiThread来刷新时间
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和label,label支持直接写字符串:
<application android:label="@string/app_name" android:icon="@mipmap/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加autoSizeTextType="uniform",让文字根据可用空间自动缩放:
<TextView android:id="@+id/tvTime" android:textSize="64sp" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="24sp" android:autoSizeMaxTextSize="96sp" />注意autoSizeTextType需要配合TextView有足够的宽度/高度约束才能生效,所以别把TextView放在没有约束的布局里。
7.4 通知栏显示时间与App的定位
如果你希望即便用户切到别的App,也能在通知栏看到刚才的时间(比如倒计时还剩多久),那就需要Notification+Service。但这里我必须泼一盆冷水:如果只是时钟这一个功能,完全没必要上Service。通知栏的时钟是系统的自带能力,你做一个App去通知栏显示时间,既重复又容易被用户卸载。
倒计时场景则另当别论。如果倒计时在App退到后台后还想提醒,最广为人知的方案是WorkManager或AlarmManager,而不是自己开一个常驻Service。现在的Android系统对后台Service限制很严格,开一个前台Service必须有可见通知,用户体验不好。AlarmManager的setExactAndAllowWhileIdle可以做精确的定时提醒,配合BroadcastReceiver在指定时刻弹通知,这个方案我在做倒计时扩展时换过一遍,实测可靠。
7.5 多语言与格式化
我默认使用Locale.getDefault()来格式化时间,这样中文用户会看到"上午/下午"在12小时制里,英文用户看到AM/PM。但时钟App现在很多走24小时制,为了稳妥,我直接用HH:mm:ss(24小时制)而不是hh:mm:ss(12小时制)。如果你做的是欧美向的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仓库,每完成一个小功能就提交一次。回头看你的提交历史,能清晰看到自己的思考变化——这是我在教别人入门时反复强调的习惯。