「喵时光」是我写的一个本地安卓专注应用(Compose、无网络权限、无账号体系,仓库在
~/dev/miao-time)。这篇文章复盘最近一轮改造:把 UI 换成可爱风、把倒计时通知接入 OPPO 流体云 / 小米超级岛、用悬浮窗兜底 vivo 这类没有本地 API 的厂商,以及过程中踩到的两个把整机拖死的深坑。所有结论都在一台 OPPO PJZ110(ColorOS V16.1.0,Android 16)上实机验证过。
一、可爱化改造:集中改主题层,全局生效
改造原则是只动主题定义,不动页面代码:
- 颜色集中在
MiaoPalette(浅色樱花奶油 / 深色梅子夜),页面里全部通过MiaoColors.*引用,换掉一处定义全应用生效。主色从松绿换成草莓粉时,按对比度选了#C9497A(白字约 4.5:1)。 - 字体全局换系统圆体
sans-serif-round(minSdk 26 可用,缺失时Typeface.create自动回退),倒计时数字加fontFeatureSettings = "tnum"防止走秒抖动。 - 启动器换成自适应图标(矢量猫头 + 樱花粉底 + monochrome 层),顺带把通知小图标从秒表换成猫头剪影——状态栏、进度条 tracker 全部跟着变猫。
有一个细节:素材里的猫插画是 yuv420p 的 webp,没有透明通道,浅色底在深色模式下是一块突兀的方疤。UI 层 clip(CircleShape) 裁成圆形徽章是最省事的修法。
UI 测试断言了按钮文案(如「暂停专注」),所以这轮只做视觉不动文案,全部测试原样通过。
二、灵动岛调研:每家一条路,先看结论
| 厂商 | 接入方式 | 本地离线应用可行性 |
|---|---|---|
| OPPO ColorOS 16 | Android 16 原生 Live Updates | 支持:通知 API 即可,见下文 |
| 小米 HyperOS 2/3 | 客户端本地实现:Notification extras 写 miui.focus.param |
可行:代码可行,实际上岛需平台审核 |
| vivo 原子岛 | 仅推送通道模板 | 不支持:无本地 API |
| 荣耀/华为实况窗 | 厂商 SDK / 推送通道 | 暂未接入 |
对无网络权限的本地应用,能走的只有「客户端本地通知」路径。
三、ColorOS 流体云实战:五个准入条件和一个反直觉结论
ColorOS 16 宣称完整兼容 Android 16 原生 Live Updates,但仅靠文档第一印象做不出来。实测(dumpsys notification 对照)总结出准入条件:
// Manifest
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
// 通知构建(NotificationCompat 1.17.0+)
builder(CHANNEL_FOCUS_ACTIVE_LIVE)
.setContentTitle(title) // 必须有 contentTitle
.setOngoing(true) // 必须 ongoing
.setRequestPromotedOngoing(true) // 请求提升
.setShortCriticalText(chipText) // 状态胶囊文本,见下文
.setStyle(NotificationCompat.ProgressStyle() // 富样式是准入条件之一
.setProgressTrackerIcon(IconCompat.createWithResource(ctx, R.drawable.ic_notification))
.addProgressSegment(Segment(elapsedSec).setColor(0xFFFF9EBB.toInt()))
.addProgressSegment(Segment(remainingSec).setColor(0x55C9497A.toInt())))
条件齐备后 dumpsys notification 里出现 flags=...|PROMOTED_ONGOING,流体云胶囊出现。几个实测反直觉点:
- 胶囊只认
shortCriticalText。不设它时系统回退到contentTitle并硬截断(「专注中 · 自由专注」变成「专注中·自田」),不会用 when+chronometer 兜底——虽然 AOSP 文档说未来 2 分钟的倒计时可以走 chronometer。胶囊宽度约 96dp,短文本要求 7 字符以内,最终定案显示剩余分钟数。 - 分钟数刷新会被待机分桶推迟。
setAndAllowWhileIdle的分钟级闹钟,在应用退后台进入深度分桶后被推迟了近三天(dumpsys alarm里adjustment=+2d23h56m),胶囊数字就停在旧值。开启前台服务后分桶保持活跃,刷新恢复可靠——这是悬浮窗功能的意外收获。 - 不要用
setAlarmClock刷分钟。它确实豁免分桶推迟,但每分钟登记用户闹钟会触发 ColorOS「三方应用异常行为」告警;而且 Android 14+ 上setAlarmClock同样要SCHEDULE_EXACT_ALARM权限,没有就SecurityException直接崩(这个崩溃循环被用户感知为「点几下就闪退、卡在开屏」)。已回退为普通 while-idle 闹钟 + 尽力而为。 - 取整方向要和悬浮窗一致:胶囊用向下取整(22:15 → 「22 分钟」),最后不足一分钟显示「N 秒」,并由前台服务在跨分钟时主动刷新通知,两处时间同秒翻转。
四、小米超级岛:客户端 extras 方案
小米官方支持不经推送的客户端实现,构建通知后往 extras 塞一段 JSON:
notification.extras.putString("miui.focus.param", focusParam(...))
JSON 双轨兼容两代系统:
- HyperOS 2 状态栏焦点:
param.baseInfo(title/content/type)+ 通知ticker; - HyperOS 3 超级岛:
param_v2.param_island的大/小岛摘要,用等宽数字模板sameWidthDigitInfo,配timerInfo(timerType:-1倒计时、timerWhen目标时刻、timerSystemCurrent当前时刻)让系统本地走秒,不需要持续推送。
注意:官方模板库里图片类模板都要求 URL 图片资源,纯文本模板是离线应用的现实选择;真正上岛展示通常还要过小米的上岛审核。
五、悬浮窗:vivo 这类厂商的兜底方案
vivo 原子岛只有推送通道,离线应用无路可走。通用兜底是自绘悬浮窗:
- 前台服务(
specialUse类型)+TYPE_APPLICATION_OVERLAY自绘胶囊,秒级实时走秒(比系统胶囊的分钟粒度更细),轻点回应用、按住拖动; - FGS 通知复用倒计时通知(同 id
startForeground(10, ...)),不新增通知条目; - 仅会话运行中存在,暂停/结束/关开关即消失;白名单拦截页是无障碍层,天然盖住悬浮窗。
设置里默认关闭,开关打开时无权限先跳系统授权页。
六、两个深坑复盘:一个递归拖死整机,一个超时崩掉应用
坑一:环境音的无限递归(ANR + mediaserver OOM)
用户反馈「点几下闪退、卡在开屏猫头」。ANR 线程转储里主线程栈是几百层重复帧:
at com.cola.miaotime.ui.MiaoAppKt.AmbientAudio$lambda$99$lambda$98$start (×几百层)
原代码:
fun start() { // 局部函数 start()
if (enabled && player == null) {
player = MediaPlayer.create(context, R.raw.ambient)?.apply {
isLooping = true; setVolume(0.25f, 0.25f); start() // ← 解析回了局部函数自己
}
}
}
apply 里的裸 start() 被 Kotlin 解析成局部函数自己而不是 MediaPlayer.start(),而此刻 player 还没赋值(仍是 null),于是无限递归:每层新建一个 MediaPlayer,主线程卡死 9 秒被 ANR 击杀,同时把系统 mediaserver 撑到 OOM(dumpsys dropbox 里 18:44 有 mediaserver 的 SIGABRT,整机跟着卡)。这是项目里的老代码,用户打开「环境音」开关才踩中。修复是重命名局部函数并显式调用 receiver 的 start()。
坑二:FGS 启动超时崩掉整个应用
悬浮窗服务偶尔触发 ForegroundServiceDidNotStartInTimeException:startForegroundService() 之后 ~10 秒内没调 startForeground()。原代码在服务 onCreate 里先挂起读 DataStore 再进前台,读取一旦变慢就超时。修复原则:startForeground 前的启动路径上不允许任何挂起——从 stateIn(Eagerly) 的缓存值同步读当前状态,startForeground 套 runCatching(后台启动被拒时安静 stopSelf())。
诊断套路
这次排查依赖三件套:logcat -b crash(Java 崩溃)、logcat -b events 里的 am_anr / am_kill / am_proc_died(定位死法)、dumpsys dropbox --print data_app_anr(完整线程栈)。ANR 栈里几百层重复帧基本就是递归/循环依赖,直接看栈顶即可定性。
七、经验清单
- 厂商"灵动岛"没有统一 API:ColorOS 16 走原生 Live Updates,小米走 extras JSON,vivo/荣耀只有推送通道。离线应用按厂商逐一适配,最后用悬浮窗兜底。
setRequestPromotedOngoing只是请求:POST_PROMOTED_NOTIFICATIONS权限、ongoing、contentTitle、富样式(ProgressStyle 等)缺一个都上不了岛。- ColorOS 状态胶囊只认
shortCriticalText,不要指望 chronometer 兜底;文本必须短。 - 前台服务是后台刷新可靠性的分水岭:没有它,分钟级闹钟会被分桶推迟到"三天后";有了它,同一条通知顺便活了。
setAlarmClock别滥用:既触发厂商安全告警,又要精确闹钟权限,没有就崩。- FGS 启动路径零挂起:
startForeground必须同步、第一时间、带防护。 - Kotlin 局部函数会遮蔽
applyreceiver 的同名成员:apply { ...; start() }调用的可能是外层局部函数,递归到栈爆。命名错开是最便宜的防御。 - 排查卡死先看
am_anr的 cause 广播和 dropbox 的 ANR 栈,比反复重启盲试快得多。
代码仓库:wildalley/miao-time(私有),厂商适配层在 timer/VendorIsland.kt,悬浮窗在 timer/FloatingTimerService.kt。