4. Foreground Service 的典型应用场景

坦白说,Foreground Service 这玩意儿,在 Android 里算是个"老演员"了。从 Android 8 开始收紧后台限制,它就变得越来越重要。说白了,前台服务就是给你的 App 发一张"免死金牌"——系统知道你正在做一件用户感知的事情,不会随便把你干掉。

我自己做 Android 开发这些年,音乐播放、位置定位、文件下载这三个场景,几乎每个项目都会碰上。今天咱们就一个一个拆开来看,聊聊它们具体怎么落地,有哪些坑。

Foreground Service 三大应用场景 Foreground Service 音乐播放场景 位置定位场景 文件下载场景 MediaStyle 通知 播放控制 高精度定位 省电策略 断点续传 进度通知 每个场景都依赖前台服务保证任务不被系统回收

4.1 音乐播放场景

音乐播放是前台服务最经典的应用,没有之一。用户听歌的时候切到后台,音乐不能停——这个需求看似简单,但实现起来有不少细节。

核心思路:通过 MediaStyle 通知来绑定前台服务,让系统知道"这个 App 正在播放音频"。通知里要包含播放、暂停、切歌等控制按钮,用户能直接操作。

我记得刚做音乐 App 那会儿,踩过一个坑:前台服务的通知必须调用 startForeground() 在 5 秒内 显示出来,否则系统会强杀服务。后来我养成了习惯,在 onCreate() 里先把通知渠道建好,再启动服务。

// 音乐播放前台服务核心代码
class MusicPlaybackService : Service() {

    override fun onCreate() {
        super.onCreate()
        createNotificationChannel()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = buildNotification()
        startForeground(NOTIFICATION_ID, notification)
        // 开始播放逻辑...
        return START_STICKY
    }

    private fun buildNotification(): Notification {
        val playAction = NotificationCompat.Action(
            R.drawable.ic_pause, "暂停",
            PendingIntent.getService(this, 0,
                Intent(this, MusicPlaybackService::class.java).apply {
                    action = ACTION_PAUSE
                }, PendingIntent.FLAG_IMMUTABLE)
        )
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("正在播放")
            .setContentText("《晴天》- 周杰伦")
            .setSmallIcon(R.drawable.ic_music)
            .setStyle(MediaStyle().setMediaSession(mediaSession.sessionToken))
            .addAction(playAction)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .build()
    }
}
经验之谈:setVisibility(VISIBILITY_PUBLIC) 别忘了加。我之前没加,锁屏状态下通知不显示,用户以为播放器挂了,差点被投诉。另外,Android 13+ 需要动态请求 POST_NOTIFICATIONS 权限,否则通知弹不出来。

4.2 位置定位场景

运动 tracking、外卖配送、导航——这些场景都需要在后台持续获取位置。Android 从 8.0 开始,后台定位会被限制频率,甚至直接掐掉。前台服务是绕过这个限制的「正路」。

核心思路:前台服务持有 FusedLocationProviderClient,在通知里实时显示当前位置信息,告诉系统"用户正在使用定位功能"。定位请求的 interval 和 priority 要根据业务场景来调。

我曾做过一个户外骑行 App,当时在 Android 12 上遇到了一个坑:从后台切回前台时,定位权限必须重新声明 FOREGROUND_SERVICE_LOCATION。嗯,这个问题卡了我一下午,后来在官方文档里找到的解决方案。

// 定位前台服务核心代码
class LocationTrackingService : Service() {

    private lateinit var fusedClient: FusedLocationProviderClient

    override fun onCreate() {
        super.onCreate()
        fusedClient = LocationServices.getFusedLocationProviderClient(this)
        createNotificationChannel()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startForeground(NOTIFICATION_ID, buildNotification("正在获取位置..."))
        requestLocationUpdates()
        return START_STICKY
    }

    private fun requestLocationUpdates() {
        val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
            .setMinUpdateDistance(10f)
            .build()

        if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
            == PackageManager.PERMISSION_GRANTED) {
            fusedClient.requestLocationUpdates(request, locationCallback, mainLooper)
        }
    }

    private val locationCallback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            val loc = result.lastLocation
            if (loc != null) {
                val notification = buildNotification(
                    "纬度: ${"%.4f".format(loc.latitude)}  经度: ${"%.4f".format(loc.longitude)}"
                )
                notificationManager.notify(NOTIFICATION_ID, notification)
            }
        }
    }
}
避坑指南:Android 10 以上,定位前台服务必须在 AndroidManifest.xml 中声明 foregroundServiceType="location"。我早期在适配时漏了这个声明,结果服务一启动就被系统弹警告,定位直接失效。别忘了检查!

