被杀掉之后:我把 Android 的「唤起 + 常驻」重做了一遍

5 阅读39分钟

被杀掉之后:我把 Android 的「唤起 + 常驻」重做了一遍

从一条短信链接点进来,到锁屏十分钟之后进程还在不在,中间其实是一整套东西。这篇把它从头拆到尾,顺带把我用 Trae 落地时发过的提示词原样贴出来。

最近在做一个需要后台持续上报的功能,连着被同一个问题坑了好几次。用户从短信里的链接点进来,页面是开对了,按返回键却直接退回桌面;换成从通知点进来,参数又莫名丢了;等把 targetSdk 升到 34,前台服务干脆一启动就抛 SecurityException,日志里只有一行 Starting FGS requires a valid type,我对着 Manifest 看了半天没找着哪写错了。

后来才想明白,这不是三个 bug,是同一件事没设计好:怎么把 App 拉起来,和拉起来之后让它按预期接着干活,本来就该是一套架构。分开处理,一定会在冷启动、热启动、进程被回收这三个交界处漏东西。

先把话说在前面:这篇不聊双进程守护、1 像素 Activity、无声音频那些老办法。不是清高,是它们真的不工作了。Android 8 之后系统一版一版地堵,国产 ROM 的省电策略比原生还狠,费半天劲换来的往往只是「多活几分钟」,代价是耗电榜单、差评,还有合规检查时被点名。我最后走的是另一条路:让后台任务看得见、停得下,进程死了能从断点接着跑。

环境也交代一下,免得后面每段都重复:Kotlin,minSdk 26,targetSdk 35,同时要顾及 Android 16 的行为;只用 AndroidX,没引任何第三方 SDK,权限说明弹窗就是原生 AlertDialog。示例业务是一个开启后要在后台持续同步数据的功能,场景够普通,但唤起和常驻两条链路都能串起来。

先把三个词分清楚

唤起、常驻、保活,平时经常混着说,在系统眼里完全是三件事。

词想解决什么系统怎么看主要 API怎么算做对了
唤起从「进程不存在 / 在后台」变成「有界面或有任务在跑」组件启动、进程创建、任务栈管理Intent、PendingIntent、App Links、startForegroundService页面开对、参数不丢、返回栈合理
常驻用户离开界面后,任务仍按预期持续进程优先级、后台执行配额Service + startForeground、WorkManager不中断或能恢复、通知可见、用户能停
保活(旧语义)让进程尽量别被杀跟 LMK、Doze、厂商省电策略对着干双进程、1 像素、互拉……已经不可靠,也不合规

还有个更容易忽略的区分:进程「活着」其实有三种活法。

状态现象Application.onCreateActivity唤起要做的
冷启动点图标 / 点链接 / 推送执行新建完整初始化、解析 Intent、重建返回栈
温启动从最近任务切回,Activity 被回收不执行新建恢复 savedInstanceState,再解析 Intent
热启动从后台切回,singleTask 复用不执行复用走 onNewIntent,别再压一个新页面

我见过最多的线上问题,就是只在 onCreate 里读 intent.data。热启动时 Activity 被复用,走的是 onNewIntent,链接参数就这么静默丢了,而且不报任何错。后面的写法里,两个入口会调同一个 dispatch。

动手前我给自己定了几条:进程随时会死,这是前提不是意外;不能丢的进度一律落盘;所有唤起路径只准有一个入口;后台在干的事必须让用户看得见、并且能停;每个跟系统版本有关的行为都标清楚 API level。前四条决定了后面所有取舍,最后一条救过我好几次。

最后定下来的样子

flowchart TB
    subgraph T["① 触发层 Trigger"]
        T1["Launcher 图标"]
        T2["Deep Link / App Links"]
        T3["通知点击 PendingIntent"]
        T4["厂商推送 / FCM"]
        T5["BOOT_COMPLETED / Job / Alarm / Geofence"]
    end
    subgraph L["② 唤起层 Launch & Router"]
        L1["Intent 解析 + 可信校验"]
        L2["DeepLinkParser → Route"]
        L3["闸门: 登录 / 隐私弹窗 / 权限"]
        L4["Navigator + TaskStackBuilder 栈重建"]
    end
    subgraph S["③ 会话与常驻层 Session"]
        S1["ResidentService (FGS)"]
        S2["常驻通知 (用户可见 / 可停止)"]
        S3["任务状态机 StateMachine"]
        S4["StateStore 持久化 (幂等/断点)"]
    end
    subgraph D["④ 调度与恢复层 Recovery"]
        D1["WorkManager / JobScheduler"]
        D2["厂商推送唤醒"]
        D3["START_STICKY + 用户再次打开"]
        D4["电池优化 / 自启动白名单引导"]
    end
    subgraph O["⑤ 可观测层 Observability"]
        O1["结构化日志 + 埋点"]
        O2["存活时长 / 恢复次数 / 耗电"]
        O3["崩溃与 ANR 监控"]
    end
    T --> L
    L --> S
    S --> D
    D -. "进程被杀后重新拉起" .-> L
    S --> O
    L --> O
    D --> O

掘金的编辑器对 Mermaid 支持时好时坏,图出不来的话看下面这版文字就够了:

[外部触发] 图标 / H5链接 / 通知 / 推送 / 开机 / 定时任务
      │
      ▼
[唤起层]  Intent → 可信校验 → 统一路由 → 闸门(登录/合规/权限) → 页面 + 返回栈重建
      │
      ▼
[常驻层]  前台服务 + 常驻通知 + 状态机 + 断点持久化
      │
      ▼
[恢复层]  WorkManager / 推送 / START_STICKY → 状态恢复 → 幂等续跑
      │
      └────────────► [可观测层] 日志 / 存活指标 / 崩溃监控

分层其实很朴素,就是回答五个问题:谁叫我、怎么进、在的时候干什么、不在了怎么回来、到底干得怎么样。拆开不是为了画图好看,是因为每层能单独换实现——比如推送通道从一家换成另一家,只要动触发层,路由和状态机一行不用改。

层关键类只负责别让它干
触发BootReceiver、PushReceiver把外部事件转成一个受控 Intent别在广播里跑耗时逻辑、别直接开页面
唤起DeepLinkParser、LaunchRouter、Navigator校验、路由、闸门、打开页面别做网络请求、别散落 startActivity
常驻ResidentService、NotificationFactory、TaskDispatcher持有一条前台服务、维护状态机别持有 Activity、别写无界循环
恢复ResidentWatchdogWorker、StateStore、RecoveryCoordinator进程死后从落盘状态幂等恢复别指望 onTaskRemoved 里重启能成
可观测ResidentLogger、ResidentMetrics记录唤起来源、存活时长、恢复次数、失败原因别采集无关的个人信息

三条链路才是重点。

链路 A,从链接冷启动到正确页面:

sequenceDiagram
    participant U as 用户 / 外部 App
    participant S as Android 系统
    participant A as Application
    participant R as LaunchRouter
    participant G as GateKeeper
    participant N as Navigator
    participant P as 目标页面
    U->>S: 点击 https://app.example.com/task/42
    S->>S: 校验 assetlinks.json → verified
    S->>A: 冷启动进程,onCreate()
    A->>R: dispatch(intent)
    R->>R: DeepLinkParser 校验 host/参数 → Route.TaskDetail(42)
    R->>G: 是否需要登录 / 隐私弹窗 / 权限
    G-->>R: 通过(或先弹窗,把 Route 暂存到 PendingRoute)
    R->>N: open(route, buildBackStack=true)
    N->>P: TaskStackBuilder 合成 首页→详情 返回栈
    Note over P: 返回键回到首页,不是直接退桌面

链路 B,用户点「开始」:

sequenceDiagram
    participant U as 用户
    participant UI as 页面
    participant G as GateKeeper
    participant S as ResidentService
    participant N as 通知栏
    participant D as TaskDispatcher
    participant DB as StateStore
    U->>UI: 点击「开始」
    UI->>G: 先弹权限用途说明
    G-->>UI: 同意 → 申请 POST_NOTIFICATIONS / 定位权限
    UI->>S: startForegroundService(ACTION_START)
    S->>N: startForeground(id, notification, typeMask)
    S->>DB: 状态置 RUNNING,写入任务队列
    S->>D: resumePendingWork() 幂等执行
    Note over N,D: 通知常驻;用户点「停止」→ ACTION_STOP → stopSelf

链路 C,进程被回收之后:

sequenceDiagram
    participant K as 系统 LMK / 厂商省电策略
    participant S as ResidentService
    participant DB as StateStore
    participant W as WorkManager / 推送
    participant R as RecoveryCoordinator
    participant N as 通知栏
    K->>S: 进程被杀(onDestroy 可能不回调)
    Note over DB: 进度在被杀之前已 checkpoint 落盘
    W->>R: 周期任务触发 / 推送到达 / 用户再次打开 App
    R->>DB: 读取 lastState + 未完成任务队列
    R->>R: 判断是否仍在用户授权窗口内
    R->>S: startForegroundService(ACTION_RESUME)
    S->>N: 重新 startForeground + 恢复通知
    S->>DB: 从断点幂等续跑(去重 key 防重复执行)

