分类 前端 下的文章

React Native 图表性能优化实践

本文首发于 “广发证券金融科技” 微信公众号,https://mp.weixin.qq.com/s/fubQyZDecKwd4deymg-SKA

背景

广发证券是金融行业中最早大规模使用 React Native 的公司之一,从2016年引入开始,已经在多个端超过30个系统/工具中使用 React Native 技术进行业务开发。在证券交易类APP 中,图表是最常用的表达数据方式之一,股票行情K线图,各种股票工具分析图,基金走势图等,在大量地使用图表进行应用开发中,我们不断探索在浏览器端、混合技术端、原生端如何高性能渲染这些图表,积累不少图表性能优化的实践经验。

以下为易淘金国际 APP 页面截图:

React Native 图表技术背景

在 React Native 中,官方提供的绘图方案只有 ART 组件,而 ART 组件只是一个基础绘图组件,对于复杂的图表场景并不是一个现成的解决方案。对于复杂图表的技术选型,主要有两个常见方案可以选择:WebView 与原生端组件方案。

  • WebView

这种方案是使用 Web 图表库,在网页里绘制图表 ,然后在 React Native 中用 WebView 将网页展示出来。这种方法简单,将 Web 成熟的图表库如:echarts,highCharts 用 WebView 组件封装一下,就能直接使用。

  • 原生端组件

这种方案分两种情况,其中,一种是绘制逻辑由 JS 端负责:Android 与 iOS 成熟的图表组件也很多,也可以将这些成熟的图表组件封装给 JS 端使用,JS 端实现绘制逻辑,原生端负责呈现。这种做法性能比 WebView 方式性能好,但是 React Native 生态中有两端都封装好的成熟的方案较少。另一种绘制逻辑完全由原生端负责:Android 与 iOS 端分别实现图表功能,给JS 端暴露方法直接调用展示。这种方式性能最好,但是需要两端分别负责绘制逻辑,维护成本高。

compare

实际使用的方案

在实际使用场景中,对于 “股票行情 K 线” 这种用户操作频度高,实时交互性强的场景,我们使用了纯原生端绘制的技术方案。

纯原生端绘制 k 线的技术方案,是由客户端团队自研与维护,该方案已经历过百万日活考验,在我司各终端交易APP的行情场景中广泛使用。尽管客户端两端实现带来更高的维护成本,在保证K线绘制性能这个最基本的股票交易行为上,这种方案经历几年后回看依然是值得的。

而对于其他次要场景,比如基金收益折线图,资产配置饼图等,这些图表我们没有自研的方案。在项目前期,需求时间紧可投入人力也不多,为了快速上线,这种场景我们采用成本低的 WebView echarts 方案。

性能优化

WebView 方案虽然使用成本低,但是存在着首次加载速度慢,内存占用高等性能问题。随着后来项目需求迭代进入正轨,团队开始考虑取缔 WebView 图表方案。

开源方案调研

我们调研了 react-native-charts-wrapper ,这个库是对 Android 热门的图表库 MPAndroidChart 与 iOS 受欢迎的图表库 Charts 的封装。尽管有原生端组件支持,性能应该较为理想,但它存在着平台 UI 差异性,文档不全,旧项目移植成本高的问题,因此我们没有采用。

ReactJS 生态里有一个 Web 图表库 victory,并且也有基于 react-native-svg 的 React Native 端的支持 victory-native 。但是 victory 在使用上比较难,文档示例较少且没有复杂示例,而 victory-native 也有不少因为 react-native-svg 的 bug 导致的问题,这也可能是 stars 数不多,不太流行的原因。

在调研了其他项目后,依然没找到理想方案,于是往自研的方向上来。我们的团队 Web 前端开发经验偏多,自然地想从 Web 绘制技术上入手,寻求可能的突破点。

跨平台绘图

Web 底层的绘图能力主要有:Canvas 与 SVG。使用 Canvas 绘图是利用命令式方式调用 Canvas API 。使用 SVG 是使用 svg,path,line 等标签描述绘图,属于声明式的方式。我们要取缔 WebView 方案,就需要提供原生端的 Canvas 或 SVG 组件与绘图能力。

社区已有 ART 与 react-native-svg 组件对 SVG 绘图方式的支持。虽然 echarts 在 Web 上能支持 SVG 绘图,但也只有改动 echarts 源码将底层 Web SVG 组件替换成 ART 或 react-native-svg,才有可能解决平台的兼容性问题。当然这其中肯定还会碰到很多其他的问题,考虑到实施成本很高,于是我们停止了对这个方向的尝试。

而 Canvas 绘图是命令式的 API 调用,这让跨平台更为容易。 echarts 对微信小程序平台的支持,用的就是 Canvas 渲染方式。选用 Canvas 方案,我们可以继续沿用 echarts,原有项目的改动成本不大。然而 React Native 社区并没有原生端支持的 Canvas 组件。尽管有一个 react-native-canvas ,却是基于 WebView 的实现。为此,我们需要自己实现原生端 Canvas 组件与提供 Canvas API。

此外需要提及一点,阿里开源的 GCanvas 项目支持 React Native 平台,但是目前提供的 Canvas API 也还不全,不够成熟,所以我们没有采用。

React Native Canvas 组件

在对 iOS 与 Android 绘图技术方案调研后,我们敲定了 Canvas 组件技术方案:iOS 端使用 Core Graphics ,Android 端使用 graphics Canvas 相关包来实现。

整个项目主要在实现 iOS 与 Android 端的 Canvas API 上花费了很多时间并且踩了不少坑,不过项目还是按照预期完成了。

此外,为了能使用 echarts 、f2 这种已经支持微信小程序的图表库,我们设计了与小程序一样的 draw 方法,为此还需要模拟微信环境。

工作原理

Canvas 组件工作原理如下:

JS 端调用 Canvas API 方法进行绘图,这些方法会转换成绘制命令,经过 bridge 将命令传到原生端,原生端在收到命令后通过反射调用原生端实现的 Canvas API 方法,视图更新,绘制完成。

下面跟大家分享几点项目中的经验:

批量执行绘制方法

不是执行每一个 Canvas API 方法就会马上绘制,反之,我们将 JS 端的绘制命令收集设计成异步的,并且是批量发送到原生端。在所有绘制动作完成后,执行 draw 方法,才会将命令集合发送到原生端。

JS 端批量发送绘制命令的相关方法:

draw(clear = true) {
    this.drawing(clear ? 1 : 0);
    ...
}

