ReactNative-渐进式指南-二-

101 阅读1小时+

ReactNative 渐进式指南(二)

原文:zh.annas-archive.org/md5/7b97db5d1b53e3a28b301bff1811634d

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:与动画一起工作

动画是每个移动应用的一部分。平滑的动画可以决定用户是否感到舒适地使用应用。实质上,动画只是屏幕反复渲染,从一个状态过渡到另一个状态。

这种渲染应该非常快,以至于用户不会意识到动画的单个状态,而是感知到它是一个平滑的动画。更进一步,动画不仅随时间从状态 A 变换到状态 B,而且还会对用户的交互做出反应,如滚动、按下或滑动。

大多数设备的屏幕帧率为 60 帧/秒fps),而现代设备已经达到 120 fps(截至编写本文时,React Native 仅支持 60 fps,你可以在 GitHub 上了解相关信息:bit.ly/prn-rn-fps)。这意味着当运行动画时,屏幕必须以 60 fps 的速度重新渲染。

这相当具有挑战性,因为计算复杂的动画和重新渲染屏幕是一些计算密集型操作。特别是在低端设备上,动画的计算可能会变得太慢,屏幕刷新率低于 60/120 fps。这会使动画和应用程序感觉迟钝和缓慢。

实质上,你可以将动画分为两种不同类型:

  • 屏幕动画:这类动画仅适用于屏幕的一部分。这种类型的动画有很多不同的用途,例如吸引用户注意、提供触摸反馈、显示进度或加载指示,或者改善滚动体验。

  • 全屏动画:这类动画过渡整个屏幕。大多数情况下,这种类型的动画用于导航到另一个屏幕。

由于全屏动画由所有流行的导航库内部处理,因此本章将重点介绍屏幕动画。全屏动画已在 第四章*,样式、存储和导航,导航部分* 中介绍。

在 React Native 中实现平滑动画有多种方法。根据项目类型和想要构建的动画类型,你可以从众多解决方案中选择,每种方案都有其自身的优缺点。在本章中,我们将讨论最佳和最广泛使用的解决方案。

在本章中,我们将涵盖以下主题:

  • 理解 React Native 中动画的架构挑战

  • 使用 React Native 内置的 Animated API

  • 使用 react-native-animatable 创建简单动画

  • 探索 Reanimated 2 – React Native 最完整的动画框架

  • 在 React Native 中使用 Lottie 动画

信息

关于使用 Skia 渲染引擎(它为 Chrome、Firefox、Android 和 Flutter 提供动力)在 React Native 中渲染动画的一些有趣的发展,但在撰写本文时,这种方法尚未准备好投入生产。

技术要求

要运行本章中的代码,您必须设置以下内容:

  • 一个可工作的 React Native 环境 (bit.ly/prn-setup-r… – React Native CLI 快速入门)

  • 一个 iOS/Android 模拟器或真实设备(真实设备更受欢迎)

理解 React Native 中动画的架构挑战

当涉及到动画时,React Native 的当前架构并不理想。想象一下,一个基于 ScrollView 垂直滚动值来缩放或移动标题图片的动画;这个动画必须基于 ScrollView 的滚动值进行计算,并立即重新渲染图片。以下图表显示了使用纯 React Native 架构时会发生什么:

![图 6.1 – 基于滚动值进行动画时的 React Native 架构img/B16694_06_01.jpg

图 6.1 – 基于滚动值进行动画时的 React Native 架构

在这里,您可以看到一般的 React Native 架构。JavaScript 线程是您编写代码的地方。每个命令都将序列化并通过桥接发送到原生线程。在这个线程中,命令被反序列化并执行。同样,用户输入也是如此,但方向相反。

对于我们的动画来说,这意味着滚动值必须序列化,通过桥接发送,反序列化,通过复杂的计算转换为动画值,序列化,通过桥接返回,反序列化,然后渲染。整个过程必须在每 16 毫秒(或每秒 60 次)内完成。

这种往返会导致多个问题:

  • 序列化/反序列化过程消耗了不必要的计算能力

  • 在大多数情况下,JavaScript 中的计算速度比原生代码慢

  • 计算可能会阻塞 JavaScript 线程,使应用无响应

  • 这种往返过程可能导致帧率下降,使动画看起来迟缓且缓慢

由于这些问题,不建议在您自己的纯 React Native 代码中编写动画(例如,通过在循环中设置状态)。幸运的是,有多个现成的解决方案可以避免这些问题,并实现高质量的动画。

在接下来的几节中,我们将探讨四种不同的解决方案。每个解决方案都有其优缺点,而应该选择哪种解决方案则取决于项目和用例。让我们从内置的 Animated API 开始。

使用 React Native 的内部动画 API

React Native 自带内置的 Animated API。这个 API 非常强大,你可以用它实现许多不同的动画目标。在本节中,我们将简要了解它是如何工作的,以及内部 Animated API 的优势和局限性。

要获取完整的教程,请查看官方文档,链接为bit.ly/prn-animate…

要了解 Animated API 的工作原理,让我们从一个简单的例子开始。

从一个简单的例子开始

以下代码实现了一个简单的淡入动画,使视图在 2 秒内出现:

import React, { useRef } from "react";
import { Animated, View, Button } from "react-native";
const App = () => {
  const opacityValue = useRef(new Animated.Value(0)).
      current;
  const showView = () => {
    Animated.timing(opacityValue, {
        toValue: 1,
        duration: 2000
        }).start();
    };
  return (
    <>
      <Animated.View
        style={{
          backgroundColor: 'red',
              opacity: opacityValue
        }}
       />
      <Button title="Show View" onPress={showView} />
    </>
  );
}
export default App;

动画 API 基于动画值。这些值随时间变化,并作为应用程序样式的组成部分使用。在这个例子中,我们将opacityValue初始化为一个Animated.Value组件,其初始值为0

如你所见,JSX 代码包含一个Animated.View组件,其样式使用opacityValue作为透明度属性。当运行此代码时,Animated.View组件最初是完全隐藏的;这是因为透明度被设置为0。当调用showView时,它启动一个Animated.timing函数。

这个Animated.timing函数期望一个Animated.Value组件作为第一个属性,一个配置对象作为第二个参数。Animated.Value组件是动画过程中应该改变的价值。通过配置对象,你可以定义动画的一般条件。

在这个例子中,我们想在 2 秒(2,000 毫秒)内将Animated.Value组件的值从 0 变为 1。然后,Animated.timing函数计算动画的不同状态,并负责渲染Animated.View组件。

值得了解

实际上,你可以对 UI 的任何部分进行动画处理。Animated API 直接导出了一些组件,例如Animated.ViewAnimated.ImageAnimated.ScrollViewAnimated.TextAnimated.FlatList。但你可以通过使用Animated.createAnimatedComponent()来对任何组件进行动画处理。

虽然 Animated API 并没有完全解决 React Native 架构的问题,但它比反复设置状态要好,因为它大大减少了从 JavaScript 线程传输到原生线程的负载,但这种传输必须每帧进行。为了防止每帧都进行这种传输,你必须使用原生驱动程序,如下面的子节所示。

使用原生驱动程序

当使用配置对象配置动画时,你可以设置一个名为useNativeDriver的属性。这非常重要,并且尽可能应该这样做。

当使用useNativeDriver: true的原生驱动程序时,React Native 在开始动画之前将所有内容发送到原生线程。这意味着动画完全在原生线程上运行,这保证了动画的流畅运行和没有帧丢失。

不幸的是,原生驱动程序目前仅限于非布局属性。因此,如变换和透明度等属性可以使用原生驱动程序进行动画处理,而所有 Flexbox 和位置属性,如 heightwidthtopleft,则不能使用。

插值动画值

在某些情况下,你不想直接使用 Animated.Value 组件。这就是插值发挥作用的地方。插值是输入和输出范围的简单映射。在下面的代码示例中,你可以看到一个插值,它向之前的简单示例添加了一个位置变化:

style={{
    opacity: opacityValue,
    transform: [{
      translateY: opacityValue.interpolate({
        inputRange: [0, 1],
        outputRange: [50, 0]
      }),
    }],
  }}

在这个代码示例中,我们向 style 对象中添加了一个 translateY 变换属性。这个属性改变了一个对象的垂直位置。我们既没有设置一个固定值,也没有直接绑定 opacityValue

我们使用一个具有定义的 inputRange[0,1] 和定义的 outputRange[50,0] 的插值函数。本质上,这意味着当 opacityValue(我们的 AnimatedValue)为 0 时,translateY 值将是 50,而当 opacityValue1 时,translateY 值将是 0。这导致我们的 AnimatedView 在淡入的同时向上移动 50px 到其原始位置。

小贴士

尽量使用插值来减少你需要在应用程序中使用的动画值数量。大多数情况下,你可以使用一个动画值,并在其上进行插值,即使在复杂的动画中也是如此。

Animated API 的插值函数非常强大。你可以有多个值来定义范围,超出范围外推或夹紧,或指定动画的缓动函数。

了解 Animated API 的高级选项

Animated API 带来了很多不同的选项,这让你几乎可以创建你所能想象的任何动画:

  • 你可以对动画值执行数学运算,如 add()subtract()divide()multiply()modulo() 等。

  • 你可以使用 Animated.sequence() 顺序组合动画,或者使用 Animated.parallel() 同时组合它们(你甚至可以将这些选项结合起来)。

  • 你还可以使用 Animated.delay() 进行延迟动画或使用 Animated.loop() 进行循环动画。

  • 除了 Animated.timing() 之外,还有其他选项可以改变 Animated.Value 组件。其中之一是使用 Animated.event()ScrollView 的滚动值绑定到 AnimatedValue

以下示例与本章 理解 React Native 中动画的架构挑战 部分的示例非常相似。代码展示了如何使用滚动值作为动画的驱动程序:

const App = () => {
  const scrolling = useRef(new Animated.Value(0)).current;
  const interpolatedScale = scrolling.interpolate({
    inputRange: [-300, 0],
    outputRange: [3, 1],
    extrapolate: 'clamp',
  });
  const interpolatedTranslate = scrolling.interpolate({
    inputRange: [0, 300],
    outputRange: [0, -300],
    extrapolate: 'clamp',
  });
  return (
    <>
      <Animated.Image 
        source={require('sometitleimage.jpg')}
        style={{
          ...styles.header,
          transform: [
            {translateY: interpolatedTranslate}, 
            {scaleY: interpolatedScale}, 
            {scaleX: interpolatedScale}
          ]
        }} 
      />
      <Animated.ScrollView
        onScroll={
          Animated.event([{nativeEvent: {contentOffset: {y: 
              scrolling,},},}],
              { useNativeDriver: true },
          )
        }
      >
        <View style={styles.headerPlaceholder} />
        <View style={styles.content}>
        </View> 
      </Animated.ScrollView>
    </>  
  );
}

在这个例子中,ScrollView 的原生滚动事件直接连接到 Animated.Value 组件。使用 useNativeDriver: true 属性,使用了原生驱动程序;这意味着动画,由滚动值驱动,完全在原生线程上运行。

前面的例子包含了两个滚动值的插值:第一个在ScrollView被过度滚动时(这意味着ScrollView返回负滚动值)缩放图像,而第二个在滚动时将图像向上移动。

再次强调,由于使用了原生驱动程序,所有这些插值都是在原生线程上完成的。这使得 Animated API 在这个用例中非常高效。你可以阅读更多关于基于用户手势运行动画的信息,请参阅第七章**,React Native 中的手势处理

Animated API 还提供了不同的缓动方法和复杂的弹簧模型。更多详细信息,请参阅官方文档bit.ly/prn-animate…

如你所见,Animated API 确实非常强大,你可以用它实现几乎每一个动画目标。那么,为什么市场上还有其他解决方案,当这个非常好的动画库已经内置时?嗯,对于每一个用例,Animated API 都远非完美。

理解 Animated API 的优缺点

内部 React Native Animated API 是一个非常好的简单到中等复杂度动画的解决方案。以下是 Animated API 最重要的优点:

  • 强大的 API: 你可以构建几乎所有的动画。

  • 无需外部库: 使用 Animated API 时,你不需要向你的项目添加任何依赖。这意味着无需额外的维护工作或更大的包大小。

  • 使用原生驱动实现平滑动画: 当使用原生驱动程序时,你可以确信你的动画以 60 fps 运行。

同时,Animated API 也有一些缺点,你在选择最适合你项目的动画解决方案时必须牢记:

  • 复杂的动画变得相当混乱: 由于 Animated API 的结构,包含大量元素或非常复杂的动画可能会变得非常混乱,代码也可能变得难以阅读和理解。

  • 原生驱动程序不支持所有样式属性: 当使用 Animated API 时,你绝对应该使用原生驱动程序。由于这个驱动程序不支持位置或 Flexbox 属性,因此本质上,Animated API 仅限于非布局属性。

  • 必须使用Animated.Value组件。

总的来说,我会推荐 Animated API 用于小型到中型复杂度的动画,当你项目中还没有其他动画库时。然而,让我们看看另一个选项:react-native-animatable

使用 react-native-animatable 创建简单的动画

有很多动画在几乎每个应用中都会被重复使用。这就是react-native-animatable的宗旨。这个库建立在内部 React Native Animated API 之上,并提供了一个非常简单且声明性和命令性的 API 来使用简单、预定义的动画。

从一个简单的例子开始

以下代码示例描述了使用声明式方法通过react-native-animatable实现的简单淡入动画,以及使用命令式方法通过react-native-animatable实现的简单淡出动画:

import React from "react";
import { View, Text, Pressable } from "react-native";
import * as Animatable from 'react-native-animatable';
const App = () => {
  const handleRef = ref => this.view = ref;
  const hideView = () => {
    this.view.fadeOutDown(2000);
  }
  return (
    <>
      <Animatable.View
        style={{
          backgroundColor: 'red'
        }}
        ref={handleRef}
        animation="fadeInUp"
        duration=2000
      />
      <Pressable onPress={hideView}>
        <Text>Hide View</Text>
      </Pressable>
    </>
  );
}
export default App;

在这个示例中,Animatable.View被赋予了一个预定义的Animatable动画作为动画属性,以及一个定义动画运行多长时间的持续时间。这就是实现入场动画所需的所有操作。

如前所述,Animatable 还支持命令式使用,这意味着您可以在 Animatable 组件上调用 Animatable 函数。在这个示例中,this.view包含对Animatable.View的引用,这使得可以在其上调用 Animatable 函数。

这是在按下Pressable时完成的。在这里,调用hideView,然后调用预定义的fadeOutDown Animatable 函数,使视图在 2 秒(2,000 毫秒)内消失。

使用原生驱动

如我们在使用 React Native 的内部动画 API部分所学,使用原生驱动对于实现流畅的动画至关重要。由于react-native-animatable基于动画 API,因此您也应该配置动画以使用原生驱动。

使用react-native-animatable,这是通过在运行动画的组件上添加useNativeDriver={true}属性来完成的。

重要提示

在使用原生驱动之前,请检查您想要使用的预定义动画是否支持原生驱动。

react-native-animatable库不仅限于预定义的动画。它还支持使用非常简单的 API 定义自定义动画。让我们看看这是如何实现的。

使用自定义动画

以下示例展示了如何创建一个简单的淡入和上升动画,就像我们在上一节中所做的那样:

const fadeInUpCustom = {
  0: {
    opacity: 0,
    translateY: 50,
  },
  1: {
    opacity: 1,
    translateY: 0,
  },
};

react-native-animatable的自定义动画将样式映射到关键帧。在这个示例中,我们从第一个关键帧(0)开始,将opacity值设置为0,将translateY值设置为50。在最后一个关键帧(1)中,opacity值应该是1translateY值应该是0。现在这个动画可以作为任何 Animatable 组件的动画属性值使用,而不是预定义的字符串值。

理解 react-native-animatable 的优缺点

基于 React Native 动画 API 构建,动画 API 的所有优缺点也适用于react-native-animatable。除此之外,以下优点也值得提及:

  • 到目前为止,react-native-animatable是创建和使用高质量动画最容易的库。

  • 声明式方法:声明式方法创建的代码易于阅读和理解。

由于react-native-animatable是一个基于动画 API 构建的库,这个额外的层也带来了一些缺点:

  • react-native-animatable 作为项目的一个额外依赖项。这尤其重要,因为在编写本文时,该项目并没有得到非常积极的维护。这意味着,如果底层 Animated API 发生任何变化,它可能会阻止你升级你的 React Native 项目。

  • 受限的 API:预定义的动画和创建自定义动画的可能性有限。如果你想创建复杂的动画,你应该使用其他选项。

实际上,react-native-animatable 是建立在 React Native Animated API 之上的一个简单库。它简化了动画的工作,并且与简单、预定义的动画配合得最好。如果你需要这些简单或标准的动画,而你又非常有限的时间来创建动画,react-native-animatable 是你的最佳选择。

如果你想要创建更复杂的动画,请参阅以下部分。

探索 Reanimated 2 – React Native 最完整的动画解决方案

Reanimated 是迄今为止 React Native 最完整、最成熟的动画解决方案。它最初是对 React Native Animated API 的改进重实现,但随着版本 2 的发布,API 发生了变化,库的功能得到了极大的增强。

本节涵盖了以下主题:

  • 通过一个简单的示例了解 Reanimated API

  • 理解 Reanimated 2 的架构

  • 理解 Reanimated 的优缺点

让我们开始吧。

通过一个简单的示例了解 Reanimated API

实际上,Reanimated 2 的核心概念与 Animated API 一样简单。有可以更改的动画值,这些动画值驱动着动画。

以下代码展示了一个在 View 组件中缩放的动画:

import React from "react";
import { Text, Pressable } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, 
    Easing, withTiming } from 'react-native-reanimated';
const App = () => {
    const size = useSharedValue(0);
    const showView = () => {
      size.value = withTiming(100, {
        duration: 2000,
        easing: Easing.out(Easing.exp),
      });
  }
    const animatedStyles = useAnimatedStyle(() => {
      return {
        width: size.value,
        height: size.value,
        backgroundColor: 'red'
      };
  });
  return (
    <>
      <Animated.View style={animatedStyles} />
      <Pressable onPress={showView}>
        <Text>Show View</Text>
      </Pressable>
    </>
  );
}

