Android進階——ExoPlayer源碼分析之寬帶預測策略的算法詳解

前言

由於國內基礎設施非常優秀,在平時的開發中,很少會關注網絡情況,很容易忽略弱網情況下的網絡狀況,如果項目屬於國外App,則需要考慮到當前的基礎設施和網絡情況,特別是播放視頻的時候,需要通過動態調整碼率去選擇當前的播放碼率。這時,就找到ExoPlayer源碼中的寬帶預測方案,其本質上使用的是移動平均算法,來獲取當前時間段的平均網絡情況。我們通過對當地寬帶預測,從而選擇更適應網速的碼率去播放視頻

疑問

1、爲什麼用移動平均算法呢?

設想一下,假如我們用的是普通的平均算法,那麼就是取整段時間的平均值,這時候問題就來

  • 如果我們取1小時的網速平均值作爲當前的網速,你覺得合適嗎?
  • 如果整個網絡的上下波動很大的情況下,平均值又能代表什麼呢?

最合適的就是圈定一段短的時間,在這段的時間內算平均網速,圈定的時間段稱爲滑動窗口。隨着時間的流逝,滑動窗口也會隨着時間移動,進而在滑動窗口中獲取平均值,這樣推斷出來的網絡情況纔是接近當前時間段內的網絡情況,這就是簡單的理解移動平均算法。舉例子,股票中的K線圖的5日均線,就是通過圈定5日時間,算出近5日的平均價格,用的就是此方法。

2、滑動窗口的本質是什麼?

  • 在概念上,是對數據的採集,對過時的數據進行丟棄,對新的數據進行採樣,保證數據一直是最新狀態,這就是滑動
  • 在代碼上,是一段存儲在數組的數據,通過不斷的採集和丟棄,保證數據一直是最新狀態

3、滑動窗口圈定時間的標準是什麼?

滑動窗口圈定時間的標準是人爲定義的,你可以

  • 通過時間戳去定義固定的時間段,隨着時間流逝,通過移動時間戳來移動我們的滑動窗口
  • 通過用戶下載的固定數據量,圈定固定的下載數據量總和,隨着數據下載量增加來移動我們的滑動窗口(本案例用此方案)

概念

  • 採集:通過對網速的採樣,獲取我們的滑動窗口
  • 獲取:獲取我們採樣後當前的網絡情況
  • 權重:定義滑動窗口的值

使用

1、定義採樣的滑動窗口的大小

private var mVideoSlidingPercentile: SlidingPercentile =
        SlidingPercentile(3000) // 滑動值在900k的數據

採取固定數據量的採樣方式,定義最大權重爲3000的滑動窗口,3000^2 = 9000000 ≈ 900k,爲什麼會這樣計算下面會解釋

2、採樣下載數據量和網絡情況

// p1.speed:下載速度 
// p1.costTime:下載耗時
addSpeedSample(p1.speed * p1.costTime, p1.speed)

通過下載器的回調中,我們可以對數據量和網絡情況進行採樣

/**
 * @data:Long 下載數據量 = 下載速度 * 下載耗時
 * @value:Long 下載數據量的速度
 */
fun addSpeedSample(data: Long, value: Long) {
    //這裏的數據量是下載累加的,這裏會對數據量做開根號處理
    //當採集到達:data = 9000000 ≈ 900k,開根號= 3000,這個時候窗口開始滑動
    val weight = Math.sqrt(data.toDouble()).toInt() 
    Log.i(TAG, "[addSpeedSample][採樣速度]下載數據量=$data, 權重=$weight, 速度=$value")
    mVideoSlidingPercentile.addSample(weight, value)
}

爲了更好的理解,我們模擬數據,通過日誌輸出看到更直觀的表現

1[addSpeedSample][採樣速度]下載數據量=1000000, 權重=100, 速度=50
2[addSpeedSample][採樣速度]下載數據量=2000000, 權重=200, 速度=60
3[addSpeedSample][採樣速度]下載數據量=3000000, 權重=300, 速度=70
4[addSpeedSample][採樣速度]下載數據量=4000000, 權重=400, 速度=80
5[addSpeedSample][採樣速度]下載數據量=5000000, 權重=500, 速度=90
6[addSpeedSample][採樣速度]下載數據量=3000000, 權重=300, 速度=70
7[addSpeedSample][採樣速度]下載數據量=2000000, 權重=200, 速度=60
8[addSpeedSample][採樣速度]下載數據量=1000000, 權重=100, 速度=50
  1. 首先會依次採樣,步驟1、2、3,此時權重總和600,此時並沒有超過我們預先設置的900
  2. 步驟4,此時權重總和1000,此時的數據量已經下載超過900k,滑動窗口被確定下來,此時窗口開始移動,超過900則會捨棄舊的點100,此時權重剛好900
  3. 步驟5,此時權重總和900+500=1400,超過900,開始移動窗口,捨棄舊點200,300,此時權重剛好900
  4. 步驟6,此時權重總和900+300=1200,超過900,開始移動窗口,如果捨棄舊點400,權重到達800,權重不夠900,所以不能捨棄,只能降維,將舊點400降維至300,讓權重保持在900
  5. 依次類推,讓權重始終保持在900內,舊點可以選擇捨棄或者降維來保持權重的穩定