drawing(clear) {
  if (this._isAsync) {
    CanvasNativeAPI.drawAsync(this._canvasId, this.actions, clear);
  } else {
    CanvasNativeAPI.drawSync(this._canvasId, this.actions, clear);
  }
}

iOS 端批量执行绘制命令的方法:

- (void)runActions
{
    for (NSDictionary *action in _actions) {
        [delegate invoke:_context method:[action objectForKey:@"method"] arguments:[action objectForKey:@"arguments"]];
    }
}

高性能反射技巧

因为绘制命令是异步批量的,所以原生端需要由命令字符串来调用相关函数方法,因此需要使用反射。

使用反射会带来代码执行的性能问题,为了提高反射性能,我们参考了 React Native 的源码,将需要反射的模块方法,在模块初始化或者首次调用的时候将反射处理的这些动态化方法与对象存储起来以便重复使用。

iOS 端方法首次执行时存储 invocation:

- (void)buildInvocation
{
    SEL selector = NSSelectorFromString(_method);
    NSMethodSignature *methodSignature = [_moduleClass instanceMethodSignatureForSelector:selector];
    _argumentsNumber = methodSignature.numberOfArguments - 2;
    _invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
    _invocation.selector = selector;
};

- (void)invoke:(id)instance arguments:(NSArray *)arguments
{
    if (_invocation == nil) {
        [self buildInvocation];
    }
    ...
}

Android 端模块初始化时存储 method,执行时直接使用:

public void invoke(Object moduleClassInstance, Object[] parameters) {
    Object[] arguments = new Object[parameters.length];
    if (mArgumentExtractors == null) {
      throw new Error("processArguments failed");
    }
    for (int i = 0; i < mArgumentExtractors.length; i++) {
      arguments[i] = mArgumentExtractors[i].extractArgument(parameters, i);
    }

    try {
      mMethod.invoke(moduleClassInstance, arguments);
    } catch (IllegalArgumentException ie) {
      throw new RuntimeException("Could not invoke " + mMethodName, ie);
    } catch (IllegalAccessException iae) {
      throw new RuntimeException("Could not invoke " + mMethodName, iae);
    } catch (InvocationTargetException ite) {
      throw new RuntimeException("Could not invoke " + mMethodName, ite);
    }

  }

同步方法调用

虽然 React Native 官方的模块与组件对外提供给 JS 端都是异步方法,文档也没有提及同步方法,但是 React Native 内部却有些地方使用了同步调用方式。我们找到了使用方式,并在原生端将同步方法暴露给了 JS 端。同步方法在 JS 线程中同步调用,不会走 React Native 的异步队列。

Canvas measureText 的 API 是同步方法,我们相应的也在原生端提供了这个同步方法。此外,对 JS 端发送命令集到原生端的方法,我们 Canvas 组件也提供了同步调用方式,可以满足手势识别等交互实时性强的,需要实时重绘的场景。

对于提供同步方法,iOS 端使用 RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD 宏:

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(id, drawSync
                                    : (NSString *)tag actions
                                    : (id)actions
                                    : (int)clear)
{
    [CanvasAPI draw:tag actions:actions clear:clear];
    return @1;
}

Android 端在 @ReactMethod 装饰器方法中传 isBlockingSynchronousMethod = true 来表示同步方法:

@ReactMethod(isBlockingSynchronousMethod = true)
public Integer drawSync(final String tag, ReadableArray actions, final Integer clear) {
    draw(tag, actions, clear);
    return 1;
}

性能对比

为了测量 Canvas 组件的性能,我们针对 WebView 与 Canvas 分别加载五个相同配置的 echarts 组件进行试验,并观测页面展示前后相关性能数据变化。

为了测量页面加载速度,试验中连续进入 Canvas 与 WebView 两次,来观察加载速度的差异性。

loading-cost-time

很明显,在首次加载速度方面,Canvas 表现正常,而 WebView 可体验到明显的延迟,第二次进入时才正常。

具体性能数据方面上,主要关注内存、FPS、CPU 使用率方面的数据。试验中分别进入 Canvas 与 WebView 一次,其中,内存在每次杀掉进程后单独统计,观测结果如下:

Android 端:

(图一二为内存变化情况,图三为 FPS,图四为 CPU 使用率)

iOS 端:

(图一二为内存变化情况,图三为 FPS,图四为 CPU 使用率)

result-compare

相比于 WebView 方案,iOS 端 Canvas 组件内存使用少,FPS 稳定,Android 端 Canvas 组件 CPU 使用率低,内存使用少,性能理想。

不足与改进之处

虽然 Canvas 组件在性能上比 WebView 优秀,但是目前也存在一些不足:

  • 待完成部分 API

目前还有部分 API 没有实现,主要有:

createImageData
getImageData
putImageData
isPointInPath
isPointInStroke
createLinearGradient
createRadialGradient
createPattern

这些方法虽然不太常用,但是之后也会慢慢实现。

  • iOS CPU 使用率

Canvas 在 iOS 端是使用 Core Graphics 实现,因为是软件绘制,所以对 CPU 利用率高。这个是技术方案的限制,之后可能尝试使用 OpenGL 等 GPU 绘制技术方案来改善。

总结

通过在原生端实现 Canvas API ,我们得以在 React Native 平台上使用 echarts 这样方便成熟的 Web 图表库。我们正逐步将旧项目中 WebView echarts 方案替换为 Canvas echarts 方案,也将在更多的绘图与动画场景中使用 Canvas 组件。

React Web 图表 Native 化

本文探讨了 Web 图表 Native 化的可行性与 React SVG 图表 Native 化的具体操作过程,文中涉及的改造项目目前并不成熟,仅供参考。

引子

图表,作为数据可视化表达具有很重要的作用。在 React Native 中,官方提供的绘图方案只有 ART 组件,而 ART 组件类似于 Web 中的阉割版的 SVG ,只是一个基础绘图组件,并不是一个成熟的图表解决方案。而对于复杂图表的技术选型,主要有两个常见方案可以选择:WebView原生端组件

  • WebView

这种方案是使用 Web 图表库,将图表功能用网页实现 ,然后在 React Native 中利用 WebView 将网页展示出来。这种做法非常简单,稍微封装一下 WebView 组件,就能直接使用 Web 现有的成熟的图表如:Echarts,highCharts 来实现功能。

  • 原生端组件

这种方案分两种情况,其中:

一种是绘制逻辑由 React Native 端负责:Android 与 IOS 成熟的图表组件也很多,也可以将这些成熟的图表组件封装给 React Native 使用,由 React Native 端实现绘制逻辑。这种做法性能比 WebView 方式性能好,但是 React Native 生态中有两端都封装好的成熟的方案较少。

