被杀掉之后:我把 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.onCreate | Activity | 唤起要做的 |
|---|---|---|---|---|
| 冷启动 | 点图标 / 点链接 / 推送 | 执行 | 新建 | 完整初始化、解析 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 图标 | 用户 | — | 无 | 常规启动 |
| 自定义 Scheme | H5 / 短信 / 别的 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 | 本 App | 10+ 基本禁止 | 只有有可见窗口、前台服务通知等少数豁免 | 别用,改发通知 |
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——开头说的那个坑,根因就在这儿。
类型怎么选,我整理了一张表放在手边,每次加后台任务都先对一遍:
| type | Manifest 权限 | 场景 | 运行时前提 | 限制 |
|---|---|---|---|---|
dataSync | FOREGROUND_SERVICE_DATA_SYNC | 上传、同步、备份 | — | 15 起每日累计 6 小时 |
mediaProcessing | FOREGROUND_SERVICE_MEDIA_PROCESSING | 媒体转码(15 新增) | — | 同样 6 小时 |
location | FOREGROUND_SERVICE_LOCATION | 导航、轨迹 | 定位权限 + while-in-use 条件 | 后台启动受限 |
health | FOREGROUND_SERVICE_HEALTH | 健康 / 运动监测 | ACTIVITY_RECOGNITION 等 | while-in-use 限制 |
camera / microphone | 对应类型权限 | 录制、通话 | CAMERA / RECORD_AUDIO | 后台不可启用 |
mediaPlayback | FOREGROUND_SERVICE_MEDIA_PLAYBACK | 音乐、播客 | — | — |
phoneCall | FOREGROUND_SERVICE_PHONE_CALL | VoIP | MANAGE_OWN_CALLS 或拨号器角色 | — |
connectedDevice | FOREGROUND_SERVICE_CONNECTED_DEVICE | 蓝牙 / 穿戴设备 | 蓝牙权限 | — |
remoteMessaging | FOREGROUND_SERVICE_REMOTE_MESSAGING | 跨设备消息同步 | — | — |
shortService | 不用额外类型权限 | 短时不可中断任务 | 约 3 分钟,不能和别的类型组合 | 到点得自己停 |
specialUse | FOREGROUND_SERVICE_SPECIAL_USE | 确实没有匹配类型 | — | Play 要提交用途声明,滥用会被拒 |
mediaProjection | FOREGROUND_SERVICE_MEDIA_PROJECTION | 录屏 / 投屏 | 14+ 每次会话重新授权 | 不能缓存授权 Intent |
systemExempted | FOREGROUND_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 分钟,不是实时心跳 |
| 更细的系统调度、要感知配额 | JobScheduler | 16 起从 FGS 里起的 job 也受 quota 约束 |
| 精确到某一时刻(闹钟 / 日程) | AlarmManager.setExactAndAllowWhileIdle | 12+ 要 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 秒内 startForeground | ContextCompat、NotificationChannel |
| 9.0 (28) | 引入 FOREGROUND_SERVICE 权限 | 不声明就 SecurityException | Manifest |
| 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 type | 14+ 没声明类型或类型权限 | 补 foregroundServiceType 和对应 FOREGROUND_SERVICE_* |
提示 while-in-use 限制的 SecurityException | location / camera / microphone / health 在后台启用受限 | 改成用户可见时启动,或换类型 |
ForegroundServiceStartNotAllowedException | 12+ 在后台起 FGS | 启动点挪到用户交互里,后台场景改走通知点击 / 恢复入口 |
ForegroundServiceDidNotStartInTimeException | 5 秒内没 startForeground | 初始化挪出关键路径,先 startForeground 再干活 |
| 15 上服务突然停了 | dataSync / mediaProcessing 每日 6 小时上限 | 实现 onTimeout,任务分段 + 调度续跑 |
| 通知不显示 | 没申请 POST_NOTIFICATIONS(33+) / 渠道没建 / 图标无效 | 补用途说明、渠道前置、smallIcon |
PendingIntent 抛 IllegalArgumentException | 12+ 没声明可变性 | 加 FLAG_IMMUTABLE |
INSTALL_PARSE_FAILED_MANIFEST_MALFORMED | 12+ 组件缺 android:exported | 全量补齐 |
| App Links 打开浏览器或弹选择框 | assetlinks.json 验证失败 | 查发布签名指纹、大写、无重定向、Content-Type、autoVerify |
| 热启动链接参数丢了 | 只在 onCreate 读 intent.data | onNewIntent + 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 和目标机型为准,文中的包名、域名、指纹都是占位,落地记得换掉。