当查看这段代码时,我们意识到以下几点:

  • 结构与 Animated API 非常相似。有一个 sharedValue,它是 Animated 中的 Animated.Value,还有一个 withTiming 函数,它是 Animated.timingAnimated 中的等效函数。Animated.View 组件的样式对象是通过 useAnimatedStyle 函数创建的,然后用作样式属性。

  • 没有使用 useNativeDriver 属性。

  • 我们在动画中更改宽度和高度值,因此布局属性会发生变化,这是使用 React Native 内部 Animated API 所不可能的。

Reanimated 的一个酷特点是,你不必关心原生驱动程序。使用 Reanimated 的每个动画都在 UI 线程上处理。另一个酷特点是,每个样式属性都可以使用。

如果你将此与 Animated API 的限制进行比较,你会立即看到 Reanimated 有多么强大。

要了解这是如何实现的,让我们看看 Reanimated 的架构。

理解 Reanimated 2 的架构

Reanimated 2 基于 animation worklet。在这种情况下,worklet 是在 UI 线程上运行的 JavaScript 函数。Reanimated 2 在 UI 线程上创建了一个第二、非常简约的 JavaScript 环境,用于处理这些动画 worklet。

这意味着它完全独立于 React Native JavaScript 线程和 React Native 桥接运行,这保证了即使是复杂的动画也能获得出色的性能。此 worklet 线程使用新的 React Native 架构。

让我们从了解如何使用 worklet 开始。

开始使用 worklet

让我们看看本章“理解 React Native 中动画的架构挑战”部分的示例。我们有一个根据 ScrollViewY 滚动值调整标题图像大小或移动的动画。以下图显示了使用 Reanimated 2 实现此示例时发生的情况:

![图 6.2 – 基于 Reanimated 2 中滚动值的动画图 6.2 – 基于 Reanimated 2 中滚动值的动画

图 6.2 – 基于 Reanimated 2 中滚动值的动画

在 Reanimated 2 中,动画作为 JavaScript 线程上的 worklet 创建。但整个动画 worklet 都在 UI 线程上的 worklet 线程中执行。因此,每次接收到新的滚动事件时,它不必跨越桥梁;相反,它直接在工作线程中处理,并将新的动画状态传递回 UI 线程进行渲染。

为了实现这种架构,Reanimated 2 提供了自己的 Babel 插件。此 Babel 插件从 react-native 代码中提取所有标记为 worklet 的函数,并使其在 UI 线程上的单独 worklet 线程中可运行。以下代码示例显示了如何将函数标记为 worklet:

function myWorklet() {
  'worklet';
  console.log("Hey I'm running on the UI thread");
}

这是一个简单的 JavaScript 函数,在第 2 行包含 worklet 注解。基于这个注解,Reanimated 2 Babel 插件知道它必须处理这个函数。

现在,这可以作为 JavaScript 线程上的标准函数运行,也可以根据调用方式作为 UI 线程上的 worklet 运行。如果函数像 JavaScript 代码中的正常函数一样被调用,它就在 JavaScript 线程上运行;如果使用 Reanimated 2 的 runOnUI 函数调用,它就在 UI 线程上异步运行。

当然,无论在哪里运行,都可以向这些 worklet 函数传递参数。

理解 JavaScript 线程和工作线程之间的联系

理解这种联系对于防止发生许多错误至关重要。本质上,JavaScript 线程和工作线程在完全不同的环境中运行。这意味着在 worklet 中,无法简单地从 JavaScript 线程访问所有内容。当涉及到 worklet 时,以下是一些可能的连接:

  • worklet并使用runOnUI调用。这将在 UI 线程上的 Worklet 上下文中运行函数。传递的每个参数都会复制到 UI 线程上的 Worklet 上下文中。

  • Worklets 可以访问 JavaScript 线程上的常量:Reanimated 2 处理 Worklet 代码,并将使用的常量和它们的值复制到 Worklet 上下文中。这意味着常量也可以在 Worklets 中使用,而无需担心性能下降。

  • Worklets 可以同步调用其他 Worklet 函数:Worklets 可以同步调用其他 Worklet,因为它们在相同的环境中运行。

  • Worklets 可以异步调用非 Worklet 函数:当从 Worklet 内部调用 JavaScript 线程上的函数时,这个调用必须是异步的,因为被调用的函数在另一个环境中运行。

想了解更多关于 Worklet 的信息,可以查看官方文档中的 Worklet 部分,链接为bit.ly/prn-reanimated-worklets

使用共享值

就像在 React Native 的内部 Animated API 中一样,Reanimated 2 使用动画值来驱动动画。在 Reanimated 2 中,这些动画值被称为共享值。它们被称为共享值,因为可以从 JavaScript 环境(JavaScript 线程和 UI 线程上的 Worklet 上下文)中访问。

由于这些共享值用于驱动动画,而这些动画在 UI 线程上的 Worklet 上下文中运行,因此它们被优化为从 Worklet 上下文中更新和读取。这意味着从 Worklet 中读取和写入共享值是同步的,而从 JavaScript 线程中读取和写入是异步的。

你可以在官方文档中更深入地了解共享值,链接为bit.ly/prn-reanimated-shared-values

使用 Reanimated 2 钩子和函数

当使用 Reanimated 2 时,大多数情况下不需要创建 Worklet。Reanimated 2 提供了一套优秀的钩子和函数,可以用来创建、运行、更改、中断和取消动画。这些钩子会自动处理将动画执行转移到 Worklet 上下文。

这就是本节开头示例中使用的方法。在那个场景中,我们使用useSharedValue钩子创建了一个共享值,将视图的样式与useAnimatedStyle钩子连接起来,并使用withTiming函数开始动画。

当然,你也可以使用 Reanimated 2 处理滚动值。以下代码示例展示了如何将ScrollView连接到共享值,通过用户滚动来缩放和移动图像的动画:

function App() {
  const scrolling = useSharedValue(0);
  const scrollHandler = useAnimatedScrollHandler((event) => 
  {
    scrolling.value = event.contentOffset.y;
  });
  const imgStyle = useAnimatedStyle(() => {
    const interpolatedScale = interpolate(
      scrolling.value,[-300, 0],[3, 1],Extrapolate.CLAMP
    );
    const interpolatedTranslate = interpolate(
      scrolling.value,[0, 300],[0, -300],Extrapolate.CLAMP
    );
    return {
      transform: [
        {translateY: interpolatedTranslate}, 
        {scaleY: interpolatedScale}, 
        {scaleX: interpolatedScale}
      ]
    };
  });
  return (
    <>
      <Animated.Image 
        source={require('sometitleimage.jpg')}
        style={[styles.header,imgStyle]} 
      />
      <Animated.ScrollView
        onScroll={scrollHandler}      >
        <View style={styles.headerPlaceholder} />
        <View style={styles.content} /> 
      </Animated.ScrollView>
    </>  
  );
}

在这个例子中,ScrollView 使用 Reanimated 的 useAnimatedScrollHandler 钩子将 Y 滚动值(内容偏移量)绑定到动画值。然后,这个动画值通过 Reanimated 2 的插值函数进行插值。这是在 useAnimatedStyle 钩子内部完成的。

这种设置使得动画工作,无需将滚动值通过桥接发送到 JavaScript 线程。整个动画在 UI 线程的工作线程中运行。这使得动画性能极高。

当然,Reanimated 2 提供了广泛的其它选项。可以使用基于弹簧的动画、基于速度的动画、延迟或重复动画,以及按顺序运行动画,仅举几例。

由于完整的 Reanimated 2 指南超出了本书的范围,请参阅官方文档(bit.ly/prn-reanimated-docs)和 API 参考(bit.ly/prn-reanimated-api-reference)。

为了完成这一部分,我们将探讨 Reanimated 2 的优缺点。

理解 Reanimated 的优缺点

到目前为止,Reanimated 2 是 React Native 中动画最先进和最完整的解决方案。有很多理由使用 Reanimated 2。以下是最重要的几个原因:

  • 易于使用的 API:带有 Hooks 和函数的 Reanimated 2 API 容易学习、阅读和理解。

  • 出色的性能:Reanimated 2 的动画在所有设备上运行流畅且性能出色。

  • 布局属性的动画:所有样式值都可以用于动画。没有像 Animated API 中的限制。

  • 中断、更改和取消动画:在 Reanimated 2 中,动画在运行时可以被中断、更改或取消,而不会导致帧率下降或操作缓慢。

Reanimated 2 是一个非常好的库,但在使用它之前,您应该查看以下缺点:

  • 复杂的安装:由于 Reanimated 2 深度干预 React Native 的架构,安装过程相当复杂。您需要对原生代码进行一些修改,并添加 Reanimated 2 Babel 插件。这并不是一个大问题,因为它只需要做一次,但会花费一些时间。当新的架构,包括新的 Fabric 渲染器推出时,这将会改变。

  • Reanimated 2 使您的包更大:虽然内部 Animated API 是 React Native 的一部分,但 Reanimated 2 是一个外部依赖项。这意味着您的包将会增大。

如果您的应用程序有很多动画、更复杂的动画以及/或动画布局属性,我肯定会推荐使用 Reanimated 2。如果您只使用基本的动画,这些动画可以通过内部 Animated API 实现,那么您不需要 Reanimated,可以继续使用 Animated API。

虽然 Reanimated 2、Animated API 以及 react-native-animatable 都有非常相似的方法,但接下来我们将了解的下一个库工作方式完全不同。让我们来看看 Lottie。

在 React Native 中使用 Lottie 动画

Lottie 是在应用和网页开发中处理动画的完全不同的方法。它允许你渲染和控制预构建的矢量动画。以下图示展示了 Lottie 动画创建和播放的过程:

图 6.3 – 使用 Lottie 动画时的流程

图 6.3 – 使用 Lottie 动画时的流程

图 6.3 – 使用 Lottie 动画时的流程

本质上,Lottie 包含一个播放器,在 React Native 的情况下是 lottie-react-native 库。这个库期望一个 Lottie 动画的 JSON 文件。这个文件是用 Adobe After Effects(一款专业的动画软件)创建的,并通过 Bodymovin 插件导出为 JSON 格式。

这个过程完全改变了我们在应用中处理动画的方式。开发者不再负责创建动画;他们只需要包含 JSON 文件。当处理非常复杂的动画时,这可以节省大量的时间。

所有这些内容在查看一个简单的 Lottie 动画时都会变得更加清晰。

从一个简单的例子开始

以下代码示例展示了如何使用 Lottie 实现一个加载动画:

import React from 'react';
import { View, StyleSheet } from 'react-native';
import LottieView from 'lottie-react-native';
const App = () => {
    return (
        <View style={styles.center}>
            <LottieView
                source={require('loading-animation.json')}
                style={styles.animation}
                autoPlay/>
        </View>
    );
};
const styles = StyleSheet.create({
    center: {
        flex: 1,
          alignItems: 'center',
            justifyContent: 'center'
    },
    animation: {
        width: 150,
        height: 150
    }
});
export default App;

无论动画多么复杂,以下代码就是包含加载动画所需的所有代码。LottieViewlottie-react-native 库中导入,并放置在动画应该发生的位置。Lottie JSON 文件作为源传递给 LottieView,可以通过样式属性像常规 React Native 视图一样进行样式化。

然而,lottie-react-native 不仅仅是一个简单的播放器。它为你提供了对动画的编程控制。你可以开始和停止动画,在加载时自动播放,并在完成后循环播放。最后一个特性对于加载动画特别有用。

将 Lottie 动画与 React Native Animated API 结合使用

lottie-react-native 的最佳特性是它可以将动画的进度绑定到 React Native Animated API 的 Animated.Value 组件。这为许多不同的用例打开了大门,例如基于 Lottie 的动画运行时间或弹簧动画。你还可以使用缓动或根据用户交互创建 Lottie 动画。

以下代码示例展示了如何创建一个由 Animated.Value 组件驱动的 Lottie 动画,该组件绑定到了 React Native ScrollViewY 滚动值:

const App = () => {
  const scrolling = useRef(new Animated.Value(0)).current;
  let interpolatedProgress = scrolling.interpolate({
    inputRange: [-1000, 0, 1000],
    outputRange: [1, 0, 1],
    extrapolate: 'clamp',
  });
  return (
    <View style={styles.container}>
      <Animated.ScrollView
        onScroll={Animated.event(
          [{
            nativeEvent: {
              contentOffset: {
                y: scrolling,
              },
            },
          }],
          { useNativeDriver: true },
        )}
        scrollEventThrottle={16}>
          <LottieView 
            source={require('looper.json')}
            style={styles.animation}
            progress={interpolatedProgress}/>
      </Animated.ScrollView>
    </View>
  )
}

在这个例子中,ScrollViewY 滚动值绑定到了 onScroll 函数中的 Animated.Value 组件。然后,Animated.Value 组件被插值以获取 interpolatedProgress,其值在 01 之间。这个 interpolatedProgress 作为进度属性传递给了 LottieView

Lottie 还支持使用原生驱动程序的 React Native Animated API 动画。这对于性能来说非常重要。关于这方面的更多信息,请参阅本章的 使用 React Native 内部 Animated API 部分。

寻找或创建 Lottie 动画

虽然 Lottie 动画对开发者来说很容易包含,但有人必须创建包含动画的 Lottie JSON 文件。获取 Lottie 动画文件有三种方法:

  • 在互联网上寻找 Lottie 文件:有很多有才华的动画艺术家在互联网上分享他们的作品。许多文件是免费的,但也可以购买高级动画内容。开始搜索 Lottie 动画的最佳地方是 lottiefiles.com/

  • 学习使用 After Effects 创建动画:有很多优秀的入门教程,即使一开始看起来令人望而却步,After Effects 仍然是一款出色的软件,用它来创建第一个动画相当简单。如果你对学习 After Effects 感兴趣,可以从 bit.ly/prn-lottie-… 上的教程开始。

  • 雇佣一位动画艺术家:在我看来,这是最好的解决方案。一位经验丰富的动画艺术家只需几个小时就能为你的项目创建一系列单独的动画。与动画艺术家合作可以节省时间和金钱,并且当拥有与你的 UI 概念完全匹配的单独动画时,将大大提高你应用的质量。你可以在 lottiefiles.com/ 上找到并联系动画艺术家。

现在我们已经很好地理解了 React Native 中的 Lottie 动画是如何工作的,让我们来看看其优缺点。

理解 Reanimated 的优缺点

由于 Lottie 方法完全不同,在考虑将 Lottie 作为项目动画解决方案时,你应该牢记其巨大的优缺点。

使用 Lottie 时,以下优点尤为突出:

  • lottie-react-native,无论动画多么复杂,只需几行代码即可集成动画。

  • 动画文件比 GIF 或 Sprites 小得多:在处理动画文件时,另一种方法是 GIF 或 Sprites。Lottie 文件比这些解决方案小得多,消耗的内存也少得多。

  • 对动画进度的程序控制:与处理 GIF 不同,你可以对动画进行程序控制。你甚至可以将动画进度绑定到 React Native Animated 的动画值。

然而,Lottie 也存在以下缺点:

  • 无法完全控制动画:当使用 Lottie 动画时,你可以控制动画的进度,但仅限于进度。你不能像完全脚本化动画那样根据用户交互更改动画路径。

  • lottie-react-native 必须包含在应用程序中,同时也需要包含针对原生平台的 Lottie 模块。

  • lottie-react-native 将立即与每个新的 React Native 版本兼容。

Lottie 是在 React Native 项目中包含高质量动画的绝佳选择。特别是对于复杂的加载动画、微动画或任何不需要完整程序控制的动画,Lottie 是一个很好的解决方案。

摘要

在本章中,你了解了在 React Native 中进行动画时的总体架构挑战。你了解到有不同解决方案可以克服这一挑战,并创建高质量和性能良好的动画。我们探讨了 Animated、react-native-animatable、Reanimated 和 Lottie,这些都是 React Native 屏幕动画的最佳和最广泛使用的动画解决方案。

这很重要,因为你在应用程序中需要使用动画来创建高质量的产品,而这些动画库是唯一在 React Native 中创建高质量和性能良好的动画的方法。

在下一章中,你将学习如何处理用户手势,以及如何与更复杂的手势一起工作来完成不同的事情——例如,驱动动画。

第七章:在 React Native 中处理手势

使好的应用在众多不良应用或移动网站中脱颖而出的最重要的事情之一就是良好的手势处理。虽然大多数情况下移动网站只监听简单的点击,但应用可以通过不同的手势来控制,例如短按、长按、滑动、捏合缩放或多指触摸。以非常直观的方式使用这些手势是开发应用时需要考虑的最重要的事情之一。

但不仅仅只是监听这些手势 – 你必须立即对用户做出响应,以便他们可以看到(并且可能取消)他们正在做的事情。一些手势需要触发或控制动画,因此必须与我们在第六章中学习到的动画解决方案配合得非常好,即与动画一起工作

在 React Native 中,有多种处理手势的方法。从简单的内置组件到非常复杂的第三方手势处理解决方案,你有很多不同的选项可以选择。

在本章中,你将学习以下内容:

  • 使用内置组件来响应用户手势

  • 与 React Native 手势响应系统以及 React Native PanResponder一起工作

  • 理解 React Native 手势处理器

技术要求

要运行本章中的代码,你必须设置以下事项:

  • 一个可工作的 React Native 环境 (bit.ly/prn-setup-r… – React Native CLI 快速入门)

  • 用于测试手势和多点触控的真实的 iOS 或 Android 设备

要访问本章的代码,请点击以下链接进入本书的 GitHub 仓库:

使用内置组件来响应用户手势

React Native 附带多个具有内置手势响应支持的组件。基本上,这些组件是对手势响应系统的抽象使用,你将在下一节中学习。手势响应系统为处理 React Native 中的手势提供了支持,同时也支持协商哪个组件应该处理用户手势。

最简单的用户交互是用一个手指点击。通过不同的Touchable组件、一个Pressable组件和一个Button组件,React Native 提供了不同的选项来识别点击并响应用户交互。

使用组件来响应简单的点击

记录用户点击操作最简单的组件是 React Native 的Touchable组件。

Touchable组件一起工作

React Native 在 iOS 上提供了三个不同的Touchable组件,以及一个仅适用于 Android 的额外第四个Touchable组件:

  • TouchableOpacity:通过减少被点击元素(及其所有子元素)的不透明度来自动提供用户反馈,让底层视图透过来。你可以通过设置activeOpacity来配置不透明度的减少。

  • TouchableHighlight: 通过减少不透明度和显示底层颜色来自动提供用户反馈,这会使被点击的元素变暗或变亮。你可以通过设置 underlayColor 来定义底层颜色,通过设置 activeOpacity 来定义不透明度的减少。

  • TouchableWithoutFeedback: 不提供用户反馈。只有在你有充分的理由时才应使用此功能,因为每个响应触摸的元素都应该显示视觉反馈。一个可能的原因是你已经在其他地方处理了视觉反馈。

  • TouchableNativeFeedback: 仅适用于 Android。通过触发原生 Android 触摸效果来自动提供用户反馈。在大多数设备上,这是众所周知的 Android 波纹效果,其中组件通过从触摸点扩展一个圆圈来改变颜色。你可以通过设置 background 属性来定义波纹效果。

所有四个 Touchable 组件都提供了四种方法来监听用户交互。这些方法的调用顺序如下图的顺序:

![Figure 7.1 – The onPress call order

![img/B16694_07_01.jpg]

图 7.1 – onPress 调用顺序

重要的是始终记住,onPressonPressOut 之后被调用,而 onLongPressonPressOut 之前被调用。让我们更详细地看看这些方法:

  • onPressIn: 当用户开始点击按钮时,立即调用此方法。

  • onPressOut: 当用户释放点击或当用户将手指移出组件外部时调用此方法。

  • onPress: 当用户在达到长按延迟(在 delayLongPress 中定义)之前完成点击时调用此方法。

  • onLongPress: 当达到长按延迟(在 delayLongPress 中定义)并且在此期间没有释放点击时调用此方法。

使用这些方法,你已可以处理许多不同的用例,并且——永远不要忘记——对用户的触摸提供即时视觉反馈。

虽然 Touchable 组件需要一些自定义样式,但 React Native 还提供了一个 Button 组件,它带有预定义的样式。

使用 Button 组件

在底层,Button 在 iOS 上使用 TouchableOpacity,在 Android 上使用 TouchableNativeFeedbackButton 带有一些预定义的样式,这样你就可以在不自己设置样式的情况下使用它。以下代码示例显示了使用 Button 的简单性:

<Button
  onPress={() => Alert.alert("Button pressed!")}
      title="Press me!"
      color="#f7941e"
/>

你只需定义一个 onPress 方法、一个按钮 标题 和按钮的 颜色Button 将处理其余部分,例如样式和视觉用户反馈。当然,你也可以使用 Touchable 组件的所有其他方法。

ButtonTouchable 是 React Native 中相当老旧的组件。由于它们工作良好,你可以在大多数情况下使用它们。但还有一个新的实现来处理用户点击。

使用 Pressable 组件

除了TouchableButton组件外,React Native 还提供了一个Pressable组件。这是最新的组件,由于其针对特定平台视觉反馈的高级支持,建议使用。

看一下以下代码示例,了解Pressable的优点:

<Pressable
  onPress={() => Alert.alert("Button pressed!")}
  style={({ pressed }) => [
    {
      backgroundColor: pressed
        ? '#f7941e'
        : '#ffffff'
    },
    styles.button
  }>
>
  {
    ({ pressed }) => (
      <Text style={styles.buttonText}>
        {pressed ? 'Button pressed!' : 'Press  me!'}
      </Text>
    )
  }
</Pressable>

它提供了与Touchable组件相同的方法,但在 Android 上还有涟漪支持,并在 iOS 上与自定义样式一起工作。你可以提供style属性作为一个函数,并监听pressed状态。

你还可以将一个功能组件作为子组件传递给Pressable组件,并使用那里的pressed状态。这意味着你可以根据它是否被按下改变Pressable组件的样式和内容。

另一个优点是你可以为Pressable组件定义点击和偏移区域:

![图 7.2 – Pressable点击和按下区域

![img/B16694_07_02.jpg]

图 7.2 – Pressable点击和按下区域

图 7.2中,你可以看到中心可见的Pressable组件。如果你想触摸区域大于可见元素,你可以通过设置hitSlop来实现。这对于重要的按钮或屏幕上重要的可触摸区域来说是一个非常常见的事情。

虽然hitSlop定义了点击开始的位置,但pressRetentionOffset定义了在Pressable组件外部,点击不会停止的额外距离。这意味着当你开始在点击区域内部开始点击并移动你的手指到点击区域外部时,通常onPressOut会被触发,点击手势完成。

但如果你已经定义了一个额外的按下区域,并且你的手势停留在该按下区域内,只要你的手指移动到该按下区域外,则点击手势被视为持续手势。hitSloppressRetention可以设置为number值或Rect值,这意味着作为一个具有bottomleftrighttop属性的Object

点击区域和按下区域都是提高你应用用户体验的绝佳方法,例如,它们可以使用户更容易按下重要的按钮。

在查看简单的点击处理之后,让我们继续看滚动手势。

ScrollView一起工作

处理滚动手势最简单的方法是 React Native 的ScrollView组件。如果内容比ScrollView本身大,这个组件可以使内容可滚动。ScrollView会自动检测和处理滚动手势。它有很多可配置的选项,所以让我们看看最重要的几个:

  • horizontal: 定义ScrollView应该是水平还是垂直。默认是垂直。

  • decelerationRate:定义用户在滚动时释放触摸时滚动减速的速度。

  • snapToIntervalsnapToOffsets:使用这两个方法,你可以定义ScrollView应该停止的间隔或偏移量。这可以极大地改善用户体验,因为滚动视图可以始终停止,以便用户可以看到一个完整的列表元素。

  • scrollEventThrottle仅适用于 iOS:定义在滚动时滚动事件将被触发的频率。这对于性能和用户体验非常重要。对于用户体验来说,最佳值是 16,这意味着滚动事件每 16 毫秒触发一次(直到 RN 支持 120 Hz - 然后,它将变为 8 毫秒)。

根据你对滚动事件的操作,这可能会导致性能问题,因为每次滚动事件都会通过桥接发送(除非你直接通过动画 API 处理,如第六章中所述,与动画一起工作)。因此,考虑你需要在这里设置什么值,并可能将其增加以防止性能问题。

小贴士

还有更多配置选项,例如定义过度滚动效果、粘性头部或弹跳。如果你想有一个完整的概述,请查看文档(bit.ly/prn-scrollview)。由于这不是初学者指南,我们专注于优化应用程序的重要部分。

说到这一点,当然,你可以通过使用ScrollView组件来自行处理滚动事件。这为你提供了优化 UX 的多种选择。ScrollView提供了以下方法:

  • onScroll: 在滚动过程中持续触发。这是一个很好的工具,可以通过将自定义动画与滚动事件结合来添加令人惊叹的用户反馈,就像我们在第六章中做的那样,与动画一起工作。但是,在这样做的时候,你应该要么使用带有本地驱动程序的 Animated API 来防止滚动事件每 16 毫秒传输一次,要么使用scrollEventThrottle来限制事件数量。

  • onScrollBeginDrag: 当用户开始滚动手势时触发。

  • onScrollEndDrag: 当用户停止滚动手势时触发。

  • onMomentumScrollBegin: 当ScrollView开始移动时触发。

  • onMomentumScrollEnd: 当ScrollView停止移动时触发。

使用这五个方法,你可以为用户的滚动手势提供很多不同的反馈。从简单地通知用户他们正在滚动到使用onScroll构建高级动画,一切皆有可能。

注意

ScrollView有非常长的子元素列表时,它可能会变得相当慢和占用大量内存。这是由于ScrollView一次性渲染所有子元素造成的。如果你需要一个具有元素懒加载的更高效版本,请查看 React Native 的FlatListSectionList

在使用内置的 React Native 组件之后,是时候看看如何完全自己处理触摸了。完成这一点的第一个选项是直接与 React Native 手势响应者系统一起工作。

与手势响应者系统和 PanResponder 一起工作

触摸响应者系统是处理 React Native 中手势的基础。所有Touchable组件都基于触摸响应者系统。使用此系统,您不仅可以监听手势,还可以指定哪个组件应该是触摸响应者。

这非常重要,因为在您的屏幕上有多个触摸响应者的情况下(例如,ScrollView中的Slider),存在几种场景。虽然大多数内置组件协商哪个组件应该成为触摸响应者并自行处理用户输入,但在直接与手势响应者系统一起工作时,您必须自己考虑这一点。

触摸响应者系统提供了一个简单的 API,并且可以在任何组件上使用。当与触摸响应者系统一起工作时,您必须做的第一件事是协商哪个组件应该成为处理手势的响应者。

成为响应者

要成为响应者,组件必须实现以下协商方法之一:

  • onStartShouldSetResponder: 如果此方法返回true,组件想要在触摸事件的开始时成为响应者。

  • onMoveShouldSetResponder: 如果此方法返回true,组件想要成为触摸事件的响应者。只要组件不是响应者,就会为每个触摸移动事件调用此方法。

重要提示

这两种方法首先在最深层的节点上调用。这意味着当多个组件实现这些方法并返回true时,最深层的组件将成为触摸事件的响应者。请在手动协商响应者时记住这一点。

您可以通过实现onStartShouldSetResponderCaptureonMoveShouldSetResponderCapture来防止子组件成为响应者。

对于这些响应者协商,如果另一个组件请求,组件释放控制权是很重要的。触摸响应者系统还为此提供了处理程序:

  • onResponderTerminationRequest: 如果此处理程序返回true,当另一个组件想要成为响应者时,组件会释放响应者。

  • onResponseTerminate: 当响应者被释放时,此处理程序会被调用。这可能是由于onResponderTerminationRequest返回true,或者由于操作系统行为。

当组件尝试成为响应者时,协商有两种可能的结果,都可以通过处理程序方法来处理:

  • onResponderGrant: 当它成功成为响应者并随后监听触摸事件时,此处理程序会被调用。最佳实践是使用此方法来突出显示组件,以便用户可以看到响应他们触摸的元素。

  • onResponderReject: 当另一个组件当前是响应者且不会释放控制权时,此处理程序会被调用。

当你的组件成功成为响应者时,你可以使用处理程序来监听触摸事件。

处理触摸

成为响应者后,你可以使用两个处理程序来捕获触摸事件:

  • onResponderMove: 当用户在屏幕上移动手指时,此处理程序会被调用。

  • onResponderRelease: 当用户从设备的屏幕上释放触摸时,此处理程序会被调用。

在处理手势时,你通常使用 onResponderMove 并处理它返回的事件的位置值。当连接位置值时,你可以重新创建用户在屏幕上绘制的路径。然后你可以按你想要的方式对此路径做出响应。

实际上是如何工作的,以下示例展示了:

const CIRCLE_SIZE = 50;
export default (props) => {
  const dimensions = useWindowDimensions();
  const touch = useRef(
    new Animated.ValueXY({ 
      x: dimensions.width / 2 - CIRCLE_SIZE / 2, 
      y: dimensions.height / 2 - CIRCLE_SIZE / 2
      })).current;
  return (
    <View style={{ flex: 1 }}
        onStartShouldSetResponder={() => true}
        onResponderMove={(event) => {
          touch.setValue({
            x: event.nativeEvent.pageX, y: event.nativeEvent.pageY
          });
        }}
        onResponderRelease={() => {
          Animated.spring(touch, {
            toValue: {
              x: dimensions.width / 2 - CIRCLE_SIZE / 2,
              y: dimensions.height / 2 - CIRCLE_SIZE / 2
            },
            useNativeDriver: false
          }).start();
        }}
    >
      <Animated.View
        style={{
          position: 'absolute', backgroundColor: 'blue',
              left: touch.x, top: touch.y,
              height: CIRCLE_SIZE, width: CIRCLE_SIZE,
              borderRadius: CIRCLE_SIZE / 2,
        }}
        onStartShouldSetResponder={() => false}
      />
    </View>
  );
};

此示例包含两个 View。外部的 View 作为触摸响应者,而内部的 View 是一个小圆圈,其位置根据用户移动手指的位置而改变。外部 View 实现了手势响应系统处理程序,而内部 View 只是对于 onStartShouldSetResponder 返回 false,以避免成为响应者。

你还可以看到手势响应系统与 React Native Animated 一起是如何工作的。当 onResponerMove 被调用时,我们处理触摸事件并将事件的 pageXpageY 值设置为 Animated.ValueXY

这是我们用来计算内部 View 位置的值。当用户从设备上移除手指时,onResponderRelease 会被调用,我们使用 Animated.spring 函数将 Animated.ValueXY 值恢复到其起始值。这使内部 View 回到屏幕中间的位置。

以下图像显示了示例中的代码在屏幕上的样子:

![图 7.3 – 在 iPhone 上运行的手势响应系统示例图片 B16694_07_03.jpg

图 7.3 – 在 iPhone 上运行的手势响应系统示例

在这里,你可以看到初始状态(左侧屏幕)。然后,用户触摸屏幕的右下角,蓝色圆圈跟随触摸移动(中间屏幕)。当用户释放触摸后,蓝色圆圈会在给定的时间段内从用户最后触摸屏幕的位置返回到屏幕中心(右侧屏幕显示了返回动画中的圆圈)。

即使在这个简单的例子中,你也可以看到手势响应器系统是一个非常强大的工具。你可以完全控制触摸事件,并且可以非常容易地将它们与动画结合。尽管如此,大多数时候你不会直接使用手势响应器系统。这是因为PanResponder,它是在手势响应器系统之上的一层轻量级层。

使用 PanResponder

PanResponder基本上与手势响应器系统的工作方式完全相同。它提供了一个相似的 API;然而,你只需要将Responder替换为PanResponder。例如,onResponderMove变为onPanResponderMove。区别在于你不仅得到原始的触摸事件。PanResponder还提供了一个状态对象,它代表了整个手势的状态。这包括以下属性:

  • stateID: 手势的唯一标识符

  • dx: 自触摸手势开始以来的水平距离

  • dy: 自触摸手势开始以来的垂直距离

  • vx: 触摸手势的当前水平速度

  • vy: 触摸手势的当前垂直速度

当涉及到解释和处理更复杂的手势时,这个状态对象非常有用。因此,大多数库和项目使用PanResponder而不是直接与手势响应器系统交互。

虽然手势响应器系统和PanResponder是响应用户触摸的非常好的选项,但它们也带来了一些缺点。首先,它们与没有原生驱动程序的 Animated API 具有相同的限制。由于触摸事件必须通过桥接传输到 JavaScript 线程,我们总是落后一帧。

这可能随着 JSI 的改进而变得更好,但这一点目前必须得到证明。另一个限制是没有任何 API 允许我们定义任何原生手势处理器的交互。这意味着总会有一些情况,无法通过手势响应器系统 API 解决。

由于这些限制,Software Mansion 团队在 Shopify 和 Expo 的支持下构建了一个新的解决方案——React Native 手势处理器。

理解 React Native 手势处理器

React Native 手势处理器是一个第三方库,它完全取代了内置的手势响应器系统,同时提供了更多的控制和更高的性能。

React Native 手势处理器与 Reanimated 2 结合使用效果最佳,因为它是由同一团队编写的,并依赖于 Reanimated 2 提供的工作 lets。

信息

本书参考的是 React Native 手势处理器 2.0 版本。版本 1 也被许多项目使用。

React Native 手势处理器 2 API 基于GestureDetectorsGestures。虽然它也支持版本 1 的 API,但我建议使用新的 API,因为它更容易阅读和理解。

让我们创建上一节中的可拖动圆形示例,但这次我们使用 React Native 手势处理器和 Reanimated 2:

const CIRCLE_SIZE = 50;
export default props => {
  const dimensions = useWindowDimensions();
  const touchX = useSharedValue(dimensions.width/
      2-CIRCLE_SIZE/2);
  const touchY = useSharedValue(dimensions.height/
      2-CIRCLE_SIZE/2);
  const animatedStyles = useAnimatedStyle(() => {
    return {
      left: touchX.value, top: touchY.value,
    };
  });
  const gesture = Gesture.Pan()
   .onUpdate(e => {
    touchX.value = e.translationX+dimensions.width/
        2-CIRCLE_SIZE/2;
    touchY.value = e.translationY+dimensions.height/
        2-CIRCLE_SIZE/2;
   })
   .onEnd(() => {
    touchX.value = withSpring(dimensions.width/
        2-CIRCLE_SIZE/2);
    touchY.value = withSpring(dimensions.height/
        2-CIRCLE_SIZE/2);
   });
  return (
    <GestureDetector gesture={gesture}>
      <Animated.View
        style={[
          { 
            position: 'absolute', backgroundColor: 'blue',
                width: CIRCLE_SIZE, height: CIRCLE_SIZE,
                borderRadius: CIRCLE_SIZE / 2 
          },
          animatedStyles,
        ]}
      />
    </GestureDetector>
  );
};

在这个例子中,你可以看到 React Native Gesture Handler 的工作原理。我们创建 GestureDetector 并将其包裹在代表触摸手势目标的元素周围。然后,我们创建一个 Gesture 并将其分配给 GestureDetector。在这个例子中,这是一个 Pan 手势,意味着它识别屏幕上的拖动。Gesture.Pan 提供了许多不同的处理程序。在这个例子中,我们使用了两个:

  • onUpdate:每次任何手势位置更新时,此处理程序都会被调用

  • onEnd:当手势释放时,此处理程序被调用

我们使用 onUpdate 来改变 Reanimated 的 sharedValue 值,并使用 onEnd 来将 sharedValue 重置到初始状态。

然后,我们使用 sharedValue 来创建 animatedStyle,并将其分配给我们的 Animated.View,即我们的圆形。

屏幕上的结果与上一节相同,但这里有两个重要的优势:

  • 更好的性能:由于我们使用了 Reanimated 2 worklets,我们的值和计算不需要通过桥接。手势输入和动画完全在 UI 线程上计算。

  • Race)或者是否可以在同一时间激活多个手势(Simultaneous)。

除了这些,React Native Gesture Handler 包含了许多不同的手势,例如 Tap(点击)、Rotation(旋转)、Pinch(捏合)、Fling(抛掷)或 ForceTouch(强触),以及内置组件如 Button(按钮)、Swipeable(可滑动)、Touchable(可触摸)或 DrawerLayout(抽屉布局),这使得它成为内置手势响应系统的优秀替代品。

如果你想深入了解 React Native Gesture Handler 所提供的所有可能选项,请查看文档:bit.ly/prn-gesture…

摘要

在本章中,我们学习了 React Native 的内置组件以及处理用户手势的解决方案。从简单的点击手势到更复杂的手势,React Native 提供了稳定的解决方案来处理手势。我们还了解了 React Native Gesture Handler,这是一个针对这些内置解决方案的优秀第三方替代品。

我建议在所有可以坚持使用标准组件的使用场景中,使用 React Native 的内置组件和解决方案。一旦你开始编写自己的手势处理,我建议使用 React Native Gesture Handler。

在动画和手势处理之后,我们将继续探讨另一个在性能方面非常重要的主题。

在下一章中,你将了解不同的 JavaScript 引擎是什么,React Native 中有哪些选项,以及不同的引擎对性能和其他重要关键指标的影响。

第八章:JavaScript 引擎和 Hermes

React Native 运行在 JavaScript 上,如第二章中所述,理解 JavaScript 和 TypeScript 的基本知识,JavaScript 需要一个 JavaScript 引擎来解释和/或将其转换为可执行的机器代码。对于 React Native 来说,这没有例外。

尽管市面上有相当多的不同 JavaScript 引擎,但在 React Native 项目中只有少数被使用。这是因为改变 JavaScript 引擎的过程相当复杂,以及新的 Hermes 引擎,这是一个为 React Native 开发的引擎,很快将成为默认引擎。尽管如此,了解不同可能的引擎及其优缺点仍然很重要和有帮助。

在本章的理论部分,我们将涵盖以下主题:

  • 理解 JavaScript 引擎

  • 了解 Hermes 引擎

  • 比较关键指标

技术要求

由于这是一个理论章节,你不需要设置任何东西。

理解 JavaScript 引擎

如本章引言所述,JavaScript 引擎负责解释 JavaScript 并将其/转换为机器代码,以便设备可以执行它。

最初的 JavaScript 引擎是简单的解释器,它们只是处理语句并确保执行。代码就像它被编写的那样执行。这已经发生了很大变化。

现代 JS 引擎提供了许多优化功能。最被讨论的是即时编译JIT),这是所有现代 JS 引擎都实现的。

编译型语言,如 C 语言,在代码执行前进行编译。在这个编译步骤中,不仅将代码转换为机器语言,还包括许多优化步骤。这产生了一个性能极优的输出。

即时编译意味着代码在运行时进行编译。这意味着即时编译器在编译时并不知道所有代码。这使得代码优化变得更加困难。即时编译器包含两个组件——分析器编译器。当 JS 代码由解释器执行时,分析器会关注不同语句执行的频率。

一个语句执行得越频繁,它从分析器那里得到的优先级就越高。当达到某个阈值时,分析器将这些代码语句发送给编译器,编译器然后将这些语句编译为字节码。当该语句下次要执行时,它将通过一个高度优化的字节码解释器执行。这使得这些部分运行得更快。

在编译过程中还可以进行一些优化。这很大程度上取决于实现,每个现代 JS 引擎都有自己的即时编译器实现。

通常,即时编译对运行时间较长的代码效果更好,因为编译器有更多时间来学习如何优化。由于在运行 React Native 应用时执行了大量的 JS 代码,即时编译效果极佳。

目前最知名的 JS 引擎是 JavaScriptCore 和 V8。由于两者都可以用于 React Native,我们将更深入地探讨它们。

使用 JavaScriptCore

JavaScriptCore 是为 Safari 浏览器提供动力的 JS 引擎。它是随 React Native 一起提供的默认引擎。如果你创建一个新的空白项目,JavaScriptCore 将解释并执行你的 JS 代码。

使用 V8

V8 是一个开源的 JS 引擎,得到了谷歌的大力支持。当你使用 React Native 的远程调试功能时,默认使用 V8。在这种情况下,你的 JS 代码将在由 V8 驱动的 Chrome 浏览器中执行。

重要提示

请始终记住,当你在远程调试开启/关闭时,你正在使用不同的 JS 引擎。在没有远程调试的情况下,你的 JS 代码在设备或模拟器上运行;当远程调试激活时,你的 JS 代码在计算机上的 Chrome 中运行,并通过 WebSocket 与本地进行通信。即使这两个引擎的行为应该相当相似,也有一些不一致之处。因此,在发布你的应用之前,始终在没有远程调试的情况下进行测试。

还有一个项目为 React Native 提供了将 V8 作为主要 JS 引擎的支持。对于 Android 来说这并不是什么大问题,因为它只是用 V8 引擎替换了 Android JS 引擎的 JavaScriptCore。在 iOS 上则更为复杂,因为 JavaScriptCore 在 iOS 上可用,无需将其包含在应用包中。因此,你不仅需要使用可用的 JS 引擎,还必须在你的应用中捆绑 V8 引擎。这会使你的应用包大小增加高达 7 MB,具体取决于你使用的版本。你可以在 react-native-v8 项目中找到更多关于此的信息:bit.ly/prn-rn-v8

虽然这两个引擎都能正常工作,但 Facebook 开始了一个名为 Hermes 的项目,以开发他们自己的 React Native JS 引擎。由于 React Native 的用例与浏览器引擎有很大不同,因为代码在构建时是可用的,并且在发布后无法更改;因此,有更多的优化空间。

了解 Hermes 引擎

Hermes 是在 2019 年的 React Native EU 大会上引入 React Native 社区的。当时,它已经在 Facebook 的应用中投入生产超过一年。它是完全以移动为中心构建的,这完全改变了架构方法。以下图显示了现代 JS 引擎的工作方式。

图 8.1 – 现代 JS 引擎管道(灵感来自 Tsvetan Mikov)

图片

图 8.1 – 现代 JS 引擎管道(灵感来自 Tsvetan Mikov)

在创建和构建 JavaScript 代码时,通常会有一些向后兼容的 JS 代码的转换编译和一些 JS 代码的压缩。然后,这个压缩后的 JS 包被发送到设备并执行。JavaScript 引擎如 JavaScriptCore 或 V8 会尝试使用即时编译来优化执行,正如之前所描述的,这是一个相当复杂的过程,可能会存储和优化错误的代码语句。Hermes 完全改变了这种方式。

以下图显示了 Hermes 中优化和编译的执行方式:

图 8.2 – Hermes 管道(灵感来源于 Tzvetan Mikov)

]

图 8.2 – Hermes 管道(灵感来源于 Tzvetan Mikov)

因为我们知道所有代码,我们希望在 React Native 应用中打包,所以可以在构建过程中进行编译和优化。这意味着所有优化都是在您的计算机(或您的 CI 环境中)进行的,而不是在用户的设备上。Hermes 使用一种所谓的内部代码表示,这种表示对代码优化进行了高度优化。

优化代码后,它被编译成优化的字节码。因此,当使用 Hermes 时,您不再发送 JavaScript,而是发送优化的字节码。这种字节码只需要在用户的设备上由 Hermes 引擎加载和执行。

这种方法带来了许多好处。其中最重要的如下:

  • 无需预热:我们不需要花费时间在即时编译器预热上。

  • 即时编译器输出的内存使用量为零:我们不需要为即时编译器的输出占用任何内存。这大大减少了内存占用。

  • 启动优化:一些在启动时由 JS 引擎执行的运算可以预先计算。这使得应用程序的启动速度大大提高。

  • 更小的包大小:优化后的包比压缩后的 JavaScript 代码更小。

由于这种方法的好处,Hermes 被推动尽快成为 React Native 的默认 JS 引擎。在撰写本文时,您仍然需要激活它,但操作非常简单:

  • android/app/build.gradle 文件中将 enableHermesfalse 改为 true。之后,您必须清理并重新构建您的应用程序。

  • ios/Podfile 文件中将 :hermes_enabled => false 改为 :hermes_enabled => true。使用 cd ios && pod install 重新安装您的 pods。

请注意,当使用 Hermes 时,远程调试功能的工作方式与之前不同。由于方法完全不同,没有可以直接在您的 Chrome 浏览器中运行的包。尽管如此,Hermes 支持使用 Chrome 检查器协议和 Chrome 开发者工具进行调试。

要使用远程调试,您必须通过 Metro 将您的 Chrome 浏览器连接到正在运行的设备。这可以通过以下方式完成:

  1. 在您的 Chrome 浏览器中转到 chrome://inspect/#devices

  2. 点击 配置… 按钮,并添加 Metro 服务器地址(通常是 localhost:8081)。

  3. 现在,有一个 Hermes React Native 目标,您可以进行检查。

更多信息,请访问 React Native 的 Hermes 文档(bit.ly/prn-hermes)或 Hermes 引擎本身的文档(bit.ly/prn-hermes-engine)。

如前所述,Hermes 方法给 React Native 带来了很多好处。这也在关键指标中得到了反映,我们将在下一节中查看这些指标。

比较关键指标

当涉及到移动应用时,在优化您的应用时,您应该查看以下几个指标。

理解重要指标

移动设备上最重要的关键指标如下:

  • 交互时间TTI):这是用户点击您的应用图标到用户可以使用您的应用之间的时间。尽可能减少 TTI 非常重要,因为移动应用用户非常没有耐心。交互时间越长,用户就越有可能在不使用您应用的情况下离开。

  • 应用大小:这是用户必须从商店下载以安装您的应用的大小。应用大小越大,用户就越不愿意下载您的应用。这可能有多种原因,例如某些国家的高传输成本或用户设备上剩余的磁盘空间。事实是,您的应用越小,用户就越有可能下载它。

  • 内存利用率:这个指标描述了您的应用在执行过程中消耗了多少内存。如果您的应用非常耗内存,可能会导致问题,尤其是在旧设备或多任务处理期间。此外,它可能导致操作系统关闭您的应用。您的应用消耗的内存越少,越好。

在查看这些指标时,有一些基准结果公开可用。由于 JavaScriptCore 和 V8 提供的结果大多相似(V8 在大多数测试中略好),我们将重点关注 React Native 应用中使用的 JavaScriptCore 和 Hermes 的比较。

在 Android 上比较 JavaScriptCore 和 Hermes

以下测试比较了 Android 上 JSC 和 Hermes 的关键指标。这次测试是由 Facebook 的 Hermes 团队使用 Hermes 的一个非常早期版本进行的:

JSCHermes
交互时间4.30s2.01s-2.29s-53%
应用大小41MB22MB-19MB-46%
内存利用率185MB136MB-49MB-26%

图 8.3 – Facebook JSC/Hermes 在 Android 上的测试(bit.ly/prn-hermes-…

另一次由备受尊敬的 React Native 社区成员 Kudo Chien 进行的测试运行也包含了 TTI。这次测试使用了不同的套件大小:

JSCHermes*毫秒
TTI 3MB 套件400240160-40%
TTI 10MB 套件584305279-48%
TTI 15MB 套件694342352-51%

图 8.4 – Kudo Chien 在 Android 上的 TTI 测试(bit.ly/prn-hermes-…

如果你查看测试结果,它们在 Android 上非常显著。所有测试中的交互时间都减少了大约 50%。这是一个真正的变革。与真正的原生或 Flutter 应用程序相比,React Native 应用程序过去打开速度较慢。这是由于在渲染第一个屏幕之前需要初始化 JS 引擎。Hermes 在 React Native 这个领域是一个巨大的进步。

当查看 Facebook 的测试时,应用程序大小也减少了近 50%。这部分原因是因为我们不再需要将 JavaScriptCore 引擎打包到我们的应用程序中,因此这种效果将在大型应用程序中减少。但即使在大型应用程序中,你也可以期待大约 30%的包大小节省。

现在让我们看看内存使用情况。在 Facebook 的测试中,Hermes 实现了大约 25%的内存节省。这主要是因为不需要即时编译,这也是一个巨大的成就。

再次强调,这些测试是在 Hermes 的非常早期版本上运行的,因此你可以期待未来有更大的提升。

虽然在 Android 上的结果非常清晰,但让我们继续在 iOS 上进行测试。

在 iOS 上比较 JSC 和 Hermes

在 iOS 上,我们必须记住 JavaScriptCore 是由操作系统提供的。这意味着当我们使用 JSC 时,我们不需要将任何 JavaScript 引擎打包到我们的应用程序中。此外,JavaScriptCore 针对 iOS 和苹果产品进行了优化。iOS 上 Hermes 的实现是由Callstack公司完成的,这是一家为 React Native 做出了大量贡献的公司。完成实现后,Callstack 团队还进行了一些测试,以比较 JSC 和 Hermes。以下是结果:

JSCHermes*以毫秒为单位
交互时间920ms570ms-350ms-38%
应用程序大小10.6MB13MB2.4MB18%
内存使用量216MB178MB-38MB-18%

图 8.5 – iOS 上 JSC/Hermes 调用栈测试(bit.ly/prn-hermes-…

与 Android 一样,交互时间和内存使用量都有很大提升。这些值略低于 Android,但这可以归因于 iOS 上 JSC 的更好优化。iOS 上的应用程序大小增加了,这似乎是合乎逻辑的,因此我们现在必须将 Hermes 添加到我们的包中,而 JSC 则由操作系统提供。

但是,当你的应用程序的 JavaScript 包增长时,由于 Hermes 的字节码比基于 JSC 的包中分发的压缩 JS 代码更小,这种效果将会减少。

摘要

在本章中,我们了解了 JavaScript 引擎的一般情况,学习了 React Native 对 JavaScript 引擎的特殊要求,我们可以在 React Native 中使用的不同引擎,以及如何更改我们的 React Native 项目的 JS 引擎。然后我们了解了 Hermes,这是一个考虑到移动设备和 React Native(尤其是 React Native)而开发的 JavaScript 引擎。

在理解了 Hermes 的方法和其优势之后,我们比较了在 JavaScriptCore、V8 和 Hermes 上运行的应用程序的关键指标。虽然使用 JSC 或 V8 没有太大差异,但 Hermes 在 TTI(触摸到文本显示时间)和内存利用率方面给 React Native 带来了巨大的提升。

在掌握 JavaScript 引擎之后,我们将在下一章中查看在处理 React Native 时有用的工具。

第九章:提高 React Native 开发的基本工具

React Native是一个拥有非常强大的开发者社区的框架。在过去的一年里,大量工具和库经历了进化式增长,使得 React Native 应用的开发变得更加容易和舒适。

除了专门为 React Native 开发的工具和库之外,你还可以在纯 React 生态系统中使用很多东西。这是因为这些大多数东西都与任何 React Native 应用的 JavaScript/React 部分兼容。

了解最佳工具和库以及如何使用它们非常有用,因为它可以节省你大量时间,并大大提高你的代码和产品的质量。

尤其是在你从事更大项目时,一些工具是绝对必需的,以确保在大团队中的良好协作。

在本章中,你将了解以下主题:

  • 如何使用类型安全、代码检查器和代码格式化工具提高代码质量

  • 为什么以及何时应该使用样板解决方案,以及如何利用它们

  • 如何寻找和使用高质量的 UI 库

  • 为什么以及何时应该使用 Storybook,以及如何使用它

技术要求

要运行本章中的代码,你必须设置以下内容:

使用类型安全、代码检查器和代码格式化工具提高代码质量

如同在第二章理解 JavaScript 和 TypeScript 的基本知识中已提到的,在大项目中使用类型化的 JavaScript 并配合一些工具确保一定程度的代码质量是必要的。

在下一节中,你将学习如何做到这一点。让我们从使用 TypeScript 或 Flow 进行类型安全开始。

使用 TypeScript 或 Flow 确保类型安全

类型安全在大多数编程语言中是标准,例如 Java 或 C#,这有很好的理由。相比之下,JavaScript 是动态类型的。这是因为 JavaScript 的历史。记住,JavaScript 最初被创建为一种脚本语言,用于快速编写小块代码。在这种情况下,动态类型是可行的,但当项目增长时,具有所有优点的静态类型是必不可少的。

使用类型化的 JavaScript 在开始创建类型时会产生一些开销,但它最终会给你带来很多优势。此外,如今,大多数库都附带定义好的类型,你可以直接使用。

第二章理解 JavaScript 和 TypeScript 的基本知识,你已经学习了如何使用和编写 TypeScript。本小节重点介绍 TypeScript 的优势以及在使用它时可以预防的错误。

动态类型可能导致严重且难以发现的错误

让我们从现实世界的一个例子开始这个部分,这是我在一个项目中的经历。在处理一个 React Native 项目时,我们没有使用静态类型化的 JavaScript。我们从远程数据库(Google Firebase)中通过唯一 ID 获取问题,并将它们本地存储在设备上(AsyncStorage)。

根据问题的 ID,我们还存储了用户答案,并在应用中将问题标记为已回答。更新后,所有答案似乎都从用户的设备上消失了,没有人知道为什么。结果是更新将唯一的 ID 从number改为string,这使得存储的用户答案与问题之间的比较失败。

调试这个错误非常困难,因为它在用应用更新的版本创建答案时不会发生。只有在用应用的老版本回答问题时才会发生;随后,应用被更新,问题被同步。

此外,错误从未抛出错误消息。它只是默默地发生了。因此,找到并修复这个错误花了一些时间。这只是一个例子,说明了由于动态类型而发生的错误,以及为什么处理这些错误很困难。它们可能导致直接注意到的严重错误,但在很多情况下,它们不会。

这在应用开发中尤其严重,因为你需要在用户的设备上存储大量数据。当你没有意识到你的数据类型有问题时,这可能导致数百万个不同设备上的数据损坏,这很难识别、调试和修复。

大多数这些错误可以通过使用 TypeScript 或 Flow 进行静态类型检查来预防。

重要提示

当使用 TypeScript 或 Flow 时,不要使用anyObject来使你的类型编写更容易。类型检查及其所有优势只有在整个项目中使用时才能真正发挥作用。因此,你应该明确地为所有属性添加类型。

带有类型检查的 JavaScript 不仅可以防止错误,还可以提高你的生产力。

通过代码补全增强你的 IDE

当你有静态定义的类型时,你的 IDE 很容易帮助你进行代码补全。大多数现代 IDE,如 Visual Studio Code 或 JetBrains WebStorm,对 TypeScript 和 Flow 都有出色的支持。

虽然 WebStorm 为 TypeScript 和 Flow 提供了大部分内置支持,但 VS Code 有很多有用的插件。特别是当使用 Flow 时,你必须安装一个扩展来确保代码补全和代码导航能正确工作。为此,请转到Flow Language Support

此外,我建议在每次提交时通过你的 CI 管道运行类型检查。你可以在第十一章 创建和自动化工作流程 中了解更多关于这个内容。

虽然类型化 JavaScript 阻止了许多错误并提高了生产力,但还有许多其他领域可以防止错误发生。其中大部分都由代码检查工具覆盖。在下一节中,你将了解它们是什么以及它们是如何工作的。

使用代码检查工具消除最常见的错误

Linters 是一种监控你的代码并强制执行某些规则的工具。当涉及到 JavaScript/TypeScript 时,ESLint 无疑是市场上最流行和最成熟的代码检查工具,因此本小节将重点关注 ESLint。它通过将你的代码与预定义的规则集进行对比来分析你的代码并找出问题。

这些问题可能是错误、非高效代码,甚至是代码风格错误。我建议使用 ESLint,因为它免费且可以确保一定程度的代码质量。

如果你使用 React Native CLI 来设置你的项目,你会发现 ESLint 已经预安装并带有工作规则集。如果你想将其添加到现有项目中,你可以使用以下命令进行安装:要么使用 npm install --save-dev eslint,要么使用 yarn add --dev eslint。在下一步中,你必须设置一个配置。这可以通过 npm init @eslint/configyarn create @eslint/config 命令自动完成。

现在,你可以使用 npx eslint file.jsyarn run eslint file.js 来使用 ESLint 检查你的代码与你的规则集。ESLint 还提供了一个 --fix 选项,它自动尝试修复尽可能多的错误。

你还可以将 ESLint 集成到大多数现代 IDE 中,以突出显示并自动修复 ESLint 发现的问题。我建议这样做。

此外,我建议在 CI 流程中每次提交时都运行 ESLint 检查。你可以在第十一章 创建和自动化工作流程中了解更多相关信息。

ESLint 是一个寻找常见错误的优秀工具,尽管它也支持代码风格规则,但在这个领域还有另一个工具做得更好。

使用 prettier 强制执行常见的代码风格

Prettier 是一个在 2016 年创建的代码格式化工具。本质上,它根据一组规则自动重写你的代码。这确保了它遵循标准,并为整个项目开发团队强制执行统一的代码风格。

要使用 prettier,你可以简单地使用以下命令将其作为开发依赖项安装。要么使用 npm install --save-dev prettier,要么使用 yarn add --dev prettier

将 prettier 与 ESLint 等代码检查工具集成可能会有些挑战。这是因为——正如你在上一个子节中学到的——这些代码检查工具也有格式化代码的规则。当你同时使用它们并指定了冲突的规则时,这不会起作用。幸运的是,prettier 随附有 ESLint 的预配置,可以防止这种情况发生。你可以从 prettier 主页下载它们。

安装完成后,您可以从命令行运行 prettier。要检查您的代码格式是否符合 prettier 规则,您可以使用 prettier 命令,后跟您想要检查的文件或文件夹的路径。在实践中,您通常希望 prettier 自动格式化您的文件。这可以通过 prettier --write 后跟文件或文件夹的路径来实现。

重要提示

您可以使用 .prettierignore 文件来排除文件不被 prettier 重新编写。您应该使用此文件来防止非您编写的文件、配置文件或其他文件的重新编写。

Prettier 为您的项目带来了很多价值,您不会希望在没有它的前提下进行开发,尤其是在您不是单独工作的时侯。使用 prettier 的最重要的优势如下列所示:

  • 更易于代码审查:在进行代码审查时,大多数编辑器会突出显示已做的更改。到目前为止,代码审查中最令人烦恼的事情是当开发者有另一个自动格式化设置时,导致所有代码都被标记为已更改以供审查。虽然这完全合理,因为所有代码都因自动格式化而更改,但它使得审查过程变得更加困难。这需要更多时间,并使审查更容易出错。Prettier 通过强制执行统一的代码风格来防止这种情况。

  • 更易于代码可读性:当您向团队添加开发者时,代码可读性是一个重要因素。代码的可读性越容易,新开发者成为您团队的有生产力成员所需的时间就越少。Prettier 保证统一的代码风格,这使得代码更容易阅读和理解。

Prettier 作为命令行工具和所有常见 IDE 的 IDE 扩展/插件提供。为了确保它被使用,您应该在项目的以下部分包含它:

  • IDE:所有开发者都应该将 prettier 添加到他们的 IDE 中,并配置他们的自动格式化快捷键以使用 prettier。

  • 提交前:提交前钩子应确保 prettier 不会抛出任何错误。

  • CI/CD:在创建拉取请求/合并请求时,应该运行 prettier 以确保手动审查可以高效进行。您可以在第十一章中了解更多信息,创建和自动化工作流程

如果您使用 prettier 实施此过程,从长远来看,您将节省大量时间。

因此,您在处理 React Native 项目时了解了最重要的工具。现在您将了解一些工具来成功启动新的 React Native 项目。市场上有不同的开源 样板解决方案,它们都有各自的优势。样板解决方案意味着您可以使用它作为模板开始,或者是一个 CLI 工具来生成您的起始项目。

使用样板解决方案

模板化解决方案使得设置具有稳固架构的项目变得容易。这非常有帮助,但你应该意识到这些模板化解决方案带来的权衡。此外,你应该确切地知道你想要什么,因为外面有完全不同的解决方案。

首先,在这个上下文中,模板化解决方案是指所有为你生成代码以开始项目而无需自己配置一切的东西。这可以是任何东西,从具有内置 TypeScript 支持但无其他功能的简单模板到提供导航、状态管理、字体、动画、连接等解决方案的完整 CLI 解决方案,例如 Infinite Red 的 Ignite CLI。

因为模板化解决方案包含的内容范围很广,很难对它们做出一般性的假设。尽管如此,可以说的是,模板化解决方案包含的内容越多,其中任何东西出现问题的风险就越大。因此,在本节中,你将了解最常见的模板,每个解决方案的优点、权衡以及如何使用它们。

使用 React Native TypeScript 模板

React Native 集成了模板引擎。当你使用 React Native CLI 来设置你的项目时,你可以使用一个 template 标志。这就是你可以使用 React Native TypeScript 模板的方式:

npx react-native init App 
    --template react-native-template-typescript

这个模板没有提供导航、状态管理或其他任何解决方案。它是普通的 React Native Starter 模板,但支持 TypeScript。我非常喜欢它,因为它非常简单,几乎没有依赖,并允许你决定你需要什么,同时为你完成所有 TypeScript 编译器配置。

优点包括以下内容:

  • TypeScript 支持

  • 无不必要的依赖

  • 易于维护

权衡包括以下内容:

虽然 React Native TypeScript 模板在开始新项目时使用起来不费吹灰之力,但以下模板化解决方案并不容易决定。这是因为它们附带更多的库。

使用 by thecodingmachine 的 React Native 模板

这个模板也使用了内置的 React Native 模板引擎来工作。但与 React Native TypeScript 模板相比,它已经为你做了很多决定。它包含了 Redux、Redux Persist 和用于状态管理的 redux toolkit、用于 API 调用的 Axios、React Navigation 和 Flipper 集成。此外,它为你的项目创建了一个良好的目录结构。你可以使用以下调用创建基于此模板的项目:

npx react-native init MyApp 
    --template @thecodingmachine/react-native-boilerplate

因为这个模板包含了很多预定义的库,你应该查看它是否是积极维护的并且最近有更新。否则,你可能会开始使用所有库的非常旧版本,这可能会很快需要耗时的更新。

优点包括以下内容:

  • TypeScript 支持

  • 良好的库

  • 良好的项目结构

权衡包括以下内容:

  • 它使用 Redux 进行状态管理,因此你可能不得不坚持使用它

  • 在撰写本文时,它已经落后于最新的 React Native 发布版三个版本,因此你将错过最新的功能和错误修复。

关于此样板更详细的信息,请访问官方文档 thecodingmachine.github.io/react-native-boilerplate/

虽然这些都是好的解决方案,但你应该查看它们是否真的适合你的项目。下一个模板带有略微不同的配置。

使用 mcnamee 的 React Native Starter Kit

这个样板不使用任何模板引擎或 CLI。它只是一个你可以下载或克隆并开始的 GitHub 仓库。此外,它还附带了一个有用的结构,并引入了许多库。

它使用 Redux 和 Rematch 进行状态管理,React Native Router Flux 进行导航,并且还附带 Native Base 作为 UI 库和 Fastlane 进行部署。基本上,它为你提供了在数小时内将第一个结果交付所需的一切。

但再次提醒,请查看模板的维护情况。在撰写本文时,React Native Router Flux 的最后一个发布版本已经超过一年,这意味着模板的一个核心库基本上是不可用的。

优点包括以下内容:

  • 良好的项目结构

  • 添加了所有启动所需的内容

权衡包括以下内容:

  • 它使用 Redux 进行状态管理,因此你可能不得不坚持使用它

  • 它使用 Native Base 作为 UI 工具包,因此你可能不得不坚持使用它

  • 它有一个过时的导航库,因此你将遇到与 React Native 最新版本的问题

你可以从官方 GitHub 页面 github.com/mcnamee/react-native-starter-kit 获取更多关于此模板的信息。

在查看两个样板模板后,我们将探讨两个真正广泛的 CLI 工具来设置你的项目。

使用 Ignite CLI 进行工作

Ignite 是由 Infinite Red 开发和维护的一个样板解决方案,Infinite Red 是一家出色的 React Native 公司,致力于出色的开源工作。它远不止是一个简单的模板。它是一个完整的 CLI,可以替换内置的 React Native init 命令。

使用以下命令,你可以创建一个新的应用程序:

npx ignite-cli new YourAppName

这创建了一个具有良好文件夹结构的应用程序,使用 React Navigation 进行导航,使用 MobX-State-Tree 进行状态管理,使用 apisauce 进行 API 调用,当然,还有 TypeScript 支持。除此之外,你的项目还自动支持 Flipper 和 Reactotron 进行调试,Detox 进行端到端测试,以及 Expo,包括 Expo web。

在所有这些之上,Ignite CLI 还带有一个名为生成器的功能。使用这些生成器,你可以通过 Ignite CLI 生成你的模型、组件、屏幕和导航器。这意味着你可以根据需要自定义项目,而无需从头开始编写这些文件。如果你想创建一个新的组件,可以使用以下命令:

npx ignite-cli generate component MyNewComponent

此命令基于存储在ignite/templates文件夹中的模板创建组件,该模板是用你的项目创建的。

小贴士

当使用 Ignite 生成器工作时,你可以编辑用于生成文件的模板。只需编辑ignite/templates中的模板,生成的文件将包含你的更改。这意味着你可以根据需要和标准调整模板,然后使用生成器确保每个人都遵守这些标准。

虽然这种设置非常适合专业项目,但它内置了许多库决策。特别是,对于状态管理,你可能想看看 MobX-State-Tree。这是一个很好的状态管理解决方案,但不像 Redux 或 React Context 那样受欢迎,这意味着社区支持相当有限。

优点包括以下内容:

  • 良好的项目结构

  • 良好的调试集成

  • Detox 端到端测试集成

  • 本地化集成

  • 生成器

权衡包括以下内容:

  • 它使用 MobX-State-Tree 进行状态管理,这不像 Redux 或 React Context 那样受欢迎。

  • 它自带 Expo 集成。这将增加你的应用包大小,并添加另一个依赖项。

  • 它为小型项目增加了很多开销

想要了解更多关于 Ignite 的信息,请访问 GitHub 页面github.com/infinitered/ignite

现在,你了解了不同的样板解决方案及其优缺点。即使你不使用样板解决方案来创建你的项目,我也建议你看看它们创建的结构。这种结构是你可以在此基础上构建的。

在查看这些样板解决方案之后,接下来我们将关注 UI 部分。同时,也有很多有用的开源解决方案,这将使你的生活变得更加轻松。

寻找和使用高质量的 UI 库

UI 库为最常见的用例提供预定义的 UI。你可以为你的项目使用很多不同的 UI 库。但有些比其他的好。本节不仅列出了最受欢迎的库,还为你提供了在进行自己的研究时必须考虑的一些想法。

一个好的 UI 库应该满足以下标准:

  • 维护良好:与所有库一样,它必须得到良好的维护。这意味着有多个贡献者,代码质量良好,并且有定期的发布。这对于确保 React Native 的未来版本升级得到支持非常重要。

  • 基于组件:一个 React Native UI 库应该提供一组组件,你可以直接使用。

  • 主题:库应该包含主题选项,并且易于适应你的颜色、字体、填充和边距。

  • 类型声明:一个好的 UI 库应该为组件和主题提供类型声明。

市面上有很多不同的 UI 库。在接下来的子节中,我将向你介绍其中两个,但鉴于它们可能并不完全适合你的项目,请在使用它们之前,根据这里提到的标准进行自己的研究。

使用 React Native Paper

React Native Paper 是一个基于 Material Design 的 UI 库。它由 Callstack 创建和维护,Callstack 是一家专注于 React Native 核心的公司,因此这些人员非常了解他们的工作。这意味着该库在代码质量方面设定了非常高的标准。

React Native Paper 符合之前子节中定义的所有标准。以下是在 React Native Paper 中包含的功能:

  • 出色的主题支持:Paper 内置了主题支持。你可以轻松地更改和扩展默认主题,并在你的应用中到处使用它们。

  • 类型声明:所有组件和主题都附带类型声明。

  • react-native-vector-iconsMaterialCommunityIcons 为你提供图标。

  • 超过 30 个预构建组件:所有组件都高度可定制且易于使用。

  • Appbar 作为 React Navigation 中的自定义导航栏。

虽然从技术角度来看,React Native Paper 可能是市面上最好的 UI 库,但你必须记住,它完全基于 Google 的 Material Design。这意味着你可能不希望在 iOS 上使用它,因为它会使你的应用看起来与 iOS 标准不同。

如需了解更多关于 React Native Paper、其安装和使用的详细信息,请访问官方文档:callstack.github.io/react-native-paper/

另一个高质量的 UI 库是 NativeBase。在下一个子节中,你将了解这个库。

使用 NativeBase

NativeBase 是一个适用于 React Native 以及纯 React 的 UI 库。这意味着它不仅适用于你的 iOS 和 Android 应用,如果你有,它也适用于你的 Web 应用。对于需要 Android、iOS 和 Web 支持的产品,这非常有用,因为你可以主要使用相同的代码库为所有平台编写代码。

此外,NativeBase 符合本节第一子节中定义的所有标准。以下是在 NativeBase 中包含的功能:

  • 出色的主题支持:NativeBase 也提供了非常好的主题支持。本质上,它的工作方式与 React Native Paper 非常相似。你可以轻松地更改和扩展默认主题,并在你的应用中到处使用它们。它还支持开箱即用的亮色和暗色模式。

  • 类型声明:所有组件和主题都带有类型声明。你还有关于如何扩展这些类型以自定义主题或组件的优秀文档。

  • react-native-vector-icons 用于纯 React Native 项目或 @expo/vector-icon 用于 Expo 项目。

  • 超过 30 个预构建组件:所有组件都高度可定制且易于使用。

  • 响应式支持:NativeBase 具有出色的响应式设计支持。这意味着你只需在组件上添加几个额外的属性,就可以适应不同的屏幕尺寸。

  • 无障碍性:基于 React Native ARIA,NativeBase 为所有组件提供了无障碍支持。这意味着你可以轻松地为屏幕阅读器提供支持,确保良好的对比度,并启用应用程序的键盘交互。

此外,NativeBase 还附带一个 Figma 文件,这使得它成为与设计专家一起创建自己的设计系统的理想起点。总的来说,它是一个在创纪录的时间内创建美观界面的非常好的解决方案。

如需了解更多关于 NativeBase 的信息,请访问官方文档docs.nativebase.io/

如前所述,还有许多开源 UI 库。请查看此列表以获取最受欢迎的库:

  • React Native UI Kitten

  • React Native Elements

  • Material Kit Pro React Native by creative-tim

  • Nachos UI Kit for React Native

这些 UI 库可以为你节省大量时间。但因为你希望为你的用户提供独特的应用程序体验,所以你应该只将它们作为起点。幸运的是,大多数库都足够灵活,你可以使用它们来创建自己的设计,同时使用经过实战考验的库结构。

随着你的项目增长,我建议扩展你选择的库,添加你自己的组件。如果你喜欢,并认为你有其他人对之感兴趣的东西,你甚至可以通过创建拉取请求和扩展官方库来为社区做出贡献。

在查看 UI 库之后,在下一个子节中,你将了解另一个有用的工具。这对于开发大型应用程序特别有用,在这些应用程序中,UI 组件在不同的存储库之间共享,并且一些开发者只负责 UI 组件。它被称为 Storybook

使用 Storybook 进行 React Native 开发

Storybook 在纯 React 世界中非常受欢迎。这是一个渲染你所有组件在预定义状态下的工具,这样你就可以查看它们,而无需启动你的真实应用程序并导航到它们被使用的位置。

使用 Storybook,你可以编写故事,这些故事随后会被打包成故事书。每个故事都包含一个组件。它还定义了故事书中的位置。以下代码示例展示了故事可能的样子:

import {PrimaryButton} from '@components/PrimaryButton;
export default {
  title: 'components/PrimaryButton,
      component: PrimaryButton,
};
export const Standard = args => (
  <PrimaryButton {...args} />
);
Standard.args = {
  text: 'Primary Button',
      size: 'large',
          color: 'orange',
};
export const Alert = args => (
  <PrimaryButton {...args} />
);
Alert.args = {
  text: 'Alert Button',
      size: 'large',
          color: 'red',
};

在第一行中,导入了 PrimaryButton 组件。接下来的默认导出定义了在 Storybook 中的位置以及与哪个组件相关联。Standard 常量和 Alert 常量是不同的状态,PrimaryButton 组件将在 Storybook 中渲染并显示。相应的 args 定义了这个状态:

图 9.1 – Storybook 在浏览器中运行

图 9.1 – Storybook 在浏览器中运行

在 React Native 上的 Storybook 既可以运行在 iOS 或 Android 模拟器上,也可以在真实设备上运行,或者您可以使用 React Native Web 创建组件的网页版本并将它们渲染到任何浏览器中。这如图图 9.1所示,当与设计师合作时特别有用。

Storybook 使得您可以在与应用程序的其他部分隔离的情况下开发组件。它不仅展示了组件,还允许您在 Storybook 中更改属性。因此,您可以看到组件在不同情况下在您的实际应用程序中的表现。

我不会在小型项目中使用 Storybook,但当您的项目和团队成长时,Storybook 可以成为提高您 UI 开发速度的有用工具。这尤其适用于您在不同存储库之间共享 UI 组件的情况。如果您公司有多个应用程序,都应该具有相同的视觉和感觉,我建议您使用它。

在这种情况下,为您的组件创建一个中心存储库可能是一个不错的解决方案。使用 Storybook,这个存储库可以由开发者和设计师维护,而无需访问所有应用程序。您可以在第十章,“结构化大规模、多平台项目”部分的编写自己的库中了解更多信息。

有关 Storybook 的更多信息,请访问官方文档storybook.js.org/

摘要

在本章中,您学习了用于提高代码质量、自动捕获最常见错误以及加快项目设置和开发过程的有用工具。您了解了类型定义的重要性以及如何使用 ESLint 和 prettier 确保您的代码符合某些标准。

此外,您还了解了最流行的 React Native 模板解决方案以启动项目,并学习了每个解决方案的优势和权衡。在本章末尾,您学习了 React Native 的 Storybook,如何使用它,以及在哪些场景下它是一个有用的工具。

在了解了所有这些有用的工具之后,是时候深入探讨大规模项目了。在下一章中,您将学习如何设置和维护项目结构,这将适用于大规模项目。此外,您还将了解您有哪些选项可以在不同平台之间共享代码,以及这些解决方案在哪些场景下效果最佳。

第三部分:在大型项目和组织中使用 React Native

你将学习如何在大型组织或大型项目中使用 React Native。这包括结构化大型应用程序、建立良好的流程、尽可能使用自动化,以及开始编写自己的库。

本节包含以下章节:

  • 第十章结构化大型、多平台项目

  • 第十一章创建和自动化工作流程

  • 第十二章React Native 应用的自动化测试

  • 第十三章技巧与展望

第十章:结构化大型、多平台项目

我坚信,软件项目的结构是决定成功或失败的关键因素之一。这包括应用程序架构、开发过程以及整个项目组织。

项目越大,参与项目的开发者越多,项目运行时间越长,良好的项目结构就越重要。但小型项目也可能因为结构不良而失败。因此,本章的大部分内容也适用于小型项目。

当使用 React Native 开发适用于多个平台的应用程序时,项目结构尤为重要,不仅限于 iOS 和 Android。不同的平台有不同的需求,并带来不同的用户期望。展示这一点的最佳例子是 iOS、Android 和网页之间的差异。

如已在第四章中提到的,React Native 中的样式、存储和导航,移动应用程序和网页中的导航概念完全不同。在规划项目结构时,您必须考虑这一点。

在投资良好的架构和良好的项目结构时的问题在于,它总是在一开始就产生一些额外开销。以下图显示了这种困境:

图 10.1 – 随着项目随时间增长,编码生产力降低

图 10.1 – 随着项目随时间增长,编码生产力降低

如果您在开始时不投资于架构,您将拥有更高的生产力。如果您投资于架构,您必须考虑应该实现什么,项目可能向哪个方向发展,以及当项目达到最大成功时,您将有什么用途、团队规模和要求。

这些考虑需要一些时间,实施和执行标准化流程可能需要更多时间。直接下载随机模板并开始编码总是更快。但如前所述,最终会得到回报,因为有了良好的应用程序架构和良好的项目结构,您最终会得到易于维护、测试和开发的软件。

因此,您将在本章中学习以下内容:

  • 设置适用于大型企业项目的应用程序架构

  • 使用 React Native 部署到不同的平台

  • 使用您自己的库重用代码

技术要求

要运行本章中的代码,您必须设置以下内容:

  • 一个有效的 React Native 环境 (bit.ly/prn-setup-r… – React Native CLI 快速入门)。

  • 您可以在本存储库中找到示例项目:bit.ly/prn-videoexample

  • 虽然本章的大部分内容也应该适用于 Windows,但我建议在 Mac 上进行操作。

  • 本章包含一些原生代码。您应该对 Java 或 Kotlin 以及 Objective-C 或 Swift 有基本了解。

为大型企业项目设置一个适用的应用架构

当我们谈论大型项目以及如何设置一个合适的应用架构时,看看这些大型项目与小型团队或单个开发者项目相比有什么不同是有意义的。

以下是最重要的几点:

  • 项目团队非常大:在大型项目中,你通常有一个由许多开发者组成的庞大团队。通常,这些开发者遍布世界各地,这意味着他们处于不同的时区,使用不同的第一语言,并且有着完全不同的文化背景。因此,拥有一个清晰的结构和明确的责任是非常重要的。否则,你的项目将会失败。

  • 多个开发者将在应用程序的同一部分工作:在最后期限即将到来,一个特性必须完成的时候,多个开发者将共同工作在同一个特性和应用程序的同一部分。这意味着你应该考虑如何组织你的代码,以便在没有冲突的情况下实现这一点。

  • 每个错误都会被用户发现:在只有少数用户的较小项目中,很多错误可能永远不会被发现。在拥有大量用户的规模较大的应用程序中,错误几乎不可能保持未被发现。这意味着在将应用程序发布给公众之前,你必须投入更多的努力来自己发现错误。

  • 代码必须通过程序进行测试:项目越大,运行时间越长,程序化测试你的代码就变得越重要。在某个时候,手动处理所有测试是不可能的。这意味着你必须有一个非常支持这种自动化测试的应用架构。

  • 代码库将变得非常大:正如“大型项目”这个术语所暗示的,项目和它的代码库将变得非常大。这意味着你必须提供一个结构,使得新开发者尽可能容易地理解项目中的情况。

在这些要点的基础上,我们将尝试找到一些支持所有这些的架构方法。

适应我们的示例项目结构

当项目增长时,最重要的就是分解。这意味着你应该尽可能地将你的组件拆分成小而意义明确的片段。

当我们查看示例项目的项目结构时,我们已经很好地分解了我们的应用程序,并且我们选择的架构对于我们的用例来说效果良好。它已经包含了一些即使在大型项目中我也推荐保留的东西。以下就是这些内容:

  • 使用服务:每个 API、SDK 或第三方连接器都应该被封装在你的服务中。这样,你可以以最小的努力更改 SDK 或服务以及合作伙伴。

  • 组件和视图的分离:可重用的组件和可导航的视图应保存在不同的文件夹中。这使得新开发者更容易找到他们正在工作的视图。

然而,这种方法也带来了一些问题,尤其是在代码库增长且多个开发者共同工作时:

  • 组件和视图难以通过程序进行测试

  • 组件目录将迅速增长并变得非常大

  • 单个功能将难以找到,并且散布在整个代码库中

  • 整个代码库将变得相当混乱

  • 许多开发者将不得不同时触摸相同的文件

因此,我们将对我们的方法进行一些调整。首先,我们将关注组件级别。到目前为止,我们将在一个文件中编写组件的业务逻辑、UI、样式和类型。现在将发生变化。我们将把我们的组件拆分为以下:

  • index.tsxindex 文件包含组件的业务逻辑,如数据获取,以及与全局应用程序状态的连接。它只渲染以下 .view 组件。

  • <component>.view.tsxview 文件包含 UI。它不保留自己的状态,也不直接连接到全局应用程序状态。它只渲染从 index 文件获得的属性。

  • <component>.styles.tsxstyles 文件包含 React Native StyleSheet 或 styled-components,具体取决于你选择的方法。

  • <component>.types.tsxtypes 文件提供了 indexview 文件的属性和状态的数据类型。

通过这种分离,我们实现了两个目标。首先,一个开发者更容易处理业务逻辑,另一个处理 UI,而不会产生合并冲突或其他问题。其次,我们的组件现在对自动化测试的支持更好。

我们可以使用任何组件测试框架来渲染和测试视图,而无需模拟我们的全局状态或组件状态。此外,使用这种方法集成工具(如 Storybook)也容易得多。

要查看该方法的实际应用,你可以查看 GitHub 仓库,选择 chapter-10-split-home-view 标签,并查看 views/home 文件夹。

提示

为了确保每个人都遵循这种模式,并使创建新的视图和组件更简单,你可以使用文件生成器。这些是小脚本,使用模板和通常是一个组件名称来创建你想要的结构。你可以在 GitHub 仓库中看到一个示例。选择 chapter-10-generator 标签,查看 util 文件夹。你可以使用生成器通过 npm run generate <name> 来生成新的视图。

在组件级别进行此更改后,我们将退后一步,再次审视整个项目。当你项目增长时,我推荐的第二个更改是按功能对视图和组件进行分组。这使得理解整个项目结构并导航代码变得容易得多。

我必须承认,这取决于个人偏好,有些人甚至喜欢在大型项目中组件和视图之间清晰的分离,但我更喜欢功能方法。这种方法如图所示:

![图 10.2 – React Native 特征分组架构]

![图片/B16694_10_02.jpg]

图 10.2 – React Native 特征分组架构

这种方法按功能对应用程序进行分组。它还有一个组件文件夹,其中包含非常基本的通用组件,如按钮、列表和头像——基本上,这些是在你的应用程序的每个功能中使用的,以提供一致的用户体验。但每个功能也有自己的组件文件夹,你可以将只为这个功能创建的组件放入其中。还有对这个方法的修改,即将多个视图放在一个功能中。

从我的个人经验来看,我可以这样说,这种功能方法使代码库结构非常清晰,并且使查找你正在寻找的内容变得更容易。另一方面,你总会遇到一些组件,你不确定是否应该将它们放入通用组件中。

最后,你必须找到自己想如何构建应用程序结构的方法。但在本节中,你学习了为了创建即使在项目扩展时也能正常工作的结构,你必须注意的最重要的事情。

现在你已经学会了如何一般性地构建 React Native 项目,我们将更进一步,专注于多平台开发。

使用 React Native 部署到不同平台

在本节中,你将学习如何设置你的 React Native 项目以支持多个平台。由于这是最常见的情况,我们将在这里大量关注 Web,但本节中的提示和方法也适用于其他平台,如桌面和电视。

当为多个平台创建应用程序时,始终有两个目标。首先,你希望尽可能支持更多平台特定的功能,并希望为用户提供他们在该平台上习惯的外观和感觉。其次,你试图尽可能多地共享代码,因为这使维护和开发你的应用程序更容易。

初看,这些目标似乎相互矛盾,但有一些智能的方法可以同时获得两者的最佳效果。让我们从最简单的方法开始。

使用 react-native-web 创建 Web 克隆

当你使用 React Native 创建应用程序时,你可以使用一个名为react-native-web的库来在 Web 上运行你的 React Native 应用程序。

在我们开始之前,你必须理解 react-native-web 是做什么的。基本上,它将所有 React Native 组件映射到 HTML 组件。例如,一个 <View/> 组件将得到 <div/>。它还将 React Native 的原生 API 调用映射到浏览器 API,只要可用。这意味着你将得到一个普通的 React 网页应用程序。

虽然 react-native-web 是一个很棒的库,但要开始使用它并不容易,因为您必须设置一个单独的构建过程来使用它。这个构建过程将创建一个独立的 React 网页应用程序。像每个 React 网页应用程序一样,它需要一个打包器来创建优化的浏览器可读 JavaScript 代码。一个非常流行的解决方案是 Webpack,我们也将使用它来构建我们的网页应用程序。此外,每个网页应用程序都需要一个入口点。在大多数情况下,这是一个 index.html 文件,然后加载包含 React 应用的 JavaScript 包。因此,我们必须将其添加到我们的项目中。

react-native-web 文档中(您可以通过以下链接查看:bit.ly/prn-rn-web)… TypeScript 支持。

因此,当我们在示例应用程序中设置基本的网页支持时,我将描述最重要的内容。您可以在选择 chapter-10-web 标签时在 GitHub 仓库中找到完整的完整设置。

安装 react-native-web

我们将从添加 react-native-webreact-dom 到我们的项目开始。请使用正确的 react-dom 版本。由于我们在 React Native 应用中使用 React 17,因此我们必须使用 react-dom@17。这些库是创建 React 应用所必需的。安装可以通过 npm 完成:

npm install react-dom@17 react-native-web

否则,可以通过 yarn 来完成:

yarn add react-dom@17 react-native-web

现在我们已经安装了 react-native-web,我们需要处理网页的构建过程和开发环境。

安装 webpack

为了做到这一点,我们将添加 Webpack、相应的 CLI 以及一个名为 webpack-dev-server 的 Webpack 扩展。这个扩展提供了一个内置的开发服务器,在您开发应用程序时支持实时重新加载。

这些 npm 库的安装可以通过以下 npm 命令来完成:

npm install –saveDev webpack webpack-cli webpack-dev-server

否则,你可以使用一个 yarn 命令:

yarn add --dev webpack webpack-cli webpack-dev-server

除了这个基本的 Webpack 设置之外,我们还将安装两个加载器。加载器是 Webpack 的一个核心概念。它们使您能够预处理文件并决定它们应该如何在您的包中使用。我们将使用以下加载器:

  • ts-loader:这是一个预处理我们的 TypeScript 文件并将其转换为浏览器可读 JavaScript 的加载器

  • file-loader:这个加载器将我们的资产二进制文件(如图像)复制到我们的最终包中

我们需要为我们的网页构建过程工作的最后一件事是 html-webpack-plugin。这个插件创建我们的入口点。它通过加载 HTML 模板并添加创建的 JavaScript 包来写入 index.html

这些添加可以通过以下npm命令安装:

npm install –saveDev file-loader ts-loader html-webpack-plugin

否则,使用以下yarn命令安装:

yarn add --dev file-loader ts-loader html-webpack-plugin

现在我们已经安装了所有工具,我们必须配置我们的项目。

配置 React Native 项目以支持 Web

首先,让我们为我们的应用程序创建一个 JavaScript 入口点。为此,我们将在应用程序的根目录中创建index.web.js。这包含以下代码。

AppRegistry.registerComponent(appName, () => App);
AppRegistry.runApplication(appName, {
  initialProps: {},
  rootTag: document.getElementById('movie-root'),
});

我们使用 React Native 的AppRegistry通过registerComponent函数加载我们的<App />组件,然后通过runApplication运行我们的应用程序。

runApplication需要一个 HTML 节点作为rootTag来支持 Web。这个 HTML 节点将在runApplication期间被 React 应用程序替换。在我们的例子中,我们将从 HTML 文档中获取带有movie-root ID 的元素。

接下来,我们将在项目的root文件夹中创建一个web/文件夹。在这个文件夹中,我们将放置一个包含以下内容的index.html模板(请参考 GitHub 仓库以获取完整文件):

  <head>
    <title>
      Movie Application
    </title>
    <style>
        html, body { height: 100%; }
        body { overflow: hidden; }
        #movie-root { display:flex; height:100%; }
      </style>
  </head>
  <body>
    <div id="movie-root"></div>
  </body>

在文档的头部,我们定义了一个标题和一些样式。这些样式对于react-native-web应用程序的显示非常重要。主体部分只包含一个空的<div />元素,并带有#movie-root ID。这是我们用于 JavaScript 入口点的容器。

接下来,我们必须配置我们的 Webpack 构建器。为此,请在web/文件夹中创建webpack.config.js。以下代码片段显示了最重要的配置。对于完整文件,请查看 GitHub 仓库:

const rootDir = path.join(__dirname, '..');
module.exports = {
  entry: {
    app: path.join(rootDir, './index.web.ts'),
  },
  output: {
    path: path.resolve(rootDir, 'dist'),
    filename: 'app-[hash].bundle.js',
  },
  module: {
    rules: [{
        test: /\.(tsx|ts|jsx|js)$/,
        exclude: /node_modules/,
        loader: 'ts-loader'
 }]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, './index.html'),
    })
  ],
  resolve: {
    extensions: [
      '.web.tsx','.web.ts','.tsx','.ts','.js'
    ],
    alias: Object.assign({
      'react-native$': 'react-native-web',
    }),
  },
};

让我们从头到尾处理这个配置。首先,我们定义了我们的 JavaScript 入口点。在这里,我们放置了我们刚刚创建的index.web.js文件。然后,我们定义了我们的输出。在这种情况下,它是dist/目录和一个带有哈希值的 JS 包,以确保每次构建都有新的文件名,以防止浏览器缓存问题。

module部分,我们可以定义规则来指定哪些加载器应该用于预处理哪些文件。我们使用正则表达式来测试文件名,并为所有匹配的文件定义加载器。在这个例子中,我们为包含.tsx.ts.jsx.js的所有文件使用ts-loader,除了node_modules文件夹中的所有内容。

在文件的下一部分,我们定义了我们将使用哪些插件。在我们的例子中,只有HTMLWebpackPlugin用于从我们的模板 HTML 文件创建入口点index.htmlconfig文件的最后一部分是resolve部分。在这里,React Native 到普通 React Web 应用程序的转换魔法正在发生。

通过为react-native创建react-native-web别名,我们替换了所有react-native的实例,现在它们都来自react-native-web。这意味着所有从react-native获取的导入现在都来自react-native-web

现在我们 Web 应用程序的构建过程已经工作,我们将在我们的 TypeScript 设置中进行一些小的调整:

"lib": ["es2017", "dom"],
"jsx": "react",
"noEmit": false,

我们在lib部分添加了dom,将jsx模式改为react,并将noEmittrue改为false。这是为了以 Webpack 可以处理的方式创建文件。这一步完成后,设置就完成了。

在浏览器中以 React 应用运行 React Native 代码

现在,我们可以从命令行以dev模式启动我们的 React Native 应用作为 React Web 应用。你可以使用以下命令来完成:

cd web && webpack-dev-server

以下截图显示了我们的示例电影应用在浏览器中的运行情况:

图 10.3 – 在浏览器中运行的我们的示例电影应用

图 10.3 – 在浏览器中运行的我们的示例电影应用

图 10.3 展示了在浏览器中运行的示例电影应用的 UI。它运行得非常完美,与原生应用使用相同的代码库。当你使用浏览器的检查工具检查 HTML 时,你会看到所有的 React Native 组件都被转换成了 HTML 组件。

作为本节的最后一步,为了使开发和创建生产构建更容易,我们在package.jsonscripts部分添加了两个命令:

"start:web": "cd web && webpack-dev-server",
"build:web": "cd web && webpack",

第一行是我们刚才用来以dev模式启动应用程序的命令。第二行是用来在生产模式下构建应用程序以进行部署的命令。这会将完整的包写入我们在webpack.config.js中定义的dist文件夹。

在本小节中,你学习了如何创建你的 React Native 应用在 Web 上的克隆。虽然这可能在某些情况下有效,但大多数时候这还不够。Web 和移动用户在大多数领域的期望是不同的,你也可能希望使用 Web 和移动不同的库,这些库不支持其他平台。区分不同平台的一个非常简单的解决方案是利用文件扩展名。

使用.native 和.web 文件扩展名

如前一小节所述,我们对 Web 和原生应用有两个完全不同的构建过程。虽然我们配置了 Webpack 打包器以支持.web.ts.web.tsx文件,但原生 Metro 打包器默认支持.native.ts.native.tsx文件。这意味着我们可以通过简单地创建文件的两个版本来编写特定平台的代码:

  • App.tsxApp.native.tsx会导致我们的 Web 应用使用App.tsx,而我们的原生应用使用App.native.tsx

  • App.tsxApp.web.tsx会导致我们的 Web 应用使用App.web.tsx,而我们的原生应用使用App.tsx

这种方法可以用来共享大部分代码,但为组件创建特定平台的版本。它也可以用来为不同的平台定义不同的导航堆栈,或者通过创建特定平台的App.tsx文件来使用不同的导航库。

总的来说,这种方法非常强大,但也存在一些限制。例如,你将不得不使用你在不同平台之间共享的库的相同版本,因为这两个平台共享一个package.json文件。如果你想更进一步,你可以要么在monorepo中处理多个包,要么从你想要共享的代码中创建自己的库,然后将这些库导入到不同的平台特定项目中。

让我们先看看monorepo方法。

在单一代码库中处理多个包

对于将你的多平台 React Native 应用程序作为monorepo进行结构化,我建议使用yarn工作空间。这是在单个存储库中设置多个 JavaScript 包的方法。yarn在版本和存储方面优化库。它还允许包之间相互链接,这也是我们在这里使用它的主要原因。

想了解更多关于yarn工作空间的信息,你可以查看官方文档(bit.ly/prn-yarn-workspaces)。以下图显示了具有yarn工作空间的多平台monorepo结构:

![图 10.4 – 基于工作空间的多平台 React Native 单一代码库图片

图 10.4 – 基于工作空间的多平台 React Native 单一代码库

你有一个共享代码包(通常也称为App),它可以包含应用程序的大部分内容,如视图、存储、服务和组件。这个包不是直接启动的,也没有本地或 Web 入口点。然后,你为每个平台有一个包。

这些包中的每一个都有自己的package.json文件,可以定义自己的库和版本。这种设置甚至允许你在不同的平台上使用同一库的不同版本,只要你的共享代码支持所有这些版本。

平台特定包包含入口点,我也建议在这里放置平台特定的东西,如导航和一般的应用程序结构(层堆栈或导航树)。这使得不仅能够为每个平台创建相同应用程序的副本,而且还可以使用非常不同的方法。

例如,你可以在 Web 和移动端有完全不同的层堆栈。这完全合理,因为大多数时候,不同平台的需求是完全不同的。有些你在 Web 上需要的东西甚至不想在移动应用中拥有,反之亦然。

这种包式方法还有另一个优点。基于 React 的框架有很多,它们执行了许多针对 Web 的特定优化,例如支持服务器端渲染、将浏览器历史支持添加到路由中,或者进行广泛的 Web 包优化。这类最受欢迎的框架是Next.jsGatsby。使用这种设置,你可以为 Web 使用它们。

如果你想要从这种monorepo设置开始,我可以推荐一个优秀的模板,你可以在以下链接找到:bit.ly/prn-rn-universal-monorepo。这个模板不仅支持移动和网页,还支持一些其他框架和平台,如 Next.js、Electron、桌面应用程序,甚至浏览器扩展。还有一个很好的描述,可以指导你完成设置过程,你可以在以下链接找到:bit.ly/prn-rn-anywhere

采用这种方法,我们首次为不同的平台创建了不同的包。在这种情况下,我们只使用了一个仓库,因为这使开发变得相当简单。我们只需要克隆仓库,安装依赖项,就可以开始了。

我真的很喜欢这种方法,但当代码库和团队规模大幅增长时,进一步深入确实是有意义的。为了更清晰地分离应用程序的不同部分并明确其责任,你可以将应用程序拆分为不同的项目。这意味着你将创建自己的库。

使用自己的库重用代码

有很多理由要创建自己的库。在不同平台之间共享代码无疑是其中之一。但通过自己的库,你还可以实现以下事情:

  • 确保所有应用程序中的一致设计:当你在提供多个应用程序的公司工作,并且需要确保这些应用程序的设计一致时,创建一个提供所有这些应用程序的 UI 组件的 UI 库是一个好主意。这确保了一个一致的设计系统。

  • 简化后端连接:你可以将你的服务提取到一个库中,然后可以在所有项目中使用这个库。这确保了统一的后端连接层。

  • 定义责任:每个库都可以由其维护者或团队维护。通过这种库方法,你可以明确定义责任。

  • 提供额外功能:你也可以编写自己的库来提供原生功能,这些功能在社区模块中无法以你所需的方式获得。在这种情况下,我总是建议以自己的库的形式提供这种功能(如果可能的话,使其对社区可用)。

注意

大多数社区模块都是因为有人遇到了尚未解决的问题而开始的。如果你能够通过新的库或模块解决问题,我强烈建议与社区分享。即使你不是出于利他的原因,这也可以是一件非常好的事情。通常,你可以找到其他人面临相同的挑战,你们可以一起创造更好的解决方案。

创建我们自己的库可能相当具有挑战性。您可以在网上找到大量教程和博客文章,介绍如何为您的库创建完美的设置。其中一些不错,一些则不好。但与其使用其中之一,我推荐使用名为react-native-builder-bob的工具包。

使用 react-native-builder-bob 编写、维护和发布我们自己的库

此工具使编写、维护和发布您自己的库的过程变得非常简单。它由一家名为Callstack的公司创建和维护,该公司在 React Native 社区中非常活跃,甚至为 React Native 的核心做出了贡献。

他们使用react-native-builder-bob为自己的库编写代码,许多最受欢迎的库也是如此。

您可以使用以下简单命令开始使用预配置的react-native-builder-bob创建自己的库:

npx create-react-native-library <your-library-name>

此命令将启动设置过程,并通过几个问题引导您完成。以下截图显示了此过程:

图 10.5 – 使用 create-react-native-library 创建自己的库

图 10.5 – 使用 create-react-native-library 创建自己的库

在回答有关作者和包的问题后,这些问题是创建package.json所必需的,create-react-native-library将询问您想要开发哪种类型的库。

您可以选择以下选项:

  • 本地模块/本地视图:如果您模块包含本地代码,应选择此选项。这些选项使用当前的桥接架构在 JavaScript 和本地代码之间进行通信。

  • JavaScript 库:如果您模块不包含任何本地代码,应选择此选项。大多数用例,如简单的 UI 库、服务 SDK 和状态提供者,都属于此类。此外,当您使用包含本地代码的其他库,但您的库是仅包含 JavaScript 的库时,这也是正确的类型。

  • Turbo 模块:在撰写本文时,此类型处于实验阶段。它基于新的 React Native 架构创建本地模块(参见第三章介绍新的 React Native 架构部分)。

我们将首先创建一个仅包含 JavaScript 的库。想象一下,我们创建的示例应用是某个大型公司众多应用中的一个。因为管理层喜欢我们的设计,所以他们希望所有未来的应用都能遵循我们的设计系统。因此,我们希望将我们的StyleConstants文件作为设置企业设计系统的第一步放入我们的库中。

创建仅包含 JavaScript 的库

要开始我们自己的仅 JavaScript 库,我们将选择 create-react-native-library 下拉菜单。create-react-native-library 使用一组预配置的工具、预定义的脚本、一个简单的乘法函数作为源代码,甚至还有一个示例应用程序来展示库。如果您想查看一个工作示例,可以查看以下 GitHub 仓库:bit.ly/prn-repo-styles-library

当我们检查我们新创建的库的 root 文件夹时,我们会发现许多我们已知的文件。这里有一个 babel.config.js 文件来定义 Babel 应如何转换我们的代码,一个包含有关包信息以及所有依赖和脚本的 package.json 文件,还有一个包含 TypeScript 编译器所有信息的 tsconfig.json 文件。

接下来,我们将更深入地查看 package.json。除了所有预定义的信息和配置之外,我想指出两个重要的事情。第一个是关于如何找到我们库各个部分的信息。以下代码片段显示了这些信息:

  "main": "lib/commonjs/index",
  "types": "lib/typescript/index.d.ts",
  "source": "src/index",

当我们使用 TypeScript 创建我们的库时,它将由 react-native-builder-bob 编译为预 ES6 JavaScript,这样它就可以在所有 React Native 项目中使用,无论它使用的是哪种堆栈(TypeScript、Flow、纯 JS 或 Expo)。这意味着我们的库代码以不同的方式分发。以下属性中定义了这一点:

  • main:这是您库的主要入口点。当您从库中导入任何内容时,这是您的项目将查找导出路径的地方。

  • types:由于我们使用 TypeScript,react-native-builder-bob 为我们的代码创建类型,以便所有使用类型化 JavaScript 的人都可以使用我们创建的类型。

  • source:这是可以找到未编译源代码的地方。

当我们在 source 目录中工作时,使用我们库的项目将只与 maintypes 一起工作。

我希望您首先查看的是 scripts 部分,尤其是以下脚本。

  "scripts": {
    "prepare": "bob build",
    "release": "release-it",
  },

这些脚本是这个库设置中最基本的部分。使用 prepare 脚本,您可以运行 react-native-builder-bobbuild 命令。它将编译您的库并提供您刚刚学到的入口点。

release 脚本将使用 release-it 库创建您库的新版本。这将启动一个引导过程,执行以下操作:

  • 更新库版本

  • 创建一个变更日志

  • 将您的库发布到 npm

  • 将库版本更新提交到 git

  • 添加一个 git 标签

  • 将更改推送到远程仓库

  • 在 GitHub 上创建一个发布

这个脚本非常有用,因为它强制您在发布和标记库方面遵循最佳实践。

现在您已经了解了库项目的结构,让我们使用这个库来发布我们的样式。由于我们已经在 StyleConstants 文件中收集了所有的样式信息,所以这很简单。

前往库项目的 src/index.tsx 文件,并将 StyleConstants.ts 文件的内容粘贴进去。接下来,提交更改,并使用以下命令构建和发布库:

npm run prepare && npm run release

注意

您需要在 www.npmjs.com/ 上创建一个免费账户,并通过命令行使用 npm login 登录,以便能够发布您的库。

在您发布库包之后,您可以在项目中安装它。您可以使用常规的 npm 命令:

npm install <your-library-name>

或者,您可以使用 yarn 命令:

yarn add <your-library-name>

现在您能够通过库访问您的样式,您可以删除 StyleConstants.ts 文件,并将所有导入替换为您的库。以下图显示了 Home.styles.tsx 的更改:

图 10.6 – 从本地文件导入更改到库

图 10.6 – 从本地文件导入更改到库

如您所见,导入保持不变,只是 from 路径变更为库。您必须在所有使用 StyleConstants 的文件中这样做。

正如您在本小节中学到的,创建自己的库的过程相当复杂,但使用正确的工具工作时会容易得多。但鉴于我们的示例是一个仅使用 JavaScript 的库,这是 React Native 库中最简单的一种。当向库中添加本地代码时,它会变得更加复杂。

理解本地库之间的区别

如您所知,React Native 有一个 JavaScript 部分和一个本地部分。这意味着当我们需要时,我们可以利用本地平台特定的代码。这不仅适用于应用程序项目,也适用于库。本地代码是用平台特定的语言编写的,例如 Android 的 Kotlin 或 Java,iOS 的 Swift 或 Objective-C。

但并不仅仅是语言在不同平台之间有所不同。应用程序管理第三方包的过程以及如何构建和部署的过程也完全不同。

Android 使用 Gradle 来获取包并构建您的应用程序。对于 iOS,有多个包管理器,但 React Native 严重依赖于 CocoaPods。构建是通过 Xcode 完成的。

这意味着当您向库中添加本地代码时,您不仅要交付和导入您的 JavaScript 代码,还要提供本地代码并将其添加到包含在本地包中的本地构建过程中。

在这种设置下,您的本地代码也包含在库包中。要能够编写本地代码,您在用 create-react-native-library 创建库时必须选择 Native Module。这将创建两个额外的文件夹(androidios),其中包含本地代码,以及本地构建过程的配置文件。

对于 Android,这是一个build.gradle文件,可以在android文件夹中找到。对于 iOS,这是一个.podspec文件,可以在库的root文件夹中找到。

所有这些文件都是为您创建的,因此您不需要修改它们。当使用原生代码安装您的库时,React Native 的自动链接功能会在 Android 上为您处理所有事情。在 iOS 上,您需要运行npx pod-install来将库的原生部分包含到原生项目中。

现在您能够创建纯 JavaScript 库和包含原生代码的库,我们将再次审视如何提供它们。我们使用公共的npm注册表来托管我们的库作为公共包。

虽然我真的很喜欢与社区共享一切的方法,但您可能需要将您的库保持为私有,尤其是在它们是公司应用程序的重要部分时。下一小节将向您展示如何仅向选定的人提供对您的库的访问权限。

对库设置访问限制

有一些方法可以将您的库仅与选定的人共享。以下两种是最常见的:

  • 使用付费 npmjs.com 计划:当使用付费的npmjs.com计划时,您可以在您的包上定义权限。这意味着只有您明确允许的人才能访问您的包。

  • package.json:

    "prn-video-example-styles": "git+https://github.com/alexkuttig/video-example-styles"
    
  • 您甚至可以通过添加一个#符号后跟标签名、分支名或提交哈希来指定您的包应该从哪里获取标签、分支或提交。

再次强调,我强烈建议尽可能地将您的模块发布出来,而不是将其保持为私有。这个拥有数千个维护良好的公共包的社区是 React Native 之所以成功的主要原因之一。因此,向社区回馈总是一个好主意。

摘要

在本章中,您学习了如何构建大规模或多平台产品。现在您能够创建适用于大规模和长期运行项目的项目结构。

您还在网络上创建了一个示例 React Native 移动应用的克隆版本,并理解了为什么这并不总是最佳选择。然后您学习了如何创建满足用户期望的多平台应用,同时保持高比例的共享代码。

在本章的最后部分,您学习了如何创建、发布和维护自己的库,了解了仅使用 JavaScript 的库和包含原生代码的库之间的区别,以及如何仅将这些库发布给选定的人。

在专注于为代码库本身创建良好的结构之后,在下一章中,我们将关注如何实施良好的工作流程以及如何使用持续集成CI)工具来支持这些流程。

第十一章:创建和自动化工作流程

使用现代工作流程自动化自动化工作流程在大规模项目中是绝对必要的。这将为您节省大量时间,但更重要的是,它将确保您不会错过任何东西,并且您的重复性流程,如检查代码样式和质量、构建应用程序或发布应用程序都能正常工作。

接下来,它让您有信心,您刚刚编写的代码不仅能在您的机器上运行,因为它是在一个干净的机器上克隆并启动的。最后,它确保项目不依赖于个人。

在特定情况下,例如构建和发布应用程序这样的步骤,在更大规模的项目中可能会变得相当复杂,因此并非项目中的每个成员都能完成这些任务。但有了正确的自动化设置,只需按一下按钮即可。

当谈到工作流程自动化时,您也会经常听到持续集成CI)和持续交付CD)这两个术语。这两个术语都描述了自动化工作流程。CI 指的是项目的开发阶段。这意味着每个开发者都会频繁地将他们创建的代码集成到一个共享的仓库中,通常每天多次。在每次集成中,代码都会自动检查(TypeScript/Flow、ESLint、Prettier 和测试),并且开发者会立即得到反馈。DS 指的是部署或交付步骤。它描述了构建和交付应用程序的自动化。

