我用 Compose 给家人写了个「生活小助手」:桌面卡片、免 Key 天气、每日语音提醒,以及国行 MIUI 上的一路暗坑
这个 App 的起点是一个很朴素的家庭需求:每天晚上 10:30,提醒我给娃穿尿不湿。 顺带把「今天走了多少步」「出门穿不穿外套」这两件每天要看好几遍的事,做到桌面卡片上一眼能看。
功能不大,但在国行红米 + Android 13 上,几乎每一个特性都撞到了一个文档里不会写、日志里看不懂的坑。 这篇文章按「需求 → 方案 → 踩坑 → 最终写法」的顺序,把整个 demo 拆开讲,代码都是项目里的真实实现。



一、这个 App 做了什么
| 模块 | 能力 | 关键点 |
|---|---|---|
| 今日步数 | 环形进度 + 热量 / 距离 / 活跃时长 | 优先读 MIUI 本地计步数据库,非 MIUI 回退传感器 |
| 天气 | 定位城市 + 实时温度 + 天气描述 | Open-Meteo + BigDataCloud,无需注册、无需 API Key |
| 桌面小组件 | Glance 卡片展示步数 / 目标 / 天气,4 套渐变背景可换 | 快照驱动渲染 + 分钟级刷新 |
| 每日语音提醒 | 支持任意多条,到点手机用 TTS 朗读「现在是晚上10点半了,给娃娃穿尿不湿了」 | WorkManager 每日自循环 + 后台直接播 TTS |
| 长按图标 | 快捷方式一键把小部件钉到桌面 | requestPinAppWidget 的调用时机 |
工程信息(便于你复现环境):
yaml
包名 com.food.myapplication(demo 阶段沿用的旧包名)
minSdk / targetSdk / compileSdk 24 / 36 / 36
Kotlin 2.0.21 AGP 8.11.2
Compose BOM 2024.09.00 Material3
Glance 1.1.1 WorkManager 2.9.1
测试机 红米 Note 13 Pro(Android 13 / 国行 MIUI)
代码结构很扁平,四个包各管一摊:
kotlin
com.food.myapplication
├── MainActivity.kt 入口 + 长按快捷方式加卡片
├── data/
│ ├── StepCountRepository.kt 计步数据源(MIUI Provider / 传感器双通道)
│ ├── WeatherRepository.kt 定位 + 天气(纯 HttpURLConnection,无第三方网络库)
│ └── AppSnapshot.kt 给桌面卡片用的数据快照
├── ui/
│ ├── StepViewModel.kt StateFlow 汇聚 + 反向推给卡片
│ ├── StepScreen.kt 全部界面(环、统计卡、天气卡、提醒卡、背景选择)
│ └── theme/ 语义色 + 深浅色主题
├── widget/
│ ├── StepWidget.kt Glance 卡片 + 刷新链路
│ └── WidgetTickReceiver.kt 每分钟闹钟刷新 + 开机重排
└── task/
└── ReminderTasks.kt 语音任务存储、WorkManager 调度、TTS 播报
二、步数:怎么在国行红米上拿到「真正的今日总步数」
2.1 只用传感器会是什么结果
TYPE_STEP_COUNTER 给的是自开机以来的累计值,不是今日步数。所以常见做法是「拿当天第一次读数当基准,之后的读数减掉它」:
kotlin
/** 传感器语义为「自开机累计」,用当天首次读数作基准换算今日增量,并处理重启回零。 */
private fun stepsOfDayBaseline(totalSinceBoot: Int): Int {
val today = todayKey()
var baseline = prefs.getInt(KEY_BASELINE, -1)
if (prefs.getString(KEY_DAY, null) != today) { // 跨天:重设基准
baseline = totalSinceBoot
prefs.edit().putString(KEY_DAY, today).putInt(KEY_BASELINE, baseline).apply()
}
if (totalSinceBoot < baseline) { // 手机重启,累计值回零
baseline = totalSinceBoot
prefs.edit().putInt(KEY_BASELINE, baseline).apply()
}
val steps = (totalSinceBoot - baseline).coerceAtLeast(0)
prefs.edit().putInt(KEY_STEPS, steps).apply()
return steps
}
这套逻辑本身没错,但它有个致命局限:基准点是「App 今天第一次打开」。你早上出门走了一万步,晚上打开 App,它只能看到你打开之后走的那点。做「今日目标 8000 步」这种产品,这个数字是错的,而且错得很隐蔽------用户以为 App 坏了。
2.2 正解:读小米/红米系统自带的计步数据库
MIUI 有个系统级 ContentProvider,它的后台服务约每 5 分钟把传感器数据落库一次,是系统级统计,包含 App 没打开时走的步数,天然按天归零:
kotlin
private companion object {
val STEPS_URI: Uri = Uri.parse("content://com.miui.providers.steps/item")
const val COLUMN_STEPS = "_steps"
const val COLUMN_BEGIN_TIME = "_begin_time"
const val PROVIDER_POLL_INTERVAL = 30_000L // 30 秒轮询一次
}
/** 查询当天所有计步记录的步数之和。返回 null 表示查询失败(权限被拒 / Provider 不存在)。 */
private fun readTodayStepsFromProvider(): Int? = try {
appContext.contentResolver.query(
STEPS_URI,
arrayOf(COLUMN_STEPS, COLUMN_BEGIN_TIME),
"$COLUMN_BEGIN_TIME >= ?",
arrayOf(todayStartMillis().toString()),
null,
)?.use { cursor ->
val idx = cursor.getColumnIndex(COLUMN_STEPS)
if (idx < 0) null else {
var total = 0
while (cursor.moveToNext()) total += cursor.getInt(idx)
total
}
}
} catch (e: Exception) {
null
}
权限是 MIUI 私有的,直接在清单里声明即可(不需要弹窗请求,用户也不用手动授权):
xml
<uses-permission android:name="miui.permission.READ_STEPS" />
然后按「先 Provider,再传感器,都不行才报错」探测数据源,UI 层根据来源显示不同说明文案,不骗用户:
kotlin
private fun detectSource(): StepSource = when {
isProviderReachable() -> StepSource.MIUI_PROVIDER
hasStepSensor -> StepSource.SENSOR
else -> StepSource.NONE
}
界面上会明确标出来,避免用户误读:
kotlin
val sourceHint = when (source) {
StepSource.MIUI_PROVIDER -> "数据源 · 小米健康(今日总步数,约每 5 分钟同步)"
StepSource.SENSOR -> "数据源 · 计步传感器(从今日首次打开起算)"
StepSource.NONE -> "数据源 · 无"
}
2.3 坑 ①:Android 11+ 包可见性,查询直接抛异常
清单里权限都加了,query 依然失败,catch 里静默返回 null,现象是「明明有权限却读不到」。 Android 11 起,跨包访问 ContentProvider 必须在 <queries> 里声明可见性:
xml
<queries>
<provider android:authorities="com.miui.providers.steps" />
</queries>
2.4 坑 ②:数据源缓存了,授权后步数永远停在 0
数据源探测是带缓存 的(探测一次,get() 复用),而首次探测可能发生在运行时权限 ACTIVITY_RECOGNITION 授予之前 ------那一刻 Provider 不可达,被误判成 SENSOR,缓存就此锁死。之后就算用户授了权,本次会话仍然走传感器分支,显示 0。
修复是给仓库加一个「让缓存失效」的口子,并在授权回调里调用:
kotlin
@Volatile
private var cachedSource: StepSource? = null
val source: StepSource
get() = cachedSource ?: detectSource().also { cachedSource = it }
/** 使缓存失效,下次访问 source 时重新探测。 */
fun refreshSource() { cachedSource = null }
kotlin
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
// 首次授权后 MIUI 计步 Provider 才可达,需重新探测数据源,否则会停在传感器的 0。
if (granted) viewModel.recheckDataSource()
}
教训:任何「运行时才可达的外部能力」的探测结果都不该被永久缓存,一定要留一个主动失效的入口。
三、天气:不注册、不要 API Key 的免费组合
想给卡片加个温度,但又不想为了 demo 去申请 key。这两个接口可以白嫖:
- Open-Meteo :
/v1/forecast?latitude=..&longitude=..¤t=temperature_2m,weather_code,返回 WMO 天气代码 - BigDataCloud :
reverse-geocode-client,把经纬度变成「省份 · 区县」,支持localityLanguage=zh
网络层刻意没用 OkHttp / Retrofit,HttpURLConnection 8 秒超时足够,少一个依赖:
kotlin
private fun httpGet(url: String): String {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.requestMethod = "GET"
conn.setRequestProperty("Accept", "application/json")
return try {
conn.inputStream.bufferedReader().use { it.readText() }
} finally {
conn.disconnect()
}
}
天气代码到中文 + emoji 的映射,直接省掉一套图标资源:
kotlin
internal fun describeWeather(code: Int): Pair<String, String> = when (code) {
0 -> "晴" to "☀️"
1 -> "大部晴朗" to "🌤️"
2 -> "局部多云" to "⛅"
3 -> "阴" to "☁️"
45, 48 -> "雾" to "🌫️"
61, 63 -> "雨" to "🌧️"
71, 73 -> "雪" to "❄️"
95 -> "雷暴" to "⛈️"
else -> "未知" to "❔"
}
定位这块我定了一条原则:拿不到真实定位就绝不编造 。 早期版本有个「默认北京·东城区」的兜底,结果用户在内测时截图问我「为什么我在成都它显示北京」------宁可显示「未获取定位」,也不要给用户一个看起来很可信的假数据。
kotlin
suspend fun load(fresh: Boolean): WeatherInfo = withContext(Dispatchers.IO) {
val coords = resolveCoordsOrNull(fresh)
// 没有真实定位:不去查询(避免显示编造的"北京·东城区"),直接返回未定位状态。
if (coords == null) {
return@withContext WeatherInfo("", "未获取到定位", "📍", Double.NaN, isFallbackLocation = true)
}
val (lat, lon) = coords
val (desc, emoji, temp) = fetchWeather(lat, lon)
WeatherInfo(reverseGeocode(lat, lon) ?: "未知位置", desc, emoji, temp, false)
}
定位取的是「新鲜度优先」:先取系统缓存位置,5 分钟内算有效直接用;过期且用户主动点了刷新,才发起一次带 10 秒超时的一次性 GPS 定位。这样冷启动不会被 GPS 拖住。
写桌面卡片快照时同样有这个防御------只有拿到真实城市名才写库,否则一次失败的网络请求会把卡片上好好的城市名刷成空:
kotlin
// 只有拿到真实城市才写快照,避免把"未定位"覆盖到桌面卡片。
if (info.city.isNotBlank()) {
AppSnapshot.saveWeather(getApplication(), info)
}
四、桌面小组件:Glance 很好看,MIUI 很难搞
4.1 架构:卡片只读快照,不碰传感器和网络
GlanceAppWidget 里做传感器注册和网络请求都不合适(生命周期不可控、也不能阻塞主线程)。所以拆成一个明确的单向数据流:
markdown
App 内 ViewModel 采到数据
→ 写 AppSnapshot(SharedPreferences)
→ 主动 push 刷新卡片
→ 卡片只读快照渲染
kotlin
/**
* 桌面小组件的数据快照存储。
* 小组件(GlanceAppWidget)不便直接做传感器监听或网络请求,因此由主界面把这些
* 最新值写入 SharedPreferences,组件只读取快照并渲染。
*/
object AppSnapshot {
private const val PREFS = "widget_snapshot"
fun saveGoal(context: Context, goal: Int) { /* ... */ }
fun saveWeather(context: Context, info: WeatherInfo) { /* ... */ }
fun loadWeather(context: Context): WeatherSnapshot? { /* ... */ }
}
Double 存进 SharedPreferences 用 toRawBits(),比字符串来回转可靠:
kotlin
.putLong(KEY_WEATHER_TEMP, info.temperatureC.toRawBits().toLong())
ViewModel 每次收到新步数就顺手推一次卡片:
kotlin
observeJob = viewModelScope.launch {
repository.observeTodaySteps().collect { steps ->
_uiState.value = buildState(steps, _uiState.value.goal)
pushWidget()
}
}
4.2 坑 ③:MIUI 上 update() 调用成功,卡片却不动
这是整个项目最玄的一个问题:从 App 里调 StepWidget.update(context),日志显示完成、不报错、界面上卡片纹丝不动。
根因链条是这样的:
- MIUI 桌面会复用 Glance 会话(不是每次都重建)
- 会话复用时
update()只发一个UpdateGlanceState事件 - 项目没配
stateDefinition,glanceState恒为null→ 事件里前后值相同 → 不触发重组 - 于是 RemoteViews 从来没被重新生成
最小改动、也最可靠的绕法:造一个进程级的全局 MutableState 当版本号,让卡片内容去「读」它------读了就等于订阅了,从 App 里改它,正在运行的会话会被强制重组:
kotlin
/**
* 进程级"版本号"。WidgetContent 组合时会读取它,从而订阅这个快照状态;
* 从 App 里改写它会让正在运行的 Glance 会话重新组合并重新发布 RemoteViews。
*/
private val widgetVersion = mutableStateOf(0L)
@Composable
private fun WidgetContent(context: Context) {
// 关键:读取版本号订阅全局快照状态,值变化时本组合会被重新执行(MIUI 上驱动重绘的机制,勿删)。
@Suppress("UNUSED_EXPRESSION")
widgetVersion.value
// ...
}
光有版本号还不够------如果会话已经死了(进程被杀、卡片被系统回收),得重新绑定。所以再补一条发给自家 Receiver 的显式广播 走系统原生 onUpdate 链路:
kotlin
/**
* 强制刷新桌面卡片:给自己包内的 Receiver 发显式广播,走系统验证可用的 onUpdate 内部更新链路。
* 背景:在 MIUI 上,从进程外直接调 Glance 会话层 API 的重绘会被静默丢弃;
* 而分钟闹钟广播触发的 onUpdate 路径能正常渲染。
* 发给自家组件的显式广播不受安卓 8+ 隐式广播限制和 ROM 拦截,稳定可达。
*/
fun GlanceAppWidget.sendSelfUpdateBroadcast(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, StepWidgetReceiver::class.java)
val ids = manager.getAppWidgetIds(component)
if (ids.isEmpty()) return
val intent = Intent(context, StepWidgetReceiver::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
context.sendBroadcast(intent)
}
/** 统一刷新入口:先 bump 版本号驱动"存活会话"重组,再发广播兜底"会话已死"重绑。 */
fun GlanceAppWidget.pushUpdate(context: Context) {
widgetVersion.value = System.currentTimeMillis()
sendSelfUpdateBroadcast(context)
}
业务代码从此只调一个 pushUpdate(),两条路都覆盖。
4.3 分钟级时间刷新
卡片上有个当前时间。updatePeriodMillis 系统最小只给 30 分钟,等不起,所以自己用 AlarmManager 排一个非精确 的每分钟闹钟(非精确 → 不需要 SCHEDULE_EXACT_ALARM 这种危险权限):
kotlin
/**
* 每分钟通过 AlarmManager 唤醒,重绘桌面小部件,让卡片上的时间保持分钟级准确。
* 使用非精确的 setRepeating(60 秒),因此无需 SCHEDULE_EXACT_ALARM 权限。
* 注意:MIUI 等桌面的省电策略可能延后唤醒,属尽力而为。
*/
fun schedule(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
val first = nextMinuteMillis(System.currentTimeMillis()) // 对齐到下一个整分钟
try {
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, first, 60_000L, pendingIntent(context))
} catch (_: Exception) {
// 个别 ROM 可能限制,忽略即可
}
}
开机后闹钟会丢,所以 WidgetTickReceiver 顺手处理 BOOT_COMPLETED 重排:
kotlin
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
WidgetRefresh.schedule(context)
}
try { StepWidget.pushUpdate(context) } catch (_: Exception) { }
}
4.4 圆角渐变背景:Glance 里别用 Modifier.background
Glance 的 background(Brush.linearGradient(...)) 在低版本桌面(以及部分 ROM)上表现不稳。改成把渐变做成 shape drawable,用 Image(ImageProvider(res)) 铺在底层,圆角和渐变都由 drawable 保证:
xml
<!-- 桌面小部件背景:圆角 + 青绿→深蓝对角渐变 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="28dp" />
<gradient android:angle="135"
android:startColor="#0F6E5E" android:centerColor="#11395C" android:endColor="#0B1E33"
android:type="linear" />
</shape>
kotlin
// 背景 drawable 由用户在 App 里选定的序号决定
val bgRes = when (AppSnapshot.getWidgetBackground(context)) {
1 -> R.drawable.widget_background_2
2 -> R.drawable.widget_background_3
3 -> R.drawable.widget_background_4
else -> R.drawable.widget_background
}
Box(modifier = GlanceModifier.fillMaxSize().clickable(onClick = actionStartActivity(...))) {
Image(provider = ImageProvider(bgRes), contentDescription = null,
modifier = GlanceModifier.fillMaxSize())
Column(modifier = GlanceModifier.fillMaxSize().padding(16.dp)) { /* 内容 */ }
}
App 里给了一组色块让用户自己挑(「青绿 / 橙红 / 紫罗兰 / 海洋」),选完立刻 pushWidget(),卡片秒变。
4.5 长按图标一键加卡片
shortcuts.xml 挂一个自定义 action 的快捷方式,MainActivity 收到后调 requestPinAppWidget。这里也有个坑:在 onCreate 里立刻申请,MIUI 会静默忽略(表现为「只打开了 App,卡片没出现」)。必须等窗口真正到前台后再发:
kotlin
// 关键:等 Activity 窗口真正到前台后再申请,否则部分桌面(尤其 MIUI)
// 会忽略这次请求,表现为"只打开了 App 却没放卡片"。
window.decorView.post {
val requested = manager.requestPinAppWidget(provider, null, null)
if (!requested) showManualTip()
}
不支持的桌面给出手动路径的 Toast,别让用户干等:
kotlin
"本桌面不支持一键放置。请长按桌面空白处→『小部件』→搜索"轻盈计步"手动添加"
五、每日语音提醒:为了让它真的响,我换了三次方案
这是这个项目里投入产出比最不像 demo 的一部分。需求一句话,实现改了三轮。
5.1 需求
每天 22:30,手机自动朗读:「现在是晚上 10:30 了,给娃娃穿尿不湿了」
5.2 第一版:精确闹钟 + 透明全屏页(被自己劝退)
直觉方案:AlarmManager.setExactAndAllowWhileIdle → 广播 Receiver → 拉起透明全屏 Activity(setShowWhenLocked / setTurnScreenOn)→ 在 Activity 里播 TTS。
实现完就撞上一堆现实:
- Android 12+ 要
SCHEDULE_EXACT_ALARM,用户得去设置里给「闹钟和提醒」授权 - MIUI 要额外开「后台弹出界面」「锁屏显示」------这两个是厂商私有开关,AOSP 里根本没有对应权限名,无法用
<uses-permission>声明,也无法弹窗请求 ,只能手动开或用adb shell appops set <包名> <编号> allow(编号随 MIUI 版本变化,10020/10021 只是常见值,得以实际 ROM 为准) - 到点不响,日志一片安静。折腾半天发现 uid 一路从
10458 → 10460 → 10462------用户(我)在反复卸载重装 。而卸载重装会连坐三件事:SharedPreferences 清空(开关回关)+ 系统取消该包所有已排闹钟 + 精确闹钟特殊授权重置。只有adb install -r覆盖安装才保留数据。
5.3 第二版:WorkManager 排期,但仍拉 Activity ------ 撞上 BAL
先换成 WorkManager(下面解释为什么它明显更合适),触发后依旧用老思路拉起透明播报页。日志显示 Worker 一秒不差地触发了,然后系统把死因直接写脸上:
less
16:09:00.048 ReminderTask D Worker 触发,准备拉起播报页
16:09:00.062 ActivityTaskManager E Background activity launch blocked! goo.gle/android-bal
callingUidProcState: TRANSIENT_BACKGROUND ... resultIfPiCreatorAllowsBal: BAL_BLOCK
Android 12+ 的 BAL(Background Activity Launch)限制 :应用处于后台时 startActivity 被系统拦截,而且 startActivity 不抛异常、静默失败。这不是 MIUI 的锅,原生就拦,所以「后台弹出界面」白名单也救不了它。
5.4 第三版:釜底抽薪------Worker 里直接播 TTS
关键领悟:根本不需要 Activity。WorkManager 触发时进程本来就活着,而**「后台播放声音」不受 BAL 限制**。透明页存在的唯一意义是「亮屏解锁」,但用户其实只要听到声音就够了。
于是最终形态:
kotlin
/** 到点被 WorkManager 拉起:后台播报该任务的文案,并排定它的下一次。 */
class DailyReminderWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = inputData.getString(ReminderTasks.KEY_TASK_ID) ?: return Result.success()
val task = ReminderTasks.find(applicationContext, id)
if (task == null || !task.enabled) return Result.success()
Log.d("ReminderTask", "Worker 触发 [${task.action}]")
// 后台播声音不受"后台启动 Activity(BAL)"限制,无需弹窗页面。
ReminderSpeech.speakBlocking(
applicationContext,
ReminderTasks.buildText(task.hour, task.minute, task.action),
)
// 记录今日已执行,界面据此显示"已执行"。
ReminderTasks.markRun(applicationContext, task.id)
// 每日重复:本次触发后排定该任务的下一次。
ReminderScheduler.schedule(applicationContext, task)
return Result.success()
}
}
CoroutineWorker 里播 TTS 要挂起等读完 再退出,否则 Worker 一结束 TTS 就被掐了:用 CompletableDeferred + withTimeout 把回调式 API 变成可等待的挂起调用:
kotlin
/** 在任意进程状态下用 TTS 朗读并等待完成;失败时兜底响系统闹铃。 */
suspend fun speakBlocking(context: Context, text: String) {
val finished = CompletableDeferred<Boolean>()
var tts: TextToSpeech? = null
withContext(Dispatchers.Main) {
tts = TextToSpeech(app) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.setLanguage(Locale.CHINESE)
val listener = object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit
override fun onDone(utteranceId: String?) { finished.complete(true) }
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) { finished.complete(false) }
}
tts?.setOnUtteranceProgressListener(listener)
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "daily_task_bg")
} else {
Log.e("ReminderTask", "后台 TTS 初始化失败 status=$status")
finished.complete(false)
}
}
}
val spoken = try {
withTimeout(30_000L) { finished.await() }
} catch (t: Throwable) {
Log.e("ReminderTask", "播报超时", t); false
}
withContext(Dispatchers.Main) { try { tts?.shutdown() } catch (_: Exception) { } }
if (!spoken) playRingtoneBlocking(app) // 兜底,别让提醒静默丢失
}
两个容易忽略的细节:
UtteranceProgressListener的回调在 binder 线程 ,里面要碰 UI 必须runOnUiThread- TTS 万一不可用(没装引擎 / 缺中文数据),用
RingtoneManager响 5 秒系统闹铃兜底------提醒类产品最怕的不是难听,而是静默失败
5.5 为什么 WorkManager 在这里比闹钟好
kotlin
/** WorkManager 排期:每个任务按 id 独立排期,REPLACE 保证每个任务只有一个待触发实例。 */
object ReminderScheduler {
private fun uniqueName(id: String) = "daily_voice_reminder_$id"
/** 排定该任务的下一次触发(今天未到用今天,否则用明天)。 */
fun schedule(context: Context, task: VoiceTask) {
val next = nextTriggerMillis(task.hour, task.minute)
val delay = (next - System.currentTimeMillis()).coerceAtLeast(0L)
val request = OneTimeWorkRequestBuilder<DailyReminderWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.setInputData(workDataOf(ReminderTasks.KEY_TASK_ID to task.id))
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(uniqueName(task.id), ExistingWorkPolicy.REPLACE, request)
}
private fun nextTriggerMillis(hour: Int, minute: Int): Long {
val cal = Calendar.getInstance()
cal.set(Calendar.HOUR_OF_DAY, hour); cal.set(Calendar.MINUTE, minute)
cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= System.currentTimeMillis()) cal.add(Calendar.DAY_OF_YEAR, 1)
return cal.timeInMillis
}
}
「每天重复」这件事,WorkManager 没有 PeriodicWorkRequest 那种「最小 15 分钟周期且无法对齐时刻」的尴尬写法,而是:
OneTimeWorkRequest+setInitialDelay(距离下次触发的时间),Worker 干完活再给自己排下一次 ------ 天然形成每日循环,且每次都能对齐到准确的时:分。
代价是触发时间可能偏差几分钟。对一个「提醒给娃穿尿不湿」的场景完全可接受,我直接把这句话写在 UI 上,用户就不会来问「为什么晚了 3 分钟」:
kotlin
Text("WorkManager 驱动,触发可能偏差几分钟", color = colors.textSecondary, fontSize = 11.sp)
换来的收益很实在:不需要精确闹钟授权、不需要开机广播、重启后任务自动恢复、MIUI 对 JobScheduler 比对 AlarmManager 宽容得多。
5.6 坑 ④:TTS 初始化返回 -1
点「立即测试播报」没声音,日志:
ini
TTS 初始化失败 status=-1
stop failed: not bound to TTS engine
又是包可见性 。Android 11+ 想绑定系统 TTS 引擎服务,必须在 <queries> 里声明------和前面计步 Provider 是同一个坑的第二张面孔:
xml
<queries>
<provider android:authorities="com.miui.providers.steps" />
<!-- Android 11+ 绑定系统 TTS 引擎服务也需声明可见,否则初始化返回 -1 -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
5.7 多任务:把「一个提醒」升级成「提醒清单」
单任务用久了必然不够用(娃的、老人的、吃药的)。模型升级成列表:
kotlin
/** 一条每日语音任务:每天 hour:minute 朗读 action。 */
data class VoiceTask(
val id: String, // UUID,也是 WorkManager 唯一任务名的一部分
val hour: Int,
val minute: Int,
val enabled: Boolean,
val action: String, // "给娃娃穿尿不湿"
)
存储用 SharedPreferences + JSON 数组(数据量小,犯不上引 Room):
kotlin
fun tasks(context: Context): List<VoiceTask> {
migrateIfNeeded(context)
val raw = prefs(context).getString(KEY_TASKS, "[]") ?: "[]"
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
VoiceTask(o.getString("id"), o.getInt("hour"), o.getInt("minute"),
o.getBoolean("enabled"), o.optString("action", DEFAULT_ACTION))
}
} catch (t: Throwable) {
Log.e("ReminderTask", "任务列表解析失败", t); emptyList()
}
}
增删改都收敛到两个方法,并且顺手把排期一起做掉------保证「存储状态」和「调度状态」不会不一致:
kotlin
/** 新增或更新任务,并按开关状态立即(重新)排期。 */
fun upsert(context: Context, task: VoiceTask) {
val list = tasks(context).toMutableList()
val idx = list.indexOfFirst { it.id == task.id }
if (idx >= 0) list[idx] = task else list.add(task)
persist(context, list)
if (task.enabled) ReminderScheduler.schedule(context, task)
else ReminderScheduler.cancel(context, task.id)
}
旧版单任务数据在首次访问时一次性迁移 成列表第一条(用 migrated_v2 标记防止重复迁移),老用户不用重设:
kotlin
private fun migrateIfNeeded(context: Context) {
val p = prefs(context)
if (p.getBoolean(KEY_MIGRATED, false)) return
if (p.contains(KEY_ENABLED) || p.contains(KEY_HOUR)) {
val legacy = VoiceTask(UUID.randomUUID().toString(),
hour = p.getInt(KEY_HOUR, 22), minute = p.getInt(KEY_MINUTE, 30),
enabled = p.getBoolean(KEY_ENABLED, false), action = DEFAULT_ACTION)
persist(context, listOf(legacy))
}
p.edit().putBoolean(KEY_MIGRATED, true)
.remove(KEY_ENABLED).remove(KEY_HOUR).remove(KEY_MINUTE).apply()
}
App 启动时再把所有启用任务补排一遍,作为重装 / 系统清理后的兜底:
kotlin
/** App 启动时把所有启用的任务补排一遍(重装/系统清理后的兜底)。 */
fun ensureAllScheduled(context: Context) {
tasks(context).filter { it.enabled }.forEach { ReminderScheduler.schedule(context, it) }
}
5.8 文案跟着时间自动生成
第一版把整句话当字符串存着,改完时间就出现「设定 15:10,播报却说晚上 10:30」的尴尬。正确做法是文案不单独存,由设定的时/分生成:
kotlin
/** "现在是晚上10点半了,给娃娃穿尿不湿"风格的口语化文案。 */
fun buildText(hour: Int, minute: Int, action: String = DEFAULT_ACTION): String {
val period = when (hour) {
in 0..5 -> "凌晨"; in 6..11 -> "上午"; in 12..17 -> "下午"; in 18..22 -> "晚上"; else -> "深夜"
}
val h12 = when {
hour == 0 || hour == 12 -> 12
hour > 12 -> hour - 12
else -> hour
}
val timePart = when (minute) {
0 -> "${h12}点"; 30 -> "${h12}点半"; else -> "${h12}点${minute}分"
}
return "现在是${period}${timePart}了,${action}"
}
| 设定 | 自动播报 |
|---|---|
| 22:30 | 现在是晚上10点半了,给娃娃穿尿不湿 |
| 07:00 | 现在是上午7点了,给娃娃热牛奶 |
| 15:10 | 现在是下午3点10分了,收阳台衣服 |
用户只填「做什么」,「几点」由系统说------数据源唯一,永远不会自相矛盾。
5.9 坑 ⑤:任务真执行了,界面却还写着「今日未执行」
Worker 播报后写库,界面读库判断,逻辑看着没毛病,但 UI 不翻转:
kotlin
val done = ReminderTasks.lastRun(context, task.id) == today // ❌ 不刷新
因为 lastRun() 是普通函数调用,不是 Compose 状态读取 ,不注册失效依赖。Worker 在后台协程里往 SharedPreferences 写东西时,没有任何 State 发生变化,这个组合项永远停在首次组合时算出的结果。附带还有第二个问题:val today = remember { Date() } 只算一次,跨午夜也不会重置。
修法是引入一个自增的「刷新节拍」,把读存储的动作挂到一个会变化的 state 上:
kotlin
// Worker 是在后台往 SharedPreferences 里写执行日期的,直接调 lastRun() 不是状态读取,
// 不会触发重组;所以用一个自增的刷新节拍把它变成 Compose 能观察到的 state。
var refreshAt by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(2_000L)
refreshAt = System.currentTimeMillis()
}
}
val today = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(refreshAt))
val doneIds = remember(tasks, refreshAt) {
tasks.filter { ReminderTasks.lastRun(context, it.id) == today }.map { it.id }.toSet()
}
// 列表项里:val done = task.id in doneIds
顺带两个收益:日期从 refreshAt 推出来,过午夜自动回到「未执行」 ;读库收进 remember 块,不会在每次重组里散落着读磁盘。
通用结论:凡是「别的进程/组件写存储、界面要跟着变」的场景,不能直接调函数读存储,必须让读取路径经过一个会变化的
MutableState(或作为remember的 key)。
5.10 最终 UI
序号、时间、执行状态、事情描述,外加试读 / 开关 / 删除:
kotlin
tasks.forEachIndexed { index, task ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${index + 1}. ", color = colors.textSecondary, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Column(modifier = Modifier.weight(1f).clickable { editing = task }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(String.format(Locale.getDefault(), "%02d:%02d", task.hour, task.minute), ...)
val done = task.id in doneIds
Text(if (done) "今日已执行" else "今日未执行",
color = if (done) colors.highlight else colors.textSecondary, ...)
}
Text(task.action, color = colors.textSecondary, fontSize = 12.sp)
}
TextButton(onClick = { /* 试读 */ }) { Text("试读") }
Switch(checked = task.enabled, onCheckedChange = { ReminderTasks.upsert(context, task.copy(enabled = it)) })
TextButton(onClick = { ReminderTasks.delete(context, task.id) }) { Text("删除") }
}
}
编辑弹窗里时间用的是两个数字输入框直填「时 : 分」,不是表盘:
kotlin
// 输入非法时暂按草稿原值预览,保存时再用兜底修正。
val hour = hourText.toIntOrNull()?.coerceIn(0, 23) ?: draft.hour
val minute = minuteText.toIntOrNull()?.coerceIn(0, 59) ?: draft.minute
OutlinedTextField(
value = hourText,
onValueChange = { hourText = it.filter { c -> c.isDigit() }.take(2) },
label = { Text("时") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
// 下方"将播报:..."跟着输入实时预览
这个改动是被用户一句「选择时间的时候,直接输入就可以了,不需要选择时间表盘」推出来的。 给长辈用的表单里,
TimePickerDialog那个表盘比两个输入框难用得多------输 22:30 要点两轮。
六、主题:把深浅色差异收进一个语义色对象
Material3 的 ColorScheme 只能表达 role 色,但设计稿里有一堆「渐变背景、卡片描边、进度环三色渐变、火焰/步行/时钟三个统计色」是它装不下的。做法是另建一套语义色,用 CompositionLocal 下发,界面代码不出现任何硬编码的深浅色专用值:
kotlin
/**
* 界面语义色。把「渐变背景、主/次文字、卡片、进度环、强调色」等随深浅色变化的
* 颜色集中到这里,界面代码只消费 LocalAppColors,避免硬编码深浅色专用值。
*/
@Immutable
data class AppColors(
val background: List<Color>, val textPrimary: Color, val textSecondary: Color,
val highlight: Color, val cardColor: Color, val trackColor: Color,
val ringGradient: List<Color>, val buttonColor: Color, val buttonText: Color,
val accentFire: Color, val accentWalk: Color, val accentTime: Color,
val isDark: Boolean,
)
val LocalAppColors = staticCompositionLocalOf { DarkAppColors }
kotlin
// 统一使用自定义运动配色,关闭动态取色保证深浅色都符合品牌视觉
dynamicColor: Boolean = false,
两个细节:
- 深色下卡片/轨道用
Color.White.copy(alpha = 0.06f)这种半透明叠加,比另配一套深灰更贴合渐变背景 - 动态取色(Material You)主动关掉:青绿品牌色在这套视觉里是刻意的,让壁纸染色会导致同一 App 在不同手机上长得不一样
- 状态栏图标颜色要跟着反转,否则浅色主题下白图标贴在浅背景上看不见:
kotlin
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
步数环用 Canvas + drawArc 画,sweepGradient 做三色渐变,进度和数字都走 animateFloatAsState,数字不是硬跳的:
kotlin
drawArc(
brush = Brush.sweepGradient(colors = ringGradient),
startAngle = -90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
热量 / 距离 / 活跃时长是经验公式估算,不是健康平台数据,所以参数在 ViewModel 里明写着,方便后续做成设置项:
kotlin
private val bodyWeightKg = 65.0 // 体重
private val strideMeters = 0.68 // 单步长约 0.68m
private val cadenceStepsPerMin = 100.0 // 中等配速,用于活跃时长估算
val calories = steps * 0.5 * bodyWeightKg * strideMeters / 1000.0 * 0.9
val distance = steps * strideMeters / 1000.0
val activeMinutes = (steps / cadenceStepsPerMin).toInt()
七、踩坑速查表
我把整个开发过程里所有「日志看不懂 / 文档里没写」的问题整理成一张表。如果你在做类似的东西,建议先对着这张表自查一遍。
| # | 现象 | 真因 | 解法 |
|---|---|---|---|
| 1 | 有权限却读不到 MIUI 计步库 | Android 11+ 包可见性 | <queries><provider android:authorities="com.miui.providers.steps"/></queries> |
| 2 | 授权后步数永远显示 0 | 数据源探测结果被永久缓存 | refreshSource() 使缓存失效 + 授权回调里调 recheckDataSource() |
| 3 | TTS 初始化 status=-1 |
同上,未声明 TTS 服务可见 | <intent><action android:name="android.intent.action.TTS_SERVICE"/></intent> |
| 4 | 卡片调 update() 成功却不刷新 |
MIUI 复用 Glance 会话;无 stateDefinition 时 state 恒 null,不触发重组 |
全局 mutableStateOf 版本号驱动重组 + 显式自广播兜底 |
| 5 | 长按快捷方式「只开 App 不放卡片」 | MIUI 忽略窗口未前台时的 requestPinAppWidget |
window.decorView.post { ... } 延后申请 |
| 6 | Worker 到点触发但没界面没声音 | BAL 限制 :后台 startActivity 被静默拦截(不抛异常) |
不拉 Activity,Worker 里直接播 TTS(后台播声音不受限) |
| 7 | 到点完全不触发且日志全无 | 反复卸载重装:prefs 清空 + 该包闹钟被系统取消 + 授权重置 | 用 adb install -r 覆盖安装;启动时 ensureAllScheduled 补排 |
| 8 | 界面显示「今日未执行」但任务确实跑了 | 直接调函数读 SharedPreferences 不是 Compose 状态读取,不注册失效依赖 | refreshAt 节拍 state + remember(key) 派生 |
| 9 | 设定 15:10 却播报「晚上10:30」 | 文案作为独立字符串存储,与时间不同步 | 文案由 buildText(hour, minute, action) 实时生成 |
| 10 | 「后台弹出界面」「锁屏显示」找不到对应权限 | MIUI 厂商私有开关,AOSP 无对应权限名 | 只能手动开或 adb shell appops set(编号因 ROM 版本而异);别指望清单声明 |
八、如果继续做下去
- 个性化参数设置:体重 / 步长 / 目标写死在 ViewModel,应做成设置页并持久化
- 历史趋势:现在只有「今天」,把每日步数按天落库(Room)后可以做周/月折线
- 提醒的确认闭环:播报后允许「一键完成」,而不只是记录系统触发
- 静默时段:夜里播报应该走震动或压低音量,现在会吵到家人
- 桌面卡片点天气直接跳详情:现在整张卡片只会打开 App
九、写在最后
这个 App 的功能清单摊开看,没有任何一项是「高级」的:读个数据库、发个网络请求、画个环、放张卡片、响一句语音。
但它让我确认了三件在做 Android 时很容易被低估的事:
- 国行 ROM 是一个独立的平台。 同一个逻辑在原生模拟器上好好的,到 MIUI 上就会遇到「会话复用导致不重组」「快捷方式请求被忽略」「私有权限开关无处声明」。做国内用户的产品,真机验证不是可选项。
- Android 的权限模型这两年收紧得很快,而且收紧的方式是「静默失败」。 BAL 拦截
startActivity不抛异常、包可见性让query直接失败、TTS 初始化默默返回 -1。写代码时必须主动配<queries>、主动看 logcat,不能等异常。 - 方案的正确答案往往不是第一直觉。 这个语音提醒,我先做精确闹钟(撞授权墙),再做 WorkManager + Activity(撞 BAL),最后才发现「Worker 里直接播声音」既不需要任何特殊权限、又能一秒不差。先问「最小可用是哪条链路」,再往上加复杂度,比反过来砍要省事得多。
代码不多、依赖很少(无第三方网络库、无 Room、无 DI),作为一个能跑在真机上的完整小项目,拿来做 Compose + Glance + WorkManager 的入门练手刚好。有兴趣可以自己 clone 下来改,或者把这张清单里的第 6、8 两条先踩一遍再动手。
有问题欢迎评论区聊。
附:清单权限与配置片段(便于对照自查)
xml
<!-- 计步 -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="miui.permission.READ_STEPS" />
<!-- 天气:联网 + 定位 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 桌面卡片分钟刷新:开机后重排闹钟 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 语音提醒(WorkManager 驱动,无需精确闹钟权限)配套 -->
<uses-permission android:name="android.permission.TURN_SCREEN_ON" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 关键:Android 11+ 跨包访问 Provider / 绑定 TTS 引擎都要声明可见性 -->
<queries>
<provider android:authorities="com.miui.providers.steps" />
<intent><action android:name="android.intent.action.TTS_SERVICE" /></intent>
</queries>
<!-- 声明计步传感器为可选,避免在无传感器设备上被应用商店过滤 -->
<uses-feature android:name="android.hardware.sensor.stepcounter" android:required="false" />
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false" />
xml
<!-- 桌面卡片配置:updatePeriodMillis 系统最小 30 分钟,分钟级刷新必须自己排闹钟 -->
<appwidget-provider
android:minWidth="180dp" android:minHeight="110dp"
android:targetCellWidth="3" android:targetCellHeight="2"
android:updatePeriodMillis="1800000"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:initialLayout="@layout/glance_default_loading_layout" />