【Jetpack Compose】100 行代码实现数字图片时钟

整体效果

主要的功能点:

  1. 监听系统时间变化,展示对应的数字图片
  2. 监听时区变化,改变对应的时间显示
  3. 自带中秋节元素,中秋节快乐!

演示如下:

准备图片资源

数字资源来自千库网,是一系列 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)
  1. 定义 calendar ,作为工具类,用来实现时间的转换
  2. 定义 currentTime ,更新 currentTime 来触发 UI 的刷新
  3. 定义 hourTensResIdhourUnitsResIdminuteTensResIdminuteUnitsResId 这几个变量,其跟随 currentTime 变化而变化,输出图片的资源 id,用来更新数字图片时钟。

分钟级的时间变化

分钟级别的时间变化,定义广播接收器监听这两个 Action: Intent.ACTION_TIME_TICKIntent.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()
    }
}
相关推荐
小镇学者6 小时前
【PHP】导入excel 报错Trying to access array offset on value of type int
android·php·excel
一笑的小酒馆9 小时前
Android11 Launcher3去掉抽屉改为单层
android
louisgeek11 小时前
Git 根据不同目录设置不同账号
android
qq_3909347412 小时前
MySQL中的系统库(简介、performance_schema)
android·数据库·mysql
whysqwhw13 小时前
Kotlin Flow 实现响应式编程指南
android
二流小码农13 小时前
鸿蒙开发:一文了解桌面卡片
android·ios·harmonyos
每次的天空13 小时前
Android第十七次面试总结(Java数据结构)
android·java·面试
梁同学与Android13 小时前
Android --- Handler的用法,子线程中怎么切线程进行更新UI
android·handler·子线程更新ui·切换到主线程
Fastcv13 小时前
这TextView也太闪了,咋做的?
android
恋猫de小郭13 小时前
iOS 26 beta1 重新禁止 JIT 执行,Flutter 下的 iOS 真机 hot load 暂时无法使用
android·前端·flutter