链路 C 我改过三版。第一版全靠 START_STICKY,测试机上偶尔能活,换台机器就没了;第二版在 onTaskRemoved 里重启服务,被 ROM 直接拦;最后才改成现在这样:START_STICKY、WorkManager 兜底、推送到达、用户重新打开,四条路都收敛到同一个 RecoveryCoordinator,靠 sessionId 和 dedupeKey 保证不管哪条先到、或者几条同时到,任务只跑一次。

状态机长这样:

            ACTION_START              ACTION_PAUSE
  ┌──────┐ ───────────► ┌─────────┐ ───────────► ┌────────┐
  │ IDLE │              │ RUNNING │              │ PAUSED │
  └──────┘ ◄─────────── └─────────┘ ◄─────────── └────────┘
       ▲      ACTION_STOP       │  ▲                   │
       │                       │  │ ACTION_RESUME     │
       │                 进程被杀│  └───────────────────┘
       │                       ▼
       │                  ┌────────────┐
       └────────────────── │ RECOVERING │ ──► 校验授权窗口 ──► RUNNING / IDLE
                          └────────────┘

状态本身要落盘,每次转换都写一个 checkpoint:state、taskQueue、lastProgress、updatedAt、sessionId,再加一个 userAuthorizedUntil。最后这个字段是后来补的。早期版本没有它,用户点了「停止」,结果被看门狗悄悄拉起来又跑了一轮——功能上看着没事,合规上说不过去。

唤起:链接是怎么走到页面的

先列一下能把我叫醒的入口,以及各自的边界。

入口谁发起后台能发起吗卡在哪用来干什么
Launcher 图标用户—无常规启动
自定义 SchemeH5 / 短信 / 别的 App可以(由前台 App 发起)可能被劫持、会弹选择框;12+ 必须显式 exported内部跳转、兼容旧版
App Links(https 已验证)浏览器 / 短信 / 邮件可以得过 assetlinks.json 验证;12+ 没验证直接落浏览器;服务端不能重定向对外分享、短信引流、扫码
通知点击 PendingIntent用户点通知这是后台唤起页面最稳的合法路子12+ 要 FLAG_IMMUTABLE / FLAG_MUTABLE;14+ 后台发 PendingIntent 要显式 opt-in后台任务状态、消息提醒
厂商推送 / FCM系统级推送通道可以(系统代拉)到达不等于能直接开页面IM、监测告警
BOOT_COMPLETED系统广播可以隐式广播受限;15 起部分 FGS 类型禁止从开机广播启动重启后重新挂调度
WorkManager / JobScheduler / AlarmManager系统调度可以周期最小 15 分钟;精确闹钟要 SCHEDULE_EXACT_ALARM 等权限兜底检查、延迟任务
Geofence / ActivityRecognition系统服务可以要 while-in-use 类权限和对应 FGS 类型位置类业务
显式 Intent 拉别的 App本 App看场景11+ 要 <queries> 声明包可见性合作应用互跳
后台直接 startActivity本 App10+ 基本禁止只有有可见窗口、前台服务通知等少数豁免别用,改发通知

App Links 看着配好了,其实没生效

这块我浪费过最多时间。Manifest 里我把 https 和自定义 scheme 分成两个 filter,因为它们写在一起会产生意想不到的匹配组合:

<activity
    android:name=".ui.MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="app.example.com" />
    </intent-filter>

    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="open" />
    </intent-filter>
</activity>

服务端 https://app.example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AA:BB:CC:...:99"]
    }
  }
]

指纹这条路我踩了个很典型的坑:本地 debug 包怎么都验证不过,最后发现 assetlinks.json 里放的是上传密钥的指纹。开了 Play App Signing 就得用 Play Console 里的 App signing key,不是 upload key;指纹还要全大写、冒号分隔,debug 包想测就再加一条 debug keystore 的。另外文件必须 HTTPS 直出,任何重定向(http→https、example.com→www.example.com)都会让验证失败,Content-Type 得是 application/json,example.com. 带尾点也得返回同样内容。Android 11 及以下更狠,一个 host 验证失败会拖累全部 App Links。

12 以上每个 host 独立验证,15 以上支持 dynamic_app_link_components 动态规则,17 以上还能用 am start --debug-link 看匹配过程。但日常排错还是这三行最直接:

adb shell pm set-app-links --package com.example.app 0 all
adb shell pm verify-app-links --re-verify com.example.app
adb shell pm get-app-links com.example.app
# 看到 example.com: verified 才算过

路由只写一个,参数才不会丢

解析、可信校验、闸门、导航,这四步顺序不能换。校验放最前面是防 Intent 重定向和参数注入;闸门必须能「暂存」路由,不能一拦就丢。

// 能去哪,收敛成一个有限集合
sealed interface Route {
    data class TaskDetail(val id: String, val from: String?) : Route
    data object Monitor : Route
    data object Home : Route
}

object DeepLinkParser {
    private val HTTPS_HOSTS = setOf("app.example.com", "m.example.com")

    fun parse(uri: Uri): Route? {
        val host = uri.host ?: return null
        when (uri.scheme) {
            "https" -> if (host !in HTTPS_HOSTS) return null
            "myapp" -> if (host != "open") return null
            else -> return null
        }
        val seg = uri.pathSegments
        return when {
            seg.getOrNull(0) == "task" && seg.size >= 2 && seg[1].length <= 64 ->
                Route.TaskDetail(seg[1], uri.getQueryParameter("from"))
            seg.getOrNull(0) == "monitor" -> Route.Monitor
            else -> null   // 不认识的一律丢掉,不做兜底跳转
        }
    }
}
class LaunchRouter(
    private val session: SessionProvider,
    private val pendingRoute: PendingRouteStore,
    private val navigator: Navigator,
    private val logger: ResidentLogger,
) {
    fun dispatch(activity: FragmentActivity, intent: Intent): Boolean {
        val data = intent.data ?: return false
        val route = DeepLinkParser.parse(data)
        if (route == null) {
            logger.warn("drop_untrusted_link", mapOf("uri" to data.toString()))
            return false
        }

        // 没过闸门就先存起来,授权完再重放
        val blocker = gateOf(route)
        if (blocker != null) {
            pendingRoute.save(route)
            navigator.openGate(blocker)
            return true
        }
        navigator.open(route, buildBackStack = activity.isTaskRoot)
        logger.event("launch", mapOf("route" to route::class.simpleName.orEmpty(),
                                     "source" to (data.getQueryParameter("from") ?: "unknown")))
        return true
    }

    private fun gateOf(route: Route): Gate? = when {
        !session.isPrivacyAgreed -> Gate.Privacy
        !session.isLoggedIn -> Gate.Login
        route is Route.Monitor && !session.hasLocationPermission -> Gate.PermissionPurpose
        else -> null
    }
}
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        router.dispatch(this, intent)        // 冷 / 温启动
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)                    // 不写这行,下次取到的还是旧 Intent
        router.dispatch(this, intent)        // 热启动复用
    }
}

返回键退桌面,是唤起质量的分水岭

外部链接冷启动时,系统默认只建目标 Activity,用户一按返回就直接回桌面了。得用 TaskStackBuilder 把「首页 → 详情」这条父栈补上,配合 Manifest 里的 android:parentActivityName 才完整:

fun openTaskDetail(activity: Activity, taskId: String, buildBackStack: Boolean) {
    val detail = Intent(activity, TaskDetailActivity::class.java).apply {
        putExtra(EXTRA_TASK_ID, taskId)
    }
    if (!buildBackStack) {
        activity.startActivity(detail)
        return
    }
    TaskStackBuilder.create(activity).apply {
        addNextIntentWithParentStack(Intent(activity, MainActivity::class.java))
        addNextIntentWithParentStack(detail)
    }.startActivities()
}

launchMode 我最后选了 singleTask。standard 连点两个链接就压两个详情页;singleTop 只有栈顶才复用,栈里已经有一个实例时照样新建;singleInstance 会把任务栈割裂,最近任务里能出现两个自己,直接排除。不过 singleTask 有清栈语义,所以 onNewIntent 里那套处理不能省。另外别乱改 taskAffinity,不然「最近任务」里的表现会很怪。

后台想让用户看到东西,只能发通知

Android 10 起后台基本不能 startActivity 了,所以正确姿势是把唤起动作塞进 PendingIntent,等用户点通知再触发。

private const val CHANNEL_ID = "resident"
private const val NOTIFICATION_ID = 1001
private const val REQ_OPEN = 2001
private const val REQ_STOP = 2002

