了解当前集团内在 Electron 场景下,Web技术栈在视频/图片处理上支持对原生组件混合调用,使集成方可以以纯Web技术栈即可轻松集成原生组件的视频处理能力。即 C++ 原生组件处理视频帧/图片数据,在 Web 页面某块区域渲染
背景
-
云手机业务是结合云计算和超低延迟音视频传输技术的跨终端虚拟安卓云服务,在云机最大化地模拟真实手机的环境和性能,通过 RTC 串流技术实现云机与客户端音视频通信。该业务有个重点 toB 场景需客户端 Electron 页面同时预览和操控几百路流,不幸的是浏览器内核 Chromium 在实现 WebRTC 逻辑时设置了 peerConnections 最大连接数量 500。初步思考怎样跳阈值限制?
- 更改并重新编译 Chromium 内核,让客户在 Electron 构建时集成更改版的 Chromium 内核?-->集成成本高。改变 toB 客户的集成方式,toB 客户依赖的 Chromium 版本也过多,使得每个客户诉求版本都需要更改重编。单功能更改,价值不大。因此该模式只能作为增量服务
- 更改云手机架构,支持单 peerConnection 连接多路流?--> 架构调整过大,可行性不高。几乎颠覆当前已存在业务前后台架构,改动成本过高
- 给 Chromium 提 issue, 周期太长,也无法兼容历史版本
- 初步分析下,在 WebRTC 侧处理大量建连想跳过阈值还是可行性均不太高,那既然主要是 Chromium 建连问题那是否可以跳过 Chromium 建连,采用其他方式进行建连?另外在单 Web 页面同时运行几百路流时也经常丢失连接、测试设备 CPU 与内存消耗达到设备顶峰。然后就走到本文的调研主题,能否有跨端方案使原生层进行建连&编译,Webview 指定位置进行渲染。
-
直播连麦业务是抖音上基于 RTC 串流技术实现实时主播连线、主播 PK、视频聊天室等一系列直播互动玩法。在该玩法中即利用 RTC 的实时音视频传输技术实现单房间多用户音视频互聊。为业务更好的降本,在该场景下 Native 端已开启 H265 编解码,H265 在编解码压缩算法上相对于 H264 更先进,相同画质下可以实现更高的压缩比,码率上可以节约 50%,而抖音 PC 端基于的 Electron & Chromium 版本过低,至少到 Chromium 128 版本WebRTC 才完全支持 H265 编解码。导致只要用 Web 端进房,Native 端的用户编解码也要降级到 H264,从而影响整体 H265 占比。基于此场景如何使 WebRTC 的用户使用H265编解码亦回到本文调研主题。能否有跨端方案集成 H265 编解码器,在原生层编解码,在 Web 指定位置进行显示。
基础概念
-
Electron: 基于 Web 技术栈,利用 Chromium 内核 和 Node.js 开发和构建桌面应用程序
- 主进程:NodeJS 运行环境,负责管理应用的生命周期,包括打开和关闭窗口、处理系统事件等
- 渲染进程:Chromium运行环境负责渲染Web页面。可以通过主进程创建渲染进程窗口时 WebPreferences 配置
nodeIntegration: true
,使渲染进程支持 NodeJS API - 主进程与渲染进程内存与数据互相隔离,采用 Electron 提供的 IPC 机制进程通信
-
node-addon : node 扩展模块,支撑 node 直接集成使用 C++ 代码功能。独立于其他线程运行
-
node-gyp: node 用于构建插件模块工具,主要用于编译 C++ 组件
-
tt-Webview: 字节内部 Infra 团队,基于主流开源 Chromium 做二次开发 TTWebView 引擎
-
OpenGL: 是一个跨平台的、语言无关的、用于渲染 2D 和 3D 矢量图形的应用程序编程接口。OpenGL 的实现由硬件制造商提供。
-
WebGL:WebGL 是一种在不需要插件的情况下在网页浏览器中使用的 3D 图形 API。它是基于 OpenGL ES(嵌入式系统的 OpenGL 子集)的标准,允许网页直接访问 GPU(图形处理单元)进行图形渲染。
-
WebRTC: 是一种使 Web 应用程序和站点能够捕获和选择性地流式传输音频或视频媒体,以及在浏览器之间交换任意数据的而无需中间件的技术
方案介绍
视频处理跨端方案根据是否需要大量帧/图片数据通信传输,主要是两个大流派:
- C++ 只作为Web的解析引擎, C++ 原生层只负责编解码,将数据传输给 Web,Web 页面在指定位置进行渲染。渲染元素即是 Web HTML 元素,主体关注点就在于数据传输效率和Web渲染效率
- C++ 和 Web 是独立的个体,C++ 是数据产生消费全链路,编解码&渲染 全流程都由C++原生层处理,Web端主要是想办法将 C++ 渲染的内容怎样展示在 Web 页面指定位置,以及将用户在该位置操作行为透传给下层 C++ 区域

