返回 登录
0

QQ音乐高级工程师袁聪:大胆尝试,展现不一样的React Native

责编:陈秋歌,关注前端开发领域,寻求报道或者投稿请发邮件chenqg#csdn.net。
欢迎加入“CSDN前端开发者”微信群,参与热点、难点技术交流。请加群主微信「Rachel_qg」,申请入群,务必注明「公司+职位」。另可申请加入CSDN前端开发QQ群:465281214。

SDCC 2016 中国软件开发者大会将于11月18日在北京京都信苑饭店召开,本届大会汇集100+讲师,设置了12大专题论坛。本文作者QQ音乐&全民K歌高级工程师袁聪,已受邀担任SDCC 2016前端开发专题演讲嘉宾,分享全民K歌React Native最佳实践。

以下内容为会前袁聪给大家带来的营养小餐,技术大餐即将在SDCC 2016软件开发者大会上呈现。


React Native(以下用RN简称代替)的开源是App开发的一个里程碑式的事件,它为App开发提供了新的开发模式和思路。RN既有传统Hybrid框架的优势,又提供了统一的Native体验,吸引着越来越多的项目去实践。

全民K歌去年十月份完成了RN的接入实践,至此已经有一年多了。现在的我们已经不再满足简单的接入和优化,我们开始大胆尝试,做一点不一样的RN,今天我们来就聊一聊全民K歌不一样的RN。

这里先给大家上两份小菜,大餐在11.18号下午SDCC前端开发专题会场,不见不散。

P.S. RN的代码已经被分析来分析去太多遍了,相关资料很多,为避免冗长枯燥,本文只提示基于0.35版本的关键代码。

Bundle拆分——业务分包

为什么要分包?

  • 避免执行大量JS代码带来的性能瓶颈
  • 减少更新时的流量消耗
  • 业务分离,按需加载

关于性能瓶颈,我们先来看一张图:

图片描述

JS Init + Require的时间在整个RN的启动过程中占了约一半,随着业务的增多以及复杂度的增加,这个比例还会上升。

流量消耗一直是用户关注的重点,用户关注的重点就是我们关注的重中之重,RN基础的Bundle有1.5M,即使压缩后也有300多K,业务Bundle的更新,往往只有几K或者几十K,带上基础Bundle这个大尾巴不太合适,分包可以为用户节省不必要的流量消耗,而且在复杂的外网环境下,包越小越有利于更新成功率的提高。

业务分离,按需加载,非纯RN的应用往往入口在不同的地方,不必一股脑的把所有业务模块一起加载,分包加载可以提升加载速度以及减少不必要的资源浪费。

RN自己提供了一个unbundle的方案,Android端把除了polyfill和startup部分之外的所有模块拆成大量的单独js文件,在引用到js文件的时候通过NativeRequire方法去加载对应的js文件,大量频繁的I/O,Android表示已哭晕。。。而iOS端则是在头部添加一堆index table用来做索引,反而会增大文件。可能是还不成熟的原因,RN还没有正式公开这个方案。而且这个方案不能区分业务,不能满足我们的需求。

那么全民K歌如何去实践根据业务分包的?来看下前端同学马铖(Calvinma)的方案:


首先我们看到RN自带的react-packager打包源码比较轻量简洁,保持轻量和对RN特性关注也是RN不使用webpack和broswerify而是自己实现打包的原因。因此我们的方案主要是通过增强 react-packager的打包源码来实现分包(不造轮子)。

RN的打包结果是一套类似CommonJS的轻量require/define模块系统。要做到上面的文件分离和不重复打包,就是要做到依赖引用(业务包去require基础包中的模块), 因此我们要把基础包中包含的模块列表导出来给业务包打包时使用,类似webapc中DllPlugin的实现。

所以分包的主要逻辑如下:

打包(Compile time):

用一个base.js做Entry(包含认为是基础库的内容,比如RN核心、组件、React)打包出基础包,并导出包含的模块列表
根据基础包模块列表打包业务包,过滤业务包的依赖:去掉重复的模块,require 时使用列表里的模块id

客户端运行(Run time):

适当的时机在JSContext运行基础包(比如App launch ready或者用户进入某些场景、下一步可能开启RN时)
用户进入RN的部分,运行业务包,然后runApplication,适当的机制从 CDN 拉取更新业务包。我们学习一下webpack,把这份模块列表称为manifest 。

一图概括之:

图片描述

这个方案的主要优点在于易施行,在本地打包时做文章,分离的包可以分开前后运行(不需要再在终端做合并)
分包灵活,自己通过Entry去控制基础包里要有什么。不用单独再写构建
降低客户端工作量,不用引入复杂的Diff和合并,只要实现可分开运行(runJSInContext和runApplication分离)就可以,RN的源码里都有接口。

