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.
 * 

* 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 Wiki: Moving average * @see Wiki: Selection algorithm */ 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;
                }
            }
        }
    }
}

由于整个过程是一直采样,会频繁创建采样点的对象,所以这里做了个简单的缓存,将采样点保存在数组中,进行复用

你可能感兴趣的:(Android进阶——ExoPlayer源码分析之宽带预测策略的算法详解)