刹车上的战争。在React Native中优化组件渲染的数量

哈Ha!我叫Kamo Spertsyan,我在Profi.ru从事React Native开发。如果您决定使用React Native技术快速交付产品功能并专注于开发速度,那么您很可能会遇到性能问题。至少那是我们发生的事情。经过六个月的积极开发,我们的应用程序性能降到了关键水平以下-一切都非常缓慢。因此,我们进行了优化-删除了启动过程中的所有“刹车”,屏幕之间的过渡,渲染屏幕以及对用户操作的反应。结果,他们在三个月内将用户体验提升到了原生水平。在本文中,我想谈谈我们如何在React Native上优化应用程序并解决多组件渲染的问题。



我汇总了一些建议,这些建议将有助于最大程度地减少无意义的组​​件重绘次数。为了清楚起见,在示例中,我比较了“不良”和“良好”的实现。本文对于那些已经面临糟糕的应用程序性能的人以及不想在将来允许这样做的人很有用。

我们使用与Redux配对的React Native。一些技巧与此库有关。同样在该示例中,我使用Redux-thunk库-模拟网络工作。

什么时候考虑性能?


实际上,从应用程序的工作开始就值得记住。但是,如果您的应用程序已经变慢了-不要失望,一切都可以修复。

大家都知道,但以防万一,我要提一下:最好检查弱设备上的性能。如果您正在使用功能强大的设备进行开发,则可能不会意识到最终用户的“过失”。自己决定将要使用的设备。在控制图中测量时间或FPS,以与优化后的结果进行比较。

开箱即用的React Native提供了通过开发人员工具→显示性能监视器来测量FPS应用程序的能力。参考值为每秒60帧。该指标越低,应用程序“放慢速度”的作用就越强-对用户操作无响应或延迟。对FPS的主要影响之一是渲染的数量,其“严重性”取决于组件的复杂性。

范例说明


我将在一个带有新闻列表的简单应用程序示例中显示所有建议。该应用程序有一个屏幕,该屏幕位于FlatList新闻中。新闻是一个NewsItem由两个较小的组成部分组成的组成部分-标题(NewsItemTitle)和正文(NewsItemBody)。整个示例可以在这里看到在本文的进一步内容中,有指向特定示例的存储库各个分支的链接。该存储库用于方便希望更深入地研究示例的读者。下面的存储库和示例中的代码并不声称是完美的-仅出于演示目的而需要。

下面,用链接和道具示意性地显示了所有组件。


在每个组件的render方法中,我将输出添加到有关它的唯一信息的控制台中:

SCREEN
ITEM_{no}
ITEM_TITLE_{no}
ITEM_BODY_{no}

{no}新闻序列号在 哪里,以便区分同一新闻稿的多个新闻稿中的不同新闻稿。

为了在每个refresh新闻列表上进行测试,将其他新闻添加到其开头。同时,控制台中显示以下消息:

--------------[ REFRESHING ]--------------

这些记录将有助于了解任何特定组件是否存在问题,并在以后确定是否有可能对其进行优化。

如果实施正确,则启动后的日志和若干更新应如下所示:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
--------------[ REFRESHING ]--------------
SCREEN
ITEM_4
ITEM_TITLE_4
ITEM_BODY_4

在第一次开始时,将绘制屏幕本身和两个初始新闻。更新面板时,由于其数据确实发生了更改,因此再次渲染了屏幕。更多消息来了。以前的所有新闻都不会重绘,因为它们的数据没有变化。

何时渲染组件?


在React和React Native中,有两个条件可以渲染组件:

  1. 改变他的道具/状态,
  2. 父组件的呈现。

可以在组件中重新定义函数shouldComponentUpdate-它接收新的Props和State作为输入,并告知是否应渲染组件。通常,为了避免不必要的重新渲染,对Props和State对象进行浅层比较就足够了。例如,如果父组件更改不影响子组件,则这样做可以消除不必要的渲染。为了不每次都手动编写表面比较,可以从React.PureComponent封装此检查的组件继承一个组件

