27、后台服务提取:使用Service或WorkManager进行后台提取任务

好,咱们今天聊点实际的。

前面几章我们都在前台做提取,Activity一退,任务就断了。这在开发阶段没问题,但真要上线,用户切出去回个微信,回来发现提取到一半的进度没了——这体验,说实话挺糟糕的。

所以这一章,我们专门讲后台提取。说白了,就是让提取任务在用户看不见的地方也能安安稳稳跑完。

为什么需要后台提取?

你想想看,用户从视频里提取音频,通常不是几秒钟的事。一个10分钟的视频,提取完可能要一两分钟。用户不可能一直盯着进度条看。他可能想切出去刷个朋友圈,或者锁屏放口袋里。

这时候如果任务跟着Activity一起销毁了,用户回来一看——嗯?怎么没了?

所以我们需要一个独立于界面的后台组件来承载这个任务。Android提供了两种主流方案:ServiceWorkManager

核心区别一句话总结:

  • Service:适合用户明确知道正在进行的任务,需要实时反馈进度。
  • WorkManager:适合可延迟、可恢复、不需要立即完成的任务,系统会帮你处理好生命周期。

方案一:使用前台Service

我个人习惯,如果提取任务需要持续几分钟,而且用户希望看到进度,我会用前台Service。为什么是前台?因为普通Service在Android 8以后,后台几分钟就会被系统杀掉。

前台Service会显示一个持续通知,告诉系统:「这个任务很重要,别杀我」。

实现步骤

  1. 创建Service子类
  2. 在onStartCommand中启动提取逻辑
  3. 创建并显示通知
  4. 提取完成后停止前台服务
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就非常合适。

实现步骤

  1. 创建Worker子类
  2. 在doWork中执行提取逻辑
  3. 通过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 + 持续通知 实时更新进度 通知栏显示 提取完成 停止前台服务 WorkManager Worker调度 系统保证执行 可设置约束条件 提取完成 返回Result.success 任务完成

避坑指南

嗯,这里我要多说几句。后台提取看起来简单,实际坑不少。

  • 我曾经在Service里直接操作MediaExtractor,没考虑线程安全。结果多个任务同时进来,MediaExtractor的状态全乱了。后来我加了个任务队列,一次只处理一个提取任务。
  • 还有一次,WorkManager的任务因为手机重启后没及时调度,用户等了半天没反应。后来我加了BOOT_COMPLETED广播,重启后重新调度未完成的任务。
  • 文件权限问题:后台Service访问外部存储时,别忘了处理Android 10以上的分区存储。我建议把输出文件放在应用私有目录,或者用MediaStore API。

我的个人建议:

如果提取时间小于30秒,用前台Service。如果超过30秒,或者用户可能锁屏后很久才回来查看,用WorkManager。两种方案不冲突,可以结合使用——比如先用WorkManager调度,然后在Worker里启动前台Service来显示进度。

好了,这一章的内容就到这里。后台提取的核心就是让任务脱离界面独立运行,同时保证系统不会随意杀掉它。你根据实际场景选方案就行。


公众号:蓝海资料掘金营,微信deep3321