另一种绘制逻辑完全由原生端负责:Android 与 IOS 端分别实现图表功能,React Native 端直接展示。这种方式性能最好,但是要写两套代码成本也很高。

在实际项目当中,笔者也是用了 react-native-echarts 这种采用 WebView 的图表解决方案,对笔者而言,这应该是目前最为简便的方案。可是,除了使用 WebView ,还有没有别的方式能利用这些成熟的 Web 图表库呢?或者,如果要将 Web 图表库复刻到 React Native 有没有什么别的可行的方案?

为了解答这些疑问,笔者将在下文详细介绍 Web 图表 Native 化的可行性React SVG 图表 Native 化 的具体操作过程。

Web 图表 Native 化的可行性

在网页上进行绘图有两种方式:CanvasSVG。使用 Canvas 来绘图是利用命令式方式调用 CanvasRenderingContext2D API。使用 SVG 来绘图是在 DOM 节点里使用相关标签如:svg,path,line 等声明式方式来绘制图形。

如果能在 React Native 中使用 Canvas 或者 SVG,那么复刻 Web 图表技术就是可行的。

从 Canvas 角度看可行性

React Native 需要 Canvas 组件并且需要能提供 CanvasRenderingContext2D API。然而,社区并没有原生端支持的 Canvas 组件,虽然有一个 react-native-canvas 但是也是基于 WebView 的。此外,阿里开源的 GCanvas 项目想做跨平台的渲染引擎,但是目前提供的 Canvas API 也还不全,不够成熟。所以,目前,对于利用 Canvas 实现的 Web 图表库,还没有能用的方案

从 SVG 角度看可行性

SVG 绘图是声明式的,这点与 React 组件的特点是一样的。恰好官方也提供了 ART 组件,社区也有 react-native-svg 这种组件,所以,将用 React + SVG 来实现的 Web 图表组件转换到 React Native 端应该是非常可行

其实,React 生态里有一个 Web 图表库 victory,并且也有基于 react-native-svg 的 React Native 端的支持 victory-native 。但是 victory 在使用上比较难,文档示例较少且没有复杂示例,而 victory-native 也有不少因为 react-native-svg 的 bug 导致的问题,这也可能是 stars 数不多,不太流行的原因。

有了 victory-naitve 作为成功示例,笔者觉得 React SVG 图表 Native 化成功率更高了。那么,具体要怎么做?

React SVG 图表 Native 化

整个 Native 化的目标非常简单,只要让在 Web 端功能正常的图表能在 React Native 端展示出来就行了

虽然 react-native-svg 提供的组件 比 ART 组件更接近 SVG, 但是鉴于 react-native-svg 还有很多 issue 还没解决,而且也不是官方组件,所以笔者选用了 ART 。接着需要选择一个合适的 React Web 基于 SVG 的图表库来进行 Native 化。

笔者调研了两个项目:rechartsreact-charts ,其中:
recharts:star 数较多,比较流行,实现和架构大体同 victory,但没有 native 支持,项目结构稍复杂。
react-charts:star 数很少,但是基本类型图表都有,文档实例也完善,项目稍简单。

笔者认为先从简单的项目入手比较容易,因此就选择 react-charts 这个项目来进行实验。整个实验性项目处理了相关的兼容性问题,主要涉及到 底层组件转换,css 转换 ,Dom API 替换等。下图是改动总览:

react-charts native化

以下详细介绍这几个填坑要点:

底层组件转换

哪些组件需要转换?对于一些抽象的高层组件如 LineChart 等折线图组件是无需做转换的,而底层组件如 HTML 标签如:svg ,这些标签组件就需要做转换,因为 React Native 里面没有,平台不兼容性。转换工作主要是需要将 Web 里面的 HTML 标签,尤其是 SVG 标签转换到 React Native 对应的组件。

  • SVG 标签的转换

笔者选用 React Native 里的 ART 组件对于 Web 的 SVG 标签,来做对应的组件替换。

react-charts 里面用到的 SVG 标签有: svg,g,circle,line,rect,path,text。而在 ART 里,只有 Surface,Group,Shape,Text 等组件。

svg 可以对应到 Surface,g 对应到 Group, text 对应到 Text ,其他的 circle,line,rect,path 都要用 Shape 通过创建 path 路径来绘制相应的组件:Circle,Line,Rect,Path 。比如,依据 SVG 的 line 标签的使用方法,可以通过 ART 的 Shape 组件来创建与之对应的 Line 组件。

line.js 的源码如下:

// @flow
import React from "react";
import { ART } from "react-native";

const { Path, Shape } = ART;
// ...

export default class Line extends React.Component {
  render() {
    const { x1 = 0, x2 = 0, y1 = 0, y2 = 0, ...rest } = this.props;
    const path = new Path()
      .moveTo(x1, y1)
      .lineTo(x2, y2)
      .close();
      
     // ...
     
    return (
      <Shape {...rest}  d={path} />
    );
  }
}

所以,这些 SVG 标签组件大致都能得到对应的 ART 替换组件。

  • 其他 HTML 标签的转换

除了 SVG 标签,react-charts 里面还有用到一些 HTML 标签,如 div,table,tbody,tr,td,strong,span 等标签,这些标签可以用 React Native 里简单的 View,Text 标签来作替换。

html.js 源码如下:

// @flow
import React from "react";
import { View as RawView, Text as RawText } from "react-native";
import transCssStyle from "../utils/transCssStyle";

class View extends React.Component {
  render() {
    const { style, ...rest } = this.props;

    return <RawView {...rest} {...transCssStyle(style)} />;
  }
}

class Text extends React.Component {
  render() {
    const { style, ...rest } = this.props;

    return <RawText {...rest} {...transCssStyle(style)} />;
  }
}

export default {
  Div: View,
  Table: View,
  Tbody: View,
  Tr: View,
  Td: View,
  Strong: Text,
  Span: Text
};

css 转换

Web css 样式也是不能直接在 React Native 里面用的,因此也需要转换。关于 css 转换到 React Native 社区已有比较成熟的方案了,就是:css-to-react-native。好在 react-charts 很多都是用内联样式,如果是用 className + 单独的 css 样式文件的话,处理起来会更麻烦。简单已有成熟方案,但是在实际操作中还是出现了一些问题,主要问题是在对 transform 的处理上。

css-to-react-native 对 trasform 的支持性不好,而且对于 ART 组件,不能直接在 style prop 中用 transform,而是有个单独的 transform prop 与单独的 Tranform 接口来做 tranform 。因此对于 ART 组件,需要应用 ART 的 Tranform 接口生成的 transform。

