安卓专注应用「喵时光」:灵动岛接入与可爱化改造复盘

文章目录 10 个章节

「喵时光」是我写的一个本地安卓专注应用(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,流体云胶囊出现。几个实测反直觉点:

  1. 胶囊只认 shortCriticalText。不设它时系统回退到 contentTitle 并硬截断(「专注中 · 自由专注」变成「专注中·自田」),不会用 when+chronometer 兜底——虽然 AOSP 文档说未来 2 分钟的倒计时可以走 chronometer。胶囊宽度约 96dp,短文本要求 7 字符以内,最终定案显示剩余分钟数。
  2. 分钟数刷新会被待机分桶推迟。setAndAllowWhileIdle 的分钟级闹钟,在应用退后台进入深度分桶后被推迟了近三天(dumpsys alarm 里 adjustment=+2d23h56m),胶囊数字就停在旧值。开启前台服务后分桶保持活跃,刷新恢复可靠——这是悬浮窗功能的意外收获。
  3. 不要用 setAlarmClock 刷分钟。它确实豁免分桶推迟,但每分钟登记用户闹钟会触发 ColorOS「三方应用异常行为」告警;而且 Android 14+ 上 setAlarmClock 同样要 SCHEDULE_EXACT_ALARM 权限,没有就 SecurityException 直接崩(这个崩溃循环被用户感知为「点几下就闪退、卡在开屏」)。已回退为普通 while-idle 闹钟 + 尽力而为。
  4. 取整方向要和悬浮窗一致:胶囊用向下取整(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 栈里几百层重复帧基本就是递归/循环依赖,直接看栈顶即可定性。

七、经验清单

  1. 厂商"灵动岛"没有统一 API:ColorOS 16 走原生 Live Updates,小米走 extras JSON,vivo/荣耀只有推送通道。离线应用按厂商逐一适配,最后用悬浮窗兜底。
  2. setRequestPromotedOngoing 只是请求:POST_PROMOTED_NOTIFICATIONS 权限、ongoing、contentTitle、富样式(ProgressStyle 等)缺一个都上不了岛。
  3. ColorOS 状态胶囊只认 shortCriticalText,不要指望 chronometer 兜底;文本必须短。
  4. 前台服务是后台刷新可靠性的分水岭:没有它,分钟级闹钟会被分桶推迟到"三天后";有了它,同一条通知顺便活了。
  5. setAlarmClock 别滥用:既触发厂商安全告警,又要精确闹钟权限,没有就崩。
  6. FGS 启动路径零挂起:startForeground 必须同步、第一时间、带防护。
  7. Kotlin 局部函数会遮蔽 apply receiver 的同名成员:apply { ...; start() } 调用的可能是外层局部函数,递归到栈爆。命名错开是最便宜的防御。
  8. 排查卡死先看 am_anr 的 cause 广播和 dropbox 的 ANR 栈,比反复重启盲试快得多。

代码仓库:wildalley/miao-time(私有),厂商适配层在 timer/VendorIsland.kt,悬浮窗在 timer/FloatingTimerService.kt。