3、獲取網絡情況

val avgSpeed = mVideoSlidingPercentile.getPercentile(0.5f)
Log.i(TAG, "[getAvgSpeed][獲取視頻下載速度]avgSpeed=$avgSpeed")

通過getPercentile獲取滑動窗口中的值,參數0.5f表示當前滑動窗口中的中間位置,在獲取滑動窗口值之前,會對滑動窗口的所有值進行有序排列,故獲取的是中間位置的值

源碼

從ExoPlayer中我們可以找到這樣的源碼,我們重點關注採集和獲取,分別是addSpeedSamplegetPercentile

/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.remo.mobile.smallvideo.sdk.videoDownload;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

import tv.athena.klog.api.KLog;

/**
 * Calculate any percentile over a sliding window of weighted values. A maximum weight is
 * configured. Once the total weight of the values reaches the maximum weight, the oldest value is
 * reduced in weight until it reaches zero and is removed. This maintains a constant total weight,
 * equal to the maximum allowed, at the steady state.
 * <p>
 * This class can be used for bandwidth estimation based on a sliding window of past transfer rate
 * observations. This is an alternative to sliding mean and exponential averaging which suffer from
 * susceptibility to outliers and slow adaptation to step functions.
 *
 * @see <a href="http://en.wikipedia.org/wiki/Moving_average">Wiki: Moving average</a>
 * @see <a href="http://en.wikipedia.org/wiki/Selection_algorithm">Wiki: Selection algorithm</a>
 */
public class SlidingPercentile {
    private static final String TAG = "SlidingPercentile";
    // Orderings.
    private static final Comparator<Sample> INDEX_COMPARATOR = (a, b) -> a.index - b.index;
    private static final Comparator<Sample> VALUE_COMPARATOR =
            (a, b) -> Float.compare(a.value, b.value);

    private static final int SORT_ORDER_NONE = -1;
    private static final int SORT_ORDER_BY_VALUE = 0;
    private static final int SORT_ORDER_BY_INDEX = 1;

    private static final int MAX_RECYCLED_SAMPLES = 5;

    private final int maxWeight;
    private final List<Sample> samples;

    private final Sample[] recycledSamples;

    private int currentSortOrder;
    private int nextSampleIndex;
    private int totalWeight;
    private int recycledSampleCount;

    /**
     * @param maxWeight The maximum weight.
     */
    public SlidingPercentile(int maxWeight) {
        this.maxWeight = maxWeight;
        recycledSamples = new Sample[MAX_RECYCLED_SAMPLES];
        // samples = new ArrayList<>();
        samples = Collections.synchronizedList(new ArrayList<>());
        currentSortOrder = SORT_ORDER_NONE;
    }

    /**
     * Resets the sliding percentile.
     */
    public void reset() {
        synchronized (samples) {
            samples.clear();
            currentSortOrder = SORT_ORDER_NONE;
            nextSampleIndex = 0;
            totalWeight = 0;
        }
    }

    /**
     * Adds a new weighted value.
     *
     * @param weight The weight of the new observation.
     * @param value  The value of the new observation.
     */
    public void addSample(int weight, long value) {
        ensureSortedByIndex();

        synchronized (samples) {
            Sample newSample = recycledSampleCount > 0 ? recycledSamples[--recycledSampleCount]
                    : new Sample();
            newSample.index = nextSampleIndex++;
            newSample.weight = weight;
            newSample.value = value;
            samples.add(newSample);
            totalWeight += weight;

            while (totalWeight > maxWeight) {
                int excessWeight = totalWeight - maxWeight;
                Sample oldestSample = samples.get(0);
                if (oldestSample.weight <= excessWeight) {
                    totalWeight -= oldestSample.weight;
                    samples.remove(0);
                    if (recycledSampleCount < MAX_RECYCLED_SAMPLES) {
                        recycledSamples[recycledSampleCount++] = oldestSample;
                    }
                } else {
                    oldestSample.weight -= excessWeight;
                    totalWeight -= excessWeight;
                }
            }
        }
    }

    /**
     * Computes a percentile by integration.
     *
     * @param percentile The desired percentile, expressed as a fraction in the range (0,1].
     * @return The requested percentile value or {@link Float#NaN} if no samples have been added.
     */
    public long getPercentile(float percentile) {
        ensureSortedByValue();
        float desiredWeight = percentile * totalWeight;
        int accumulatedWeight = 0;
        synchronized (samples) {
            for (int i = 0; i < samples.size(); i++) {
                Sample currentSample = samples.get(i);
                if (currentSample != null) {
                    accumulatedWeight += currentSample.weight;
                    if (accumulatedWeight >= desiredWeight) {
                        return currentSample.value;
                    }
                }
            }
            // Clamp to maximum value or NaN if no values.
            if (samples.isEmpty() || samples.get(samples.size() - 1) == null) {
                return 0;
            }
            return samples.get(samples.size() - 1).value;
        }
    }

