27、后台服务提取:使用Service或WorkManager进行后台提取任务
好,咱们今天聊点实际的。
前面几章我们都在前台做提取,Activity一退,任务就断了。这在开发阶段没问题,但真要上线,用户切出去回个微信,回来发现提取到一半的进度没了——这体验,说实话挺糟糕的。
所以这一章,我们专门讲后台提取。说白了,就是让提取任务在用户看不见的地方也能安安稳稳跑完。
为什么需要后台提取?
你想想看,用户从视频里提取音频,通常不是几秒钟的事。一个10分钟的视频,提取完可能要一两分钟。用户不可能一直盯着进度条看。他可能想切出去刷个朋友圈,或者锁屏放口袋里。
这时候如果任务跟着Activity一起销毁了,用户回来一看——嗯?怎么没了?
所以我们需要一个独立于界面的后台组件来承载这个任务。Android提供了两种主流方案:Service 和 WorkManager。
核心区别一句话总结:
- Service:适合用户明确知道正在进行的任务,需要实时反馈进度。
- WorkManager:适合可延迟、可恢复、不需要立即完成的任务,系统会帮你处理好生命周期。
方案一:使用前台Service
我个人习惯,如果提取任务需要持续几分钟,而且用户希望看到进度,我会用前台Service。为什么是前台?因为普通Service在Android 8以后,后台几分钟就会被系统杀掉。
前台Service会显示一个持续通知,告诉系统:「这个任务很重要,别杀我」。
实现步骤
- 创建Service子类
- 在onStartCommand中启动提取逻辑
- 创建并显示通知
- 提取完成后停止前台服务
class ExtractService : Service() {
private lateinit var mediaExtractor: MediaExtractor
private lateinit var mediaMuxer: MediaMuxer
override fun onCreate() {
super.onCreate()
// 我在项目中遇到过,如果不在onCreate里初始化,onStartCommand可能会因为
// 系统重建Service而丢失状态
mediaExtractor = MediaExtractor()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val inputPath = intent?.getStringExtra("INPUT_PATH") ?: return START_NOT_STICKY
val outputPath = intent?.getStringExtra("OUTPUT_PATH") ?: return START_NOT_STICKY
// 启动前台通知
startForeground(NOTIFICATION_ID, createNotification("准备提取..."))
// 在新线程中执行提取,避免阻塞主线程
thread {
try {
extractAudio(inputPath, outputPath)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} catch (e: Exception) {
// 我曾经因为没捕获异常,导致Service默默挂掉,用户完全不知道
Log.e("ExtractService", "提取失败", e)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_REDELIVER_INTENT
}
private fun extractAudio(inputPath: String, outputPath: String) {
mediaExtractor.setDataSource(inputPath)
// 选择音频轨道
var audioTrackIndex = -1
for (i in 0 until mediaExtractor.trackCount) {
val format = mediaExtractor.getTrackFormat(i)
if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) {
audioTrackIndex = i
break
}
}
if (audioTrackIndex == -1) {
throw IllegalStateException("没有找到音频轨道")
}
mediaExtractor.selectTrack(audioTrackIndex)
mediaMuxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val audioFormat = mediaExtractor.getTrackFormat(audioTrackIndex)
val trackIndex = mediaMuxer.addTrack(audioFormat)
mediaMuxer.start()
val buffer = ByteBuffer.allocate(1024 * 1024)
val info = MediaCodec.BufferInfo()
var totalSize = 0L
while (true) {
val sampleSize = mediaExtractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
info.offset = 0
info.size = sampleSize
info.presentationTimeUs = mediaExtractor.sampleTime
info.flags = if (mediaExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0)
MediaCodec.BUFFER_FLAG_SYNC_FRAME else 0
mediaMuxer.writeSampleData(trackIndex, buffer, info)
mediaExtractor.advance()
totalSize += sampleSize
// 更新通知进度
updateNotification(totalSize)
}
mediaMuxer.stop()
mediaMuxer.release()
mediaExtractor.release()
}
private fun createNotification(content: String): Notification {
val channelId = "extract_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId, "音频提取",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
return NotificationCompat.Builder(this, channelId)
.setContentTitle("正在提取音频")
.setContentText(content)
.setSmallIcon(android.R.drawable.ic_media_play)
.setOngoing(true)
.build()
}
private fun updateNotification(size: Long) {
val notification = createNotification("已处理: ${size / 1024} KB")
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, notification)
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
private const val NOTIFICATION_ID = 1001
}
}
小提示:
START_REDELIVER_INTENT 这个标志很重要。如果系统因为资源紧张杀了你的Service,它会重新传intent给你,任务就能继续。我建议提取类任务都用这个。
方案二:使用WorkManager
WorkManager是Google推荐的后台任务方案。它最大的优势是——你不用操心系统什么时候杀进程,也不用管设备是不是在休眠。WorkManager会保证任务最终被执行。
但注意,它不适合需要实时进度的场景。你想想看,用户点了提取,然后锁屏睡觉了,第二天醒来发现音频已经提取好了——这种场景用WorkManager就非常合适。
实现步骤
- 创建Worker子类
- 在doWork中执行提取逻辑
- 通过WorkManager的API调度任务
class ExtractWorker(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result {
val inputPath = inputData.getString("INPUT_PATH") ?: return Result.failure()
val outputPath = inputData.getString("OUTPUT_PATH") ?: return Result.failure()
return try {
extractAudio(inputPath, outputPath)
Result.success()
} catch (e: Exception) {
Log.e("ExtractWorker", "提取失败", e)
// 如果失败,可以重试
Result.retry()
}
}
private fun extractAudio(inputPath: String, outputPath: String) {
val extractor = MediaExtractor()
extractor.setDataSource(inputPath)
// 选择音频轨道
var audioTrackIndex = -1
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) {
audioTrackIndex = i
break
}
}
if (audioTrackIndex == -1) {
throw IllegalStateException("没有音频轨道")
}
extractor.selectTrack(audioTrackIndex)
val muxer = MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val audioFormat = extractor.getTrackFormat(audioTrackIndex)
val trackIndex = muxer.addTrack(audioFormat)
muxer.start()
val buffer = ByteBuffer.allocate(1024 * 1024)
val info = MediaCodec.BufferInfo()
while (true) {
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
info.offset = 0
info.size = sampleSize
info.presentationTimeUs = extractor.sampleTime
info.flags = if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0)
MediaCodec.BUFFER_FLAG_SYNC_FRAME else 0
muxer.writeSampleData(trackIndex, buffer, info)
extractor.advance()
// WorkManager不支持实时进度更新,但可以设置进度
setProgress(workDataOf("progress" to "running"))
}
muxer.stop()
muxer.release()
extractor.release()
}
}
// 在Activity或Fragment中调度任务
val workRequest = OneTimeWorkRequestBuilder<ExtractWorker>()
.setInputData(
workDataOf(
"INPUT_PATH" to inputPath,
"OUTPUT_PATH" to outputPath
)
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.setRequiresBatteryNotLow(true) // 电池不低于低电量
.build()
)
.addTag("audio_extract")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
注意:
WorkManager的doWork运行在后台线程,但你不能在里面做耗时太长的操作。默认情况下,系统会给Worker 10分钟的执行时间。如果提取一个很大的视频文件,可能会超时。我建议对于超过100MB的视频,还是用前台Service更稳妥。
两种方案对比
| 维度 | 前台Service | WorkManager |
|---|---|---|
| 实时性 | 高,可以实时更新进度 | 低,只能设置阶段性进度 |
| 系统杀死风险 | 低(前台通知保护) | 极低(系统保证执行) |
| 电量优化 | 需要自己处理 | 系统自动优化 |
| 适用场景 | 用户主动触发的短时任务 | 可延迟的后台任务 |
| 代码复杂度 | 中等 | 较低 |
核心逻辑流程图
下面这张图展示了两种方案的核心流程对比,我画的时候特意把关键决策点标出来了:
避坑指南
嗯,这里我要多说几句。后台提取看起来简单,实际坑不少。
- 我曾经在Service里直接操作MediaExtractor,没考虑线程安全。结果多个任务同时进来,MediaExtractor的状态全乱了。后来我加了个任务队列,一次只处理一个提取任务。
- 还有一次,WorkManager的任务因为手机重启后没及时调度,用户等了半天没反应。后来我加了BOOT_COMPLETED广播,重启后重新调度未完成的任务。
- 文件权限问题:后台Service访问外部存储时,别忘了处理Android 10以上的分区存储。我建议把输出文件放在应用私有目录,或者用MediaStore API。
我的个人建议:
如果提取时间小于30秒,用前台Service。如果超过30秒,或者用户可能锁屏后很久才回来查看,用WorkManager。两种方案不冲突,可以结合使用——比如先用WorkManager调度,然后在Worker里启动前台Service来显示进度。
好了,这一章的内容就到这里。后台提取的核心就是让任务脱离界面独立运行,同时保证系统不会随意杀掉它。你根据实际场景选方案就行。
公众号:蓝海资料掘金营,微信deep3321