Android音视频开发框架(下)

前言

前文讲到Android音视频开发框架中的上半段:音视频的创建,编码,保存,这个属于音视频资源生产端的过程。在消费端,还需要经历读取,解码,播放这三个节点。

音视频读取

在前文中,我们可以打通从摄像头+麦克风-编码数据-保存文件这个过程,假如一切顺利,那么可以在磁盘中保存一个MP4文件。但是想要消费这段影片,首先要做的就是提取文件里的编码过的音频和视频信息。这个工作主要依赖于MediaExtractor类。

MediaExtractor的主要方法如下:

scss 复制代码
// 设置数据源
mediaExtractor.setDataSource()
// 获取轨道数(音频轨道,视频轨道,字幕轨道等)
mediaExtractor.getTrackCount()
// 获取该轨道的格式类型(是音频还是视频)
mediaExtractor.getTrackFormat()
// 选择轨道(确定读取哪个轨道的数据)
mediaExtractor.selectTrack()
// 读取采样数据到数组
mediaExtractor.readSampleData()
// 进入下一个采样,readSampleData之后需要调用advance推动指针往前挪动
mediaExtractor.advance()
// 返回当前轨道索引
mediaExtractor.getSampleTrackIndex()
// 返回当前采样的显示时间
mediaExtractor.getSampleTime()
// seek到对应时间
mediaExtractor.seekTo()
// 释放资源
mediaExtractor.release()

我们可以把MediaExtrtactor看作是MediaMuxer的逆过程,后者是把音频视频封装写入文件,前者是读取文件,解封装获取独立的音频和视频。

音频和视频分别是独立线程编解码的,那么读取自然在分在两个线程中分别读取互不干扰。而且由于操作的相似性,我们可以对它的操作进行一定的封装:

kotlin 复制代码
class MExtractor(filePath:String) {
    companion object{
        val EXTRACTOR_TAG = "extractor_tag"
    }
    private var audioTrackIndex = -1
    private var videoTrackIndex = -1
    private val mediaExtractor:MediaExtractor by lazy {
        MediaExtractor()
    }

    init {
        try {
            mediaExtractor.setDataSource(filePath)
        }catch (e:IOException){
            e.printStackTrace()
            Log.e(EXTRACTOR_TAG,"${e.message}")
        }
    }
    // 选择音频轨道
    fun selectAudioTrack(){
        val index = getAudioTrack()
        if (index == -1) return
        mediaExtractor.selectTrack(index)
    }
     // 选择视频轨道
    fun selectVideoTrack(){
        val index = getVideoTrack()
        if (index == -1) return
        mediaExtractor.selectTrack(index)
    }
    // 读取(对应轨道的)数据
    fun readSampleData(byteBuf: ByteBuffer,  offset:Int):Pair<Int,Long>{
        //读取一块数据
        val readSize = mediaExtractor.readSampleData(byteBuf, offset)
        // 获取这块数据对应的时间错
        val sampleTimeValue = mediaExtractor.sampleTime
        //指针往前移动
        mediaExtractor.advance()
        return Pair(readSize,sampleTimeValue)
    }
    ...
    ...
    fun getAudioTrack():Int{
        if (audioTrackIndex != -1){
            return audioTrackIndex
        }
        for (i in 0..mediaExtractor.trackCount) {
            val format = mediaExtractor.getTrackFormat(i)
            if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true){
                Log.i(EXTRACTOR_TAG,"selected format: $format  track: $i")
                audioTrackIndex = i
                return i
            }
        }
        return -1;
    }

    fun getVideoTrack():Int{
        if (mediaExtractor.trackCount == 0){
            return -1
        }
        if (videoTrackIndex != -1){
            return videoTrackIndex
        }
        for (i in 0..mediaExtractor.trackCount) {
            val format = mediaExtractor.getTrackFormat(i)
            Log.i(EXTRACTOR_TAG,"video index: $i  format: $format")

            if (format.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true){
                Log.i(EXTRACTOR_TAG,"format: $format")
                videoTrackIndex = i
                return i
            }
        }
        return -1
    }
}

