学 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 的正确定位是:一个"没有界面的组件",系统给它的承诺是"即使用户离开了你的页面,我也尽量不立刻杀掉你" ------它买的是存活时间,不是线程。要真正的后台计算,你仍然要在服务里自己开线程(Thread、ExecutorService、协程都行)。
那什么时候才需要 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()、ServiceConnection、IBinder、LocalBinder、BIND_AUTO_CREATE、stopSelf(startId) - 系统机制:onCreate 单例性、startId 递增、绑定计数销毁、onServiceDisconnected 仅异常触发
- 常见坑:把耗时操作写进主线程回调;用
stopSelf()误杀并发请求;指望onServiceDisconnected做正常解绑收尾
三、onStartCommand 返回值:服务被系统杀死之后怎么办
onStartCommand 的返回值不是形式参数,它是你给系统的一份"遗嘱":如果服务被系统杀掉(内存不足回收),之后要不要重建、怎么重建?
三个取值,语义完全不同:
| 返回值 | 被杀后的行为 | 适用场景 |
|---|---|---|
START_NOT_STICKY |
不重建。除非有新的 startService 请求,服务就死透了 |
任务型服务:活干完就完,丢了可以重来(如一次性同步) |
START_STICKY |
重建并再次调用 onStartCommand,但 intent 是 null |
常驻型服务:活着本身就是意义,如媒体播放器待机 |
START_REDELIVER_INTENT |
重建,并用最后的 intent 重新调用 | 续跑型服务:任务参数重要,丢了必须原样恢复,如文件下载 |
Demo 里用 RadioGroup 切换 START_STICKY 和 START_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_STICKY、START_NOT_STICKY、START_REDELIVER_INTENT - 系统机制:低内存回收优先级(前台服务 < 绑定前台 Activity 的服务 < 常驻 started 服务)、重建时 intent 置空/重放
- 常见坑:STICKY 重建后 null intent 未判空 NPE;对非幂等任务用 REDELIVER 导致重复执行
四、混合状态:销毁的双条件规则
真实业务里最常踩的生命周期事故,发生在"既 start 又 bind"的服务上。典型场景:音乐服务先 startService 让它独立于页面存活(页面关了音乐不停),再 bindService 让页面能控制播放。
这时的销毁规则是官方文档里一句容易被略过的话:
"
stopService()orstopSelf()doesn't actually stop the service until all of the clients unbind."
翻译:stop 请求 + 全部解绑,两个条件必须同时满足,服务才会销毁。 只满足一个,服务继续活着。
Demo 区域③把这个规则做成了四按钮的顺序实验(start → bind → stop → unbind):
start:日志出现onCreate → onStartCommandbind:日志出现onBindstop:日志出现......什么都没有。onDestroy 没有执行------虽然 stop 请求发了,但绑定还在,服务不死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) | 后台执行限制:应用退到后台后,普通后台服务几分钟内被停止;后台 startService 抛 IllegalStateException |
后台服务基本被判死刑,官方引导去 JobScheduler/WorkManager |
| Android 9(API 28,2018) | FGS 需要 FOREGROUND_SERVICE 权限 |
普通权限,Manifest 声明即可 |
| Android 12(API 31,2021) | 后台禁止启动 FGS :应用不在前台时 startForegroundService 抛 ForegroundServiceStartNotAllowedException |
"收到广播偷偷拉活"的玩法终结,只有白名单豁免(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()、foregroundServiceType、ServiceInfo.FOREGROUND_SERVICE_TYPE_*、ForegroundServiceStartNotAllowedException、MissingForegroundServiceTypeException - 系统机制:后台执行限制(8.0)、后台启动 FGS 限制(12)、通知运行时权限(13)、类型强制(14)、BOOT_COMPLETED 类型限制(15)
- 权限:
FOREGROUND_SERVICE、FOREGROUND_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.onDestroy 里 countListener = 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_SETTINGS、Build.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 后内部线程继续跑 |