由于在构建应用程序时可以进行 CI,因此您应该使用它。CD 适用于测试构建,但对于公共生产构建,如移动应用程序,它效果不佳。每天多次向公众发布是不可能的,因为每个发布都必须由苹果和谷歌手动审查才能在相应的应用商店中可用。

即使可能(您可以通过在第十三章中学习的 CodePush 实现,技巧与展望),我也不建议过于频繁地推送更新,因为这会导致每个用户在每次启动时都必须更新应用程序版本。

正因如此,我们将专注于开发过程中的持续集成(CI)以及为构建和发布步骤构建自动化工作流程,这些工作流程可以手动触发以进行公开生产构建,或者自动触发以进行内部测试构建(CD)。

这使您能够自动将应用程序更新推送给测试用户,并通过一键推送将应用程序发布给公众,同时不会因为过于频繁的更新而打扰真实用户。

由于当自动化的工作流程不好时,最好的自动化工具也毫无价值,因此在本章中,我们也将关注创建一个有效的开发工作流程。

在本章中,我们将涵盖以下主题:

  • 理解集成/交付工作流程自动化

  • 创建协作开发工作流程

  • 为开发过程创建有用的 CI 管道

  • 理解工作流程自动化和 CD 的构建和发布

技术要求

要能够运行本章中的代码,您必须设置以下内容:

  • 一个可工作的 React Native 环境 (bit.ly/prn-setup-r… – React Native CLI 快速入门)

  • 虽然本章的大部分内容也应该在 Windows 上工作,但我建议在 Mac 上进行操作

  • 拥有 GitHub 账户以运行 CI 管道

  • 拥有 Bitrise 账户以运行 Bitrise 交付工作流程