transTransform.js 的源码如下:

// @flow
import { ART } from "react-native";

const { Transform } = ART;

export default function transTransform(transforms?: Array<number>) {
  if (!transforms) {
    return undefined;
  }

  const transformStyle = transforms.reduce((acc, transform) => {
    if (transform.rotate !== undefined) {
      transform.rotate = transform.rotate.replace(
        /(\d+)deg/g,
        ($input, $1) => $1
      );
    }
    return Object.assign(acc, transform);
  }, {});
  const { translateX, translateY, scaleX, scaleY, rotate } = transformStyle;
  const transform = new Transform();

  if (translateX !== undefined || translateY !== undefined) {
    transform.move(translateX || 0, translateY || 0);
  }

  if (rotate !== undefined) {
    transform.rotate(rotate || 0);
  }

  if (scaleX !== undefined || scaleY !== undefined) {
    transform.scale(scaleX || 1, scaleY || 1);
  }

  return transform;
}

将上文的 ART Line 组件应用到 style 的转换:

// ...
const resolvedStyle = {
 ...defaultStyle,
 ...transCssStyle(style)
};
const transform = transTransform(resolvedStyle.transform);
const path = new Path()
 .moveTo(x1, y1)
 .lineTo(x2, y2)
 .close();

return (
 <Shape {...rest} {...resolvedStyle} d={path} transform={transform} />
);

Dom API 替换

对于 react-charts 的 Dom API 的替换主要涉及到有 getBoundingClientRect点击事件的处理。

  • getBoundingClientRect

在 React Native 中可以用 UIManager 来提供类似的接口,但是在使用上需要进行调整:

由 react-charts 的 el.getBoundingClientRect() 到 React Native 平台上的 getBoundingClientRect(el)

getBoundingClientRect.js 源码如下:

import { findNodeHandle, UIManager } from "react-native";

function transformBoundingClientRect(x, y, width, height, pageX, pageY) {
  return {
    left: pageX,
    top: pageY,
    right: pageX + width,
    bottom: pageY + height,
    width,
    height,
    offsetLeft: x,
    offsetTop: y
  };
}

export async function getBoundingClientRect(ref) {
  return new Promise(resolve => {
    UIManager.measure(findNodeHandle(ref), (...args) => {
      resolve(transformBoundingClientRect(...args));
    });
  });
}
  • 点击事件

React Native 处理点击事件需要用 PanResponder 来处理,因此涉及点击事件处理的部分也需要有所调整。

react-charts 里的写法:

<Svg onTouchStart={this.this.onTouchStart} {...otherProps}/>

需要改为:

panHandlers = createPanResponder({
  start: this.onTouchStart,
  move: this.onTouchMove, 
  end: this.onTouchEnd
});

<Svg {...panHandlers} {...otherProps}/>
  

createPanResponder.js 源码如下:

// @flow
import { PanResponder } from "react-native";

type ResponderFunction = (evt: Object, gestureState: Object) => void;

export default function createPanResponder({
  start,
  move,
  end,
  cancel
}: {
  start?: ResponderFunction,
  move?: ResponderFunction,
  end?: ResponderFunction,
  cancel?: ResponderFunction
}) {
  return PanResponder.create({
    onStartShouldSetPanResponder: (evt, gestureState) => true,
    onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
    onMoveShouldSetPanResponder: (evt, gestureState) => true,
    onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,

    onPanResponderGrant: (evt, gestureState) => {
      // start
      start && start(evt, gestureState);
    },
    onPanResponderMove: (evt, gestureState) => {
      // move
      move && move(evt, gestureState);
    },
    onPanResponderTerminationRequest: (evt, gestureState) => true,
    onPanResponderRelease: (evt, gestureState) => {
      // end
      end && end(evt, gestureState);
    },
    onPanResponderTerminate: (evt, gestureState) => {
      // cancel
      cancel && cancel(evt, gestureState);
    },
    onShouldBlockNativeResponder: (evt, gestureState) => {
      return true;
    }
  }).panHandlers;
}

成果

经过这一番折腾,笔者最终达成了基本目标。这个改造项目可以在: react-charts-native 进行查看 ,读者可以查看 commit 记录了解具体的改动过程。以下是成果截图:

react-charts-native-demo

总结

对笔者而言,本文的这番探索非常有意义。Web 图表库如果能复刻到 React Native 平台,React Native 图表技术选型将会有更多的选择。此外,在有了相对统一的图表框架的基础上,React Native 图表 Web 化也将非常方便

不过,目前笔者对 react-charts Native 化的进展只是停留在实验性的阶段,距离完全复刻 Web 上的成熟型功能还有一段距离。该项目也还有几个部分需要完善的地方:

  • 坐标的精细化

标记过长时展示会有重叠,这点不够完善。

  • 点击交互 (点击事件定位)处理

react-charts 的点击事件处理,不是将事件监听在 svg 元素上而是监听在 svg 里面的 path 等元素,而 ART 组件中除了 Surface 能监听点击事件,其他的 Group,Shape 组件都不行。所以,目前根据手势动态展示图例等功能还不能很好地支持。

  • 支持动画

react-charts 的动画依赖 react-show ,react-show 使用了 css3 transition 动画,这部分改造还没有做,所以没有支持。后期可以动态构造 path d 并结合 Animated 组件来实现动画。

以上部分涉及到具体的绘制逻辑,改动较大,需要在对 react-charts 核心代码有进一步的理解,才能很好地进行改造。笔者之后如有精力,将会持续完善。

React-Native 里怎么写表单业务代码

笔者最近处理了一些大表单的业务需求,本次结合自己的一些实践经验,来谈谈对表单业务代码的一些感悟

引子

我们先看看 React-Native 里面怎么写一个文本输入框:

export default class UselessTextInput extends Component {
  constructor(props) {
    super(props);
    this.state = { text: 'Useless Placeholder' };
  }

  render() {
    return (
      <TextInput
        onChangeText={(text) => this.setState({text})}
        value={this.state.text}
      />
    );
  }
}

为了收集输入框的值,我们需要给表单组件 TextInputvalue 绑定到一个state,并添加 onChangeText 事件来更改对应的 state,这样TextInput输入的值,就能被收集到state里面。

在输入框比较多的情况,我们需要一个一个给输入框加上这样的逻辑。在某些情况下,我们可以用一个共用的事件处理方法来处理。如:

handleChange = (type, value) => {
  this.setState({
    [type]: value
  })
}

render() {
  const { name, age } = this.state
  return (
    <View>
      <TextInput
        onChangeText={(text) => this.setState({name: text})}
        value={name}
      />
      <TextInput
        onChangeText={(text) => this.setState({age: text})}
        value={age}
      />
    </View>
  )
}