Web 渲染
该大类即上述所说的第一种流派,即 C++ 原生层只负责编解码,将数据传输给 Web,Web 页面在指定位置进行渲染。由于渲染是 Web canvas / video 元素进行渲染,整体还是 HTML 布局,那就无需考虑定位、滚动、操作事件透传等问题。那么性能相关的方向,就回到了大量帧数据传输效率和渲染效率。基于不同的处理传输和渲染细分为不同的方案。如采用 GPU / CPU 传输数据,canvas2d / WebGL / video 渲染等
GPU 传输
该方案是理想情况下的数据传输方式。直接在 C++ 层将数据通过 OpenGL / EGL 方法写入到 GPU,只需要将 GPU 对应的纹理标识(抽象的 handle,作为个引用指针指向底层图像数据而不包含图像数据)透传到跨端的上层消费层,上层直接通该纹理标识传入到 WebGPU / WebGL 中进行渲染。避免数据从 CPU 传递再导入至 GPU 的开销。
scss
/** 伪代码 **/
GLuint textureID;
// 进程1 创建EGLImageKHR将纹理和EGLImage对象关联起来
EGLImageKHR eglImage = eglCreateImageKHR(eglDisplay, eglContext, EGL_GL_TEXTURE_2D_KHR, (EGLClientBuffer)texture, NULL);
// 进程2 根据eglImage获取到纹理进行渲染
GLuint sharedTextureID;
glBindTexture(GL_TEXTURE_2D, sharedTextureID);
glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, (GLeglImageOES)eglImage);
遗憾的是该方式在 Android / IOS / Windows 原生以及接下来的鸿蒙上支持(openGL / Vulkan / EGL等等),而 Web 技术栈在浏览器环境下依赖的是 WebGPU / WebGL,然而 WebGPU / WebGL 并不支持根据纹理标识获取共享纹理进行渲染。导致当前无法采用该理想方案,只能将该方案做个深入学习内核与 WebGL 的预研课题,待后续有时间/精力/能力时深入探索实现共享纹理。
CPU 传输
帧数据无法直接通过 GPU 传输,那就只能考虑从 CPU 传输了,我们通常的代码逻辑运行环境就是 CPU 运行,所以非特殊操作数据均是在 CPU 进行处理和传输。基于 CPU 传输进一步分析,就会碰到几个核心问题:
- 解析后的数据所在的内容能否不再复制拷贝,直接上层 Electron 即可消费?
- 传输的数据帧数据格式是什么?怎样能使得传输的数据量小,这样传输又快又占内存小?
- 上层 Electron 渲染采用什么方式渲染,会使得渲染效果更快?性能消耗最小?
方法调用
由 NodeJS 知识所知,NodeJS 的 Buffer对象本身是基于 C++ 的 ArrayBuffer,是 JS 专门访问和操作底层内存二进制数据。而 NAPI 在 NodeJs 和 C++ 之间传输 Buffer 时,直接传递的底层引用而非传输的所有数据。那基于上述传输上,确实是可以实现数据不复制拷贝,直接在上层中消费。这里就是所谓的共享内存。
通过 NAPI 向 Electron 层 Nodejs 层传输 Buffer 有两种 API。
Napi::Buffer.New
接口。该接口主要是扩展 Buffer 类的接口, 不同的重载方法实现对内部缓冲区二进制数据的拷贝或封装。其中Buffer<T> New(napi_env env, T* data, size_t length, Finalizer finalizeCallback,Hint* finalizeHint);
不会复制数据而是直接包装数据进行透出。napi_create_external_arraybuffer
接口。该接口相对Napi::Buffer.New
功能更单一,就是将指定外部数据源包装进行透出(外部数据源除了内部缓冲区二进制数据外还可以是文件的指针等)。稍许遗憾的是Electron21 版本以上不再支持该接口。
代码示例
ini
/** 伪代码 **/
// C++ NAPI 抛出根据内存名称和大小获取到帧数据的方法
napi_value GetSharedMemoryHandle(const Napi::CallbackInfo& info) {
std::string dataNameOrigin = info[1].As<Napi::String>().Utf8Value(); // 示例大小
std::wstring dataName(dataNameOrigin.begin(), dataNameOrigin.end());
HANDLE hMapFile = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE,dataName.c_str());
Napi::Env env = info.Env();
void* pBuf = MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, 0);
size_t dataSize = info[0].As<Napi::Number>().Int64Value(); // 示例大小
napi_value result;
napi_status status = napi_create_external_arraybuffer(env, pBuf, dataSize, NULL, NULL, &result);
return result;
}
// 注册NAPI方法
Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "getSharedMemoryHandle"), Napi::Function::New(env, GetSharedMemoryHandle));
return exports;
}
NODE_API_MODULE(vePhoneCSDK, InitAll)
//上层Electron Web调用
const arraybuffer = vePhoneCSDK.getSharedMemoryHandle(size,name);
//类型化数组,并不是拷贝而是读取视图,以特定格式操作二进制数据
//方便JS类型安全以及避免直接操作原始二进制数据类型转化消耗
const unit8Array = new Uint8ClampedArray(arraybuffer);
传输同步机制
- C++ 设置定时器,定时器定时查看是否有帧数据更新。待更新后再统一回调给上层 Electron。刷新帧率由 C++ 逻辑控制,Web 层仅可设置固定帧率
- 直接帧数据处理完回调,存入内存中,将数据信息传递给上层 Electron,Electron 再调用 NAPI 方法从指定内存读帧数据。刷新帧率由 Web 逻辑控制,Web 逻辑判断什么时间获取帧数据
渲染方式
数据传输不拷贝数据的问题解决了,接下来该看传输数据格式和渲染方式的了。通常解码的视频帧为 YUV420 格式,相对于 RGB 数据量上减少了一半。这里不再赘述,感兴趣可以参考颜色空间 -- RGB及YUV格式解析与YUV基础知识,一文读懂 YUV 是什么!
由于 canvas2d 无法直接渲染 YUV 数据格式,只能渲染 RGBA 数据格式。但是在批量简单 2D 图形的处理上canvas2d 相对 WebGL 性能消耗和冷启动时间要好些,所以可以分为 WebGL 渲染和 canvas 渲染方案(另 WebGL 通常只支持最大渲染数量上限 15 个,但是在 Electron 场景下可以 max-active-Webgl-contexts
重新设置阈值)。
WebGL / WebGPU 渲染
以 mediaSDK 为底层的业务基本采用该方式,如直播伴侣 RTC。JS 从 NAPI 中获取到的帧数据直接丢到 WebGL 通过 WebGL 纹理处理和加工,直接将问题转化为 WebGL 渲染 2D 图片,整体实现比较简单,不做赘述,具体代码可参考第三方包 yuv-canvas
优点就是 GPU 利用率高,可以直接解析 YUV420 数据格式,缺点是独立解析渲染大量多路视频时,WebGL 创建相对内存占比多,冷启动时间慢,更消耗性能。
canvas2d 渲染
由于处理好的视频帧数据为 2D 图形,无需更多 3D 转换及数据处理,采用 canvas2d 渲染在独立解析渲染大量多路视频时即存在首帧渲染快,性能消耗低的优势。
由于 C++ 解析的帧数据为 YUV420 格式,而 canvas2d 不支持解析该格式,则采用 canvas2d 渲染前需要先处理好帧数据格式转化,无论是在 C++ 层先将 YUV420 转化为 RGBA 传输给 Web 层还是 Web 层接收到 YUV 数据后再转化为 RGBA 都涉及到 CPU 处理数据转化问题,存在一定 CPU 消耗。
video 渲染
video 的 srcObject
属性允许直接将媒体资源( MediaStream / MediaSource / Blob)等绑定到 video 元素上,WebRTC入门教程第一个 demo 就是获取本地摄像头采集实时渲染在 Web 网页上,这个实现就是获取到摄像头采集的 mediaStream流 绑定到 video 的 srcObject 属性上实现的。而 Chromium 在 94 版本之后已支持通过MediaStreamTrackGenerator 根据视频帧生成 MediaStream,我们当前获取到的又是 YUV420 的视频帧。直接借用浏览器内置的 video 元素,借助浏览器内建的视频处理管道,只是简单渲染又无需复杂数据处理,这就是双向奔赴。
代码示例
ini
// 创建一个视频轨道生成器
const trackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
// 创建一个 MediaStream 并添加生成的轨道
const stream = new MediaStream();
stream.addTrack(trackGenerator);
// 将MediaStream绑定video元素
const video = document.getElementById('video');
video.srcObject = stream;
// 使用 WritableStream 来推送视频帧
const writer = trackGenerator.writable.getWriter();
// 创建一个简单的视频帧并推送
const videoFrame = new VideoFrame( YUV420Data, { timestamp: performance.now() });
writer.write(videoFrame);
videoFrame.close(); // 释放资源
示例项目架构图

