整体效果
主要的功能点:
- 监听系统时间变化,展示对应的数字图片
- 监听时区变化,改变对应的时间显示
- 自带中秋节元素,中秋节快乐!
演示如下:

准备图片资源
数字资源来自千库网,是一系列 0~9 的图片,由于自己没有会员,是找某宝购买的,还算实惠。所有的资源大概长这样:

而背景就比较难搞了,很难找到和这些数字搭配的背景,只能小小的妥协一下,最后使用的是这里的资源,也是一只可可爱爱的兔子:

实现 UI 展示
如下代码相信各位 1s 就能理解:
kotlin
Column {
Row {
Image(painter = painterResource(hourTensResId), contentDescription = "hourTensImg")
Image(painter = ..., contentDescription = "hourUnitsImg")
}
Row {
Image(painter = ..., contentDescription = "minuteTensImg")
Image(painter = ..., contentDescription = "minuteUnitsImg")
}
}
渲染完成之后,就是一个普普通通的四宫格布局:

时间更新
定义相关副作用变量
kotlin
val calendar = remember { Calendar.getInstance() }
val currentTime = remember { mutableStateOf(System.currentTimeMillis()) }
val (hourTensResId, hourUnitsResId, minuteTensResId, minuteUnitsResId) =
rememberTimeResourceIds(calendar, currentTime)
- 定义
calendar
,作为工具类,用来实现时间的转换 - 定义
currentTime
,更新currentTime
来触发 UI 的刷新 - 定义
hourTensResId
,hourUnitsResId
,minuteTensResId
,minuteUnitsResId
这几个变量,其跟随currentTime
变化而变化,输出图片的资源 id,用来更新数字图片时钟。
分钟级的时间变化
分钟级别的时间变化,定义广播接收器监听这两个 Action: Intent.ACTION_TIME_TICK
和 Intent.ACTION_TIME_CHANGED
。这里由于在 dispose
之后要取消广播的注册,防止内存泄漏。所以我们将广播注册和解绑的代码放到 DisposableEffect
中:
kotlin
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
addAction(Intent.ACTION_TIME_CHANGED)
}
context.registerReceiver(receiver, filter)
onDispose {
context.unregisterReceiver(receiver)
}
}
接收到广播之后,要做的事情自然就是更新时间了:
kotlin
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
currentTime.value = System.currentTimeMillis()
calendar.timeInMillis = currentTime.value
}
}
更新 currentTime
来触发时间的更新,进而驱动 UI 的变化
更新 calendar.timeInMillis
来改变 calendar
内部的时间,进行时间的转换
监听时区的变化
监听时区的变化,只需要在 IntentFilter()
里加上 Intent.ACTION_TIMEZONE_CHANGED
即可。
同时,onReceive()
方法增加相应的逻辑。把 timeZone
塞到 calendar
中,供时间转换使用。
kotlin
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (Intent.ACTION_TIMEZONE_CHANGED == intent?.action) {
val timeZone = TimeZone.getTimeZone(intent.getStringExtra(Intent.EXTRA_TIMEZONE))
calendar.timeZone = timeZone
}
// 时间更新的逻辑不变
currentTime.value = System.currentTimeMillis()
calendar.timeInMillis = currentTime.value
}
}
进行时间的转换
如下简单列举一下时钟的转换逻辑,分钟的转换逻辑也是类似的
kotlin
@Composable
fun rememberTimeResourceIds(calendar: Calendar, currentTime: MutableState<Long>): TimeResourceIds {
// 小时十位数的资源ID
val hourTensResId by remember(currentTime.value) {
derivedStateOf { getResourceId(calendar.get(Calendar.HOUR_OF_DAY) / 10) }
}
// ...
}
我们定义了 rememberTimeResourceIds()
方法,其接收 currentTime
,当 currentTime.value
变化时,会进行相应的计算,从而驱动 hourTensResId
的更新。
值得注意的是我们这里使用 derivedStateOf
,它会缓存上一次的计算结果,当新值和上一次的值相同时,将不会触发 hourTensResId
的更新逻辑,从而减少 Compose 的重组。
具体到当前场景,时间变化会每分钟触发一次,而 60 次分钟的变化才会触发一次小时的变化。每次时间变化之后,如果小时数没变,hourTensResId
也不应该变化,分钟相关的 ResId 变化即可。derivedStateOf
是非常适合当前场景的。
总结
使用 Compose 写自定义 View,难免让人不和 Android 传统自定义 View 的书写做对比。总的来说,使用 Compose 写自定义 View 会更加舒服一点,上手的难度也不高,没有传统的自定义 View 那么多啰啰嗦嗦的代码。一切都是函数,都是组合,都是必要的逻辑。纯纯的数据驱动 UI,舒服!
REFERENCE
core/java/android/widget/TextClock.java - platform/frameworks/base - Git at Google
源代码
真的是 100 行,不多也不少 😜。 Github 仓库
kotlin
@Composable
fun ImageClock() {
val calendar = remember { Calendar.getInstance() }
val currentTime = remember { mutableStateOf(System.currentTimeMillis()) }
val (hourTensResId, hourUnitsResId, minuteTensResId, minuteUnitsResId) =
rememberTimeResourceIds(calendar, currentTime)
val receiver = rememberBroadcastReceiver(calendar, currentTime)
val context = LocalContext.current
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
context.registerReceiver(receiver, filter)
onDispose {
context.unregisterReceiver(receiver)
}
}
Column(modifier = Modifier.padding(20.dp)) {
Row {
Image(painter = painterResource(hourTensResId), contentDescription = "hourTensImg")
Image(painter = painterResource(hourUnitsResId), contentDescription = "hourUnitsImg")
}
Row {
Image(painter = painterResource(minuteTensResId), contentDescription = "minuteTensImg")
Image(painter = painterResource(minuteUnitsResId), contentDescription = "minuteUnitsImg")
}
}
}
@Composable
private fun rememberBroadcastReceiver(
calendar: Calendar,
currentTime: MutableState<Long>
) = remember {
object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onReceive(context: Context?, intent: Intent?) {
if (Intent.ACTION_TIMEZONE_CHANGED == intent?.action) {
val timeZone = TimeZone.getTimeZone(intent.getStringExtra(Intent.EXTRA_TIMEZONE))
calendar.timeZone = timeZone
}
// update time
currentTime.value = System.currentTimeMillis()
calendar.timeInMillis = currentTime.value
}
}
}
fun getResourceId(num: Int) = when (num) {
0 -> R.drawable.clock_num_0
1 -> R.drawable.clock_num_1
2 -> R.drawable.clock_num_2
3 -> R.drawable.clock_num_3
4 -> R.drawable.clock_num_4
5 -> R.drawable.clock_num_5
6 -> R.drawable.clock_num_6
7 -> R.drawable.clock_num_7
8 -> R.drawable.clock_num_8
9 -> R.drawable.clock_num_9
else -> R.drawable.clock_num_0
}
data class TimeResourceIds(
val hourTensResId: Int,
val hourUnitsResId: Int,
val minuteTensResId: Int,
val minuteUnitsResId: Int
)
@Composable
fun rememberTimeResourceIds(calendar: Calendar, currentTime: MutableState<Long>): TimeResourceIds {
val hourTensResId by remember(currentTime.value) {
derivedStateOf { getResourceId(calendar.get(Calendar.HOUR_OF_DAY) / 10) }
}
val hourUnitsResId by remember(currentTime.value) {
derivedStateOf { getResourceId(calendar.get(Calendar.HOUR_OF_DAY) % 10) }
}
val minuteTensResId by remember(currentTime.value) {
derivedStateOf { getResourceId(calendar.get(Calendar.MINUTE) / 10) }
}
val minuteUnitsResId by remember(currentTime.value) {
derivedStateOf { getResourceId(calendar.get(Calendar.MINUTE) % 10) }
}
return TimeResourceIds(hourTensResId, hourUnitsResId, minuteTensResId, minuteUnitsResId)
}
@Preview
@Composable
fun ImageClockPreview() {
ImageClockTheme {
ImageClock()
}
}