Android技術分享| 自定義ViewGroup實現直播間大小屏無縫切換

anyRTC發表於2021-08-17

原始碼地址:請點選這裡

在這裡插入圖片描述

需求

兩種顯示方式:

  1. 主播全屏,其他遊客懸浮在右側。下面簡稱大小屏模式。

在這裡插入圖片描述

  1. 所有人等分螢幕。下面簡稱等分模式。

在這裡插入圖片描述

分析

  • 最多4人連麥,明確這點方便定製座標演算法。
  • 自定義的 ViewGroup 最好分別提供等分模式和大小屏模式的邊距設定介面,便於修改。
  • SDK 自己管理了 TextureView 的繪製和測量,所以 ViewGroup 需要複寫 onMeasure 方法以通知 TextureView 測量和繪製。
  • 一個計算 0.0f ~ 1.0f 逐漸減速的函式,給動畫過程做支撐。
  • 一個記錄座標的資料模型。和一個根據現有 Child View 的數量計算兩種佈局模式下,每個 View 擺放位置的函式。

實現

1.定義座標資料模型

private data class ViewLayoutInfo(
    var originalLeft: Int = 0,// original開頭的為動畫開始前的起始值
    var originalTop: Int = 0,
    var originalRight: Int = 0,
    var originalBottom: Int = 0,
    var left: Float = 0.0f,// 無字首的為動畫過程中的臨時值
    var top: Float = 0.0f,
    var right: Float = 0.0f,
    var bottom: Float = 0.0f,
    var toLeft: Int = 0,// to開頭的為動畫目標值
    var toTop: Int = 0,
    var toRight: Int = 0,
    var toBottom: Int = 0,
    var progress: Float = 0.0f,// 進度 0.0f ~ 1.0f,用於控制 Alpha 動畫
    var isAlpha: Boolean = false,// 透明動畫,新新增的執行此動畫
    var isConverted: Boolean = false,// 控制 progress 反轉的標記
    var waitingDestroy: Boolean = false,// 結束後銷燬 View 的標記
    var pos: Int = 0// 記錄自己索引,以便銷燬
) {
    init {
        left = originalLeft.toFloat()
        top = originalTop.toFloat()
        right = originalRight.toFloat()
        bottom = originalBottom.toFloat()
    }
}

以上,記錄了執行動畫和銷燬View所需的資料。(於原始碼中第352行)

2.計算不同展示模式下View座標的函式

if (layoutTopicMode) {
    var index = 0
    for (i in 1 until childCount) if (i != position) (getChildAt(i).tag as ViewLayoutInfo).run {
        toLeft = measuredWidth - maxWidgetPadding - smallViewWidth
        toTop = defMultipleVideosTopPadding + index * smallViewHeight + index * maxWidgetPadding
        toRight = measuredWidth - maxWidgetPadding
        toBottom = toTop + smallViewHeight
        index++
    }
} else {
    var posOffset = 0
    var pos = 0
    if (childCount == 4) {
        posOffset = 2
        pos++
                                                                                                               
        (getChildAt(0).tag as ViewLayoutInfo).run {
            toLeft = measuredWidth.shr(1) - multiViewWidth.shr(1)
            toTop = defMultipleVideosTopPadding
            toRight = measuredWidth.shr(1) + multiViewWidth.shr(1)
            toBottom = defMultipleVideosTopPadding + multiViewHeight
        }
    }
                                                                                                               
    for (i in pos until childCount) if (i != position) {
        val topFloor = posOffset / 2
        val leftFloor = posOffset % 2
        (getChildAt(i).tag as ViewLayoutInfo).run {
            toLeft = leftFloor * measuredWidth.shr(1) + leftFloor * multipleWidgetPadding
            toTop = topFloor * multiViewHeight + topFloor * multipleWidgetPadding + defMultipleVideosTopPadding
            toRight = toLeft + multiViewWidth
            toBottom = toTop + multiViewHeight
        }
        posOffset++
    }
}

post(AnimThread(
    (0 until childCount).map { getChildAt(it).tag as ViewLayoutInfo }.toTypedArray()
))

Demo原始碼中的add、remove、toggle方法重複程式碼過多,未來得及最佳化。這裡只附上 addVideoView 中的計算部分(於原始碼中第141行),只需稍微修改即可適用add、remove和toggle。(也可參考 CDNLiveVM 中的 calcPosition 方法,為經過最佳化的版本)layoutTopicMode = true 時,為大小屏模式。

由於是定製演算法,只能適用這一種佈局,故不寫註釋。只需明確一點,此方法最終目的是為了計算出每個View當前應該出現的位置,儲存到上面定義的資料模型中並開啟動畫(最後一行 post AnimThread 為開啟動畫的程式碼,我這裡是透過 post 一個執行緒來更新每一幀)。

可根據不同的需求寫不同的實現,最終符合定義的資料模型即可。

3.逐漸減速的演算法,使動畫效果看起來更自然。