    public long getLimitPercentile(float percentile) {
        float desiredWeight = percentile * totalWeight;
        if (desiredWeight < maxWeight * 0.2) {
            return 0;
        }
        return getPercentile(percentile);
    }

    /**
     * Sorts the samples by index.
     */
    private void ensureSortedByIndex() {
        synchronized (samples) {
            try {
                if (currentSortOrder != SORT_ORDER_BY_INDEX) {
                    Collections.sort(samples, INDEX_COMPARATOR);
                    currentSortOrder = SORT_ORDER_BY_INDEX;
                }
            } catch (Exception e) {
                KLog.e(TAG, e.toString());
            }
        }
    }

    /**
     * Sorts the samples by value.
     */
    private void ensureSortedByValue() {
        synchronized (samples) {
            try {
                if (currentSortOrder != SORT_ORDER_BY_VALUE) {
                    Collections.sort(samples, VALUE_COMPARATOR);
                    currentSortOrder = SORT_ORDER_BY_VALUE;
                }
            } catch (Exception e) {
                KLog.e(TAG, e.toString());
            }
        }
    }

    private static class Sample {
        public int index;
        public int weight;
        public long value;
    }

}

1、採集的樣本

private static class Sample {
    public int index;
    public int weight;
    public long value;
}

數據樣本的存儲

private final List<Sample> samples;

samples = Collections.synchronizedList(new ArrayList<>());

2、定義滑動窗口

/**
 * @param maxWeight The maximum weight.
 */
public SlidingPercentile(int maxWeight) {

    this.maxWeight = maxWeight;
    ......
}

3、數據採樣

數據的採樣就是滑動窗口的邏輯

public void addSample(int weight, long value) {
    ensureSortedByIndex();

    synchronized (samples) {
        // 1、採樣累加權重
        Sample newSample = recycledSampleCount > 0 ? recycledSamples[--recycledSampleCount]
                : new Sample();
        newSample.index = nextSampleIndex++;
        newSample.weight = weight;
        newSample.value = value;
        samples.add(newSample);
        totalWeight += weight;
        
        // 2、當權重超過設置的滑動窗口,則開始進入滑動階段
        while (totalWeight > maxWeight) {
            // 拿到當前要滑動的權重的差值
            int excessWeight = totalWeight - maxWeight;
            // 拿到舊點開始滑動
            Sample oldestSample = samples.get(0);
            if (oldestSample.weight <= excessWeight) {
                // 3、如果權重超過,則直接移除舊點
                totalWeight -= oldestSample.weight;
                samples.remove(0);
                ......
            } else {
                // 4、權重不夠時則對舊點進行降維
                oldestSample.weight -= excessWeight;
                totalWeight -= excessWeight;
            }
        }
    }
}

4、獲取預測值

public long getPercentile(float percentile) {
    // 1、先排序
    ensureSortedByValue(); 
    // 2、算出要取的權重值,假如是0.5,則是所有采樣點權重的一半
    float desiredWeight = percentile * totalWeight;
    int accumulatedWeight = 0;
    synchronized (samples) {
        for (int i = 0; i < samples.size(); i++) {
            // 3、遍歷所有采樣點,取權重剛好超過要取的權重的值
            Sample currentSample = samples.get(i);
            if (currentSample != null) {
                accumulatedWeight += currentSample.weight;
                if (accumulatedWeight >= desiredWeight) {
                    return currentSample.value;
                }
            }
        }
        // Clamp to maximum value or NaN if no values.
        if (samples.isEmpty() || samples.get(samples.size() - 1) == null) {
            return 0;
        }
        return samples.get(samples.size() - 1).value;
    }
}

5、性能優化

private static final int MAX_RECYCLED_SAMPLES = 5;

public SlidingPercentile(int maxWeight) {
    recycledSamples = new Sample[MAX_RECYCLED_SAMPLES];
    ......
}

public void addSample(int weight, long value) {
    ensureSortedByIndex();

    synchronized (samples) {
        // 1、詢問緩存裏面是否有存在採樣點,有就拿來用
        Sample newSample = recycledSampleCount > 0 ? recycledSamples[--recycledSampleCount]
                : new Sample();
        ......
        samples.add(newSample);

        while (totalWeight > maxWeight) {
            if (oldestSample.weight <= excessWeight) {
                 ......
                if (recycledSampleCount < MAX_RECYCLED_SAMPLES) {
                    // 2、由於是要捨棄的舊點,棄之可惜,緩存起來備用
                    recycledSamples[recycledSampleCount++] = oldestSample;
                }
            }
        }
    }
}

由於整個過程是一直採樣,會頻繁創建採樣點的對象,所以這裏做了個簡單的緩存,將採樣點保存在數組中,進行復用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章