理解集成/交付工作流程自动化

集成和交付工作流程自动化的过程相当简单:您需要一个仓库和一个可以连接到您的仓库的自动化工具或构建服务器。然后,您必须定义规则,关于哪些 Git 事件应向服务器发送信息以触发某些脚本。以下图表说明了这个过程:

图 11.1 – 基本 CI 设置

图 11.1 – 基本 CI 设置

Git 事件,如提交、拉取请求或合并,会触发自动化工具。自动化工具会启动一个配置在自动化工具设置中的干净服务器。然后,它从您的仓库克隆代码并开始在其上运行脚本。对于 React Native 应用程序,这些脚本通常从安装所有项目依赖项和运行静态类型检查器(Flow/TypeScript)开始。

接下来,您应该运行代码质量工具,如 ESLint 和 Prettier,并检查代码是否符合所有要求。大多数时候,您也会在这里运行一些测试(更多关于这一点在 第十二章*,React Native 应用程序的自动化测试)。

您可以在这里运行其他任何脚本,以及集成其他云工具,如 SonarQube (bit.ly/prn-sonarcube,一个高级代码质量工具) 或 Snyk (bit.ly/prn-snyk,一个基于云的安全智能工具)。

在脚本执行完毕后,您的自动化工具会创建一个响应并将其发送回您的仓库。然后,这个响应会在您的仓库中显示,并可用于允许或拒绝进一步的操作。