private inner class AnimThread(
    private val viewInfoList: Array<ViewLayoutInfo>,
    private var duration: Float = 180.0f,
    private var processing: Float = 0.0f
) : Runnable {
    private val waitingTime = 9L
                                                                                   
    override fun run() {
        var progress = processing / duration
        if (progress > 1.0f) {
            progress = 1.0f
        }
                                                                                   
        for (viewInfo in viewInfoList) {
            if (viewInfo.isAlpha) {
                viewInfo.progress = progress
            } else viewInfo.run {
                val diffLeft = (toLeft - originalLeft) * progress
                val diffTop = (toTop - originalTop) * progress
                val diffRight = (toRight - originalRight) * progress
                val diffBottom = (toBottom - originalBottom) * progress
                                                                                   
                left = originalLeft + diffLeft
                top = originalTop + diffTop
                right = originalRight + diffRight
                bottom = originalBottom + diffBottom
            }
        }
        requestLayout()
                                                                                   
        if (progress < 1.0f) {
            if (progress > 0.8f) {
                var offset = ((progress - 0.7f) / 0.25f)
                if (offset > 1.0f)
                    offset = 1.0f
                processing += waitingTime - waitingTime * progress * 0.95f * offset
            } else {
                processing += waitingTime
            }
            postDelayed(this@AnimThread, waitingTime)
        } else {
            for (viewInfo in viewInfoList) {
                if (viewInfo.waitingDestroy) {
                    removeViewAt(viewInfo.pos)
                } else viewInfo.run {
                    processing = 0.0f
                    duration = 0.0f
                    originalLeft = left.toInt()
                    originalTop = top.toInt()
                    originalRight = right.toInt()
                    originalBottom = bottom.toInt()
                    isAlpha = false
                    isConverted = false
                }
            }
            animRunning = false
            processing = duration
            if (!taskLink.isEmpty()) {
                invokeLinkedTask()// 此方法執行正在等待中的任務,從原始碼中能看到,remove、add等函式需要依次執行,前一個動畫未執行完畢就進行下一個動畫可能會導致不可預知的錯誤。
            }
        }
    }
}

上述程式碼除了提供減速演算法,還一併更新了對應View資料模型的中間值,也就是模型定義種的 left, top, right, bottom 。

透過減速演算法提供的進度值,乘以目標座標與起始座標的間距,得出中間值。

逐漸減速的演算法關鍵程式碼為:

if (progress > 0.8f) {
    var offset = ((progress - 0.7f) / 0.25f)
    if (offset > 1.0f)
        offset = 1.0f
    processing += waitingTime - waitingTime * progress * 0.95f * offset
} else {
    processing += waitingTime
}

這個演算法實現的有缺陷,因為它直接修改了進度時間,大機率會導致執行完畢的時間與設定的預期時間(如設定200ms執行完畢,實際可能超過200ms)不符。文末我會提供一個最佳化的減速演算法。

變數 waitingTime 表示等待多久執行下一幀動畫。用每秒1000ms計算即可,如果目標為60重新整理率的動畫,設定為1000 / 60 = 16.66667即可(近似值)。

計算並儲存每個 View 的中間值後,呼叫 requestLayout() 通知系統的 onMeasure 和 onLayout 方法,重新擺放 View 。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    if (childCount == 0)
        return
                                                                         
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val layoutInfo = child.tag as ViewLayoutInfo
        child.layout(
            layoutInfo.left.toInt(),
            layoutInfo.top.toInt(),
            layoutInfo.right.toInt(),
            layoutInfo.bottom.toInt()
        )
        if (layoutInfo.isAlpha) {
            val progress = if (layoutInfo.isConverted)
                1.0f - layoutInfo.progress
            else
                layoutInfo.progress
                                                                         
            child.alpha = progress
        }
    }
}

4.定義邊距相關的變數,供簡單的定製修改

/**
 * @param multipleWidgetPadding : 等分模式讀取
 * @param maxWidgetPadding : 大小屏佈局讀取
 * @param defMultipleVideosTopPadding : 距離頂部變距
 */
private var multipleWidgetPadding = 0
private var maxWidgetPadding = 0
private var defMultipleVideosTopPadding = 0
                                                                                  
init {
    viewTreeObserver.addOnGlobalLayoutListener(this)
    attrs?.let {
        val typedArray = resources.obtainAttributes(it, R.styleable.AnyVideoGroup)
        multipleWidgetPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_between23viewsPadding, 0
        )
        maxWidgetPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_at4smallViewsPadding, 0
        )
        defMultipleVideosTopPadding = typedArray.getDimensionPixelOffset(
            R.styleable.AnyVideoGroup_defMultipleVideosTopPadding, 0
        )
        layoutTopicMode = typedArray.getBoolean(
            R.styleable.AnyVideoGroup_initTopicMode, layoutTopicMode
        )
        typedArray.recycle()
    }
}

取名時對這三個變數的職責定義,與編寫邏輯時的定義有出入,所以有點詞不達意,需參考註釋。

由於這只是定製化的變數,並不重要,可根據業務邏輯自行隨意修改。

5.複寫 onMeasure 方法,這裡主要是通知 TextureView 更新大小。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
                                                                                               
    multiViewWidth = widthSize.shr(1)
    multiViewHeight = (multiViewWidth.toFloat() * 1.33334f).toInt()
    smallViewWidth = (widthSize * 0.3125f).toInt()
    smallViewHeight = (smallViewWidth.toFloat() * 1.33334f).toInt()
                                                                                               
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val info = child.tag as ViewLayoutInfo
        child.measure(
            MeasureSpec.makeMeasureSpec((info.right - info.left).toInt(), MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec((info.bottom - info.top).toInt(), MeasureSpec.EXACTLY)
        )
    }
                                                                                               
    setMeasuredDimension(
        MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY),
        MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)
    )
}

總結

  1. 明確資料模型,一般情況下記錄起始上下左右座標、目標上下左右座標、和進度百分比就足夠了。
  2. 根據需求明確動畫演算法,這裡補充一下最佳化的減速演算法:
factor = 1.0
if (factor == 1.0)
    (1.0 - (1.0 - x) * (1.0 - x))
else
    (1.0 - pow((1.0 - x), 2 * factor))
// x = time.
  1. 根據演算法計算出來的值更新 layout 佈局即可。

此類 ViewGroup 實現簡單方便,只涉及到幾個基本系統API。如不想寫 onMeasure 方法可繼承 FrameLayout 等已寫好 onMeasure 實現的 ViewGroup 。

相關文章