onclick reactnative

2016-04-01 10:05:44 tangxiaoyin 阅读数 969

来自俄罗斯新西伯利亚的开发者Dima日前在GitHub上开源了一款名为React Native Desktop的开发利器,可以让开发者使用React Native构建OS X桌面应用程序,并分享了自己研发所得的示例,项目截至目前已经收获了五千多star。

示例代码:

<View>
  <Button onClick={() => alert('clicked')} />
</View>

演示截图:

Button

不过,Dima也在README.md中声明:“强烈建议有React Native开发经验的开发者使用。截至目前,还没有成功诞生任何RN桌面应用,因此使用React Native Desktop并不是一定能开发出跑在OS X上的原生应用。”

Examples

第一时间掌握最新移动开发相关信息和技术,请关注mobilehub公众微信号(ID: mobilehub)。

mobilehub

2017-07-02 21:31:23 lyglostangel 阅读数 2492

React Native 是最近非常火的一个话题,介绍如何利用 React Native 进行开发的文章和书籍多如牛毛,但面向入门水平并介绍它工作原理的文章却寥寥无几。

本文分为两个部分:上半部分用通俗的语言解释了相关的名词,重点介绍 React Native 出现的背景和试图解决的问题。适合新手对 React Native 形成初步了解。(事实证明,女票能看懂这段)

下半部分则通过源码(0.27 版本)分析 React Native 的工作原理,适合深入学习理解 React Native 的运行机制。最后则是我个人对 React Native 的分析与前景判断。

动态配置

由于 AppStore 审核周期的限制,如何动态的更改 app 成为了永恒的话题。无论采用何种方式,我们的流程总是可以归结为以下三部曲:“从 Server 获取配置 --> 解析 --> 执行native代码”。

很多时候,我们自觉或者不自觉的利用 JSON 文件实现动态配置的效果,它的核心流程是:

  1. 通过 HTTP 请求获取 JSON 格式的配置文件。
  2. 配置文件中标记了每一个元素的属性,比如位置,颜色,图片 URL 等。
  3. 解析完 JSON 后,我们调用 Objective-C 的代码,完成 UI 控件的渲染。

通过这种方法,我们实现了在后台配置 app 的展示样式。从本质上来说,移动端和服务端约定了一套协议,但是协议内容严重依赖于应用内要展示的内容,不利于拓展。也就是说,如果业务要求频繁的增加或修改页面,这套协议很难应付。

最重要的是,JSON 只是一种数据交换的格式,说白了,我们就是在解析文本数据。这就意味着它只适合提供一些配置信息,而不方便提供逻辑信息。举个例子,我们从后台可以配置颜色,位置等信息,但如果想要控制 app 内的业务逻辑,就非常复杂了。

记住,我们只是在解析字符串,它完全不具备运行和调试的能力。

React

不妨暂时抛弃移动端的烦恼,来看看前端的“新玩意”。

背景

作为前端小白,我以前对前端的理解是这样的:

  • 用 HTML 创建 DOM,构建整个网页的布局、结构
  • 用 CSS 控制 DOM 的样式,比如字体、字号、颜色、居中等
  • 用 JavaScript 接受用户事件,动态的操控 DOM

在这三者的配合下,几乎所有页面上的功能都能实现。但也有比较不爽地方,比如我想动态修改一个按钮的文字,我需要这样写:

<button type="button" id="button" onclick="onClick()">old button</button>

然后在 JavaScript 中操作 DOM:

<script>
function onClick() {
  document.getElementById('button').innerHTML='new button';
}
</script>

可以看到,在 HTML 和 JavaScript 代码中,id 和 onclick 事件触发的函数必须完全对应,否则就无法正确的响应事件。如果想知道一个 HTML 标签会如何被响应,我们还得跑去 JavaScript 代码中查找,这种原始的配置方式让我觉得非常不爽。

初识 React

随着 FaceBook 推出了 React 框架,这个问题得到了大幅度改善。我们可以把一组相关的 HTML 标签,也就是 app 内的 UI 控件,封装进一个组件(Component)中,我从阮一峰的 React 教程中摘录了一段代码:

var MyComponent = React.createClass({
  handleClick: function() {
    this.refs.myTextInput.focus();
  },
  render: function() {
    return (
      <div>
        <input type="text" ref="myTextInput" />
        <input type="button" value="Focus the text input" onClick={this.handleClick} />
      </div>
    );
  }
});

如果你想问:“为什么 JavaScript 代码里面出现了 HTML 的语法”,那么恭喜你已经初步体会到 React 的奥妙了。这种语法被称为 JSX,它是一种 JavaScript 语法拓展。JSX 允许我们写 HTML 标签或 React 标签,它们终将被转换成原生的 JavaScript 并创建 DOM。

在 React 框架中,除了可以用 JavaScript 写 HTML 以外,我们甚至可以写 CSS,这在后面的例子中可以看到。

理解 React

前端界总是喜欢创造新的概念,仿佛谁说的名词更晦涩,谁的水平就越高。如果你和当时的我一样,听到 React 这个概念一脸懵逼的话,只要记住以下定义即可:

React 是一套可以用简洁的语法高效绘制 DOM 的框架

上文已经解释过了何谓“简洁的语法”,因为我们可以暂时放下 HTML 和 CSS,只关心如何用 JavaScript 构造页面。

所谓的“高效”,是因为 React 独创了 Virtual DOM 机制。Virtual DOM 是一个存在于内存中的 JavaScript 对象,它与 DOM 是一一对应的关系,也就是说只要有 Virtual DOM,我们就能渲染出 DOM。

当界面发生变化时,得益于高效的 DOM Diff 算法,我们能够知道 Virtual DOM 的变化,从而高效的改动 DOM,避免了重新绘制 DOM。

当然,React 并不是前端开发的全部。从之前的描述也能看出,它专注于 UI 部分,对应到 MVC 结构中就是 View 层。要想实现完整的 MVC 架构,还需要 Model 和 Controller 的结构。在前端开发时,我们可以采用 Flux 和 Redux 架构,它们并非框架(Library),而是和 MVC 一样都是一种架构设计(Architecture)。

如果不从事前端开发,就不用深入的掌握 Flux 和 Redux 架构,但理解这一套体系结构对于后面理解 React Native 非常重要

React Native

分别介绍完了移动端和前端的背景知识后,本文的主角——React Native 终于要登场了。

融合

前面我们介绍了移动端通过 JSON 文件传递信息的不足之处:只能传递配置信息,无法表达逻辑。从本质上讲,这是因为 JSON 毕竟只是纯文本,它缺乏像编程语言那样的运行能力。

而 React 在前端取得突破性成功以后,JavaScript 布道者们开始试图一统三端。他们利用了移动平台能够运行 JavaScript 代码的能力,并且发挥了 JavaScript 不仅仅可以传递配置信息,还可以表达逻辑信息的优点。

当痛点遇上特点,两者一拍即合,于是乎:

一个基于 JavaScript,具备动态配置能力,面向前端开发者的移动端开发框架,React Native,诞生了!

看到了么,这是一个面向前端开发者的框架。它的宗旨是让前端开发者像用 React 写网页那样,用 React Native 写移动端应用。这就是为什么 React Native 自称:

Learn once,Write anywhere!

而非很多跨平台语言,项目所说的:

Write once, Run anywhere!

React Native 希望前端开发者学习完 React 后,能够用同样的语法、工具等,分别开发安卓和 iOS 平台的应用并且不用一行原生代码。

如果用一个词概括 React Native,那就是:Native 版本的 React

原理概述

React Native 不是黑科技,我们写的代码总是以一种非常合理,可以解释的方式的运行着,只是绝大多数人没有理解而已。接下来我以 iOS 平台为例,简单的解释一下 React Native 的原理。

首先要明白的一点是,即使使用了 React Native,我们依然需要 UIKit 等框架,调用的是 Objective-C 代码。总之,JavaScript 只是辅助,它只是提供了配置信息和逻辑的处理结果。React Native 与 Hybrid 完全没有关系,它只不过是以 JavaScript 的形式告诉 Objective-C 该执行什么代码。

其次,React Native 能够运行起来,全靠 Objective-C 和 JavaScript 的交互。对于没有接触过 JavaScript 的人来说,非常有必要理解 JavaScript 代码如何被执行。

我们知道 C 系列的语言,经过编译,链接等操作后,会得到一个二进制格式的可执行文,所谓的运行程序,其实是运行这个二进制程序。

而 JavaScript 是一种脚本语言,它不会经过编译、链接等操作,而是在运行时才动态的进行词法、语法分析,生成抽象语法树(AST)和字节码,然后由解释器负责执行或者使用 JIT 将字节码转化为机器码再执行。整个流程由 JavaScript 引擎负责完成。

苹果提供了一个叫做 JavaScript Core 的框架,这是一个 JavaScript 引擎。通过下面这段代码可以简单的感受一下 Objective-C 如何调用 JavaScript 代码:

JSContext *context = [[JSContext alloc] init];
JSValue *jsVal = [context evaluateScript:@"21+7"];
int iVal = [jsVal toInt32];

这里的 JSContext 指的是 JavaScript 代码的运行环境,通过 evaluateScript 即可执行 JavaScript 代码并获取返回结果。

JavaScript 是一种单线程的语言,它不具备自运行的能力,因此总是被动调用。很多介绍 React Native 的文章都会提到 “JavaScript 线程” 的概念,实际上,它表示的是 Objective-C 创建了一个单独的线程,这个线程只用于执行 JavaScript 代码,而且 JavaScript 代码只会在这个线程中执行。

Objective-C 与 JavaScript 交互

提到 Objective-C 与 JavaScript 的交互,不得不推荐 bang神的这篇文章:React Native通信机制详解 。虽然其中不少细节都已经过时,但是整体的思路值得学习。

本节主要分析 Objective-C 与 JavaScript 交互时的整理逻辑与流程,下一节将通过源码来分析具体原理。

JavaScript 调用 Objective-C

由于 JavaScript Core 是一个面向 Objective-C 的框架,在 Objective-C 这一端,我们对 JavaScript 上下文知根知底,可以很容易的获取到对象,方法等各种信息,当然也包括调用 JavaScript 函数。

真正复杂的问题在于,JavaScript 不知道 Objective-C 有哪些方法可以调用。

React Native 解决这个问题的方案是在 Objective-C 和 JavaScript 两端都保存了一份配置表,里面标记了所有 Objective-C 暴露给 JavaScript 的模块和方法。这样,无论是哪一方调用另一方的方法,实际上传递的数据只有 ModuleIdMethodId 和 Arguments 这三个元素,它们分别表示类、方法和方法参数,当 Objective-C 接收到这三个值后,就可以通过 runtime 唯一确定要调用的是哪个函数,然后调用这个函数。

再次重申,上述解决方案只是一个抽象概念,可能与实际的解决方案有微小差异,比如实际上 Objective-C 这一端,并没有直接保存这个模块配置表。具体实现将在下一节中随着源码一起分析。

