案例:React Native在字节跳动游戏营销场景中的实践
客户端跨端框架已经发展了很多年了,最近比较流行的小程序、Flutter、ReactNative,都算是比较成功、成熟的框架,面向的开发者也不一样,很多大型 App 都广泛的使用了,笔者有幸很早就参与学习使用了这些优秀的跨端方案,在这几年的开发和架构设计中,除了在 App 中支撑了千万级 DAU,也慢慢将 ReactNative 跨端方案运用到了游戏,来提升开发、迭代效率。
ReactNative 是目前比较流行的跨端方案,目前支持 Android、iOS、Windows 等平台,能解决了开发中的人力和双端统一性问题,支持热更新,做到了随时随地上线。
提到 ReactNative,大家一定会和 Flutter 去做比较,Flutter 同样是比较通用的跨端框架,两者各有优缺点,围绕着两者之间的讨论也挺多的,但对于开发者而言,在合适的场景中选择合适的更重要,后面我们也会介绍在游戏中,我们是如何使用 ReactNative 完成一些活动页面的开发的。
ReactNative 作为跨端框架在原生端 App 中使用比较多,从国外的 Facebook 到国内各大大厂都在使用,而且基于该设计思想的跨端框架也不少,如 Weex 等,而在游戏中使用的还比较少,主要原因还是游戏的开发、运行原理与原生差异太大。目前游戏的运行环境有很多种,主要以 Unity、Cocos、UE4 为主,而且这些游戏平台已经具备了跨移动端平台的能力,同时支持 Android、iOS 等设备,且具备热更新能力。
在原生 App 开发中很多快速迭代的页面都采用了 H5 开发,主要集成、开发起来简单,游戏中也比较类似,这些 H5 页面也做到了多平台同时支持,但在响应速度、启动速度、内存等性能指标上会显得有点不足,而且在游戏环境中很难做到沉浸式的体验。所以围绕着解决活动快速迭代、发布、高性能、沉浸体验等问题,也做了不少解决方案,参考、学习这些经验我们也做了大量的对比测试,在早期的方案上最终选定了 ReactNative 作为基础引擎。
自渲染引擎:现在的游戏一般都是基于 opengl 设计,自建的 UI 渲染引擎,将 UI 内容更新到原生的 surface 来显示,所以和原生的 UI 组件体系差别较大,原生端的系统组件不再适用,且整个游戏在 Android 设备上是一个 Activity,另外游戏自渲染引擎提供了很多动画属性,来满足高质量的动画体验及沉浸式体验,这些在原生侧都是无法共享的。
开发语言差异:在开发语言上也存在很大的差异(如 Unity C#/Lua 等),不同于 Android、iOS 的系统开发语言、IDE,相对客户端,参与这方面的开发者也比较少,这也限制了很多原生框架在游戏中的使用,当然游戏也是需要访问设备的一些资源的,这就是常说的 bridge,游戏通过 bridge 来调用系统或者开发者提供的 API,UI 上仍无法直接共享,虽然网络上也有一些纹理共享的方案打通游戏和原生 UI,但使用上仍然有很大的局限性。
性能要求高:游戏相比于 app 来说对画质、渲染性能要求较高,这也导致了自身对内存、CPU 占用较高,对于接入的业务、页面的内存峰值、稳定值方面要求比较严格,最好能做到退出页面即释放。
热更能力强:游戏一般都支持热更能力,如 Unity,本身就支持 JavaScript 和 Lua 开发,所以存在 unity+Lua 的热更方式,且很多活动或者游戏业务都会采用热更和动态入口方式,减少安装包大小,提升灵活性。
支持设备复杂:游戏的运行环境除了常见的 Android、iOS 平台外,还存在 PC、Android 模拟器等,所以在兼容性和体验上要求比常见的 app 要高。测试发现现有的 Android 模拟器一般都是采用 x86 架构,支持 32 及 64 位,而手机设备一般是 arm v7、v8,虽然模拟器也支持了 v7、v8 兼容模式,但都是通过 arm 转 intel 指令完成,实测存在很多兼容问题,支持起来难度比较很大。
从游戏端内的数据来看,除了游戏本身核心外,一些活动、功能都是需要快速迭代的,因此端内用了很多 h5 的活动场景,且为游戏提供的大量的 API 和数据能力,但在内存、性能、沉浸式体验上与游戏仍有很大的差距;
另外因游戏、H5 与原生端方案的差异,原生 UI 组件无法在游戏中直接使用,需要开发实现,这点比较类似于 Flutter,所以为了能更好的兼容原生端的一些能力、场景,选择能支持系统 UI 交互、跨不同游戏平台,是我们选择的首要考虑条件。
从技术上来看,有游戏内的解决方案,有客户端的解决方案,在考虑选择方案时,我们主要考虑了以下几个问题:
游戏拥有很多不同的平台,而且开发语言不一致,采用游戏端内方案,就会涉及到维护多套引擎的问题,很难做到架构统一性,比较流行的如 xLua、PureTS 等。
客户端比较流行的跨端方案比较多,上面说的 h5 页面就能很好的解决跨平台问题,也是目前很多游戏活动采用的解决方案,但因为其性能、体验与原生客户端的差异,才有了后来 Facebook 对外开源的 ReactNative 方案,它很好的支持了统一的原生体验,并大大提升了性能,引领了大前端的浪潮。现如今发展比较迅速的 Flutter,自渲染引擎和 UI 一致性,也逐渐被很多大厂 App 采用;另外国内友商也提出了自己的跨端方案 Weex,原理上与 ReactNative 类似。
其中 xLua、PureTS 采用的是游戏端内的 UI 渲染,在游戏内部是比较成熟的方案,之所以不在我们前期的评估的范围之内的原因如下,也不是方案不好,而是不适合,当然采用 ReactNative 原生端方案也有局限性,如 UI 无法和游戏混排,这也是为什么完成 ReactNative 搭建后,也开始持续迭代支持了 PureTS 方案的原因,具体原理这里就不解释,这是一个开源项目,原理不算复杂:
两者采用的是游戏 UI 组件,开发者必须对游戏本身的设计和架构有很深的了解,比较适合有丰富游戏开发经验的团队,而我们是客户端团队。
这些方案都是针对某个游戏平台而设计,不具体全平台统一性,需要大量适配支持 Cocos、Unity、UE4 等平台,维护成本较高。
很难复用客户端很多复杂的组件,例如地图、直播、地图等等的组件,这些在游戏中就不支持,自然这些方案也很难支持
相信大家还是会有疑问,选择 ReactNative 感觉不是一个最好的选择,相比于 Flutter 的最近的突飞猛进发展,ReactNative 逊色了太多,这两年进展微乎其微,而且渲染性能的瓶颈也越来越制约了其发展,先后有很多开发者都宣布不再开发、维护 ReactNative,转向原生开发或者 Flutter 的怀抱,但我们最终还是坚定的选择了 ReactNative,更看好其未来的架构发展,详细大家可以参考我的文章《庖丁解牛!深入剖析 React Native 下一代架构重构》,同时在后面的章节中,也会为大家重点讲解新架构的特点。
上面已经聊过游戏环境和 App 的差异了,也是因为这些特殊性,我们最初的方案采用了 ReactNative,官方标准的设计和脚手架工程可以帮助我们很好的搭建一个 App,可以帮助开发者在不需要有原生客户端开发的经验的情况下,生成跨端的 App。但游戏不一样,拿 Unity 的游戏来说,游戏的打包、开发环境是 Unity 的 IDE,另外游戏的页面是自渲染的,如何在游戏中显示原生的 UI,这些都是要解决的问题。
总结下来要在游戏环境中运行 ReactNative,要解决几个问题:
工程化,快速支持 ReactNative 的调试和集成
ReactNative 页面容器,承载并管理 ReactNative 页面
支持热更新服务,支持灵活、快速上线业务
因整体原理不复杂,很多文章也做了很详细的介绍,下面就简单说在游戏中的一些差异和思路。
在游戏中引入原生端的组件库都是以 plugin 方式集成,所以首先要将引擎作为一个 Module、Plugin 集成到游戏中,拿 Android 举例,将 ReactNative sdk 封装成独立的 aar module,在游戏中引入这个 aar 作为 Plugin,游戏的 Native 代码能访问我们 aar plugin,这点和原生开发其实是一致的。
在游戏中很多页面和游戏都是游戏中实现的,所以还需要解决游戏调用 native code 问题,比如我们需要在游戏中的某个按钮打开 ReactNative 页面,拿 unity 来说,就要实现 c# 代码到 ReactNative 代码的调用,这里要封装一层 bridge,这些都是标准的游戏 API,具体可以参考《浅谈 Unity 与 Android 原生的桥接》,其他游戏平台也比较类似。
Debug 也是开发调试中必要环境,需要在游戏中引入 debug 开关和入口,如悬浮窗等,并要做好 release 包关闭入口。
上面也介绍了游戏都是采用自绘引擎,所有交互都是在一个 Activity 页面中,任何新的页面的跳转都会导致游戏 pause 或者 stop,打断其沉浸式体验,而 ReactNative 是在 ReactRootView 中承载所有的 UI 渲染的,所以容器的设计思路考虑了以下几种方案:
将 ReactRootView 加载到游戏 Activity 的 Rootview 中,作为一个子 View,关闭页面时,从 Rootview 阶段移除。
将 ReactRootView 封装到系统的 Dialog 窗口中,这样既可以做到独立窗口加载到游戏中,也不打断游戏进程。
有了页面容器后,跳转不同功能的页面,就需要制定一个协议了,通过协议完成页面数据和功能的传递,可以参考开源的 Router 协议等。
活动页面多了后,就涉及到页面之间跳转和窗口管理了,所以需要一套完善的窗口管理 API,并通过 Unity api,让游戏可以快速通过 pop 协议或者指定 id 关闭页面。
以下是设计完成后大概能力介绍:
热更新能力是 ReactNative 最基础的能力,因引擎支持从 asset 目录或者磁盘分区中加载 JS 文件,解决好加载路径和包下载问题,就能很好的支持热更新能力,其中包更新:
考虑引擎的统一性,可以采用 native 的包下载机制
游戏也支持资源更新,也可考虑将 js 文件作为资源更新
热更模式一般会支持 diff 更新、强更、非强更,这些都有比较成熟的框架,这里就不细述了
在实际的业务开发工程中,仅仅靠 ReactNative 提供的基础 API 和组件是不够的,比如网络请求,大部分客户端都会有网关,标准的 API 基本无法满足要求,这里就涉及到要封装自己的 API 和组件的问题:
基于 ReactNative 框架提供的 ReactBaseJavaModule,完成对一些公共 API 的封装
基于 ReactNative 提供的 ViewManager 框架,扩展一些自定义的原生端组件
但似乎这些还是不够,因为我们是在游戏环境中开发,实际上游戏中或者游戏开发者也需要注入一些 API 到 ReactNative 中,供业务使用、扩展,而上述的 ReactNative 的组件和 API 架构,对于不熟悉架构的同学来说,会有相当大的学习成本,所以我们基于 ReactNative,提出了 CommonModule 的架构:
不依赖 ReactNative SDK,采用系统标准的数据结构和 interface 实现
提供标准的注册 API,将这些 interface 注入到 CommonModuleManager
-
初始化 ReactPackge 时,会根据 CommonModule 生成对应的 ReactBaseJavaModule,并完成注册
从架构图中我们可以看到,基本覆盖了游戏活动中需要用到各种 API 及各种自定义场景:
沉浸式原生体验,与游戏页面活动完美融合
快速、完善的接入、开发、验收体验
模版化的页面搭建,跨平台运行
-
业务活动热更上线,随时、动态、不发版上线
随着版本不断迭代完善,基本具有大量上线游戏的能力,随着游戏业务越来越多,在不同的游戏环境中,也碰到不少问题,这也从侧面体现出了游戏场景和架构的复杂性,主要核心问题还是在于 ReactNative 的沉浸式体验、启动性能、内存、渲染性能问题等,似乎这些问题也是 ReactNative 的通病,为了解决这些问题,我们开始专项优化。
整体页面渲染显示前,需要首先加载加载初始化 React Native Core Bridge,主要包含 ReactNative 的运行环境、UI 和 API 组件功能等,然后才能运行业务的 JS,执行 render 绘制 UI,完成后,React Native 才能将 JS 的组件渲染成原生的组件。因页面的加载流程是固定不变的,所以我们可以采用了提前预加载 Core bridge 的方案来提升加载性能,当游戏营销页面启动前,预先加载好原生端 bridge,这样在打开业务是指需要运行前端 JS 代码渲染,设计思路上我们也根据业务场景设计了模式:
预加载业务包:提前加载好完整的业务包到内存,生成并缓存 ReactInstanceManager 对象,在业务启动时,从内存缓存中获取该对象,并直接运行绑定 rootview,经过改造,该方案能提升整体的打开速度 30%-50% 左右,游戏环境下,手机设备基本都达到秒开,模拟器设备在 2s 内,但这种通过内存换取速度的方法,在业务量大后,很明显是不可取的,所以整包预加载的局限性比较强。
Common 包预加载:针对全包预加载的局限性,我们提出了分包方案,预加载 common 包,研究发现 ReactNative 打包生成的业务包其实有两部分内容,一部分是公共的基础组件、API 包,统称 common 包,一部分是业务的核心逻辑包。改造打包方式,可以把原有的全包模式分离成 common+bussiness,在多业务包模式下,可以共享统一的 common 包,在打开业务前,我们会优先预加载 common 包,并缓存对应的 ReactInstanceManager 对象,用户触发打开业务后,再加载 bussiness 包,该方案相对于全包预加载性能略差,但比不预加载能提升 15%-20% 左右,同时支持多业务运行环境,具体思路可以参考开源项目 react-native-multibundler
从时序运行上,除了 core bridge 的初始化外,js 运行到页面显示,实际上也占用了不少时间,在预加载 core bridge 上,我们更近一步,支持了预加载 rootview,提前将要渲染页面的 rootview 运行起来缓存在内存,当然这里加载的还是基础模块,在业务打开时,路由触发展示页面即可,可以做到页面无延时打开,但是对内存的开销,比预加载 core bridge 更高。
当然上述方案都是通过内存换性能,不同的加载方式都做到了云控,随时切换、关闭。除了这些方案外同样还有其他方式能优化启动性能:
Lazy module,将引擎自定义的 API Native Module 改造成懒加载方式,整体性能提升在 5% 左右。
业务代码做到按需 require,不需要展示的部分,采用 lazy require,提升页面的显示、渲染速度。
裁剪业务包,将业务代码没有用到 React 的 module、API、组件删除,减少业务包大小来提升启动性能。
分包方案,从测试数据来看,业务包越小,启动性能越好,包大小无法减小后,将业务包按照路由拆分为子包,也能立竿见影的解决启动速度问题。将业务包按照路由页面和功能分成多个子的业务子包,让首屏业务逻辑包变小,做到按需加载其他业务包,提升首页启动性能。
这些方案都从引擎加载的角度解决了启动性能慢,做到了按需加载,整体性能达到了最优化。但是在游戏中,业务页面的显示还是太依赖服务度请求来完成页面的渲染,所以在逐步优化后,发现网络请求对于页面的显示也占了很大一部分,为了进一步提升首屏显示,我们增加了网络请求预拉取、图片预缓冲方案:
网络预拉取,对于一些对首屏显示影响较大的网络请求,在引擎加载后,在合适时机从云控平台获取后,根据配置拉取并缓存到内存,打开业务后,优先从缓存中读取网络接口内容并显示。
图片预缓存,对于一些加载较慢的图片,将链接配置到云端后,在合适时机提前预加载到 Fresco 内存,页面打开后 Fresco 会从缓存中直接读取 bitmap
除了这些方案外,替换 JSC 引擎到 hermes,也能很好的解决启动性能问题,后面章节会重点介绍。
以上所有的优化更多是针对启动性能的优化设计,也是业内用于提升加载性能的方案,在游戏的复杂环境下,除了性能外,对于内存的要求也是很严格的,游戏启动后,本身对于内存的消耗就比一般的原生 app 高,所以在内存使用上会更精确和严格,那 ReactNative 是怎么优化内存的:
分包方案,分包方案除了在启动速度上有很大优化外,实现了按需加载,对于内存来说也做到了最优化。
字体加载,因游戏字体库无法和原生字体共享,导致在 ReactNative 页面使用字体会大大增加整体的内存,为了降低字体的内存,我们支持了字体的裁剪方案,按需打入字体,删掉一些生僻的字,大大降低了字体包的大小。另外字体文件对于业务包大小影响也比较大,我们支持字体的动态下发和加载。
图片优化,除了业务 UI 和 JS 本身占用的内存外,内存上占用比较大的是图片,而且图片有缓存,为了降低图片的内存消耗,我们支持了 webp、gif 等格式的图片,有损压缩,同时对于网络图片做到了按手机分辨率下发。另外提供 API 到前端业务,按需清理不使用的图片,及时释放内存,并控制图片缓存大小。
除了内存、启动性能外,在游戏中的渲染性能也至关重要,ReactNative 受限于游戏内的内存和 CPU 负载高,同等复杂度页面,表现不如原生 App。为了能优化这些指标,我们对 ReactNative 的渲染流程做了分析和优化,支持静止状态下帧率基本达到了 60fps,大致优化如下:
ReactNative 是前端事件驱动原生 UI 渲染的,所以设计上 ReactNative 会在 Frame Buffer 每一帧绘画结束后的回调在 UI 线程中处理 UI 更新,即使没有更新的情况下也会空运转,这在 UI 线程负载本就较高的游戏中,增加了 UI 的负担
动画、点击事件都是同样的设计,会不断的有任务空转占用 UI 线程,增加了 UI 线程每次绘制的时间
解决这个问题,就是要支持资源的按需加载,我们将动画、UI 更新事件放到了消息 map,每次一帧渲染完成后,我们会检查 map 消息,是否有需要处理的消息,没有后续就不再在一帧渲染完成后调度 UI 线程,当用户触发了动画或者 UI 更新,会发送消息 map,并注册帧渲染的 callback,在 callback 中检查 map 消息更新 UI
另外 ReactNative 采用的是原生 UI 渲染,在打开硬件加速的情况,整体渲染性能表现比较高,但是在游戏环境中,大部分游戏都是不开硬件加速的(自渲染组件和引擎的缘故),对于比较复杂的 ReactNative UI,更新 UI 时整体 FPS 会偏低,UI 响应会比较慢,特别是在模拟器(限制 fps30)的情况下,渲染性能更加差强人意。在复杂交互的情况,要怎么提升性能?
简单的 UI 设计,没有大图背景的情况下,不开硬件加速,整体渲染性还不算差,但有大的背景情况下,UI 性能表现尤其差,所以解决渲染问题,其实更多的是要解决大图渲染的问题
ReactNative 提供了 renderToHardwareTextureAndroid 来用 native 内存换渲染的性能,导致的问题是内存消耗较高,对于图片不是太多、内存限制不是很严格的业务,可以采用该方式提升性能
对于大量使用图片的业务,我们设计一套采用 opengl 渲染方式的组件,支持纹理图 (比较通用的 etc1),从内存和渲染性能上,明显都得到了很大的提升,但这种模式依赖硬件加速,所以一般是在 Dialog 窗口模式中使用,具体的实现原理,大家可以关注前端之巅,后面会详细和大家分享核心示例代码。
/* GLES20.glCompressedTexImage2D(target, 0, ETC1.ETC1_RGB8_OES,
bitmap.getWidth(), bitmap.getHeight(), 0,
etc1tex.getData().capacity(), etc1tex.getData());*//*
和高人聊,从书中学,在事上练。如果 9 月 1 日(周三)晚上 8 点你没事,推荐你看看这个直播,小盖会和之前华为的首席架构师梁宇宁连麦,聊创业、聊认知、聊基础软件、聊梦想。我自己是特别感兴趣,大家想看的话,点击红色按钮就能预约。