浅浅地优化下视频流播放体验(1),2024年最新零基础学习HarmonyOS鸿蒙编程

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

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

img
img
htt

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

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

如果你需要这些资料,可以添加V获取:vip204888 (备注鸿蒙)
img

正文

播放器封装

上述这些操作对于不同播放器有不同的实现,定义一层接口屏蔽这些差异:

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

该接口为上层提供了操纵播放器的统一接口,这样做的好处是向上层屏蔽了播放器实现的细节,为以后更换播放器提供了便利。

其中IVideoStateListener是播放状态的抽象:

interface IVideoStateListener {
fun onStateChange(state: State)
}

//视频状态
sealed interface State {
//第一帧被渲染
object FirstFrameRendered : State
//缓冲结束,随时可播放。
object Ready : State
//播放出错
class Error(val exception: Exception) : State
//播放中
object Playing : State
//播放手动停止
object Stop : State
//播放结束
object End : State
//缓冲中
object Buffering : State
}

ExoPlayer 对于上述接口的实现如下,它作为一个单独的库 player-exo 存在:

class ExoVideoPlayer(context: Context) : FrameLayout(context), VideoPlayer {
private var playerView: StyledPlayerView? = null
private val skipStates = listOf(Player.STATE_BUFFERING, Player.STATE_ENDED)
private val exoListener: Listener by lazy {
object : Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_ENDED -> listener?.onStateChange(State.End)
Player.STATE_BUFFERING -> listener?.onStateChange(State.Buffering)
Player.STATE_IDLE -> resumePosition = _player.currentPosition
Player.STATE_READY -> listener?.onStateChange(State.Ready)
}
}

override fun onRenderedFirstFrame() {
listener?.onStateChange(State.FirstFrameRendered)
}

override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
listener?.onStateChange(State.Playing)
} else {
if (_player.playbackState !in skipStates && _player.playerError != null) {
listener?.onStateChange(State.Stop)
}
}
}

override fun onPlayerError(error: PlaybackException) {
listener?.onStateChange(State.Error(error))
}
}
}

private var _player = ExoPlayer.Builder( context, DefaultRenderersFactory(context).apply { setEnableDecoderFallback(true) })
.build().also { player ->player.addListener(listener}
override var listener: IVideoStateListener? = null
private var cache: Cache? = null
private var mediaItem: MediaItem? = null

private fun buildMediaSource(context: Context): MediaSource {
if (mediaItem == null) mediaItem = MediaItem.fromUri(url.toString())
val cacheFile = context.cacheDir.resolve(CACHE_FOLDER_NAME + File.separator + abs(mediaItem.hashCode()))
cache = SimpleCache(
cacheFile,
LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTE),
StandaloneDatabaseProvider(context)
)
return run {
val cacheDataSourceFactory = CacheDataSource.Factory().setCache(cache)
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context))
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
if (url.toString().endsWith(“m3u8”)) {
HlsMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem!!) //m3u8
} else {
ProgressiveMediaSource.Factory(cacheDataSourceFactory).createMediaSource(mediaItem!!)
}
}
}

init {
playerView = LayoutInflater.from(context).inflate(R.layout.playerview, null) as StyledPlayerView
this.addView(playerView)
playerView?.player = _player
}

override var url: URL? = null
get() = field
set(value) {
field = value
mediaItem = MediaItem.fromUri(value.toString())
}

override var playControl: MediaController.MediaPlayerControl = PlayerControl(_player)

override fun play() {
if (_player.isPlaying) return
if (_player.playbackState == Player.STATE_ENDED) {
_player.seekTo(0)
}
_player.playWhenReady = true
}

override fun load() {
_player.takeIf { !it.isLoading }?.apply {
setMediaSource(buildMediaSource(context))
prepare()
}
}

override fun stop() {
_player.stop()
}

override fun release() {
_player.release()
}
}

然后在一个单独库 player-pseudo 中定义一个构建VideoPlayer的抽象行为:

package com.demo.player

fun createVideoPlayer(context: Context): VideoPlayer = throw NotImplementedError()

在库 player-exo 中同样的包名下,定义一个同样的文件,并给出基于 ExoPlayer 的实现:

package com.demo.player

fun createVideoPlayer(context: Context): VideoPlayer = ExoVideoPlayer(context)

这些库的上层有一个管理器库 player-manager,它作为业务层使用播放器的入口:

package com.demo.player

fun createVideoPlayer(context: Context): VideoPlayer = ExoVideoPlayer(context)

player-manager 库需要依赖 player-pseudo:

object PlayerManager {
fun getVideoPlayer(context: Context) = createVideoPlayer(context)
}