闭包与回调

既然说到函数互调,那么就不得不提到回调了。对于 Objective-C 来说,执行完 JavaScript 代码再执行 Objective-C 回调毫无难度,难点依然在于 JavaScript 代码调用 Objective-C 之后,如何在 Objective-C 的代码中,回调执行 JavaScript 代码。

目前 React Native 的做法是:在 JavaScript 调用 Objective-C 代码时,注册要回调的 Block,并且把 BlockId 作为参数发送给 Objective-C,Objective-C 收到参数时会创建 Block,调用完 Objective-C 函数后就会执行这个刚刚创建的 Block。

Objective-C 会向 Block 中传入参数和 BlockId,然后在 Block 内部调用 JavaScript 的方法,随后 JavaScript 查找到当时注册的 Block 并执行。

图解

好吧,如果你是新手,并且坚持读到了这里,估计已经懵逼了。不要担心,与 JavaScript 的交互确实不是一下子能够完全理清楚的,你可以先参考这个示意图:


交互流程

注:

  1. 本图由 bang 的文章中的图片修改而来
  2. 本图只是一个简单的示意图,不建议当做时序图使用,请参考下一节源码分析。
  3. Objective-C 和 JavaScript 的交互总是由前者发起,本图为了简化,省略了这一步骤。

React Native 源码分析

要想深入理解 React Native 的工作原理,有两个部分有必要阅读一下,分别是初始化阶段和方法调用阶段。

为了提炼出代码的核心含义,我会在不改变代码意图的基础上对它做一些删改,以便阅读。

写这篇文章是,React Native 还处于 0.27 版本,由于在 1.0 之前的变动幅度相对较大,因此下面的源码分析很可能随着 React Native 的演变而过时。但不管何时,把下面的源码读一遍都有助于你加深对 React Native 原理的理解。

初始化 React Native

每个项目都有一个入口,然后进行初始化操作,React Native 也不例外。一个不含 Objective-C 代码的项目留给我们的唯一线索就是位于 AppDelegate 文件中的代码:

RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                    moduleName:@"PropertyFinder"
                                             initialProperties:nil
                                                 launchOptions:launchOptions];

用户能看到的一切内容都来源于这个 RootView,所有的初始化工作也都在这个方法内完成。

在这个方法内部,在创建 RootView 之前,React Native 实际上先创建了一个 Bridge 对象。它是 Objective-C 与 JavaScript 交互的桥梁,后续的方法交互完全依赖于它,而整个初始化过程的最终目的其实也就是创建这个桥梁对象。

初始化方法的核心是 setUp 方法,而 setUp 方法的主要任务则是创建 BatchedBridge

BatchedBridge 的作用是批量读取 JavaScript 对 Objective-C 的方法调用,同时它内部持有一个 JavaScriptExecutor,顾名思义,这个对象用来执行 JavaScript 代码。

创建 BatchedBridge 的关键是 start 方法,它可以分为五个步骤:

  1. 读取 JavaScript 源码
  2. 初始化模块信息
  3. 初始化 JavaScript 代码的执行器,即 RCTJSCExecutor 对象
  4. 生成模块列表并写入 JavaScript 端
  5. 执行 JavaScript 源码

我们逐个分析每一步完成的操作:

读取 JavaScript 源码

这一部分的具体代码实现没有太大的讨论意义。我们只要明白,JavaScript 的代码是在 Objective-C 提供的环境下运行的,所以第一步就是把 JavaScript 加载进内存中,对于一个空的项目来说,所有的 JavaScript 代码大约占用 1.5 Mb 的内存空间。

需要说明的是,在这一步中,JSX 代码已经被转化成原生的 JavaScript 代码。

初始化模块信息

这一步在方法 initModulesWithDispatchGroup: 中实现,主要任务是找到所有需要暴露给 JavaScript 的类。每一个需要暴露给 JavaScript 的类(也成为 Module,以下不作区分)都会标记一个宏:RCT_EXPORT_MODULE,这个宏的具体实现并不复杂:

#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString *)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }

这样,这个类在 load 方法中就会调用 RCTRegisterModule 方法注册自己:

void RCTRegisterModule(Class moduleClass)
{
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    RCTModuleClasses = [NSMutableArray new];
  });

  [RCTModuleClasses addObject:moduleClass];
}

因此,React Native 可以通过 RCTModuleClasses 拿到所有暴露给 JavaScript 的类。下一步操作是遍历这个数组,然后生成 RCTModuleData 对象:

for (Class moduleClass in RCTGetModuleClasses()) {
    RCTModuleData *moduleData = [[RCTModuleData alloc]initWithModuleClass:moduleClass                                                                      bridge:self];
    [moduleClassesByID addObject:moduleClass];
    [moduleDataByID addObject:moduleData];
}

可以想见,RCTModuleData 对象是模块配置表的主要组成部分。如果把模块配置表想象成一个数组,那么每一个元素就是一个 RCTModuleData 对象。

这个对象保存了 Module 的名字,常量等基本信息,最重要的属性是一个数组,保存了所有需要暴露给 JavaScript 的方法。

暴露给 JavaScript 的方法需要用 RCT_EXPORT_METHOD 这个宏来标记,它的实现原理比较复杂,有兴趣的读者可以自行阅读。简单来说,它为函数名加上了 __rct_export__ 前缀,再通过 runtime 获取类的函数列表,找出其中带有指定前缀的方法并放入数组中:

- (NSArray<id<RCTBridgeMethod>> *)methods{
    unsigned int methodCount;
    Method *methods = class_copyMethodList(object_getClass(_moduleClass), &methodCount); // 获取方法列表
    for (unsigned int i = 0; i < methodCount; i++) {
        RCTModuleMethod *moduleMethod = /* 创建 method */
        [_methods addObject:moduleMethod];
      }
    }
    return _methods;
}

因此 Objective-C 管理模块配置表的逻辑是:Bridge 持有一个数组,数组中保存了所有的模块的 RCTModuleData 对象。只要给定 ModuleId 和 MethodId 就可以唯一确定要调用的方法。

初始化 JavaScript 代码的执行器,即 RCTJSCExecutor 对象

通过查看源码可以看到,初始化 JavaScript 执行器的时候,addSynchronousHookWithName这个方法被调用了多次,它其实向 JavaScript 上下文中添加了一些 Block 作为全局变量:

- (void)addSynchronousHookWithName:(NSString *)name usingBlock:(id)block {
    self.context.context[name] = block;
}

有些同学读源码时可能会走进一个误区,如果在 Block 中打一个断点就会发现,Block 其实是被执行了,但却找不到任何能够执行 Block 的代码。

这其实是因为这个 Block 并非由 Objective-C 主动调用,而是在第五步执行 JavaScript 代码时,由 JavaScript 在上下文中获取到 Block 对象并调用,有兴趣的读者可以自行添加断点并验证。

这里我们需要重点注意的是名为 nativeRequireModuleConfig 的 Block,它在 JavaScript 注册新的模块时调用:

get: () => {
    let module = RemoteModules[moduleName];
    const json = global.nativeRequireModuleConfig(moduleName); // 调用 OC 的 Block
    const config = JSON.parse(json); // 解析 json
    module = BatchedBridge.processModuleConfig(config, module.moduleID); // 注册 config
    return module;
},

这就是模块配置表能够加载到 JavaScript 中的原理。

另一个值得关注的 Block 叫做 nativeFlushQueueImmediate。实际上,JavaScript 除了把调用信息放到 MessageQueue 中等待 Objective-C 来取以外,也可以主动调用 Objective-C 的方法:

if (global.nativeFlushQueueImmediate &&
    now - this._lastFlush >= MIN_TIME_BETWEEN_FLUSHES_MS) {
    global.nativeFlushQueueImmediate(this._queue); // 调用 OC 的代码
}

目前,React Native 的逻辑是,如果消息队列中有等待 Objective-C 处理的逻辑,而且 Objective-C 超过 5ms 都没有来取走,那么 JavaScript 就会主动调用 Objective-C 的方法:

[self addSynchronousHookWithName:@"nativeFlushQueueImmediate" usingBlock:^(NSArray<NSArray *> *calls){
    [self->_bridge handleBuffer:calls batchEnded:NO];
}];

这个 handleBuffer 方法是 JavaScript 调用 Objective-C 方法的关键,在下一节——方法调用中,我会详细分析它的实现原理。

一般情况下,Objective-C 会定时、主动的调用 handleBuffer 方法,这有点类似于轮询机制:

// 每个一段时间发生一次:
Objective-C:嘿,JavaScript,有没有要调用我的方法呀?
JavaScript:有的,你从 MessageQueue 里面取出来。

然而由于卡顿或某些特殊原因,Objective-C 并不能总是保证能够准时的清空 MessageQueue,这就是为什么 JavaScript 也会在一定时间后主动的调用 Objective-C 的方法。查看上面 JavaScript 的代码可以发现,这个等待时间是 5ms。

请牢牢记住这个 5ms,它告诉我们 JavaScript 与 Objective-C 的交互是存在一定开销的,不然就不会等待而是每次都立刻发起请求。其次,这个时间开销大约是毫秒级的,不会比 5ms 小太多,否则等待这么久就意义不大了。

生成模块配置表并写入 JavaScript 端

复习一下 nativeRequireModuleConfig 这个 Block,它可以接受 ModuleName 并且生成详细的模块信息,但在前文中我们没有提到 JavaScript 是如何知道 Objective-C 要暴露哪些类的(目前只是 Objective-C 自己知道)。

这一步的操作就是为了让 JavaScript 获取所有模块的名字:

- (NSString *)moduleConfig{
    NSMutableArray<NSArray *> *config = [NSMutableArray new];
    for (RCTModuleData *moduleData in _moduleDataByID) {
      [config addObject:@[moduleData.name]];
    }
}

查看源码可以发现,Objective-C 把 config 字符串设置成 JavaScript 的一个全局变量,名字叫做:__fbBatchedBridgeConfig

执行 JavaScript 源码

这一步也没什么技术难度可以,代码已经加载进了内存,该做的配置也已经完成,只要把 JavaScript 代码运行一遍即可。

运行代码时,第三步中所说的那些 Block 就会被执行,从而向 JavaScript 端写入配置信息。

至此,JavaScript 和 Objective-C 都具备了向对方交互的能力,准备工作也就全部完成了。

画了一个简陋的时序图以供参考:


初始化过程

方法调用

如前文所述,在 React Native 中,Objective-C 和 JavaScript 的交互都是通过传递 ModuleIdMethodId 和 Arguments 进行的。以下是分情况讨论:

调用 JavaScript 代码

也许你在其他文章中曾经多次听说 JavaScript 代码总是在一个单独的线程上面调用,它的实际含义是 Objective-C 会在单独的线程上运行 JavaScript 代码:

- (void)executeBlockOnJavaScriptQueue:(dispatch_block_t)block
{
  if ([NSThread currentThread] != _javaScriptThread) {
    [self performSelector:@selector(executeBlockOnJavaScriptQueue:)
                 onThread:_javaScriptThread withObject:block waitUntilDone:NO];
  } else {
    block();
  }
}

调用 JavaScript 代码的核心代码如下:

- (void)_executeJSCall:(NSString *)method
             arguments:(NSArray *)arguments
              callback:(RCTJavaScriptCallback)onComplete{
    [self executeBlockOnJavaScriptQueue:^{
        // 获取 contextJSRef、methodJSRef、moduleJSRef
        resultJSRef = JSObjectCallAsFunction(contextJSRef, (JSObjectRef)methodJSRef, (JSObjectRef)moduleJSRef, arguments.count, jsArgs, &errorJSRef);
        objcValue = /*resultJSRef 转换成 Objective-C 类型*/
        onComplete(objcValue, nil);
    }];
}

需要注意的是,这个函数名是我们要调用 JavaScript 的中转函数名,比如 callFunctionReturnFlushedQueue。也就是说它的作用其实是处理参数,而非真正要调用的 JavaScript 函数。

这个中转函数接收到的参数包含了 ModuleIdMethodId 和 Arguments,然后由中转函数查找自己的模块配置表,找到真正要调用的 JavaScript 函数。

在实际使用的时候,我们可以这样发起对 JavaScript 的调用:

[_bridge.eventDispatcher sendAppEventWithName:@"greeted"
                                         body:@{ @"name": @"nmae"}];

这里的 Name 和 Body 参数分别表示要调用的 JavaScript 的函数名和参数。

JavaScript 调用 Objective-C

在调用 Objective-C 代码时,如前文所述,JavaScript 会解析出方法的 ModuleIdMethodId 和 Arguments 并放入到 MessageQueue 中,等待 Objective-C 主动拿走,或者超时后主动发送给 Objective-C。

Objective-C 负责处理调用的方法是 handleBuffer,它的参数是一个含有四个元素的数组,每个元素也都是一个数组,分别存放了 ModuleIdMethodIdParams,第四个元素目测用处不大。

函数内部在每一次方调用中调用 _handleRequestNumber:moduleID:methodID:params 方法。,通过查找模块配置表找出要调用的方法,并通过 runtime 动态的调用:

[method invokeWithBridge:self module:moduleData.instance arguments:params];

在这个方法中,有一个很关键的方法:processMethodSignature,它会根据 JavaScript 的 CallbackId 创建一个 Block,并且在调用完函数后执行这个 Block。

实战应用

俗话说:“思而不学则神棍”,下面举一个例子来演示 Objective-C 是如何与 JavaScript 进行交互的。首先新建一个模块:

// .h 文件
#import <Foundation/Foundation.h>
#import "RCTBridgeModule.h"

@interface Person : NSObject<RCTBridgeModule, RCTBridgeMethod>

@end

Person 这个类是一个新的模块,它有两个方法暴露给 JavaScript:

#import "Person.h"
#import "RCTEventDispatcher.h"
#import "RCTConvert.h"

@implementation Person
@synthesize bridge = _bridge;

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(greet:(NSString *)name)
{
  NSLog(@"Hi, %@!", name);
  [_bridge.eventDispatcher sendAppEventWithName:@"greeted"
                                           body:@{ @"name": @"nmae"}];
}

RCT_EXPORT_METHOD(greetss:(NSString *)name name2:(NSString *)name2 callback:(RCTResponseSenderBlock)callback)
{
  NSLog(@"Hi, %@! %@!!!", name, name2);
  callback(@[@[@12,@23,@34]]);
}

@end

在 JavaScript 中,可以这样调用:

Person.greet('Tadeu');
Person.greetss('Haha', 'Heihei', (events) => {
  for (var i = 0; i < events.length; i++) {
    console.log(events[i]);
  }
});

有兴趣的同学可以复制以上代码并自行调试。

React Native 优缺点分析

经过一长篇的讨论,其实 React Native 的优缺点已经不难分析了,这里简单总结一下:

优点

  1. 复用了 React 的思想,有利于前端开发者涉足移动端。
  2. 能够利用 JavaScript 动态更新的特性,快速迭代。
  3. 相比于原生平台,开发速度更快,相比于 Hybrid 框架,性能更好。

缺点

  1. 做不到 Write once, Run everywhere,也就是说开发者依然需要为 iOS 和 Android 平台提供两套不同的代码,比如参考官方文档可以发现不少组件和API都区分了 Android 和 iOS 版本。即使是共用组件,也会有平台独享的函数。

  2. 不能做到完全屏蔽 iOS 端或 Android 的细节,前端开发者必须对原生平台有所了解。加重了学习成本。对于移动端开发者来说,完全不具备用 React Native 开发的能力。

  3. 由于 Objective-C 与 JavaScript 之间切换存在固定的时间开销,所以性能必定不及原生。比如目前的官方版本无法做到 UItableview(ListView) 的视图重用,因为滑动过程中,视图重用需要在异步线程中执行,速度太慢。这也就导致随着 Cell 数量的增加,占用的内存也线性增加。

综上,我对 React Native 的定位是:

利用脚本语言进行原生平台开发的一次成功尝试,降低了前端开发者入门移动端的门槛,一定业务场景下具有独特的优势,几乎不可能取代原生平台开发。

参考资料

  1. React Native 官方文档React Native 官方文档中文版
  2. React Native通信机制详解
  3. React 入门实例教程
2018-05-05 19:35:13 theVicTory 阅读数 3176

        最近学习了一个网上的React Native项目,利用React Native制作一个类似于美团的App,项目属于对之前React Native常用组件的基本使用,但是仍有一些关键点值得记录。最后做成的效果如下:

1、通过React Navigation来搭建整体的页面框架

    可以看到这个App大致分为四个板块:主页、商家、我的、更多,分别对应四个标签导航,可以利用React native提供的TabNavigator来实现四个标签页的导航。每个页面对应一个组件,新建一个专门的文件夹component用于存放这些组件。

import HomeStack from './component/Home/HomeScreen'
import ShopStack from './component/Shop/ShopScreen'
import MoreScreen from './component/MoreScreen'
import MineScreen from './component/MineScreen'

