浅浅地优化下视频流播放体验(1),面试问题汇总及答案

额外布局空间最终会在填充表项时被使用:

public class LinearLayoutManager{
// 向列表中填充表项
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {

// 计算剩余空间=现有空间+额外空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 循环填充表项,直到没有剩余空间
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);

// 在列表剩余空间中扣除刚填充表项所消耗的空间
if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null || !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
remainingSpace -= layoutChunkResult.mConsumed;
}

}

}
}

有限的解码资源

用上面的代码实现视频流,当不停地往下翻看视频时,视频会加载不出来,ExoPlayer会抛如下异常:

com.google.android.exoplayer2.ExoPlaybackException: MediaCodecAudioRenderer error, index=1, format=Format(null, null, null, audio/raw, null, -1, null, [-1, -1, -1.0], [2, 48000]), format_supported=YES

com.google.android.exoplayer2.audio.AudioSink$InitializationException: AudioTrack init failed 0 Config(48000, 12, 65600) … 13 more Caused by: java.lang.UnsupportedOperationException: Cannot create AudioTrack

音频解码错误,源于无法创建音轨。

手机的音轨资源是有限的,如果每个视频都占用一个音轨并且不释放的话,就会导致上述问题。

可以使用下面这个命令查看当前手机音轨占用情况:

adb shell dumpsys media.audio_flinger

打印出来的日志长这个样子:

3 Tracks of which 1 are active

Type Id Active Client Session Port Id S Flags Format Chn mask SRate ST Usg CT G db L dB R dB VS dB Server FrmCnt FrmRdy F Underruns Flushed Latency

25136 no 15781 82753 25105 P 0x400 00000001 00000003 44100 3 1 0 -inf 0 0 0 0000485A 11025 11025 A 0 0 293.91 k

25137 yes 15781 82761 25106 A 0x000 00000001 00000003 44100 3 1 0 -26 0 0 0 0001102E 11025 11025 A 0 0 307.29 t

25138 no 15781 82737 25107 I 0x000 00000001 00000003 44100 3 1 0 -inf 0 0 0 00000000 11025 6144 I 0 0 new

该日志表示已经创建3个音轨,其中一个是活跃的。

每一个新的 ExopPlayer 实例就会重新申请解码资源,而不是复用已有资源。

上述代码中,每次构建新的VideoFragment,都会新建ExoVideoPlayer实例,而其内部对应一个ExoPlayer实例。气人的是 ViewPager2 中 Fragment 的实例并不会被复用,而是每次新建,这就导致滑动过程中,ExoPlayer 实例被不断地新建,最终导致音轨资源被耗尽。

那就得及时释放播放器持有的资源:

class VideoFragment(private val url: String) : Fragment() {
private val player by lazy { PlayerManager.getVideoPlayer() }

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val itemView = inflater.inflate(R.layout.playerview, container, false) as StyledPlayerView
return itemView.also { it.player = player }
}

override fun onResume() {
super.onResume()
player.url = url
player.load()
player.play()
}

override fun onDestroy() {
super.onDestroy()
player.release()
}
}

在 Fragment 生命周期方法 onDestroy() 中调用 release() 释放播放器资源。

这样不管往下翻多少视频,都不会报异常了。

播放器生命周期控制

我本以为当视频流向下滑动时,播放器的构建及回收时机如下图所示:

即当表项移入视窗时对应的 Fragment 被构建(播放器实例也被构建),当表项移出视窗时对应的 Fragment 被销毁(播放器资源被释放)。

但 ViewPager2 内部机制不是这样的,它会缓存比预想更多的 Fragment:

上图中索引为4的红色表示当前正在播放的视频,两块蓝色的表示因预加载而保留在内存中的视图(预加载数=1)。

虽然视频1和2也移出了屏幕,但它们依然存在于内存中(不会回调onDestroy()),这是 RecyclerView 的 cached View 缓存机制,本意是缓存移出屏幕的视图以便回滚时快速展示。ViewPager 的实现基于 RecyclerView,复用了这套机制。

当手向上滑动,视频6进入视窗开始预加载(onResume()),视频1被回收(onDestroy())。

ViewPager2 持有比预期更多的 Fragment 除了对内存造成压力之外,还会占用更多解码资源,当有多个视频流叠加时依然会耗尽解码资源(比如从推荐流点击作者头像进入作者视频流)。