这样处理没什么很大的问题。

但是,如果我们的业务需求有很多大表单,需要文本输入框,单选框,复选框,弹出选择框等各种类型的表单组件,我们还需要表单数据收集,常规检验,与错误提示等等。在这些复杂的场景下,我们只会在上面再堆一大堆逻辑,这个时候可以想一下是不是可以做点优化什么的?

笔者在工作中正好遇到了这样的复杂场景,本着组件复用,逻辑复用的原则,笔者觉得应该需要封装一下这些表单组件与逻辑。

目标

在对表单组件做封装之前,我们先来确认一下希望达成的目标:

  • 定制化风格表单组件

    基本表单组件,自定义表单组件的封装,能实现页面布局,组件复用

  • 抽象表单数据收集

    onChangeText 等处理组件输入数据的同步逻辑可以抽象出来,使其通用

  • 抽象表单检验与错误处理

    能够提供通用常规检验处理与自定义检验方法

目标确认之后,笔者在使用第三方库与自己动手实现,这两个方面进行了思考,最后决定自己实现。理由如下:

  • 即使用了第三方的表单组件,但是UI还是需要定制
  • 第三方库没有非常规的表单组件,但是这种组件也需要将数据收集到表单
  • 核心的表单数据收集逻辑,并不复杂
  • 自己动手,可轻可重,细节可控,便于维护

实现

在编码之前,笔者参考了一些抽象化的表单的使用场景,最终希望实现的代码应该是长成下面的样子:

submit(){
  const values = this.form.getValues();
  if (values) {
    // 提交表单数据
  }
}

render() {
 return (
    <Form ref={(ref) => {
      this.form = ref;
    }}>
    <FormTextInput
      required
      help={"请输入姓名!"}
      name={"name"}
      placeholder={"请输入姓名"}
      defaultValue={""}
    />
    <FormTextInput
      required
      help={"请输入年龄!"}
      name={"age"}
      placeholder={"请输入年龄"}
      defaultValue={""}
    />
   </Form>
 )
}

组件 Form 是表单内数据统一收集检验的载体。

FormTextInput 是对TextInput做了封装的表单组件,需要放到Form里面使用,在UI上符合我们的定制化需求。

FormTextInput接受到的props,传入的name作为字段名,defaultValue 是初始值,help是为空值的错误提示语,requiredhelp搭配,在submit方法调用时,会对该字段做简单的非空检验,如果字段为空值则会提示。

围绕这些特性,笔者细化了一下目标,作为一套表单组件,应该至少有这些功能:

  • 表单组件: 涵盖常用的表单如文本输入框等

  • 数据管理: 赋初始值,保存变化值

  • 表单检验: 简单非空检验,自定义规则检验

首先,实现基本的表单组件,这个是页面布局与功能的基础。依照需求,需要实现文本框,单选框,复选框,选择框等组件,并且定制UI,这一点无需赘述。在这一个过程,我们只是实现UI与单个组件操作逻辑,还没有统一管理数据逻辑。

其次,来看看数据管理,我们需要将数据统一交给Form组件管理,保持输入框值改变了之后能同步保存。这里就需要跨组件进行通信。利用context,可以无需声明props来进行父子组件的通讯,符合要求。

而对于表单检验,也是统一在Form组件里面处理,不过是否需要检验则需要看像FormTextInput这样的组件是否传了相关的条件如required

本文不谈UI层面上的基本表单组件的实现,主要谈谈表单组件数据相关的逻辑

架构

笔者本次实现的表单组件主要是三部分:Form组件,createInput高阶组件(HOC),createInput包装过的表单组件。

架构概览图如下:

form

基本能力

如下是Form组件的实现:

// @flow
// 表单组件抽象,利用 context 与表单内输入框做数据绑定
// 统一管理输入框值,基础检验,自定义检验等

import React, { PureComponent } from "react";
import { View } from "react-native";
import propTypes from "prop-types";
import Toast from "components/toast";
import type { formValueType, formValidationType } from "./flowTypes";

export default class Form extends PureComponent<{
  children: any
}> {
  static childContextTypes = {
    inForm: propTypes.bool,
    setValue: propTypes.func,
    getValue: propTypes.func,
    setValidation: propTypes.func,
    clear: propTypes.func
  };
  _values: formValueType = {};
  _validation: formValidationType = {};
  getChildContext() {
    return {
      inForm: true,
      setValue: this.setValue,
      getValue: this.getValue,
      setValidation: this.setValidation,
      clear: this.clear
    };
  }
  getValues(isValidate: boolean = true) {
    // 获取数据
    if (isValidate) {
      return this.validate() ? this._values : null;
    }
    return this._values;
  }
  validate() {
    // 表单检验
    return Object.keys(this._values).every(name => {
      const validation = this._validation[name];
      const value = this._values[name];
      if (typeof validation === "function") {
        return validation(value);
      }
      if (validation && !value) {
        Toast.show(validation);
        return false;
      }
      return true;
    });
  }
  removeValue(name: string) {
    delete this._values[name];
  }
  removeValidation(name: string) {
    delete this._validation[name];
  }
  clear = (name: string) => {
    this.removeValidation(name);
    this.removeValue(name);
  };
  setValidation = (name: string, help: any) => {
    this._validation[name] = help;
  };
  getValue = (name: string) => this._values[name];
  setValue = (name: string, value: any) => {
    this._values[name] = value;
  };
  render() {
    return <View>{this.props.children}</View>;
  }
}

getChildContext 为子组件提供了一些属性和方法,其中,setValue getValue方法提供了对字段值的读写操作,使得表单组件能够同步数据到Form

 getChildContext() {
    return {
      inForm: true,
      setValue: this.setValue,
      getValue: this.getValue,
      setValidation: this.setValidation,
      clear: this.clear
    };
  }

高级能力

对于Form里面的表单组件,都需要使用 setValue getValue,这是每个表单组件的必要逻辑,我们可以抽象这些逻辑。

React强调组合优于继承的理念,官方推荐的做法是用HOC 高阶组件的方式来复用小组件来构建大组件。使用高阶组件,可以将任何组件进行数据绑定到表单,既能满足常用组件,也能满足自定义组件。

// @flow
// 将输入框绑定到 Form 表单进行管理,支持自定义组件拓展

import React, { PureComponent } from "react";
import propTypes from "prop-types";

