专栏名称: 移动开发前线
专注于分享移动开发前沿和一线技术。
目录
相关文章推荐
奇舞精选  ·  前端工程师的 AI DAY 来啦! ·  昨天  
奇舞精选  ·  前端工程师的 AI DAY 来啦! ·  昨天  
前端大全  ·  15 分钟带你感受 CSS :has() ... ·  3 天前  
奇舞精选  ·  vercel是如何做微前端迁移的 ·  4 天前  
奇舞精选  ·  vercel是如何做微前端迁移的 ·  4 天前  
前端早读课  ·  【第3418期】HTML ... ·  4 天前  
前端大全  ·  预测一波,最近前端即将起飞! ·  1 周前  
51好读  ›  专栏  ›  移动开发前线

从动画到UI,React Native应用如何达到 60FPS?

移动开发前线  · 公众号  · 前端  · 2017-04-25 08:04

正文

版权声明

作者:Tal Kol

译者:Tino

原文:https://hackernoon.com/moving-beyond-animations-to-user-interactions-at-60-fps-in-react-native-b6b1fa0ba525

本文由作者授权翻译并发布,未经许可禁止转载。

由于 React Native 中的 Bridge 的异步特性, JavaScript 代码编写的动画存在着性能问题。像 Animated 这样的现代动画库,采取的是尽量减少调用 React Native Bridge 的手段,来克服这类缺陷。用户交互,则是更进一步的问题,界面需要不停更新以响应用户的输入。我们能用 React Native 的方式来实现 60 FPS 吗?


完美收官:跨越关键的“最后一英里”


作为现代移动应用开发框架的一种,React Native 对开发者有很大吸引力。这种框架的一大优点是能够大幅提高开发生产效率。简单来说就是,它能极大地提高开发应用的速度,部分原因在于,使用它构建应用后,开发者能在不同平台共享代码。

不过,开发者对这种框架一直存在担忧,他们会考虑,我能不能利用 React Native 完成最后阶段的关键步骤?我开发的应用的性能,能否和纯原生开发的最优秀应用相比?

必须承认,这些担忧的确是有道理的。在我们的平台 Wix.com 上,早在一年之前,就已经开始将纯原生代码迁移到 React Native。这次开发过程中,前面 95% 的工作都很轻松,我们的推进速度大概是平时的四倍,可到了最后 5% 的阶段时,却出现了一些挑战。我们发现,到了这个被我称之为“最后一英里”的阶段,竟然不能直接用 React Native 来开发。

如何顺利跨越这最后一英里,是我们和社区力求改进的目标。


什么样的应用算出类拔萃?


最出色的应用和那些平庸之流有什么区别?在移动领域,我们的评价标准其实已经有所提高,希望动画对象不再只是从屏幕上弹出,还要能进行流程的变换和移动等。

以60帧/秒的速度实现流畅的动画性能,是上述最后 5% 开发阶段的重要环节。过去, React Native 的一大毛病就是动画性能不佳。这个问题最终被优秀的 Animated 动画库所解决。

不止是动画,我们还更进一步——寻求实现模拟现实的动态用户交互。当用户对一个视图做手势,这个视图再根据用户的手势不断做出现实状态下应有的反应,这就产生了交互。

为了便于理解以上内容,在此举一些现实生活中的例子。我打开自己的手机,开始从最喜欢的一些应用里找一些良好交互的例子,把它们做了如下分类:

  • ListView上下滑动——以上最左边的截图,让我们看到,用苹果为 iOS 系统提供的官方版本邮件应用和谷歌电邮应用 Gmai 的收件箱。在这种清单式呈现的界面,用户只要手指上下滑动,侧栏就会逐渐出现各种操作按键。

  • 卡片滑动——从左往右数第二张截图,显示了谷歌的即时资讯应用Google Now和Lifehack Labs开发的删除和管理照片应用Flic,它们呈现了类似 Tinder 一样的用户互动。随着用户手指划过,这些卡片形的界面外观会调整,如果手指滑动的力道够大,界面就会从屏幕上飞走。

  • 可折叠视图——从右往左数第二张截图,展现了共享民宿短租平台 Airbnb 和 Any.DO 开发的智能日历应用Cal。它们都向用户提供能在多个状态间转换时折叠的视图。Airbnb 用户可以切换筛选和搜索页面,Cal 用户可以选择以月或是以周为期间浏览,在两种期间之间切换。

  • 滑动面板和抽屉拉动式——最右边的截图,是苹果iOS系统的官方版本顶部通知面板,以及苹果的iOS官方版本地图应用Maps。用户可以拖动这些面板,把那些通常隐藏的新增用户界面元素显示出来。这种方式很像流行的导航抽屉或者侧栏菜单。