当我们使用connect link函数时,Redux库会创建一个“连接”到全局State的新组件。更改此状态会触发方法mapStateToProps返回新的道具。接下来,开始比较旧道具和新道具,而不管组件是否被声明为PureComponent

在我们的示例中考虑这些细微差别。

我们将NewsItem组件通过传球connectNewsItemTitle继承React.Component,以及NewsItemBody-从React.PureComponent

完整的示例代码

export class NewsItemTitle extends React.Component
export class NewsItemBody extends React.PureComponent

这是更新一块板后的日志内容:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

您可以看到新闻和标题组件已重绘。我们将依次考虑它们。

NewsItem使用声明connect作为道具,此组件将接收一个标识符,随后它将通过该标识符接收以下消息mapStateToProps

const mapStateToProps = (state, ownProps) => ({
  item: state.newsMap[ownProps.itemKey],
});

由于更新板时,所有新闻都将再次下载,因此该对象将item更新,然后将引用各种存储单元。换句话说,即使所有包含的字段都相同,它们也将是不同的对象。因此,比较了以前和新的State'ov组件return false。尽管事实上数据并没有改变,但是该组件将被重新渲染。

NewsItemTitle是从继承的React.Component,因此每次渲染父组件时都会重新渲染。无论新旧道具的价值如何,都会发生这种情况。

NewsItemBody继承自React.PureComponent,因此它会比较新旧道具。在新闻1和2中,它们的值是相等的,因此仅针对新闻3渲染组件。

要优化渲染NewsItemTitle只需将其声明为即可React.PureComponent在这种情况下,您必须NewsItem重新定义函数shouldComponentUpdate

shouldComponentUpdate(nextProps) {
  return !shallowEqual(this.props.item, nextProps.item);
}

完整的示例代码

shallowEqual是Redux提供的对象的表面比较功能。您可以这样写:

shouldComponentUpdate(nextProps) {
  return (
    this.props.item.title !== nextProps.item.title ||
    this.props.item.body !== nextProps.item.body
  );
}

这是我们的日志在此之后的样子:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3

注意
shouldComponentUpdate NewsItem , NewsItemTitle . . NewsItemTitle - NewsItem, .

React.memo和功能组件


shouldComponentUpdate无法 覆盖功能组件。但这并不意味着为了优化功能组件,您必须将其重写为一个类。对于这种情况,提供了React.memo记忆功能它接受组件输入和可选的比较功能areEqual调用时,它将areEqual获得新旧道具,并应返回比较结果。其区别shouldComponentUpdate是什么areEqual应该返回true如果道具都是平等的,而不是相反。

例如,NewsItemTitle备忘录可能看起来像这样:

areEqual(prevProps, nextProps) {
  return shallowEqual(prevProps, nextProps);
}
export OptimizedNewsItemTitle = React.memo(NewsItemTitle, areEqual)

如果你不及格areEqualReact.memo,然后道具肤浅的比较将进行,所以我们的例子可以简化为:

export OptimizedNewsItemTitle = React.memo(NewsItemTitle)

道具中的Lambda函数


要处理组件事件,可以将函数传递给其道具。最醒目的例子是实现onPress通常,匿名lambda函数用于此目的。假设NewsItemBody我们只想显示预览,如果单击它,则显示整个文本。为此,在渲染NewsItem时,NewsItemBody我们将传递以下属性:

<NewsItemBody
  ...
  onPress={() => this.props.expandBody()}
  ...
/>

下面是该日志看起来像当这个方法执行shouldComponentUpdateNewsItem删除:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

新闻机构1和2渲染,虽然他们的数据并没有改变,不过NewsItemBodyPureComponent这是由于以下事实:对于每个渲染NewsItem,道具价值onPress都会重新创建。从技术上讲,onPress每次渲染时,它都指向内存中的一个新区域,因此表面上的道具比较NewsItemBody返回false。通过以下条目解决此问题:

<NewsItemBody
  ...
  onPress={this.props.expandBody}
  ...
/>

日志:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

完整的示例代码

