第15周:Service 全功能 + 后台优化

学 Service 之前,我心里对它的定义一直是错的:"Service 就是 Android 给的后台线程,把耗时任务扔进去跑就行。"

直到写本周 Demo,我在 onStartCommand 里打印了当前线程名,屏幕上赫然显示 main------服务的所有回调都跑在主线程上。如果我真的把"耗时任务"直接塞进 Service,结果不是后台执行,而是整个 App 卡死,然后 ANR(Application Not Responding,应用无响应弹窗)。

这是我这周拆掉的第一个、也是最重要的一个误解。除此之外,Service 还有一整层被版本演进反复改写的规则:Android 8 限制后台服务、Android 12 限制后台启动前台服务、Android 14 强制服务类型------每一刀都砍在"老教程教你的写法"上。标题里的"优化"指的是三件事:不泄漏(内存)、不合规就崩(正确性)、被系统和厂商乱杀(可用性)


实践来源

来源 说明
Android Developers:Services overview Service 主线程模型的官方原文、三种形态、started/bound 生命周期、START_* 语义、混合销毁规则的核心依据
Android Developers:Restrictions on background starts(FGS) Android 12 后台启动前台服务限制、豁免清单、ForegroundServiceStartNotAllowedException 的依据
Android Developers:Foreground service types Android 14 类型强制三要素、类型清单、各类型运行时权限前提的依据

一、概念地基:Service 不是线程,也不是进程

官方文档对 Service 线程模型有一段加粗的警告(Caution),我把它放在最前面,因为它摧毁的误解最普遍:

"A service runs in the main thread of its hosting process; the service does not create its own thread and does not run in a separate process unless you specify otherwise."

翻译过来三句话:Service 跑在宿主进程的主线程上;它不创建自己的线程;它也不在独立进程里 (除非你在 Manifest 里用 android:process 显式指定)。

Demo 里的验证只需要一行日志:

kotlin 复制代码
// Week15StartedService.kt
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    Log.d(TAG, "回调线程:${Thread.currentThread().name}")   // 输出:main
    return startMode
}

运行后日志区显示"回调线程:main"。这就是证据:你在 onStartCommand 里写一个 Thread.sleep(10000),卡的不是"后台",是整个 App 的 UI。

所以 Service 的正确定位是:一个"没有界面的组件",系统给它的承诺是"即使用户离开了你的页面,我也尽量不立刻杀掉你" ------它买的是存活时间,不是线程。要真正的后台计算,你仍然要在服务里自己开线程(ThreadExecutorService、协程都行)。

那什么时候才需要 Service?官方文档给了一个很狠的判断标准:如果这个工作只在用户和你的界面交互期间进行,你根本不需要 Service------在 Activity 里开个线程就够了。Service 的价值场景是"用户走了,活还得继续":音乐播放、文件下载、数据同步、定位记录。

这三个概念再收一遍口:

概念 它是什么 和 Service 的关系
进程(Process) 应用代码的运行容器 Service 默认和 Activity 同进程,主线程共享
线程(Thread) 代码执行流 Service 不自带线程,回调全在主线程
Service 无界面的存活型组件 买"存活性",不买"后台执行"

二、三种形态与生命周期:系统什么时候创建,什么时候销毁

Service 有三种用法形态,官方定义分得很清楚:

  • Started(启动型)startService() 拉起,服务独立存活,直到自己 stopSelf() 或别人 stopService()
  • Bound(绑定型)bindService() 绑定,提供客户端-服务器接口,所有客户端解绑后销毁
  • Foreground(前台型) :必须挂一条常驻通知的特殊服务,系统几乎不杀它(第五节展开)

三种形态共享四个核心回调,但走的路径完全不同:

kotlin 复制代码
class Week15StartedService : Service() {
​
    override fun onCreate() {
        // 首次创建时走一次;服务已存在时,再次 startService 不会重走
    }
​
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 只有 started 形态会走;每次 startService 都走一次,startId 递增
        return START_STICKY
    }
​
    override fun onBind(intent: Intent?): IBinder? {
        // 只有 bound 形态会走;必须实现,不支撑绑定就返回 null
        return null
    }
​
    override fun onDestroy() {
        // 服务收到的最后一个回调,清理资源的唯一机会
    }
}