这些例子有什么共同点?它们都在仿实物运动。视图的速度随着用户拖动和摇晃而变化。注意一些微妙的区别,比如用力挥动的时候,通知面板会从底部跳出来。


使用 JavaScript 实现


选择了 React Native 开发框架,我们自然而然会设法使用 JavaScript 代码实现这些交互。首先,让我们回顾一下它是如何运行的。第一个例子,ListView上下滑动。是使用 React Native 核心代码中的 SwipeableRow 实现的。

这是目前最新潮、非常重视性能的实现方式,大量运用 Animated 动画库。不过,先让我们把注意力放在实现交互本身这个环节上。

_handlePanResponderMove(event: Object, gestureState: Object): void {
  if (this._isSwipingExcessivelyRightFromClosedPosition(gestureState)) {
    return;
  }
  this.props.onSwipeStart();
  if (this._isSwipingRightFromClosed(gestureState)) {
    this._swipeSlowSpeed(gestureState);
  } else {
    this._swipeFullSpeed(gestureState);
  }
},

_isSwipingRightFromClosed(gestureState: Object): boolean {
  const gestureStateDx = IS_RTL ? -gestureState.dx : gestureState.dx;
  return this._previousLeft === CLOSED_LEFT_POSITION && gestureStateDx > 0;
},

_swipeFullSpeed(gestureState: Object): void {
  this.state.currentLeft.setValue(this._previousLeft + gestureState.dx);
},

_swipeSlowSpeed(gestureState: Object): void {
  this.state.currentLeft.setValue(
    this._previousLeft + gestureState.dx / SLOW_SPEED_SWIPE_FACTOR,
  );
},

_isSwipingExcessivelyRightFromClosedPosition(gestureState: Object): boolean {
  const gestureStateDx = IS_RTL ? -gestureState.dx : gestureState.dx;
  return (
    this._isSwipingRightFromClosed(gestureState) &&
    gestureStateDx > RIGHT_SWIPE_THRESHOLD
  );
},

这个实现依赖 PanResponder 来计算 touch 事件导致的视图改变。对于这种方式,我们应该要求达到什么样的性能呢?

为了分析交互的表现,我们必须深入了解 React Native 的内部机制。React Native 同时运行着两个部分:一是我们运行业务逻辑的 JavaScript 部分,二是我们的原生视图所在的原生部分。这两个部分通过 Bridge 进行通信。因为每次通过 Bridge 传输数据要求数据进行序列化,所以频繁通信的成本不菲。

Touch 事件是原生结构体,它们在原生部分发生。对于交互的每一帧,都需要通过 Bridge 传输这些事件,并交给 JavaScript 部分中的_handlePanResponderMove处理。等业务逻辑计算了响应,我们就设定 Animated Value 的值。但由于更新视图必须在原生部分进行,所以我们必须再一次经由 Bridge 通信。

这样一来你就会发现,我们看到的每一帧都需要经由 Bridge 传递数据以及序列化。一旦你的应用运行繁忙,这个性能上的消耗会降低App的帧率到 60FPS 以下。


使用原生实现


我们最初开发 Wix 的 RN 应用,是使用 JavaScript 代码实现所有交互的。但交互性能没有预期的流畅,我们开始使用原生组件来实现 UI。

这意味着一切实现都要来两遍——在 iOS 系统上用 Objective-C 语言来一次,在安卓系统上用 Java 语言再来一次。要想达到60 FPS 的速度,通常在原生部分实现更容易,因为这么做可以避免通过 Bridge 传递数据,通过原生来实现业务逻辑视图渲染可以很快的结束掉每一帧更新的事件循环。

