Android OpenGL ES 10.1 視頻播放器

課程介紹

在學習了前面章節(jié)OpenGL基礎(chǔ)知識后,讀者應(yīng)該具備了復(fù)雜界面特效、圖片高效處理的開發(fā)能力。接下來的章節(jié)主要轉(zhuǎn)向Android視頻應(yīng)用開發(fā)中的OpenGL ES部分。

一. 視頻播放器搭建

1. 視圖容器

界面視圖容器依舊使用GLSurfaceView,繪制方式是RENDERMODE_CONTINUOUSLY持續(xù)繪制的模式(課程演示,減少框架部分,相應(yīng)的有不必要的性能損耗)。

2. 必要框架

因為本節(jié)涉及外部文件讀取,所以會涉及到外部存儲讀寫權(quán)限的獲取、文件URI的解析、媒體文件數(shù)據(jù)解析,該部分內(nèi)容非本節(jié)重點,因此詳情見工程代碼。

3. 媒體播放器

要驅(qū)動視頻進行播放,需要借助到系統(tǒng)的媒體播放器MediaPlayer,它的相關(guān)方法如下:

  • setDataSource:設(shè)置數(shù)據(jù)源,這里直接傳入文件路徑
  • isLooping:是否循環(huán)播放
  • prepare:進入準備狀態(tài)
  • start:開始播放
  • pause:暫停
  • stop:停止
  • release:釋放資源

具體的API可以參見官網(wǎng),生命周期流程圖如下:

mediaplayer_state_diagram

播放器生命周期的邏輯處理,詳見工程代碼。這里重點講解下如何將MediaPlayer和GLSurfaceView進行綁定使用,從而可以在GL上進行視頻渲染播放。

①. 綁定紋理ID

//  1. 創(chuàng)建紋理ID
    val textureIds = ......
//  2. 創(chuàng)建SurfaceTexture、Surface,并綁定到MediaPlayer上,接收畫面驅(qū)動回調(diào)
    surfaceTexture = SurfaceTexture(textureIds[0])
    surfaceTexture!!.setOnFrameAvailableListener(this)
    val surface = Surface(surfaceTexture)
    mediaPlayer.setSurface(surface)

這里分為以下幾步:

  1. 創(chuàng)建紋理ID,用于GL渲染
  2. 通過紋理ID創(chuàng)建一個SurfaceTexture對象
  3. 通過SurfaceTexture對象創(chuàng)建一個Surface對象
  4. 將Surface傳入MediaPlayer中

通過以上4步,將紋理ID和MediaPlayer進行關(guān)聯(lián)。

②. 接收畫面解析完畢的回調(diào)

當視頻幀解析完畢時,MediaPlayer會通過層層的接口調(diào)用到SurfaceTexture的onFrameAvailable接口,這時候我們可以標志下畫面已經(jīng)解析完畢。

/**
 * MediaPlayer有新的畫面幀刷新時,通過SurfaceTexture的onFrameAvailable接口進行回調(diào)
 */
override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) {
    updateSurface = true
}

③. 驅(qū)動畫面更新紋理ID

override fun onDrawFrame(glUnused: GL10) {
    if (updateSurface) {
        // 當有畫面幀解析完畢時,驅(qū)動SurfaceTexture更新紋理ID到最近一幀解析完的畫面,并且驅(qū)動底層去解析下一幀畫面
        surfaceTexture!!.updateTexImage()
        updateSurface = false
    }
    // ...之后通過紋理ID繪制畫面
}

在這里必須將updateTexImage放在onDrawFrame中進行調(diào)用,而不能放在第二步的onFrameAvailable方法中,因為這個方法內(nèi)部介紹了,必須是在GL線程中進行調(diào)用,而不能在主線程中。而GL線程的生命周期方法有onSurfaceCreated、onSurfaceChanged、onDrawFrame,想要不停地更新畫面,自然是放在onDrawFrame中最為合適。

二. GL渲染視頻畫面

之前課程中,我們渲染的紋理都是2D紋理,而在紋理繪制的課程中,有介紹到紋理單元是包括多種類型的紋理目標,包括了GL_TEXTURE_1D、GL_TEXTURE_2D、CUBE_MAP、GL_TEXTURE_OES,而本節(jié)課用到了GL_TEXTURE_OES這個類型的紋理目標。

要啟用GL_TEXTURE_OES這種拓展類型的紋理目標,需要對之前課程中的2D紋理進行以下修改。

1. 修改紋理類型聲明