fun buildResidentNotification(context: Context, state: ResidentState): Notification {
    val open = Intent(context, MainActivity::class.java).apply {
        action = Intent.ACTION_VIEW
        data = Uri.parse("https://app.example.com/monitor?from=notification")
        flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
    }
    val openPi = TaskStackBuilder.create(context).run {
        addNextIntentWithParentStack(open)
        getPendingIntent(
            REQ_OPEN,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
    }

    // 给用户一个明确的「停止」出口,这条不能省
    val stop = Intent(context, ResidentService::class.java).apply { action = ACTION_STOP }
    val stopPi = PendingIntent.getService(
        context, REQ_STOP, stop,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    return NotificationCompat.Builder(context, CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_stat_monitor)
        .setContentTitle("数据同步正在运行")
        .setContentText("已持续 ${state.minutes} 分钟 · 点此查看")
        .setContentIntent(openPi)
        .addAction(R.drawable.ic_stop, "停止", stopPi)
        .setOngoing(true)
        .setOnlyAlertOnce(true)
        .setCategory(NotificationCompat.CATEGORY_SERVICE)
        .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
        .build()
}

逐版本要记的几条:8.0 通知渠道必须先建,且在 startForeground 之前;12 的 PendingIntent 不写可变性直接抛 IllegalArgumentException;13 的 POST_NOTIFICATIONS 被拒时通知不可见,得有降级;14 想在后台用 PendingIntent.send() 带后台启动 Activity 权限,得显式 ActivityOptions.setPendingIntentBackgroundActivityStartMode(MODE_BACKGROUND_ACTIVITY_START_ALLOWED),跨进程 bindService 则用 BIND_ALLOW_ACTIVITY_STARTS;14 动态注册的广播还要标 RECEIVER_EXPORTED / RECEIVER_NOT_EXPORTED;16 又加固了 Intent 重定向,所有外部传进来的 Uri 更得先过白名单。

开机广播只能用来挂调度

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<receiver
    android:name=".trigger.BootReceiver"
    android:exported="true"
    android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
    </intent-filter>
</receiver>
class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
        // 只把兜底调度重新挂上,不在这里启服务、不开页面、不跑长任务
        RecoveryCoordinator.scheduleWatchdog(context.applicationContext)
    }
}

这么克制是有原因的。8.0 之后大部分隐式广播静态注册失效,BOOT_COMPLETED 是少数还能用的白名单;15 起从开机广播启动 dataSync、mediaProcessing、camera、microphone 这些类型的前台服务被禁止;国产 ROM 的「自启动管理」默认关着,广播能不能送到本身就不确定;onReceive 的执行窗口又很短,稍微多做点就 ANR。所以开机事件在架构里的定位就是重新注册调度,续跑交给别的路。

唤起这层我一般这么验:

# 冷启动
adb shell am force-stop com.example.app
adb shell am start -W -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE -d "https://app.example.com/task/42?from=sms"

# 不 force-stop 再发一次,看 onNewIntent 有没有丢参数、有没有重复压页
adb shell am start -W -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE -d "https://app.example.com/task/99?from=push"

# 恶意参数应该被静默丢掉,不打开任何页面
adb shell am start -W -a android.intent.action.VIEW -d "https://evil.example.com/task/1"

adb shell dumpsys activity activities | grep -i "com.example.app"
adb shell dumpsys notification | grep -i "com.example.app"

常驻:前台服务到底能撑多久

先接受「它随时会死」

机制干了什么对我的影响
进程优先级 / oom_adj前台 > 可见 > 服务 > 缓存,内存紧张时 lmkd 从低往高杀startForeground 只是把优先级抬上去,不是给进程上了锁
Doze / App Standby Buckets静止灭屏时限网络、Job、Alarm,按使用频次分桶轮询和固定心跳必然不稳,得接受延迟
8.0 后台执行限制后台不能随便 startService用 startForegroundService,且 5 秒内必须 startForeground,否则 ForegroundServiceDidNotStartInTimeException
12 后台启动 FGS 限制应用在后台时不能起前台服务ForegroundServiceStartNotAllowedException,只能靠用户动作、通知交互或少数豁免
14 / 15 / 16 的类型与配额类型强制声明、dataSync 时长上限、job 配额下面单独说
国产 ROM 省电策略自启动管理、后台耗电限制、冻结同一份代码,Pixel 能跑十几小时,某些 ROM 半小时就没

想通这点之后,目标就从「别被杀」换成了「死了能回来」。链路 C 就是为这个存在的。

权限和类型,Manifest 与代码要对得上

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<application ...>
    <service
        android:name=".resident.ResidentService"
        android:exported="false"
        android:foregroundServiceType="dataSync|location" />
</application>

exported="false" 是默认值就该这样,这个服务只该由自己启动。类型写成 dataSync|location,代码里的 typeMask 就得一模一样,不然 14 以上直接 SecurityException——开头说的那个坑,根因就在这儿。

类型怎么选,我整理了一张表放在手边,每次加后台任务都先对一遍:

typeManifest 权限场景运行时前提限制
dataSyncFOREGROUND_SERVICE_DATA_SYNC上传、同步、备份—15 起每日累计 6 小时
mediaProcessingFOREGROUND_SERVICE_MEDIA_PROCESSING媒体转码(15 新增)—同样 6 小时
locationFOREGROUND_SERVICE_LOCATION导航、轨迹定位权限 + while-in-use 条件后台启动受限
healthFOREGROUND_SERVICE_HEALTH健康 / 运动监测ACTIVITY_RECOGNITION 等while-in-use 限制
camera / microphone对应类型权限录制、通话CAMERA / RECORD_AUDIO后台不可启用
mediaPlaybackFOREGROUND_SERVICE_MEDIA_PLAYBACK音乐、播客——
phoneCallFOREGROUND_SERVICE_PHONE_CALLVoIPMANAGE_OWN_CALLS 或拨号器角色—
connectedDeviceFOREGROUND_SERVICE_CONNECTED_DEVICE蓝牙 / 穿戴设备蓝牙权限—
remoteMessagingFOREGROUND_SERVICE_REMOTE_MESSAGING跨设备消息同步——
shortService不用额外类型权限短时不可中断任务约 3 分钟,不能和别的类型组合到点得自己停
specialUseFOREGROUND_SERVICE_SPECIAL_USE确实没有匹配类型—Play 要提交用途声明,滥用会被拒
mediaProjectionFOREGROUND_SERVICE_MEDIA_PROJECTION录屏 / 投屏14+ 每次会话重新授权不能缓存授权 Intent
systemExemptedFOREGROUND_SERVICE_SYSTEM_EXEMPTED系统 / 特权场景要系统豁免普通应用拿不到

一个服务只挂它真正需要的类型。为了「更稳」多写几个,权限审查、Play 声明、运行时校验都会跟着变麻烦,specialUse 更是能不用就不用。

服务本体

class ResidentService : Service() {

    private lateinit var stateStore: StateStore
    private lateinit var dispatcher: TaskDispatcher

    override fun onCreate() {
        super.onCreate()
        createNotificationChannel()          // 要早于 startForeground
        stateStore = StateStore(applicationContext)
        dispatcher = TaskDispatcher(stateStore)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            ACTION_STOP -> {
                stateStore.transition(State.IDLE)
                stopForeground(STOP_FOREGROUND_REMOVE)
                stopSelf()
                return START_NOT_STICKY        // 用户主动停的,别让系统重建
            }
            ACTION_PAUSE -> stateStore.transition(State.PAUSED)
            ACTION_RESUME -> stateStore.transition(State.RECOVERING)
            else -> stateStore.transition(State.RUNNING)
        }

        val typeMask = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or
                       ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
        ServiceCompat.startForeground(
            this, NOTIFICATION_ID,
            buildResidentNotification(this, stateStore.current()),
            typeMask
        )

        dispatcher.resumePendingWork()       // 从落盘队列断点续跑
        stateStore.transition(State.RUNNING)
        return START_STICKY
    }

    // Android 15 (API 35)+:dataSync / mediaProcessing 跑满每日 6 小时
    @RequiresApi(35)
    override fun onTimeout(startId: Int, fgsType: Int) {
        dispatcher.checkpoint()              // 先落盘,再停
        stateStore.transition(State.PAUSED)
        stopForeground(STOP_FOREGROUND_REMOVE)
        stopSelf()
    }

    override fun onTaskRemoved(rootIntent: Intent?) {
        // 只记一笔。在这里 startService 基本会被 ROM 拦,还容易被判异常唤醒
        stateStore.markTaskRemoved()
        super.onTaskRemoved(rootIntent)
    }

    override fun onDestroy() {
        dispatcher.checkpoint()
        super.onDestroy()
    }

    override fun onBind(intent: Intent?): IBinder? = null
}

START_STICKY 值得多说两句,我被它误导过。它的语义是:服务被杀后系统尝试在资源允许时重建,并用 intent == null 回调 onStartCommand。既不保证马上重建,也不保证带上原来的 Intent,在多数国产 ROM 上还会被自启动策略挡掉。所以它只能是四条恢复路径里最弱的一条,当成唯一依赖迟早出事。