现在,基本的自动化工具已集成到所有流行的基于 Git 的源代码仓库服务中,包括 GitHub(GitHub Actions)、Bitbucket(Bitbucket Pipelines)和 GitLab(GitLab CI/CD)。虽然这些工具对于 React Native CI 要求来说工作得很好,但构建和部署移动应用程序是一个非常复杂的过程,具有特殊的要求。

例如,iOS 应用程序仍然只能在 macOS 机器上构建。虽然从技术上讲,这些基本自动化工具中的大多数也可以完成这一步,但我不会推荐使用它们进行构建和部署。

对于这一步,有一个专门的工具包称为 fastlane,它可以集成到特殊的自动化工作流程工具中,如 Bitrise、CircleCI 和 Travis CI。我建议使用这个工具包,因为它可以为您节省很多时间。

现在您已经了解了流程自动化的理论,是时候考虑我们的开发过程应该是什么样子了。在我们可以自动化任何事情之前,我们需要建立一个良好的流程。

创建协作开发工作流

在大规模项目中,最重要的是信息更新。通常,在这些项目中,很多人需要协调,多个项目部分需要协同工作以构建复杂的产品。虽然信息很重要,但它不应限制开发速度。

因此,我们必须创建一个可以支持自动化的工作流程,以满足这两个要求。以下图表显示了此工作流程的重要部分:

![图 11.2 – 工作流自动化设置图片 B16694_11_02.jpg

图 11.2 – 工作流自动化设置

如您所见,工作流程需要四个技术部分。具体如下:

  • 信息单一来源:所有信息都集中在这里。通常,这是一个问题跟踪器,其中每个任务、错误或功能请求都作为一个问题创建。例如,包括 Jira、ClickUp、GitLab 问题跟踪器和 GitHub 问题跟踪器。

  • 代码管理:这是您的源代码存储的地方。它应能够与您的 信息单一来源 集成,以传输有关哪些问题已经完成或正在处理的信息。例如,包括 Bitbucket、GitHub 代码和 GitLab 仓库。

  • 工作流自动化:这是您的应用程序进行测试和构建的地方。此工具还应能够与您的 信息单一来源 通信,以传输有关问题状态的信息。例如,包括 Bitbucket Pipelines、GitHub Actions、GitLab CI/CD、CircleCI 和 Bitrise。

  • 稳定性监控:在您的应用程序部署给用户之后,您应该跟踪有关其稳定性的信息。崩溃或其他问题应自动报告给您的 信息单一来源。例如,包括 Bugsnag、Sentry、Rollbar 和 Crashlytics。您将在 第十三章 “技巧与展望” 中了解更多关于这些工具的信息。

现在,我们可以开始创建我们的开发工作流程。以下图表显示了推荐的标准化功能分支工作流程:

图 11.3 – 功能分支工作流程

图片 B16694_11_03.jpg

图 11.3 – 功能分支工作流程

正如工作流名称所暗示的,对于每个功能(这也可以是一个错误或改进——在这里,每个单独的问题都被视为一个功能),都会创建一个新的分支。然后,以下工作流程开始:

  1. 当创建分支时,信息单一来源必须更新,以便包含有关问题是否已经处理以及谁正在处理的信息。

  2. 接下来,开发者会对问题进行一次或多次提交以解决问题。

  3. 每个提交都会由工作流程自动化工具进行检查。

  4. 如果有错误,开发者会立即收到通知。当开发者认为他们已经解决了问题并完成了他们的工作后,他们会创建一个拉取请求(有时也称为合并请求)。

  5. 这个拉取请求也经过了工作流程自动化的检查,但这次,不仅进行了简单的检查,还进行了更广泛的检查(例如,端到端测试)。

  6. 如果一切顺利,必须更新单一的信息点。问题被分配给另一位开发者进行审阅,状态也会相应地更改以反映审阅状态。

  7. 如果需要更改,则过程将回退到步骤 1。如果审阅者对结果满意,他们可以将代码合并到 master 或 main 分支。

  8. 再次,必须更新单一的信息点,以反映问题的正确状态。

我非常喜欢这个过程,因为它为你提供了你需要的大量东西。以下是一些例子:

  • 你总是能知道项目的确切状态。

  • 工作流程的大部分可以自动化以节省时间。通常,开发者和审阅者只需要在代码管理工具中工作;其他一切都是自动化的。

  • 这确保了每段代码都由另一位开发者进行双重检查,从而提高了代码质量。

  • 审阅者不需要对基本代码质量进行检查,因为这是自动完成的。

现在我们已经了解了我们的流程,让我们开始编写自动化管道。

为开发过程创建有用的持续集成(CI)管道

再次,我们将使用我们的示例项目。首先,我们将设置一个管道,它可以在开发过程中通过非常简单的检查来支持我们,针对的是图 11.3 的步骤 3。我们将使用 GitHub Actions 来执行这个 CI 管道,但它与 Bitbucket (bit.ly/prn-bitbucket-pipelines) 和 GitLab CI/CD (bit.ly/prn-gitlab-cicd) 非常相似。

首先,我们必须创建我们希望在管道中使用的脚本。在我们的例子中,我们想要使用 TypeScript 编译器进行类型检查,并使用 ESLint 和 Prettier 进行静态代码分析,以确保代码风格正确。

为了做到这一点,我们将在package.json文件的scripts部分提供以下脚本:

"typecheck": "tsc --noEmit",
"lint": "eslint ./src",
"prettier": "prettier ./src --check",

接下来,我们必须创建一个可以被 GitHub Actions 解释的工作流程文件。由于这是一个完全集成的自动化工作流程,一旦我们将这个文件推送到我们的 GitHub 仓库,GitHub Actions 就会开始工作。

这就是我们的第一个工作流程自动化管道(或 CI 管道)的样子。你必须将它创建在 .github/workflows/<the github actions workflow name>.yml 下:

name: Check files on push
on: push
jobs:
  run-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: install modules
        run: npm install
      - name: run typecheck
        run: npm run typecheck
      - name: run prettier check for code styling
        run: npm run prettier
      - name: run eslint check for code errors
        run: npm run lint

让我们逐行查看代码。第一行定义了工作流程的名称。第二行定义了工作流程应该在何时运行。在这种情况下,我们希望在每次向仓库推送时运行它,无论推送来自哪个分支或作者。

提示

您可以在不同的触发事件上运行工作流。您可以在文档中找到完整的列表(bit.ly/prn-github-actions-events为 GitHub Actions 事件列表)。

在上一节描述的开发过程中,一些特别有用的触发事件是推送和拉取请求。您还可以将这些触发事件限制在特定的分支上。

接下来,您可以看到jobs部分。在这里,您定义实际的流程,它包含一个或多个可以顺序或并行运行的作业。在这种情况下,我们定义了一个包含多个步骤的作业。

对于我们的工作,我们首先要做的是定义它应该在哪个机器上运行。每个工作流自动化工具都有许多预定义的机器映像供您选择,但您始终可以提供自己的机器来运行自动化管道。在我们的例子中,我们将使用 GitHub Actions 提供的最新 Ubuntu 映像。

接下来,我们定义作业的步骤。这可以是使用uses命令与预定义操作一起使用的预定义操作,或者是我们自己创建的操作。在我们的例子中,我们使用了这两种选项。首先,我们使用预定义操作来检出我们的代码,然后我们使用四个自定义操作来安装模块和运行我们的检查。

提示

当使用工作流自动化工具时,您的工作流运行时间将是您需要支付的指标。因此,您应该始终考虑如何构建您的工作流,以便在自动化工具机器上花费尽可能少的时间。

一旦我们将此文件推送到我们的 GitHub 仓库,自动工作流的第一次运行就被触发了。在这种情况下,机器启动,克隆了仓库,安装了依赖模块,并运行了我们的检查。您可以在GitHub Actions标签页中查看自动化运行情况。

在前面的提示中,您了解到优化工作流以尽可能快地运行是很重要的。所以,这就是我们接下来要做的。以下图表显示了两种优化我们工作流的方法,以便我们可以更快地完成它:

![Figure 11.4 – Parallelize workflows]

![img/B16694_11_04.jpg]

图 11.4 – 并行化工作流

完成事情最快的方法是通过并行运行它们。GitHub Actions 不允许您并行运行步骤,但您可以并行运行多个作业。您必须详细调查您的工作流,以找出哪些部分可以并行化,哪些步骤更适合顺序运行。

在我们的例子中,仅仅为三个任务创建三个作业并没有太多意义。这是因为花费时间最长的步骤是安装依赖项,这对于所有三个作业都是必要的。幸运的是,我们可以使用缓存来工作,这样我们就不必在每次测试运行中重复缓存的任务。

在前面的图示左侧,您可以看到我们示例的管道设置,它首先安装依赖项,然后并行运行我们的三个作业。所有三个作业都从缓存中获取依赖项,这些依赖项是在安装步骤中填充的。在右侧,您可以看到另一种设置。在这个设置中,我们有三个并行作业,它们完全独立于彼此运行。

所有三个作业都试图从缓存中获取依赖项,并且只有在找不到它们时才安装它们。在某些场景下,这两种选项都更快。如果您必须安装依赖项,第二种设置会稍微长一点,因为安装步骤将被触发三次(因为步骤是并行开始的,而在它们开始的时候,依赖项要么被缓存,要么不是所有三个作业都有)。

第一种设置只触发一次依赖项安装,并确保它为其他作业缓存。在大多数场景中,这种第一种设置会花费更多时间,因为它需要您按顺序运行两个作业(安装 + 类型检查/Prettier/ESLint)。

正因如此,我建议采用以下代码中所示的第二种设置:

name: Check files on push alternative
on: push
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - uses: actions/cache@v2
        id: npm-cache
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{
               hashFiles('**/package-lock.json') }}
      - name: Install dependencies if not cached
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm install
      - name: run typecheck
        run: npm run typecheck
  prettier:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - uses: actions/cache@v2
        id: npm-cache
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{
               hashFiles('**/package-lock.json') }}
      - name: Install dependencies if not cached
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm install
      - name: run prettier check for code styling
        run: npm run prettier
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - uses: actions/cache@v2
        id: npm-cache
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{
               hashFiles('**/package-lock.json') }}
      - name: Install dependencies if not cached
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm install
      - name: run eslint check for code errors
        run: npm run lint