Demo 里能直接观察到的关键事实有三个。

事实一:onCreate 只有一次,onStartCommand 每次都有。 连续点三次"startService",日志里 onCreate 出现一次,onStartCommand 出现三次,而且 startId 是 1、2、3 递增的。这个 startId 不是摆设------它是并发场景的保护锁:如果你的服务同时处理多个请求,某个请求处理完时应该用 stopSelf(startId) 停掉"那一单",而不是裸 stopSelf()。少了 startId 匹配,旧任务结束时会把刚到的新请求一起误杀。

事实二:started 服务没有"暂停"概念,也没有 onStop 回调。 它被停止时,系统直接销毁,你只会收到 onDestroy。所以所有资源清理(线程、Handler 回调、监听器、注册的广播)都必须放在 onDestroy 里,没有第二次机会。

事实三:bound 服务的生死完全由客户端数量决定。 多个组件可以同时绑定同一个服务,系统计数,最后一个客户端解绑时 服务才销毁。Demo 区域②的操作链是:bindService(日志出现 onCreate → onBind)→ 调用 increment()(通过 Binder 直接调服务方法)→ unbindService(日志出现 onUnbind → onDestroy)。

绑定的通信通道是 IBinder。同进程场景的标准写法是 LocalBinder

kotlin 复制代码
// Week15BoundService.kt
inner class LocalBinder : Binder() {
    fun getService(): Week15BoundService = this@Week15BoundService
}
​
override fun onBind(intent: Intent?): IBinder {
    return LocalBinder()   // 客户端拿到后强转,直接拿到服务实例调方法
}

Activity 侧通过 ServiceConnection 接收它:

kotlin 复制代码
private val connection = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
        boundService = (service as Week15BoundService.LocalBinder).getService()
        // 从这里开始可以像调普通对象一样调服务的方法
    }
​
    override fun onServiceDisconnected(name: ComponentName?) {
        // 注意:主动 unbindService 不会回调这里,只有服务异常死亡才会
    }
}
​
bindService(intent, connection, Context.BIND_AUTO_CREATE)

BIND_AUTO_CREATE 的意思是"服务没在跑就先创建再绑定";少了它,绑定一个不存在的服务会静默失败。onServiceDisconnected 的触发条件是个高频误解点:主动解绑不走它,只有服务所在进程被杀才走------所以指望它做"解绑完成"的收尾逻辑是错的。

实践

  • 参考方向:音乐 App 的播放控制(Activity 绑定播放服务调 play()/pause()/seekTo())、下载管理器(页面绑定下载服务查进度)
  • 解决问题:页面和服务解耦------页面只持有 Binder 接口,服务不用知道页面存在;页面销毁重建(旋转)后重新绑定即可继续控制,业务连续性不受 UI 生命周期影响
  • 本节落点:Demo 区域② 的 LocalBinder 通信与 increment() 调用
  • 取舍判断:绑定模式写着比启动模式重(要维护 ServiceConnection 状态机),只调用一两次的轻任务用 started 更划算;需要双向通信、持续交互、进度查询的场景才是 bound 的主场

相关技术

  • API / 类:startService()/stopService()bindService()/unbindService()ServiceConnectionIBinderLocalBinderBIND_AUTO_CREATEstopSelf(startId)
  • 系统机制:onCreate 单例性、startId 递增、绑定计数销毁、onServiceDisconnected 仅异常触发
  • 常见坑:把耗时操作写进主线程回调;用 stopSelf() 误杀并发请求;指望 onServiceDisconnected 做正常解绑收尾

三、onStartCommand 返回值:服务被系统杀死之后怎么办

onStartCommand 的返回值不是形式参数,它是你给系统的一份"遗嘱":如果服务被系统杀掉(内存不足回收),之后要不要重建、怎么重建?

三个取值,语义完全不同:

返回值 被杀后的行为 适用场景
START_NOT_STICKY 不重建。除非有新的 startService 请求,服务就死透了 任务型服务:活干完就完,丢了可以重来(如一次性同步)
START_STICKY 重建并再次调用 onStartCommand,但 intent 是 null 常驻型服务:活着本身就是意义,如媒体播放器待机
START_REDELIVER_INTENT 重建,并用最后的 intent 重新调用 续跑型服务:任务参数重要,丢了必须原样恢复,如文件下载