举个栗子:

拿官方Hello World来演示下,先把所有公开的组件和API打到个基础包里:

base.android.js
图片描述

输出来的manifest.json大概长这个样子:
图片描述

再带上这份manifest来打包原版的Hello World,最终生成的文件如下:
图片描述

surprise,业务Bundle就是这么小。是不是等不及来看看怎么实现的了?

下面分析一下分包涉及的源码修改和实现(源码根据0.35版本):

首先我们在local-cli添加了两个参数:

–manifest-output: 打包时把bundle包含的模块导出生成为一个manifestFile(打基础包)

–manifest-file : 打包时传入指定的manifestFile进行过滤(打业务包)

local-cli/bundle/bundleCommandLineArgs.js
bundleCommandLineArgs

在打包生成时(拿到所有依赖模块列表后),如果有传入manifestOutput,把模块列表按固定格式输出一份JSON文件(模块名: 模块id)

local-cli/bundle/output/bundle.js
bundle

packager/react-packager/src/Bundler/Bundle.js
bundle1

在依赖解析中require模块时,如果有传入manifestFile,require 已有的模块时使用manifestFile中对应的id。比较方便就是在getModuleId()中做一个处理。

packager/react-packager/src/Bundler/index.js
图片描述

最后在打包前,如果有传入manifestFile,从resolutionResponse按照其过滤掉已存在的模块。

packager/react-packager/src/Bundler/index.js
图片描述

主要的源码涉及就是Bundler和Bundle部分,其次还有cli的添加,过滤掉polyfill的重复插入等。

需要注意的问题:

id重复:

目前的RN打包模块使用递增数字id,分开打包时id就会重复。我的做法是把基础包的最大id输出来接着递增,或者多个业务包时给id加前缀(–id-prefix)。增强方案也可学习webpack使用filename hash做id。


好了,前端同学已经帮我们拆出来的多个Bundle,那么客户端怎么去加载呢?

先来看下一个简单的JavaScript VM模型:

VM

JSContext是JavaScript执行的上下文,由GlobalObject管理。我们在同一个GlobalObject对应的同一个JSContext中执行JavaScript代码,执行一个和执行多个JavaScript是没有区别的,所以上面多个包的加载完全没有问题。

这里不赘述RN的Bundle加载流程,最终用到了JSC的一个API——JSEvaluateScript去执行Bundle,我们只需要针对API封装,提供一个方法给上层调用即可,当然注意执行顺序,在基础Bundle加载完再去执行业务Bundle,iOS和Android实现基本类似。

Native组件动态加载

听说RN支持随时发版,这么好?先来一斤需求!

一开始听到这种提问,第一反应是去解释,RN提供了基础的Native组件(这里我们把Native Module和封装的View统称为Native组件),RN的动态发布是基于已有的Native组件的动态发布,我们可以根据业务的需要去扩展Native组件,但是不可能在一个版本里面把所有可能用到的Native组件都封装好。后来源码读得多了,也开始思考,是不是真的可以随时无限制的去做需求?如何在不更新版本的基础上如何让RN真正的实现需求快速迭代开发,放飞产品同学们的想象力呢?

动态添加Native组件并将其动态加载。

动态添加Native组件,这对于iOS来说可能有点难度,但是对于Android,插件和热补丁大家都玩了这么多年了,这只是个小问题,很容易解决。

那么如何去动态加载Native组件呢?有几种方法可以实现,

  • 方法一:构建一个通用的代理NativeModule,传递需要执行的类名、方法名以及参数列表,通过反射去调用;
  • 方法二:去了解NativeModule的注册和调用流程,动态向其中添加新的Native组件。

方法一简单易于实现,但是需要自己维护对象和构建参数,同时对JS非透明,应用Native版本升级时若想合入则需要多套JS代码对应于不同的调用方式,版本控制比较麻烦。

方法二实现难度稍大,沿用RN的设计,对JS透明,应用Native版本升级可以随之合并进最新APK中。

鉴于以上原因,我们更倾向于方法二。下面就让我们来看看RN是如何完成Native Module的注册以及JS如何去调用Native Module的(这里不带大家走一遍完整的启动流程和Native、JS互调的源码逻辑了,大家可以在网上搜到很多这类的文章,已经被解释得都很详尽)。

1.JNI层中ModuleRegistryHolder的构造函数中传入了Java层用JavaModuleWrapper封装过的NativeModule列表,将其构造JavaNativeModule容器对象传入ModuleRegistry的构造函数中,保存在其成员变量modules_中。

//ModuleRegistryHolder.cpp

