React Native 按需加载 手 Q 狼人杀探索之路

导语:最近特别火的狼人杀和最近特别火的 React Native 会擦出什么样的火花呢?本文和您一同探讨 RN 性能优化的现实场景。

项目简介

狼人杀游戏是多人实时性游戏,对流畅度等性能都有要求。作为大型游戏,无论从代码规模和迭代速度来看,手 Q 的安装包和版本迭代速度都无法用 native 来承载这样的游戏。从而 React Native 成为了比较好的选择。

手 Q React Native 简介

在手 Q 目前使用的 React Native 版本是 0.15 版本。下面的数据分析都是基于手 QRN0.15 版本进行的分析数据。

问题分析

开发过 React Native 的同学,大体都对白屏界面有所了解。作为 RN 原生自带功能,基本上每个使用 RN 的业务都在优化这一阶段。通过对狼人杀的测试来看,首次从 RN 启动到渲染,耗时基本有 1.7s 左右。而这些耗时数据还是在 iPhone6s 中测试得出,可想低端局的情况可能会更加糟糕。

分析性能

工欲善其事必先利其器,要分析其耗时。还得从源头着手,根据常规做法,都会将 React Native 打包的 js 拆分成 Base Bundle 和业务 Bundle。从上图,RN 加载流程来看,加载 BaseBundle 与业务 Bundle 的耗时是可以有优化空间的。

优化的方案和大多数人的思路一样,只需在业务启动前预加载 BaseBundle 与业务 Bundle 即可达到优化时间的效果。

目前所遇到的瓶颈

在优化的开始,我们可能一直把精力放在 BaseBundle 中,认为 BaseBundle 是 RN 的公共库,体积肯定不小。但是从数据来看,我们的狼人杀业务 Bundle 已经是 1.8MB(纯 js 代码,不包括资源文件)而 BaseBundle 只有 918KB,已经是两倍的体量。现在还只是狼人杀业务的初期,随着业务的快速迭代,业务 Bundle 只会更快的增加。而过大的业务 Bundle 所导致的加载时间也会加长。

可能有同学会说,这不是有预加载嘛。我承认,预加载确实解决了绝大部分业务 Bundle 的加载耗时。但是,并不是每次预加载都可以刚刚好预加载好业务 Bundle。虽然业务 Bundle 加载耗时变长,预加载好的几率就会慢慢变低。

而这不是最关键的行为,最关键的是内存的消耗,我们来看一张图。

从上图就可以看出,仅仅是 BaseBundle,仅仅只是在内存中展开,还没有到运行。这个时候内存消耗已经达到了 6MB。而整个狼人杀 RN 渲染起来,则消耗了 20MB 以上的内存。而这还没有包括业务使用的内存。在手 Q 中,内存的消耗是巨大的,而留给狼人杀使用的内存其实已经很少了。从这里可以看出,内存的优化好像更加迫在眉睫。

React Native 按需加载

React Native 的思路是在业务运行之前,将所有 js 代码在 JavaScriptContext 中展开。这个逻辑本身没有什么问题。但是,我们需要改造成按需加载。按需加载的本质就是将不是关键路径的业务 RN 拆分开,变成插件中的插件。当业务触发到此逻辑的时候,再去将 js 代码动态展开。达到动态执行的目的。

而我们想要达成按需加载的效果,可能会面临着三个挑战。

1.js 在动态运行的时候,代码注入的问题。

2.js 模块与模块之间相互引用的问题。

3.打包工具改造的问题。我们来依次看下这三个问题。