Native 渲染 & Web 嵌入
该大类即上述所说的第二种流派,C++ 是数据产生消费全链路,编解码 & 渲染 全流程都由 C++ 原生层处理,Web 端主要是想办法将 C++ 渲染的内容怎样展示在Web页面指定位置,以及将用户在该位置操作行为透传给下层 C++ 区域。在这种情况下 C++ 更像个 Web 新增了一个独立的 HTML 元素以及配套的完整功能插件。实际实现也是类似这样处理的。由于 C++ 是独立渲染, 而 Electron 窗口的 Web 页面与 C++ 独立渲染的窗口分属于不同的窗口。 不同窗口层级上下层层级 zIndex
只能是固定的,假设存在两个窗口 A 和 B,原生上无法实现窗口 A 在窗口 B 下方,又能在 B 窗口看到正下方窗口 A 内容。那么该大类主要解决的也就是这个问题,解决的方案也就是 TTWebview / tt_electron 之前做的同层渲染。
挖洞
挖洞,这个名词就很形象。既然期望看到 Electron 窗口下看到底层 C++ 渲染视频的原生窗口,那直接给 Electron 窗口在 C++ 窗口的正上方,挖个洞让这个洞透明化,这样不正好用户看到的画面就是 Electron Web 页面内容以及在Web 页面合适的位置上显示的视频(挖洞后透明看到底下的C++ 窗口的视频)。这个就涉及到内核的改动了。基于这个思路往下走,那么就问题来了
- 有没有办法让 Electron 窗口的某个区域透明,其他区域不透明?
- 怎样知道让 Electron 窗口的哪个区域,指定的区域大小进行透明?
- 怎样处理位置变化、触控等事件传递?
支持区域透明
庆幸的是核心问题是否可以让 Electron 窗口某个区域透明是有解法的,以 Windows 为例,创建窗口时,可以指定设置扩展样式WS_EX_LAYERED,这个允许窗口具有透明样式,通过 SetLayeredWindowAttributes 方法可以重新设置透明度。而 Electron 创建的窗口是可以通过 getNativeWindowHandle
方法获取到原生 Handle。以上万事俱备,那么即可以着手定制内核逻辑了。详细代码参考 TTWebview 同层渲染代码 MR。
区域定位与事件传递
核心的问题解决了,至于其他两个问题,怎样指定窗口透明区域的位置,以及位置变化那实现手段就多样了。TTWebview 1.0 方案将该方式开放给集成方,由集成方自行管理和实现。又介于对集成方集成复杂度较高,TTWebview 1.1(又称挖洞-三明治模式)又在此基础上做新方案,提供标准化的容器管理,让集成方无需考虑 Electron 哪些区域透明,怎样传递触控信息。
Embed 元素
借助于 HTML <``embed``>
元素标签,将外部内容嵌入文档中的指定位置。此内容由外部应用程序或其他交互式内容源。通过该标签标识,直接将底层 C++ 成窗口设置成 Embed 元素一样大小,并放置 Embed 元素位置正下方。这样内核只需要需要监听 Embed 元素位置变化以及 Embed 元素上的点击事件透传至底层 C++ 层即可解决上述问题。这样使得集成方集成成本得以优化,但是核心实现还是挖洞 1.0 那套。
问题
上述介绍完挖洞原理, 虽然核心的问题解决了,但是看到实现方案上存在位置信息的同步和交互,位置变化时,需要进行位置的通信传输( Electron 监听到位置变化 -> C++ 获取到 Electron 信令通知 -> C++ 更新原生窗口位置)。这就需要考虑到位置同步就存在信令的频率和传输与更新位置消耗的时间。不幸的是实测下来在快速拖动和滑动场景下,存在位置延迟更新的残影。
纹理同步
上述的挖洞方案,从原理分析上是借助于上层窗口的透明度将两个窗口感官上认为在同一层的方案,实际上渲染时并未在同一层。那有无方式可以实现将 Native 的渲染就和 Web 的渲染放在一起呢,从挖洞那块可以显而易见,只要有一定改动 Chroumium 的能力,那就可以实现该功能。直接拦截每一帧 C++ 的纹理,直接在底层渲染时拷贝到Webview 的指定区域上,TTWebview2.0同层渲染即是采用该方案
核心思路是依托于 Chromium 的 WebPlugin 创建一个独立的 videoLayer 层,在 native view 绘制时进行拦截,将纹理传递给上层 view,通过 Chromium Compositor 模块,合绘在 Webview 指定区域上。
问题
虽然做到了真正的同层渲染,但是由于需要实时对 native 合成绘制,会影响运行性能。(对比 TTWebview 1.x 多了拦截、替换重绘流程,TTWebview 2.0 官方暂无数据报告)。
基于上述问题 TTWebview 官方推荐视频/直播采用 1.x 即挖洞方案,image/text 等采用 2.0 即纹理同步方案。
优缺点
方案详情 | 优点 | 缺点 | |
---|---|---|---|
Web渲染 | GPU传输 | - 无需修改内核进行支持,Native 原生功能进行数据处理,将数据传递给web侧进行消费 |
- GPU层面上通过纹理标识传输,无数据CPU传输消耗 | - 当前webGL/WebGPU不支持 | | CPU传输 | webGL渲染 | - 无需修改内核进行支持,Native原生功能进行数据处理,将数据传递给web侧进行消费
- GPU利用率高,可以直接解析YUV420数据。webGL计算与渲染数据均在GPU进行处理 | - 独立解析渲染大量多路视频时,WebGL 创建相对内存占比多,冷启动时间慢,更消耗性能 | | canvas2d渲染 | - 无需修改内核进行支持,Native原生功能进行数据处理,将数据传递给web侧进行消费
- 视频帧计算均在Native原生层,web仅为接受数据与渲染,无需处理数据。在独立解析大量多路视频时,canvas2d 相对 WebGL 性能消耗和冷启动时间要好些 | - canvas2d无法解析YUV420数据,需先转化为RGBA格式。存在CPU 转化损耗 | | | video渲染 | - 无需修改内核进行支持,Native原生功能进行数据处理,将数据传递给web侧进行消费
- 借助浏览器提供的VideoFrame和MediaStreamTrackGenerator功能,复用video功能,进行渲染 | - Chromium 在 94 版本以上支持,对应Electron 15.0.0版本及以上
- VideoFrame数据源仅支持单一数据源,仍需要在Native原生层将视频帧Y/U/V三个平面数据合并在同一个内存地址中 | | | Native渲染&web嵌入 | 挖洞方案 | - 内部业务引入TTWebview后,集成成本低
- Native原生功能与web功能不耦合,可独立管理和升级。对于接入
- 相对纹理同步方案,挖洞方案无纹理劫持,性能较好 | - 需要内核支持,内部业务需集成tt_webview,外部业务需自行开发对应版本内核逻辑
- 不支持部分CSS样式
- 位置变化依赖位置同步,快速拖动时存在残影 | | 纹理同步方案 | - 内部业务引入TTWebview后,集成成本低
- 纹理拦截与合绘,所以相对挖洞方案,不会存在原生与web位置残影问题 | - 需要内核支持,内部业务需集成tt_webview,外部业务需自行开发对应版本内核逻辑
- 需要拦截纹理与合绘,相对于挖洞方案性能稍差 | |
业务落地
回到背景中业务诉求,根据不同的业务场景已落地和正在落地对应的技术选型
云手机
云手机业务由于是 toB 场景,当前提供给客户的为 SDK 包,客户在其 Electron 业务工程中调用,并且不同客户使用的 Electron 版本均不同。提供客户更改逻辑后浏览器内核版本成本较高,因此无法采用 Native 渲染(需提供对应版本更改后的内核)。由于有同时预览几百路流的概念,无需音频, 视频帧在web层并未有数据处理,只是拿到数据直接渲染。从冷启动速度和性能角度上,所以选了 canvas2d ,直接将获取到的视频帧数据 putImageData 绘制在 canvas2d 上。
抖音连麦
抖音连麦为内部业务,且抖音PC已集成 tt_electron,本可以是可以利用Native渲染挖洞功能来实现功能。但鉴于当前业务逻辑结构较为复杂,采用挖洞功能可能需要调整下业务代码。同时当前RTC已存在 ElectronRTC SDK,为方便融合 webRTC 与 ElectronRTC 形成 HybridRTCSDK ,后续用户丰富toB 场景解决方案。所以最终还是回归到 Web 渲染方案上去。由于该业务无同页面大批量实例场景,需要单实例视频渲染与音频播放,同时探索 webcodec 的技术推广落地,最终视频帧采用 VideoFrame 和 MediaStreamTrackGenerator 功能,复用 video DOM 元素进行渲染(PS: 音频帧采用 AudioFrame )。