React Native中如原生般流畅地使用设备传感器

背景

支付宝的会员页的卡片,有一个左右翻转手机,光线随手势移动的效果。

alipay

我们也要实现这种效果,但是我们的卡片是在RN页里的,那么RN能否实现这样的功能呢?

调研

开始先看了一下react-native-sensors
大概写法是这样

subscription = attitude.subscribe(({ x, y, z }) =>
    {
        let newTranslateX = y * screenWidth * 0.5 + screenWidth/2 - imgWidth/2;
        this.setState({
            translateX: newTranslateX
        });
    }
);

这还是传统的刷新页面的方式——setState,最终JS和Native之间是通过bridge进行异步通信,所以最后的结果就是会卡顿。

如何能不通过bridge,直接让native来更新view的呢
答案是有——Using Native Driver for Animated!!!

Using Native Driver for Animated

什么是Animated

Animated API能让动画流畅运行,通过绑定Animated.Value到View的styles或者props上,然后通过Animated.timing()等方法操作Animated.Value进而更新动画。更多关于Animated API可以看这里

Animated默认是使用JS driver驱动的,工作方式如下图:

图片

此时的页面更新流程为:

[JS] The animation driver uses requestAnimationFrame to update Animated.Value
[JS] Interpolate calculation
[JS] Update Animated.View props
[JS→N] Serialized view update events
[N] The UIView or android.View is updated.

Animated.event

可以使用Animated.event关联Animated.Value到某一个View的事件上。

<ScrollView
  scrollEventThrottle={16}
  onScroll={Animated.event(
    [{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }]
  )}
>
  {content}
</ScrollView>

useNativeDriver

RN文档中关于useNativeDriver的说明如下:

The Animated API is designed to be serializable. By using the native driver, we send everything about the animation to native before starting the animation, allowing native code to perform the animation on the UI thread without having to go through the bridge on every frame. Once the animation has started, the JS thread can be blocked without affecting the animation.

使用useNativeDriver可以实现渲染都在Native的UI线程,使用之后的onScroll是这样的:

<Animated.ScrollView // <-- Use the Animated ScrollView wrapper
  scrollEventThrottle={1} // <-- Use 1 here to make sure no events are ever missed
  onScroll={Animated.event(
    [{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }],
    { useNativeDriver: true } // <-- Add this
  )}
>
  {content}
</Animated.ScrollView>

使用useNativeDriver之后,页面更新就没有JS的参与了

[N] Native use CADisplayLink or android.view.Choreographer to update Animated.Value
[N] Interpolate calculation
[N] Update Animated.View props
[N] The UIView or android.View is updated.

我们现在想要实现的效果,实际需要的是传感器的实时翻转角度数据,如果有一个类似ScrollView的onScroll的event映射出来是最合适的,现在就看如何实现。

实现

首先看JS端,Animated API有个createAnimatedComponent方法,Animated内部的API都是用这个函数实现的

const Animated = {
  View: AnimatedImplementation.createAnimatedComponent(View),
  Text: AnimatedImplementation.createAnimatedComponent(Text),
  Image: AnimatedImplementation.createAnimatedComponent(Image),
  ...
}

然后看native,RCTScrollView的onScroll是怎么实现的

RCTScrollEvent *scrollEvent = [[RCTScrollEvent alloc] initWithEventName:eventName
                                                                 reactTag:self.reactTag
                                                               scrollView:scrollView
                                                                 userData:userData
                                                            coalescingKey:_coalescingKey];
[_eventDispatcher sendEvent:scrollEvent];

这里是封装了一个RCTScrollEvent,其实是RCTEvent的一个子类,那么一定要用这种方式么?不用不可以么?所以使用原始的调用方式试了一下:

if (self.onMotionChange) {
    self.onMotionChange(data);
}

发现,嗯,不出意料地not work。那我们调试一下onScroll最后在native的调用吧:

调试图

所以最后还是要调用[RCTEventDispatcher sendEvent:]来触发Native UI的更新,所以使用这个接口是必须的。然后我们按照RCTScrollEvent来实现一下RCTMotionEvent,主体的body函数代码为:

- (NSDictionary *)body
{
    NSDictionary *body = @{
                           @"attitude":@{
                                   @"pitch":@(_motion.attitude.pitch),
                                   @"roll":@(_motion.attitude.roll),
                                   @"yaw":@(_motion.attitude.yaw),
                                   },
                           @"rotationRate":@{
                                   @"x":@(_motion.rotationRate.x),
                                   @"y":@(_motion.rotationRate.y),
                                   @"z":@(_motion.rotationRate.z)
                                   },
                           @"gravity":@{
                                   @"x":@(_motion.gravity.x),
                                   @"y":@(_motion.gravity.y),
                                   @"z":@(_motion.gravity.z)
                                   },
                           @"userAcceleration":@{
                                   @"x":@(_motion.userAcceleration.x),
                                   @"y":@(_motion.userAcceleration.y),
                                   @"z":@(_motion.userAcceleration.z)
                                   },
                           @"magneticField":@{
                                   @"field":@{
                                           @"x":@(_motion.magneticField.field.x),
                                           @"y":@(_motion.magneticField.field.y),
                                           @"z":@(_motion.magneticField.field.z)
                                           },
                                   @"accuracy":@(_motion.magneticField.accuracy)
                                   }
                           };
    
    return body;
}