以上基本上就是MediaExtractor的全部了,他往往需要配合其他的组件使用。

音视频解码

有了MediaExtractor的帮助,我们已经可以 从文件中获取数据源,接着我们还是使用异步模式来开启解码过程

视频

kotlin 复制代码
private val videoHandlerThread: HandlerThread = HandlerThread("video-thread").apply { start() }
private val videoHandler = Handler(videoHandlerThread.looper)

private val mediaExtractor: MExtractor by lazy {
    MExtractor(fileData.filePath)
}

// 异步模式的回调
private val videoCallback = object : CodecCallback() {
    override fun onInputBufferAvailableWrapper(codec: MediaCodec, index: Int) {
        if (isSignalEOF || mediaExtractor.getSampleTrackIndex() == -1) {
            return
        }
        pauseIfNeed()

        val inputBuffer = codec.getInputBuffer(index) ?: return
        inputBuffer.clear()
        // 选择视频轨道
        mediaExtractor.selectVideoTrack()
        //读取数据
        // sampleTime 视频的PTS
        var (readSize, sampleTime) = mediaExtractor.readSampleData(inputBuffer, 0)
        if (readSize < 0) {
            inputBuffer.limit(0)
            codec.queueInputBuffer(index, 0, 0, 0, 0)
            isSignalEOF = true
        } else {
            codec.queueInputBuffer(index, 0, readSize, sampleTime, 0)
        }


    }

    override fun onOutputBufferAvailableWrapper(
        codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
    ) {
        if (isOutputEOF) {
            return
        }
        isOutputEOF = (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
        trySleep(info.presentationTimeUs)
        // index 是解码后的数据缓存空间下标
        // 第二个参数表示是否渲染(如果提前设置了输出端的Surface的话,填true)
        codec.releaseOutputBuffer(index, true)

    }
    ...
    ...
}


...
// configure
mediaExtractor.getVideoFormat()?.let {
    val mime = it.getString(MediaFormat.KEY_MIME)
    mime?.let {m->
        videoDecoder = MediaCodec.createDecoderByType(m)
        // 这个surface来自于播放器(SurfaceView或者TextureView)
        videoDecoder?.configure(it, surface, null, 0)
        videoDecoder?.setCallback(videoCallback, videoHandler)
    }

}
// 开始解码
videoDecoder?.start()
...
...
// release
videoDecoder?.stop()
videoDecoder?.release()

对于视频的解码过程,输出端我们仍然可以使用Surface来简化我们的输出操作,MediaCodec提供了直接输出数据到Surface的过程,因此我们把播放端的SurfaceView或者TextureView中的surface传入进来,那么数据就可以直接打通了。

音频

音频的解码过程和视频解码大差不差

kotlin 复制代码
private val audioHandlerThread: HandlerThread = HandlerThread("audio-thread").apply { start() }
private val audioHandler = Handler(audioHandlerThread.looper)

private val mediaExtractor: MExtractor by lazy {
    MExtractor(fileData.filePath)
}

// 解码异步模式回调
    private val audioCallback = object : CodecCallback() {
        override fun onInputBufferAvailableWrapper(codec: MediaCodec, index: Int) {
            if (isEOF || mediaExtractor.getSampleTrackIndex() == -1) {
                return
            }
            pauseIfNeed()
            val inputBuffer = codec.getInputBuffer(index) ?: return
            inputBuffer.clear()
            mediaExtractor.selectAudioTrack()
            // 读取采样数据到buffer,获取采样时间,同时指针向前推进
            // sampleTimeValue就是当前数据的PTS,这个直接从mediaExtractor中获取,从0开始
            val (readSize, sampleTimeValue) = mediaExtractor.readSampleData(inputBuffer, 0)
            if (readSize < 0) {
                codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                isEOF = true
            } else {
                codec.queueInputBuffer(index, 0, readSize, sampleTimeValue, 0)
            }

        }

        override fun onOutputBufferAvailableWrapper(
            codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
        ) {
            val outputBuffer = codec.getOutputBuffer(index)
            outputBuffer?.let {
                it.position(info.offset)
                it.limit(info.offset + info.size)
                ...
                // 向音频播放设备写入数据
                ...
            }

            trySleep(info.presentationTimeUs)
            codec.releaseOutputBuffer(index, false) // 重要
        }
    ...
    ...
    }


// configure
mediaExtractor.getAudioFormat()?.let {
    val mime = it.getString(MediaFormat.KEY_MIME) ?: ""
    audioDecoder = MediaCodec.createDecoderByType(mime)
    audioDecoder?.configure(it, null, null, 0)
    audioDecoder?.setCallback(audioCallback, audioHandler)
    Log.i(TAG, "audio inputbuffer mime: $mime")

}
// start
audioDecoder?.start()
...
...
// release
audioDecoder?.stop()
audioDecoder?.release()

音视频播放

音视频播放其实是完全不同的路径,视频播放依赖TextureView等的view展示,而音频播放则是依赖音频设备。

对于视频而言,我们需要在UI中插入TextureView(SurfaceView也一样),然后在TextureView中设置SurfaceTextureListener,等待SUrface的创建成功,接着把SUrface传入解码器

kotlin 复制代码
dataBinding.textureview.surfaceTextureListener = object :SurfaceTextureListener{
    override fun onSurfaceTextureAvailable(
        surfaceTexture: SurfaceTexture,
        width: Int,
        height: Int
    ) {
        Log.i(TAG,"onSurfaceTextureAvailable  $width $height $surfaceTexture")
        val surface = Surface(surfaceTexture)
        startDecodeVideo(surface) // 传入解码模块
        startDecodeAudio() // 一般也可以在此时触发音频的解码

    }

    override fun onSurfaceTextureSizeChanged(
        surface: SurfaceTexture,
        width: Int,
        height: Int
    ) {
        Log.i(TAG,"onSurfaceTextureSizeChanged  $width $height $surface")

    }

    override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
        curSurface?.release()
        Log.i(TAG,"onSurfaceTextureDestroyed   $surfaceTexture")
        return true
    }

    override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
        Log.i(TAG,"onSurfaceTextureUpdated   $surfaceTexture")

    }
}