onTimeout 是升到 targetSdk 35 之后补的。系统给的处理窗口很短,不尽快 stopForeground + stopSelf 就会 ANR 甚至被强杀,所以顺序是先 checkpoint 把断点写下去,再收摊。

启动点也有讲究:

// 页面里,用户点了「开始」之后
fun startResident(context: Context) {
    val intent = Intent(context, ResidentService::class.java).apply { action = ACTION_START }
    ContextCompat.startForegroundService(context, intent)
}

Android 12 以上,应用在后台时起前台服务会被拒。常见的豁免是用户可见的 Activity 交互、通知点击或气泡交互、有可见 overlay 的 SYSTEM_ALERT_WINDOW(15 起收紧为必须真有可见浮层)、精确闹钟、地理围栏或活动识别回调,以及拨号器之类的系统角色。别在豁免之外赌运气,把调用放在「用户刚点了按钮」这条路上最省心。

真正决定成败的是状态怎么放

data class ResidentSnapshot(
    val state: State,
    val sessionId: String,             // 一次任务一个 ID,用来去重
    val queue: List<WorkItem>,         // 未完成任务,每条带 dedupeKey
    val lastProgress: Long,            // 断点位置
    val updatedAt: Long,
    val userAuthorizedUntil: Long,     // 授权窗口,过期就不再自动恢复
)

class StateStore(context: Context) {
    private val ds = context.getSharedPreferences("resident", MODE_PRIVATE)

    fun checkpoint(snapshot: ResidentSnapshot) {
        ds.edit().putString(KEY, Json.encodeToString(snapshot)).apply()
    }
    fun current(): ResidentSnapshot = /* 反序列化;坏了就降级成 IDLE,别抛异常 */
    fun transition(state: State) = checkpoint(current().copy(state = state, updatedAt = now()))
}
class TaskDispatcher(private val store: StateStore) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val inFlight = Collections.synchronizedSet(mutableSetOf<String>())

    fun resumePendingWork() {
        val snapshot = store.current()
        if (snapshot.state == State.IDLE) return
        if (System.currentTimeMillis() > snapshot.userAuthorizedUntil) {
            store.transition(State.IDLE); return          // 授权过期,不偷偷复活
        }
        snapshot.queue.forEach { item ->
            if (!inFlight.add(item.dedupeKey)) return@forEach   // 同一个 key 只跑一次
            scope.launch {
                try {
                    uploadIncremental(item, snapshot.lastProgress)
                    store.remove(item.dedupeKey)
                } catch (e: Exception) {
                    store.markFailed(item.dedupeKey)     // 留在队列等下次,别清空
                } finally {
                    inFlight.remove(item.dedupeKey)
                }
            }
        }
    }

    fun checkpoint() = store.transition(State.PAUSED)
}

这里有几条是我用事故换来的:进度一推进就 checkpoint,别等 onDestroy,进程被杀时它未必回调;每个任务带 dedupeKey,恢复时靠它去重,不然重复上报很难查;恢复前校验授权窗口,用户点了停止或者已经过期就不许自动活过来;服务层别抓着 Activity 或 View,只跟 StateStore、Repository 打交道。

调度:别自己写循环

需求用什么卡点
延迟能忍、要重试和约束(联网、充电)WorkManager周期最小 15 分钟,不是实时心跳
更细的系统调度、要感知配额JobScheduler16 起从 FGS 里起的 job 也受 quota 约束
精确到某一时刻(闹钟 / 日程)AlarmManager.setExactAndAllowWhileIdle12+ 要 SCHEDULE_EXACT_ALARM;13+ 的 USE_EXACT_ALARM 只对闹钟日历类开放,还有 Play 政策
用户主动触发的大数据传输user-initiated data transfer job(API 34+ setUserInitiated(true) + RUN_USER_INITIATED_JOBS)用户可见、可取消;16 起豁免普通 job 配额
要即时到的消息厂商推送 / FCM 高优先级数据消息系统代拉进程,比自己养长连接靠谱
class ResidentWatchdogWorker(appContext: Context, params: WorkerParameters)
    : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        val store = StateStore(applicationContext)
        val s = store.current()
        if (s.state == State.IDLE || now() > s.userAuthorizedUntil) return Result.success()
        return try {
            RecoveryCoordinator.resumeIfNeeded(applicationContext)
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

fun scheduleWatchdog(context: Context) {
    val req = PeriodicWorkRequestBuilder<ResidentWatchdogWorker>(15, TimeUnit.MINUTES)
        .setConstraints(
            Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
        )
        .build()
    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "resident_watchdog", ExistingPeriodicWorkPolicy.KEEP, req
    )
}

看门狗只负责「看一眼状态,需要的话从断点续上」,不是秒级心跳。Handler.postDelayed 死循环、服务里 while(true) 加 Thread.sleep 这类写法扛不住 Doze,也特别容易被厂商记成异常唤醒。业务真需要长连接的话,连接挂在前台服务下、心跳按网络类型和 Doze 状态自适应退避、断线重连加指数退避和抖动、没送达的消息先落盘,这套才站得住。

国产 ROM 的最后一公里

用户没把 App 放进「自启动 / 后台运行」白名单,代码再规范也可能被冻结。但引导得合规:先讲清楚要干嘛,再跳设置,而且允许拒绝。

fun showBackgroundPurposeDialog(activity: Activity, onAccept: () -> Unit) {
    // 原生 AlertDialog,没引弹窗库;文案要说清用途、范围、能随时关
    AlertDialog.Builder(activity)
        .setTitle("需要允许后台运行")
        .setCancelable(true)
        .setMessage(
            "为保证您在锁屏或切换到其他应用后,仍能持续收到数据同步结果与提醒," +
            "需要允许本应用在后台运行。\n\n" +
            "· 仅在您开启同步期间使用\n" +
            "· 不会额外读取与同步无关的信息\n" +
            "· 可随时在「设置 → 应用 → 电池」中关闭,也可在通知栏点「停止」"
        )
        .setNegativeButton("暂不开启", null)
        .setPositiveButton("去设置") { _, _ -> onAccept() }
        .show()
}

private fun openBatterySettings(activity: Activity) {
    val pkg = activity.packageName
    val chain = listOf(
        Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
            .setData(Uri.parse("package:$pkg")),
        Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
            .setData(Uri.parse("package:$pkg"))
    )
    for (i in chain) {
        try {
            activity.startActivity(i)
            return
        } catch (e: ActivityNotFoundException) {
            // 各家 ROM 支持度不一样,逐级降级,别让设置页打不开就崩
        }
    }
}

ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 在 Google Play 有明确的适用用例,不符合的时候应该引导用户自己去系统设置开,别静默申请。各厂商的「自启动管理」没有统一公开的 Intent,用 ACTION_APPLICATION_DETAILS_SETTINGS 兜底再配图文说明最稳。用户拒绝了要记下来,别反复弹窗骚扰;功能上降级成「下次打开 App 时补做」,而不是换个地方偷偷重试。这些文案最好和隐私政策、权限清单对一遍,检查时通常就看这个。

四条恢复路径最后长这样:

触发源靠不靠谱干什么
START_STICKY低,ROM 常拦尽力重建,intent == null 时从 StateStore 读状态
WorkManager 看门狗中高,可能被延迟兜底检查 + 断点续跑,最小 15 分钟
厂商推送 / FCM高,系统代拉消息到了顺势恢复,最「合法」的唤醒源
用户再次打开 App最高在 Application.onCreate 或首页调 resumeIfNeeded()

四条全部收敛到同一个 RecoveryCoordinator,靠 sessionId 和 dedupeKey 保证多源同时到达也只跑一次。

我删掉过的那些「保活方案」

做法现在怎么样为什么不做
双进程守护 / AIDL 互拉8.0+ 基本失效,ROM 直接拦没效果、费电、还容易踩关联启动
Native 层 fork 子进程被 lmkd / cgroup 清掉兼容性灾难,还有安全风险
1 像素 Activity被防抖和后台启动限制识别没效果,还侵入用户
播放无声音频会被系统和应用商店识别明显违规
高频系统广播拉活隐式广播大面积受限效果有限,容易被标记异常唤醒
应用间互相唤醒 / ContentProvider 拉活厂商重点打击合规风险
滥用账号同步 / 无障碍 / 通知监听要敏感权限,商店严查权限和用途对不上,违反最小必要

算下来收益只是「某些机型上多活几分钟」,代价是耗电榜单、差评、拒审、通报,性价比是负的。

这些年系统加过的限制,一条条对

这张表我直接放进了项目的 README,也塞进了 Trae 的项目规则里——AI 生成代码时最容易漏的就是版本分支。

