简介:围绕进程常驻与优先级调整的实践型工程包,面向希望理解服务保活机制、进程调度与后台运行策略的开发者,可用于课程实验、项目预研和方案对照。包内共69个文件,以20个Java源码、18个XML配置与布局、20张PNG截图、2个AIDL接口定义为主,另含Gradle构建脚本、jar依赖和工程配置,压缩包约16.9MB。目录中可见csdnactivity与servicelive1等多个module,便于观察服务声明、模块拆分及接口调用关系。已有319人学习,适合作为守护进程、服务注册、启动脚本、nice值与Windows优先级类等知识的参考样本,帮助读者从工程结构入手排查服务生命周期、资源占用和兼容性问题。
1. 从一次后台断连说起:多 module 常驻工程到底能保住什么
后台连接被掐断的那一刻,前端页面什么都看不出来——心跳不发了,长连接静默断开,用户只觉得「怎么半天没消息」。做进程常驻的人大多是从这个现象入门的:Application 里挂个 service,锁屏几分钟被系统回收,日志停在onDestroy之前,连像样的栈都抓不到。手头这个资源是一套以 service 为核心的多 module 常驻探索工程,把前台服务、双进程守护、定时任务兜底、像素页与静音音频几条线拆成独立模块,每个模块能单独编译、单独验证、单独摘掉。它适合已经踩过后台被杀、想搞清楚每条路上限和代价的 Android 开发者;如果你找的是「一键杀不死」,可以直接关掉——这套东西给的是可观测、可降级、可关闭的存活策略,不是对抗系统的黑魔法。
2. 前台 Service 打底:通知渠道、类型声明与 startForeground 时序
工程拆成六块:app是宿主壳,core放公共封装和状态机,foreground是前台服务,guard管双进程与死亡重连,scheduler做定时兜底,tricks收集那几个旁路手段。第一次拆包建议先把foreground单独跑起来,因为它是整套方案里唯一被系统正式承认的底座,其余五个模块的价值都建立在它站得住的前提上。
2.1 为什么前台 Service 是唯一能站住脚的底座
Android 的进程优先级是分档的:前台进程 > 可见进程 > 服务进程 > 缓存进程。普通的startService起来的东西属于服务进程档,内存一紧张就是第一批被回收的对象,而且这个档位里没有任何 API 能让系统「手下留情」。前台服务不一样,它把一条常驻通知绑在服务上,等于向系统声明「用户在感知这个服务」,进程被提到前台档,被回收的顺序排到后面。
这条路径的价值不在于「绝对不死」,而在于它是唯一没有版本断层的方案。从 API 26 到 API 35,前台服务的规则一直在收紧——通知渠道、foregroundServiceType、启动时机限制、类型对应权限——但收紧的方向始终是「管得更细」,而不是「取消这条路」。相比之下,双进程守护在 API 26 之后基本被打断,1 像素 Activity 在 API 29 之后从后台拉起会被拦,静音音频在部分定制 ROM 上会被单独识别为「耗电应用」。所以工程的默认配置是:前台服务先起,其它模块按需挂在它下面,主机型上只保留前台服务也能跑。
需要提前想清楚的一点是通知的重要性等级。很多人一上手就把渠道设成IMPORTANCE_LOW以上,结果通知横幅反复弹,用户第一件事就是去关通知,关完顺手把服务也停了。工程里默认走IMPORTANCE_MIN,通知被折叠到前台服务区,不发声不振动,用户仍然可以长按停掉,但不会被打扰。这是取舍,不是技巧。
2.2 从 startForegroundService 到 startForeground:一份能跑的最小实现
API 26 之后后台进程不能直接startService,必须先startForegroundService,然后在 5 秒内调用startForeground,否则抛RemoteServiceException直接把应用干掉。这 5 秒是硬窗口,任何在中间做网络请求、读大文件、等初始化回调的写法都会翻车。工程里的做法是把通知构建压缩到onCreate里同步完成。
// foreground/GuardForegroundService.kt class GuardForegroundService : Service() { companion object { private const val CHANNEL_ID = "guard_channel" private const val NOTI_ID = 1001 // 停止服务用独立 action,避免外部直接 stopService 绕过状态机 const val ACTION_STOP = "com.example.keepalive.STOP" } override fun onCreate() { super.onCreate() ensureChannel() // 渠道必须在 startForeground 之前建好 startAsForeground() // 同步执行,不放进协程,压缩 5 秒窗口风险 } private fun ensureChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val mgr = getSystemService(NotificationManager::class.java) if (mgr.getNotificationChannel(CHANNEL_ID) == null) { val ch = NotificationChannel( CHANNEL_ID, "链路维持", NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰 ).apply { setShowBadge(false) enableVibration(false) setSound(null, null) lockscreenVisibility = Notification.VISIBILITY_SECRET } mgr.createNotificationChannel(ch) } } } private fun startAsForeground() { val noti = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_guard) .setContentTitle("链路维持中") .setContentText("保持消息通道连接") .setOngoing(true) // 用户不能直接划掉 .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .build() // API 29 起支持显式传 type,与 manifest 声明保持一致 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTI_ID, noti, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { startForeground(NOTI_ID, noti) } } // START_STICKY:进程被杀后系统重建服务,onStartCommand 收到 null intent override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { stopSelf() return START_NOT_STICKY } return START_STICKY } override fun onBind(intent: Intent?): IBinder? = null }onCreate里就完成渠道创建和startForeground,是为了把 5 秒窗口压到最短;startForeground里传FOREGROUND_SERVICE_TYPE_DATA_SYNC必须和 manifest 的foregroundServiceType一致,不一致在 API 34 上会直接抛异常。START_STICKY的含义是进程被杀后系统尝试重建服务,但重建时intent是null,也就是说onStartCommand里那段读 action 的逻辑必须对 null 做防御,否则空指针又是一次崩溃。
manifest 侧的声明同样容易漏:
<!-- app/src/main/AndroidManifest.xml --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- API 34 起,dataSync 类型必须单独申请对应权限 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application> <service android:name=".foreground.GuardForegroundService" android:exported="false" android:foregroundServiceType="dataSync" /> </application>FOREGROUND_SERVICE是基础权限,FOREGROUND_SERVICE_DATA_SYNC是类型权限,两个都要写;POST_NOTIFICATIONS在 API 33 上属于运行时权限,用户拒绝之后前台服务本身还能起,但通知不展示,用户体验上等同于「后台偷偷跑」,这在部分定制 ROM 上会被额外判定为异常行为,建议在引导页里做一次权限申请,别默认用户会给。
2.3 参数与时序表:5 秒窗口、type 与权限怎么配
把版本相关的差异压成一张表,改代码时对着查比翻文档快:
| 版本节点 | 变化点 | 工程里的应对 |
|---|---|---|
| API 26 / Android 8 | 后台startService被禁,需startForegroundService且 5 秒内startForeground | 通知构建全部前置到onCreate同步执行 |
| API 28 / Android 9 | 后台应用访问传感器受限 | 不使用传感器类常驻手段 |
| API 29 / Android 10 | foregroundServiceType开始生效 | 声明dataSync并按版本传 type 参数 |
| API 31 / Android 12 | 后台启动前台服务受限,抛ForegroundServiceStartNotAllowedException | 只在用户可见时机(点击、界面回前台)启动,不自作主张在后台起 |
| API 33 / Android 13 | 通知需运行时权限 | 引导页申请,拒绝后降级为无通知模式并记录 |
| API 34 / Android 14 | 类型必须声明且需对应权限 | manifest + 运行时双检查 |
| API 35 / Android 15 | dataSync类型有累计时长上限 | 长连接场景改用其它类型或接受被系统停掉 |
最后一行值得单独说。较新版本对dataSync类型的前台服务有累计运行时长上限,挂满之后系统会要求应用停止服务,这不是 bug,是设计意图。所以不要把前台服务当成「永久通道」,业务上要设计重连和降级路径:服务被停就退到定时兜底,兜底也失效就靠下次用户打开应用时补拉。
另一个参数是onTaskRemoved的回调时机。用户从最近任务里划掉卡片时系统会调这个方法,注意它只在Service被startService且有任务关联时才可靠触发,走startForegroundService的路径在部分版本上不回调。工程里没用它做拉活主路径,只用来记录一次「用户主动清理」事件,作为统计口径。
提示:前台服务被系统或用户停掉时不会有任何回调,
onDestroy在很多场景下不执行。状态机别依赖onDestroy做清理,改成每次启动时幂等初始化。
3. 双进程守护:AIDL 绑定、死亡回调与 JobScheduler 兜底
双进程守护是一个被过度神话的手段。它的黄金年代在 API 26 之前:A 进程用bindService绑住 B 进程,B 进程也绑住 A 进程,任意一方被杀,另一方的onServiceDisconnected会立刻回调,马上把对方拉起来。API 26 之后后台启动服务的口子被堵死,这个「拉」的动作本身就违规了——你能收到死亡通知,但你没有合法手段把对方重新起来。所以guard模块在这个工程里的定位变了:不是拉活主力,而是「感知死亡 + 通知前台服务做决策」。
3.1 双进程为什么能互拉,以及它在 8.0 之后还剩多少
原理不复杂。Android 的bindService会在客户端和服务之间建立一条 binder 通道,服务进程死亡时,内核会通知 binder 驱动,客户端注册的DeathRecipient就会被回调。早期版本里,客户端在自己的回调里调startService拉起对方,因为这时候客户端还活着,属于「前台进程启动后台服务」,系统不管。
API 26 之后规则改成「后台应用不能启动后台服务」,即使是 A 拉起 B,只要 A 当时不在前台,startService直接抛IllegalStateException。想绕过就得用startForegroundService,但那需要一条通知,而且 API 31 之后连「后台启动前台服务」都被限制了。结论很直接:双进程互拉在 API 26 之后只能作为「前台服务已被用户看到」的场景下的补充,比如用户在应用界面里操作时,A 死了 B 能立刻把它拉回来,用户几乎无感;纯后台状态下这条路走不通。
那还留着它干什么?因为它能给出一份精确的死亡时间线。linkToDeath的回调是实时的,比onDestroy可靠得多,日志里能明确看到「几点几分几秒,对端进程死于哪个 uid」。这套数据在排查「到底是不是系统杀的」时是唯一的证据,比猜有用。
3.2 落地:AIDL 接口、bindService 与 linkToDeath
guard模块里定义了一个极简的 AIDL 接口,只做注册和探活,不传业务数据:
// guard/src/main/aidl/com/example/keepalive/guard/IGuardBridge.aidl package com.example.keepalive.guard; interface IGuardBridge { void register(String processName); // 对端上线时登记 boolean isAlive(); // 探活,比 binder.isBinderAlive 更可控 }客户端侧的绑定与死亡监听:
// guard/GuardClient.kt class GuardClient( private val ctx: Context, private val peerService: Class<*>, private val processName: String ) { private val handler = Handler(Looper.getMainLooper()) private var bound = false // 死亡回调:对端进程挂了 private val deathRecipient = IBinder.DeathRecipient { bound = false Log.w("GuardClient", "[$processName] peer died, retry in 1s") handler.postDelayed({ bindPeer() }, 1000L) } private val conn = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { bound = true binder?.linkToDeath(deathRecipient, 0) // 0 表示不设置死亡通知的 flags runCatching { IGuardBridge.Stub.asInterface(binder)?.register(processName) }.onFailure { Log.e("GuardClient", "register failed", it) } } override fun onServiceDisconnected(name: ComponentName?) { // 主动解绑不会走这里,只有异常断开才回调 bound = false handler.postDelayed({ bindPeer() }, 1000L) } } fun bindPeer() { if (bound) return val intent = Intent(ctx, peerService).apply { action = "com.example.keepalive.BIND_PEER" } // BIND_AUTO_CREATE:对端不存在时创建;不要用 BIND_IMPORTANT,行为随版本变化 runCatching { ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE) } .onFailure { Log.e("GuardClient", "bind failed", it) } } fun release() { if (bound) { runCatching { // 必须 unlink,否则 DeathRecipient 持有外部引用造成泄漏 // 这里拿不到 binder 时直接忽略,进程退出时内核会清理 ctx.unbindService(conn) } bound = false } } }几个细节值得说明。linkToDeath的第二个参数是 flags,传 0 表示只关心死亡事件,不关心复活——binder 没有「复活」概念,进程重建是一个新对象,所以要重新绑定。onServiceDisconnected只在异常断开时回调,主动unbindService不会走它,靠它做「解绑清理」会漏。重连的延迟设成 1 秒是经验值:太短会在对端崩溃循环时打满 CPU,太长用户能感知到断档。重连次数必须设上限,工程里在core模块做了一层计数器,连续失败 5 次就退避到 30 秒一次,避免变成死循环——这个坑踩过一次就记住了,日志里全是一个进程在反复 bind 另一个已经起不来的服务。
3.3 兜底:JobScheduler 与 AlarmManager 的触发参数
前台服务管主干,双进程管感知,兜底交给scheduler。这里有两个工具,选型上有明确差别。
JobScheduler适合做「周期性检查」,最小的setPeriodic间隔是 15 分钟,系统会在 Doze 模式下把任务攒起来集中执行,所以实际间隔可能远超 15 分钟。它的优势是系统统一调度、不额外耗电、setPersisted(true)可以在重启后仍然生效(需要RECEIVE_BOOT_COMPLETED权限)。
// scheduler/GuardJobScheduler.kt fun schedule(context: Context) { val component = ComponentName(context, GuardJobService::class.java) val job = JobInfo.Builder(JOB_ID, component) .setPeriodic(15 * 60 * 1000L) // 系统允许的最小周期,实际会被 Doze 拉长 .setPersisted(true) // 重启后仍有效,需 RECEIVE_BOOT_COMPLETED .setRequiredNetworkType(JobInfo.NETWORK_TYPE_NONE) // 不依赖网络,降低触发门槛 .setRequiresCharging(false) // 充电条件会大幅降低触发率,不要开 .build() val mgr = context.getSystemService(JobScheduler::class.java) if (mgr.getPendingJob(JOB_ID) == null) { // 幂等:已存在就不重复提交 mgr.schedule(job) } }setPeriodic的最小值是硬编码的 15 分钟,传更小的值会被系统按 15 分钟处理,不会有异常,容易误以为生效了。setPersisted(true)要求应用有RECEIVE_BOOT_COMPLETED权限,否则提交时直接返回失败,返回码是RESULT_FAILURE,这个值必须检查,不检查就只能看到「任务没跑」而不知道原因。setRequiresCharging(true)看起来很省电,但在真实设备上会把触发率压到极低,除非业务明确只在充电时需要处理数据,否则不要开。
AlarmManager适合做「到点必须做一次」的场景,比如定时同步。这里的关键参数是setExactAndAllowWhileIdle,它能穿透 Doze 但系统对每个应用的触发频率有限制(Doze 下大约 9 分钟一次),且 API 31 起需要SCHEDULE_EXACT_ALARM权限,用户可以在设置里单独关掉。
// scheduler/GuardAlarm.kt fun scheduleExact(context: Context, triggerAt: Long) { val mgr = context.getSystemService(AlarmManager::class.java) // API 31 起精确闹钟需要用户授权,未授权时降级为非精确 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !mgr.canScheduleExactAlarms()) { mgr.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent(context) ) return } mgr.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent(context) ) }RTC_WAKEUP表示用绝对时间并且会唤醒设备,代价是耗电;如果业务对时间不敏感,用ELAPSED_REALTIME系列能省不少电。canScheduleExactAlarms()必须在每次调度前查,用户可能在运行过程中去设置里关掉,缓存这个值会崩——SecurityException在崩溃平台上是很常见的一类。
注意:不要用
setRepeating做短周期轮询,API 19 之后所有重复闹钟都是非精确的,setRepeating的间隔只是「最短间隔」而不是「实际间隔」,实际触发会被系统合并,看起来像丢了任务。
4. 旁路 module:1 像素 Activity、静音音频与账号同步的边界
tricks模块里放的是三个流传很广但没有一个能当主力的手段。把它们单独拆出来是有意的:这三个东西的失败模式各不相同,混在主工程里会让人误以为是「组合拳」,实际上每一个都有明确的适用边界,用错了只会增加耗电和崩溃率。这一章把每个手段的触发条件、失效场景和退场策略都说清楚,方便你决定要不要启用。
4.1 1 像素透明 Activity 的触发条件和失效场景
思路是把进程优先级从「服务进程」提到「可见进程」:弹一个 1×1 的透明 Activity,用户视觉上感知不到,但系统认为有一个界面在前台。理论上可见进程的回收优先级高于服务进程,能在内存压力下多活一会儿。
实现上是三段:主题设透明、Activity 尺寸设 1dp、启动后立刻finish但保留一段时间。工程的写法是不finish,而是在onResume里起一个延迟任务,若干秒后再关:
// tricks/TinyActivity.kt class TinyActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 不 setContentView,窗口背景由主题控制为透明 window.setLayout(1, 1) // 尺寸压到 1dp,减少渲染开销 } override fun onResume() { super.onResume() // 停留 3 秒后自动关闭,避免长期占用可见进程档位引发用户察觉 Handler(Looper.getMainLooper()).postDelayed({ finish() }, 3000L) } override fun onPause() { super.onPause() finish() // 任何失焦都立即退出 } }对应主题必须关闭动画和标题栏,否则启动时会有一个短暂的窗口缩放入场动画,肉眼可见,用户会以为应用闪了一下:
<!-- tricks/src/main/res/values/styles.xml --> <style name="TinyTheme" parent="@android:style/Theme.Translucent.NoTitleBar"> <item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowNoTitle">true</item> <item name="android:windowAnimationStyle">@null</item> <item name="android:backgroundDimEnabled">false</item> </style>失效场景非常明确:API 29 之后后台启动 Activity 被限制,除非应用有「在其他应用上层显示」权限或者处于豁免状态,否则startActivity会被静默丢弃。日志里不会报错,只是 Activity 没起来。所以这个模块在工程里的定位是「用户正在使用应用时,切换页面的间隙顺带提权」,不是后台拉起手段。另外onPause里立刻finish是必要的:用户一旦切走,这个 Activity 就变成不可见,留着只会占内存,还可能在最近任务里留下一个空白卡片。
4.2 静音音频循环:资源、AudioAttributes 与省电策略
原理是播放一段无声的音频文件,让系统把进程归类为「正在播放媒体的应用」,在某些版本上能获得更宽松的后台待遇。工程里的资源是一个 1 秒的静音 wav,循环播放,音量设 0,AudioAttributes用USAGE_MEDIA。
// tricks/SilentAudio.kt class SilentAudio(private val ctx: Context) { private var player: MediaPlayer? = null fun start() { if (player != null) return player = MediaPlayer().apply { // 静音资源放在 raw 下,1 秒长度,循环播放 val afd = ctx.resources.openRawResourceFd(R.raw.silence) setDataSource(afd.fileDescriptor, afd.startOffset, afd.declaredLength) afd.close() setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) isLooping = true setVolume(0f, 0f) // 双通道都设 0,不依赖系统音量 prepare() start() } } fun stop() { player?.run { runCatching { stop() } release() // 必须 release,否则 native 层持有引用 } player = null } }setVolume(0f, 0f)和系统媒体音量是两回事,前者是播放器内部增益,即使系统音量开到最大也不会有声音,这一点比调系统音量安全得多——改系统音量会直接影响用户其它应用,属于明确不该做的行为。
真正的坑在省电策略上。部分定制 ROM 会把「后台长期持有音频焦点」识别成异常耗电行为,直接杀掉进程并标记;另一些 ROM 会在息屏后主动释放音频焦点,导致MediaPlayer进入 error 状态但不抛异常,播放器看着还在,实际已经停了。工程里的处理是注册AudioManager.OnAudioFocusChangeListener,失焦就停,重新获得再起,同时在setOnErrorListener里做重建,最多重建 3 次,超过就放弃这个手段。
还有一点容易被忽略:音频焦点是全局资源。哪怕音量是 0,占了焦点也会影响用户其它应用(比如音乐播放器被暂停)。启用之前必须在core里判断当前是否有其它应用持有焦点,有就别抢——这是产品层面的礼貌问题,也是被投诉最多的地方。
4.3 账号同步与自启动白名单引导
账号同步是这三个里最「正规」的一个,因为它走的是系统提供的账户框架。注册一个账户类型,用户授权后系统会定期触发同步,同步时拉起进程。它的问题是链路长、依赖用户主动添加账户,而且同步周期由系统决定,不可控。
配置上需要在 manifest 里声明 authenticator 和 sync adapter:
<!-- tricks/src/main/AndroidManifest.xml 片段 --> <service android:name=".sync.GuardAuthenticatorService" android:exported="true"> <intent-filter> <action android:name="android.accounts.AccountAuthenticator" /> </intent-filter> <meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator" /> </service> <service android:name=".sync.GuardSyncService" android:exported="true"> <intent-filter> <action android:name="android.content.SyncAdapter" /> </intent-filter> <meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_adapter" /> </service>authenticator.xml里要写accountType和accountPreferences,sync_adapter.xml里写contentAuthority和accountType,两处的accountType必须是同一个字符串,否则addAccountExplicitly会返回 false 而日志里只有一行 warning。这个错误排查起来很费劲,因为失败是静默的。
坦白说,这条路的实际收益和复杂度不成正比:用户得先同意加账户,加了之后还得在系统设置里确认同步开关是开的,任何一步不做,整条链路就是摆设。工程里把它放在tricks下并且默认关闭,只在需要「跨设备数据同步」这个真实业务需求时才启用——有业务支撑的账户体系是正当的,纯粹为了保活而加账户,用户和审核都说不过去。
至于自启动白名单,这个模块只提供一个引导页,列出常见机型的设置路径,跳过去让用户自己点。写死的跳转Intent在不同 ROM 上成功率很低,而且组件名属于厂商私有,经常变动,工程里的策略是只提供文字指引加一个「打开应用设置」的通用入口,不做强行跳转。
提示:
tricks下三个手段都应该有独立的开关,默认全关。理由很实际——它们的失败模式是「静默失效 + 增加耗电」,开着不但没收益还会拉低整体稳定性指标。
5. 排查笔记:五个把常驻率打回原形的坑
这一章是踩出来的。以下每一条都在真机上复现过,现象、原因、解决按顺序写,方便你对照日志快速定位。
5.1 现象:前台服务起了,但通知栏什么都没有
原因:API 33 上POST_NOTIFICATIONS是运行时权限,用户没给的时候startForeground调用成功,服务也进入前台状态,但通知不展示。更隐蔽的情况是渠道重要性被设成了IMPORTANCE_NONE(用户手动关掉了这个渠道),这时同一渠道的所有通知都不展示,而startForeground依然返回成功。
解决:启动前用NotificationManager.areNotificationsEnabled()和getNotificationChannel(CHANNEL_ID)?.importance != IMPORTANCE_NONE做双重检查,任一不满足就走降级路径——把渠道换成备用 ID,或者直接提示用户去设置页。排查时先看areNotificationsEnabled的返回值,再看渠道重要性,两个都正常才去查服务状态。
5.2 现象:startForegroundService之后应用直接崩,栈里是RemoteServiceException
原因:5 秒窗口内没调startForeground。最常见的原因是通知构建里用了异步图片加载,或者onCreate里做了数据库初始化,主线程被卡住。另一种情况是startForeground抛了异常被外层try-catch吞掉,服务没进前台状态,5 秒后系统照样算超时。
解决:把startForeground提到onCreate第一行能做的位置,通知先用一个占位图标构建,异步资源加载完成后再notify更新内容。startForeground外层的try-catch只记录日志不要吞,吞掉的后果是延迟 5 秒崩溃,栈信息完全对不上,排查成本翻倍。
5.3 现象:bindService之后内存持续上涨,几小时后 OOM
原因:linkToDeath注册的DeathRecipient持有外部类的引用,unbindService时没有unlinkToDeath,binder 驱动侧一直持有这个对象。另外ServiceConnection作为匿名内部类持有 Activity 引用时,Activity 销毁后连接没解绑,整条引用链都泄漏。
解决:DeathRecipient放在长生命周期对象里(工程里挂在GuardClient上,不是 Activity),release()里先unlinkToDeath再unbindService。如果拿不到 binder 引用,unlinkToDeath会抛NoSuchElementException,用runCatching包住并记录,不要因为解绑失败就不解绑。用内存分析工具抓一次堆,能看到DeathRecipient的实例数等于绑定次数,基本就实锤了。
5.4 现象:重启手机后 JobScheduler 任务不执行
原因:三个可能——setPersisted(true)提交时没有RECEIVE_BOOT_COMPLETED权限,schedule()返回RESULT_FAILURE但没人检查;或者用户手动在设置里「强行停止」过应用,强行停止会清掉所有持久化任务且不恢复;或者任务本身执行时抛了未捕获异常,jobFinished没调,系统会认为任务一直没结束。
解决:schedule()的返回值必须判断,非RESULT_SUCCESS就打日志带上错误码。onStartJob里所有逻辑包try-finally,finally里调jobFinished(params, false),注意第二个参数传false表示不需要重试,传true会让系统按退避策略重排。强行停止的场景无解,只能接受,所以兜底逻辑里要有一条「应用启动时检查并补齐所有定时任务」的幂等路径。
5.5 现象:日志显示服务还活着,但消息就是收不到
原因:服务进程活着不等于网络连接活着。长连接在 NAT 超时、网络切换、息屏省电策略下会被静默断开,TCP 层没有 FIN 包,应用侧以为连接还在。这种情况下服务的心跳逻辑如果只检查「进程是否存活」而不检查「连接是否可用」,就会出现日志一片绿、功能全废的状态。
解决:心跳要有应用层确认,不能只靠Socket.isConnected(这个值在连接断开后依然是 true)。工程里的做法是每 N 秒发一次应用层 ping,超过 2 个周期没收到 pong 就主动重连,重连走指数退避。排查这类问题先看收发字节计数是不是停在某个时间点,再看网络状态回调有没有触发,两个时间点对不上基本就是静默断连。
6. 怎么验证这套 module 真生效了:三条可量化的检查线
调常驻最容易陷入的误区是「看起来在跑」。跑一次adb shell dumpsys activity services看到服务在列表里就以为成了,实际上进程可能已经被降级到缓存档,随时会被回收。我一般用三条线交叉验证:第一是进程优先级,adb shell dumpsys activity processes | grep <包名>看oom_adj值,前台服务应该在 200 上下,可见进程更低,服务进程在 500 以上,缓存进程 900 以上,数值变化的时间点就是被降级的时刻;第二是存活时长,把应用放后台锁屏,每隔 10 分钟发一次 ping 记录响应,画出来的曲线如果在中途断掉,断点就是被杀的时间;第三是电量,adb shell dumpsys batterystats --charged <包名>看单位时间唤醒次数,常驻手段如果让待机功耗涨了 30% 以上,即使存活率提高了也不划算。
我还习惯在core里加一个极简的事件记录器,把服务启动、被停、重连、兜底触发这四类事件带时间戳写到本地环形缓冲区,容量固定 200 条,超了覆盖最早的。这个东西在事后排查时比任何日志框架都直接,尤其是用户反馈「昨天下午收不到消息」这类没有具体时间的场景,翻一遍事件流就能看到那个时间段到底发生了什么。
从那以后我每次接常驻相关的需求,都强制先把这三条检查线跑一遍再动代码,不跑就不改。很多时候你想加的那个手段,在数据上根本不解决问题——真正的问题可能是心跳策略太粗,或者重连退避参数设错了,跟进程活不活没关系。希望这套拆法和这几条检查线,能帮你在自己的工程里少绕两圈。
本文还有配套的精品资源,点击获取