ViewPager2 是有预加载(offscreenPageLimit),RecyclerView 中 cached View 机制意义已经不大了。但 ViewPager2 是 final 类型了,而且也并未公开其内部的 RecyclerView 实例。

所以只能将 ViewPager2 的源码都原样拷贝出来:

把源码中的这些类拷贝出来后,所有源码中的报错都可以消除。

修改其中的 FragmentStateAdapter:

public abstract class FragmentStateAdapter extends RecyclerView.Adapter implements StatefulAdapter {
// 持有所有活跃的 Fragment 实例
public final LongSparseArray mFragments = new LongSparseArray<>();
// 将下面的方法改为 public
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
if (mFragmentMaxLifecycleEnforcer != null) throw new IllegalArgumentException();
mFragmentMaxLifecycleEnforcer = new FragmentMaxLifecycleEnforcer();
mFragmentMaxLifecycleEnforcer.register(recyclerView);
}

// 将下面的方法改为 public
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
mFragmentMaxLifecycleEnforcer.unregister(recyclerView);
mFragmentMaxLifecycleEnforcer = null;
}

// 新增方法:获取指定 Fragment
public Fragment getFragment(long key) {
return mFragments.get(key);
}

}

修改onAttachedToRecyclerView()onDetachedFromRecyclerView()为 public,让子类可以重写该方法。并且新增方法,使得子类中可以方便地获取到指定的 Fragment 实例。

然后改写视频流适配器:

class VideoPageAdapter(
private val fragmentManager: FragmentManager,
lifecycle: Lifecycle,
private val urls: List
) : FragmentStateAdapter(fragmentManager, lifecycle) {
override fun onViewAttachedToWindow(holder: FragmentViewHolder) {
super.onViewAttachedToWindow(holder)
// 获取刚进入视窗的 Fragment 实例
val attachFragment = getFragment(getItemId(holder.absoluteAdapterPosition))
(attachFragment as? VideoFragment)?.load()
}

override fun onViewDetachedFromWindow(holder: FragmentViewHolder) {
// 获取刚移出视窗的 Fragment 实例
val detachFragment = getFragment(getItemId(holder.absoluteAdapterPosition))
(detachFragment as? VideoFragment)?.release()
}
}

重写列表中视图依附窗口/脱离窗口的回调,在其中获取对应的 Fragment 实例,并触发播放/回收资源。

现在播放器的生命周期不再基于 Fragment 的生命周期,改为基于列表滚动时视图的生命周期,而视图生命周期是相对于当前屏幕对称的。(就像本小节的第一张图所示)

这样一来,内存中播放器的数量就可以进一步减少,并且可以更精准地控制预加载/释放视频资源。

播放器数量控制

上述代码虽然可以精准控制播放器的生命周期,但依然无法避免不停地销毁/重建播放器造成的内存抖动。

有没有什么办法将整个App中播放器的实例控制在一个固定的数值之下?

有!播放器池!

在使用播放器池之前,还有一个障碍,回看一下之前对播放器接口的抽象:

interface VideoPlayer : View {
// 视频url
var url: URL?
// 视频控制器,用于上层绘制进度条
var playControl: MediaPlayerControl
// 视频状态回调
var listener: IVideoStateListener?
// 播放视频
fun play()
// 加载视频
fun load()
// 停止视频
fun stop()
// 释放资源
fun relese()
}

这个接口设计将播放器和视图混为一体,即从接口层面规定一个播放器实例对应一个视图,且生命周期同步。当视频流滚动时,播放器会随着视图被不断地新建。

如果在这个接口基础上使用播放器池,则会造成内存泄漏。因为播放器池是一个单例,它的生命周期要长于视图,但由于接口设计的不合理,播放器就是视图,视图就是播放器,存在着交叉持有关系,导致内存泄漏(最终导致解码资源耗尽)。

播放器和视图分离

从播放流畅度、内存占用、CPU 使用率方面考虑,ExoPlayer 官方建议将单个播放器实例复用于多个播放视图。因为每新建一个播放器实例,就会重新申请解码资源,这是一个耗时/耗资源的过程。

为了实现播放器实例的复用,不得不重构上层接口,将原先的接口拆分成职责更单一的多个接口:

  1. 播放器视图接口