我们开源了大多数的原生代码,最后完成了多个动画库,比如实现卡片滑动视图的“react-native-swipe-view”,以及实现 ListView 滑动视图的“react-native-action-view”。我们没有尝试去寻找通用的解决方案,最终每一个用例都产生了用途单一的视图库。

这种方式的主要问题,是要求开发者有原生开发能力,而且通常需要 iOS 和 Android 两位不同的开发者。在 Wix,我们将前端的开发人员中的 10% 保持为原生开发者,因为在上面的这种开发方式中还是需要 Objective-C/Swift 或 Java 开发能力。

当然,这种方式并不够好。我们应该设定更高的目标,寻找更有效的、通用的解决方案。


从动画库中学到的


实际上,动画也给我们提出了类似的挑战。原生 UI 会补充 JavaScript 代码绘制的帧之间的视图属性。这会在 Bridge 上产生大量数据交换,也会造成丢帧。我们都知道, Animated 动画库成为了RN 中 60 FPS 动画的主流解决方案,它是怎么实现的呢?

Animated 的原理,是运用一种声明式的 API 描述动画。如果我们能预先声明整个动画,整个动画的 JavaScript 的代码就可以序列化,并一次性的通过 Bridge 传输。这样的话,一种通用的渲染驱动就可以按照规定,逐帧实现动画。

最开始 Animated 的动画渲染驱动是用 JavaScript 实现的。但最近的版本提供了原生代码编写的驱动,它能在原生层面逐帧运行动画,并且可以无需通过 Bridge 更新原生视图。

这种方法减少了通过 Bridge 的数据流量,确保数据交换仅限于初始化阶段。这让我们得到了以下有趣的结论:

声明式 API 是我们跨越最后一英里的关键

这是一个非常强大的概念,我们应该多考虑编写这样的代码库。无论何时,只要在 React Native 发现性能局限,就可以尝试用这种方法来改善。而我们所需要做的,就是寻找一些典型用例,设计一种可以覆盖所有这些用例的声明式 API——这就是我们接下来要做的。


用户交互的声明式 API


为了设计能达到效果的 API ,我们应该明确以下两个目标:

1、我们的 API 应该是通用的。校验通用与否有个好方法,那就是保证此 API 覆盖我们在以上用户体验交互设计模式中看到的全部八个例子。

2、我们的 API 应该是简单的。校验是否简单的好方法是,保证每一交互的定义代码都不超过三到五行。

在介绍我们 API 之前,我想讲讲 Animated 动画库为用户交互提供支持的一些有趣工作。其中一个比较有意思的 API 是Animated.ScrollView,它能根据界面滚动的位置操作视图属性。还有一个尚未完成的工作也十分有趣,它是克日什扎夫·玛吉瑞(Krzysztof Magiera)开发的视图库,名为react-native-gesture-handler。可以根据手势参数操作视图属性。

但现在,我们要综合的方法和上文提到的又有些不同。我们会从以上展示的八种用户体验交互设计模式入手,设计最简单的、并且能定义所有这些模式的高级 API 。


 定义 API 第一阶段


分析以上八种UX模式,我们可以发现,其中一些视图可以自由地水平移动,另一些则可以自由地上下移动。因此,我们做 API 适合从确定移动方向这点入手。

另一方面,我们发现视图只有在被人拖动后才能任意移动。只要用户放手不动,它们通常会瞬间移到某个预先定义的捕捉点位置。比如在抽屉拉动式交互的界面,视图会瞬间去往一个拉开的位置或者一个关闭的位置。

最后,要让瞬间的动作有真实世界的运动感觉,我们就要做出类似弹簧的动画曲线效果。如果不想让弹簧一直摆动下去,还要在 API 里设定弹跳系数(或者说设定弹簧的阻尼)。

总体而言,声明式 API 的第一阶段可以依靠设定以下属性完成:

  • 水平/垂直

  • 捕捉点

  • 弹跳系数

