本文由微信开发团队工程师“virwu”分享。html
近期,微信小游戏支持了视频号一键开播,将微信升级到最新版本,打开腾讯系小游戏(如跳一跳、欢乐斗地主等),在右上角菜单就能够看到发起直播的按钮一键成为游戏主播了(以下图所示)。android
然而微信小游戏出于性能和安全等一系列考虑,运行在一个独立的进程中,在该环境中不会初始化视频号直播相关的模块。这就意味着小游戏的音视频数据必须跨进程传输到主进程进行推流,给咱们实现小游戏直播带来了一系列挑战。小程序
本文是系列文章中的第5篇:api
《直播系统聊天技术(一):百万在线的美拍直播弹幕系统的实时推送技术实践之路》安全
《直播系统聊天技术(二):阿里电商IM消息平台,在群聊、直播场景下的技术实践》微信
《直播系统聊天技术(三):微信直播聊天室单房间1500万在线的消息架构演进之路》markdown
《直播系统聊天技术(四):百度直播的海量用户实时消息系统架构演进实践》微信开发
《直播系统聊天技术(五):微信小游戏直播在Android端的跨进程渲染推流实践》(* 本文)架构
小游戏直播本质上就是把主播手机屏幕上的内容展现给观众,天然而然地咱们能够想到采用系统的录屏接口MediaProjection进行视频数据的采集。app
这种方案有这些优势:
可是最终这个方案被否决了,主要出于如下考虑:
转念一想,既然小游戏的渲染彻底是由咱们控制的,为了更好的直播体验,可否将小游戏渲染的内容跨进程传输到主进程来进行推流呢?
为了更好地描述咱们采用的方案,这里先简单介绍一下小游戏的渲染架构:
能够看到图中左半边表示在前台的小游戏进程,其中MagicBrush为小游戏渲染引擎,它接收来自于小游戏代码的渲染指令调用,将画面渲染到在屏的SurfaceView提供的Surface上。整个过程主进程在后台不参与。
小游戏以前支持过游戏内容的录制,和直播原理上相似,都须要获取当前小游戏的画面内容。
录屏启用时小游戏会切换到以下的模式进行渲染:
能够看到,MagicBrush的输出目标再也不是在屏的SurfaceView,而是Renderer产生的一个SurfaceTexture。
这里先介绍一下Renderer的做用:
Renderer是一个独立的渲染模块,表示一个独立的GL环境,它能够建立SurfaceTexture做为输入,收到SurfaceTexture的onFrameAvailable回调后经过updateTexImage方法将图像数据转换为类型是GL_TEXTURE_EXTERNAL_OES的纹理参与后续的渲染过程,并能够将渲染结果输出到另外一个Surface上。
下面逐步对图中过程进行解释:
1)
MagicBrush接收来自小游戏代码的渲染指令调用,将小游戏内容渲染到第一个Renderer所建立的SurfaceTexture上;
2)
随后这个Renderer作了两件事情:
3)
第二个Renderer将第一个Renderer提供的纹理渲染到mp4编码器提供的输入SurfaceTexture上,最终编码器编码产生mp4录屏文件。
能够看到,录屏方案中经过一个Renderer负责将游戏内容上屏,另外一个Renderer将一样的纹理渲染到编码器上的方式实现了录制游戏内容,直播其实相似,是否是只要将编码器替换为直播的推流模块就能够了呢?
**确实如此,但还缺乏关键的一环:**推流模块运行在主进程,咱们须要实现跨进程传输图像数据!如何跨进程呢?
**说到跨进程:**可能咱们脑海里蹦出的第一反应就是Binder、Socket、共享内存等等传统的IPC通讯方法。但仔细一想,系统提供的SurfaceView是很是特殊的一个View组件,它不通过传统的View树来参与绘制,而是直接经由系统的SurfaceFlinger来合成到屏幕上,而SurfaceFlinger运行在系统进程上,咱们绘制在SurfaceView所提供的Surface上的内容必然是能够跨进程进行传输的,而Surface跨进程的方法很简单——它自己就实现了Parcelable接口,这意味着咱们能够用Binder直接跨进程传输Surface对象。
因而咱们有了下面这个初步方案:
**能够看到:**第3步再也不是渲染到mp4编码器上,而是渲染到主进程跨进程传输过来的Surface上,主进程的这个Surface是经过一个Renderer建立的SurfaceTexture包装而来的,如今小游戏进程做为生产者向这个Surface渲染画面。当一帧渲染完毕后,主进程的SurfaceTexture就会收到onFrameAvailable回调通知图像数据已经准备完毕,随之经过updateTexImage获取到对应的纹理数据,这里因为直播推流模块只支持GL_TEXTURE_2D类型的纹理,这里主进程Renderer会将GL_TEXTURE_EXTERNAL_OES转换为GL_TEXTURE_2D纹理后给到直播推流编码器,完成推流过程。
**通过一番改造:**上述方案成功地实现了将小游戏渲染在屏幕上的同时传递给主进程进行推流,但这真的是最优的方案吗?
思考一番,发现上述方案中的Renderer过多了,小游戏进程中存在两个,一个用于渲染上屏,一个用于渲染到跨进程而来的Surface上,主进程中还存在一个用于转换纹理以及调用推流模块。若是要同时支持录屏,还须要在小游戏进程再起一个Renderer用于渲染到mp4编码器,过多的Renderer意味着过多的额外渲染开销,会影响小游戏运行性能。
纵观整个流程,其实只有主进程的Renderer是必要的,小游戏所使用的额外Render无非就是想同时知足渲染上屏和跨进程传输,让咱们大开脑洞——既然Surface自己就不受进程的约束,那咱们干脆把小游戏进程的在屏Surface传递到主进程进行渲染上屏吧!
最终咱们大刀阔斧地砍掉了小游戏进程的两个冗余Renderer,MagicBrush直接渲染到了跨进程传递而来的Surface上,而主进程的Renderer在负责纹理类型转换的同时也负责将纹理渲染到跨进程传递而来的小游戏进程的在屏Surface上,实现画面的渲染上屏。
最终所须要的Renderer数量从原来的3个减小到了必要的1个,在架构更加清晰的同时提高了性能。
后续须要同时支持录屏时,只要稍做改动,将mp4编码器的输入SurfaceTexture也跨进程传递到主进程,再新增一个Renderer渲染纹理到它上面就好了(以下图所示)。
到这里,不由有点担忧,跨进程传输和渲染Surface的这套方案的兼容性会不会有问题呢?
实际上,虽然并不常见,可是官方文档里面是有说明能够跨进程进行绘制的:
SurfaceView combines a surface and a view. SurfaceView's view components are composited by SurfaceFlinger (and not the app), enabling rendering from a separate thread/process and isolation from app UI rendering.
而且Chrome以及Android O之后的系统WebView都有使用跨进程渲染的方案。
在咱们的兼容性测试中,覆盖了Android 5.1及之后的各个主流系统版本和机型,除了Android 5.x机型上出现了跨进程渲染黑屏的问题外,其他都可以正常渲染上屏和推流。
**性能方面:**咱们使用了WebGL水族馆的Demo进行了性能测试,能够看到对于平均帧率的影响在15%左右,主进程的CPU由于渲染和推流有所升高,奇怪的是小游戏进程的CPU开销却出现了一些降低,这里降低的缘由暂时尚未确认,怀疑与上屏操做移到主进程相关,也不排除是统计方法的影响。
为了实现不录制主播端的评论挂件,咱们从小游戏渲染流程入手,借助于Surface跨进程渲染和传输图像的能力,把小游戏渲染上屏的过程移到了主进程,并同时生成纹理进行推流,在兼容性和性能上达到了要求。
在音频采集方案中,咱们注意到在Android 10及以上系统提供了AudioPlaybackCapture方案容许咱们在必定的限制内对系统音频进行采集。当时预研的一些结论以下。
捕获方 - 进行捕获的条件:
被捕获方 - 能够被捕获的条件:
**总的来讲:**Android 10及以上可使用AudioPlaybackCapture方案进行音频捕获,但考虑到Android 10这个系统版本限制太高,最终咱们选择了本身来采集并混合小游戏内播放的全部音频。
如今,老问题又摆在了咱们眼前:小游戏混合后的音频数据在小游戏进程,而咱们须要把数据传输到主进程进行推流。
与通常的IPC跨进程通讯用于方法调用不一样:在这个场景下,咱们须要频繁地(40毫秒一次)传输较大的数据块(16毫秒内的数据量在8k左右)。
**同时:**因为直播的特性,这个跨进程传输过程的延迟须要尽量地低,不然就会出现音画不一样步的状况。
**为了达到上述目标:**咱们对Binder、LocalSocket、MMKV、SharedMemory、Pipe这几种IPC方案进行了测试。在搭建的测试环境中,咱们在小游戏进程模拟真实的音频传输的过程,每隔16毫秒发送一次序列化后的数据对象,数据对象大小分为3k/4M/10M三挡,在发送前储存时间戳在对象中;在主进程中接收到数据并完成反序列化为数据对象的时刻做为结束时间,计算传输延迟。
最终获得了以下结果:
**注:**其中XIPCInvoker(Binder)和MMKV在传输较大数据量时耗时过长,不在结果中展现。
对于各个方案的分析以下(卡顿率表示延迟>2倍平均延迟且>10毫秒的数据占总数的比例):
**能够看到:**LocalSocket方案在各个状况下的传输延迟表现都极为优异。差别的缘由主要是由于裸二进制数据在跨进程传输到主进程后,仍须要进行一次数据拷贝操做来反序列化为数据对象,而使用LocalSocket时能够借助于ObjectStream和Serializeable来实现流式的拷贝,相比与其余方案的一次性接收数据后再拷贝节约了大量的时间(固然其余方案也能够设计成分块流式传输同时拷贝,但实现起来有必定成本,不如ObjectStream稳定易用)。
咱们也对LocalSocket进行了兼容性与性能测试,未出现不能传输或断开链接的状况,仅在三星S6上平均延迟超过了10毫秒,其他机型延迟均在1毫秒左右,能够知足咱们的预期。
经常使用的Binder的跨进程安全性有系统实现的鉴权机制来保证,LocalSocket做为Unix domain socket的封装,咱们必须考虑它的安全性问题。
论文《The Misuse of Android Unix Domain Sockets and Security Implications》较为详细地分析了Android中使用LocalSocket所带来的安全风险。
**PS:**论文原文附件下载(请今后连接的4.3节处下载:www.52im.net/thread-3594…)
**总结论文所述:**因为LocalSocket自己缺少鉴权机制,任意一个应用均可以进行链接,从而截取到数据或是向接收端传递非法数据引起异常。
针对这个特色,咱们能够作的防护方法有两种:
为了兼容Android 10如下的机型也能直播,咱们选择本身处理小游戏音频的采集,并经过对比评测,选用了LocalSocket做为跨进程音频数据传输的方案,在延迟上知足了直播的需求。
同时,经过一些对抗措施,能够有效规避LocalSocket的安全风险。
回头来看,虽然整个方案看起来比较通顺,可是在实现的过程当中仍是因为多进程的缘由踩过很多坑,下面就分享其中两个比较主要的。
在刚实现跨进程渲染推流的方案后,咱们进行了一轮性能与兼容性测试,在测试中发现,部分中低端机型上帧率降低很是严重(以下图所示)。
复现后查看小游戏进程渲染的帧率(即小游戏进程绘制到跨进程而来的Surface上的帧率)发现能够达到不开直播时的帧率。
而咱们所用的测试软件PerfDog所记录的是在屏Surface的绘制帧率,这就说明性能降低不是直播开销太高引发的小游戏代码执行效率降低,而是主进程上屏Renderer效率过低。
因而咱们对主进程直播时运行效率进行了Profile,发现耗时函数为glFinish。
而且有两次调用:
若是将第一次调用去掉,直播SDK内部的此次则会耗时100多毫秒。
为了弄清为何这个GL指令耗时这么久,咱们先看看它的描述:
glFinish does not return until the effects of all previously called GL commands are complete.
**描述很简单:**它会阻塞直到以前调用的全部GL指令所有完成。
**这么看来是以前的GL指令太多了?**可是GL指令队列是以线程为维度隔离的,在主进程的Renderer线程中,glFinish前只会执行纹理类型转换的很是少许的GL指令,和腾讯云的同窗了解到推流接口也不会在本线程执行不少GL指令,如此少许的GL指令怎么会使glFinish阻塞这么久呢?等等,大量GL指令?小游戏进程此时不就正在执行大量GL指令吗,难道是小游戏进程的大量GL指令致使了主进程的glFinsih耗时过长?
**这样的猜想不无道理:**虽然GL指令队列是按线程隔离的,但处理指令的GPU只有一个,一个进程的GL指令过多致使另外一个进程在须要glFinish时阻塞太久。Google了一圈没找到有相关的描述,须要本身验证这个猜想。
**从新观察一遍上面的测试数据:**发现直播前能达到60帧的状况下,直播后也能达到60帧左右,这是否是就说明在小游戏的GPU负载较低时glFinish的耗时也会降低呢?
**在性能降低严重的机型上:**控制其余变量不变尝试运行低负载的小游戏,发现glFinsih的耗时成功降低到了10毫秒左右,这就印证了上面的猜想——确实是小游戏进程正在执行的大量GL指令阻塞了主进程glFinish的执行。
该如何解决呢?小游戏进程的高负载没法改变,那能让小游戏在一帧渲染完成之后停住等主进程的glFinish完成后再渲染下一帧吗?
**这里通过了各类尝试:**OpenGL的glFence同步机制没法跨进程使用;因为GL指令是异步执行的,经过跨进程通讯加锁锁住小游戏的GL线程并不能保证主进程执行glFinish时小游戏进程的指令已经执行完,而能保证这点只有经过给小游戏进程加上glFinish,但这会使得双缓冲机制失效,致使小游戏渲染帧率的大幅降低。
既然glFinish所带来的阻塞没法避免,那咱们回到问题的开始:为何须要glFinish?因为双缓冲机制的存在,通常来讲并不须要glFinish来等待以前的绘制完成,不然双缓冲就失去了意义。两次glFinish中,第一次纹理处理的调用能够直接去掉,第二次腾讯云SDK的调用通过沟通,发现是为了解决一个历史问题引入的,能够尝试去掉。在腾讯云同窗的帮助下,去掉glFinish后,渲染的帧率终于和小游戏输出的帧率一致,通过兼容性和性能测试,没有发现去掉glFinish带来的问题。
这个问题最终的解法很简单:但分析问题缘由的过程实际上作了很是多的实验,同一个应用中一个高GPU负载的进程会影响到另外一个进程的glFinish耗时的这种场景确实也很是少见,能参考的资料很少。这个过程也让我深入体会到了glFinish使得双缓冲机制失效所带来的性能影响是巨大的,在使用OpenGL进行渲染绘制时对于glFinish的使用应当很是谨慎。
在测试过程当中:咱们发现不管以多少的帧率向直播SDK发送画面,观众端看到的画面帧率始终只有16帧左右,排除后台缘由后,发现是编码器编码的帧率不足致使的。经腾讯云同窗测试同进程内编码的帧率是能够达到设定的30帧的,那么说明仍是多进程带来的问题,这里编码是一个很是重的操做,须要消耗比较多的CPU资源,因此咱们首先怀疑的就是后台进程优先级的问题。
为了确认问题:
**综上:**能够确认帧率降低就是因为后台进程(以及其拥有的线程)的优先级太低致使的。
提升线程优先级的作法在微信里比较常见,例如:小程序的JS线程以及小游戏的渲染线程都会在运行时经过
android.os.Process.setThreadPriority
方法设置线程的优先级。腾讯云SDK的同窗很快提供了接口供咱们设置线程优先级,但当咱们真正运行起来时,却发现编码的帧率仅从16帧提升到了18帧左右,是哪里出问题了呢?
**前面提到:**咱们经过chrt命令设置线程优先级是有效的,但android.os.Process.setThreadPriority这个方法设置的线程优先级对应的是renice这个命令设置的nice值。仔细阅读chrt的manual后,发现以前测试时的理解有误,以前直接用chrt -p [pid] [priority]的命令设置优先级,却没有设置调度策略这个参数,致使该线程的调度策略从Linux默认的SCHED_OTHER改成了命令缺省设置的SCHED_RR,而SCHED_RR是一种“实时策略”,致使线程的调度优先级变得很是高。
**实际上:**经过renice(也就是android.os.Process.setThreadPriority)设置的线程优先级,对于后台进程所拥有线程来讲没有太大的帮助。
其实早有人解释过这一点:
To address this, Android also uses Linux cgroups in a simple way to create more strict foreground vs. background scheduling. The foreground/default cgroup allows thread scheduling as normal. The background cgroup however applies a limit of only some small percent of the total CPU time being available to all threads in that cgroup. Thus if that percentage is 5% and you have 10 background threads all wanting to run and one foreground thread, the 10 background threads together can only take at most 5% of the available CPU cycles from the foreground. (Of course if no foreground thread wants to run, the background threads can use all of the available CPU cycles.)
关于线程优先级的设置,感兴趣的同窗能够看看另外一位大佬的文章:《Android的离奇陷阱 — 设置线程优先级致使的微信卡顿惨案》。
**最终:**为了提升编码帧率并防止后台主进程被杀,咱们最终仍是决定直播时在主进程建立一个前台Service。
多进程是一把双刃剑,在给咱们带来隔离性和性能优点的同时也带来了跨进程通讯这一难题,所幸借助系统Surface的能力和多种多样的跨进程方案能够较好地解决小游戏直播中所遇到的问题。
**固然:**解决跨进程问题最好的方案是避免跨进程,咱们也考虑了将视频号直播的推流模块运行在小游戏进程的方案,但出于改形成本的考虑而没有选择这一方案。
**同时:**此次对于SurfaceView跨进程渲染的实践也对其余业务有必定参考价值——对于一些内存压力较大或是安全风险较高,又须要进行SurfaceView渲染绘制的场景,能够把逻辑放到独立的进程,再经过跨进程渲染的方式绘制到主进程的View上,在得到独立进程优点的同时又避免了进程间跳转所带来的体验的割裂。(本文同步发布于:www.52im.net/thread-3594…)