资讯动态

Android多Module常驻:前台Service、双进程与定时兜底

发布时间:2026/10/24 4:43:59 来源:尧图企业网站定制
简介围绕进程常驻与优先级调整的实践型工程包面向希望理解服务保活机制、进程调度与后台运行策略的开发者可用于课程实验、项目预研和方案对照。包内共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:nameandroid.permission.FOREGROUND_SERVICE / !-- API 34 起dataSync 类型必须单独申请对应权限 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_DATA_SYNC / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / application service android:name.foreground.GuardForegroundService android:exportedfalse android:foregroundServiceTypedataSync / /applicationFOREGROUND_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 10foregroundServiceType开始生效声明dataSync并按版本传 type 参数API 31 / Android 12后台启动前台服务受限抛ForegroundServiceStartNotAllowedException只在用户可见时机点击、界面回前台启动不自作主张在后台起API 33 / Android 13通知需运行时权限引导页申请拒绝后降级为无通知模式并记录API 34 / Android 14类型必须声明且需对应权限manifest 运行时双检查API 35 / Android 15dataSync类型有累计时长上限长连接场景改用其它类型或接受被系统停掉最后一行值得单独说。较新版本对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 与 linkToDeathguard模块里定义了一个极简的 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. 旁路 module1 像素 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 nameTinyTheme parentandroid:style/Theme.Translucent.NoTitleBar item nameandroid:windowBackgroundandroid:color/transparent/item item nameandroid:windowIsTranslucenttrue/item item nameandroid:windowNoTitletrue/item item nameandroid:windowAnimationStylenull/item item nameandroid:backgroundDimEnabledfalse/item /style失效场景非常明确API 29 之后后台启动 Activity 被限制除非应用有「在其他应用上层显示」权限或者处于豁免状态否则startActivity会被静默丢弃。日志里不会报错只是 Activity 没起来。所以这个模块在工程里的定位是「用户正在使用应用时切换页面的间隙顺带提权」不是后台拉起手段。另外onPause里立刻finish是必要的用户一旦切走这个 Activity 就变成不可见留着只会占内存还可能在最近任务里留下一个空白卡片。4.2 静音音频循环资源、AudioAttributes 与省电策略原理是播放一段无声的音频文件让系统把进程归类为「正在播放媒体的应用」在某些版本上能获得更宽松的后台待遇。工程里的资源是一个 1 秒的静音 wav循环播放音量设 0AudioAttributes用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:exportedtrue intent-filter action android:nameandroid.accounts.AccountAuthenticator / /intent-filter meta-data android:nameandroid.accounts.AccountAuthenticator android:resourcexml/authenticator / /service service android:name.sync.GuardSyncService android:exportedtrue intent-filter action android:nameandroid.content.SyncAdapter / /intent-filter meta-data android:nameandroid.content.SyncAdapter android:resourcexml/sync_adapter / /serviceauthenticator.xml里要写accountType和accountPreferencessync_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时没有unlinkToDeathbinder 驱动侧一直持有这个对象。另外ServiceConnection作为匿名内部类持有 Activity 引用时Activity 销毁后连接没解绑整条引用链都泄漏。解决DeathRecipient放在长生命周期对象里工程里挂在GuardClient上不是 Activityrelease()里先unlinkToDeath再unbindService。如果拿不到 binder 引用unlinkToDeath会抛NoSuchElementException用runCatching包住并记录不要因为解绑失败就不解绑。用内存分析工具抓一次堆能看到DeathRecipient的实例数等于绑定次数基本就实锤了。5.4 现象重启手机后 JobScheduler 任务不执行原因三个可能——setPersisted(true)提交时没有RECEIVE_BOOT_COMPLETED权限schedule()返回RESULT_FAILURE但没人检查或者用户手动在设置里「强行停止」过应用强行停止会清掉所有持久化任务且不恢复或者任务本身执行时抛了未捕获异常jobFinished没调系统会认为任务一直没结束。解决schedule()的返回值必须判断非RESULT_SUCCESS就打日志带上错误码。onStartJob里所有逻辑包try-finallyfinally里调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 条超了覆盖最早的。这个东西在事后排查时比任何日志框架都直接尤其是用户反馈「昨天下午收不到消息」这类没有具体时间的场景翻一遍事件流就能看到那个时间段到底发生了什么。从那以后我每次接常驻相关的需求都强制先把这三条检查线跑一遍再动代码不跑就不改。很多时候你想加的那个手段在数据上根本不解决问题——真正的问题可能是心跳策略太粗或者重连退避参数设错了跟进程活不活没关系。希望这套拆法和这几条检查线能帮你在自己的工程里少绕两圈。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