課程介紹
在學習了前面章節(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和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)
這里分為以下幾步:
- 創(chuàng)建紋理ID,用于GL渲染
- 通過紋理ID創(chuàng)建一個SurfaceTexture對象
- 通過SurfaceTexture對象創(chuàng)建一個Surface對象
- 將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)象如下圖:

這里我們借助一個自定義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ù)。但是,這樣的話,會引起幾個問題:
- 視頻內(nèi)容外區(qū)域的顏色: 由于背景色會受GLES20.glClear影響,或者如果我們在當前畫面添加了一個反色濾鏡,那么示例圖中的黑色部分就會變成白色,也就是說這塊不屬于視頻內(nèi)容的區(qū)域,被GL所控制,這應(yīng)該不是我們想看到的產(chǎn)品效果;
- 水印坐標:如果想要在視頻畫面上添加內(nèi)容,那么我們定了水印的坐標在視頻的右下角,那么如果傳進來的值是(1.0 - 水印寬度, -1.0 - 水印高度),那么我們會看到的效果是,水印添加到了手機的右下角,而不是視頻內(nèi)容區(qū)域的右下角,這也不是我們想看到的,這會導(dǎo)致我們需要對之后所有繪制在視頻上的圖層做坐標換算處理,帶來很多工作量。
其他
- 本系列課程目錄詳見 簡書 - Android OpenGL ES教程規(guī)劃
- 參考資料見Android OpenGL ES學習資料所列舉的博客、資料。
?GitHub工程?
本系列課程所有相關(guān)代碼請參考我的GitHub項目?GLStudio?,喜歡的請給個小星星。??