进度回调与UI更新:Handler、LiveData、协程实现进度监听

做音视频提取的时候,最头疼的问题是什么?

我个人觉得,不是提取本身,而是——用户不知道进度到哪了。

你想想看,一个几百兆的视频文件,提取音频可能要好几秒甚至十几秒。用户盯着屏幕,界面一动不动,他第一反应就是「App 卡死了」或者「是不是没反应了」。嗯,这其实是个体验问题,而且是个大问题。

我在项目中遇到过好几次,用户反馈说「点击提取后 App 就没反应了」。后来排查发现,其实提取任务一直在跑,只是 UI 没更新。用户看不到进度,自然就焦虑了。

所以今天咱们就来聊聊,怎么把提取进度实时反馈到 UI 上。说白了,就是解决「后台干活,前台显示」的问题。

为什么需要进度回调?

MediaExtractor 提取音视频,本质上是个同步操作。你调用 readSampleData(),它就把数据读出来。但这个过程是阻塞的,如果文件很大,主线程就会卡住。

所以通常我们会把提取任务放到子线程。但子线程不能直接更新 UI,这就需要一个「桥梁」来传递进度信息。

常见的方案有三种:

  • Handler:经典方案,Android 从诞生就有了
  • LiveData:Jetpack 组件,生命周期感知
  • 协程:Kotlin 官方推荐,代码简洁

这三种我都用过,各有优劣。下面一个一个说。

方案一:Handler 实现进度回调

Handler 是最传统的方式。我在早期项目里一直用这个,简单直接,没什么花哨的。

核心思路是:子线程里通过 Handler 发送 Message,主线程的 Handler 接收并更新 UI。

// 主线程创建 Handler
private val mHandler = object : Handler(Looper.getMainLooper()) {
    override fun handleMessage(msg: Message) {
        when (msg.what) {
            MSG_PROGRESS -> {
                val progress = msg.arg1
                progressBar.progress = progress
                tvProgress.text = "$progress%"
            }
            MSG_COMPLETE -> {
                tvStatus.text = "提取完成"
            }
            MSG_ERROR -> {
                tvStatus.text = "提取失败"
            }
        }
    }
}

// 子线程中发送进度
fun extractInThread() {
    Thread {
        val extractor = MediaExtractor()
        // ... 初始化 extractor ...
        val totalDuration = getTotalDuration()
        var currentPosition = 0L
        
        while (currentPosition < totalDuration) {
            // 读取一帧数据
            extractor.readSampleData(buffer, 0)
            extractor.advance()
            
            currentPosition = extractor.sampleTime
            val progress = (currentPosition * 100 / totalDuration).toInt()
            
            // 发送进度到主线程
            val msg = mHandler.obtainMessage()
            msg.what = MSG_PROGRESS
            msg.arg1 = progress
            mHandler.sendMessage(msg)
        }
        
        // 发送完成消息
        mHandler.sendEmptyMessage(MSG_COMPLETE)
    }.start()
}
小技巧: 我习惯用 obtainMessage() 而不是 new Message(),因为前者会复用 Message 对象,减少内存分配。虽然影响不大,但写多了就成习惯了。

Handler 的缺点也很明显:代码分散,容易造成内存泄漏。如果你在 Activity 里写了匿名内部类 Handler,Activity 销毁时 Handler 还在持有引用,就会泄漏。

解决办法?用静态内部类 + 弱引用。或者,干脆用下面两种方案。

方案二:LiveData 实现进度监听

LiveData 是 Jetpack 里的好东西。它最大的优点是「生命周期感知」——Activity 在前台时才更新 UI,后台了就不更新,省得崩溃。

我记得第一次用 LiveData 时,感觉就是「终于不用手动处理生命周期了」。

// ViewModel 中定义 LiveData
class ExtractViewModel : ViewModel() {
    private val _progress = MutableLiveData<Int>()
    val progress: LiveData<Int> get() = _progress
    
    private val _status = MutableLiveData<String>()
    val status: LiveData<String> get() = _status
    
    fun startExtract() {
        // 使用协程或线程执行提取
        Thread {
            val extractor = MediaExtractor()
            // ... 初始化 ...
            
            val totalDuration = getTotalDuration()
            var currentPosition = 0L
            
            while (currentPosition < totalDuration) {
                extractor.readSampleData(buffer, 0)
                extractor.advance()
                
                currentPosition = extractor.sampleTime
                val progress = (currentPosition * 100 / totalDuration).toInt()
                
                // 通过 LiveData 更新进度
                _progress.postValue(progress)
            }
            
            _status.postValue("提取完成")
        }.start()
    }
}