这样,解码的视频帧就可以显示在textureView上了。

但是音频的播放过程则完全在后台进行

scss 复制代码
//  创建音频播放设备
mediaExtractor.getAudioFormat()?.let {
// 初始化配置
    val audioAttr = AudioAttributes.Builder()
        .setContentType(CONTENT_TYPE_MOVIE)
        .setLegacyStreamType(AudioManager.STREAM_MUSIC)
        .setUsage(USAGE_MEDIA)
        .build()
    val sampleRate = it.getInteger(MediaFormat.KEY_SAMPLE_RATE)
    var channelMask = if (it.containsKey(MediaFormat.KEY_CHANNEL_MASK)) {
        it.getInteger(MediaFormat.KEY_CHANNEL_MASK)
    } else {
        null
    }
    var channelCount = 1
    if (it.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
        channelCount = it.getInteger(MediaFormat.KEY_CHANNEL_COUNT)

    }
    val channelConfig =
        if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
    if (channelMask == null) {
        channelMask = channelConfig
    }
    val formatInt = if (it.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
        it.getInteger(MediaFormat.KEY_PCM_ENCODING)
    } else {
        AudioFormat.ENCODING_PCM_16BIT
    }
    val audioFormat = AudioFormat.Builder()
        .setChannelMask(channelMask)
        .setEncoding(formatInt)
        .setSampleRate(sampleRate)
        .build()

    bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, formatInt)
    // 创建音频播放设备
    audioTrack = AudioTrack(
        audioAttr,
        audioFormat,
        bufferSize,
        AudioTrack.MODE_STREAM,
        audioManager.generateAudioSessionId()
    )
}


//开始播放,和audioDecode.start同时调用即可
audioTrack?.play()

// 在合适的时机写入音频数据(一般就放在解码完成输出之后写入即可)
audioTrack?.write(...)


// 释放资源
audioTrack?.stop()
audioTrack?.release()

以上就是音频播放设备的使用方式。

你以为这样就结束了么?天真。