让我们用这个简单的 API 说明前两个 UX 模式——ListView 左右滑动(下图左)和卡片滑动(下图右):

// ListView row actions


// swipeable cards

为了让右边的卡片呈现以刷卡方式消失的效果,我们只需要定义卡片彻底从屏幕上消失的捕捉点(+/-360 逻辑像素)。注意,我们现在是图简单才用这种像素值,之后可以给 UI 元素增加支持,以适合多屏幕解决方案,比如增设百分比。

这是个好开头,但设计声明式 API 只是前半部分工作。接下来就看看后半部分工作:实现原生驱动。让我们进入下一阶段。


 实现原生驱动 尝试一


当 JavaScript 层面的 props 属性给出了交互的流程描述,React Native 在初始化期间已经完成了序列化,通过 Bridge 向原生层进行了一次传输。我们的通用原生驱动会收到这些描述,在原生层面驱动交互,更新每一帧的计算都将不必再通过 Bridge 传输。由于免去了频繁的数据交换,交互得以在60 FPS 速度下运行。

我们先从Objective-C语言编写的简单实现开始。用UIPanGestureRecognizer达到拖动视图的目的,当这种手势停下来的时候,我们会寻找距离最近的捕捉点,赋予视图一段弹簧曲线似的运动轨迹。

- (void)handlePan:(UIPanGestureRecognizer *)pan {

CGPoint translation = [pan translationInView:self];
self.center = CGPointMake(self.initialPanCenter.x + translation.x, 
                          self.initialPanCenter.y + translation.y);

if (pan.state == UIGestureRecognizerStateEnded) {
  InteractablePoint *snapPoint = [self findClosestPoint:self.snapTo 
                                  toPoint:self.center];
  if (snapPoint) {
    [UIView animateWithDuration:0.8 
     delay:0 
     usingSpringWithDamping:0.7 
     initialSpringVelocity:0 
     options:nil 
     animations:^{
       self.center = [snapPoint positionWithOrigin:self.origin];
     }
     completion:^(BOOL finished) {}];
   }
 }
}

这种方式能运行得很好,但问题在于我们在用动画模拟的力学作用过于单一。想想假如用户以某种初速度翻动视图,会有什么效果。我们采用的动画函数只适合反映弹簧跳动那种方向的运动速度,要是用户往另一个方向翻动试图,视图要怎么动呢?我们的模型没有那么强大,还不能处理那种情况。


 实现原生驱动 尝试二


我们来看看更强大的交互驱动方法。如果你研究一下原生 SDK,会发现苹果早已为开发者准备好了动画系统 UIKit Dynamics 了。

这种超炫的 API 在苹果的 iOS 7 操作系统里被引入,它简直是披着“虚拟外衣”的全方位力学引擎,我们能把质量、速度和力量这类实物具有的属性加在视图上。场景的实物参数根据应用的动作定义。我们可以轻而易举地完善上述实现。

if (pan.state == UIGestureRecognizerStateEnded) {
  CGPoint velocity = [pan velocityInView:self.superview];
  InteractablePoint *snapPoint = [self findClosestPoint:self.snapTo 
                                  toPoint:self.center];
  if (snapPoint) {

    // initial velocity
    UIDynamicItemBehavior *itemBehaviour = [[UIDynamicItemBehavior alloc] 
                                            initWithItems:@[self]];
    [itemBehaviour addLinearVelocity:velocity forItem:self];
    [self.animator addBehavior:itemBehaviour];

    // snap to point
    UISnapBehavior *snapBehaviour = [[UISnapBehavior alloc] 
                                     initWithItem:self 
                                     snapToPoint:[snapPoint 
                                                  positionWithOrigin:
                                                  self.origin]];
    snapBehaviour.damping = 0.8f;
    [self.animator addBehavior:snapBehaviour];
  }
}

这样一来,我们似乎离目标更近了,但还有一步之遥。UIKit Dynamics 存在两大缺陷:其一,它不支持安卓系统,换句话说,这种 API 只能在 iOS 系统运行,无法用安卓SDK实现同样性能;其二,它还无法完全控制捕捉等一些动作,比如无法明确界定瞬间动作的力道。


 实现原生驱动 尝试三