export default function createInput(WrappedComponent: any): any {
  return class BaseFormInput extends PureComponent<any> {
    static contextTypes = {
      inForm: propTypes.bool,
      inCollapse: propTypes.bool,
      setValue: propTypes.func,
      getValue: propTypes.func,
      setValidation: propTypes.func,
      clear: propTypes.func
    };
    state = {
      value: this.getInitValue()
    };
    constructor(props: any, context: any) {
      super(props, context);
      if (this.context.inForm !== undefined) {
        this.init();
      }
    }
    init() {
      const { name, validate, required, help } = this.props;
      const { value } = this.state;
      if (name) {
        if (validate) {
          this.context.setValidation(name, validate);
        } else if (required && help) {
          this.context.setValidation(name, help);
        }
        this.context.setValue(name, value);
      }
    }
    getInitValue() {
      const { name, defaultValue } = this.props;
      return defaultValue || (name ? this.context.getValue(name) || "" : "");
    }
    setValue = (value: any) => {
      this.setState({ value });
      const { name } = this.props;
      if (this.context.inForm && name) {
        this.context.setValue(name, value);
      }
    };
    render() {
      return (
        <WrappedComponent
          {...this.props}
          setValue={this.setValue}
          value={this.state.value}
        />
      );
    }
  };
}

利用createInput函数,可以创造各种表单组件,如下是一个文本输入表单组件示例:

export default createInput(
  class ItemTextInput extends PureComponent<any> {
    onChangeText = (value: string) => {
      const { onChange, setValue } = this.props;
      setValue(value);
      onChange && onChange(value);
    };
    render() {
      return (
       <View style={styles.rawInput}>
         <TextInput
           placeholderTextColor={"#999999"}
           onChangeText={this.onChangeText}
           {...otherProps}
         />
       </View>
     );
   }

各个表单组件只需要 setValuevalue来保持数据同步。

总结

通过这些实践,笔者编写的业务代码十分清爽,达到了想要的效果。虽然实现的功能还有一些不全,但是当下还能满足业务需求。

在整个开发的过程中,笔者花的时间最多的不是在这些表单的数据逻辑抽象复用,而是在实现基本的定制化的表单组件上。这也说明了有些优化性的尝试不一定会花掉很多时间,为了提升代码体验和自身的代码能力,花的这些时间也是很有意义的。

题外

笔者在本次实践中使用了两个套路:

  • context组件通信

本次利用了context来进行组件通信,笔者也看到有开源方案用redux来进行数据存储与事件处理

  • HOC高阶组件

这是React推荐的组件复用方法,我们能在很多开源代码如react-redux源码里面看到

React-Redux 工作原理

原理

  • 发布/订阅

connect 过的组件订阅 state变化,dispatch通知所有订阅者更新

  • 渲染时机

每次 dispatchreducer 产生一个新 state 对象,所有 connect 组件判断是否更新,因为是数据的浅比较,所以所需对象引用变了,则该组件重新渲染

  • 性能问题

v5.0 之前存在着一个问题:父(祖先)子(孙)组件都订阅,子(孙)组件可能受到父(祖先)组件渲染影响,而导致多次渲染。v5.0 之后 发布/订阅 模块重写,解决了之前的问题,主要特点:增加层级(嵌套)观察者,保证事件通知与组件更新的顺序

v5 源码浅析

简单分析下 v5 是如何解决性能问题的:

观察者

// 源码:src/utils/Subscription.js

// 添加层级订阅
addNestedSub(listener) {
  this.trySubscribe()
  return this.listeners.subscribe(listener)
}
// 订阅
trySubscribe() {
  if (!this.unsubscribe) {
   this.unsubscribe = this.parentSub
     ? this.parentSub.addNestedSub(this.onStateChange) // 订阅到父(向上找到最开始的祖先)组件
     : this.store.subscribe(this.onStateChange) // 订阅到 redux

   this.listeners = createListenerCollection()
  }
}
// 通知层级订阅
notifyNestedSubs() {
  this.listeners.notify()
}
// 通知
notify() {
   const listeners = current = next
   for (let i = 0; i < listeners.length; i++) {
     listeners[i]()
   }
}

订阅

通过 context 传递 subscription,组件订阅前检查,如果父(祖先)组件已经订阅,则将子组件的回调函数 stateChange 订阅到父(向上找到最开始的祖先)组件的观察者,否则订阅到 redux store(上面的 trySubscribe 方法)

// 源码:src/components/connectAdvanced.js

getChildContext() {
  const subscription = this.propsMode ? null : this.subscription
    return { [subscriptionKey]: subscription || this.context[subscriptionKey] }
}

componentDidMount() {
  ...
  this.subscription.trySubscribe() // 订阅
}

initSubscription() {
  if (!shouldHandleStateChanges) return
    const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey]
    this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this)) // 新建订阅对象
    this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
}

发布更新

  • 父(祖先)组件需要更新,则执行 setState 重新 render, 并在 ComponentDidUpdate 时通知子(孙)组件更新(触发回调函数)
  • 父(祖先)组件不需要更新,直接通知子(孙)组件更新
// 源码:src/components/connectAdvanced.js

onStateChange() {
  this.selector.run(this.props)

  if (!this.selector.shouldComponentUpdate) {
   this.notifyNestedSubs() // 通知
  } else {
   this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
   this.setState(dummyState) // 更新自身
  }
}

notifyNestedSubsOnComponentDidUpdate() {
  // 在 componentDidUpdate 方法通知,确保自身与子组件渲染之后再通知
  this.componentDidUpdate = undefined
  this.notifyNestedSubs()
}

疑问与总结

父(祖先)组件setState明明可能会让子(孙)组件更新,为什么还需要通知一次?

  • 必要性:因为存在这样的场景:子组件可能不更新(ShouldComponentUpdate 返回 false),则需要更新的孙子组件没有受 setState 影响,为了保证所有订阅的组件都能得到数据更新,所以需要通知

  • 无副作用:假设子组件因为父组件 render 而重新 render,因为通知是在父组件 componentDidUpdate 方法触发,而子组件因为已经渲染过,数据得到了更新,所以就算再收到了通知也不会再渲染了

React-Native Animated 动画源码浅析

引子

Aniamted组件是React-Native官方出品的动画组件。在本文开始之前,我们首先来看看来自官网的一个例子,看看如何使用Animated组件。

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

class FadeInView extends React.Component {
  state = {
    fadeAnim: new Animated.Value(0),  // Initial value for opacity: 0
  }

  componentDidMount() {
    Animated.timing(                  // Animate over time
      this.state.fadeAnim,            // The animated value to drive
      {
        toValue: 1,                   // Animate to opacity: 1 (opaque)
        duration: 10000,              // Make it take a while
      }
    ).start();                        // Starts the animation
  }