const Main=TabNavigator (
  {
    Home:{screen:HomeStack},       //标签页Home对应HomeScreen组件
    Shop:{screen:ShopStack},
    Mine:{screen:MineScreen},
    More:{screen:MoreScreen},
  },

    以上是一级页面,在点击其中的图标后可以跳转到响应的二级详情页面,可以用React Navigation中的StackNavigator来实现详情页的跳转。相应的,对于复杂的模块,可能不止包含一个组件模块,这就需要为它新建文件夹来存放它的组件了,例如主页Home文件夹下包含了主页面HomeScreen及详情页HomeDetail,以及其他的组件。


2、组件化的开发思维

    2.1组件的相同

        使用React开发网页最便捷的地方就是它可以把相同的部分提炼成组件,通过组件化的思路来渲染页面,可以免去很多重复的代码。首先实现最简单的页面:更多

            

    可以看到这个页面的元素类型都是单一的选项条,只不过显示的文字不同,可以把每一条抽象成为一个类Cell,当传入不同的属性时,显示不同的文字

class Cell extends Component{
  render(){
    return(
      <TouchableOpacity activeOpacity={0.5} onPress={this.props.cellFunction}>
        <View style={styles.cellBar}>
          <Text style={{fontSize:16}}>{this.props.title}</Text>
          <Image style={styles.cellImage} source={{uri:'icon_cell_rightarrow'}} />
        </View>
      </TouchableOpacity>
    )
  }
}
    在页面调用Cell组件:
<Cell title="消息提醒"/>
<Cell title="邀请好友"/>
<Cell title="清空缓存"/>

2.2、组件的不同

    在类似的组件中,难免存在不同的地方,需要根据不同的情况进行分别处理,例如接下来实现的“我的”页面:

            

    页面中也存在许多类似的选项条:“我的订单”、“钱包”、“抵用券”......它们整体结构分为左边图标、标题,右边文字、箭头,但仔细看“今日推荐”右边没有文字,而是一个图标“new”。这就需要在调用时传入属性来规定是否渲染图标以及渲染怎样的图标。在react中会根据是否传入参数来决定是否渲染,例如在组件定义时将右边的文字、图标都写上:

{/*右边的文字*/}
<Text>{this.props.infoText}</Text>
{/*右边的图标*/}
<Image source={{uri:this.props.badgeSrc}} style={{width:30,height:18}} />
{/*右边的箭头*/}
<Image style={styles.cellArrow} source={{uri:'icon_cell_rightarrow'}} />
但在调用组件时有的组件需要文字,就传入属性infoText,有的组件需要图标,就传入badgeSrc,不传入的属性就不会被渲染:
<Cell title='今日推荐' iconSrc="icon_mine_recommend" badgeSrc="icon_cell_new" />
<Cell title='我要合作' iconSrc="icon_mine_corporation" infoText="轻松开店" />

3、Flex布局的使用

    通过flex可以很快捷地将组件布局为想要的样式,例如通过flexDirection:'row',justifyContent:'space-between'可以将元素分布于左边、右边,而不是设置float浮动:


    或者通过justifyContent:'space-around'将元素均匀分布在一行,而不必根据屏幕大小计算每个元素的大小与margin,通过alignItem:'center',在竖直方向上元素居中


4、FlatList与ScrollView的使用

    在渲染一组相似结构的数据时,可以利用FlatList,我们只需要定义其中一个元素的渲染方法,就可以把一组数据渲染出来。

    当希望页面可以上下或左右滚动时,需要使用ScrollView。例如下面主页的菜单:

            

        每个菜单选项都是一个图标加一个文字,可以通过flatlist来渲染,菜单可以左右滑动来切换两个FlatList,需要在外面包装一个ScrollView组件。

<View style={styles.menu}>
  <ScrollView horizontal={true} showsHorizontalScrollIndicator={false}
              pagingEnabled={true} onMomentumScrollEnd={(e)=>this.slideMenu(e)}
  >
    <FlatList data={this.props.listData[0]} style={styles.menuList}
              keyExtractor = {(item, index) => index.toString()}
              renderItem={this.renderMenuItem} numColumns={5}
              columnWrapperStyle={styles.menuColumn}
    />
    <FlatList data={this.props.listData[1]} style={styles.menuList}
              keyExtractor = {(item, index) => index.toString()}
              renderItem={this.renderMenuItem} numColumns={5}
              columnWrapperStyle={styles.menuColumn}
    />
  </ScrollView>
  <View style={styles.indicateBar}>
    {/*渲染底部指示标签点*/}
    {this.renderIndicate()}
  </View>
</View>

5、动态数据加载

    App中的数据不可能是写死的,而是从网络上随时动态请求的,但是在页面中呈现的格式却是固定的。我们可以动态的从网络上请求数据,然后将这些数据通过属性传递给对应的组件模块,就可以实现动态的数据渲染。

    在组件挂载之后利用fetch请求数据并保存到state中:

componentDidMount() {
  let shopUrl="http://api.meituan.com/group/v2/recommend/homepage/city/20?userId=160495643...";
 fetch(shopUrl).then((res)=>res.json())
    .then((resJson)=>{
      this.setState({
        shopList:resJson.data
      });
    }).catch((err)=>{
    console.log(err);
  })
}

    例如以下为一条请求的数据,其中包括渲染标题的颜色、主标题、副标题、图片地址、对应的跳转链接等,

{
        "position": 0,
        "typeface_color": "#ff9900",
        "id": 7486,
        "share": {
            "message": "1元能吃肯德基",
            "url": "http://i.meituan.com/firework/kfchanbao"
        },
        "title": "1元吃肯德基",
        "module": false,
        "maintitle": "1元肯德基",
        "tplurl": "imeituan://www.meituan.com/web?url=http://i.meituan.com/firework/kfchanbao",
        "type": 1,
        "imageurl": "http://p0.meituan.net/w.h/groupop/9aa35eed64db45aa33f9e74726c59d938450.png",
        "solds": 0,
        "deputytitle": "新用户专享"
 }

    接着就需要把这些数据填充到界面上,界面上的显示模块是固定的,例如主页中的活动模块如下:

   

    可以看到活动广告模块可以分为三类:MediumBlock(左上角粉色框)、SmallBlock(绿色框)、LargeBlock(蓝色框),可以将这三类框分别抽象为组件,然后排布到页面上。例如SmallBlock.js:

export default class SmallBlock extends Component {
  render() {
    return (
      <TouchableOpacity style={styles.container}>
        <View>
          <Text style={[{color:this.props.data.typeface_color},styles.title]}>
            {this.props.data.title}
          </Text>
          <Text>{this.props.data.deputytitle}</Text>
        </View>
        <Image source={{uri:this.handleUrl(this.props.data.imageurl)}} style={styles.image}/>

      </TouchableOpacity>
    )
  }
  handleUrl(url){
    let imageUrl='';
    if(url.indexOf('w.h')===-1){
      imageUrl=url;
    }else {//美团的图片url中有w.h字段,代表图片的长与宽,需要替换后才能得到图片
      imageUrl=url.replace('w.h','60.60');
    }
    return imageUrl;
  }
}
    在页面中调用组件,并填充数据:
<SmallBlock data={this.props.shopList[4]}/>

6、反向事件绑定

    例如将商家页面shopScreen.js中的“购物中心”封装成为一个组件ShopCenter,

        

        当点击它时跳转到详情页shopDetail.js,但是在每个ShopCenter组件中是没办法处理跳转事件的,只有在ShopScreen类中才可以访问到navigation对象,实现跳转。因此需要在ShopScreen中调用ShopCenter组件时,为其绑定一个事件属性onClick(这个属性名可自定),然后在ShopCenter组件中点击时调用该属性触发父组件中对应的事件:

    例如父组件中调用子组件ShopCenter以及绑定onClick属性为jumpDetail函数:

<ShopCenter key={index} data={item} onClick={this.jumpDetail}/>
...
jumpDetail(url){
  navigation.navigate('Detail',url);
}
    其中变量navigation是this.props.navigation,是由StackNavigator传递给它的子组件的,我直接使用时,会报错this.props未定义,于是我把它保存到一个全局变量navigation中,然后再调用其navigate方法。

    在子组件ShopCenter中点击触发jumpTo函数来调用父组件属性onClick

export default class ShopCenter extends Component {
  render() {
    return (
      <TouchableOpacity  style={styles.container}
        onPress={()=>this.jumpTo(this.props.data.detailurl)}
      >
        <Image source={{uri:this.props.data.img}} style={styles.image} />
        <Text style={styles.imageLabel}>{this.props.data.showtext.text}</Text>
        <Text style={styles.name}>{this.props.data.name}</Text>
      </TouchableOpacity>
    )
  }

  jumpTo(detailurl){
    let url=detailurl;//对url进行处理,去掉url前面没用的部分
    url=detailurl.replace('imeituan://www.meituan.com/web/?url=','');
    this.props.onClick({url:url});//触发父组件onOnclick,并传入url参数
  }
}

7、Hybrid开发思维

       App中并不是所有的页面都是写死的,这样很不易于维护与更新。一些页面是通过网页来实现的,在App中点击时跳转到对应的网页。当我们想要修改时,只需要更新在服务器端网页就可以,而不必更新App、重新发布等。这种思维就是一种Hybrid混合开发的思维。

        例如当点击购物中心时跳转到ShopDetail页面,并通过navigation传入对应网页的url,在ShopDetail中只需通过<WebView>组件将网页呈现出来即可。

    

        ShopDetail.js就只有很短几行用于呈现WebView:

export default class ShopDetail extends Component {
  static navigationOptions={
    title:'商场详情',
    headerStyle:{                                 //导航栏样式设置
      backgroundColor:'#8bffce',
    },
  };
  render() {
    let url=this.props.navigation.state.params.url+ '?uuid=5C7B6342814C7B496D836A69C872';
   return (
      <WebView source={{uri: url}}
               javaScriptEnabled={true}
               domStorageEnabled={true}
      />
    )
  }
}

8、打包发布

    之前一直通过debug来将react native安装到手机上,如果需要发行则需要打包生成apk。

8.1、生成签名

    Android要求所有应用都有一个数字签名才会被允许安装在用户手机上,所有首先需要生成一个签名密钥。要通过keytool生成密钥,首先进入jdk下的bin目录,打开cmd输入如下命令

keytool -genkey -v -keystore my-release-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000

    其中my-release-key为密钥库的名字,my-key-alias为密钥库别名可以自定义,接着会出现命令行提示,要求输入相关信息,并设置相关密码,之后会在当前目录下生成my-release-key.keystore文件。

    把该文件拷贝到react native工程下的android/app目录下

8.2设置全局gradle

    在C:\Users\你的用户名\.gradle目录下新建gradle.properties文件,并在其中输入如下内容:

MYAPP_RELEASE_STORE_FILE=my-release-key.keystore     密钥库的名字
MYAPP_RELEASE_KEY_ALIAS=my-key-alias                 密钥库别名
MYAPP_RELEASE_STORE_PASSWORD=*****                   密钥库密码 
MYAPP_RELEASE_KEY_PASSWORD=*****                     密钥密
      我的密钥密码与库密码一致

8.3、配置项目的gradle文件

    打开react native项目下的android/app/build.gradle文件,添加如下内容

android {
    ...
    defaultConfig { ... }
    signingConfigs {
        release {
            storeFile file(MYAPP_RELEASE_STORE_FILE)
            storePassword MYAPP_RELEASE_STORE_PASSWORD
            keyAlias MYAPP_RELEASE_KEY_ALIAS
            keyPassword MYAPP_RELEASE_KEY_PASSWORD
        }
    }
    buildTypes {
        release {
            ...
            signingConfig signingConfigs.release
        }
    }
}
    

8.4、生成apk

    进入react native项目的android目录下执行cmd命令:

gradlew assembleRelease
    生成的apk文件位于项目的android/app/build/outputs/apk/app-release.apk


在GitHub上的代码仓库为:https://github.com/SuperTory/React-Native-ECommerce

2016-08-31 22:12:47 wxq888 阅读数 4692

不仅可以在react native 的js界面和现有工程的界面之间跳转,而且可以把js写的界面当成一个控件,嵌入到现有的activity,作为原生界面的一部分使用。

第一节:按照官方的例子,把js写页面放在一个activity,在原生应用里启动该activity。


开始之前,你要搭好react native开发Android环境,我是在mac上搭建的IDE,具体参看我前面的blog。本文以一个hello world为例。


第一步先在Android studio中建立Hello world程序。
第二步:进入你工程的根目录,在命令行运行下面的命令:
$ npm init  
这个命令会创建package.json文件,输入这个命令后,会提示你输入一系列参数。按照提示输入:我输入的

参数如下:
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (myAppWithTest) integrate  // 输入项目名称
version: (1.0.0) // 回车,使用默认版本号
description: test native for react // 输入项目描述
entry point: (index.js) // 回车,使用默认文件名
test command:     // 回车,使用默认值
git repository: // 回车留空或填入Git地址
keywords: react test // 填写关键字react和test
author: andy // 填写作者
license: (ISC) // 回车,使用默认值


上面填完后,再输入下面的命令:
$ npm install --save react react-native
$ curl -o .flowconfig https://raw.githubusercontent.com/facebook/react-native/master/.flowconfig

上面的命令执行后,会创建node_modules目录。
第三步:打开在根目录下刚才创建好的package.json文件,添加下面一行

"start": "node node_modules/react-native/local-cli/cli.js start"


第四步:编辑index.android.js文件,加入简单的代码,你可以copy下面的代码到该文件:

'use strict';

import React from 'react';
import {
  AppRegistry,
  StyleSheet,
  Text,
  View
} from 'react-native';

class HelloWorld extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.hello}>Hello, Andy</Text>
      </View>
    )
  }
}
var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
  },
  hello: {
    fontSize: 20,
    textAlign: 'center',
    margin: 10,
  },
});

AppRegistry.registerComponent('HelloWorld', () => HelloWorld);


第五步:在你app的build.gradle文件中添加react native依赖库

compile "com.facebook.react:react-native:+"  // From node_modules

第六步:在你project的 build.gradle文件中添加 react native路径:

allprojects {
    repositories {
        ...
        maven {
            // All of React Native (JS, Android binaries) is installed from npm
            url "$rootDir/node_modules/react-native/android"
        }
    }
    ...
}


第七步:在你的AndroidManifest.xml文件中添加网络权限

<uses-permission android:name="android.permission.INTERNET" />

第八步:创建一个加载JS代码的activity,activity的代码如下:

public class MyReactActivity extends Activity implements DefaultHardwareBackBtnHandler {
    private ReactRootView mReactRootView;
    private ReactInstanceManager mReactInstanceManager;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);


        mReactRootView = new ReactRootView(this);
        mReactInstanceManager = ReactInstanceManager.builder()
                .setApplication(getApplication())
                .setBundleAssetName("index.android.bundle")
                .setJSMainModuleName("index.android")
                .addPackage(new MainReactPackage())
                .setUseDeveloperSupport(BuildConfig.DEBUG)
                .setInitialLifecycleState(LifecycleState.RESUMED)
                .build();
        mReactRootView.startReactApplication(mReactInstanceManager, "HelloWorld", null);


        setContentView(mReactRootView);
    }


    @Override
    public void invokeDefaultOnBackPressed() {
        super.onBackPressed();
    }
}

第九步:在AndroidManifest.xml文件中,为刚才创建的activity指定一个主题,

 <activity
   android:name=".MyReactActivity"
   android:label="@string/app_name"
   android:theme="@style/Theme.AppCompat.Light.NoActionBar">
 </activity>

第十步:添加一些activity的生命周期函数,并添加一些代码:

@Override
protected void onPause() {
    super.onPause();


    if (mReactInstanceManager != null) {
        mReactInstanceManager.onHostPause();
    }
}


@Override
protected void onResume() {
    super.onResume();


    if (mReactInstanceManager != null) {
        mReactInstanceManager.onHostResume(this, this);
    }
}


@Override
protected void onDestroy() {
    super.onDestroy();


    if (mReactInstanceManager != null) {
        mReactInstanceManager.onHostDestroy();
    }
}

@Override
 public void onBackPressed() {
    if (mReactInstanceManager != null) {
        mReactInstanceManager.onBackPressed();
    } else {
        super.onBackPressed();
    }
}

第十一步:在MyReactActivity中添加按键响应函数:

@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_MENU && mReactInstanceManager != null) {
        mReactInstanceManager.showDevOptionsDialog();
        return true;
    }
    return super.onKeyUp(keyCode, event);
}

第十二步:在hello world程序的界面上添加一个按钮,加载MyReactActivity。

Button bt = (Button)findViewById(R.id.start_react);
bt.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Intent intent = new Intent(MainActivity.this,MyReactActivity.class);
        startActivity(intent);
    }
});

第十三步:到这里基本可以在android studio中运行程序了,先在命令行启动js所需的服务器,执行下面的命令即可:

$ npm start

最后,在android studio,像启动其他程序一样运行程序,点击按钮就可以加载react native界面了。激动吧。如下图:




第二节:将react native 写的界面当成组建嵌入到现有的activity。

 这个其实比较简单,新建一个布局xml文件,在上面的创建的MyReactActivity的onCreate函数中,像原生一样调用setContentView(R.layout.native_js); 然后通过addView 把reactnative 的界面,加入进入。下面是完整的onCreate函数代码:

public class MyReactActivity extends Activity implements DefaultHardwareBackBtnHandler {
    private ReactRootView mReactRootView;
    private ReactInstanceManager mReactInstanceManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.native_js);
        mReactRootView = new ReactRootView(this);
        mReactInstanceManager = ReactInstanceManager.builder()
                .setApplication(getApplication())
                .setBundleAssetName("index.android.bundle")
                .setJSMainModuleName("index.android")
                .addPackage(new MainReactPackage())
                .setUseDeveloperSupport(BuildConfig.DEBUG)
                .setInitialLifecycleState(LifecycleState.RESUMED)
                .build();
        mReactRootView.startReactApplication(mReactInstanceManager, "HelloWorld", null);

        LinearLayout view = (LinearLayout) findViewById(R.id.react_root);
        view.addView(mReactRootView);

    }


编译运行,如下图,灰色是原声界面部分,蓝色为react native界面:



问题:今天【20160908】将react native嵌入到原生应用后,运行碰到下面的错误。

解决办法:我另外用命令react-native init  test.新建了一个应用,然后把新工程node_modules目录下的react目录copy到了对应的位置。











2020-01-14 20:53:36 sinat_17775997 阅读数 463

2020 年谈 React Native,在日新月异的前端圈,可能算比较另类了。文章动笔之前我也犹豫过,但是想到写技术文章又不是赶时髦,啥新潮写啥,所以还是动笔写了这篇 React Native 性能优化的文章。

本文谈到的 React Native 性能优化,还没到修改 React Native 源码那种地步,所以通用性很强,对大部分 RN 开发者来说都用得着。

本文的内容,一部分是 React/RN/Android/iOS 官方推荐的优化建议,一部分是啃源码发现的优化点,还有一部分是可以解决一些性能瓶颈的优秀的开源框架。本文总结的内容你很少在网络上看到,所以看完后一定会有所收获。如果觉得写的不错,请不要吝啬你的赞,把这篇 1w 多字的文章分享出去,让更多的人看到。

看文章前要明确一点,一些优化建议并不是对所有团队都适用的。有的团队把 React Native 当增强版网页使用,有的团队用 React Native 实现非核心功能,有的团队把 React Native 当核心架构,不同的定位需要不同的选型。对于这些场景,我在文中也会提一下,具体使用还需要各位开发者定夺。

 

目录:

  • 一、减少 re-render
  • 二、减轻渲染压力
  • 三、图片优化那些事
  • 四、对象创建调用分离
  • 五、动画性能优化
  • 六、长列表性能优化
  • 七、React Native 性能优化用到的工具
  • 八、推荐阅读

 

一、减少 re-render

因为 React Native 也是 React 生态系统的一份子,所以很多 React 的优化技巧可以用到这里,所以文章刚开始先从大家最熟悉的地方开始。

对于 React 来说,减少 re-render 可以说是收益最高的事情了。

1️⃣ shouldComponentUpdate

📄 文档
https://react.docschina.org/docs/optimizing-performance.html#shouldcomponentupdate-in-actionsx

简单式例:

class Button extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.color !== nextProps.color) {
      return true;
    }
    return false;
  }

  render() {
    return <button color={this.props.color} />;
  }
}

无论哪篇文章,谈到 React 性能优化,shouldComponentUpdate 一定是座上宾。

我们通过这个 API,可以拿到前后状态的 state/props,然后手动检查状态是否发生了变更,再根据变更情况来决定组件是否需要重新渲染。

🔗 官方文档对 shouldComponentUpdate 的作用原理和使用场景已经说的非常清晰了,我就没有必要搬运文章了。在实际项目中,阅文集团的 🔗 React Native 应用「元气阅读」也做了很好的示范,🔗 Twitter 的性能优化分享也做的图文并茂,可有很高的参考价值,对此感兴趣的同学可以点击跳转查看。

在此我想提醒的是,shouldComponentUpdate 是强业务逻辑相关的,如果使用这个 API,你必须考虑和此组件相关的所有 props 和 state,如果有遗漏,就有可能出现数据和视图不统一的情况。所以使用的时候一定非常小心。

在此我想提醒的是,shouldComponentUpdate 是强业务逻辑相关的,如果使用这个 API,你必须考虑和此组件相关的所有 props 和 state,如果有遗漏,就有可能出现数据和视图不统一的情况。所以使用的时候一定非常小心。

2️⃣ React.memo

📄 文档:https://react.docschina.org/docs/react-api.html#reactmemo

React.memo 是 React v16.6 中引入的新功能,是一个专门针对 React 函数组件的高阶组件。

默认情况下,它和 PureComponent 一样,都是进行浅比较,因为就是个高阶组件,在原有的组件上套一层就可以了:

const MemoButton = React.memo(function Button(props) {
  return <button color={this.props.color} />;
});

如果想和 shouldComponentUpdate 一样,自定义比较过程,React.memo 还支持传入自定义比较函数:

function Button(props) {
  return <button color={this.props.color} />;
}
function areEqual(prevProps, nextProps) {
  if (prevProps.color !== nextProps.color) {
      return false;
    }
  return true;
}
export default React.memo(MyComponent, areEqual);

值得注意的是areEqual() 这个函数的返回值和 shouldComponentUpdate 正好相反,如果 props 相等,areEqual()返回的是 trueshouldComponentUpdate 却返回的是 false

3️⃣ React.PureComponent

📄 文档:https://react.docschina.org/docs/react-api.html#reactpurecomponent

简单式例:

class PureComponentButton extends React.PureComponent {
  render() {
    return <button color={this.props.color} />;
  }
}

和 shouldComponentUpdate 相对应,React 还有一个类似的组件 React.PureComponent,在组件更新前对 props 和 state 做一次浅比较。所以涉及数据嵌套层级过多时,比如说你 props 传入了一个两层嵌套的 Object,这时候 shouldComponentUpdate 就很为难了:我到底是更新呢还是不更新呢?