版本加了什么限制影响在哪代码里对应
6.0 (23)Doze / App Standby轮询、心跳不可靠改成事件驱动,接受延迟
7.0 (24)移除部分隐式广播网络变化等拉活失效ConnectivityManager.NetworkCallback / WorkManager
8.0 (26)后台不能 startService;通知渠道必须 startForegroundService 且 5 秒内 startForegroundContextCompat、NotificationChannel
9.0 (28)引入 FOREGROUND_SERVICE 权限不声明就 SecurityExceptionManifest
10 (29)后台启动 Activity 限制;FGS 要 location 类型后台一律改走通知 + PendingIntent通知那节
11 (30)包可见性 <queries>;FGS 要 camera/microphone 类型显式拉别的 App 前要声明Manifest
12 (31)后台禁起 FGS;PendingIntent 可变性;exported 显式启动点得在用户交互内;补 FLAG_IMMUTABLE服务启动那节
13 (33)POST_NOTIFICATIONS 运行时权限被拒通知不可见,要降级先弹用途说明再申请
14 (34)FGS 类型强制 + 类型权限;后台 Activity 启动再收紧;动态广播导出标记;MediaProjection 每次重新授权typeMask 和 Manifest 必须一致Manifest + ServiceCompat
15 (35)dataSync / mediaProcessing 每日 6 小时、onTimeout;BOOT_COMPLETED 禁起部分 FGS;SYSTEM_ALERT_WINDOW 豁免收紧长任务要分段 + 断点;开机只挂调度onTimeout / BootReceiver
16 (36)FGS 内起的 job 也受 JobScheduler 配额;Intent 重定向加固用 user-initiated job 豁免;外部 Intent 全量白名单调度那节 / DeepLinkParser
17+后台音频要 while-in-use 类型且不能是 shortService;--debug-link 诊断通话录音类要重新核类型跟着官方 Behavior Changes 走

用 Trae 把这套东西搭出来:我实际发过的提示词

这套代码是我对着 Trae 一轮一轮聊出来的。最开始偷懒,丢了一句「帮我写个后台常驻服务」,它给我吐了一段双进程 AIDL 互拉,2016 年那种写法,连编译都过不了。后来改成先立规矩、每轮只干一件事、每轮都让它自己给验收命令,才顺起来。下面按当时的顺序把提示词原样列出来,基本可以整段复制。

模式上我是这么分的:从零建目录、多文件联动走 Builder(Ctrl+I,mac 是 Cmd+I);单文件精修、解释和排错走 Chat(Ctrl+Shift+A);选中一段重构用 Ctrl+K;SOLO 只交给「补一组单元测试」这种自包含任务,状态机和权限流程不敢让它自己跑。架构和合规相关的轮次我切到推理更稳的模型,批量建文件才用快的那个。上下文用 #File / #Folder 精确指到 AndroidManifest.xml、resident/ 这些位置,别指望它全仓自动索引——项目一大,索引经常是不全的。

先把规矩钉死

项目根目录我建了两个文件。.traeignore 挡掉索引噪音,不然它会把 build/ 里生成的代码、旧的 R 文件一起读进去,写 Manifest 的时候特别容易想当然:

.gradle/
.idea/
build/
app/build/
*/build/
local.properties
*.apk
*.aab
*.keystore
captures/
.externalNativeBuild/
.cxx/

另一个是 .trae/rules/project_rules.md(不同版本可能叫「自定义规则」或「AI Rules」,看你自己那版的设置页),内容我直接贴全:

# 项目规则 / Android 唤起与常驻架构

## 技术栈
- Kotlin,单 Activity,minSdk 26,targetSdk 35,同时兼容 Android 16(API 36) 行为。
- 只允许使用 AndroidX / Jetpack 官方组件(core-ktx、appcompat、work-runtime-ktx)。
- 禁止引入任何第三方库、第三方保活 SDK、第三方路由框架、第三方推送 SDK。
- 权限用途说明与设置引导一律使用原生 AlertDialog,不引第三方弹窗库。

## 合规红线(最高优先级)
- 禁止生成双进程守护、1 像素 Activity、无声音频、Native fork、应用间互相拉起、
  ContentProvider 拉活、高频广播拉活、账号同步/无障碍滥用等任何绕过系统后台限制的代码。
- 后台持续任务必须是用户可感知的前台服务,通知必须提供显式「停止」操作。
- 任何权限申请前必须先展示用途说明,用户可拒绝,拒绝后不得反复弹窗。
- 进程恢复必须校验用户授权窗口;用户主动停止后不得自动复活。

## 编码约定
- 每一个依赖系统版本的行为都要用 Build.VERSION.SDK_INT 分支,并在注释里标注 API level。
- Manifest 中带 intent-filter 的组件必须显式声明 android:exported。
- 前台服务的 android:foregroundServiceType 与 startForeground 的 typeMask 必须完全一致。
- 所有外部传入的 Intent / Uri 必须经 DeepLinkParser 白名单校验,未知路径静默丢弃。
- 状态必须落盘,任务必须带 dedupeKey 保证幂等。
- 类文件顶部写一行职责说明;不要生成 TODO 占位实现。

## 输出要求
- 先给方案(分层、文件清单、版本差异),我确认后再写代码。
- 每次只完成本轮约定的文件,不要顺手重构其他模块。
- 每个文件给出完整路径,并列出本轮的验收用 adb 命令。
- 不确定的系统行为,标注「需查官方文档」而不是编造 API。

这一步最省事。把「零第三方库」「不写保活黑科技」「权限前先说明用途」「版本分支标 API level」一次写进去,后面十轮都不用重复叮嘱,它也不会中途混进老代码。建完我在 Chat 里发了句确认:

请读取项目根目录下的 .traeignore 和 .trae/rules/project_rules.md,
用一段话复述你理解的项目技术栈、依赖限制、合规红线和输出要求,
并明确列出你在本项目中「绝对不会生成」的 5 类代码。
本轮不要创建或修改任何源码文件。

它漏复述了一条合规红线,我就回了句「第 3 条没复述到,重新读一遍规则文件,后续输出以规则文件为最高优先级」。这种回读看着多余,实际省掉不少返工。

第一轮只要方案,一行代码都不让写

挂在强推理模型上,#Folder 指一下空的 app/src/main,发这段:

你是 Android 平台工程师。我们要为一个 minSdk 26 / targetSdk 35、Kotlin、仅使用 AndroidX 的
单 Activity 项目,设计「应用唤起 + 后台常驻」架构,业务场景是:用户可从 H5 链接、
通知、厂商推送打开详情页,并可主动开启一段后台持续上报任务。

本轮【禁止写任何代码】,只输出方案:

1. 分层设计:触发层 / 唤起层 / 常驻层 / 恢复层 / 可观测层,逐层说明职责与禁止事项;
2. 完整文件树(给出每个文件的完整路径与一句话职责);
3. 三条关键链路的文字时序:外部唤起冷启动、用户开启常驻、进程被杀后恢复;
4. 状态机:列出状态、事件、以及每次状态转换需要持久化的字段;
5. 版本差异清单:逐条列出 Android 8/10/12/13/14/15/16 对「后台启动 Activity、
   后台启动前台服务、前台服务类型与配额、通知权限、PendingIntent」的限制,
   以及每一条限制对应到哪个文件里的哪处分支;
6. 合规红线:列出明确不会采用的做法及原因;
7. 验收方案:列出需要覆盖的 8 个手工测试场景和对应 adb 命令。

输出用 Markdown 表格与编号列表,不要代码块。对不确定的系统行为标注「需查官方文档」。

第一版方案漏了 onTimeout 和热启动参数,我是这么追的:

方案里缺少三点,请补充后再定稿:
(1) Android 15 对 dataSync 类型前台服务的时长上限与超时回调如何处理;
(2) 进程被杀后除了 START_STICKY 还有哪三条恢复触发源,如何避免重复执行;
(3) 热启动(singleTask 复用)时链接参数不丢的具体机制。

跳过这轮、直接让 Builder 一口气生成全套我试过,结果文件互相引用不上、权限和类型对不齐,收拾残局比自己写还慢。方案先过一遍,后面每轮都只是在确认过的骨架里填肉。

清单、权限和版本常量

Builder 里发,#File 带上刚定稿的方案:

按已确认的架构方案,本轮只完成「清单与版本适配骨架」,涉及文件:
- app/src/main/AndroidManifest.xml
- app/src/main/java/com/example/app/platform/AndroidVersions.kt
- app/src/main/java/com/example/app/resident/ResidentContract.kt

要求:
1. Manifest 声明以下权限并逐条写注释说明用途:INTERNET、FOREGROUND_SERVICE、
   FOREGROUND_SERVICE_DATA_SYNC、FOREGROUND_SERVICE_LOCATION、POST_NOTIFICATIONS、
   ACCESS_FINE_LOCATION、RECEIVE_BOOT_COMPLETED、WAKE_LOCK;