  render() {
    let { fadeAnim } = this.state;

    return (
      <Animated.View                 // Special animatable View
        style={{
          ...this.props.style,
          opacity: fadeAnim,         // Bind opacity to animated value
        }}
      >
        {this.props.children}
      </Animated.View>
    );
  }
}

// You can then use your `FadeInView` in place of a `View` in your components:
export default class App extends React.Component {
  render() {
    return (
      <View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
        <FadeInView style={{width: 250, height: 50, backgroundColor: 'powderblue'}}>
          <Text style={{fontSize: 28, textAlign: 'center', margin: 10}}>Fading in</Text>
        </FadeInView>
      </View>
    )
  }
}

这个例子展示了如何写一个FadeInView动画组件,呈现了在10s内驱动一个组件的透明度从0到1渐变的过程。

要点

从上面这个例子中,我们可以归纳出Animated动画组件的几个要点:

  • new Animated.Value(0) => 即 Animated.Value 动画值
  • Animated.timing(value, { toValue }).start() => 即Animated.timing动画函数
  • <Animated.View style={ animatedValue }/> => 即Animated.View动画组件

问题

在下文的源码分析中,我们将带着以下几个问题来进行分析:

  • 动画组件 Animated.View style 如何更新?
  • 动画值 Animated.Value 与 动画组件 Animated.View之间有什么关系?
  • 动画函数 Animated.timing 与 动画值 Animated.Value 之间有什么关系?

源码分析

本文分析的源码在:react-native@0.49.3/Libraries/Animated/src

本文分析篇幅仅限不使用原生动画驱动的场景,原生动画驱动使用了NativeAnimatedModule,鉴于笔者没有相关原生开发经验,在此不展开。不过据笔者阅读源码所知:原生动画驱动并不会驱动组件重复渲染,而是直接修改相关属性

调用栈

首先来看看动画执行时的调用栈,之后我们再详细分析:

我们先来了解一下,作为动画过程的关键变化值Animated.Value

动画值Animated.Value

动画值的Animated.ValueAnimatedValue类的实例,源码在:nodes/AnimatedValue.js
AnimatedValue有几个特点:

维护自身值的变化

setValue,__getValue,_updateValue等方法

管理自身调用的其他实例对象

class AnimatedValue extends AnimatedWithChildren (72)

AnimatedValue 继承自 AnimatedWithChildren,使的AnimatedValue实例拥有_children数组属性,与相关操作_childrenaddremove方法。(AnimatedWithChildren之后也有相关介绍)

在自身值变化会触发其他实例对象的update方法

由上图调用栈我们知道:animate => _updateValue => _flush => animateStyle.update (63)

我们先来看下animate方法:

animate(animation: Animation, callback: ?EndCallback): void {
    ......
    animation.start(
      this._value,
      value => {
        // Natively driven animations will never call into that callback, therefore we can always
        // pass flush = true to allow the updated value to propagate to native with setNativeProps
        this._updateValue(value, true /* flush */);
      },
      result => {
        this._animation = null;
        if (handle !== null) {
          InteractionManager.clearInteractionHandle(handle);
        }
        callback && callback(result);
      },
      previousAnimation,
      this,
    );
  }

_updateValue会作为动画函数的回调函数,在数据更新时触发。而_updateValue会更新自身value,并触发flush函数。

我们再来看下flush方法:

function _flush(rootNode: AnimatedValue): void {
  const animatedStyles = new Set();
  function findAnimatedStyles(node) {
    if (typeof node.update === 'function') {
      animatedStyles.add(node);
    } else {
      node.__getChildren().forEach(findAnimatedStyles);
    }
  }
  findAnimatedStyles(rootNode);
  /* $FlowFixMe */
  animatedStyles.forEach(animatedStyle => animatedStyle.update());
}

flush 函数通过向上递归遍历找到最近的使用该 value,并且拥有update方法的实例对象,并触发该类的 update 方法。

接下来我们看看,动画怎么执行。

动画函数 Animated.timing

动画开始start其实是触发AnimatedValue类的animate方法

singleValue.animate(new TimingAnimation(singleConfig), callback);(AnimatedImplementation.js 200)

AnimatedValueanimate方法中,其实是调用了TimingAnimation类的start方法

TimingAnimation类的源码在:animations/TimingAnimation.js

我们来看下start方法:

  start(
    fromValue: number,
    onUpdate: (value: number) => void,
    onEnd: ?EndCallback,
    previousAnimation: ?Animation,
    animatedValue: AnimatedValue,
  ): void {
      ......
    const start = () => {
      // Animations that sometimes have 0 duration and sometimes do not
      // still need to use the native driver when duration is 0 so as to
      // not cause intermixed JS and native animations.
      if (this._duration === 0 && !this._useNativeDriver) {
        this._onUpdate(this._toValue);
        this.__debouncedOnEnd({finished: true});
      } else {
        this._startTime = Date.now();
        if (this._useNativeDriver) {
          this.__startNativeAnimation(animatedValue);
        } else {
          this._animationFrame = requestAnimationFrame(
            this.onUpdate.bind(this),
          );
        }
      }
    };
    if (this._delay) {
      this._timeout = setTimeout(start, this._delay);
    } else {
      start();
    }
  }

即:animation.start => animation.onUpdate (requestAnimationFrame)

onUpdate方法,则是根据当前选择的缓动方法easing计算更新时机,并在requestAnimationFrame触发回调函数(AnimatedValue_udpateValue)。

接下来,是最重要的组件环节了。

动画组件 Animated.View

Animated.View是封装过的React组件,名称是AnimatedComponent,源码在createAnimatedComponent.js

props属性获取

render() {
  const props = this._propsAnimated.__getValue();
  ......
}

this._propsAnimatedAniamtedProps类的实例,AnimatedComponent组件的propsAniamtedProps维护,关于AniamtedProps,之后会详细介绍。

组件更新

AnimatedComponent组件的生命周期方法componentWillMount/componentWillReceiveProps中都调用了_attachProps方法,我们先来看下这个方法:

_attachProps(nextProps) {
  ......
  const callback = () => {
    if (
      !AnimatedComponent.__skipSetNativeProps_FOR_TESTS_ONLY &&
      this._component.setNativeProps
    ) {
      if (!this._propsAnimated.__isNative) {
        this._component.setNativeProps(
          this._propsAnimated.__getAnimatedValue(),
        );
      } else {
        throw new Error(
          'Attempting to run JS driven animation on animated ' +
            'node that has been moved to "native" earlier by starting an ' +
            'animation with `useNativeDriver: true`',
        );
      }
    } else {
      this.forceUpdate();
    }
  };

  this._propsAnimated = new AnimatedProps(nextProps, callback);
  ......
}

