Kotlin/Android中如何运行多计时器并每秒自动更新UI?
解决放置游戏多计时器UI不实时刷新问题
问题场景与现状
我要实现放置游戏的多计时器功能:
- 每次在本地Room DB创建新数据对象时,生成一个从指定数值(比如100)开始的独立倒计时器
- 倒计时结束时,自动触发DB操作(比如给对应字段+1)并更新UI,无需用户交互,不管当前在哪个页面
- 计时器要能在后台运行,切回应用时自动同步状态
使用WorkManager尝试实现后,遇到核心问题:DB里的计时器数据已更新,但只有新增任务时UI才会刷新,单个计时器的更新无法触发UI变化。
现有代码示例
JobList Composable
@Composable fun JobList() { val jobViewModel: JobViewModel = hiltViewModel() val jobList = jobViewModel.getAllJobs() val jobValues = jobList.collectAsState(emptyList()) Column { jobValues.value.forEach { value -> Button(onClick = { deleteJob(jobViewModel, value) }) { Text("Delete me") } val jobTimer = jobViewModel.getJobTimerDisplay(value.jobId) val jobTimerValue = jobTimer.collectAsState("") JobTimer(totalTime = jobTimerValue.value) Text(text = "Job Level: ${value.jobLevel} : ${value.name} : Stealth: ${value.stealthRequirement}, Brute: ${value.bruteRequirement}") } } }
可正常更新的列表逻辑
val jobList = jobViewModel.getAllJobs() val jobValues = jobList.collectAsState(emptyList())
无法刷新的计时器逻辑
val jobTimer = jobViewModel.getJobTimerDisplay(value.jobId) val jobTimerValue = jobTimer.collectAsState("") JobTimer(totalTime = jobTimerValue.value)
WorkManager实现
override suspend fun doWork(): Result { var working = true while (working) { delay(1001) val currentTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) val jobId = inputData.getString("jobId") val job = jobId?.let { AppDatabase.getInstance(applicationContext).jobDao().loadById(it) } if (job != null) { AppDatabase.getInstance(applicationContext).jobDao() .updateJob(job.copy(jobTimer = currentTime)) } else { working = false } } return Result.success() }
问题根源分析
- Room Flow通知机制未利用好:
getAllJobs()返回的Flow仅在列表增删时触发更新,单个Job字段变更时,若ViewModel未监听单个Job的Flow变化,UI无法感知。 - WorkManager逻辑低效且偏离需求:循环delay更新当前时间既浪费系统资源,也没有实现真正的倒计时逻辑(应该记录倒计时结束时间,而非实时更新当前时间)。
- Composable Flow收集方式错误:在
forEach内部调用collectAsState,会导致列表重组时重复收集Flow,或无法正确触发UI重组。
解决方案
1. 优化Room DAO,确保单条数据变更能触发通知
修改DAO,新增单Job监听Flow,同时保留全量列表Flow的自动更新特性:
@Dao interface JobDao { @Query("SELECT * FROM jobs") fun getAllJobs(): Flow<List<Job>> @Query("SELECT * FROM jobs WHERE jobId = :jobId") fun getJobById(jobId: String): Flow<Job?> @Update suspend fun updateJob(job: Job) // 其他增删方法 }
2. 重构ViewModel的计时器逻辑
改为监听单Job的变化,计算剩余倒计时时间,同时优化WorkManager调度逻辑:
class JobViewModel @Inject constructor(private val jobDao: JobDao) : ViewModel() { fun getAllJobs(): Flow<List<Job>> { return jobDao.getAllJobs() } // 监听单个Job,计算并返回格式化的剩余倒计时 fun getRemainingTimeFlow(jobId: String): Flow<String> { return jobDao.getJobById(jobId) .filterNotNull() .map { job -> val currentTime = System.currentTimeMillis() val remainingTime = job.countdownEndTime - currentTime if (remainingTime <= 0) { "已完成" } else { val seconds = (remainingTime / 1000).toInt() String.format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60) } } .distinctUntilChanged() // 避免重复发送相同值,减少无用重组 } // 创建新Job时,初始化倒计时结束时间并调度Work任务 suspend fun startNewJob(job: Job) { val countdownDuration = 100 * 1000 // 对应需求的100秒倒计时 val endTime = System.currentTimeMillis() + countdownDuration jobDao.insertJob(job.copy(countdownEndTime = endTime)) scheduleCountdownWork(job.jobId, endTime) } // 调度倒计时结束任务,避免循环浪费资源 private fun scheduleCountdownWork(jobId: String, endTime: Long) { val delayMillis = endTime - System.currentTimeMillis() if (delayMillis <= 0) { completeJob(jobId) return } val workRequest = OneTimeWorkRequestBuilder<CountdownWorker>() .setInitialDelay(delayMillis, TimeUnit.MILLISECONDS) .setInputData(workDataOf("jobId" to jobId)) .build() WorkManager.getInstance().enqueue(workRequest) } // 倒计时结束后的业务逻辑:升级Job等级,可选自动开启下一轮 suspend fun completeJob(jobId: String) { val job = jobDao.getJobById(jobId).firstOrNull() ?: return jobDao.updateJob(job.copy( jobLevel = job.jobLevel + 1, countdownEndTime = System.currentTimeMillis() + 100 * 1000 )) } }
3. 修正WorkManager实现
改为一次性任务,仅在倒计时结束时执行业务逻辑:
class CountdownWorker( context: Context, workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { val jobId = inputData.getString("jobId") ?: return Result.failure() val jobDao = AppDatabase.getInstance(applicationContext).jobDao() val job = jobDao.getJobById(jobId).firstOrNull() ?: return Result.failure() // 执行倒计时完成逻辑 jobDao.updateJob(job.copy( jobLevel = job.jobLevel + 1, countdownEndTime = System.currentTimeMillis() + 100 * 1000 )) return Result.success() } }
4. 优化Composable的UI刷新逻辑
调整Flow收集位置,确保单Job计时器变化能触发UI重组:
@Composable fun JobList() { val jobViewModel: JobViewModel = hiltViewModel() val jobList by jobViewModel.getAllJobs().collectAsState(emptyList()) Column { jobList.forEach { job -> Button(onClick = { /* 实现删除逻辑 */ }) { Text("Delete me") } // 直接收集单Job的剩余时间Flow,确保变化时触发重组 val remainingTime by jobViewModel.getRemainingTimeFlow(job.jobId).collectAsState("") JobTimer(totalTime = remainingTime) Text(text = "Job Level: ${job.jobLevel} : ${job.name} : Stealth: ${job.stealthRequirement}, Brute: ${job.bruteRequirement}") } } }
额外注意事项
- 后台运行保障:WorkManager的
OneTimeWorkRequest在设备开机状态下会自动执行,但若受系统电池优化限制,可考虑配合前台服务提升优先级(需申请对应权限)。 - 避免重复任务:创建新Job时,可先取消该Job对应的旧Work任务,防止重复执行。
- Room Flow特性:Room的Flow为冷流,仅在收集时触发查询,数据变更时自动推送新值,确保UI实时同步。
内容的提问来源于stack exchange,提问作者Lee Delarm
相关产品推荐
相关产品推荐

