我在GMTC上分享的Cocos跨平台应用开发心得。
2018 年 6 月 GMTC 全球移动技术大会在北京举办,大会旨在通过聚焦前沿技术与实践经验帮助参会者了解移动开发、前端领域最新的技术趋势与最佳实践。
我作为讲师嘉宾也参与了这次大会,分享了《基于 Cocos 的高性能跨平台开发方案》。
如下是整理后的演讲正文。
大概从去年九月份开始,我们选择使用Cocos来作为我们一款产品 ABCmouse 的跨平台应用开发方案,在这个过程中,我们做了一系列的优化,也踩了一些坑,本文将对这个过程做一个回顾和总结。
本文的内容主要分三块来讲。首先简单介绍一下项目背景,接下来具体介绍下我们的实践过程,分享一些经验。最后给出新的开发方案和以前的效果对比。
附图 1 现场观众首先介绍一下我们的产品,ABCmouse 是美国知名的儿童英语在线学习领导品牌,在美国有超过百万家庭在使用,也获得了7万多个教师的推荐。
这个应用采用的是典型的 Hybrid App 跨平台开发方案,里头基本全是 H5 的页面。附图 2 Apollo GraphQL的开发者 Sashko Stuballo 也来了
Hybrid App 最大的问题就是性能问题,用户经常会在页面加载上等待非常多时间。
我们统计了 ABCmouse 各个场景的平均加载耗时,发现平均都要花费大约三到四秒的时间。漫长的等待时间也对用户的学习积极性带来影响。
从去年九月份开始,团队与 ABCmouse 的研发公司 Age of Learning 公司开展了战略合作,我们希望能够开发出一款针对中国儿童的英语学习应用——我们称之为 ABCmouse 腾讯版。我们希望它能提供更符合中国儿童使用习惯的学习路径,并在里头融入腾讯的社交元素,从而带动儿童外语学习的积极性。附图 3 在GMTC上遇到很多老朋友
从技术上,我们希望新版的 ABCmouse 能够在表现力、性能、效率和社交四大方面都能有更好的表现(这里的表现力指的是产品的界面和交互,能够做到更吸引中国的小朋友)。
通过初期技术预研后,我们决定使用 Cocos 来改造这个项目:
在具体实践这一块,我准备分成架构篇、甜头篇、踩坑篇、优化篇四个部分来介绍。附图 4 新认识的一帮来自腾讯、Facebook、Twitter、UC、搜狗的小伙伴。我们开玩笑说互联网社交圈快凑齐了。学会一个新词儿,叫做“局气”。
一图胜千言。我们整个系统架构可以用这张图来概括。
我们自底向上看,最底层是 native 层,Cocos2d-x 开发框架,在这一层提供了对 JavaScriptCore、SpiderMonkey、V8、ChakraCore 等多种可选的 JS 执行引擎的封装。在这基础上又架设了一层 JSB ,主要起到桥接作用。我们的应用也在底层封装了多种基础能力,包括支持直出的webview、自定义的视频播放器、音频播放器、支付、推送等。
再往上是 JS 层,在这一层 Cocos 提供了丰富的开发组件和 API,我们也扩展了多种组件,包括一些通用的UI组件、一个多端通用的音频播放器、一个带缓存和内存回收功能的图片加载器、常驻节点、上报、日志等组件。有些组件是依赖 native 层的。
Cocos 层和 Native 层就通过 callStaticMethod 和 evalString 来完成互相调用。
有了这些基础后,再往上则可以开展具体的场景开发了。
为了帮助大家更好地理解 Cocos 的跨平台原理,我们可以拿 Cocos 的渲染原理和 React Native 做一个对比。
Cocos 的渲染原理是在 UI 线程将场景文件理解成场景树,然后交给 GL 线程渲染。也就是说,用户看到的大部分场景都是使用 OpenGL 或者 WebGL 绘制的,即使在不同的平台,也能够有完全相同的表现。
而 React Native 的渲染原理是将 JS/JSX 理解成 Virtual DOM,然后调用各自平台的 Widget 。由于不同的平台,底层的 Widget 表现是不同的,因此使用上可能会存在差异。这也是 React Native 为人诟病的一点。
采用 Cocos 作为我们的跨平台开发框架后,我们尝到了不少甜头。
首先是跨平台带来的便利。我们使用一套代码可以生成到安卓、iOS、Web、微信小游戏等多种平台,并且在多个端达到了高度一致的体验。在 React Native 上经常遇到的 UI 体验不一致的问题,在 Cocos 开发中基本没有遇到过。
由于Cocos支持构建小游戏版本的应用,所以我们的项目也提供了小游戏版本。上周末已经有很多爸爸在微信小游戏里收到了他们的孩子使用 ABCmouse 制作的贺卡。值得一提的是,小游戏版本是我们两个开发在花了一周左右的时间内移植完成的。这里头主要的移植工作在于接入微信小游戏的登录授权,接入 VideoPlayer 和 InnerAudioContext 以分别支持视频播放和音频播放。
第二个甜头是开发效率的提升。
首先,Cocos 提供了可视化的 Cocos Creator ,使用它来管理和构建工程非常轻松。
其次,设计萌妹子也能直接使用 Cocos Creator 编辑动效,输出动效资源给开发,提高协作效率。
另外,Cocos Creator 支持直接在浏览器中预览调试场景,节省了大把构建编译的耗时。
第三个甜头是热更新带来的便利。
附图 6 颐和园摆渡Cocos 同时支持脚本和资源的热更新,这给我们修复线上问题、发布运营活动带来了很大便利。
此外,Cocos 的热更新可以做到 hot reload,无需冷重启,很好的保证了用户的体验。并且,Cocos 的热更新支持高度可定制,可以很方便的定制满足业务需要的热更新流程。
第四个甜头是 Cocos 提供的强大的社区支持。Cocos 的开发团队来自中国,有着非常活跃的中文社区。
另外,使用 Cocos 开发小游戏也成了最主要的方式,可见 Cocos 的受欢迎程度,也侧面证明了这套开发框架的生命力。
跨平台开发虽然方便,但是在一些具体的实践中难免也会踩到坑。
首先,Cocos 主要是面向游戏开发的,要使用它来开发应用,少不了需要开发一些 UI 组件。因此,我们在 Cocos 层开发了一系列的通用 UI 组件,包括对话框、选择器、表单、按钮、toast、loading 等组件,这些组件遵循一套规范化的接口标准,使用起来非常便捷灵活。
开发完 UI 组件后,我们发现这些组件的加载也存在问题。和原生应用开发不同,这些UI组件本质上都是挂载在场景里头的节点,如果没有调度的话,可能存在同时弹出多种弹窗和对话框的情况,整个场景就会变得很混乱。
为了解决这种问题,我们写了一个针对 Cocos 的弹窗调度器,统一由它来调度弹窗,避免了弹窗的混乱。
我们接下来遇到的另一个坑是 VideoPlayer 的置顶问题。
前面提到,Cocos 的场景是在 GL 上绘制的。例如,对于 Android 平台,Cocos 开启了一个 OpenGL 的 SurfaceView 来进行场景绘制。而这个 GLSurfaceView 不能直接支持渲染视频,所以,Cocos 提供了一个 VideoPlayer 组件用于播放视频。这个 VideoPlayer 是独立且置顶的一层。
这带来的一个问题是:无法在视频上绘制 UI 。
比如我们希望视频播放器里头能加上我们自定义的按钮、进度条,如果是直接在 Cocos 层对 VideoPlayer 进行封装的话,会发现这些 UI 元素会被视频本身遮盖,达不到定制界面的目的。
最终我们放弃了直接使用 Cocos 提供的 VideoPlayer 组件,而是在底层为各个端开发视频播放器,并各自实现界面的定制。
视频播放问题解决了,我们又遇到了音频播放的问题。
由于应用中有非常多的音乐、音效、语音,为了减小包大小,大部分的语音素材放在 CDN 上,需要的时候才从 CDN 上拉取播放。少部分常见的音效会直接打进应用包中。而 Cocos 自带的 AudioEngine 组件在 Native 端只支持本地资源的播放。因此,我们又封装了一个跨平台的音频播放器,可以自动根据指定的音频路径决定使用播放方式:
由于对外的接口只有一套,开发者无需考虑具体的平台和底层播放器的选择。并且可以使用同样的接口来统一管理不同的音频。
最后我们遇到的一个比较严重的问题是 local reference table overflow error 问题。
为了复用 Native 端的能力,我们在 Cocos 层大量地使用反射机制来调用 Native 端提供的方法。然而,我们经常会遇到 local reference table overflow error 错误导致的界面卡死问题。
1 | A/art: art/runtime/indirect_reference_table.cc:138] JNI ERROR (app bug): local reference table overflow (max=512) |
最初,我们怀疑是反射调用使用得太频繁导致。因此,我们对诸如打 log、事件上报等 Native 方法进行了频率限制,例如使用缓冲的方法将多个 log 合并后再打印。
然而,虽然这个做法减少了界面卡死的发生,但依然没有彻底杜绝问题的再次出现,就像是一个定时炸弹一样,威胁着我们应用的稳定性。
通过阅读引擎的代码,我们发现 Cocos 的引擎在反射阶段处理字符串参数时,使用了 NewStringUTF()
方法将其转换为 JNI 层的字符串,然而在调用执行完成后并没有相应地使用 DeleteLocalRef()
释放该字符串的引用,从而导致了引用表的溢出。
1 | static bool JavaScriptJavaBridge_callStaticMethod(se::State& s) |
了解到这个原因后,我们给 Cocos 的引擎提交了一个 pull request,修复了这个问题。
虽然 Cocos 比起纯 Hybrid 的方案在性能上已经占据了优势,但是比起 native 还是有一些差距的。下面就说说我们在开发过程中尝试过的一些优化,让我们的应用做到接近原生的体验。
官方 ScrollView 组件需要配合 layout 组件,当一次加载大量的子节点组件,或者分帧加载单个子节点组件时,初始化 ScrollView 节点视图会比较慢,在加载完成前存在拖动掉帧的问题。另外,一次性加载所有节点,也会导致内存资源的浪费。
下图这个场景是 ABCmouse 里的二级资源页,由于一次性加载了太多子节点,当屏幕滚动时,帧率降到了 8 fps 左右,给人的感受是非常卡顿。
我们对 ScrollView 进行了重写,基本的优化思路是:一次仅加载页面可容纳的少量数目子节点。并在滚动过程中,回收不可视的子节点组件并重用。
具体来说,ScrollView 大多数情况下表现为列表组件和宫格组件,以列表组件为例,可以根据子节点数目和子节点大小,计算出整个 ScrollView 内容的宽高,同时计算出屏幕可视区域最多可以容纳的子节点行数 rows,加载时仅加载 rows + 2 个子节点组件,其中添加的 2 个字节点组件作为滚动回收缓冲。
下图是对上述思路的图例。当手势向上,内容往下滚动时,一旦最上排的子节点组件不可视,就立马将它们回收掉并将其重用于将要渲染的子节点组件中。
这么做的优点在于:一次仅加载页面可容纳的少量数目子节点,并且逐帧加载,能极大提升展示和滚动性能,另外大大减少了内存占用。
经过优化后,不管二级资源页场景里有多少元素需要展示,整体的帧率都维持在 60 fps 左右,非常流畅。
内存占用过高也是 Cocos 开发过程中很容易遇到的问题。如果没有优化好内存占用,很可能就会引发黑屏或者 OOM。
要优化内存占用,有几个思路。第一个思路是把内存消耗大以及没有回收的元凶先找出来对症下药。
于是,我们仿照 Cocos 的监视器也写了一个内存监视器,利用它来找出疑似存在内存泄漏的场景。
对于每一个场景,我们也对每个节点的内存占用做了一个排名,找出靠前的,分析是否合理,并进行针对性的优化。比如把原图缩小,把无需透明像素的png图转换成JPG图,等等。
第二个思路是为图片渲染开启纹理压缩,从而大幅度降低图片渲染的内存占用。Cocos 提供了 ETC1、PVR 等几种纹理压缩方案,其中,PVR 兼容性最好,内存消耗也最低,但是质量较差;ETC1 不支持 iOS 的低端机型,质量也较差。我们又对 Cocos2d-x 进行扩展,增加了 ETC2 纹理压缩,这种方案的优势比起 ETC1 而言,压缩质量更好。
下图可以看到 ETC2 和 PVR 压缩质量和内存占用的直观对比。 对比原图,我们可以看出 ETC2 的压缩结果与原图相差不大,但内存减少了 75% 。而 PVR 的压缩结果相比 ETC2 言在细节方面少了很多,内存则减少了 87.5% 。
针对兼容性问题,我们设计了一种混合纹理压缩方案:对于高质量要求的纹理,如果该机型能支持ETC2,就使用ETC2纹理压缩;如果不支持,就将该纹理进行大小减半压缩;对于低质量要求的纹理,使用兼容性好的PVR纹理压缩。单图渲染的内存消耗可以降低接近 75%~87.5%。
纹理压缩是一项耗时的任务,所以我们把这项任务放在项目构建完后进行,而不是在客户端运行的时候才动态压缩。
我们编写了一个扩展工具,在构建完成后自动进行纹理压缩任务。后面我们发现这个工具压缩完一遍纹理要花费大概3分钟的时间,我们又改进成了增量压缩的方式,一次压缩任务缩短到10秒左右。
每一帧的渲染耗时直接影响到整个应用的性能,而和渲染耗时相关的操作是 drawcall 。
什么是 drawcall 呢?我们可以看这张图来了解一下。在一帧的渲染过程中,场景会先被解析成场景树。场景树的每一个节点依次加入渲染队列中等待交付 GPU 渲染。GPU 接收渲染指令并执行的操作就叫做一次 drawcall。在一帧里头,drawcall 越少,性能当然就越好。
Cocos 针对 drawcall 优化已经提供了一种自动合并技术:比如,上图中的渲染指令 1、2 来自贴图 A,3、4 来自贴图 B ,5、6、7 来自贴图 C,这些指令会被分别合并优化,最终只产生 3 次 drawcall。我们要做的就是利用好这个自动合并技术。
首先可以找出浪费 drawcall 的节点对症下药。一般可以通过把节点的 active 属性设为 false 看看 drawcall 有没有大量减少来判断。
接下来我们可以利用好 Cocos 的合并技术。
目前这套优化方案还不能满足动态资源和动画的优化,我们也期待 Cocos 能够把 batching 技术做得更完善。
另外,还有另外一个需要注意的地方:小心避免跨层切换合图。Cocos 是按照节点层级顺序依次提交渲染指令的,如果不注重层级顺序,可能会导致贴图的切换从而浪费不必要的 drawcall 。
例如,下图中的渲染指令 4 使用的是贴图 C,直接卡在了渲染指令 3 和 5 之间,导致贴图 B 的渲染指令没法合并,从而浪费了多余的 drawcall。通过调整节点层级可以避免这个问题。
我们的应用里头目前依然存在一些原来的版本遗留下来的 H5 页面构成的场景,对于这些 H5 页面,我们也使用了一些比较常规的 Hybrid 优化技术,来达到首屏直出的要求。
因为已经有很多现有的优化方案了,所以这一块我并不打算细讲。简单为大家罗列几个技术点吧:
通过这一系列的优化,我们的应用里头的 H5 页面的加载耗时也能够控制在 1 秒以内。
最后我们来看一下整体的改造效果。
项目整体的 Cocos 化率目前占到了 56%,剩下的还有 40% 的 H5 的页面(主要是一些小游戏),还有像视频这种 native 场景。
对比原来的场景启动耗时,经过一系列改造和优化后的场景都能控制在 1 秒内启动。
直接看数据不够直观,我们可以看一下原来加载耗时最长的一个场景,经过改造后做到了秒开。
而腾讯版本的包大小也比原来的版本小了 64% 。
欢迎扫码体验新版本的 ABCmouse :