不幸的是,匿名函数绝不能始终作为此类记录的方法或类字段重写。最常见的情况是在lambda函数内部使用声明了该函数的函数的范围变量。

在我们的示例中考虑这种情况。为了从一般列表切换到一个新闻的屏幕,我们添加了单击新闻正文的处理。renderItem组件方法FlatList将如下所示:

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={() => this.onItemBodyPress(item)}
  />
);

匿名函数onBodyPress不能在类中声明,因为item转到特定新闻所需的变量将从范围中消失

解决该问题的最简单方法是更改onBodyPress组件属性的签名,NewsItem以便在调用时将所需的参数传递给函数。在这种情况下,这是新闻标识符。

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={item => this.onItemBodyPress(item)}
  />
);

在这种情况下,我们已经可以在组件类方法中删除匿名函数。

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={this.onItemBodyPress}
  />
);

但是,这样的解决方案将需要我们更改组件NewsItem

class NewsItemComponent extends React.Component {
render() {
  ...
  return (
      ...
      <NewsItemBody
        ...
        onPress={() => this.props.onBodyPress(this.props.item)}
        ...
      />
      ...
  );
}

再次,我们回到指示的问题-对于父级的每个渲染,我们将一个新的lambda函数传递给子级组件。直到现在我们才降到一个水平。日志:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

要从根本上消除此问题,可以使用useCallback挂钩它允许通过传递参数来记忆函数调用。如果函数的参数不变,则调用结果useCallback将指向相同的内存区域。在我们的示例中,这意味着在重绘相同新闻时,prop onPress组件NewsItemBody不会更改。挂钩只能在功能组件中使用,因此该组件的最终外观NewsItem如下:

function NewsItemComponent(props) {
  ...
  const {itemKey, onBodyPress} = props.item;
  const onPressBody = useCallback(() => onBodyPress(itemKey), [itemKey, onBodyPress]);
  return (
    <View>
      ...
      <NewsItemBody
        ...
        onPress={onPressBody}
        ...
      />
    </View>
  );
}

和日志:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

完整的示例代码

数组和对象


在JavaScript中,函数与数组一起表示为对象。因此,上一个块中的示例是在props中创建新对象的特例。这很普遍,因此我将其放在单独的段落中。

在props中创建任何新函数,数组或对象都会导致组件重新渲染。在以下示例中考虑此规则。让我们传递NewsItemBody两个值组合样式:

<NewsItemBody
  ...
  style={[styles.body, styles.item]}
  ...
/>

再次,日志显示了额外的组件渲染器:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

要解决此问题,您可以选择将body组合在一起的单独样式item,或者例如将数组的声明移动[styles.body, styles.item]到全局变量中。

完整的示例代码

阵列减速器


考虑另一个与使用相关的“刹车”的流行来源FlatList一个经典的应用程序包含一长串来自服务器的项目,可以实现分页。也就是说,它以第一页的形式加载一组有限的元素,当当前元素列表结束时,它加载下一页,依此类推。项目列表缩减器可能如下所示:

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

当每个下一页以应用程序的样式加载时,将创建一个新的标识符数组。如果我们稍后将此数组传递给props FlatList,则组件渲染日志将如下所示:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..10>
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
ITEM_<1..30>

对于此示例,我在测试应用程序中进行了一些更改。