使用 compileOnly 是为了在编译时不报错并且不将 player-pseudo 源码打入包。在打包时 player-manager 真正应该依赖的是 player-exo。所以最上层的 app 依赖关系应该如下:

implementation project(‘player-manager’)
implementation project(‘player-exo’)

这样就通过 gradle 实现了依赖倒置,即上层(player-manager)不依赖于下层(player-exo)具体的实现,上层和下层都依赖于中间的抽象层(player-pseudo)

视频流

上一小节解决了播放单个视频的问题,这一节介绍如何构建视频流。

视频流就是像抖音那样的纵向列表,每一个表项都是一个全屏视频。

我使用 ViewPager2 + Fragment 实现。

下面是 Fragment 的实现:

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()
}
}

然后在 FragmentStateAdapter 中构建 Frament 实例:

class VideoPageAdapter(
private val fragmentManager: FragmentManager,
lifecycle: Lifecycle,
private val urls: List
) : FragmentStateAdapter(fragmentManager, lifecycle) {

override fun getItemCount(): Int {
return urls.size
}

override fun createFragment(position: Int): Fragment {
return VideoFragment(urls[position])
}
}

最后为业务界面的 ViewPager2 设置适配器:

class VideoActivity : AppCompatActivity() {
private lateinit var viewPager: ViewPager2
private var videoPageAdapter: VideoPageAdapter? = null
private val urls = listOf {“xxx”,“xxx”}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.video_player_activity)

videoPageAdapter = VideoPageAdapter(supportFragmentManager, lifecycle, urls)
viewPager = findViewById(R.id.vp)
viewPager.apply {
orientation = ViewPager2.ORIENTATION_VERTICAL
offscreenPageLimit = 1 // 预加载一个视频
adapter = videoPageAdapter
}
}
}

一个简单的视频流就完成了。

预加载及其原理

上述代码使用了ViewPager2.offscreenPageLimit = 1实现预加载一个视频。该参数的意思是 “将视窗上下都扩大1” 。默认的视窗大小是1,如下图所示:

上图表示 ViewPager2 正在展示索引为2的视频,其视窗大小为1(视窗占满屏幕),只有当手向上滑动视频3从视窗的底部出现时,才会触发视频3的加载。

若 offscreenPageLimit = 1,则表示视窗在当前屏幕的上下拓宽了一格:

图中的红色+蓝色区域就是视窗大小,只有当列表项进入视窗后才会发出其加载。当前屏幕停留在视频2,当手向上滑动视频4会进入视窗底部,所以当你滑动到视频3时,视频4已经被预加载了。

从源码上,ViewPager2 是基于 RecyclerView 实现的,在内部它自定义了一个 LinearLayoutManager:

// ViewPager2 内部自定义的 LayoutManager
private class LinearLayoutManagerImpl extends LinearLayoutManager {
// 在布局表项时,计算额外布局空间
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state, NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
// 如果 OffscreenPageLimit 等于 OFFSCREEN_PAGE_LIMIT_DEFAULT,则不进行预加载
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
// 进行预加载表现为“额外布局 OffscreenPageLimit 个 page“
final int offscreenSpace = getPageSize() * pageLimit;
extraLayoutSpace[0] = offscreenSpace;
extraLayoutSpace[1] = offscreenSpace;
}
}

ViewPager2 重写了calculateExtraLayoutSpace()方法,它用于计算在滚动时是否需要预留额外空间以布局更多表项,若需要则将额外空间赋值给extraLayoutSpace,它是一个数组,第一个元素表示额外的宽,第二元素表示额外的高。当设置了 offscreenPageLimit 后,ViewPager2 申请了额外的宽和高。

额外的宽高会被记录在LinearLayoutManager.mLayoutState.mLayoutState中:

// androidx.recyclerview.widget.LinearLayoutManager
private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 计算额外布局空间
calculateExtraLayoutSpace(state, mReusableIntPair);
int extraForStart = Math.max(0, mReusableIntPair[0]);
int extraForEnd = Math.max(0, mReusableIntPair[1]);
// 存储额外布局空间
mLayoutState.mExtraFillSpace = layoutToEnd ? extraForEnd : extraForStart;

}

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

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. 播放器接口

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip204888 (备注鸿蒙)
img

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

叉持有关系,导致内存泄漏(最终导致解码资源耗尽)。

播放器和视图分离

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

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

  1. 播放器视图接口

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

  1. 播放器接口

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip204888 (备注鸿蒙)
[外链图片转存中…(img-Dadok996-1713413946829)]

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

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值