Demo 里用 RadioGroup 切换 START_STICKYSTART_NOT_STICKY,返回值实时生效。

这里最该记住的是 START_STICKY 的 null intent 陷阱:重建后 onStartCommand(intent=null) 会被调用,如果你的代码不写判空直接 intent.getStringExtra(...),服务重建的瞬间就是一次 NPE 崩溃。所以 STICKY 服务的标准骨架是:

kotlin 复制代码
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    if (intent == null) {
        // 系统重建路径:恢复"活着"的状态,但不要当成新任务处理
        resumeStandby()
    } else {
        handleCommand(intent)
    }
    return START_STICKY
}

还有一个系统侧的决策规则要知道:服务被杀的概率和它的形态强相关。官方明确的排序是------绑定了前台 Activity 的服务最难被杀;前台服务几乎不被杀;长时间运行的 started 服务随着时间推移在后台列表里排位越来越低,最容易被杀。这就是为什么"想活得久"的终极手段不是 START_STICKY,而是前台服务。

大厂实践

  • 参考方向:音乐播放器(STICKY:被杀后重建回到待播状态)、下载器(REDELIVER:断点任务原样恢复)、埋点上报(NOT_STICKY:丢了下轮再报)
  • 解决问题:返回值选错会导致两类事故------NOT_STICKY 用于常驻服务导致"服务莫名其妙就没了";REDELIVER 用于一次性任务导致旧任务被重复执行(重复下载、重复扣费类事故的原型)
  • 本节落点:Demo 区域① 的 RadioGroup 切换 + startMode 返回
  • 取舍判断:START_REDELIVER_INTENT 的"重放"语义要求任务本身幂等(同一个 intent 执行两次不出错),任务不幂等就别用它,否则重建等于制造重复数据

相关技术

  • API / 类:START_STICKYSTART_NOT_STICKYSTART_REDELIVER_INTENT
  • 系统机制:低内存回收优先级(前台服务 < 绑定前台 Activity 的服务 < 常驻 started 服务)、重建时 intent 置空/重放
  • 常见坑:STICKY 重建后 null intent 未判空 NPE;对非幂等任务用 REDELIVER 导致重复执行

四、混合状态:销毁的双条件规则

真实业务里最常踩的生命周期事故,发生在"既 start 又 bind"的服务上。典型场景:音乐服务先 startService 让它独立于页面存活(页面关了音乐不停),再 bindService 让页面能控制播放。

这时的销毁规则是官方文档里一句容易被略过的话:

"stopService() or stopSelf() doesn't actually stop the service until all of the clients unbind."

翻译:stop 请求 + 全部解绑,两个条件必须同时满足,服务才会销毁。 只满足一个,服务继续活着。

Demo 区域③把这个规则做成了四按钮的顺序实验(start → bind → stop → unbind):

  1. start:日志出现 onCreate → onStartCommand
  2. bind:日志出现 onBind
  3. stop:日志出现......什么都没有。onDestroy 没有执行------虽然 stop 请求发了,但绑定还在,服务不死
  4. unbind:日志出现 onUnbind → onDestroy------此刻双条件凑齐,服务才真正销毁

这个实验反过来做也成立:先 stop 再 bind,或者先 unbind 再 stop,只要两个条件最后凑齐,onDestroy 就在凑齐的那一刻执行。

少了这个认知会出什么事?最经典的是"我以为我停了":业务代码在页面退出时调了 stopService,就放心地把服务当成死了,但另一个页面还绑着它------服务继续跑、继续占内存、继续执行旧逻辑,排查时你会发现"这个服务怎么杀都杀不掉"。

大厂实践

  • 参考方向:音乐/音频类 App 的播放服务(start 保活 + bind 控制是这类服务的标准架构)
  • 解决问题:页面退出时"停"服务,但全局还有其他入口(悬浮控制条、通知栏控制、另一个页面)持有绑定,服务不销毁是正确行为,不是 bug------产品语义是"只要还有控制方,音乐就不该停"
  • 本节落点:Demo 区域③ 的四按钮顺序实验 + 日志区 onDestroy 出现时机
  • 取舍判断:混合状态的服务,停止逻辑必须收口到一个地方统一管理(谁最后离开谁负责 stop + unbind),散落在各个页面的"顺手 stopService"是混合状态事故的主要来源