2. 声明 MainActivity(android:exported=true、launchMode=singleTask),包含三组 intent-filter:
   LAUNCHER、autoVerify=true 的 https App Links(host=app.example.com)、
   单独的自定义 scheme(myapp://open)——后两组必须分开,不得混写;
3. 声明 ResidentService:android:exported=false、
   android:foregroundServiceType="dataSync|location";
4. 声明 BootReceiver:exported=true 且只接受 BOOT_COMPLETED,显式设置 android:permission;
5. 所有带 intent-filter 的组件显式写 android:exported;
6. AndroidVersions.kt 提供语义化判断(如 supportsFgsTypeRequirement() 对应 API 34、
   supportsFgsTimeout() 对应 API 35、supportsUserInitiatedJob() 对应 API 34),
   每个函数注释标注 API level;
7. ResidentContract.kt 集中定义 ACTION_START / ACTION_STOP / ACTION_PAUSE / ACTION_RESUME、
   NOTIFICATION_ID、CHANNEL_ID、请求码常量,禁止在其他文件里散落字符串字面量;
8. 额外输出一份 assetlinks.json 模板(放到 docs/assetlinks.example.json),
   指纹处用占位符并注明「必须替换为发布签名证书的 SHA-256,大写、冒号分隔」。

硬约束:不引入任何新依赖;不生成任何保活黑科技;不确定的权限名标注「需查官方文档」。
输出先列文件清单与关键决策,再给完整代码,最后给 3 条 adb 验收命令。

它把 https 和 myapp 写进了同一个 filter,服务类型也只写了 dataSync,我直接指出来让它改:

你把 https 和 myapp 写进了同一个 intent-filter,这会产生意外的匹配组合;
另外 ResidentService 的 foregroundServiceType 写成了 dataSync 单类型,
与方案约定的 dataSync|location 不一致。请按方案修正,并说明 Manifest 类型
与 startForeground 的 typeMask 一致性在 Android 14+ 为什么是强制的。

装完我一般跑 adb shell dumpsys package com.example.app | grep -i -A2 "foregroundServiceType" 和 adb shell pm get-app-links 看一眼。

唤起层

Builder,#File 带 AndroidManifest.xml 和 ResidentContract.kt:

本轮只实现「唤起层」,涉及文件:
- app/src/main/java/com/example/app/launch/Route.kt
- app/src/main/java/com/example/app/launch/DeepLinkParser.kt
- app/src/main/java/com/example/app/launch/GateKeeper.kt
- app/src/main/java/com/example/app/launch/PendingRouteStore.kt
- app/src/main/java/com/example/app/launch/Navigator.kt
- app/src/main/java/com/example/app/launch/LaunchRouter.kt
- app/src/main/java/com/example/app/ui/MainActivity.kt

要求:
1. Route 用 sealed interface 收敛为 TaskDetail(id, from) / Monitor / Home 三种;
2. DeepLinkParser 对 scheme 与 host 做白名单:https 仅允许 app.example.com、m.example.com,
   自定义 scheme 仅允许 myapp://open;路径仅识别 /task/{id} 与 /monitor;
   id 长度限制 64 且做字符校验;未知路径返回 null,禁止兜底跳转;
   解析失败不得把原始 Uri 拼进异常或日志以外的任何地方;
3. LaunchRouter.dispatch(activity, intent) 是唯一分发入口,顺序固定为:
   解析 → 可信校验 → 闸门(隐私同意 / 登录 / 权限用途说明) → 导航 → 埋点;
   闸门未通过时用 PendingRouteStore 把 Route 持久化,授权完成后重放,禁止丢弃参数;
4. MainActivity 的 onCreate 与 onNewIntent 都调用同一个 dispatch;
   onNewIntent 必须 setIntent(intent),注释写明「不写会丢热启动参数」;
5. Navigator 在 activity.isTaskRoot 时用 TaskStackBuilder 合成 首页→详情 返回栈,
   非 taskRoot 时直接打开,避免重复压栈;
6. 所有外部 Uri 参数在埋点前脱敏,只记录 route 类型与 from 来源,不记录完整 URL 与用户参数;
7. 使用原生 API,不引入 ARouter 等任何路由框架;不写 TODO 占位实现。

输出:先给「冷启动 / 温启动 / 热启动」三种场景下的参数流转说明,再给完整代码,
最后给 4 条 adb 验收命令(含 force-stop 后的冷启动与不 force-stop 的热启动)。

第一次它对未登录直接 return false 把路由丢了,Navigator 也无条件合成父栈,热启动会重复压页。追问原话:

你在 LaunchRouter 里直接对未登录情况 return false 丢弃了路由,违反「闸门必须暂存」约定;
另外 Navigator 无条件 addNextIntentWithParentStack 会导致热启动重复压栈。
请按「pendingRoute 持久化 + isTaskRoot 判断」修正,并补充 PendingRouteStore 的重放入口
应该由哪个回调触发。

常驻层

Builder,#File 带 Manifest、ResidentContract.kt、AndroidVersions.kt:

本轮只实现「常驻层」,涉及文件:
- app/src/main/java/com/example/app/resident/ResidentService.kt
- app/src/main/java/com/example/app/resident/NotificationFactory.kt
- app/src/main/java/com/example/app/resident/ResidentEntry.kt

要求:
1. ResidentService 继承 Service,onBind 返回 null;onCreate 里先创建通知渠道;
2. onStartCommand 处理 ACTION_START / ACTION_RESUME / ACTION_PAUSE / ACTION_STOP:
   - ACTION_STOP:状态置 IDLE、stopForeground(STOP_FOREGROUND_REMOVE)、stopSelf、
     返回 START_NOT_STICKY(用户主动停止不允许复活);
   - 其余分支调用 androidx.core.app.ServiceCompat.startForeground,
     typeMask 必须严格等于 Manifest 里的 dataSync|location,用注释写明两者的对应关系;
   - 正常情况返回 START_STICKY,并用注释说明它「不保证立即重建、intent 可能为 null、
     国产 ROM 可能拦截」,因此不能作为唯一恢复手段;
3. 覆盖 Android 15(API 35)+ 的 onTimeout(startId, fgsType):先 checkpoint 落盘断点,
   再 stopForeground + stopSelf;注释说明 dataSync 每日累计 6 小时上限与超时不处理的后果;
4. onTaskRemoved 只做状态标记,禁止在其中 startService;onDestroy 必须 checkpoint;
5. Android 8(26)+ 使用 ContextCompat.startForegroundService 启动,并在注释中写明
   「必须在 5 秒内 startForeground」以及「Android 12+ 后台调用会被拒,启动点必须在用户交互内」;
6. NotificationFactory 构建常驻通知:
   - 通知渠道 IMPORTANCE_LOW,setOnlyAlertOnce(true)、setOngoing(true)、CATEGORY_SERVICE;
   - 内容点击 PendingIntent 走 App Links Uri 到 /monitor?from=notification,
     用 TaskStackBuilder 合成父栈,FLAG_IMMUTABLE + FLAG_UPDATE_CURRENT;
   - 必须提供一个「停止」Action,PendingIntent.getService 指向 ACTION_STOP;
   - 文案写明正在做什么、已持续多久、可随时停止;
7. 禁止:无界循环、Handler 死循环心跳、持有 Activity 引用、任何保活黑科技、任何新依赖。

输出:先给生命周期时序说明(含被杀场景),再给完整代码,最后给 5 条 adb 验收命令
(含 dumpsys activity services 与通知渠道检查)。

这轮生成的东西我逐行看过,typeMask、onTimeout、通知里的「停止」出口三个地方它第一版全漏了,追问是这么写的:

生成的代码存在三个问题,请逐个修正并说明原因:
(1) startForeground 没有传 typeMask,Android 14 会抛什么异常;
(2) 缺少 onTimeout,Android 15 上 dataSync 跑满 6 小时会发生什么;
(3) 通知没有「停止」出口,为什么这在合规上不可接受。
另外请把「后台启动前台服务在 Android 12+ 被拒」的规避方式写成注释,不要写成绕过代码。

状态机和幂等,这轮我没敢交给 SOLO

Chat 挂在强推理模型上,#Folder 带 resident/:

本轮实现「可恢复」的核心,涉及文件:
- app/src/main/java/com/example/app/state/State.kt
- app/src/main/java/com/example/app/state/ResidentSnapshot.kt
- app/src/main/java/com/example/app/state/StateStore.kt
- app/src/main/java/com/example/app/state/TaskDispatcher.kt
- app/src/main/java/com/example/app/state/RecoveryCoordinator.kt

要求:
1. State 枚举:IDLE / RUNNING / PAUSED / RECOVERING / STOPPED,
   给出合法转换表,非法转换记录日志但不崩溃;
2. ResidentSnapshot 字段固定为:state、sessionId、queue(List<WorkItem>,每项带 dedupeKey)、
   lastProgress、updatedAt、userAuthorizedUntil;用 SharedPreferences 的原生方式实现
   (不引入新依赖),反序列化失败时降级为 IDLE 而不是抛异常;
3. StateStore.transition() 每次转换都落盘;checkpoint() 在任何进度推进后调用;
4. TaskDispatcher.resumePendingWork() 必须幂等:
   - 用 dedupeKey 去重,同一个 key 不并发、不重复执行;
   - 失败时把任务留在队列并标记失败次数,不清空;
   - 使用 SupervisorJob + Dispatchers.IO,不阻塞主线程;
5. RecoveryCoordinator.resumeIfNeeded(context) 是「唯一恢复入口」,被以下四处共同调用:
   START_STICKY 重建、WorkManager 看门狗、推送到达、Application/首页打开;
   它必须先校验 userAuthorizedUntil(过期或 state==IDLE 直接返回,禁止自动复活),
   再启动服务并传 ACTION_RESUME;
6. 明确注释:为什么「进程随时会死」是本设计的前提,以及 onDestroy 不可靠时靠什么兜底;
7. 不要生成任何新依赖,不要生成 TODO。

输出:先给状态转换表与「多源同时到达」的并发时序说明,再给完整代码,
最后列出 6 个需要单元测试覆盖的用例(用文字描述即可,本轮不写测试)。

resumeIfNeeded 里少了授权窗口校验,这个必须追,不然用户点了停止还会被看门狗拉起来:

resumeIfNeeded 缺少 userAuthorizedUntil 校验,会导致用户点「停止」后被 WorkManager 偷偷复活,
这是合规问题,请修正。另外请补充:当 START_STICKY、看门狗、推送三者在同一秒内同时到达时,
如何保证只启动一次恢复流程(给出具体去重机制,不要只写「加锁」两个字)。

调度和触发

Builder,#File 带 RecoveryCoordinator.kt、AndroidVersions.kt:

本轮只实现「触发与调度」,涉及文件:
- app/src/main/java/com/example/app/trigger/BootReceiver.kt
- app/src/main/java/com/example/app/trigger/PushReceiver.kt
- app/src/main/java/com/example/app/trigger/ResidentWatchdogWorker.kt
- app/src/main/java/com/example/app/trigger/ScheduleRules.kt

要求:
1. BootReceiver 只做两件事:调用 ScheduleRules.scheduleWatchdog() 重新挂兜底调度、
   给 StateStore 标记一次重启事件;禁止在其中启动前台服务、跑耗时逻辑或直接开页面;
   注释写明 Android 15 起 BOOT_COMPLETED 不允许启动部分类型的前台服务;
2. PushReceiver 收到推送后只调用 RecoveryCoordinator.resumeIfNeeded(),
   并在注释里说明「推送由系统代拉进程,是最合法的唤醒源」;
   不得在广播里直接 startActivity(Android 10+ 后台启动 Activity 限制);
3. ResidentWatchdogWorker 用 PeriodicWorkRequestBuilder 最小周期 15 分钟,
   约束 NetworkType.CONNECTED,失败返回 Result.retry(),
   用 enqueueUniquePeriodicWork + KEEP 防止重复入队;
   注释说明「看门狗只做检查与断点续跑,不是秒级心跳」;
4. ScheduleRules 额外给出 Android 16(API 36) 分支说明:
   从前台服务里启动的 JobScheduler job 也受配额约束,
   用户主动触发的大数据传输应使用 user-initiated data transfer job(API 34+),
   这一部分只写清晰的注释与接入点,不要编造不存在的 API;
5. 精确闹钟相关:注释说明 SCHEDULE_EXACT_ALARM / USE_EXACT_ALARM 的适用版本与政策限制,
   本轮不实现精确闹钟;
6. 不引入任何新依赖(WorkManager 使用项目已有的 work-runtime-ktx)。

输出:先给「四条恢复触发源 → 唯一恢复入口」的收敛示意,再给完整代码,
最后给 4 条 adb 验收命令(含 dumpsys jobscheduler 与 am broadcast 模拟开机)。

合规弹窗

这轮的措辞我单独发给法务过了一遍,技术上都对,文案没写清「范围、期间、可关闭」一样会被要求整改。

本轮实现「权限与后台运行的合规引导」,涉及文件:
- app/src/main/java/com/example/app/compliance/PermissionPurposeDialog.kt
- app/src/main/java/com/example/app/compliance/BatteryGuide.kt
- app/src/main/java/com/example/app/compliance/ConsentGate.kt

要求:
1. 一律使用原生 AlertDialog.Builder,禁止引入任何第三方弹窗/权限库;
2. 在申请 POST_NOTIFICATIONS、定位权限、以及跳转电池优化设置【之前】,
   先展示用途说明:说明收集什么、用于什么功能、仅在什么期间使用、用户可随时关闭;
   提供明确的「暂不开启」按钮,用户可拒绝;
3. ConsentGate 记录用户是否拒绝及拒绝次数:拒绝后不得在同一会话反复弹窗,
   功能降级为「用户下次主动打开 App 时补做」,禁止用循环或定时重试骚扰用户;
4. BatteryGuide 打开设置的 Intent 采用三级降级:
   ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS →
   ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS →
   ACTION_APPLICATION_DETAILS_SETTINGS,
   每级用 try/catch ActivityNotFoundException 兜底,绝不崩溃;
   注释说明该权限在 Google Play 的政策限制,以及不符合用例时应引导用户自行到系统设置开启;
5. 弹窗文案需与隐私政策、权限申请清单的表述保持一致,用常量集中管理便于法务 review;
6. 所有文案常量放在 res/values/strings.xml,代码中不写死中文字符串。

输出:先给「权限申请 → 用途说明 → 用户决策 → 降级」的流程图(Mermaid),
再给完整代码与 strings.xml 条目,最后给 5 条手工验收场景。

测试和埋点

自包含,我丢给 SOLO 跑的:

本轮只做测试与埋点,不改动任何生产代码逻辑。

一、单元测试(src/test):
1. DeepLinkParser:合法 https / 合法 myapp / 非法 host / 非法 scheme /
   超长 id / 未知路径 / 空 Uri,共 7 个用例,断言非法输入一律返回 null;
2. StateStore:正常读写、反序列化损坏降级为 IDLE、transition 落盘;
3. TaskDispatcher:同一 dedupeKey 并发只执行一次、失败后任务仍留在队列;
4. RecoveryCoordinator:授权窗口过期不复活、state==IDLE 不复活、四源并发只触发一次恢复。

二、instrumentation / 手工场景(只输出操作步骤,不写自动化脚本):
冷启动链接唤起返回栈、热启动参数不丢、通知点击、用户点「停止」后不复活、
权限被拒后降级、进程被 force-stop 后由看门狗恢复。

三、可观测性:
新增 ResidentLogger.kt,定义结构化事件:launch(route, source)、
fgs_start(typeMask)、fgs_timeout(reason)、recovery(trigger, success, elapsedMs)、
consent(action, granted)、stop(source)。
要求:只记录事件类型与非个人标识的参数,禁止记录完整 URL、位置坐标、用户标识;
输出一份 Markdown 埋点字典表(事件名 / 参数 / 用途 / 是否含个人信息)。

不引入任何新依赖;不修改生产代码逻辑;不确定的断言标注「需人工确认」。

最后一轮,让它自己复盘

Chat,#Folder 带整个 app/src/main:

基于当前项目的全部源码,做一次架构复盘,只输出文档不修改代码:

1. 输出一张 Mermaid flowchart:五层架构 + 三条链路的数据流向,节点使用中文;
2. 输出一张 Mermaid sequenceDiagram:进程被系统回收后的恢复链路;
3. 逐条核对 Android 8/10/12/13/14/15/16 的版本差异清单,
   列出「已覆盖 / 未覆盖 / 需人工确认」三列;
4. 输出一份上架前检查表:权限声明与隐私政策一致性、前台服务类型用途说明、
   通知可停止、用户拒绝后的降级、电池优化引导文案、埋点是否含个人信息;
5. 输出一份「继续深入」清单:列出 8 个后续可优化方向,按投入产出比排序;
6. 最后把以上内容组织成一篇掘金技术文章的 Markdown 大纲(含每章要点与建议配图)。

对源码里没有实现的部分,明确标注「当前项目未实现」,不要编造。

回过头看,真正起作用的是三件事:约束一次写进规则里,别每轮重复;先要方案再要代码;每轮把「只准改哪些文件」和「怎么验收」一起说清楚,包括让它自己列 adb 命令——它漏考虑的分支,往往在写验收命令的时候就暴露了。另外提示词里那句「不确定的系统行为标注需查官方文档」,能挡掉不少编出来的权限名。Builder 生成完别整体 Accept,服务和状态机这两轮我都是开着 Diff 一行行看的。

跑不通的时候,我怎么跟它追问

调试我固定按四段发:异常栈、环境、已经排除掉的、要求。编译报错直接用终端报错旁边的 AI 入口抓错误栈,比手打复述准;运行时问题存一份 adb logcat -v brief > logcat.txt,在 Chat 里用 #File 引这个文件。

【异常】完整粘贴 logcat 异常栈(不要删减 Caused by 部分)
【环境】设备型号 / ROM / Android 版本 / targetSdk / 是否开了电池优化白名单
【已排除】我已经确认 Manifest 声明了 X、权限 X 已授予、typeMask 与 Manifest 一致
【要求】
1. 先按可能性从高到低给出 3 个根因假设,并说明每个假设如何用一个 adb 命令证伪;
2. 再给出最小改动补丁,只改必要的文件,不要顺手重构;
3. 补丁里标注涉及的 API level,并说明在低版本设备上是否会引入兼容性问题;
4. 最后给一条能验证修复是否生效的 adb 命令。

最常问的四句:

现象我怎么问
SecurityException: Starting FGS requires a valid type / MissingForegroundServiceTypeException对照 Manifest 的 foregroundServiceType 和 ServiceCompat.startForeground 的 typeMask 逐位比对并列出差异,同时检查两个 FOREGROUND_SERVICE_* 权限是不是都声明了,只修不一致的地方
ForegroundServiceStartNotAllowedException找出所有 ContextCompat.startForegroundService 调用点,标出哪些在用户可见交互里、哪些在 BroadcastReceiver / Worker 里;后者改成发通知由用户点击触发,或交给 RecoveryCoordinator 在恢复入口判断,别写任何绕过后台限制的代码
App Links 打开浏览器 / verified: false检查 assetlinks.json 是不是 HTTPS 直出无重定向、Content-Type、指纹是否大写且为发布签名证书(Play App Signing 要取 App signing key)、autoVerify 有没有和自定义 scheme 分开,再给完整的 pm set-app-links / verify-app-links --re-verify / get-app-links 序列
通知不显示 / 前台服务秒退检查 POST_NOTIFICATIONS(33+) 是否授予、NotificationChannel 是不是在 startForeground 之前建的、有没有在 5 秒内 startForeground、通知的 smallIcon 和 contentIntent 是否有效,给 dumpsys notification 与 logcat 过滤命令

上线前我自己会过一遍的东西

先说合规,这块比技术更容易被卡:

  • 权限只申请用得上的,前台服务只挂真正需要的类型,用途说明要在 Play Console 或应用商店的材料里对得上;
  • 申请通知、定位、后台运行之前先弹用途说明,用户能拒绝,弹窗、隐私政策、商店权限说明三处措辞一致,文案集中在 strings.xml 里方便 review;
  • 后台持续做的事必须有常驻通知,写清在干什么、多久了,并且给「停止」出口;停止之后不许自动复活;
  • 拒绝之后别反复弹,也别换个地方偷偷重试,降级成下次打开 App 时补做;
  • 埋点不带完整 URL、位置坐标、用户标识,能说清后台期间收集了什么、存多久、怎么删;
  • 不碰应用间互相拉起、ContentProvider 拉活、无障碍/账号同步滥用这类关联启动,这是国内专项检查的重点;
  • ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 在 Play 有明确适用用例,不符合就引导用户自己去系统设置开。

然后是那张我贴在显示器边上的踩坑表:

现象根因怎么修
SecurityException: Starting FGS requires a valid type14+ 没声明类型或类型权限补 foregroundServiceType 和对应 FOREGROUND_SERVICE_*
提示 while-in-use 限制的 SecurityExceptionlocation / camera / microphone / health 在后台启用受限改成用户可见时启动,或换类型
ForegroundServiceStartNotAllowedException12+ 在后台起 FGS启动点挪到用户交互里,后台场景改走通知点击 / 恢复入口
ForegroundServiceDidNotStartInTimeException5 秒内没 startForeground初始化挪出关键路径,先 startForeground 再干活
15 上服务突然停了dataSync / mediaProcessing 每日 6 小时上限实现 onTimeout,任务分段 + 调度续跑
通知不显示没申请 POST_NOTIFICATIONS(33+) / 渠道没建 / 图标无效补用途说明、渠道前置、smallIcon
PendingIntent 抛 IllegalArgumentException12+ 没声明可变性加 FLAG_IMMUTABLE
INSTALL_PARSE_FAILED_MANIFEST_MALFORMED12+ 组件缺 android:exported全量补齐
App Links 打开浏览器或弹选择框assetlinks.json 验证失败查发布签名指纹、大写、无重定向、Content-Type、autoVerify
热启动链接参数丢了只在 onCreate 读 intent.dataonNewIntent + setIntent() 也调 dispatch
返回键直接退桌面外部唤起没合成父栈TaskStackBuilder 重建首页 → 详情
连点两次压两个详情页launchMode=standard 且没复用收敛到 singleTask / singleTop + 单一 Router
划掉任务卡片后再也起不来把 onTaskRemoved 里重启当唯一手段改成多源恢复 + 状态外置
用户点「停止」后又被拉起WorkManager / START_STICKY 没校验停止状态恢复入口统一校验 state / 授权窗口
恢复后重复上报没有幂等去重每个任务带 dedupeKey
国产 ROM 上几分钟就没自启动 / 电池优化白名单没开用途说明 + 设置引导 + 可恢复设计兜底
后台直接弹页面失败10+ 后台启动 Activity 限制发通知,由用户点 PendingIntent 唤起

命令也顺手记一下,排查的时候不用翻:

adb shell am force-stop com.example.app
adb shell am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://app.example.com/task/42"
adb shell am start -W -a android.intent.action.VIEW -d "myapp://open/monitor"
adb shell pm get-app-links com.example.app
adb shell dumpsys activity activities | grep -i com.example.app
adb shell dumpsys activity services com.example.app
adb shell dumpsys notification | grep -i com.example.app
adb shell dumpsys jobscheduler | grep -i com.example.app
adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -p com.example.app
adb logcat -v brief | grep -iE "ForegroundService|SecurityException|BackgroundServiceStart"
adb shell dumpsys deviceidle force-idle
adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS

上线之后盯几个数就够了:分来源的唤起成功率和参数到达率、冷启动到首帧的耗时、按机型和 ROM 拆开的前台服务平均存活时长、恢复触发源分布、恢复成功率和重复执行率(服务端按 dedupeKey 去重统计)、权限与白名单同意率、用户主动停止率,再加耗电和异常唤醒次数。前面几个靠 launch(route, source)、fgs_start、recovery(trigger, success, elapsedMs)、consent、stop 这几个事件就能凑出来,关键是别一开始就把埋点做成个人信息采集。

还没做完的几件事

按我现在的优先级排,头三个是必须做的:跟着 Android 16/17 的行为变更把回归矩阵补上,尤其是 job 配额、while-in-use 服务和 --debug-link 诊断,每次升 targetSdk 先跑一遍前面那张版本表;把「用户主动触发的大数据传输」从前台服务迁到 user-initiated data transfer job,绕开配额和时长上限;把各家厂商推送收敛到一个 PushPort 接口,统一「到达 → resumeIfNeeded」的语义,唤醒成功率最高、代码也最干净。

再往后,状态层从 SharedPreferences 换成 Room,用事务把 checkpoint 和 remove(dedupeKey) 做成原子的;客户端的 dedupeKey 和服务端幂等表打通,做一次「杀进程 → 恢复 → 重复上报」的全链路压测;路由层如果迁 Compose,直接让 Navigation 消费 Route,多 Activity 和 taskAffinity 那堆麻烦能从根上消失。

还有几件偏工程治理的:CI 里加静态检查,扫 exported 缺失、Manifest 与 typeMask 不一致、保活关键词,把合规卡点挪到编译期;白名单引导文案按 ROM 分层做 A/B,看同意率;长连接按网络类型和 Doze 状态做自适应心跳,断线重连加指数退避和抖动;最后把上面那些指标做成看板,按 ROM、系统版本、App 版本下钻,别等用户反馈才知道服务又没了。

最后留个自测,随便挑一台国产 ROM 手机走四步:从短信链接冷启动,详情对、返回键回首页;开启同步,通知可见且有「停止」;锁屏切走,再 adb shell am force-stop 模拟被杀;等看门狗、推送或者自己重新打开 App,任务从断点续上,且没有重复执行。前三步靠 API,第四步靠设计——第四步过了,这套东西才算真的立住。

参考

  • Android Developers:Foreground services、Changes to foreground services
  • Android Developers:Restrictions on starting activities from the background
  • Android Developers:Verify App Links、Troubleshoot App Links、Digital Asset Links
  • Android Developers:Behavior changes(Android 14 / 15 / 16)
  • Android Developers:WorkManager、JobScheduler、Background work overview
  • Android Developers:Request runtime permissions、Notification runtime permission
  • Google Play 开发者政策中心:前台服务类型声明、电池优化权限适用用例
  • Trae 官网与官方中文社区:Builder / Chat / SOLO、TRAE Rules、.traeignore
  • 《个人信息保护法》及国内应用商店关于后台行为、自启动、关联启动的相关规范

系统行为最终还是以你自己的 targetSdk 和目标机型为准,文中的包名、域名、指纹都是占位,落地记得换掉。