  • 将页面大小设置为10条新闻。
  • item NewsItem FlatList-, connect. NewsItem React.Component .
  • .
  • . №1 .

该示例显示,当每个下一页加载时,将再次呈现所有旧元素,然后再次呈现旧元素和新页面的元素。对于数学爱好者:如果页面大小相等X,则在加载i第ith页时,将呈现X元素,而不是仅呈现新元素(i - 1) * X + i * X

``好吧,''您说,``我理解了为什么在添加新页面后绘制所有元素的原因:reduce返回了一个新数组,一个新的内存区域以及所有这些内容。但是为什么我们需要在添加新元素之前呈现旧列表?” “好问题,”我会回答你。这是VirtualizedList在其基础上使用组件状态的结果FlatList我不会详细介绍它们,因为它们涉及另一篇文章。谁在乎,我建议您深入研究文档和来源。

如何摆脱这种非最优性?我们重写了reducer,以便他不为每个页面返回一个新数组,而是向现有页面添加元素:

注意!反模式!
. , , , PureComponent, . , . . Redux.

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    action.news.forEach(item => state.push(item.key));
    return state;
    // return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

之后,我们的日志将如下所示:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..30>

将元素添加到新页面之前, 我们摆脱了旧元素的呈现,但是更新列表之后仍会绘制旧元素。现在,下一页的渲染数量相等i * X。公式变得更简单了,但我们不会止步于此。我们只有X新元素,我们只想要X新渲染。我们将使用已经熟悉的技巧来删除未更改道具的新闻呈现器。返回连接到NewsItem

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<11..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<21..30>

精细!现在我们可以对自己感到满意了。无处可优化。

完整的示例代码

细心的读者将指出,NewsItem无论您如何实现reducer ,在将连接应用到日志后,其外观都将与上一个示例相同。这是正确的-如果新闻组件在渲染之前检查其道具,那么缩减器使用旧数组还是创建新数组就没有关系。仅绘制新元素,并且仅绘制一次。但是,更改旧数组而不是创建新数组可以使我们免于FlatList使用其中的组件的不必要渲染VirtualizedList以及道具等效性检查的不必要迭代NewsItem。使用大量元素,这也可以提高性能。

在化简器中使用可变数组和对象时应格外小心。在此示例中,这是有道理的,但是如果您有normal的话PureComponent,那么当您将元素添加到可变数组时,组件将不会呈现。实际上,它的属性保持不变,因为更新数组之前和之后将其指向相同的存储区域。这可能会导致意外的后果。难怪所描述的示例违反了Redux原理

还有其他


如果您使用表示级别的库,建议您确保您详细了解它们的实现方式。在我们的应用程序中,我们使用Swipeable库中的组件react-native-gesture-handler当您从列表中刷卡时,它允许您执行一系列附加操作。

在代码中,它看起来像这样:

<Swipeable
  ...
  renderRightActions={this.renderRightActions}
  ...
>

方法renderRightActionsrenderLeftActions返回在轻扫后显示的组件。我们在更换组件期间确定并更改了面板的高度,以适应必要的内容。这是一个资源密集的过程,但是如果它在滑动动画期间发生,则用户不会看到干扰。


问题在于,组件在渲染主组件时会Swipeable调用该方法renderRightActions。滑动之前不可见的所有计算,甚至是动作栏的渲染都预先发生。因此,将对列表中的所有卡同时执行所有这些操作。滚动面板时,这会引起严重的“刹车”。

该问题已通过以下方式解决。如果操作面板是与主要组件一起绘制的,而不是由于滑动而绘制的,则该方法renderRightActions将返回一个View与主要组件大小相同的空白。否则,我们将像以前一样绘制其他动作面板。
我给出这个例子是因为支持库并非总是如您所愿。而且,如果这些是表示级别的库,那么最好确保它们不会浪费不必要的资源。

发现


在消除了本文中描述的问题之后,我们极大地加速了React Native上的应用程序。现在,很难将其性能与本机实现的相似性能区分开。过多的渲染会减慢单个屏幕的加载速度以及对用户操作的反应。最重要的是,它在列表中引人注目,其中一次绘制了数十个组件。我们还没有对所有内容进行优化,但是应用程序的主屏幕不再变慢。

下面简要列出了本文的要点。

  1. React Native : Props/State- .
  2. , React.PureComponent, , .
  3. , shouldComponentUpdate React.Memo .
  4. - . , (shallow compare). , .
  5. 支持表示级别的库可能会导致资源意外浪费。值得在应用中小心。

就这样。我希望您能找到有用的信息。我将很高兴收到任何反馈!

有用的资料


  1. 了解React + Redux中的渲染
  2. 在JavaScript中比较对象
  3. 使用React.memo()提高React功能组件的性能
  4. Discord如何通过React Native实现iOS的本机性能

All Articles