相关技术

  • API / 类:stopService()unbindService()onUnbind()
  • 系统机制:销毁双条件(停止请求 + 零绑定)、两条件凑齐时即时销毁
  • 常见坑:以为 stopService 必销毁;多个页面各自 stop/unbind 缺乏统一管理

五、前台服务与后台限制:一条八年的收紧时间线

前台服务(Foreground Service,FGS)是"系统承诺几乎不杀你"的服务形态,代价是必须挂一条用户可见、不可划掉的常驻通知。它是系统和应用之间的一份契约:你告诉用户"我在跑、我在耗资源",系统就让你活着。

但这份契约在过去八年里被持续收紧。这条时间线是理解所有合规要求的主线:

版本 限制 影响
Android 8.0(API 26,2017) 后台执行限制:应用退到后台后,普通后台服务几分钟内被停止;后台 startServiceIllegalStateException 后台服务基本被判死刑,官方引导去 JobScheduler/WorkManager
Android 9(API 28,2018) FGS 需要 FOREGROUND_SERVICE 权限 普通权限,Manifest 声明即可
Android 12(API 31,2021) 后台禁止启动 FGS :应用不在前台时 startForegroundServiceForegroundServiceStartNotAllowedException "收到广播偷偷拉活"的玩法终结,只有白名单豁免(FCM 高优先级推送、用户点击通知/小组件、精确闹钟、BOOT_COMPLETED 等)
Android 13(API 33,2022) 通知变成运行时权限 POST_NOTIFICATIONS FGS 的通知没有权限就不显示(服务本身不崩)
Android 14(API 34,2023) FGS 类型强制 :Manifest 必须声明 foregroundServiceType + 对应的 FOREGROUND_SERVICE_<type> 权限;部分类型(camera/mic/location/health)还要求启动时已持有运行时权限 缺类型启动抛 MissingForegroundServiceTypeException;权限不满足抛 SecurityException
Android 15(API 35,2024) dataSync / mediaPlayback 等类型禁止从 BOOT_COMPLETED 广播启动 开机自启拉活进一步受限

这个项目的 targetSdk 是 34,所以 Android 14 的三要素全部生效。Demo 的合规链路:

xml 复制代码
<!-- Manifest:权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
​
<!-- Manifest:服务声明带类型 -->
<service
    android:name=".Week15ForegroundService"
    android:exported="false"
    android:foregroundServiceType="dataSync" />
scss 复制代码
// Week15ForegroundService.kt
override fun onCreate() {
    super.onCreate()
    createNotificationChannel()
​
    val notification = buildNotification(0)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
        // Android 14+:必须传入类型常量,且必须和 Manifest 声明的一致
        startForeground(
            NOTIFICATION_ID,
            notification,
            ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
        )
    } else {
        startForeground(NOTIFICATION_ID, notification)
    }
}

这段代码里藏着三个"少了就崩"的点:

第一,类型三要素缺一不可。 Manifest 声明 foregroundServiceType、声明 FOREGROUND_SERVICE_DATA_SYNC 权限、startForeground() 传入匹配的类型常量。少了 Manifest 类型声明,Android 14+ 上 startForeground 直接抛 MissingForegroundServiceTypeException。选 dataSync 的原因是它是少数没有运行时权限前提 的类型------camera / microphone / location / health 类型要求启动时已经持有对应运行时权限,而且是"使用期间"(while-in-use)权限:应用在后台时系统认为你不持有它们,创建服务那一刻就抛 SecurityException

第二,5 秒规则。startForegroundService() 启动后,服务必须在约 5 秒内调用 startForeground(),超时就是 ANR。所以 startForeground() 要放在 onCreate 靠前的位置,别在前面塞重初始化。

第三,exported="false" 官方安全建议:不给其他应用启动你服务的机会。还有一条相关的硬规则------启动和绑定服务永远用显式 Intent ,Android 5.0 起隐式 Intent bindService() 直接抛异常。