最终,在JS端的使用代码为

var interpolatedValue = this.state.roll.interpolate(...)

<AnimatedDeviceMotionView
  onDeviceMotionChange={
    Animated.event([{
      nativeEvent: {
        attitude: {
          roll: this.state.roll,
        }
      },
    }],
    {useNativeDriver: true},
    )
  }
/>

<Animated.Image style={{height: imgHeight, width: imgWidth, transform: [{translateX:interpolatedValue}]}} source={require('./image.png')} />

最终实现效果:

motion-event

继续优化

上面的实现方式有一点不太好,就是需要在render中写一个无用的AnimatedMotionView,来实现Animated.event和Animated.Value的连接。那么有没有方法去掉这个无用的view,像一个RN的module一样使用我们的组件呢?

Animated.event做的事情就是将event和Animated.Value关联起来,那么具体是如何实现的呢?

首先我们看一下node_modules/react-native/Libraries/Animated/src/AnimatedImplementation.jscreateAnimatedComponent的实现,里面调用到attachNativeEvent这个函数,然后调用到native:

NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping);

我们看看native代码中这个函数是怎么实现的:

- (void)addAnimatedEventToView:(nonnull NSNumber *)viewTag
                     eventName:(nonnull NSString *)eventName
                  eventMapping:(NSDictionary<NSString *, id> *)eventMapping
{
  NSNumber *nodeTag = [RCTConvert NSNumber:eventMapping[@"animatedValueTag"]];
  RCTAnimatedNode *node = _animationNodes[nodeTag];
......
  NSArray<NSString *> *eventPath = [RCTConvert NSStringArray:eventMapping[@"nativeEventPath"]];

  RCTEventAnimation *driver =
    [[RCTEventAnimation alloc] initWithEventPath:eventPath valueNode:(RCTValueAnimatedNode *)node];

  NSString *key = [NSString stringWithFormat:@"%@%@", viewTag, eventName];
  if (_eventDrivers[key] != nil) {
    [_eventDrivers[key] addObject:driver];
  } else {
    NSMutableArray<RCTEventAnimation *> *drivers = [NSMutableArray new];
    [drivers addObject:driver];
    _eventDrivers[key] = drivers;
  }
}

eventMapping中的信息最终构造出一个eventDriver,这个driver最终会在我们native构造的RCTEvent调用sendEvent的时候调用到:

- (void)handleAnimatedEvent:(id<RCTEvent>)event
{
  if (_eventDrivers.count == 0) {
    return;
  }

  NSString *key = [NSString stringWithFormat:@"%@%@", event.viewTag, event.eventName];
  NSMutableArray<RCTEventAnimation *> *driversForKey = _eventDrivers[key];
  if (driversForKey) {
    for (RCTEventAnimation *driver in driversForKey) {
      [driver updateWithEvent:event];
    }

    [self updateAnimations];
  }
}

等等,那么那个viewTag和eventName的作用,就是连接起来变成了一个key?What?

黑人问号脸

这个标识RN中的view的viewTag最后只是变成一个唯一字符串而已,那么我们是不是可以不需要这个view,只需要一个唯一的viewTag就可以了呢?

顺着这个思路,我们再看看生成这个唯一的viewTag。我们看一下JS加载UIView的代码(RN版本0.45.1)

mountComponent: function(
  transaction,
  hostParent,
  hostContainerInfo,
  context,
) {
  var tag = ReactNativeTagHandles.allocateTag();

  this._rootNodeID = tag;
  this._hostParent = hostParent;
  this._hostContainerInfo = hostContainerInfo;
...
  UIManager.createView(
    tag,
    this.viewConfig.uiViewClassName,
    nativeTopRootTag,
    updatePayload,
  );
...
  return tag;
}

我们可以使用ReactNativeTagHandles的allocateTag方法来生成这个viewTag。

2019.02.25更新:在RN0.58.5中,由于没有暴露allocateTag()方法,所以只能赋给tag一个大数来作为workaround

到此为止,我们就可以使用AnimatedImplementation中的attachNativeEvent方法来连接Animated.event和Animated.Value了,不必需要在render的时候添加一个无用的view。

详细代码请移步Github: https://github.com/rrd-fe/react-native-motion-event-manager,觉得不错请给个star :)

Reference

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

https://facebook.github.io/react-native/blog/2017/02/14/using-native-driver-for-animated.html

https://medium.com/xebia/linking-animations-to-scroll-position-in-react-native-5c55995f5a6e

https://www.raizlabs.com/dev/2018/03/react-native-animations-part1/

https://www.jianshu.com/p/7aa301632e4c

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容