动态注入

  1. 从 JS 层面分析,想要达到 JS 代码的动态注入。必须要和运行的 JS 在相同运用域下面。我们通过分析打包后的 JS 代码得知,必须要在_d(verboseName 模块名称)作用域下面。
  2. 从 native 层面分析,想要达到 JS 代码的动态注入。则必须要拿到 JavaScriptCore 中的 JSContext。
 - (void)enqueueApplicationScript:(NSData *)script
                                 url:(NSURL *)url
                          onComplete:(RCTJavaScriptCompleteBlock)onComplete
    {
      RCTAssert(onComplete != nil, @"onComplete block passed in should be non-nil");

      RCTProfileBeginFlowEvent();
      [_javaScriptExecutor executeApplicationScript:script sourceURL:url onComplete:^(NSError *scriptLoadError) {
        RCTProfileEndFlowEvent();
        RCTAssertJSThread();

        if (scriptLoadError) {
          onComplete(scriptLoadError);
          return;
        }

        RCTProfileBeginEvent(0, @"FetchApplicationScriptCallbacks", nil);
        [_javaScriptExecutor executeJSCall:@"BatchedBridge"
                                    method:@"flushedQueue"
                                 arguments:@[]
                                  callback:^(id json, NSError *error)
         {
           RCTProfileEndEvent(0, @"js_call,init", @{
             @"json": RCTNullIfNil(json),
             @"error": RCTNullIfNil(error),
           });

           [self handleBuffer:json batchEnded:YES];

           onComplete(error);
         }];
      }];
    }

而上述函数则是比较关键的执行函数,需将此函数从 RN 内核中暴露出来。

模块相互引用

如果要实现按需加载,则主逻辑 JS 中包含的其他插件 JS 代码,则不能在主逻辑 JS 展开的时候运行。我们想要实现这样的效果,则有两个方案可以实施(二选一即可)。

1.跟进 JS 动态执行的原理,我们可以将主业务 JS A 中引用插件 B 的实现函数使用空方法_d(verboseName 业务名{空}) 代替。然后等到运行时,再注入相同的方法(_d(verboseName 业务名{真实方法}) )。等业务触发了插件 B 逻辑的时候,真正运行的是刚刚注入的 B 真实方法。

2.懒 require

我们平常的业务代码基本是这样引入另外一个模块的

import GameWait from '../gameWait/gameWait';
import NetOperation from '../netOperation/NetOperation';
import GameNight from '../gameOperation/GameNight';
import GameDay from '../gameOperation/GameDay';
import GameState from '../gameState/GameState';
import {GameStateEnum} from '../gameState/GameEnum';

最终打包工具会把他打包成这样的