让我们换一种更酷炫的方式。我们为什么不试试亲自运行 UIKit Dynamics?现实世界的力量和数学方程相比还算简单。从零开始造一个仿力学效果的引擎不会太难。

UIKit Dynamics 会教我们怎么做。我们甚至能用它的行为模式。这里以瞬间动作为例。我们可以用一个弹簧的动作表现它的运行,弹簧是怎么动的?现在来回顾一些基本的力学知识。

别担心涉及太多数学运算,有些工作动画库内部会做。维基百科的牛顿力学定律和胡克定律词条可以提供全面的物理背景知识。

我们必须计算每帧的力度和速度。为此我们需要一部高精度的计时器,以60 帧每秒速度运转。幸运的是,有一种原生 API ——CADisplayLink就是专为执行这种任务设计的。运用此工具的计算结果如下。

self.displayLink = [CADisplayLink displayLinkWithTarget:self 
                    selector:@selector(displayLinkUpdated)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

- (void)displayLinkUpdated {
  CFTimeInterval deltaTime = 0.0;
  CFTimeInterval currentTime = [self.displayLink timestamp];
  if (self.lastFrameTime > 0.0) deltaTime = currentTime - self.lastFrameTime;
  self.lastFrameTime = currentTime;
  [self animateFrameWithDeltaTime:deltaTime];
}

- (void)executeFrameWithDeltaTime:(CFTimeInterval)deltaTime onObject:(PhysicsObject*)object {
  CGFloat dx = self.target.center.x - self.anchorPoint.x;
  CGFloat ax = (-self.tension * dx) / object.mass;
  CGFloat vx = object.velocity.x + deltaTime * ax;

  CGFloat dy = self.target.center.y - self.anchorPoint.y;
  CGFloat ay = (-self.tension * dy) / object.mass;
  CGFloat vy = object.velocity.y + deltaTime * ay;

  object.velocity = CGPointMake(vx, vy);
}

现在感觉对路了,我们认识到一件很有意思的事:

我们在为 React Native 编写一种声明式的力学引擎。

这真是酷毙了。

我们终于拥有了可控的原生驱动。是时候运用强大的引擎,给我们的声明式 API 增添一些功能了。


丰富 API ——增加属性


到目前为止,我们的声明式 API 已经能提供坚实的基础,但能力还不足,无法实现上述八种 UX 模式中更精细的互动。回想一下苹果官方 iOS 系统的顶部通知面板。用户用足够力道向下滑动面板,它就会下沉并占据整个屏幕。

我们很容易在自己的声明式 API 里添加支持这种行为的属性。我们会设置界线限制视图移动,给视图边缘增加弹跳。

// notification panel having boundaries with bounce

我们来考虑另一种复杂的用例。这次是 ListView 的左右移动。有些移动界面的两边并没有操作的按钮。在这种情况下,UX 的常见设计是在用户在列表元素上左右滑动露出按钮,并且这种滑动方向左右是不同的,如果反向滑动,会遇到很大的阻力,并很快恢复。

我们可以设置一个随时有效的弹簧效果,让视图的一边与屏幕的一边形成联动,以此增加左右移动的阻力。用户拖动视图时也会激活这种弹簧反应。

我们还需要解决另一个问题。往左边移动应该没有任何阻力(这个方向有按钮),可往右边就会受阻(这个方向没有按钮)。我们可以给包括弹簧在内的每种力一个可选的影响区域,给我们的 API 增加这种行动属性。

当视图超出了影响区域的覆盖范围,作用力就会消失。

// ListView row actions with a spring having limited influence area

如你所见,我们满足了越来越多用例的要求,这样就能丰富我们声明式的 API ,增加用以丰富说明的通用功能。


丰富 API ——与动画融合


我们还有很大一部分谜团没有解开,想想 ListView 左右滑动的用例。如果你左右滑动,操作按钮会逐渐显现。常规的表现方式是随着它们慢慢出现逐步改变它们的外观,比如尺寸和透明度。

你可以看到下图这种动作(操作按钮为蓝色)。

还要注意到,我们希望有动画效果的视图是蓝色的操作按钮,它和与用户交互的视图不同,后者是灰色的覆盖行。

这种效果并不容易实现,因为此动画阶段的关键不是时间,而是行的水平位置。尽管如此,这还是一种动画,视图的属性(尺寸和透明度)要按顺序修改。我们已经有一样强大的工具——动画库 Animated,用它就能根据我们的想法实现视图属性的动画化。所以现在可以找方法,用它达到我们的目的。

通过声明 Animated.Value 的插值,Animated 得以协助执行视图属性动画。

this._animValue = new Animated.Value(0);


  ...

既然动画取决于行的水平位置,那我们在Animated.Value中定义的话如何?这样一来,我们就能根据视图的位置定义插值。那种视图的位置会影响其他不属于交互直接组成部分(比如按钮)的视图。

这在我们的声明式 API 中如何实现?我们可以将Animated.Value作为属性传递(animatedValueX) 。

我们的原生驱动就会在背后完成传递,这用 Animated.events 就能实现。Animated 动画库最近的一些版本甚至支持用原生驱动来使用Animated.events。这意味着,从传递视图位置到更新视图属性,整个动画过程都可以在原生层面执行,无需通过 Bridge 传输。这对我们实现 60 FPS 来说是大好消息。


丰富 API ——完善细节


如果我们自己设计基于物理学的交互,可能也会需要增加其余的作用力。之前已经有了弹簧的弹力,现在也可以添加重力和磁力。这些能力赋予开发者灵活性来设计各种精彩的仿实物互动。

我们也会增加对事件的支持,因此,在交互停止或者视图迅速移到某个点时,我们的 JavaScript 代码就会收到通知。在此之上,我们还可以增加触觉反馈,比如,在视图碰到屏幕周围时,让设备发出震动。这些改良的细节让用户能拥有更完美的体验。

该做一下总结了。

我想向你展示我们创造的 API 全部实力如何。看看以下代码里的声明,你能猜它会实现什么效果?

效果就是:我们的视图会被吸附到屏幕左边或移到右边,而且在屏幕底部还有重力作用,只要视图特别靠近,就会跌到底部。并且,我们没有限制视图的移动方向。

是的,我们只用七行代码就全方位实现了“聊天头像”的效果!

运行速度真是60 帧每秒?


看视频和你用实物设备体验交互不同。请注意,模拟器无法提供真实体验,因为它会丢帧。

那么,在实物设备上真能以60 帧每秒速度运行吗?这要你自己判断。在我们刚刚创造的引擎帮助下,我已经通过我们利用 React Native 设计的声明式 API 运行了上述八种UX模式。在苹果电子商店 App Store 和谷歌电子商店 Google Play,你可以分别找到 iOS 和 Android 的示例。

我们 iOS 和 Android 版本在原生驱动——力学引擎的所有运行和示例应用都可以在 GitHub 上找到:

https://github.com/wix/react-native-interactable

在此特别感谢移动网络基础设施工程师Rotem Mizrachi-Meidan和开发者Tzachi Kopylovitz,谢谢他们帮助我们,赶在 ReactConf 2017 召开前完成了核心代码。


携手跨越“最后一英里”


我希望你从这次有趣的实验中收获的不仅仅是 React Native 的一个技巧,同时也能体会到,React Native 社区在不断的探索 React Native 的局限,并且突破这些局限。

如果你运用 React Native 的时候碰到有意思的性能问题,我建议你收集典型用例,试着设计一种简单的声明式 API 定义它们。要是性能问题源于 React Native 的 Bridge (这是常见的原因),给 API 配个原生驱动可能很好地解决问题。

最后,让我们一起跨越“最后一英里”吧!

活动推荐:

由InfoQ主办的第二届GMTC全球移动技术大会开始报名了!大会将于6月9-10日在北京举行。本届大会,我们将探讨智能时代的大前端,在动态化、React Native等逐渐流行的现在,移动和前端的融合将会发生怎样的变化?点击阅读原文进入大会官网,现在报名享8折优惠!