考虑到上面的情况,我在项目中一般很少用 PureComponent虽然很简单易用,但是面对复杂逻辑时,反而不如利用 shouldComponentUpdate 手动管理简单粗暴。当然这个只是个人的开发习惯,社区上也有其他的解决方案:

  • 把组件细分为很小的子组件,然后统一用 PureComponent 进行渲染时机的管理
  • 使用 immutable 对象,再配合 PureComponent 进行数据比较(🔗 参考链接:有赞 React 优化
  • ......

在这个问题上仁者见仁智者见智,在不影响功能的前提下,主要是看团队选型,只要提前约定好,其实在日常开发中工作量都是差不多的(毕竟不是每个页面都有必要进行性能优化)。

 

二、减轻渲染压力

React Native 的布局系统底层依赖的是 🔗 Yoga 这个跨平台布局库,将虚拟 DOM 映射到原生布局节点的。在 Web 开发中,99% 的情况下都是一个 Virtual DOM 对应一个真实 DOM 的,那么在 React Native 中也是一一对应的关系吗?我们写个简单的例子来探索一下。

我们先用 JSX 写两个橙色底的卡片,除了卡片文字,第一个卡片还嵌套一个黄色 View,第二个卡片嵌套一个空 View:

// 以下示例 code 只保留了核心结构和样式,领会精神即可
render() {
  return (
    <View>
      <View style={{backgroundColor: 'orange'}}>
        <View style={{backgroundColor: 'yellow'}}>
          <Text>Card2</Text>
        </View>
      </View>
      <View style={{backgroundColor: 'orange'}}>
        <View>
          <Text>Card2</Text>
        </View>
      </View>
    </View>
  );
};

用 react-devtools 查看 React 嵌套层级时如下所示:

从上图中可以看出,React 组件和代码写的结构还是一一对应的。

我们再看看 React Native 渲染到原生视图后的嵌套层级(iOS 用 Debug View Hierarchay,Android 用 Layout Inspector):

从上图可以看出,iOS 是一个 React 节点对应一个原生 View 节点的;Android 第二个卡片的空白 View 却不见了!

如果我们翻一翻 React Native 的源码,就会发现 React Native Android UI 布局前,会对只有布局属性的 View(LAYOUT_ONLY_PROPS 源码)进行过滤,这样可以减少 View 节点和嵌套,对碎片化的 Android 更加友好。

通过这个小小的例子我们可以看出,React 组件映射到原生 View 时,并不是一一对应的,我们了解了这些知识后,可以如何优化布局呢?

1️⃣ 使用 React.Fragment 避免多层嵌套

📄 React Fragments 文档:https://zh-hans.reactjs.org/docs/fragments.html

我们先从最熟悉的地方讲起——React.Fragment。这个 API 可以让一个 React 组件返回多个节点,使用起来很简单:

render() {
  return (
    <React.Fragment>
      <ChildA />
      <ChildB />
      <ChildC />
    </React.Fragment>
  );
}

// 或者使用 Fragment 短语法
render() {
  return (
    <>
      <ChildA />
      <ChildB />
      <ChildC />
    </>
  );
}

Fragments 作用还是蛮明显的:避免你多写一层 View。用处还是很广的,比如说自己业务上封装的 React 组件,React Native 官方封装的组件(比如说 ScrollView or Touchable* 组件 ),活用这个属性,可以减少你的 View 嵌套层级。

2️⃣ 减少 GPU 过度绘制

我们在业务开发时,经常会遇到这种场景:整个界面的背景色是白色的,上面又加了一个白色背景的卡片组件,卡片内部又包含了一个白色背景的小组件......

// 以下示例 code 只保留了核心结构和样式,领会精神即可
render() {
  return (
    <View>
      <View style={{backgroundColor: 'white'}}>
        <View style={{backgroundColor: 'white'}}>
          <Text style={{backgroundColor: 'white'}}>Card1</Text>
        </View>
      </View>
      <View>
        <View>
          <Text>Card2</Text>
        </View>
      </View>
    </View>
  );
};

首先我们要明确一点,屏幕上的每个像素点的颜色,是由多个图层的颜色决定的,GPU 会渲染这些图层混合后的最终颜色,但是,iOS 和 Android 的 GPU 渲染机制是不一致的。

虽然上面的代码最后的的渲染结果在显示上都是白色的,但是 GPU 的优化是不一样的。我们用 iOS 的 Color Blended Layers 和 Android 的🔗 GPU 过度绘制调试工具查看最后的渲染结果:

对于 iOS 来说,出现红色区域,就说明出现了颜色混合:

  • Card1 的几个 View 都设置了非透明背景色,GPU 获取到顶层的颜色后,就不再计算下层的颜色了
  • Card2 的 Text View 背景色是透明的,所以 GPU 还要获取下一层的颜色进行混合

对于 Android 来说,GPU 会多此一举地渲染对用户不可见的像素。有一个颜色指示条:白 -> 蓝 -> 绿 -> 粉 -> 红,颜色越往后表示过度绘制越严重。

  • Card1 的几个 View 都设置了非透明背景色,红色表示起码发生了 4 次过度绘制
  • Card2 只有文字发生了过度绘制

在过渡绘制这个测试上,iOS 和 Android 的实验结果几乎是完全相反的,所以解决方案肯定不是两全其美的,我个人认为,React Native 开发做视图优化时,应该优先优化 Android,所以我们可以从以下几点优化:

  • 减少背景色的重复设置:每个 View 都设置背景色的话,在 Android 上会造成非常严重的过度绘制;并且只有布局属性时,React Native 还会减少 Android 的布局嵌套
  • 避免设置半透明颜色:半透明色区域 iOS Android 都会引起过度绘制
  • 避免设置圆角:圆角部位 iOS Android 都会引起过度绘制
  • 避免设置阴影:阴影区域 iOS Android 都会引起过度绘制
  • ......

避免 GPU 过度绘制的细节太多了,一般页面不需要这种精细化管理,长列表优化时可以考虑一下这个方向。

 

三、图片优化那些事

性能优化的另一个大头就是图片。这里的图片优化不仅仅指减少图片大小,减少 HTTP 带宽占用,我会更多的讨论一些 Image 组件上的优化,比如说缓存控制,图片采样等技术。

1️⃣ Image 组件的优化项

React Native 的 Image 图片组件,如果只是作为普通的图片展示组件,那它该有的都有了,比如说:

  • 加载本地/网络图片
  • 自动匹配 @2x/@3x 图片
  • 图片加载事件:onLoadStart/onLoad/onLoadEnd/onError
  • loading 默认图 or loading 指示器
  • ......

但是,如果你要把它当一个图片下载管理库用时,就会非常的难受,因为 Image 的这几个属性在 iOS/Android 上有不同的表现,有的实现了有的没有实现,用起来非常不顺手。

在讲解图片优化前,我们先想一下,一个基本的图片下载管理库要实现什么:

  1. 图片类型:首先你的主要职责是加载图片,你起码能加载多种图片类型
  2. 下载管理:在加载多张图片的场景,能管理好多个请求,可以控制图片加载的优先级
  3. 缓存管理:做好三级缓存,不能每个图片都要请求网络,均衡好内存缓存和磁盘缓存的策略
  4. 多图加载:大量图片同时渲染时,如何让图片迅速加载,减少卡顿

针对上面的 4 条原则,我们来一一刨析 Image 组件。

1.图片类型

基础的 png/jpg/base64/gif 格式,支持良好。不过要注意的是,想要 Android 加载的 gif 图片动起来,要在 build.gradle 里面加一些依赖,具体内容可以看这个 🔗 链接

如果要加载 webp 格式的图片,就有些问题了。作为 Google 推出的一种图片格式,Android 自然是支持的,但是 iOS 就不支持了,需要我们安装一些第三方插件。

2.下载管理

先说结论,Image 组件对图片的下载管理能力基本为 0。

Image基本上只能监听单张图片的加载流程:onLoadStart/onLoad/onLoadEnd/onError,如果要控制多张图片的下载优先级,对不起,没有。

3.缓存管理

缓存这里要从两方面说,一是通过 HTTP 头信息管理缓存,二是直接通过一些组件属性管理缓存。

Image 组件请求网络图片时,其实是可以加 HTTP header 头信息的,这样就可以利用 HTTP 缓存来管理图片,写法如下面代码所示:

<Image
  source={{
    uri: 'https://facebook.github.io/react/logo-og.png',
    method: 'POST',
    headers: {
      Pragma: 'no-cache',
    },
    body: 'Your Body goes here',
  }}
  style={{width: 400, height: 400}}
/>

具体的控制参数可以参考 🔗 MDN HTTP 缓存,这里就不细说了。

直接通过属性控制图片缓存,iOS 有。Android?对不起,没有。

iOS 可以通过 source 参数里的 cache 字段控制缓存,属性也是非常常见的那几种:默认/不使用缓存/强缓存/只使用缓存。具体的使用可以看 🔗 iOS Image 缓存文档

4.多图加载

都快到 5G 时代了,短视频/VLog 大家都天天刷了,更不用说多图场景了,基本上已经是互联网应用的标配了。

讲图片加载前先明确一个概念:图片文件大小 != 图片加载到内存后的大小

我们常说的 jpg png webp,都是原图压缩后的文件,利于磁盘存储和网络传播,但是在屏幕上展示出来时,就要恢复为原始尺寸了。

React Native 性能优化——图片内存优化

比如说一张 100x100 的 jpg 图片,可能磁盘空间就几 kb,不考虑分辨率等问题,加载到内存里,就要占用 3.66 Mb。

// 不同的分辨率/文件夹/编码格式,都会带来数值差异
// 下面的计算只是最一般的场景,领会精神即可

(100 * 100 * 3) / (8 * 1024) = 3.66 Mb
(长 * 宽 * 每个像素占用字节数) / (8 * 1024) = 3.66 Mb

上面只是 100x100 的图片,如果图片尺寸增加一倍,图片在内存里的大小是按平方倍数增长的,数量一多后,内存占用还是很恐怖的。

在多图加载的场景里,经过实践,iOS 不管怎么折腾,表现都比较好,但是 Android 就容易出幺蛾子。下面我们就详细说说 Android 端如何优化图片。

在一些场景里,Android 会内存爆涨,帧率直接降为个位数。这种场景往往是小尺寸 Image 容器加载了特别大的图片,比如说 100x100 的容器加载 1000x1000 的图片,内存爆炸的原因就是上面说的原因。

那么这种问题怎么解决呢?Image 有个 resizeMethod 属性,就是解决 Android 图片内存暴涨的问题。当图片实际尺寸和容器样式尺寸不一致时,决定以怎样的策略来调整图片的尺寸。

  • resize小容器加载大图的场景就应该用这个属性。原理是在图片解码之前,会用算法对其在内存中的数据进行修改,一般图片大小大概会缩减为原图的 1/8。
  • scale:不改变图片字节大小,通过缩放来修改图片宽高。因为有硬件加速,所以加载速度会更快一些。
  • auto:文档上说是通过启发式算法自动切换 resize 和 scale 属性。这个启发式算法非常误导人,第一眼看上去还以为是会对比容器尺寸和图片尺寸采用不同策略。但我看了一下源码,它只是单纯的判断图片路径,如果是本地图片,就会用 resize,其他都是 scale 属性,所以 http 图片都是 scale 的,我们还得根据具体场景手动控制。

顺便提一下,Android 图片加载的时候,还会有一个 easy-in 的 300ms 加载动画效果,看上去会觉得图片加载变慢了,我们可以通过设置 fadeDuration 属性为 0,来关闭这个加载动画。

2️⃣ 优先使用 32 位色彩深度的图片

📄 色彩深度 wiki:https://github.com/DylanVann/react-native-fast-image/blob/master/README.md

色彩深度这个概念其实前面也提了一下,比如说我们常用的带透明度 PNG 图片,就是 32 位的:

  • R:红色,占据 8 bit
  • G:绿色,占据 8 bit
  • B:蓝色,占据 8 bit
  • A:透明通道,占据 8 bit

为啥推荐使用 32 bit 图片呢?直接原因有 2 个:

  1. Android 推荐使用 🔗 ARGB_8888 格式的图片,因为这种图片显示效果更好
  2. iOS GPU 只支持加载 32 bit 的图片。如果是其他格式的(比如说 24 bit 的 jpg),会先在 CPU 里转为 32 bit,再传给 GPU

 

虽然推荐 32 bit 图片,但是说实话,这个对前端开发是不可控的,因为图片来源一般就 2 个:

  1. 设计师的切图,由设计师控制
  2. 网络上的图片,由上传者控制

所以想针对这一点进行优化的话,沟通成本挺高,收益反而不高(一般只在长列表有些问题),但也是图片优化的一个思路,故放在这一节里。

3️⃣ Image 和 ImageView 长宽保持一致

前面举了一个 100x100 的 ImageView 加载 1000x1000 Image 导致 Android 内存 OOM 的问题,我们提出了设置 resizeMethod={'resize'} 的方法来缩减图片在内存中的体积。其实这是一种无奈之举,如果可以控制加载图片的大小,我们应该保持 Image 和 ImageView 长宽一致。

首先我们看看长宽不一致会引起的问题:

  • Image 小于 ImageView:图片不清晰,表情包电子包浆质感
  • Image 大于 ImageView:浪费内存,有可能会引起 OOM
  • 尺寸不一致会带来抗锯齿计算,增加了图形处理负担

React Native 开发时,布局使用的单位是 pt,和 px 存在一个倍数关系。在加载网络图片时,我们可以使用 React Native 的 🔗 PixelRatio.getPixelSizeForLayoutSize 方法,根据不同的分辨率加载不同尺寸的图片,保证 Image 和 ImageView 长宽一致。

4️⃣ 使用 react-native-fast-image

📄 react-native-fast-image 文档:https://github.com/DylanVann/react-native-fast-image/blob/master/README.md

经过上面的几个 Image 属性分析,综合来看,Image 组件对图片的管理能力还是比较弱的,社区上有个 Image 组件的替代品:react-native-fast-image

它的底层用的是 🔗 iOS 的 SDWebImage 和 🔗 Android 的 Glide。这两个明星图片下载管理库,原生开发同学肯定很熟悉,在缓存管理,加载优先级和内存优化上都有不错的表现。而且这些属性都是双平台可用,这个库都封装好了,但是官网上只有基础功能的安装和配置,如果想引入一些功能(比如说支持 WebP),还是需要查看 SDWebImage 和 Glide 的文档的。

引入前我还是想提醒一下,React Native 的 Android Image 组件底层封装了 FaceBook 的 Fresco,引入这个库相当于又引入了 Glide,包体积不可避免的会变大,所以引入之前可能还要均衡一下。

5️⃣ 图片服务器辅助

前面说的都是从 React Native 侧优化图片,但是一个产品从来不是单打独斗,借助服务端的力量其实可以省很多事。

1.使用 WebP

WebP 的优势不用我多说,同样的视觉效果,图片体积会明显减少。而且可以显著减小 CodePush 热更新包的体积(热更新包里,图片占用 90% 以上的体积)。

虽然 WebP 在前端解压耗时可能会多一点点,但是考虑到传输体积缩小会缩短网络下载时间,整体的收益还是不错的。

2.图床定制图片

一般比较大的企业都有内建图床和 CDN 服务,会提供一些自定制图片的功能,比如说指定图片宽高,控制图片质量。当然一些比较优秀的第三方对象存储也提供这些功能,比如说🔗 七牛云 图片处理

借用云端图片定制功能,前端可以轻松通过控制 URL 参数控制图片属性

比如说 Android 通过 resizeMethod 的 resize 更改图片字节大小,虽然也可以解决问题,但是这个算法还是在前端运行的,还是会占用用户内存资源。我们把链接改成:

https://www.imagescloud.com/image.jpg/0/w/100/h/100/q/80
// w: 长为 100 px
// h: 宽最多为 100 px
// q: 压缩质量为 80

这样子就可以把计算转移到服务端,减少前端的 CPU 占用,优化前端整体的性能。

 

四、对象创建调用分离

对象创建和调用分离,其实更多的是一种编码习惯。

我们知道在 JavaScript 里,啥都是对象,而在 JS 引擎里,创建一个对象的时间差不多是调用一个已存在对象的 10 多倍。在绝大部分情况下,这点儿性能消耗和时间消耗根本不值一提。但在这里还是要总结一下,因为这个思维习惯还是很重要的。

1️⃣ public class fields 语法绑定回调函数

📄 文档:https://zh-hans.reactjs.org/docs/handling-events.html

作为一个前端应用,除了渲染界面,另一个重要的事情就是处理用户交互,监听各种事件。所以在组件上绑定各种处理事件也是一个优化点。

在 React 上如何处理事件已经是个非常经典的话题了,我搜索了一下,从 React 刚出来时就有这种文章了,动不动就是四五种处理方案,再加上新出的 Hooks,又能玩出更多花样了。

最常见的绑定方式应该是直接通过箭头函数处理事件:

class Button extends React.Component {
  handleClick() {
    console.log('this is:', this);
  }

  render() {
    return <button onClick={(e) => this.handleClick(e)}>Click me</button>;
  }
}

但这种语法的问题是每次 Button 组件重新渲染时,都会创建一个 handleClick() 函数,当 re-render 的次数比较多时,会对 JS 引擎造成一定的垃圾回收压力,会引起一定的性能问题。

🔗 官方文档里比较推荐开发者使用 🔗 public class fields 语法 来处理回调函数,这样的话一个函数只会创建一次,组件 re-render 时不会再次创建:

class Button extends React.Component {
  // 此语法确保 handleClick 内的 this 已被绑定。
  handleClick = () => {
    console.log('this is:', this);
  }

  render() {
    return <button onClick={this.handleClick}>Click me</button>;
  }
}

在实际开发中,经过一些数据对比,因绑定事件方式的不同引起的性能消耗基本上是可以忽略不计的,re-render 次数过多才是性能杀手。但我认为这个意识还是有的,毕竟从逻辑上来讲,re-render 一次就要创建一个新的函数是真的没必要。

2️⃣ public class fields 语法绑定渲染函数

这个其实和第一个差不多,只不过把事件回调函数改成渲染函数,在 React Native 的 Flatlist 中很常见。

很多新人使用 Flatlist 时,会直接向 renderItem 传入匿名函数,这样每次调用 render 函数时都会创建新的匿名函数:

render(){
  <FlatList
    data={items}
    renderItem={({ item }) => <Text>{item.title}</Text>}
  />
}

改成 public class fields 式的函数时,就可以避免这个现象了:

renderItem = ({ item }) => <Text>{item.title}</Text>;

render(){
  <FlatList
    data={items}
    renderItem={renderItem}
  />
}

同样的道理,ListHeaderComponent 和 ListFooterComponent 也应该用这样写法,预先传入已经渲染好的 Element,避免 re-render 时重新生成渲染函数,造成组件内部图片重新加载出现的闪烁现象。

3️⃣ StyleSheet.create 替代 StyleSheet.flatten

📄 文档:https://reactnative.cn/docs/stylesheet/

StyleSheet.create 这个函数,会把传入的 Object 转为优化后的 StyleID,在内存占用和 Bridge 通信上会有些优化。

const styles = StyleSheet.create({
  item: {
    color: 'white',
  },
});

console.log(styles.item) // 打印出的是一个整数 ID

在业务开发时,我们经常会抽出一些公用 UI 组件,然后传入不同的参数,让 UI 组件展示不一样的样式。

为了 UI 样式的灵活性,我们一般会使用 StyleSheet.flatten,把通过 props 传入自定义样式和默认样式合并为一个样式对象:

const styles = StyleSheet.create({
  item: {
    color: 'white',
  },
});

StyleSheet.flatten([styles.item, props.style]) // <= 合并默认样式和自定义样式

这样做的好处就是可以灵活的控制样式,问题就是使用这个方法时,会🔗 递归遍历已经转换为 StyleID 的样式对象,然后生成一个新的样式对象。这样就会破坏 StyleSheet.create 之前的优化,可能会引起一定的性能负担。

当然本节不是说不能用 StyleSheet.flatten通用性和高性能不能同时兼得,根据不同的业务场景采取不同的方案才是正解。

4️⃣ 避免在 render 函数里创建新数组/对象

我们写代码时,为了避免传入 [] 的地方因数据没拿到传入 undefined,经常会默认传入一个空数组:

render() {
  return <ListComponent listData={this.props.list || []}/>
}

其实更好的做法是下面这样的:

const EMPTY_ARRAY = [];

render() {
    return <ListComponent listData={this.props.list || EMPTY_ARRAY}/>
}

这个其实算不上啥性能优化,还是前面再三强调的思路:对象创建和调用分离。毕竟每次渲染的时候重新创建一个空的数组/对象,能带来多大的性能问题?

把 [] 改为统一的 EMPTY_ARRAY 常量,其实和日常编码中避免出现 Magic Number 一样,算一种编程习惯,但我觉得这种优化可以归到这个类别里,所以专门提一下。

 

五、动画性能优化

动画流畅很简单,在大部分的设备上,只要保证 60fps 的帧率就可以了。但要达到这个目标,在 React Native 上还是有些问题的,我画了一张图,描述了目前 React Native 的基础架构(0.61 版本)。

  • UI Thread:在 iOS/Android 上专门绘制 UI 的线程
  • JS Thread:我们写的业务代码基本都在这个线程上,React 重绘,处理 HTTP 请求的结果,磁盘数据 IO 等等
  • other Thread:泛指其他线程,比如说数据请求线程,磁盘 IO 线程等等

上图我们可以很容易的看出,JS 线程太忙了,要做的事情太多了。而且 UI Thread 和 JS Thread 之前通信是异步的(Async Bridge),只要其它任务一多,就很难保证每一帧都是及时渲染的。

分析清楚了,React Native 动画优化的方向自然而然就出来了:

  • 减少 JS Thread 和 UI Thread 之间的异步通信
  • 尽量减少 JS Thread 侧的计算

1️⃣ 开启 useNativeDrive: true

📄 文档:https://facebook.github.io/react-native/docs/animations#using-the-native-driver

JS Thread 和 UI Thread 之间是通过 JSON 字符串传递消息的。对于一些可预测的动画,比如说点击一个点赞按钮,就跳出一个点赞动画,这种行为完全可以预测的动画,我们可以使用 useNativeDrive: true 开启原生动画驱动。

通过启用原生驱动,我们在启动动画前就把其所有配置信息都发送到原生端,利用原生代码在 UI 线程执行动画,而不用每一帧都在两端间来回沟通。如此一来,动画一开始就完全脱离了 JS 线程,因此此时即便 JS 线程被卡住,也不会影响到动画了。

使用也很简单,只要在动画开始前在动画配置中加入 useNativeDrive: true 就可以了:

Animated.timing(this.state.animatedValue, {
  toValue: 1,
  duration: 500,
  useNativeDriver: true // <-- 加上这一行
}).start();

开启后所有的动画都会在 Native 线程运行,动画就会变的非常丝滑顺畅。

经过各种暴力测试,使用原生驱动动画时,基本没有掉帧现象,但是用 JS 驱动动画,一旦操作速度加快,就会有掉帧现象。

值得注意的是,useNativeDriver 这个属性也有着局限性,只能使用到只有非布局相关的动画属性上,例如 transform 和 opacity。布局相关的属性,比如说 height 和 position 相关的属性,开启后会报错。而且前面也说了,useNativeDriver 只能用在可预测的动画上,比如说跟随手势这种动画,useNativeDriver 就用不了的。

2️⃣ 使用 setNativeProps

📄 文档:https://facebook.github.io/react-native/docs/direct-manipulation

setNativeProps 这个属性,相当于直接操作浏览器的 DOM。React 官方一般是不推荐直接操作 DOM 的,但业务场景千变万化,总会遇到一些场景不得不操作 DOM,在React Native 里也是同样的道理。

比如说下面的动图,在屏幕中上下滚动时,y 轴上的偏移可以通过 ScrollView#onScroll 属性开启 useNativeDrive: true 来优化滚动体验。但是我们可以看到,随着上下滑动,圆圈里的数字也是随之变化的。

如果把数字存在 this.state 里, 每次滑动不可避免的要进行大量的 setState,React 端会进行大量的重绘操作,可能会引起掉帧。我们这里就可以用 setNativeProps,避免 React 端重绘,相当于直接修改 DOM 上的数字,这样可以让动画更加流畅。

3️⃣ 使用 InteractionManager

📄 文档:https://facebook.github.io/react-native/docs/interactionmanager

原生应用感觉如此流畅的一个重要原因就是在互动和动画的过程中避免繁重的操作。

在 React Native 里,JS 线程太忙了,啥都要干,我们可以把一些繁重的任务放在 InteractionManager.runAfterInteractions() 里,确保在执行前所有的交互和动画都已经处理完毕。

InteractionManager.runAfterInteractions(() => {
  // ...需要长时间同步执行的任务...
});

在 React Native 官方提供的组件里,PanResponder、Animated,VirtualizedList 都用了 InteractionManager,为的就是平衡复杂任务和交互动画之间的执行时机。

4️⃣ 使用 react-native-reanimated 和 react-native-gesture-handler

📺 视频教程:https://www.youtube.com/channel/UC806fwFWpiLQV5y-qifzHnA

📄 react-native-gesture-handler 文档:https://github.com/software-mansion/react-native-gesture-handler

📄 react-native-reanimated 文档:https://github.com/software-mansion/react-native-reanimated

这两个库是被 Youtube 一个自由软件开发者博主 🔗 William Candillon 安利的,后面查了一下,也是 Expo 默认内置动画库和手势库。

这两个库目的就是替代 React Native 官方提供的🔗 手势库🔗 动画库,除了 API 更加友好,我认为最大的优势是:手势动画是在 UI Thread 运行的

我们在前面也说了,useNativeDrive: true 这个属性,只能用在可预测的动画上。跟随手势的动画,是无法使用这个属性的,所以手势捕捉和动画,都是在 JS 侧动态计算的。

我们举一个简单的例子:小球跟随手势移动

我们先看看 React Native 官方提供的手势动画,可以看到 JS Thread 有大量的计算,计算结果再异步传输到 UI Thread,稍微有些风吹草动,就会引起掉帧。

如果使用 react-native-gesture-handler,手势捕捉和动画都是 UI Thread 进行的,脱离 JS Thread 计算和异步线程通信,流畅度自然大大提升:

所以说,如果要用 React Native 构建复杂的手势动画,使用 react-native-gesture-handler 和 react-native-reanimated,是一个不错的选择,可以大幅度提高动画的流畅度。

5️⃣ 使用 BindingX

📄 BindingX 文档:https://alibaba.github.io/bindingx/guide/cn_introduce

BindingX 是阿里开源的一个框架,用来解决 weex和 React Native上富交互问题,核心思路是将"交互行为"以表达式的方式描述,并提前预置到 Native,避免在行为触发时 JS 与 Native 的频繁通信。

当然,引入上面几个第三方库会肯定会带来一定的学习成本。对于复杂交互的页面,有的团队可能会采用原生组件来代替,比如说🔗 美团外卖就会用原生组件去实现精细动画和强交互模块,所以具体使用还要看团队的技术储备和 APP 场景。

 

六、长列表性能优化

在 React Native 开发中,最容易遇到的对性能有一定要求场景就是长列表了。在日常业务实践中,优化做好后,千条数据渲染还是没啥问题的。

虚拟列表前端一直是个经典的话题,核心思想也很简单:只渲染当前展示和即将展示的 View,距离远的 View 用空白 View 展示,从而减少长列表的内存占用。

在 React Native 官网上,🔗 列表配置优化其实说的很好了,我们基本上只要了解清楚几个配置项,然后灵活配置就好。但是问题就出在「了解清楚」这四个字上,本节我会结合图文,给大家讲述清楚这几个配置。

1️⃣ 各种列表间的关系

React Native 有好几个列表组件,先简单介绍一下:

  • ScrollView:会把视图里的所有 View 渲染,直接对接 Native 的滚动列表
  • VirtualizedList:虚拟列表核心文件,使用 ScrollView,长列表优化配置项主要是控制它
  • FlatList:使用 VirtualizedList,实现了一行多列的功能,大部分功能都是 VirtualizedList 提供的
  • SectionList:使用 VirtualizedList,底层使用 VirtualizedSectionList,把二维数据转为一维数据

还有一些其他依赖文件,有个🔗 博文的图总结的挺好的,我这里借用它的图一下:

我们可以看出 VirtualizedList 才是主演,下面我们结合一些示例代码,分析它的配置项。

2️⃣ 列表配置项

讲之前先写个小 demo。demo 非常简单,一个基于 FlatList 的奇偶行颜色不同的列表。

export default class App extends React.Component {
  renderItem = item => {
    return (
      <Text
        style={{
          backgroundColor: item.index % 2 === 0 ? 'green' : 'blue',
        }}>
        {'第 ' + (item.index + 1) + ' 个'}
      </Text>
    );
  }

  render() {
    let data = [];
    for (let i = 0; i < 1000; i++) {
      data.push({key: i});
    }

    return (
      <View style={{flex: 1}}>
        <FlatList
      data={data}
          renderItem={this.renderItem}
          initialNumToRender={3} // 首批渲染的元素数量
          windowSize={3} // 渲染区域高度
          removeClippedSubviews={Platform.OS === 'android'} // 是否裁剪子视图
      maxToRenderPerBatch={10} // 增量渲染最大数量
          updateCellsBatchingPeriod={50} // 增量渲染时间间隔
          debug // 开启 debug 模式
        />
      </View>
    );
  }
}

VirtualizedList 有个 debug 的配置项,开启后会在视图右侧显示虚拟列表的显示情况。

这个属性文档中没有说,是翻🔗 源码发现的,我发现开启它后用来演示讲解还是很方便的,可以很直观的学习 initialNumToRender、windowSize、Viewport,Blank areas 等概念。

下面是开启 debug 后的 demo 截屏:

上面的图还是很清晰的,右侧 debug 指示条的黄色部分表示内存中 Item,各个属性我们再用文字描述一下:

1.initialNumToRender

首批应该渲染的元素数量,刚刚盖住首屏最好。而且从 debug 指示条可以看出,这批元素会一直存在于内存中。

2.Viewport

视口高度,就是用户能看到内容,一般就是设备高度。

3.windowSize

渲染区域高度,一般为 Viewport 的整数倍。这里我设置为 3,从 debug 指示条可以看出,它的高度是 Viewport 的 3 倍,上面扩展 1 个屏幕高度,下面扩展 1 个屏幕高度。在这个区域里的内容都会保存在内存里。

将 windowSize 设置为一个较小值,能有减小内存消耗并提高性能,但是快速滚动列表时,遇到未渲染的内容的几率会增大,会看到占位的白色 View。大家可以把 windowSize 设为 1 测试一下,100% 会看到占位 View。

4.Blank areas

空白 View,VirtualizedList 会把渲染区域外的 Item 替换为一个空白 View,用来减少长列表的内存占用。顶部和底部都可以有。

上图是渲染图,我们可以利用 react-devtools 再看看 React 的 Virtual DOM(为了截屏方便,我把 initialNumToRender 和 windowSize 设为 1),可以看出和上面的示意图是一致的。

5.removeClippedSubviews

这个翻译过来叫「裁剪子视图」的属性,文档描述不是很清晰,大意是设为 true 可以提高渲染速度,但是 iOS 上可能会出现 bug。这个属性 VirtualizedList 没有做任何优化,是直接透传给 ScrollView 的。

在 0.59 版本的一次 🔗 commit 里,FlatList 默认 Android 开启此功能,如果你的版本低于 0.59,可以用以下方式开启:

removeClippedSubviews={Platform.OS === 'android'}

6.maxToRenderPerBatch 和 updateCellsBatchingPeriod

VirtualizedList 的数据不是一下子全部渲染的,而是分批次渲染的。这两个属性就是控制增量渲染的。

这两个属性一般是配合着用的,maxToRenderPerBatch 表示每次增量渲染的最大数量,updateCellsBatchingPeriod 表示每次增量渲染的时间间隔

我们可以调节这两个参数来平衡渲染速度和响应速度。但是,调参作为一门玄学,很难得出一个统一的「最佳实践」,所以我们在业务中也没有动过这两个属性,直接用的系统默认值。

3️⃣ ListLtems 优化

📄 ListLtems 优化 文档:https://reactnative.cn/docs/optimizing-flatlist-configuration/#list-items

文档中说了好几点优化,其实在前文我都介绍过了,这里再简单提一下:

1.使用 getItemLayout

如果 FlatList(VirtualizedList)的 ListLtem 高度是固定的,那么使用 getItemLayout 就非常的合算。

在源码中(#L1287#L2046),如果不使用 getItemLayout,那么所有的 Cell 的高度,都要调用 View 的 onLayout 动态计算高度,这个运算是需要消耗时间的;如果我们使用了 getItemLayout,VirtualizedList 就直接知道了 Cell 的高度和偏移量,省去了计算,节省了这部分的开销。

在这里我还想提一下几个注意点,希望大家使用 getItemLayout 要多注意一下:

  • 如果 ListItem 高度不固定,使用 getItemLayout 返回固定高度时,因为最终渲染高度和预测高度不一致,会出现页面跳动的问题【🔗 问题链接
  • 如果使用了 ItemSeparatorComponent,分隔线的尺寸也要考虑到 offset 的计算中【🔗 文档链接
  • 如果 FlatList 使用的时候使用了 ListHeaderComponent,也要把 Header 的尺寸考虑到 offset 的计算中【🔗 官方示例代码链接

2.Use simple components & Use light components

使用简单组件,核心就是减少逻辑判断和嵌套,优化方式可以参考「二、减轻渲染压力」的内容。

3.Use shouldComponentUpdate

参考「一、re-render」的内容。

4.Use cached optimized images

参考「三、图片优化那些事」的内容。

5.Use keyExtractor or key

常规优化点了,可以看 React 的文档 🔗 列表 & Key

6.Avoid anonymous function on renderItem

renderItem 避免使用匿名函数,参考「四、对象创建调用分离」的内容。

 

七、React Native 性能优化用到的工具

性能优化工具,本质上还是调试工具的一个子集。React Native 因为它的特殊性,做一些性能分析和调试时,需要用到 RN/iOS/Android 三端的工具,下面我就列举一下我平常用到的工具,具体的使用方法不是本文的重点,如有需要可根据关键词自行搜索。

1.React Native 官方调试工具

这个官网说的很清楚了,具体内容可见🔗 直达链接

2.react-devtools

React Native 是跑在原生 APP 上的,布局查看不能用浏览器插件,所以要用这个基于 Electron 的 react-devtools。写本文时 React Native 最新版本还是 0.61,不支持最新 V4 版本的 react-devtools,还得安装旧版本。具体安装方法可见这个🔗 链接

3.XCode

iOS 开发 IDE,查看分析性能问题时可以用 instruments 和 Profiler 进行调试。

4.Android Studio

Android 开发 IDE,查看性能的话可以使用 Android Profiler🔗 官方网站写的非常详细。

5.iOS Simulator

iOS 模拟器,它的 Debug 可以看一些分析内容。

6.Android 真机 -> 开发者选项

Android 开发者选项有不少东西可看,比如说 GPU 渲染分析和动画调试。真机调试时可以开启配合使用。

 

八、推荐阅读

【React Native 性能优化指南】到此就算写完了,文中内容可能有不严谨 or 错误的地方,请各位前端/iOS/Android 大佬多多指教。

全文参考近 50 个链接,全放文末太占篇幅了,所以我都分散在文章各处了,我以 emoji 表情🔗标记的方式进行提示,大家有疑惑的地方可以去原文查看。

在此我还要推荐一下我以前写的关于 Webpack 的文章,两篇都是全网独创