Android 12 的后台启动限制还有一个演示不了的细节值得说:Demo 从前台页面启动 FGS 是合法路径;如果同样的调用发生在应用退到后台之后(比如在一个静态广播接收器里),系统直接抛 ForegroundServiceStartNotAllowedException。合法的"后台拉起"路径只剩白名单:FCM 高优先级消息、用户点了你的通知或桌面小组件、精确闹钟触发、BOOT_COMPLETED(Android 15 起部分类型也被禁)等。这些豁免的共同特征很明显------要么用户刚刚和你互动过,要么用户明确授权过

大厂实践

  • 参考方向:音乐播放(mediaPlayback 类型)、跑步/骑行记录(location 类型)、微信电话本类通话(phoneCall 类型)
  • 解决问题:这些场景的共同点是"用户明确知道任务在进行"------通知不是合规负担,而是产品承诺的一部分;类型声明让系统能向用户展示"这个 App 正在用你的麦克风/位置",滥用 FGS 保活的应用在 Play 审核会被拒
  • 本节落点:Demo 区域④ 的 dataSync 前台服务 + 常驻计时通知 + 通知权限申请
  • 取舍判断:FGS 的代价是用户可见的常驻通知和电量消耗,任务如果"几分钟内能完成且不需要用户感知",正确答案是 WorkManager(延迟容忍)而不是 FGS;短任务还有 shortService 类型(约 3 分钟时限,超时 ANR)可选

相关技术清单

  • API / 类:startForegroundService()startForeground()foregroundServiceTypeServiceInfo.FOREGROUND_SERVICE_TYPE_*ForegroundServiceStartNotAllowedExceptionMissingForegroundServiceTypeException
  • 系统机制:后台执行限制(8.0)、后台启动 FGS 限制(12)、通知运行时权限(13)、类型强制(14)、BOOT_COMPLETED 类型限制(15)
  • 权限:FOREGROUND_SERVICEFOREGROUND_SERVICE_<type>POST_NOTIFICATIONS
  • 常见坑:Manifest 漏声明类型;startForeground() 调用太晚触发 ANR;while-in-use 类型在后台创建服务;隐式 Intent 启动服务

六、服务泄漏规避:三条最典型的泄漏路径

服务是"存活型组件",它的寿命天然比页面长------这正是泄漏的温床。一个存不住内存的服务,等于把"后台优化"四个字反转成了"后台负担"。三条路径按出现频率排:

路径一:静态引用持有 Activity。 Demo 里三个服务的 logListener 都是 companion(静态)变量,Activity 把自己赋值进去的那一刻,静态域就持有了 Activity 引用。页面退出后如果不置空,整个 Activity(连带它的 View 树、图片、数据)都无法回收。Demo 的 onDestroy 里专门做了清理:

kotlin 复制代码
override fun onDestroy() {
    if (isBound) {
        boundService?.setCountListener(null)
        unbindService(connection)
    }
    if (isMixedBound) unbindService(mixedConnection)
    // 静态引用清零:断开 服务 → Activity 的引用链
    Week15StartedService.logListener = null
    Week15BoundService.logListener = null
    Week15ForegroundService.logListener = null
    super.onDestroy()
}

路径二:回调/监听器没反注册。 绑定服务里客户端注册的回调(Demo 里的 countListener)、服务里注册的广播接收器、传感器监听、位置监听------onDestroy 是清理它们的唯一机会(第二节说过,服务没有 onStop)。Demo 的 Week15BoundService.onDestroycountListener = null,断的就是这条链。

路径三:绑定没解除。 Activity 退出时 isBound 还是 true,服务就一直持有 ServiceConnection,间接持有 Activity。所以"页面 onDestroy 里检查并 unbind"是绑定模式的标准收尾动作------注意 unbindService 一个没绑定过的连接会直接抛异常,所以要用 isBound 状态位保护。

路径四(附赠):Handler/线程没停。 服务 onDestroy 后,如果它内部的 Handler.postDelayed 循环或裸线程还在跑,Runnable 持有服务引用,服务对象死而不僵。Demo 三个服务的 onDestroy 第一行都是 handler.removeCallbacks(...)