var _gameWaitGameWait = require('react-
                native/Werewolf.zip.dir/module/gameWait/gameWait.js');
var _gameWaitGameWait2 = _interopRequireDefault(_gameWaitGameWait);
var _netOperationNetOperation = require(
                'react-native/Werewolf.zip.dir/module/netOperation/NetOperation.js');
var _netOperationNetOperation2 = _ 
                interopRequireDefault(_netOperationNetOperation);
var _gameOperationGameNight = require(
                'react-native/Werewolf.zip.dir/module/gameOperation/GameNight.js');
var _gameOperationGameNight2 = _ 
                interopRequireDefault(_gameOperationGameNight);
var _gameOperationGameDay = require(
                'react-native/Werewolf.zip.dir/module/gameOperation/GameDay.js');
var _gameOperationGameDay2 = _ 
                interopRequireDefault(_gameOperationGameDay);

而这些在业务函数体中,会在编译的时候去找寻此文件是否存在。而这样会报错。

正确的做法是在业务逻辑中,再去 require 其模块。

if (this.state.nowGameStateEnum === GameStateEnum.game_start) {
                var GameWait = require('../gameWait/gameWait');
                this._changeToDay();
                return (
                        <GameWait
                                onClosePage={this._onCloseWait.bind(this)}
                         />
                 );
}

在打包工具中展示则是这样的效果。

if (this.state.nowGameStateEnum === _gameStateGameEnum.GameStateEnum.game_start) {
            var GameWait = require('react-native/Werewolf.zip.dir/module/gameWait/gameWait.js');
            this._changeToDay();
            return (
                 _React2.default.createElement(GameWait, {
                 onClosePage: this._onCloseWait.bind(this)
            }));
}

这样就实现了 require 的懒加载。实现了先运行主业务,再动态运行插件业务。

打包工具改造

resolve(ReactPackager.createClientFor(options).then(client => {
    log('Created ReactPackager');
    return client.buildBundle(requestOpts)
        .then(outputBundle => {
            log('Closing client');
            client.close();
            return outputBundle;
        })
        .then(outputBundle => deleteBaseBundle(outputBundle))
        .then(outputBundle => processBundle(outputBundle, !args.dev))
        .then(outputBundle => saveBundleAndMap(
            outputBundle,
            args.platform,
            args['bundle-output'],
            args['bundle-encoding'],
            args['sourcemap-output'],
            args['assets-dest']
        ));
}));

打包工具的改造,重要的是将业务 Bundle 拆分成不同的插件。这个可以仿照以前 BaseBundle 与业务 Bundle 拆分的做法。

按需加载小结

RN 按需加载,只是一个思路。当业务逐渐庞大的时候,相信大家都会面临这个问题。不过,安卓则比较幸运一点。RN 有一个原生的 unbundle 命令可以将业务 Bundle 以每个业务一个 js 文件。不过 unbundle 命令不能打出 iOS 平台的,解释是因为 iOS 上面对小文件有 IO 性能的瓶颈。不过,这里我就没有亲自测试过了。不过个人感觉,真正做到按需加载,就得根据业务做不同的打包,不易过大,也不易过小。平衡才是王道。

后续

大家从上文耗时表可以了解到,预加载和按需加载,只是优化了启动耗时的一部分。而 RN 在执行 RunApplication 到 RNComponent 展示出,中间还有 800ms 的耗时。这部分目前来看,不管是狼人杀大型业务的启动,还是 demo 业务的启动,都会有这 800ms 的耗时,应该与业务大小无关。从时间表来看,是 js 在大量绘制 ReactNativeBaseComponent。所以,这部分应该也有优化的空间。后续有进展再和大家分享。

后面会分享更多有关 React Native 相关的内容,希望和大家共同学习,成长。

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

孟磊的专栏

1 篇文章1 人订阅

我来说两句

3 条评论
登录 后参与评论

相关文章

来自专栏WeTest质量开放平台团队的专栏

你知道android的MessageQueue.IdleHandler吗?

商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。

561
来自专栏JackieZheng

如何写出好代码

如何写出好代码 这个题目把我自己都看傻了,因为仔细想想,这不是一个命题,是对代码的思考,对细节的推敲和打磨。写好代码是一门学问,还是一种修行。 以前是公众号(...

1975
来自专栏腾讯Bugly的专栏

iOS App 启动性能优化

导语 本文介绍了如何优化 iOS App 的启动性能,分为四个部分: 第一部分科普了一些和App启动性能相关的前置知识 第二部分主要讲如何定制启动性能的优化目标...

3756
来自专栏阮一峰的网络日志

都柏林核心(Dublin Core)

在上一篇日志中,我介绍了元数据(MetaData),并且说只要有一个集合,就可以定义一套元数据。 这样一来,很自然的,我们就会想到一个问题:有没有可能定义一套通...

2657
来自专栏WeTest质量开放平台团队的专栏

Android性能优化来龙去脉总结

一款app除了要有令人惊叹的功能和令人发指交互之外,在性能上也应该追求丝滑的要求,这样才能更好地提高用户体验。

70714
来自专栏xingoo, 一个梦想做发明家的程序员

【设计模式】——工厂方法FactoryMethod

  前言:【模式总览】——————————by xingoo   模式意图   工厂方法在MVC中应用的很广泛。   工厂方法意在分离产品与创建的两个层次,使用...

1839
来自专栏极乐技术社区

微信小游戏初体验

前言 上周【跳一跳】小游戏刷遍了朋友圈,也代表了微信小程序拥有了搭载游戏的功能(早该往这方面发展了,这才是应该有的形态嘛)。作为一个前端er,我的大刀早已经饥渴...

8737
来自专栏黄奕坤的专栏

火焰图性能调优记

最近手头开发维护的一个辅助小工具经常接到投诉可用性问题, 于是抽时间定位了下, 一看吓一跳, 起初不起眼的一个组件的日志量直接翻了两个数量级。 这怎么吃得消 !

5702
来自专栏牛客网

贝壳前端面经

当一个构造函数的原型上有一个基本类型的属性a,new 两个实例b和c,改变b.a , c.a是否会跟着变?不变。如果原型上的属性是个数组,改变b实例上的这个数组...

531
来自专栏SHERlocked93的前端小站

JS 桥接模式

桥接模式(Bridge)将抽象部分与它的实现部分分离,使它们都可以独立地变化。 其实就是函数的封装,比如要对某个DOM元素添加color和backgroundC...

231

扫码关注云+社区