你想想看,用户跑着步突然定位停了,那体验多糟糕。前台服务 + 合理的通知更新,既保证定位不中断,也让用户知道 App 还在工作。

4.3 文件下载场景

大文件下载,比如视频缓存、APK 更新包——这些任务如果放在后台服务里,系统随时可能把你回收。前台服务就是给下载任务上了一道保险。

核心思路:前台服务启动后,用 OkHttp 或 DownloadManager 执行下载,通过通知实时展示进度百分比。用户能清楚看到下载状态,还能手动暂停/取消。

我曾经接手过一个下载模块,Notification 每秒更新好几次,结果 UI 线程卡得不行。后来做了个简单节流——进度变化超过 2% 才更新一次通知,流畅多了。

// 文件下载前台服务核心代码
class DownloadService : Service() {

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val url = intent?.getStringExtra("download_url") ?: return START_NOT_STICKY
        startForeground(NOTIFICATION_ID, buildNotification(0))
        downloadFile(url)
        return START_STICKY
    }

    private fun downloadFile(url: String) {
        val request = Request.Builder().url(url).build()
        val client = OkHttpClient.Builder()
            .addInterceptor { chain ->
                val response = chain.proceed(chain.request())
                response.body?.let { body ->
                    val total = body.contentLength()
                    var downloaded = 0L
                    // 进度回调处理...
                    object : ResponseBody() {
                        override fun contentLength() = total
                        override fun contentType() = body.contentType()
                        override fun source() = object : ForwardingSource(body.source()) {
                            override fun read(sink: Buffer, byteCount: Long): Long {
                                val bytes = super.read(sink, byteCount)
                                downloaded += if (bytes != -1L) bytes else 0
                                val progress = (downloaded * 100 / total).toInt()
                                // 每 2% 更新一次通知
                                if (progress % 2 == 0) {
                                    updateNotification(progress)
                                }
                                return bytes
                            }
                        }
                    }
                } ?: response
            }.build()
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                stopForeground(STOP_FOREGROUND_REMOVE)
            }
            override fun onResponse(call: Call, response: Response) {
                // 下载完成,停止前台服务
                stopForeground(STOP_FOREGROUND_REMOVE)
            }
        })
    }

    private fun updateNotification(progress: Int) {
        notificationManager.notify(NOTIFICATION_ID, buildNotification(progress))
    }

    private fun buildNotification(progress: Int): Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("下载中")
            .setContentText("${progress}%")
            .setProgress(100, progress, false)
            .setSmallIcon(R.drawable.ic_download)
            .setOngoing(true)
            .build()
    }
}
优化建议:下载任务最好支持断点续传。我一般会在 SharedPreferences 里记录已下载的字节数,下次启动时从断点处继续。前台服务配合断点续传,用户就算手滑杀进程,下载也能接上——这个体验很加分。

4.4 场景对比与选择

三个场景虽然都用前台服务,但侧重点不同。我整理了一张对比表,你看一眼就明白了:

对比维度 音乐播放 位置定位 文件下载
通知类型 MediaStyle(可交互) 常规通知(信息展示) 进度通知(带进度条)
通知优先级 高(需可见) 低(后台更新) 中(持续显示)
关键 API MediaSession + MediaStyle FusedLocationProviderClient OkHttp 进度拦截
前台服务类型 mediaPlayback location dataSync
典型坑点 通知5秒内必须显示 Android 10+ 需声明 type 通知更新频率别太高

一句话总结:三个场景的核心都是「用可见的通知换后台的保活权限」。音乐播放重交互,定位重精度,下载重进度——搞清楚业务诉求,前台服务的配置就有的放矢了。

说实话,前台服务并不是"万能保活丹"。系统在内存紧张时仍然可能回收你的服务,但相比普通后台服务,它已经稳定太多了。我自己的原则是:只要用户能感知到的任务,就用前台服务。这个原则帮我避免了很多线上崩溃。