// 播放器视图接口(向上层屏蔽不同播放器视图实现的细节)
interface VideoPlayerView : View {
// 视频重力方位,用于指定从哪个方位裁剪视频
var gravity: Int
// 将播放视图和播放器解绑
fun clearPlayer()
// 将视频宽高传递给视图
fun setResizeMode(width: Int, height: Int)
}

  1. 播放器接口

// 播放器接口(向上层屏蔽不同播放器实现的细节)
interface VideoPlayer {
// 资源地址
var url: URL?
// 视频控制器,用于上层绘制进度条
var playControl: MediaPlayerControl
// 状态监听器
var listener: IVideoStateListener?
// 开始播放
fun play()
// 加载视频
fun load()
// 停止播放
fun stop()
// 销毁资源
fun release()
// 将播放器和视图绑定
fun attachPlayerView(view: VideoPlayerView)
}

视图归视图,播放器归播放器。前者的生命周期由 ViewPager 控制,后者的生命周期通过一个播放器池来管理:

  1. 播放器池接口

// 播放器池
interface VideoPlayerPool {
// 获取播放器实例
fun getVideoPlayer(index: Int): VideoPlayer
// 清空池
fun clear()
}

上层通过VideoPlayerPool接口获取播放器实例。

播放器池接口实现如下:

class VideoPlayerPool : VideoPlayerPool {
// 池大小
val POOL_SIZE = 2 * manager.config.movie.prefetchCount + 1 + 1
// 池设计为循环数组
private val pool: Array<VideoPlayer?> = arrayOfNulls(POOL_SIZE)

// 构建新的播放器实例
private fun createVideoPlayer(context: Context): VideoPlayer {
return ExoVideoPlayer(context)
}

// 从池中获取播放器实例
override fun getVideoPlayer(index: Int): VideoPlayer {
val realIndex = index.mod(POOL_SIZE)
return pool[realIndex] ?: createVideoPlayer(ContextUtil.applicationContext).also {
pool[realIndex] = it
}
}

// 释放池中所有播放器资源
override fun clear() {
(pool.indices).forEach { index ->
pool[index]?.release()
pool[index] = null
}
}
}

播放器池长度

播放器池通过一个固定长度的循环数组实现播放器复用:

上图表示当前正在播放视频流中索引为2的视频。此时播放器池中有四个播放器实例正好用于视频流中的前四个视图。

视频流索引和播放器池索引的对应关系通过取余实现,即播放器池索引 = 视频流索引对池长度取余。当列表向上滚动,即索引为4的视图进入视窗,它加载视频会复用到池中索引为0的播放器实例。通过取余运算实现循环数组复用机制。

理论上池大小应该等于视窗大小,但使用 ViewPager2 实现视频流有一个特殊情况会导致播放器实例复用失败。还是以上图的场景举例:

此图表示播放器池大小为3,所以视频3会复用索引为0的播放器实例。

当用手拖住视频2向上滚动一点点(即视频3从屏幕底部露出一点点)后松手,此时不会产生翻页而是停留在视频2,但滚动使得视频4进入了视窗触发了加载,它会复用到索引为1的播放器实例,导致视频1的内容被抹去,但视频1又在视窗内,所以回滚到视频1时并不会触发它再次加载,最终导致黑屏。

为了避免这种情况,播放器池大小要大于视窗大小。

播放器池数量

多个视频流共存的场景也很常见,比如从推荐流跳转到剧集流。随着业务的迭代,共存的视频流可以无限叠加。

让共存的视频流共享同一个播放器池应该怎么实现?

“在发生视频流跳转时,释放当前池中所有播放器资源以便在新流中复用。”

这个方案有一个缺点,当返回旧视频流时,当前视频需重新加载,即会先黑屏一下再开始播放。

可不可以保留当前正播放的实例,释放其余播放器资源?

可是可以,但增加了播放器和视图映射关系的复杂度,即新视频流中不能简单地按索引值取余的方式拿到复用的播放器实例,得跳过保留的播放器。或者将保留播放器挪到池尾,并将池长度-1。这样的话,每有一个新的视频流,播放器池长度就减少1,这限制了共存视频流的数量。

最终采用的方案是:

每个视频流分配一个播放器池。在跳转到新视频流时,释放旧池中播放器资源(除了当前视频和下一个视频,因为大概率返回时会往下滑)。在返回时,清空新建的播放器池。

滑动体验

视频流滑动过程中,通过复用播放器实例避免了内存抖动、减少了 GC 次数、加速了视频解码速度(解码资源复用),一定程度上提升了滑动的流畅度。除此之外,松手之后的动画也会影响滑动的手感。

若使用 ViewPager2 默认的滑动实现,在松手后,视频也会匀速地滚动到下一页。在参考了各大视频平台App之后,发现在松手之后,会有一个加速滑动,到终点之前又逐渐减速的过程。

ViewPager2 松手后自动滑至下一页是通过自定义的 SnapHelper 实现的:

class PagerSnapHelperImpl extends PagerSnapHelper {
PagerSnapHelperImpl() {
}

private float MILLISECONDS_PER_INCH = 100f;
private int MAX_SCROLL_ON_FLING_DURATION = 120;
// 新增减速插值器
private Interpolator interpolator =new DecelerateInterpolator(2.1f);

// 重写 createScroller 以使用自定义的插值器
@Nullable
@Override
protected RecyclerView.SmoothScroller createScroller( RecyclerView.LayoutManager layoutManager) {
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return null;
}
return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
// 使用自定义插值器
action.update(dx, dy, time, interpolator);
}
}

@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}

@Override
protected int calculateTimeForScrolling(int dx) {
return Math.min(MAX_SCROLL_ON_FLING_DURATION, super.calculateTimeForScrolling(dx));
}
};
}
}

将 ViewPager2 源码拷贝出来,修改其中的PagerSnapHelperImpl的实现,重写createScroller()方法,并将插值器替换为DecelerateInterpolator。该插值器的参数越大,松手后的滑动速度就越快,而到达终点的速度就会越慢。还可以通过调整MAX_SCROLL_ON_FLING_DURATION来改变整个动画的持续时间。

无缝播放体验

除了预加载、复用播放器实例、滑动动画插值器之外,播放视频的时机也是影响视频流体验的因素之一。

如果在 Fragment.onResume() 中才开始播放视频,就意味着下一个视频要等到滚动动画完成后才开始播放视频,视觉上的体验就是下一个视频的第一帧会卡一下再播。

更好的方案是在松手时就暂停上一个视频并播放下一个视频:

viewPager2.registerOnPageChangeCallback(object :OnPageChangeCallback(){
// 上一次滚动偏移量
private var lastOffset = 0f
// 是否向下滚动
private var isScrollDown = false
override fun onPageScrollStateChanged(state: Int) {
super.onPageScrollStateChanged(state)
// 当松手后
if(state == SCROLL_STATE_SETTLING){
// 获取下一个播放视频的索引
val playIndex = if(isScrollDown) viewPager.currentItem +1 else viewPager.currentItem - 1;
// 播放下一个视频
videoPageAdapter?.getFragment(playIndex)?.play()
// 暂停当前视频
videoPageAdapter?.getFragment(viewPager.currentItem)?.pause()
}
}

override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
// 如果偏移量在变大意味着向下滚动
isScrollDown = positionOffset > lastOffset
lastOffset = positionOffset
}
})

通过onPageScrollStateChanged()中的SCROLL_STATE_SETTLING状态捕捉松手时机,通过onPageScrolled()中的偏移量判定滚动方向,以此确定该暂停哪个视频,该播放哪个视频。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数HarmonyOS鸿蒙开发工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年HarmonyOS鸿蒙开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上HarmonyOS鸿蒙开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新

如果你觉得这些内容对你有帮助,可以添加VX:vip204888 (备注鸿蒙获取)
img

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

且极易碰到天花板技术停滞不前!**

因此收集整理了一份《2024年HarmonyOS鸿蒙开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-UMECeZlF-1712780388292)]
[外链图片转存中…(img-B82Od69r-1712780388292)]
[外链图片转存中…(img-xkhWgHZk-1712780388292)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上HarmonyOS鸿蒙开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新

如果你觉得这些内容对你有帮助,可以添加VX:vip204888 (备注鸿蒙获取)
[外链图片转存中…(img-JwOuaXPX-1712780388293)]

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 15
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值