// Activity 中观察
viewModel.progress.observe(this) { progress ->
    progressBar.progress = progress
    tvProgress.text = "$progress%"
}

viewModel.status.observe(this) { status ->
    tvStatus.text = status
}

重点: 子线程更新 LiveData 必须用 postValue(),不能用 setValue()。因为 setValue() 只能在主线程调用。我曾经在这个坑里摔过一次,debug 了半天才发现。

LiveData 的好处是解耦。ViewModel 只管发数据,Activity 只管收数据。两者互不依赖,测试起来也方便。

方案三:协程 + Flow 实现进度流

如果你项目已经用了 Kotlin 协程,那 Flow 绝对是首选。代码写起来像流水一样顺畅。

我个人现在新项目都优先用协程。为什么?因为协程可以取消啊!用户点了个「取消提取」,协程直接 cancel,线程还得自己搞标志位,麻烦得很。

// ViewModel 中使用协程
class ExtractViewModel : ViewModel() {
    private val _progress = MutableStateFlow(0)
    val progress: StateFlow<Int> = _progress.asStateFlow()
    
    private val _status = MutableStateFlow("准备中")
    val status: StateFlow<String> = _status.asStateFlow()
    
    private var extractJob: Job? = null
    
    fun startExtract() {
        extractJob = viewModelScope.launch(Dispatchers.IO) {
            val extractor = MediaExtractor()
            // ... 初始化 ...
            
            val totalDuration = getTotalDuration()
            var currentPosition = 0L
            
            while (currentPosition < totalDuration) {
                // 检查是否被取消
                ensureActive()
                
                extractor.readSampleData(buffer, 0)
                extractor.advance()
                
                currentPosition = extractor.sampleTime
                val progress = (currentPosition * 100 / totalDuration).toInt()
                
                // 发射进度
                _progress.value = progress
            }
            
            _status.value = "提取完成"
        }
    }
    
    fun cancelExtract() {
        extractJob?.cancel()
        _status.value = "已取消"
    }
}

// Activity 中收集
lifecycleScope.launch {
    viewModel.progress.collect { progress ->
        progressBar.progress = progress
        tvProgress.text = "$progress%"
    }
}

lifecycleScope.launch {
    viewModel.status.collect { status ->
        tvStatus.text = status
    }
}

注意: 协程里用 ensureActive() 检查取消状态,这是个好习惯。如果不检查,即使调用了 cancel(),协程也会继续执行到下一个挂起点。我在一个批量提取功能里吃过这个亏,用户取消了但还在后台跑,白白浪费性能。

三种方案对比

方案 优点 缺点 适用场景
Handler 简单直接,无需额外依赖 容易内存泄漏,代码分散 老项目、简单场景
LiveData 生命周期感知,解耦好 需要 ViewModel,学习成本略高 Jetpack 项目、MVVM 架构
协程 + Flow 可取消,代码简洁,支持背压 需要 Kotlin 协程基础 新项目、Kotlin 为主的项目

核心逻辑流程图

下面这张图展示了三种方案的整体流程。从子线程提取数据,到进度传递,再到 UI 更新,一目了然。

进度回调与UI更新核心流程 子线程提取数据 计算进度百分比 Handler.sendMessage LiveData.postValue Flow.emit 主线程更新UI

实际项目中的选择建议

说了这么多,到底该用哪个?

我的建议是这样的:

  • 如果你在维护老项目,项目里全是 Java,那 Handler 是最稳妥的选择。别为了一个进度回调引入一堆新依赖,不值当。
  • 如果你在用 MVVM 架构,那 LiveData 是标配。配合 ViewModel,代码结构清晰,测试也方便。
  • 如果你是新项目,而且全 Kotlin,那直接上协程 + Flow。代码最少,功能最强,还能方便地做取消、重试、超时等操作。

个人经验: 我现在的项目都是「协程 + Flow」组合。但有一个例外——如果进度需要被多个观察者同时监听,我会用 SharedFlow 替代 StateFlow。因为 StateFlow 只保留最新值,新观察者收不到历史进度。SharedFlow 可以配置 replay 数量,更灵活。

好了,进度回调这块就聊到这儿。三种方案各有千秋,选适合自己项目的就行。记住一点:不管用哪种方案,一定要保证子线程不直接操作 UI,这是 Android 的铁律,碰都不要碰。


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