3. Android 通知栏与前台服务通知

前台服务必须绑定一个可见通知,这是 Android 系统的硬性要求。说白了,就是告诉用户:“嘿,我还在跑呢,别杀我”。但这个通知如果处理不当,很容易跟 Compose 的渲染管线抢资源。

3.1 前台服务通知的“保命”作用

系统从 Android 8.0(API 26)开始强制要求:任何前台服务必须在 5 秒内调用 startForeground(),否则会抛 IllegalStateException。而且通知栏必须一直显示,用户能手动关闭,但服务不会因此停止。

我早年踩过这个坑——项目里有个后台音乐播放服务,开发时忘了调 startForeground,结果三星手机上频繁闪退。查日志才发现是系统杀的,原因就是没通知。后来老老实实加了通知,问题解决。

注意: 从 Android 13 开始,POST_NOTIFICATIONS 权限必须动态申请。即便你的服务是前台服务,用户拒绝权限后,startForeground() 会静默失败,通知栏不显示,但服务仍然运行——这会导致后台行为不可控。

3.2 通知的构建与渠道

创建一个前台服务通知,通常需要三步:

  1. 创建 NotificationChannel(API 26+)
  2. 构建 Notification 对象,设置标题、内容、小图标
  3. 调用 startForeground(NOTIFICATION_ID, notification)

一个典型实现如下:

// 创建通知渠道(只需一次)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channel = NotificationChannel(
        CHANNEL_ID,
        "后台任务",
        NotificationManager.IMPORTANCE_LOW
    ).apply {
        description = "用于前台服务的持久通知"
    }
    val nm = getSystemService(NotificationManager::class.java)
    nm.createNotificationChannel(channel)
}

// 构建通知
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
    .setContentTitle("正在上传文件")
    .setContentText("进度:${progress}%")
    .setSmallIcon(R.drawable.ic_upload)
    .setOngoing(true)
    .build()

// 启动前台服务
startForeground(NOTIFICATION_ID, notification)

这里有个细节:IMPORTANCE_LOW 会让通知不会弹出声音,但依然在状态栏显示。如果你的通知频繁更新,IMPORTANCE_HIGH 会频繁震动或响铃,用户体验很差。

3.3 通知更新:一个容易被忽视的性能陷阱

很多同学以为通知更新只是调一下 startForeground 传个新 notification 就完事了。其实不然——每次更新通知,系统内部会经历:

  • Binder 跨进程通信,将新的 notification 发送到 SystemUI 进程
  • SystemUI 解析 RemoteViews,重新 inflate 布局
  • 触发系统渲染管线,更新通知栏 UI

而你想想看,如果 Compose 主线程正在密集渲染一个动画(比如 Lottie 或列表快速滑动),这个时候通知每 100ms 更新一次进度,主线程就要同时处理 Compose 的测量/布局/绘制,以及 SystemUI 回调带来的 IPC 唤醒。资源竞争就这么产生了。

关键结论: 通知更新频率越高,主线程被抢占的风险越大。Compose 帧率从 60fps 掉到 30fps,往往就是这种隐藏的竞争导致的。

我项目中曾有一个实时位置追踪的前台服务,每 1 秒更新一次通知显示距离。用户反馈通知栏很流畅,但地图页面掉帧严重。抓 Trace 一看,android.app.ActivityManager.updateLruProcessLocked 和 com.android.server.notification.NotificationManagerService.enqueueNotificationInternal 占了大量时间。果断改成每 5 秒更新一次通知,页面帧率立刻恢复。

3.4 实战中的避坑经验

避免通知更新与 Compose 帧率竞争,我总结了三条原则:

原则 说明 典型反例
降低更新频率 非必要不更新,至少 1 秒以上间隔 每 200ms 更新一次下载进度
合并更新 多个数据变化时,等 500ms 窗口再刷新 进度、状态、剩余时间各发一次
避免复杂 RemoteViews 使用系统内置模板,不要自定义复杂布局 自定义通知包含 ImageView 和动画

我曾经接手过一个智能家居 App,前台服务通知里放了一个可点击的“停止”按钮,还自定义了背景色。结果每次更新通知,RemoteViews 都要重新解析,耗时暴增。后来改成系统默认布局,只用 setContentIntent 实现停止动作,性能提升明显。

小技巧: 如果必须高频更新进度(比如文件上传),可以考虑只在通知里显示“文件上传中”,具体进度放到 Activity 或 Compose 组件里展示。前台服务通知只作为保活凭据,不承载实时数据。

嗯,这里还要补充一个坑:当你更新通知时,记得检查 NotificationManager.areNotificationsEnabled()。如果用户关闭了通知权限,你频繁调 startForeground 虽然不会崩溃,但每次都会走一次无效的 Binder 调用,白白消耗 CPU 时间片。

前台服务通知与 Compose 帧率资源竞争流程 启动前台服务 创建通知渠道 构建 Notification startForeground() SystemUI 渲染通知 通知频繁更新 IPC + 主线程抢占 (Binder 调用 & RemoteViews 解析) Compose 帧率下降 降低频率 / 合并更新 注意 频繁更新通知= 多次Binder调用 + 主线程抢占

3.5 再说一个容易忽略的点

前台服务通知的 RemoteViews 默认是在主线程解析的。如果你在子线程构建 Notification,看起来没问题,但 startForeground 内部还是会走到主线程去处理 Binder 回包。所以别指望切线程能完全避免竞争——关键是减少调用次数。

我曾经在日志里看到过 BindingDied 异常,就是因为通知更新太频繁导致 SystemUI 进程 OOM 重启。系统杀掉了 SystemUI,通知栏就崩了,用户直接“白条”。还好我们当时有后台监控,发现是通知更新频率每小时超过 10 万次……

核心一句话: 前台服务通知是帮应用保活的,不是用来刷存在的。更新越少,帧率越稳。