private const val FRAGMENT_SHADER = """
        #extension GL_OES_EGL_image_external : require
        precision mediump float;
        varying vec2 v_TexCoord;
        uniform samplerExternalOES u_TextureUnit;
        void main() {
            gl_FragColor = texture2D(u_TextureUnit, v_TexCoord);
        }
        """

在編寫Shader時,需要聲明當前使用的是拓展紋理GL_OES_EGL_image_external,然后采樣器類型必須是samplerExternalOES。

另外經(jīng)過測試,在Fragment Shader中,不能同時啟用2D紋理和OES紋理的繪制,即使只是聲明而不執(zhí)行也是不可以的。

2. 創(chuàng)建紋理、繪制紋理

創(chuàng)建紋理、繪制紋理時,我們綁定的紋理目標類型必須改為是OES類型的。

GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)

在完成這一步時,應(yīng)該就可以進行視頻畫面的播放了,不過還會有些小問題,比如視頻比例、方向的處理,下面會為大家解決這兩個問題。


三. 解決視頻角度問題

手機相機拍攝出來的文件,若帶了Exif文件信息,那么就需要對文件解析展示的過程中進行處理。否則就會出現(xiàn)和我們預(yù)期不一致的效果。

可交換圖像文件格式(英語:Exchangeable image file format,官方簡稱Exif),是專門為數(shù)碼相機的照片設(shè)定的,可以記錄數(shù)碼照片的屬性信息和拍攝數(shù)據(jù)。

角度問題效果如下圖:


視頻播放器方向問題

視頻Exif信息包含了視頻的方向,通過以下方法可以獲取到。

/**
 * 初始化視頻信息
 */