如您所见,三个作业非常相似。我们检出项目,使用指定的节点版本设置节点环境,并检查缓存。缓存的键包含运行时的操作系统版本和package-lock.json文件的哈希值,当依赖项(版本更新、新库等)发生变化时,这个哈希值会改变。

接下来,我们有一个条件安装步骤,它只在未命中缓存时安装依赖项。这发生在我们的缓存名称更改时,如前所述,或者如果缓存过期(这发生在至少一周未使用后)。

最后,我们执行我们的类型检查/Prettier/ESLint 步骤。虽然这种并行化看起来相当复杂,但在大规模使用时可以节省您大量时间。因此,您应该花些时间设置您的工作流程自动化,以确保它符合您的需求。

所有现代代码管理解决方案,如 GitHub、Bitbucket 和 GitLab,都深度集成了工作流程自动化工具。这意味着一旦您配置了工作流程自动化,您不仅会在工作流程自动化工具或部分中看到结果,还会在您的仓库中看到结果。例如,它将直接在提交列表中显示每个已测试提交的结果。

对于更多详细信息,您必须访问工作流程自动化工具或部分 – 在我们的案例中,GitHub Actions – 以查看 CI 管道的结果。如果一切按预期进行,您将看到一个绿色的勾选标记。如果工作流程检测到我们的任何检查中抛出了错误,我们将看到一个红色的点,这会通知我们我们的工作流程执行失败。

以下截图显示了一个包含多个工作流程运行的列表:

图 11.5 – GitHub Actions 中的工作流程运行

图 11.5 – GitHub Actions 中的工作流程运行

在这个例子中,我们的工作流程运行了两次成功,而其中一次失败了。失败的流程运行总是最有趣的,因为它提供了大量关于出错原因的信息。

点击它,你会看到有关日志和执行时间的详细信息,这样你就可以找到并修复错误。这是它在GitHub Actions中的样子:

![Figure 11.6 – GitHub Actions 中的失败工作流程运行

![img/B16694_11_06.jpg]

图 11.6 – GitHub Actions 中的失败工作流程运行

如你所见,我们不仅可以看到哪些检查失败,还可以看到详细的日志。在这种情况下,我们在Genre.tsx文件中使用了错误类型,这导致了一系列错误。通过这个工作流程,我们不仅找到了错误,还知道了我们必须修复错误的精确文件和行号。

注意

与 CI 管道一起工作,关键在于尽快提供反馈。你应该使用 Husky (bit.ly/prn-husky)等工具在将它们提交到本地机器之前运行你的管道。这不仅取代了你的工作流程自动化工具,还可以进一步缩短反馈周期。

现在你已经知道了如何创建 CI 管道来支持和改进开发过程,让我们来看看构建和发布应用。

理解工作流程自动化和 CD 对于构建和发布的重要性

在我们开始创建我们的管道之前,让我们先看看构建和发布应用的一般情况。Android 使用 Gradle 作为其构建工具,并使用 KeyStore 文件来验证应用的所有权。如果你不熟悉发布 Android 应用,请先阅读此指南:bit.ly/prn-android-release

在 iOS 上,你必须使用 Xcode 来构建、签名和发布你的应用。如果你不熟悉这个过程,请先阅读此指南:bit.ly/prn-ios-release

幸运的是,对于两个平台(Android 和 iOS),构建和部署过程可以通过命令行工具执行。Gradle 本身就是一个命令行工具,Xcode 提供了 Xcode 命令行工具。这意味着我们可以为整个过程编写脚本,然后我们可以使用我们的工作流程自动化工具调用这些脚本。

不幸的是,这些过程相当复杂,所以我们不想自己编写脚本。这就是一个名为Fastlane的工具集发挥作用的地方。Fastlane 是 iOS 和 Android 应用的专用自动化工具。它提供了用于签名、构建和将代码部署到 Apple App Store 和 Google Play 的脚本。你可以在这里找到有关 Fastlane 的更多信息:bit.ly/prn-fastlane

我不推荐直接使用 Fastlane 的原因是它与 Bitrise 和 CircleCI 等高级工作流程自动化工具具有出色的集成。我们将以 Bitrise 为例进行深入了解,但其他工具如 CircleCI 和 Travis CI 的工作方式非常相似。

Bitrise 以与 GitHub Actions 相同的方式集成到您的代码管理解决方案中。您可以使用某些事件来触发工作流程。它提供了一个出色的 UI 来创建这些工作流程。我喜欢使用它,因为它相当简单,并且节省了大量时间。

您可以从大量预定义的操作中选择,这些操作主要关注 iOS 和 Android 应用程序。Bitrise 甚至为 React Native 应用程序提供自己的自动设置。以下图表显示了典型的 iOS 构建和部署工作流程:

Figure 11.7 – Bitrise iOS build and deploy workflow

img/B16694_11_07.jpg

图 11.7 – Bitrise iOS 构建和部署工作流程

步骤是按列执行的。因此,我们首先激活一个 SSH 密钥,以便能够连接到仓库。接下来,仓库被克隆。之后,安装npm依赖模块,以及通过 CocoaPods 安装的原生模块。

例如,对于可以在此集成的每个其他脚本,我们将获取我们应用 UI 的最近翻译文件,以便在下一步与应用程序包集成。然后,我们将更新Info.plist文件中的版本号。接下来,工作流程处理代码签名,构建应用程序,并将其部署到 App Store Connect。

Android 构建的工作流程看起来非常相似:

Figure 11.8 – Bitrise Android build and deploy workflow

img/B16694_11_08.jpg

Figure 11.8 – Bitrise Android build and deploy workflow

再次,操作是按列执行的。第一列与 iOS 工作流程中的相同。激活 SSH 密钥,克隆仓库,并安装npm依赖模块。接下来,我们必须安装所有缺失的 Android SDK 工具。

然后,我们必须更改 Android 版本代码,就像我们在 iOS 中做的那样,获取与应用程序捆绑的翻译。然后,我们必须构建应用程序并将其部署到 Google Play。

在幕后,Bitrise 和其他具有图形工作流程编辑器的 CI 工具使用您在设置开发 CI 管道时了解到的相同逻辑。以下代码是为 iOS 工作流程的.yml文件:

  ios-release-build:
    steps:
    - activate-ssh-key@4:
        run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}'
    - git-clone@4: {}
    - npm@1:
        inputs:
        - command: install
    - cocoapods-install@2: {}
    - script@1:
        inputs:
        - content: |-
            cd scripts
            bash getTranslationsCrowdin.sh
    - set-ios-info-plist-unified@1:
        inputs:
        - bundle_version: „$VERSION_NUMBER_IOS"
        - info_plist_file: "$BITRISE_SOURCE_DIR_PLIST"
    - manage-ios-code-signing@1:
    - xcode-archive@4.3:
        inputs:
        - project_path: "$BITRISE_PROJECT_PATH"
        - distribution_method: app-store
        - export_method: app-store
    - deploy-to-itunesconnect-deliver@2:

如您所见,它具有相同的结构。它包含多个步骤,这些步骤可以作为配置获取额外的输入。像任何其他工作流程自动化工具一样,Bitrise 使用环境变量。这些变量存储在平台上,并在工作流程执行期间替换占位符(在这里,它们以$开头)。

注意

你永远不应该将私钥或签名信息添加到你的仓库中。如果发生了这种情况,任何可以访问仓库的人都可以获取这些私有数据,并能够为你的应用程序签名发布。将此信息存储在你的自动化工作流程工具中会更好,因为在那里,没有人可以获取密钥和签名证书,但所有有访问权限的开发者仍然可以创建新的发布。

此工作流程可以是手动触发的,我建议用于公共生产构建,或者自动触发的,我建议用于内部或公共测试构建。

摘要

现在,是时候总结本章内容了。首先,你学习了工作流程自动化、持续集成和持续交付这些术语的含义,以及它们在应用开发中的应用。然后,你考虑了一个适用于大规模项目的开发流程。

接着,你学习了如何通过简单的自动化工作流程工具,如 GitHub Actions,来支持这个流程。最后,你了解了专门的自动化工作流程工具,如 Bitrise,这样你就可以构建、签名和部署你的 iOS 和 Android 应用。

在本章中,有一个特别重要的主题——测试——在关于工作流程自动化的讨论中被遗漏了。自动化测试在开发阶段以及发布前都很重要。因此,我们将在下一章详细探讨自动化测试。