即:_attachProps => new AnimatedProps(props, callback(forceUpdate)) (104)

我们注意到,组件将forceUpdate封装成了回调函数,并交给了AniamtedProps

接下来,我们来详细看看AnimatedProps

props维护者AnimatedProps

AnimatedProps的源码在nodes/AnimatedProps.js,该类的构造函数如下:

class AnimatedProps extends AnimatedNode {
  _props: Object;
  _animatedView: any;
  _callback: () => void;

  constructor(props: Object, callback: () => void) {
    super();
    if (props.style) {
      props = {
        ...props,
        style: new AnimatedStyle(props.style),
      };
    }
    this._props = props;
    this._callback = callback;
    this.__attach();
  }

注意这行:this._callback = callback;

Animated.View组件,将触发自身forceUpdate的回调函数传给AnimatedProps类的构造函数,以在适当的时机触发该回调函数。

与此相关的是AnimatedProps的实例方法update

update(): void {
    this._callback();
 }

所以:执行update方法,便会触发从动画组件Animated.View传过来的回调函数,在不使用原生动画驱动的情况下,触发forceUpdate,使组件重新渲染。

style是一个AnimatedStyle类实例对象。AnimatedStyle类下文会详细介绍。

同样的,__attach方法很关键,我们来看一下:

__attach(): void {
    for (const key in this._props) {
      const value = this._props[key];
      if (value instanceof AnimatedNode) {
        value.__addChild(this);
      }
    }
  }

对于使用了继承自AnimatedNode的类实例,该实例会将当前AnimatedProps实例对象,添加到_children数组中寄存,便于之后使用。

在本例中,AnimatedStyle实例对象会将AnimatedProps实例对象添加到_children数组属性中存储。

对于props值的获取,是AnimatedProps类的_getValue方法,该方法如下:

__getValue(): Object {
    const props = {};
    for (const key in this._props) {
      const value = this._props[key];
      if (value instanceof AnimatedNode) {
        if (!value.__isNative || value instanceof AnimatedStyle) {
          // We cannot use value of natively driven nodes this way as the value we have access from
          // JS may not be up to date.
          props[key] = value.__getValue();
        }
      } else if (value instanceof AnimatedEvent) {
        props[key] = value.__getHandler();
      } else {
        props[key] = value;
      }
    }
    return props;
  }

从这里我们知道,对于style属性,AnimatedProps直接取自AnimatedStyle_getValue

接下来,我们来看下AnimatedStyle

style的维护者AnimatedStyle

在例子中以及经过上面的代码分析,我们得知:AnimatedValue的实例对象交给了AnimatedStyle

我们来看看AnimatedStyle的构造函数。

class AnimatedStyle extends AnimatedWithChildren {
    ......
    constructor(style: any) {
    ......
    if (style.transform) {
      style = {
        ...style,
        transform: new AnimatedTransform(style.transform),
      };
    }
    this._style = style;
    }
}

在此说明一下,如果style 中有transform属性,则会创建一个AnimatedTransform类实例来管理transform

我们知道,AnimatedValue也是style的属性,我们来关注下与AnimatedValue相关的有两个主要地方:

  • __attach 方法

该方法保证将使用了AnimatedValue的当前实例被添加到_children数组中。

__attach(): void {
    for (const key in this._style) {
      const value = this._style[key];
      if (value instanceof AnimatedNode) {
        value.__addChild(this);
      }
    }
  }

不像AnimatedProps,在AnimatedStyle类的构造函数中,并没有调用__attach方法。通过观察函数调用栈,我们发现了端倪:

AnimatedWithChildren__addChild方法,会在_children数组为空时,调用__attach方法。因为AnimatedStyle继承自AnimateWithChildren也没有重写__addChild,所以会执行同样的逻辑。

 __addChild(child: AnimatedNode): void {
    if (this._children.length === 0) {
      this.__attach();
    }
    ......
  }

(nodes/AnimatedWithChildren.js)

所以:AnimatedProps__attach方法里,AnimatedStyle 执行__addChild时,便会自动执行__attach完成下层调用关系 (AnimatedValue) 的添加

  • AnimateValue的获取
__getValue(): Object {
    return this._walkStyleAndGetValues(this._style);
  }

_walkStyleAndGetValues(style) {
    const updatedStyle = {};
    for (const key in style) {
      const value = style[key];
      if (value instanceof AnimatedNode) {
        if (!value.__isNative) {
          // We cannot use value of natively driven nodes this way as the value we have access from
          // JS may not be up to date.
          updatedStyle[key] = value.__getValue();
        }
      } else if (value && !Array.isArray(value) && typeof value === 'object') {
        // Support animating nested values (for example: shadowOffset.height)
        updatedStyle[key] = this._walkStyleAndGetValues(value);
      } else {
        updatedStyle[key] = value;
      }
    }
    return updatedStyle;
  }

这里保证了AnimatedStyle能够拿到AnimatedValue的值。

归纳

由此可知,动画组件Animated.ViewAnimatedComponent,在初次渲染时,构建了如下所属关系:

AnimatedComponent <= AnimatedProps <= AnimatedStyle (<= AniamtedTransform)<= AnimatedValue

其中,AnimatedValue.__children保留了AnimatedStyle实例,AnimatedStyle.__children保留了AnimatedProps实例,AnimatedProps保留了AnimatedComponentforceUpdate回调函数,以此满足了由下至上的遍历方式,并驱动组件更新。

完整过程

flow

Animated.View组件在初始化渲染的同时,使得Animated.Value保存了调用自身的类实例对象。

timing.start 动画开始执行,Animated.Value会以一定时间规律触发自身值改变,并在改变的同时,触发flush函数,通过向上遍历调用实例的update方法。

在整个调用子集中,只有AnimatedProps类有 update 方法,而AnimatedProps类的update函数,只是执行来自Aimated.View组件的回调函数。

动画组件Animated.View的回调函数里调用了forceUpdate,该函数执行,组件强行更新,并重新render获得新的style

结论

不使用原生动画驱动的场景下,Animated.View会在动画函数根据缓动方式的更新时机触发在requestAnimationFrame 包裹下的forceUpdate ,使组件持续更新并拿到最新的style

题外

在本文中,我们可以学到几个套路:

React 动画解决方案:缓动函数 + requestAnimationFrame + forceUpdate

自底向上遍历:跨组件通信,底层组件驱动嵌套的上层组件更新。