先回答一个很多开发者都问过的问题:某天你做性能分析或者排查崩溃,打开系统进程列表,发现某个关联功能模块的进程竟然是“Running”,而且它的整个 Functional Group 的状态列表里只有孤零零的一个“Running”。你心里第一个反应肯定是:我从来没有主动打开过它,它是什么时候被拉起来的?
这个现象在移动开发里太常见了。很多人第一反应是“系统自动启动”,也有人怀疑是 SDK 自己搞的鬼,还有人说是不是被杀毒软件/安全中心拉起来的。但这些回答都太笼统。要真正搞清楚“它是怎么被拉起的”,得从操作系统对应用进程的管理机制说起。
这篇文章我会把“Functional Group”的状态模型、进程被拉起的底层入口、六条最常见的启动路径、还有用 dumpsys 和 logcat 做“案发现场”排查的方法一次讲清楚。不管你是做 Android、iOS,还是搞逆向分析,这篇都能给你一套能直接上手的排查思路。
1. 先把概念对齐:Functional Group、states、Running 到底指什么
1.1 功能组(Functional Group)不是玄学,是进程分组
在移动操作系统里,一个 APP 往往不是一个单独的进程。尤其现在大厂的应用都是“多进程架构”,常见的有主进程、push 进程、工具进程、地理位置进程、WebView 独立进程等等。系统为了方便统一管理这些兄弟姐妹进程,会按照“功能”把它们归到一个逻辑分组里,这就是标题里说的 Functional Group。
以 Android 为例,默认情况下同一个 APK 里不同android:process属性的组件会跑在不同进程里,但它们共享同一个 UID、同一个 Application 信息,在 ActivityManagerService 眼中它们都属于同一个“应用分组”。系统做内存回收、进程优先级判断、状态统计的时候,都是站在这个分组的视角去汇总的。
iOS 上也有类似概念。由于 iOS 的应用通常只用一个进程,更多时候是后台 Activity、Extension、BGTask 之间的协作,系统层面通过 launchd 和 FrontBoard 来维护进程的活动状态。不过 iOS 的 Functional Group 概念不如 Android 那么“显式”,它更多体现在“同一个 App Group 容器内的多个进程(比如主 App + 扩展)共享 group 状态”上。
1.2 states 里的 Running 到底代表什么
states(状态集合)是系统对一个进程组当前活动状态的统一描述。一个进程组的完整状态可以包含好几个维度,比如:
- 是否有前台 Activity
- 是否有正在运行的 Service
- 是否有被绑定的状态
- 是否有活动的 ContentProvider
- 是否有可见但非前台的窗口
而标题里“只有 Running”这个描述非常有信息量。它说明这个进程组里没有任何一个 Activity 处于前台可见状态,也没有“暂停”“停止”“缓存”等其他标记,只有一个纯正的“运行中”。换句话说:这个进程不是用户正在使用的,但它确实活着,而且在后台执行着某些代码。
这个问题拆开揉碎之后的核心是:一个用户没有主动打开过的应用,进程管理的状态机里凭什么会出现 Running?是哪些入口能触发系统为它创建一个进程并让它跑起来?
1.3 一个常见的误解:“Running”不等于“前台运行”
很多刚接触的人会把 Running 理解成“用户正在使用”。实际上在 Android 的进程状态模型里,Running 只是个相对宽泛的概念。它意味着进程存在且被系统标记为“非死缓”,但它的具体 adj(oom_adj,即 Out-Of-Memory Adjustment 值)可以是前台、可见、服务、后台等不同档次。
换句话说,Running 状态的进程也可能是系统马上就要杀掉的后台进程。这也是为什么“只有 Running”这个问题值得深挖:它反映了某一时刻进程组处于“非交互但存续”的状态,而我们真正想知道的是,是谁把它从“不存在”变成“存在”的。
2. 应用被拉起的底层原理:谁有权限让一个“死人”复活
2.1 进程创建的总入口:Zygote 与启动请求
在 Android 里,所有应用进程都是由 Zygote 进程 fork 出来的。注意,没有任何应用能自己去“创建进程”,它只能向系统服务发起“启动请求”。真实流程是这样的:
- 某个发起方(系统服务、系统广播、另一个 APP 的组件)向 ActivityManagerService 提交一个启动请求。
- AMS 检查目标进程是否存在。如果不存在,就向 Zygote 发送 socket 指令,让它 fork 出一个新进程。
- 新进程启动后会绑定 Application、创建 Instrumentation、加载目标组件。
- 最后 AMS 把这一次启动请求“交付”给新进程,让对应组件跑起来。
也就是说,“被拉起”的本质是:有人向 AMS 提交了合法请求,而 AMS 判断你有资格被拉起来。搞清楚是谁提交了请求,就等于找到了拉起的源头。
2.2 四大组件:Android 拉起进程的所有“把手”
Android 应用进程不是凭空运行的,它必须依托于至少一个组件。系统拉起进程的唯一方式,就是告诉它“你需要运行某个组件”,而这个组件一定是四大组件之一:
- Activity:通过
startActivity()启动,启动后进入前台或后台栈。 - Service:通过
startService()或bindService()启动。 - BroadcastReceiver:通过
sendBroadcast()触发,系统会临时创建进程来执行onReceive()。 - ContentProvider:通过
query/insert/update/delete等操作触发,访问那一刻进程会被创建。
所以当你看到某个“Functional Group”是 Running 状态时,逻辑上必然是以上四种组件中的某一种被激活了。顺着这条线索排查,方向就非常清晰:找哪个组件被谁触发了,就能定位到拉起的源头。
2.3 iOS 的拉起机制有哪些不同
iOS 跟 Android 的思路差别很大。iOS 不允许应用随意在后台运行,系统对进程的启动有严格管控。总入口是 launchd 和 FrontBoard。
- 用户点击图标时,SpringBoard 请求 FrontBoard 启动进程。
- 收到 APNs 推送时,推送服务会唤醒应用,让它执行一小段代码。
- 后台任务(BGTaskScheduler)、远程通知、PushKit、后台定位等,都有各自独立的拉起通道。
iOS 里没有“进程组状态列表”这种直观的调试界面,但 Xcode 的 Debug Navigator、Instruments 里的 Launch 分析,能帮你看到 App 是怎么被启动的。如果你做的是 iOS 逆向,可以用launchctl print去看系统里的 launchd 任务状态。
3. 六条最常见的“Running”拉起路径
3.1 用户主动行为:最朴素但别忘了它
很多人排查时最先忽略的就是用户行为。你会觉得“我的测试机明明没有打开过它啊”,但实际场景里用户可能:
- 在最近任务列表里滑过一下某个页面
- 点击了桌面小组件(App Widget)中的按钮
- 通过通知栏点击了一条推送
- 通过第三方 App 的“跳转”按钮打开了你应用的某个页面
这些行为都会触发 Activity 启动。哪怕页面很快就退出了,进程一旦被创建,就会保持在 Running 状态,之后再被各种后台任务续命。在排查时永远把“用户最近是否真的碰过这个应用”作为第一假设,再去看别的路径。
3.2 系统推送与消息通知:高频拉活主力
推送是后台拉起应用的最大来源之一。
在 Android 上,FCM(Google Play 服务)推送通过onMessageReceived()让应用进程起来;国内厂商推送(小米、华为、OPPO、vivo)也都有自家通道,系统级推送服务会唤醒应用进程或者直接调用某个 Service 组件。
具体到进程状态上,推送到达时:
- 如果应用进程不存在,系统会创建进程并投递广播/回调。
- 回调执行完,进程不会被立刻杀死,而是按照 adj 规则进入后台缓存。
- 于是你的 Functional Group 里就多了一个 Running。
iOS 上则是 APNs 或 PushKit。普通推送不一定能拉起进程到后台执行,但 PushKit 收到的 VoIP 推送可以直接唤醒 App,哪怕 App 被系统挂起也能在后台拿到回调,这类机制在“进程被悄悄拉活”的场景里非常典型。
3.3 定时器与后台调度:精准到分钟级的自动拉起
这一条是“最隐蔽”的启动路径。很多应用没有用户主动触发,却能周而复始地保持 Running,核心就是定时任务。
Android 里有两个家族:
- AlarmManager:通过
setExactAndAllowWhileIdle()或setRepeating()设置闹钟,到点之后系统直接广播给应用。应用进程不存在也会被拉起。 - WorkManager:继承自 JobScheduler,根据电量、网络、时间窗口等条件评估执行时机。它同样会在满足条件时让进程跑起来。
这两个机制有一个关键点:它们在“进程不存在”时依然有效。AlarmManager 的 PendingIntent 被触发时,系统会找到目标组件并申请创建进程。这就是为什么你明明清理了后台,过一段时间它又活过来了。
iOS 这边对应的是 BGTaskScheduler。你可以注册BGAppRefreshTask和BGProcessingTask,系统会根据使用习惯在后台择机唤醒应用。另外background fetch也是类似机制,系统自主决定什么时候唤醒、唤醒几次,不是开发者能强行决定的。
3.4 跨应用通信:被别人用 Intent / Scheme 拉起来
这种路径在“应用互跳”场景里特别常见。比如:
- 电商 App 唤起支付 App
- 社交 App 唤起地图 App
- 网页里用 URL Scheme(如
myapp://open)唤起应用 - 通过
Intent携带ComponentName显式启动
当其他应用调用startActivity()或者bindService()时,如果目标应用进程不存在,AMS 会以调用方的名义发起进程创建请求。这跟用户点击效果一样,都走 Activity/Service 启动路径。
需要注意的是,Android 11 之后对“可见应用可以启动其他应用”有包可见性限制,但系统级的拉起不受影响。跨应用调用只要声明了权限,依然能把你的进程从“无”直接拉到 Running。
3.5 系统广播事件:被动触发的“鬼探头”
系统广播是“进程被莫名拉起”的重灾区。以下这些事件只要发生,所有注册了对应权限的应用都可能被拉起来:
- 开机广播
BOOT_COMPLETED - 网络变化广播
CONNECTIVITY_CHANGE - 充电状态广播
ACTION_POWER_CONNECTED - 屏幕解锁广播
- 应用安装/卸载广播
- 时区变化、语言切换、闹钟事件
问题在于,有些第三方 SDK 会悄悄注册各种系统广播,用来做一些“保活”或数据上报。应用进程被杀掉之后,SDK 还能靠注册在系统里的广播接收器被重新拉起来。这一点在dumpsys activity的输出里可以看到所有注册的动态接收器。
但注意 Android 8.0 以后,清单文件里静态注册的隐式广播大部分被限制了,只有少数系统广播豁免。现在更多是动态注册 —— 进程在活着时注册,广播发生时若进程还在则直接回调;若进程不在,系统因为没人转发,反而不会拉活。所以这条路径更多是“维持 Running”,而不是“从零创建进程”。
3.6 前台服务与长连接:挂上“免死金牌”的 Running
最后一条是最容易解释“为什么它一直 Running”的。如果你的应用里存在一个前台服务(Foreground Service),系统会强制保证进程优先级非常高,几乎不会被杀死。
常见场景:
- 音乐播放:后台播放音乐时,进程是 Running 的。
- 导航应用:开着导航,服务是前台服务。
- 运动健康类应用:计步、心率监测,通过前台服务持续采集数据。
- 推送 SDK 的“保活”服务:部分 SDK 会用一个前台服务挂着,配上一条常驻通知。
这个是“功能组状态只有 Running”的最正统解释:它不是被拉起的,而是作为服务创建之后就一直跑着,中间因为有高优先级,垃圾回收也不敢动它。它的生命周期非常长,从服务启动到用户主动停止或者系统重启,中途都不会有“不存在”阶段。
4. 现场排查:怎么确定它是被谁拉起的
4.1 Android:三板斧抓“真凶”
排查“谁拉起了我的进程”第一步,用dumpsys activity processes看进程详情:
adb shell dumpsys activity processes输出里重点看两个字段:
mProcesses里每个进程的pid、uid、processNamemProcessObservers和mPendingStartActivity相关的日志
如果你能拿到进程启动那一刻的系统日志,直接搜Start proc关键字:
adb logcat -d | grep "Start proc"这一条日志会非常明确地说出启动来源,格式类似于:
Start proc 12345:com.example.app/u0a123 for service com.example.app/.PushService最后面那个for ...就是拉起原因。上面这个例子明确显示是为了启动PushService。如果是 Activity,会写成for activity com.example.app/.MainActivity;如果是广播,会写成for broadcast com.example.app/.MyReceiver。
光有Start proc还不够,我得知道是谁发的请求。继续往上翻日志,找 ActivityTaskManager 或 ActivityManager 输出的 am_ 相关日志。比较实用的是:
adb logcat -d | grep -E "am_|ActivityManager" | grep -i start你会看到一串带调用者身份的日志。
4.2 用 Frida 直接从函数层确认
如果logcat信息不够,或者你想看清 Binder 调用链,可以用 Frida hook 住关键 API。
对 Android 来说,最核心的是 hookActivityManagerService或ActivityTaskManager的启动方法。不过这类 hook 在最新系统上有点繁琐,更简单的方式是直接 hook Zygote 的fork入口,或者 hook 目标进程的ActivityThread.main(),在它启动时打一个堆栈,看调用是从哪个 Binder 线程进来的。
// frida -U -f com.example.app --no-pause -l launch.js Java.perform(function () { var ActivityThread = Java.use('android.app.ActivityThread'); ActivityThread.main.overload('java.lang.String', 'boolean', 'boolean').implementation = function (a, b, c) { console.log('[*] ActivityThread.main called'); console.log(Java.use('android.util.Log').getStackTraceString(Java.use('java.lang.Throwable').$new())); return this.main(a, b, c); }; });这里打出来的堆栈里,如果顶层是Binder调用,那说明是系统进程通过 Binder 请求创建的。再结合堆栈里的parcel数据,基本能判断出是哪个系统服务。
4.3 iTunes Style:iOS 侧的排查方式
iOS 上做这类排查没有 Android 那么直接,但还是有招可用。核心是看系统日志里的launchd和RunningBoard记录。
在 Mac 上打开 Console.app,筛选进程名和launchd,搜FrontBoard相关日志。也可以执行:
launchctl print gui/501看看有哪些Label对应的进程处于state = running,然后看它的program指向哪个 App 的Executable。
更直接的场景是:如果你收到了 PushKit 唤醒,在系统日志里搜PushKit和你 App 的 Bundle ID,能看到完整的唤醒链路。如果 App 被 BGTask 拉起来,系统的RunningBoard日志会有对应的task描述。
4.4 一个实战排查案例:为什么我的应用总在凌晨四点 Running
我以前排查过一个线上问题:某应用在凌晨四点准时变 Running,用户并没有使用它,而且时间点很固定。我的排查步骤是这样:
第一步,用dumpsys alarm看所有闹钟:
adb shell dumpsys alarm | grep com.example结果发现凌晨三点五十九分有一个来自某 SDK 的AlarmManager任务,包名指向一个数据上报模块。继续往下看,dumpsys alarm里直接打印了发起者的PendingIntent创建来源:com.example.sdk/.DataReportReceiver。
第二步,再到logcat -d | grep "Start proc"里确认:
Start proc 28716:com.example.app/u0a123 for broadcast com.example.sdk/.DataReportReceiver证据链闭合:SDK 在用户设置闹钟后,凌晨四点让系统发广播,系统把进程拉起来拿到 Running 状态。整个链路就是这么短。
5. 写代码时怎么控制“被拉起”这件事
5.1 组件最小暴露:别让你的四大组件随便被人拿到
作为开发者,要想减少应用被莫名拉起的场景,第一条准则就是“组件不要乱暴露”。在 AndroidManifest 里,每个组件默认都有被外部应用直接start的风险,除非你加了android:exported="false"。
注意事项:
- 不需要被外部调用的 Activity、Service、Receiver 一律显式设置
android:exported="false"。 - 必须对外暴露的组件,用
android:permission限制只有声明了对应权限的调用者才能启动。 - 不要用隐式 Intent 唤醒自己内部的组件,省得别的应用用同一个 action 把你劫持或触发。
- 注册动态广播时,建议把
RECEIVER_EXPORTED设为 false(Android 13+ 上动态注册广播必须显式声明 flag)。
5.2 谨慎使用“保活”方案,别让 Running 变成性能毒瘤
很多人看到自己的进程老是 Running,第一反应是想再加一个保活机制让它更“稳”。但我的建议是反过来:先搞清楚它为什么 Running,再决定要不要保活。
如果某个 SDK 用前台服务保活,但它并没有真实的用户价值——不播音乐、不在导航、不采集运动数据——那这个前台服务就是纯耗电。Android 系统会统计这类高耗电行为,用户也能在电池详情里看到你应用在后台的高活跃度,最终用户只有卸载一条路。
追求“进程永远活着”这个目标本身就不健康。Android 的设计哲学是进程可以被随时回收,应用只要保证在收到广播、定时器、推送时能迅速做出响应就够了。真正的稳定不是靠进程活多久,而是靠组件能在各种启动场景下正确执行。
5.3 后台启动限制:记住 Android 的这些红线
Android 不同版本对后台拉起管得越来越严:
- Android 8.0(API 26):限制后台 Service 启动,禁止大部分隐式广播。
- Android 10(API 29):限制后台启动 Activity。
- Android 12(API 31):新增前台服务启动限制,后台应用不能随意启动前台服务。
- Android 14(API 34):强制要求前台服务必须声明明确的前台服务类型。
如果看到“我的 App 在后台能自动运行”这种需求,合理的落地方案是WorkManager或者高优先级的 FCM 推送,而不是去硬怼系统限制。这个经验是从无数被厂商安全系统弹窗警告的项目里总结出来的。
5.4 自查清单:从清单文件开始做一次体检
最后给一份自查清单,你可以对着自己的 AndroidManifest.xml 过一遍:
- 有没有
RECEIVE_BOOT_COMPLETED权限?如果有,对应的广播接收器是否真的需要开机拉起? - 有没有在清单里静态注册了隐式广播?现在系统基本都收不到了,留着只会让扫描工具报警。
- Service 是否都设置了
stopWithTask或stopSelf()的合理时机? - 有没有长时间占用
WAKE_LOCK?WakeLock 也是让进程保持 Running 的重要原因。 - 第三方 SDK 的 Manifest 合并结果里有没有多余的组件?用
adb shell dumpsys package com.example.app看一下requested permissions和activities,对照文档找出不认识的。
写到最后的一点体会
把“Functional Group 的 states 只有 Running”这个问题彻底想明白,你就等于理解了一整个移动操作系统的进程管理套路。排查它的过程有点像侦探破案:先看尸体状态(Running),再找案发时间(日志时间点),接着找作案工具(Alarm/Intent/Broadcast),最后找凶手(发起拉起的调用方)。
我个人在排查这类问题时的最大体会是:别被“只有 Running”这几个字带偏。这个状态只告诉你“它活着”,并没有告诉你“它干过什么事”。真正有用的信息在进程启动那一刻的系统日志里。只要它被拉起过,系统就一定会留下记录,最常见的Start proc日志会精确到启动来源、拉起组件和启动原因。下次再遇到异常 Running,别急着加保活或者删代码,先拉一条完整的启动链路出来,答案往往就在里面。
最后再补一个小技巧:做这种分析时,尽量用一台不常插着充电线的测试机。因为充电状态下系统策略会宽松很多,一些本不该发生的拉起也会发生,容易误导判断。真想模拟真实用户环境,就在拔掉电源、息屏、清理完后台的状态下盯logcat,这才是最接近线上环境的“案发现场”。