导读:作为短视频利用最重要的组件,播放器表演了至关重要的角色;对于短视频 App 来说,播放相干的性能间接影响到外围用户体验,围绕播放器做一系列的优化是性价比极高的技术投资。
全文 3680 字,预计浏览工夫 12 分钟。
一、背景介绍
作为短视频利用最重要的组件,播放器表演了至关重要的角色;对于短视频 App 来说,播放相干的性能间接影响到外围用户体验,围绕播放器做一系列的优化是性价比极高的技术投资。通过继续的重构和优化,难看视频 Android 端的视频播放体验根本达到秒开成果,霎时起播,滑动晦涩度也明显提高。咱们先来看看重构前后的比照。
重构前:很容易察看视频起播前有显著的进展,中低端机分外显著。
重构后:简直感触不到播放切换的进展感。
本次就和大家分享一下难看视频 Android 端重构中的想法和心得,次要侧重于架构、性能优化方面。
二、难看视频历史回顾——单播放器
难看视频 2016 年起源于 hao123 的图文信息流,历经 4 年,确立了信息流模式的卡尺播放模式。在 2020 年的 Q3,难看视频开启了沉迷式全屏播放我的项目,最终推全。
因为历史起因,难看视频始终是单播放器架构:全局一个播放器,飘在所有 View 的最上层,跟随着左右滑动的 ViewPager
和高低翻页的RecyclerView
(竖划翻页配合应用了 PagerSnapHelper)同时挪动。
mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {mVideoView.moveVideoViewByX(positionOffsetPixels);
}
}
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {mVideoView.moveVideoViewByY(dy);
}
});
老架构的外围特点是,播放器有极高的生命周期和作用域(和以后 Activity 统一),且低作用域和生命周期的组件又间接持有 Context 管制和操作播放器。播放器全局单例尽管逻辑上简略,但实现上并不简洁,次要存在几个显著问题
1、业务耦合重大,开发效率低
- 播放器和业务代码耦合重大,多个外围类代码 1 万 + 行,保护老本高,对新人极其不敌对。播放器在初始化时就有 221 个 View,各个 View 之间的暗藏和显示逻辑简单,函数括号嵌套档次十分深,保护老本极高。Feed 列表只承载视频封面图,导致广告 / 直播等第三方业务既要负责 holder 的展现,又要独立创立高层级的播放器进行管制,代码复杂度极高。
- 播放器状态管制简单错乱,从 Activity、Fragment、ViewPager、RecyclerView、RecyclerViewAdapter、RecyclerViewHolder、每个 View 都能间接管制全局的单例播放器,生命周期难以追踪,播放相干的 bug 和用户反馈定位十分困难。
2、性能问题尾大不掉
- 因为播放器是飘在所有 View 的最上层,导致某些业务的 View 如果须要在最顶层,只能放在播放器外部再从新实现一遍。
RecyclerViewHolder
中的某些 View,既要在 holder 中又要在播放器的 View 中,再加上历史的古老代码,线上大量呈现播放器 View 初始化时的 ANR 和卡顿
播放器自身的复杂度又使得性能的优化难度和危险极大,再加上历史起因,这就导致了老架构 feed 滑动性能很差,滑动卡顿在中端机上非常显著,和竞品差距微小,以火焰图为例:
- Feed 列表滑动须要同步播放器进行卡尺滑动(包含播放器复位等),导致启播速度人为劣化。
- 低级别组件须要持有 Activity 级别的句柄,非常容易产生内存透露。
- 无奈间接获取 Activity 句柄的业务,大量通过 EventBus 散发音讯和管制逻辑,导致播放管制凌乱(EventBus 事件凌乱和组件生命周期事件抵触等)。EventBus 不仅加剧了内存透露的危险,还导致一些列的性能问题。
三、难看视频重构我的项目——多播放器
不破不立
咱们最终决定围绕播放器来对现有代码进行重构,将全局单例的播放器下沉到每个 holder 中,便于业务隔离和灵便调用播放器。在进步架构合理性从而晋升团队开发效率的同时,顺带解决局部性能问题:立项之时,仅仅架构优化可能不足以压服众人,但性能的优化带来的根底体验的优化,不容小觑。可能对于客户端来说,往往是带有性能优化的重构,才是完满的重构。
新架构:多播放器实例,每个 holder 独有播放器,播放器追随本人的 holder 滑动;播放器和业务解耦。
新架构的显著特点,就是升高了播放器作用域和生命周期:
- 在 holder 内实现播放器状态自洽治理,直播 / 广告等业务仅在 holder 就能够实现本身业务(包含播放管制等),缩小无用逻辑,升高代码耦合。
- 通过 LifecycleLite 散发播放相干事件,升高对 EventBus 的依赖,升高组件间耦合和内存透露危险。
- 利用自定义 PageSnapHelper 等组件,集中优化 Feed 列表启播 / 预加载等外围播放体验。
”多快好省“,能够说新架构只实现了前三个,但对于客户端来说,刻意”节俭内存“不见得是好主见。一个播放器大概占用 10M 的虚拟内存,大部分状况下 App 同时存在 2 - 3 个播放器实例,以 20-30M 的”空间“换取”工夫“(开发节俭的工夫 + 性能晋升的工夫),看起来就是大赚小亏的交易。
不论是从火焰图,还是从线上统计的掉帧率和业务卡顿、ANR 来看,新架构显著具备更优良的滑动性能和体验。而且即便目前仍然耗时的局部,也比拟容易修复和缓解。
重构前后的掉帧率比照:
对于起播工夫的优化
视频起播工夫,对于短视频 App 来说至关重要,如果用户要期待很久的缓冲能力看到视频开始播放,来到 App 的概率就会减少。在老架构下,单播放器实例会让针对起播工夫的专项优化难以实现,而新架构则为此优化提供了架构层面的反对。
1、对于播放器创立的机会
依据 RecyclerView 的机制,以后视频在播放时,下一个视频的 holder 会提前调用 onBindViewHolder
筹备页面和数据,所以能够在 RecyclerViewHolder
的onBind
时就初始化下一个待播放视频的播放器。
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {if (holder instanceof ImmersiveBaseHolder) {((ImmersiveBaseHolder) holder).onBind(getData(position), position);
holder.createPlayer();}
}
2、对于播放器开始播放 (start) 的机会
个别状况下,咱们会在 RecyclerView
的onScrollStateChanged
中判断列表滑动的状态,当 RecyclerView
滑动进行时再起播,并完结上一个视频的播放。
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {if (newState == SCROLL_STATE_SETTLING) {currentHolder.player.prepareAysnc();
lastHolder.player.stopAndRelease();}
}
});
这种形式诚然可行,但咱们能不能把播放器播放的机会再提前呢?
当咱们将手指放在屏幕时,View 随着手指滑动而高低滑动;当咱们松开手指时,PagerSnapHelper
会计算要跳转的视频,并依据速度和残余的滑动间隔计算工夫,通过 SmoothScroller
做惯性滚动动画——咱们思考下,如果在松开手指的一刻,换句话说,当咱们明确晓得了下一个待播放的视频时,就连忙播放它,会有什么成果?
简直秒播
从播放器调用 prepareAsync 到首帧渲染 (onInfo
的MEDIA_INFO_VIDEO_RENDERING_START
回调),大略须要 300-500ms,而从手指开屏幕到滑动完结,也靠近 200-300ms。一般来说,起播速度在 200ms 左右用户简直能够认为是”秒开“,所以提前起播对用户体验的晋升微小。
// PagerSnapHelper.java
@Override
protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {int nextPosition = state.getTargetScrollPosition();
adapter.getHolder(nextPosition).player.start();
adapter.getHolder(currentPosition).player.stopAndRelease();}
}
}
// 重点在 onTargetFound 此时曾经胜利定位被抉择的 holder
甚至在性能个别的机器上,能够思考更深层次的优化,在 onBindViewHolder
中创立播放器后,立刻 prepare
播放器,但不调用start
。此类优化须要对播放器的生命周期把握极其纯熟,处理不当很容易导致多个视频同时播放或者其余的暗藏 bug,须要分外小心。
3、更早的起播
有同学可能会问,为什么不在 holder 刚呈现在屏幕中的时候就起播呢?比如说在 holder 的 attachToWindow
中
这样会导致一个问题,因为起播过早,当屏幕停下来之后,视频曾经播放了 1 -2s,对用户来说体验会很奇怪,永远看不到残缺的短视频,体验非但没有变好,反而更差。
但这个思路并不是毫无用处,他实用于一种非凡格局的视频流:对于 feed 中呈现有直播流的状况。提前起播直播流(假如是 flv 或者 rtmp),但不播放直播的声音,等到滑动完结后再开始播放直播的声音,成果就很赞。直播的特点在于,用户不须要从第一帧看起,而且直播的起播往往比短视频要慢,提前起播对于直播来说是非常完满的解决方案,这个思路也是很多 App 的实现形式。
对于新架构的整体收益
从开发效率上来说,广泛的主观反映,进步了起码 20% 的开发效率,代码更加好找,”历史包袱“也少了很多。
从技术指标上来看,用户感知的起播工夫大幅提高了 150ms,网络不差的状况下根本能做到视频秒开;而且后续的优化也更简略,咱们也在继续一直的优化每个细节。
从业务指标上看,留存率,人均播放视频数,应用时⻓等指标均有不同水平的上涨,商业化收益也有所上涨,业务收益非常显著。
四、浅谈播放器预加载
所谓”预加载“,指的是提前下载好肯定长度的视频,当要播放该视频的时候,播放器只须要下载一小部分, 甚至能够间接立刻起播。
1、对于预加载的文件大小问题
加载太少,齐全失去了秒开的成果;加载太多,对带宽来说又是一种节约(当然,如果用户非常沉闷,滑动志愿强,能够预加载好残缺的下一个视频),一般来说,300K-500K 是一个常见的抉择。
$pip install qtfaststart
$qtfaststart -l 已经的你.mp4
ftyp (32 bytes)
moov (6891 bytes)
free (8 bytes)
mdat (3244183 bytes)
对于一般长度的短视频来说,300K 能够蕴含几帧的数据,如上图所示,文件头占了不到 100K,咱们再看看帧的状况。
$ffprobe 已经的你.mp4 -show_frames | grep -E 'pict_type|coded_picture_number|pkt_size'
pkt_size=28604
pict_type=I
coded_picture_number=0
pkt_size=145
pkt_size=479
pkt_size=568
pict_type=B
coded_picture_number=3
pkt_size=476
pkt_size=531
pkt_size=1224
pict_type=B
coded_picture_number=2
pkt_size=703
视频的第一帧是关键帧 I 帧,大概占了 30K,B 和 P 帧体积绝对较小,所以不难预计 300K 能够渲染不少帧数,根本满足咱们的需要;如果视频较长或者码率较大,预加载长度该当适度减少,最佳计划是后端转码端提前计算好数据,端上依据这个倡议值来加载对应的大小。
大多数播放器为了播放晦涩度和音视频同步的须要,本地都会有一个缓冲 buffer,有的逻辑是依照帧数设置,比如说 20 帧,也有的是依据工夫来设置,比方 1 - 2 秒,并不一定 300K 就肯定能起播,须要本地测试具体数值,必要时须要批改播放器内核的配置。大部分状况下,难看的视频在 300K 时,自研的播放器可能顺利起播。
2、对于预加载的机会问题
预加载机会如果太晚,简直没有成果;如果太早,可能会跟以后正在播放的视频争夺贵重的带宽资源,可能会导致起播速度收益递加,甚至造成重大的卡顿。设想一下,如果咱们疾速滑动 ABC 三个视频,此时 AB 视频正在预加载,正在播放的 C 视频,C 上面的 D 也在预加载,C 的起播工夫岂但不会升高,反而还会晋升!
咱们必须要恪守一个准则:视频预加载相对不能影响到以后视频的播放。一个简略的计划就是,以后视频缓冲到肯定比例,再进行下个视频的预加载。当然还有更加粗疏的计划,比如说动静计算以后已缓冲的进度 (onBufferingUpdate 回调) 和播放进度(getCurrentPosition),如果以后曾经缓冲好的工夫足够撑持到后续播放,就能够提前开启下个视频的预加载; 以后视频如果曾经完播(onComplete 回调),则能够放心大胆的对下个视频加载更多长度。
另外,在视频被滑走之后,播放器和预加载该当立刻进行,开释无用带宽;否则,在网络抖动的状况下,预加载的竞争会显著加剧卡顿。
难看视频新架构逐步放量之后,播放卡顿率进步不少。咱们通过紧急排查,将狐疑的对象放到了视频预加载策略上。从新梳理确定了预加载的细节后,卡顿率降落到之前程度,且用户感知起播工夫也没有堕落,收效很大。
3、对于预加载库 AndroidVideoCache
https://github.com/danikula/A…
这个库近些年不再更新,bug 很多,issue 也不少,不适于在生产环境,但不失为学习预加载库设计和实现的好资源,该库的设计验证了一个计算机界的箴言:
计算机科学畛域的任何问题都能够通过减少一个间接的中间层来解决。
Any Problem in computer science can be sovled by another layer of indircetion.
VideoCache 作为播放器和远端资源 (CDN) 的中间层,一方面从远端缓存视频到本地,一方面本机又开启了一个 server,响应来自播放器的申请。
但这个库本来并不反对预加载固定数的字节,只反对全副下载,咱们能够先简略的实现一下局部预加载的性能。
// in HttpProxyCacheServer.java
static final int PRELOAD_CACHE_SIZE = 300 * 1024;
public void preload(Context context, String url, int preloadSize) {socketProcessor.submit(new PreloadProcessorRunnable(url, preloadSize));
}
private final class PreloadProcessorRunnable implements Runnable {
private final String url;
private int preloadSize = PRELOAD_CACHE_SIZE;
public PreloadProcessorRunnable(String url, int preloadSize) {
this.url = url;
this.preloadSize = preloadSize;
}
@Override
public void run() {processPreload(url, preloadSize);
}
}
private void processPreload(String url, int preloadSize) {
try {HttpProxyCacheServerClients clients = getClients(url);
clients.processPreload(preloadSize);
clientsMap.remove(url);
} catch (ProxyCacheException | IOException e) {e.printStackTrace();
}
}
public void stopPreload(String url) {
try {HttpProxyCacheServerClients clients = getClientsWithoutNew(url);
if(clients != null) {clients.shutdown();
}
} catch (ProxyCacheException e) {e.printStackTrace();
} catch (Exception e) {e.printStackTrace();
}
}
// HttpProxyCacheServerClients.java
public void processPreload(int preloadSize) throws ProxyCacheException, IOException {startProcessRequest();
try {clientsCount.incrementAndGet();
proxyCache.processPreload(preloadSize);
} finally {finishProcessRequest();
ProxyLogUtil.d(TAG, "processPreload finishProcessRequest");
}
}
// HttpProxyCache.java
public void processPreload(int preloadSize) throws IOException, ProxyCacheException {long cacheAvailable = cache.available();
if (cacheAvailable < preloadSize) {byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
long offset = cacheAvailable;
while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
offset += readBytes;
if (offset > preloadSize) break;
}
ProxyLogUtil.d(TAG, "preloaded url =" + source.getUrl() + ", offset =" + offset + ", preloadSize =" + preloadSize);
}
}
// 仅供学习应用,不实用生产环境
这样一来,咱们就有了比拟根底的预加载 (preload) 和勾销预加载 (stopPreload) 性能, 自身这个库的实现并不简单,有工夫和人力齐全能够本人开发一套适宜本人业务的根底库。
最近“端智能 ”概念很火,相比于预估点击率(ctr) 等尝试加强举荐成果的各种想法,基于播放器的预加载的策略调优,可能是端智能最容易落地的方向。反证来看,前者如果有收益,那齐全能够移植到举荐和算法端。后者实践上能够做到真正的“多快好省”:在显著进步用户感知的起播速度根底上,保障不进化卡顿,还不会因为预加载减少太多的带宽资源。
五、浅谈播放器卡顿
对于播放场景较多的 App 来说,在 ANR 和卡顿数据里,播放器必然占据一席之地。以 ijkplayer 为例,release 函数耗时显著,甚至通过抓 trace 都能看到。
播放器的创立和销毁都是耗时的操作,很容易将主线程阻塞住,导致 App 卡顿甚至 ANR。最直观的解决方案就是将 release 函数放到子线程执行,成果空谷传声;但如果是日活百万以上的利用,会新增不少 SIGABRT 等 native 谬误,crash 率进步很多。起因也很简略,同一个播放器,主线程和子线程同时操作 player 必然存在线程抵触问题;某个线程曾经 release 了播放器,另外的线程还在一直调用播放器的接口。
1 long ijkmp_get_duration(IjkMediaPlayer *mp)
2 {3 assert(mp);
4 pthread_mutex_lock(&mp->mutex);
5 long retval = ijkmp_get_duration_l(mp);
6 pthread_mutex_unlock(&mp->mutex);
7 return retval;
8 }
通过 addr2line
或者 ndk-stack
定位到有大量解体产生在第 5 行,mp 为空指针导致 crash。这个不难猜测,既然 App 没有 crash 在第 3 行的 assert 语句而解体在了前面,阐明必然产生了在这把锁管制之外的线程问题。一个简略的解决方案是再次退出判空解决,但此计划仍然不能齐全杜绝 crash。
static long ijkmp_get_duration_l(IjkMediaPlayer *mp)
{if (mp == NULL) {return 0;}
return ffp_get_duration_l(mp->ffplayer);
}
// NOTICE: 此计划仍存在线程抵触问题
想要齐全解决,一个更优雅的计划是:
1、将同一个播放器的所有操作,包含创立和销毁等都放在同一个子线程,倡议放在一个 HandlerThread 中;
2、业务侧独自减少变量isPlayerReleased
, 在播放器销毁之前将此变量置为true
,前面对播放器的所有操作都要间接疏忽;
须要留神的是,除了业务侧被动调用播放器之外,ijkplayer 自身也有一个线程在操作内核,播放器自身会周期性的被动回调给业务。
// in https://github.com/bilibili/ijkplayer/blob/master/android/ijkplayer/ijkplayer-java/src/main/java/tv/danmaku/ijk/media/player/IjkMediaPlayer.java
private static class EventHandler extends Handler {
@Override
public void handleMessage(Message msg) {switch (msg.what) {
case MEDIA_PREPARED:
player.notifyOnPrepared();
return;
case MEDIA_PLAYBACK_COMPLETE:
player.stayAwake(false);
player.notifyOnCompletion();
return;
case MEDIA_BUFFERING_UPDATE:
long bufferPosition = msg.arg1;
if (bufferPosition < 0) {bufferPosition = 0;}
...
此处也须要退出 isPlayerReleased
管制,在从新编译播放器内核之后,线上跟播放器相干的 crash 简直隐没
六、架构、性能优化的意义
大规模的性能优化,能显著进步利用的用户体验,进一步会进步业务的外围指标,尤其对于用户增长和商业变现更有裨益。单纯的技术性指标,比方冷启动速度和滑动晦涩度,并不能让所有人服气,但如果咱们能将之转化为业务指标的增益,认可度就会大大提高。比如说,启动速度的优化往往会带来留存率的晋升,晦涩度的优化可能会进步消费类指标;既然技术服务于业务,那技术很可能也可能通过数据来证实本人的收益。如果咱们负责的是电商 App,可能会进步订单转化率千分之一;如果咱们负责的是信息流资讯 App,可能会进步人均观看视频个数、图文 feed 浏览量、应用时长,甚至还有可能因为展示和生产以及应用时长上涨,间接进步商业化支出。
所以咱们无妨先把技术放在一旁,先把本人当做产品经理,以一般的业务迭代需要来代替技术优化来思考。咱们精心设计 AB 试验,在代码中被动退出 AB 试验开关,盯紧 AB 试验后盾,随时查看用户反馈,带着业务数据汇报收益。师出有名,咱们就算不能证实用户体验显著变好,起码也能证实没有变得更差。一旦咱们让技术优化的收益深入人心,即便是小型的优化也立刻有了存在的正当理由。
当然,并不是所有的优化都适宜 AB 试验,如果业务迭代非常频繁,优化又不能在短时间内实现,期待咱们的将是无穷和有情的代码抵触。”大胆假如,小心求证“,只有有了适合机会,咱们就应该设计 AB 试验并时刻盯紧 AB 试验大盘看数据,无论是对业务 RD 还是性能 RD,或者架构 RD。
举荐浏览:
|浅谈百度浏览 / 文库 NA 端排版技术
|云原生架构下的继续交付实际
|一年数十万次试验背地的架构与数据迷信
|百度短视频举荐零碎的指标设计
———- END ———-
百度 Geek 说
百度官网技术公众号上线啦!
技术干货 · 行业资讯 · 线上沙龙 · 行业大会
招聘信息 · 内推信息 · 技术书籍 · 百度周边
欢送各位同学关注