大厂实践

  • 参考方向:成熟团队的内存治理(LeakCanary 上报的服务相关泄漏链中,静态回调和未解绑占比最高)
  • 解决问题:服务泄漏的内存不是"多一点"的问题------Activity 整套 View 树被拖住,动辄几十 MB;且泄漏会随页面反复进出累积,最终把应用推向 OOM 和被系统优先杀死的位置
  • 本节落点:Demo Week15ServiceActivity.onDestroy 的完整清理链 + 三个服务各自的资源释放
  • 取舍判断:Demo 用静态 listener 是为了单 Activity 教学最简化;真实项目服务与页面的通信应该用生命周期感知的方案(LocalBroadcast 已废弃,现代替代是 SharedFlow/LiveData 加作用域,或干脆把状态收进 ViewModel),从源头消除静态引用

相关技术

  • API / 类:unbindService()Handler.removeCallbacks()、弱引用(WeakReference
  • 系统机制:静态域引用链、ServiceConnection 间接持页、onDestroy 是唯一清理点
  • 性能工具:LeakCanary、adb shell dumpsys meminfo
  • 常见坑:静态回调忘了清;unbind 未绑定的连接抛异常;以为服务 onDestroy 后内部线程自动停

七、厂商后台限制适配:系统规则之外的第二层战场

通过了系统的所有合规检查,服务在国产 ROM 上还有第二层关卡:厂商自己的省电策略。小米的"神隐模式"、华为的"应用启动管理"、OPPO/vivo 的电池优化白名单------它们没有公开 API,行为不透明,同一段合规代码在不同厂商机器上存活时间可能差一个数量级。

能做的标准动作有两层。

第一层:系统级电池优化白名单,有官方 API。

scss 复制代码
// 检测:应用是否在电池优化白名单里
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
val ignoring = pm.isIgnoringBatteryOptimizations(packageName)
​
// 引导:跳系统的"忽略电池优化"设置页(不需要权限)
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))

isIgnoringBatteryOptimizations 返回 true 意味着系统 Doze 模式和应用待机桶不会限制你。注意这个 API 只覆盖系统层的限制,不覆盖厂商自启动管理。

第二层:厂商自启动管理,没有 API,只能引导用户手动开。

各厂商入口(方向性说明,路径随版本变化):

厂商 设置入口方向
小米 设置 → 应用设置 → 应用管理 → 你的应用 → 自启动管理
华为 设置 → 应用和服务 → 应用启动管理 → 关闭自动管理,手动允许自启/关联启动/后台活动
OPPO 设置 → 电池 → 应用耗电管理 → 允许完全后台行为
vivo 设置 → 电池 → 后台耗电管理 → 允许后台高耗电

诚实的边界是:这层适配做到"检测 + 引导"就是工程上限了。真正的架构级答案是减少对流程度存活的依赖------能延迟的任务给 WorkManager(它走系统的 JobScheduler 通道,厂商限制相对克制),必须即时的任务走 FCM 高优先级推送(Android 12 白名单里明确有它),前台服务只留给"用户明确感知"的任务。

大厂实践

  • 参考方向:IM/推送类应用(对后台存活最敏感的一类,普遍做法就是"系统白名单检测 + 厂商设置引导 + FCM/厂商推送通道兜底"三层)
  • 解决问题:客服工单里"收不到消息/任务中断"的相当大比例最终定位到厂商省电策略;与其对抗,不如把关键路径迁移到厂商自己也维护的通道(各厂商推送联盟通道在 ROM 层有保活特权)
  • 本节落点:Demo 区域⑤ 的白名单检测 + 设置页跳转
  • 取舍判断:把用户引导到设置页是有转化损耗的------每一步跳转都在流失用户;所以只对"确实需要长存活"的核心功能做引导,别把它当成所有页面的标配弹窗

相关技术清单

  • API / 类:PowerManager.isIgnoringBatteryOptimizations()Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGSBuild.MANUFACTURER
  • 系统机制:Doze 模式、应用待机桶、厂商自启动管理(无 API)
  • 替代通道:WorkManager / JobScheduler、FCM 高优先级消息、厂商推送通道
  • 常见坑:以为电池白名单包含厂商自启动(两层独立);引导文案不说明"为什么要开"导致用户拒绝

八、方案决策矩阵

第15周的内容收拢成一张"按任务特征选方案"的表:

