客户端跨端框架已经发展了很多年了,最近比较流行的小程序、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 页面
-
支持热更新服务,支持灵活、快速上线业务
因整体原理不复杂,很多文章也做了很详细的介绍,下面就简单说在游戏中的一些差异和思路。
1. 工程化
-
在游戏中引入原生端的组件库都是以 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 包关闭入口。
2.ReactNative 页面容器
上面也介绍了游戏都是采用自绘引擎,所有交互都是在一个 Activity 页面中,任何新的页面的跳转都会导致游戏 pause 或者 stop,打断其沉浸式体验,而 ReactNative 是在 ReactRootView 中承载所有的 UI 渲染的,所以容器的设计思路考虑了以下几种方案:
-
将 ReactRootView 加载到游戏 Activity 的 Rootview 中,作为一个子 View,关闭页面时,从 Rootview 阶段移除。
-
将 ReactRootView 封装到系统的 Dialog 窗口中,这样既可以做到独立窗口加载到游戏中,也不打断游戏进程。
-
有了页面容器后,跳转不同功能的页面,就需要制定一个协议了,通过协议完成页面数据和功能的传递,可以参考开源的 Router 协议等。
-
活动页面多了后,就涉及到页面之间跳转和窗口管理了,所以需要一套完善的窗口管理 API,并通过 Unity api,让游戏可以快速通过 pop 协议或者指定 id 关闭页面。
以下是设计完成后大概能力介绍:
3. 热更新能力
热更新能力是 ReactNative 最基础的能力,因引擎支持从 asset 目录或者磁盘分区中加载 JS 文件,解决好加载路径和包下载问题,就能很好的支持热更新能力,其中包更新:
-
考虑引擎的统一性,可以采用 native 的包下载机制
-
游戏也支持资源更新,也可考虑将 js 文件作为资源更新
-
热更模式一般会支持 diff 更新、强更、非强更,这些都有比较成熟的框架,这里就不细述了
4.Common API 设计
在实际的业务开发工程中,仅仅靠 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 的沉浸式体验、启动性能、内存、渲染性能问题等,似乎这些问题也是 ReactNative 的通病,为了解决这些问题,我们开始专项优化。
1. 启动性能优化
整体页面渲染显示前,需要首先加载加载初始化 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,也能很好的解决启动性能问题,后面章节会重点介绍。
2. 内存优化 :
以上所有的优化更多是针对启动性能的优化设计,也是业内用于提升加载性能的方案,在游戏的复杂环境下,除了性能外,对于内存的要求也是很严格的,游戏启动后,本身对于内存的消耗就比一般的原生 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 点你没事,推荐你看看这个直播,小盖会和之前华为的首席架构师梁宇宁连麦,聊创业、聊认知、聊基础软件、聊梦想。我自己是特别感兴趣,大家想看的话,点击红色按钮就能预约。
文章评论