ModuleRegistryHolder::ModuleRegistryHolder(
    CatalystInstanceImpl* catalystInstanceImpl,
    jni::alias_ref<jni::JCollection<JavaModuleWrapper::javaobject>::javaobject> javaModules,
    jni::alias_ref<jni::JCollection<CxxModuleWrapper::javaobject>::javaobject> cxxModules) {
  std::vector<std::unique_ptr<NativeModule>> modules;
  ...
  for (const auto& jm : *javaModules) {
    modules.emplace_back(folly::make_unique<JavaNativeModule>(jm));
  }
  ...

  registry_ = std::make_shared<ModuleRegistry>(std::move(modules));
}
//ModuleRegistry.cpp

ModuleRegistry::ModuleRegistry(std::vector<std::unique_ptr<NativeModule>> modules)
    : modules_(std::move(modules)) {}

这里需要留意的是ModuleRegistry中的moduleNames()函数,这是一个关键的方法,返回值是所有NativeModule方法的名字,这在后面向JS中注册NativeModule时举足轻重。

//ModuleRegistry.cpp

std::vector<std::string> ModuleRegistry::moduleNames() {

  std::vector<std::string> names;
  for (size_t i = 0; i < modules_.size(); i++) {
    std::string name = normalizeName(modules_[i]->getName());
    modulesByName_[name] = i;
    names.push_back(std::move(name));
  }
  return names;
}

2.initializeBridge是一个复杂的过程,它构建了Native和JS相互调用的通道。Native通过NativeToJsBridge中调用JSC的API去执行JS方法,而JS则调用在JavaScript VM中注册的JNI方法,通过JsToNativeBridge去执行Native方法。这里我们要去JSCExecutor的构造函数里面去看看NativeModule注册的相关逻辑。

//JSCExecutor.cpp

JSCExecutor::JSCExecutor(std::shared_ptr<ExecutorDelegate> delegate,
                         std::shared_ptr<MessageQueueThread> messageQueueThread,
                         const std::string& cacheDir,
                         const folly::dynamic& jscConfig) throw(JSException) :
    m_delegate(delegate),
    m_deviceCacheDir(cacheDir),
    m_messageQueueThread(messageQueueThread),
    m_jscConfig(jscConfig) {
  initOnJSVMThread();

  SystraceSection s("setBatchedBridgeConfig");

  folly::dynamic nativeModuleConfig = folly::dynamic::array();

  {
    SystraceSection s("collectNativeModuleNames");
    std::vector<std::string> names = delegate->moduleNames();
    for (auto& name : delegate->moduleNames()) {
      nativeModuleConfig.push_back(folly::dynamic::array(std::move(name)));
    }
  }

  folly::dynamic config =
    folly::dynamic::object
      ("remoteModuleConfig", std::move(nativeModuleConfig));

  SystraceSection t("setGlobalVariable");
  setGlobalVariable(
    "__fbBatchedBridgeConfig",
    folly::make_unique<JSBigStdString>(detail::toStdString(folly::toJson(config))));
}

initOnJSVMThread()函数去创建了JavaScript VM、JSContext、Global对象以及向其中注册了JNI方法。

在initOnJSVMThread()之后,出现了一个我们熟悉的方法moduleNames(),这里拿到所有的NativeModule的名字之后,构建JSON,赋值给Global中的__fbBatchedBridgeConfig对象,完成了NativeModule向JS的初步注册。

3.JS中新建MessageQueue对象,通过在JSCExecutor写入的__fbBatchedBridgeConfig对象去构建remoteModules,这样就把NativeModule映射到JS中了,但这还远远没有结束。

 //BatchedBridge.js

const BatchedBridge = new MessageQueue(() => global.__fbBatchedBridgeConfig);
//MessageQueue.js

type Config = {
  remoteModuleConfig: Object,
};

class MessageQueue {
  constructor(configProvider: () => Config) {
    ...
    lazyProperty(this, 'RemoteModules', () => {
      const {remoteModuleConfig} = configProvider();
      const modulesConfig = remoteModuleConfig;
      return this._genModules(modulesConfig);
    });
  }
  ...
  }

function lazyProperty(target: Object, name: string, f: () => any) {
  Object.defineProperty(target, name, {
    configurable: true,
    enumerable: true,
    get() {
      const value = f();
      Object.defineProperty(target, name, {
        configurable: true,
        enumerable: true,
        writeable: true,
        value: value,
      });
      return value;
    }
  });
}

_genModules(remoteModules) {
    const modules = {};
    remoteModules.forEach((config, moduleID) => {
      // Initially this config will only contain the module name when running in JSC. The actual
      // configuration of the module will be lazily loaded (see NativeModules.js) and updated
      // through processModuleConfig.
      const info = this._genModule(config, moduleID);
      if (info) {
        modules[info.name] = info.module;
      }
      ...
    });
    return modules;
  }