如果按照正常操作视频的解码速度会很快,你会发现视频像走马灯一样播放完了,音频还在播放,因此我们需要对音视频进行同步。

音视频同步

由于每一帧音频或者视频数据都有PTS,也就是说已经设定好了这一帧数据应该播放的时间点,而音视频同步要做的就是,当解码出来的帧的时间戳还没到播放的时间节点时,我们需要等待,一直等到播放的时间节点到来。

音视频同步的方法不止一种,我选择大家比较容易理解的一种来描述:选择一条独立的时间轴,每次音频或者视频解码出来之后的时间戳与独立时间轴的当前时间戳进行比较,如果大于当前时间戳,表示该帧数据还没有到展示的时候,需要等待,否则就直接展示。

如何实现呢?比较简单,在开始解码时的时间设为独立时间轴的起点startPresentationTimeUs,后续的解码回调中和这个时间起点进行比较即可

kotlin 复制代码
// 开始解码时调用,并记录一下时间起点
@CallSuper
 override fun start() {
    if (startPresentationTimeUs == -1L){
        startPresentationTimeUs = getMicroSecondTime()
    }
}

protected fun getMicroSecondTime():Long{
    return System.nanoTime()/1000L
}

// 每次准备播放音频或者视频时调用一次,
protected fun trySleep(sampleTime:Long){
    val standAlonePassTime = getMicroSecondTime()-startPresentationTimeUs
    if (sampleTime>standAlonePassTime){
        try {
            val sleepTime = (sampleTime-standAlonePassTime)/1000
            Log.i(TAG,"sleep time $sampleTime  ${sleepTime}ms  $this")
            // 如果时间不够,就休眠
            Thread.sleep(sleepTime)
        }catch (e:InterruptedException){
            e.printStackTrace()
        }
    }
}

这就实现了一个简单的音视频同步的逻辑了,我相信理解起来没有太大的难度。当然,如果系统有支持的方法我们自然不必亲自实现同步逻辑,在Android体系中,有MediaSync可以帮助我们实现音视频播放同步的逻辑,使用起来不算太复杂,不过它也同样深度嵌套到音视频的解码过程中去了,这个留给大家去熟悉吧。

除了音视频同步这个重要内容外,其实还有播放/暂停,这个过程也会影响到音视频同步的逻辑,因为播放暂停时,每帧数据的显示时间戳PTS不会变,但是我们建立的独立时间轴的时间会继续流逝,等恢复之后,在比较时间戳就完全错误了,因此我们需要在暂停和恢复时记录一下暂停的时长,然后在比较时减去这段时间,又或者直接把独立时间轴的起点时间往后挪动暂停时长即可。

此外,播放过程中获取预览图,播放进度条等内容也是基本内容,我认为它们并没有比音视频同步更难以理解,因此不一一说明了。

Android当然有支持较好的播放器可以同时播放音频和视频,而且还能自动帮助我们解码数据,这些我相信大家是更了解的。

总结

到此,Android的音视频开发框架基本描述完整了,它涵盖了音视频的创建,编码,保存,提取,解码,播放的全过程,当然每个部分只是囫囵吞枣的介绍,代码也不是完整,其实这里里面很多内容都可以单列一章来讲,细节颇多,不过我认为作为一个简介性质的文章深度是够了的,主要侧重于介绍概念和使用方法。后续深入研究还靠自己,本身的水平也有限。

相关推荐
2401_8574396916 分钟前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧66617 分钟前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
李老头探索19 分钟前
Java面试之Java中实现多线程有几种方法
java·开发语言·面试
weixin_4493108424 分钟前
高效集成:聚水潭采购数据同步到MySQL
android·数据库·mysql
芒果披萨25 分钟前
Filter和Listener
java·filter
qq_49244844629 分钟前
Java实现App自动化(Appium Demo)
java
阿华的代码王国37 分钟前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话
Zender Han39 分钟前
Flutter自定义矩形进度条实现详解
android·flutter·ios
找了一圈尾巴1 小时前
前后端交互通用排序策略
java·交互
白乐天_n3 小时前
adb:Android调试桥
android·adb