任务特征 正确方案 理由
只在用户使用页面期间需要计算 Activity/ViewModel 里开线程或协程 不需要 Service,官方明确这么说
用户离开也要继续 + 用户明确感知(播放/导航/记录) 前台服务(匹配的类型) 系统承诺几乎不杀,通知是契约
一次性后台任务,丢了可重来 started 服务 + START_NOT_STICKY 最轻量,不追求重建
常驻待命,活着本身有意义 started 服务 + START_STICKY(null intent 判空) 被杀后重建回待机态
长任务需断点续跑,参数重要 started 服务 + START_REDELIVER_INTENT(任务幂等) 重建时重放 intent
页面需要持续查询/控制服务 bound 服务 + LocalBinder 双向通信的标准结构
可延迟、不需要即时的后台工作 WorkManager 走系统调度通道,后台限制影响最小(后续周展开)
后台需要被"唤醒"拉活 FCM 高优先级消息 Android 12 白名单豁免路径

技术清零表

技术 它是什么 Demo 落点 真实项目价值 常见坑
Service 主线程模型 服务回调跑宿主进程主线程 日志区显示"回调线程:main" 决定耗时操作必须自建线程 把耗时任务直接写进回调 → ANR
Started 服务 startService 拉起的独立存活服务 区域① start/stop 一次性后台任务 以为 stopService 后还有 onStop 回调
Bound 服务 bindService 绑定的接口型服务 区域② bind/increment/unbind 页面与服务双向通信 忘记 BIND_AUTO_CREATE 静默失败
LocalBinder 同进程绑定通信句柄 getService() 拿实例调方法 客户端直接调服务方法 跨进程场景用它(需 AIDL/Messenger)
ServiceConnection 绑定状态回调接口 connection / mixedConnection 感知绑定建立与服务异常死亡 指望 onServiceDisconnected 做正常解绑收尾
startId 每次 startService 的递增请求编号 日志显示 startId 递增 stopSelf(startId) 防并发误杀 裸 stopSelf 杀掉新请求
START_STICKY 被杀后重建、intent 置空 RadioGroup 切换 常驻待机服务 null intent 未判空 NPE
START_NOT_STICKY 被杀后不重建 RadioGroup 切换 可重来的任务型服务 常驻服务误用导致"服务消失"
START_REDELIVER_INTENT 被杀后重建并重放 intent 文章语义讲解 断点续跑型任务 非幂等任务被重复执行
混合销毁双条件 stop 请求 + 零绑定才销毁 区域③ 四按钮顺序实验 音乐类 start+bind 架构的正确停止 以为 stopService 必销毁
前台服务(FGS) 挂常驻通知的免杀服务 区域④ dataSync 计时服务 用户感知型长任务 把 FGS 当保活工具滥用(审核风险)
FGS 类型三要素 Manifest 类型 + 类型权限 + startForeground 传类型 Manifest 声明 + startForeground(..., TYPE_DATA_SYNC) targetSdk 34 合规底线 缺 Manifest 类型声明 → MissingForegroundServiceTypeException
5 秒规则 startForegroundService 后必须 5 秒内 startForeground onCreate 开头调用 防 ANR 在 startForeground 前做重初始化
后台启动 FGS 限制 Android 12 起后台禁启,白名单豁免 文章时间线讲解 理解"广播拉活"为何失效 在后台路径调用 startForegroundService → ForegroundServiceStartNotAllowedException
POST_NOTIFICATIONS Android 13+ 通知运行时权限 区域④ 启动前申请 FGS 通知可见的前提 以为 FGS 通知不受该权限影响
电池优化白名单 系统级省电豁免名单 区域⑤ 检测 + 跳设置页 长存活功能的系统层保障 以为它覆盖厂商自启动管理
服务泄漏四路径 静态引用/未反注册/未解绑/未停线程 onDestroy 清理链示范 内存治理的高频失分点 服务 onDestroy 后内部线程继续跑
相关推荐
Android打工仔1 小时前
从 finally 理解程序的控制流:它为什么不是 catch 后面的代码?
android·kotlin
YXL1111YXL1 小时前
续体和状态机 —— suspend 函数的 CPS 变换
android·kotlin
WAsbry1 小时前
Flow数据模型:冷流、状态、事件与共享策略
android
WAsbry1 小时前
深入理解 Kotlin suspend:挂起语义、状态机与恢复调度
android
WAsbry1 小时前
Flow在Android中的完整落地:异常、重试与生命周期
android
WAsbry1 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry1 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry1 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android