4.NativeModules是不是很熟悉?JS就是通过NativeModules.类名.方法名去调用NativeModule的。这里通过去remoteModules中查找NativeModule并且调用JNI方法去获取到NativeModule的方法等信息,完成构建NativeModules对象。

//NativeModules.js

const NativeModules = {};
Object.keys(RemoteModules).forEach((moduleName) => {
  Object.defineProperty(NativeModules, moduleName, {
    configurable: true,
    enumerable: true,
    get: () => {
      let module = RemoteModules[moduleName];
      if (module && typeof module.moduleID === 'number' && global.nativeRequireModuleConfig) {
        const config = global.nativeRequireModuleConfig(moduleName);
        module = config && BatchedBridge.processModuleConfig(config, module.moduleID);
        RemoteModules[moduleName] = module;
      }
      Object.defineProperty(NativeModules, moduleName, {
        configurable: true,
        enumerable: true,
        value: module,
      });
      return module;
    },
  });
});

看到这里想必大家都已经明白了,想要去动态加载NativeModule,只需要:

  • 1)在ModuleRegistry的modules_变量中增加新的NativeModule
  • 2)调用moduleNames更新modulesByName_同时得到moduleID
  • 3)执行代码向JS中MessageQueue对象的RemoteModules中添加新的NativeModule描述
  • 4)执行代码向JS中的NativeModules对象添加新的NativeModule描述

主要代码:

在NativeModules.js中为global添加了新的function,以便在JNI中调用该方法去动态向NativeModules对象中添加新的NativeModule描述:

//NativeModules.js

global.__UPDATE_NATIVE_MODULE__=function(moduleName){
Object.defineProperty(NativeModules,moduleName,{
configurable:true,
enumerable:true,
get:function get(){
var module=RemoteModules[moduleName];
if(module&&typeof module.moduleID==='number'&&global.nativeRequireModuleConfig){
var config=global.nativeRequireModuleConfig(moduleName);
module=config&&BatchedBridge.processModuleConfig(config,module.moduleID);
RemoteModules[moduleName]=module;
}
Object.defineProperty(NativeModules,moduleName,{
configurable:true,
enumerable:true,
value:module});

return module;
}});

};

在JSCExecutor中增加函数,向JS中的RemoteModules添加元素以及执行JS方法去更新NativeModules,此处要注意在JS线程中去操作:

//JSCExecutor.cpp

void JSCExecutor::addNativeModuleDynamically(){
          std::vector<std::string> names = m_delegate->moduleNames();
          int index = (int)names.size() - 1;
          const char* moduleName = names[index].c_str();
          m_messageQueueThread->runOnQueue([this, moduleName, index] () {
              auto global = Object::getGlobalObject(m_context);
              auto batchedBridgeValue = global.getProperty("__fbBatchedBridge");
              auto batchedBridge = batchedBridgeValue.asObject();
              auto remote = batchedBridge.getProperty("RemoteModules").asObject();
              auto empty = JSObjectMake(m_context, NULL, NULL);
              JSObjectSetProperty(m_context, empty, String("moduleID"),
                                  JSValueMakeNumber(m_context, index), 0, NULL);
              remote.setProperty(moduleName, Value(m_context, empty));
              auto nativemodules = global.getProperty("__UPDATE_NATIVE_MODULE__").asObject();
              nativemodules.callAsFunction(
                      {Value(m_context, JSStringCreateWithUTF8CString(moduleName))});
          });
        }

以上就是在RN源码读完后的一点思考,一点拙见,在构建更快更易扩展的RN应用的实践。如有不足之处欢迎大家斧正。

相关文章:


目前SDCC 2016前端开发专题的所有演讲嘉宾已全部确定,以下为嘉宾名单及演讲议题(排名不分先后),详情请见:SDCC 2016前端开发专题讲师、议题大揭底

  • Stackla前端团队Leader蒋定宇
    • 演讲主题:不断归零的前端人生
  • QQ音乐&全民K歌高级工程师袁聪
    • 演讲主题:全民K歌React Native最佳实践
  • 饿了么Node Team负责人黄鼎恒
    • 演讲主题:纯手工搭建一个高性能实时监控系统
  • 360奇舞团前端工程师钟恒
    • 演讲主题:使用Vue.js 2.0开发高交互Web应用
  • Ruff架构师、JavaScript专家周爱民
    • 演讲主题:有前端思想的物联网系统架构
  • 58到家高级前端工程师周俊鹏
    • 演讲主题:基于webpack的前端工程解决方案

想与这些专家现场面对面进行技术探讨吗?目前SDCC 2016大会门票8折销售中,团购更有优惠,是给辛勤工作一年的你,年终最好的礼物,或许这样,SDCC才能更真切地服务好开发者。【注册参会

图片描述

评论