fun initMetadata() {
    if (TextUtils.isEmpty(path)) {
        return
    }

    try {
        val retriever = MediaMetadataRetriever()
        retriever.setDataSource(path)
        degrees = getInteger(retriever.extractMetadata(METADATA_KEY_VIDEO_ROTATION))
        duration = getInteger(retriever.extractMetadata(METADATA_KEY_DURATION))
        bitRate = getInteger(retriever.extractMetadata(METADATA_KEY_BITRATE))
        width = getInteger(retriever.extractMetadata(METADATA_KEY_VIDEO_WIDTH))
        height = getInteger(retriever.extractMetadata(METADATA_KEY_VIDEO_HEIGHT))
        displayWidth = if (isDisplayRotate) height else width
        displayHeight = if (isDisplayRotate) width else height
        retriever.release()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

private fun getInteger(value: String): Int {
    return if (TextUtils.isEmpty(value)) 0 else Integer.valueOf(value)
}

這里獲取到的Degree信息就是視頻的角度信息,這里返回的Int值有4個可能:0、90、180、270,這代表了視頻若想要正確播放,那么需要順時針方向旋轉(zhuǎn)這個角度值。

想要解決視頻方向問題,可以通過旋轉(zhuǎn)頂點坐標旋轉(zhuǎn)紋理坐標的方式去實現(xiàn),這里我采用的是旋轉(zhuǎn)頂點坐標的方式,工具類如下:

/**
 * 頂點旋轉(zhuǎn)工具類
 *
 * @author Benhero
 * @date   2019/2/11
 */
object VertexRotationUtil {
    enum class Rotation {
        NORMAL, ROTATION_90, ROTATION_180, ROTATION_270;
    }

    fun getRotation(rotation: Int): Rotation {
        return when (rotation) {
            0 -> VertexRotationUtil.Rotation.NORMAL
            90 -> VertexRotationUtil.Rotation.ROTATION_90
            180 -> VertexRotationUtil.Rotation.ROTATION_180
            270 -> VertexRotationUtil.Rotation.ROTATION_270
            else -> VertexRotationUtil.Rotation.NORMAL
        }
    }

    fun rotate(rotation: Int, srcArray: FloatArray): FloatArray {
        return VertexRotationUtil.rotate(getRotation(rotation), srcArray)
    }

    fun rotate(rotation: VertexRotationUtil.Rotation, srcArray: FloatArray): FloatArray {
        return when (rotation) {
            VertexRotationUtil.Rotation.ROTATION_90 -> floatArrayOf(
                    srcArray[2], srcArray[3],
                    srcArray[4], srcArray[5],
                    srcArray[6], srcArray[7],
                    srcArray[0], srcArray[1])
            VertexRotationUtil.Rotation.ROTATION_180 -> floatArrayOf(
                    srcArray[4], srcArray[5],
                    srcArray[6], srcArray[7],
                    srcArray[0], srcArray[1],
                    srcArray[2], srcArray[3])
            VertexRotationUtil.Rotation.ROTATION_270 -> floatArrayOf(
                    srcArray[6], srcArray[7],
                    srcArray[0], srcArray[1],
                    srcArray[2], srcArray[3],
                    srcArray[4], srcArray[5])
            else -> srcArray
        }
    }
}

問題解決后效果如下圖:


視頻播放器方向問題解決

四. 解決視頻比例問題

在播放橫向視頻時,可能會遇到下面這種情況,這是由于我們之前視頻的容器都是充滿屏幕的,而屏幕比例和視頻的比例不一致,所以才會有拉伸的問題。所以只要調(diào)整視頻容器的方向和視頻比例一致即可。

問題現(xiàn)象如下圖:


視頻比例問題.png

這里我們借助一個自定義View來解決這個比例問題,只要將這個View作為GLSurfaceView的父容器即可。

/**
 * 自動適配比例的布局
 */
class AspectFrameLayout : FrameLayout {

    private var mTargetAspect = -1.0
    /**
     * 是否自動適配尺寸
     */
    private var mIsAutoFit = true

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    fun setAspectRatio(aspectRatio: Double) {
        if (aspectRatio < 0) {
            throw IllegalArgumentException()
        }
        Log.w(TAG, "Setting aspect ratio to $aspectRatio (was $mTargetAspect)")
        if (mTargetAspect != aspectRatio) {
            mTargetAspect = aspectRatio
            requestLayout()
        }
    }

    fun setAutoFit(autoFit: Boolean) {
        mIsAutoFit = autoFit
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var widthMeasure = widthMeasureSpec
        var heightMeasure = heightMeasureSpec
        if (!mIsAutoFit) {
            super.onMeasure(widthMeasure, heightMeasure)
            return
        }

        if (mTargetAspect > 0) {
            var initialWidth = View.MeasureSpec.getSize(widthMeasure)
            var initialHeight = View.MeasureSpec.getSize(heightMeasure)

            val horizontalPadding = paddingLeft + paddingRight
            val verticalPadding = paddingTop + paddingBottom
            initialWidth -= horizontalPadding
            initialHeight -= verticalPadding

            val viewAspectRatio = initialWidth.toDouble() / initialHeight
            val aspectDiff = mTargetAspect / viewAspectRatio - 1

            if (Math.abs(aspectDiff) < 0.01) {
                Log.w(TAG, "aspect ratio is good (target=" + mTargetAspect +
                        ", view=" + initialWidth + "x" + initialHeight + ")")
            } else {
                if (aspectDiff > 0) {
                    initialHeight = (initialWidth / mTargetAspect).toInt()
                } else {
                    initialWidth = (initialHeight * mTargetAspect).toInt()
                }
                initialWidth += horizontalPadding
                initialHeight += verticalPadding
                widthMeasure = View.MeasureSpec.makeMeasureSpec(initialWidth, View.MeasureSpec.EXACTLY)
                heightMeasure = View.MeasureSpec.makeMeasureSpec(initialHeight, View.MeasureSpec.EXACTLY)
            }
        }

        super.onMeasure(widthMeasure, heightMeasure)
    }

    companion object {
        private const val TAG = "AspectFrameLayout"
    }
}

最后只需要在解析到視頻文件信息,讀取到視頻的比例后,通過setAspectRatio將比例值設(shè)置進這個自定義View,就可以解決比例問題。

解決效果如下圖


視頻比例問題解決

對于這個縱向問題的解決方案,可能有些讀者會疑惑,為什么不通過OpenGL自身來解決呢?如果通過OpenGL來解決,確實是可以通過按照比例修改頂點坐標參數(shù)。但是,這樣的話,會引起幾個問題:

  1. 視頻內(nèi)容外區(qū)域的顏色: 由于背景色會受GLES20.glClear影響,或者如果我們在當前畫面添加了一個反色濾鏡,那么示例圖中的黑色部分就會變成白色,也就是說這塊不屬于視頻內(nèi)容的區(qū)域,被GL所控制,這應(yīng)該不是我們想看到的產(chǎn)品效果;
  2. 水印坐標:如果想要在視頻畫面上添加內(nèi)容,那么我們定了水印的坐標在視頻的右下角,那么如果傳進來的值是(1.0 - 水印寬度, -1.0 - 水印高度),那么我們會看到的效果是,水印添加到了手機的右下角,而不是視頻內(nèi)容區(qū)域的右下角,這也不是我們想看到的,這會導(dǎo)致我們需要對之后所有繪制在視頻上的圖層做坐標換算處理,帶來很多工作量。

其他

?GitHub工程?

本系列課程所有相關(guān)代碼請參考我的GitHub項目?GLStudio?,喜歡的請給個小星星。??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容