贴心使用的语音生活小助手

我用 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=..&current=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),日志显示完成、不报错、界面上卡片纹丝不动。

根因链条是这样的:

  1. MIUI 桌面会复用 Glance 会话(不是每次都重建)
  2. 会话复用时 update() 只发一个 UpdateGlanceState 事件
  3. 项目没配 stateDefinition,glanceState 恒为 null → 事件里前后值相同 → 不触发重组
  4. 于是 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 时很容易被低估的事:

  1. 国行 ROM 是一个独立的平台。 同一个逻辑在原生模拟器上好好的,到 MIUI 上就会遇到「会话复用导致不重组」「快捷方式请求被忽略」「私有权限开关无处声明」。做国内用户的产品,真机验证不是可选项。
  2. Android 的权限模型这两年收紧得很快,而且收紧的方式是「静默失败」。 BAL 拦截 startActivity 不抛异常、包可见性让 query 直接失败、TTS 初始化默默返回 -1。写代码时必须主动配 <queries>、主动看 logcat,不能等异常。
  3. 方案的正确答案往往不是第一直觉。 这个语音提醒,我先做精确闹钟(撞授权墙),再做 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" />
相关推荐
mmsx2 小时前
Android 状态保持:进程被杀、旋屏与后台任务的存活术
android·kotlin
九皇叔叔2 小时前
【10】SpringBoot4 MyBatisPlus 枚举类型(enum)
android
yubang32231112 小时前
Android保活sdk
android·gitee·保活
2501_916007472 小时前
苹果应用商店App Store上架费用标准及原因解析
android·ios·小程序·https·uni-app·iphone·webview
脚踏实地,坚持不懈!3 小时前
Linux 内核源码解析:从 secondary_startup_64 到 pick_eevdf 的完整调用栈分析
android·linux·arm开发
事圆则缓3 小时前
面向对象六大基本原则:从概念到 Android 实战
android
程序员-珍4 小时前
关于协程相关问题
android·安卓
wdfk_prog4 小时前
Wi-Fi Direct 教程 04:Interface 协议子系统初始化——WPA/EAPOL、WPS、DPP/NAN、GAS 与 P2P callback
android·运维·服务器·ubuntu·p2p·wps·wifi-direct
警醒与鞭策13 小时前
【无标题】
android·unity·性能优化·游戏引擎·perforce