后台服务Service销毁逻辑+单例造成的内存泄露

63 阅读4分钟

代码背景:

  • GlobalTaskService集成自原始的Android Service,并且动态声明为一个前台服务
  • GlobalTaskService在Manifest 声明了 foregroundServiceType="phoneCall"(后续需要追加datasyns ) GlobalTaskService从onCreate就监听AirIMEngine(object单例)来自IM Server端发送来的数据(这是一个很常见的移动端和Server端的交互模型)

问题描述: 当app退到后台,GlobalTaskService按照产品设计预期,可以持续保活接收来自Server端的数据及进行后续处理,但实际现象是app在后台运行一段时间后,GlobalTaskService就会被自动杀掉

基于 GlobalTaskServiceAirIMEngine 的现场问题。将这个问题拆解为三个核心维度:保活机制失效单例内存泄露Android 14 合规陷阱

可以把这份总结作为后续开发后台类应用的技术备忘录。


一、 现场还原:为什么“息屏即死,亮屏诈尸”?

现象描述: 观察到 GlobalTaskService 在息屏时打印 onDestroy,亮屏时打印 onCreate。特别是当 IM 连接建立(持有 Socket)后,这种现象更频繁

核心原因:这也是 Android 系统的“杀手”逻辑

  1. 伪后台服务:你的代码中 startForeground 被注释掉了。对系统而言,这是一个普通的后台 Service
  2. 省电策略 (Doze Mode):当屏幕关闭,系统进入打盹模式。系统检测到App 在后台跑着,还持有一个高耗电的 Socket 连接(IM),但用户界面上没有任何通知(Notification)显示在运行
  3. 系统判定:“这个 App 在偷偷摸摸耗电,杀掉它。” -> 触发 onDestroy
  4. 诈尸重启:因为你在 onStartCommand 返回了 START_STICKY。当亮屏系统资源宽裕时,系统会尝试重建服务 -> 触发 onCreate

结论:不调用 startForeground,Service 在现代 Android 系统中就像“无证驾驶”,随时会被交警(系统)扣车


二、 隐形杀手:Lambda 导致的内存泄露

现象描述: 虽然 Service 被销毁重建了,但旧的 Service 尸体并没有从内存中清除。

代码现场:

// GlobalTaskService.onCreate
AirIMEngine.addMsgObserver { msg, sender -> 
    // 这个 Lambda 内部隐式持有了 Service 的引用 (this)
    dispatchMsgScene(...) 
}

泄露逻辑闭环:

  1. 谁活着? AirIMEngineobject (单例),它的生命周期 = App 进程的生命周期(它永远活着)
  2. 谁被抓住了? 当你注册 Lambda 时,AirIMEngine 里的列表(或变量)就引用了这个 Lambda。而 Lambda 为了能调用 dispatchMsgScene,必须持有 GlobalTaskService 的实例
  3. 悲剧发生:
    • 第一次 Service 启动 -> AirIMEngine 抓住 Service_V1
    • 息屏,系统杀掉 Service -> Service_V1onDestroy但在内存中,因为 AirIMEngine 还抓着它,它无法被回收(GC)
    • 亮屏,Service 重启 -> 创建 Service_V2
    • Service_V2 再次向 AirIMEngine 注册
  4. 结果:内存里同时存在 Service_V1 (僵尸) 和 Service_V2 (活体)。如果不改,在这个 App 的生命周期内,僵尸会越堆越多

解决方案的核心: 从 Lambda 改为 接口 (interface)。因为接口允许我们明确地传入 this,也允许我们在 onDestroy 中明确地调用 removeListener(this),打断单例对 Service 的引用链。


三、 合规陷阱:Android 14 的“类型”强校验

潜在风险: 你的 Manifest 声明了 foregroundServiceType="phoneCall",这在 Android 14+ 是高危操作

逻辑冲突:

  1. 场景 A(待机):App 启动,IM 连接中,但没打电话
    • 系统检查:你启动了前台服务,声明是 phoneCall
    • 系统质问:你现在的 TelecomManager 里有通话会话吗?
    • 回答:没有
    • 结果:抛出 ForegroundServiceStartNotAllowedException 或直接 Crash
  2. 场景 B(通话):App 真的在自动拨打电话。
    • 此时用 phoneCall 才是合法的

解决方案: 采用 “双模切换” 策略

  • 待机模式:使用 dataSync 类型(数据同步),告诉系统我在维持 IM 连接,这是合法的后台行为
  • 通话模式:检测到拨号指令后,动态更新 Notification,将类型切换为 phoneCall

四、 总结架构图(技术复盘)

这是我们最终修复后的架构逻辑,你可以对照代码理解:

graph TD
    A[GlobalTaskService 启动] --> B(onCreate);
    
    B --> C{修复点1: 立即启动前台服务};
    C -->|待机状态| D[startForeground type=DATA_SYNC];
    D -- 说明 --> E[系统不再杀后台, 允许持有Socket];
    
    B --> F{修复点2: 注册IM监听};
    F -->|AirIMEngine.addListener this | G[AirIMEngine (单例)];
    G -- 关键 --> H[使用 WeakReference 或 明确的 Remove];
    
    I[收到拨号指令] --> J{修复点3: 动态切换类型};
    J --> K[更新 Notification type=PHONE_CALL];
    K -- 说明 --> L[Android 14 允许使用通话API];
    
    M[GlobalTaskService 销毁] --> N(onDestroy);
    N --> O[AirIMEngine.removeListener this];
    O -- 结果 --> P[断开引用链, Service内存成功回收];
    N --> Q[stopForeground];

核心心法(记忆口诀)

  1. 要保活,必前台:没有 Notification 的 Service 在息屏时就是系统的“猎物”。
  2. 用单例,必有借有还:在 onCreateadd 进单例的东西,必须在 onDestroyremove 掉,否则就是内存泄露。
  3. Android 14,类型要对版:干什么事挂什么牌(Type),没打电话别挂“正在通话”的牌子,会被系统封杀。