Coinbase Pro|优化React Native

作者:资深软件工程师Nick Cherry

在过去的八个月中,Coinbase一直在使用React Native从零开始重写其Android应用。了解我们在此过程中遇到并克服的一些性能挑战。

在过去的八个月中,Coinbase一直在使用React Native从零开始重写其Android应用。截至上周,新的和重新设计的应用程序已推广到100%的用户。我们为我们的小型团队能够在短时间内完成的工作感到自豪,并且我们继续对React Native作为一项技术感到非常乐观,期望它在工程速度和产品质量方面继续获得回报。

话虽这么说,这并不容易。性能一直是我们面临的重大挑战之一,特别是在Android设备上。在接下来的几个月中,我们计划发布一系列博客文章,记录我们遇到的各种问题以及如何缓解这些问题。今天,我们将重点关注对我们影响最大的产品:不必要的渲染。

事情直到不发生都很棒

在项目初期,该应用程序的性能感觉良好。即使我们没有花时间优化代码,它也与完全本地化的产品几乎没有区别。我们知道其他团队在使用React Native时面临(并克服)性能挑战,但是我们的初步基准测试都没有给我们预警的理由。毕竟,我们计划构建的应用程序大部分是只读的,不需要显示任何庞大的列表,也不需要动画,而这些动画不能被卸载到本机驱动程序中。

但是,随着增加的功能,我们开始注意到性能下降。起初,退化是微妙的。例如,即使使用我们的生产版本,导航到新屏幕仍会感觉缓慢,并且UI更新会稍有延迟。但是很快就需要花费一秒钟的时间在选项卡之间进行切换,并且在进入新屏幕后,UI可能会长时间无响应。用户体验恶化到了阻止发射的地步。

找出问题

很快我们就认识到UI垃圾与JavaScript帧速率之间的相关性。在用户交互之后,我们通常会观察到JS FPS下降到低(或负!)个位数几秒钟。对我们而言,不是那么明显的是为什么。就在一个月前,该应用程序的性能还算不错,而我们添加的所有功能似乎都没有特别麻烦。我们使用React的Profiler对我们认为可能很慢的大型组件进行基准测试,结果发现许多组件渲染的数量超过了所需。我们通过备忘录设法减少了对这些较大组件的重新渲染,但我们的改进并没有起到什么作用。我们还研究了一些原子和分子组件的渲染时间,这些组件似乎都不昂贵。

为了更全面地了解重新渲染的成本最高,我们编写了一个自定义的Babel插件,该插件使用Profiler将应用程序中的每个JSX元素包装起来。每个Profiler都分配了一个onRender函数,该函数报告给React树顶部的上下文提供者。这个顶级上下文提供程序将汇总渲染计数和持续时间(按组件类型分组),然后每隔几秒钟记录一次最严重的违规事件。下面是我们最初实现的输出的屏幕截图:

正如我们在之前的基准测试中所观察到的,大多数原子/分子组分的平均绘制时间都足够。例如,我们的PortfolioListCell组件花费了大约2ms的渲染时间。但是,当有11个PortfolioListCell实例且每个实例渲染17次时,这2ms渲染就会累加起来。我们的问题不是单个组件的速度这么慢,而是我们重新渲染的次数太多了。

我们自己做到了

为了解释为什么会发生这种情况,我们需要退后一步来谈论我们的堆栈。该应用程序高度依赖于称为rest-hooks的数据获取库,Coinbase Web团队已经愉快地使用了一年多的时间。通过使用摘钩,我们可以与Web共享大量的数据层代码,包括为API端点自动生成的类型。该库的一个显着特征是它使用全局上下文来存储其缓存。如React文档所述,上下文的一个显着特征是:

对我们来说,这意味着无论何时将数据写入缓存(例如,当应用程序收到API响应时),访问商店的每个组件都会重新呈现,无论该组件是被存储还是引用更改后的数据。事实是,我们接受了将数据挂钩与组件共同定位的模式,这加剧了重新渲染的事实。例如,我们经常在低级组件中使用诸如useLocale()和useNativeCurrency()之类的数据消耗挂钩,这些挂钩根据用户的偏好来格式化信息。这对于开发人员来说是很棒的体验,但是这也意味着,使用这些挂钩的每个组件(无论是直接存储还是间接存储)都将在写入缓存时重新呈现,即使它们已被记忆。

这里值得一提的堆栈的另一部分是react-navigation ,它是当前React Native生态系统中使用最广泛的导航解决方案。来自Web背景的工程师可能会惊讶地发现,即使用户没有主动查看它,它的默认行为是导航器中的每个屏幕都保持安装状态。这允许未聚焦的屏幕保持其本地状态和滚动位置以“自由”显示。它在移动环境中也很实用,在移动环境中,我们通常希望在过渡期间(例如,推入堆栈或从堆栈弹出)向用户显示多个屏幕。对于我们来说不幸的是,这也意味着当用户浏览应用程序时,我们本来就很麻烦的重新渲染可能会成倍恶化。例如,如果我们有四个选项卡堆栈,并且用户在每个堆栈上浏览了一个屏幕,那么每次API响应返回时,我们将重新渲染八个屏幕的大部分!

容器组件

一旦我们了解了最紧迫的性能问题的根本原因,就需要找出解决方法。防止重新渲染的第一道防线是积极的记忆。正如我们前面提到的,当组件使用上下文时,无论上下文是否被记忆,它将在上下文值更改时重新呈现。这导致我们采用了功能性 容器模式,在该 模式中,我们将数据消耗大的钩子悬挂在一个薄包装器组件上,然后将这些钩子的返回值传递给可以从存储中受益的表示性组件。考虑以下要点。每当useWatchList()挂钩触发重新渲染时(即,任何时候更新数据存储),即使watchList的值未更改,我们也需要重新渲染Card和AssetSummaryCell组件。

应用容器模式时,我们将useWatchList()调用移至其自己的组件,然后记住视图的表示部分。每次数据存储更新时,我们仍将重新呈现WatchListContainer,但这将相对便宜,因为该组件的作用很小。

稳定道具

容器模式是一个好的开始,但是有一些陷阱我们需要小心避免。看下面的例子:

通过将useAsset(assetId)和useWatchListToggler()都提升到容器组件,我们似乎可以保护已记忆资产免受与数据相关的重新渲染。但是,由于我们为toggleWatchList传递了一个不稳定的值,因此该备忘录永远不会真正起作用。换句话说,每次AssetContainer重新渲染时,toggleWatchList将是一个新的匿名函数。当备忘在先前的道具和当前的道具之间进行浅层相等性比较时,值将永远不相等,并且资产将始终重新呈现。

为了从记忆资产中获得任何收益,我们需要使用useCallback来稳定我们的toggleWatchList函数。使用下面的更新代码,仅当资产实际更改时,资产才会重新渲染:

但是,回调并不是我们无意间破坏记忆的唯一方法。相同的原理也适用于对象。考虑另一个示例:

使用上面的代码,即使已经记住了Search组件,在PriceSearch呈现时它也将始终重新呈现。发生这种情况是因为间距和图标在每次渲染时都是不同的对象。

为了解决这个问题,我们将依靠useMemo来记住我们的icon元素。记住,每个JSX标记都编译为一个React.createElement调用,该调用在每次调用时都会返回一个新对象。我们需要记住该对象,以在整个渲染过程中保持参照完整性。由于间距实际上是恒定的,因此我们可以简单地在功能组件之外定义值以使其稳定。

进行以下更改后,可以有效地记住我们的搜索组件:

未聚焦屏幕上的短路渲染

备忘功能显着减少了每个屏幕的渲染计数/持续时间。但是,由于反应导航使未聚焦的屏幕保持安装状态,因此我们仍在浪费宝贵的资源来重新渲染用户不可见的大量内容。这导致我们开始浏览react-navigation的文档,以寻找可能缓解此问题的选项。当我们发现unmountOnBlur时,我们充满了希望。将标记切换为true确实会大大减少渲染效果,但它仅适用于未聚焦的选项卡的屏幕,并保持当前选项卡堆栈的所有屏幕都已安装。更可恶的是,在选项卡之间切换时会导致闪烁,并且当用户离开时会丢失屏幕的滚动位置和本地状态。

我们的第二次尝试涉及通过在用户离开时抛出承诺来将屏幕置于悬念状态(回到全屏加载微调器),然后在用户返回时解决该承诺,以允许再次显示屏幕。使用这种方法,我们可以消除不必要的渲染,并为所有未聚焦的屏幕保留局部状态。不幸的是,这种体验很尴尬,因为当用户返回到已经访问过的屏幕时,他们会短暂地看到加载指示器。此外,如果没有一些粗糙的技巧,它们的滚动位置将会丢失。

最终,我们采用了一种通用的解决方案,该解决方案可以防止在所有未聚焦的屏幕上重新渲染,而不会产生任何负面影响。我们通过将每个屏幕都包裹在一个组件中来实现此目的,该组件会在屏幕未聚焦时用“冻结”值覆盖指定的上下文(在这种情况下为休息挂钩的StateContext)。因为即使在“真实”上下文更新时,该冻结值(子屏幕中所有组件/钩子消耗的冻结值)也保持稳定,所以我们可以使与给定上下文有关的所有渲染短路。当用户返回到屏幕时,冻结的值将无效,并且实际上下文值将通过,从而触发初始重新渲染以同步所有已订阅的组件。当屏幕聚焦时,它将像往常一样接收所有上下文更新。下面的要点显示了如何使用DeactivateContextOnBlur完成此操作:

以下是如何使用DeactivateContextOnBlur的演示:

减少网络请求

有了DeactivateContextOnBlur和我们所有的备忘,我们已大大降低了应用中不必要的重新渲染的成本。但是,有一些关键屏幕(即“主页”和“资产”)在首次安装时仍然不堪JavaScript线程。发生这种情况的部分原因是因为每个屏幕都需要发出近十个网络请求。这是由于我们现有API的局限性,在某些情况下,它需要n + 1个查询才能获取用户界面所需的资产数据。这些请求不仅引入了计算开销和延迟,而且每当应用程序收到API响应时,它都需要更新数据存储,触发更多的重新渲染,降低我们的JavaScript FPS,并最终使UI的响应速度降低。

本着快速交付价值的精神,我们选择了添加两个新端点的低成本解决方案-一个返回“主页”屏幕的监视列表资产,另一个返回“资产”屏幕的相关资产。现在,我们将与这些UI组件相关的所有数据嵌入到单个响应中,不再需要对列表中的每个资产执行额外的请求。此更改显着提高了两个相关屏幕的TTI和帧速率。

尽管临时端点从我们最重要的两个屏幕中受益,但应用程序中仍有几个区域存在效率低下的数据访问模式。我们的团队目前正在探索可以解决该问题的更多基础解决方案,从而使我们的应用可以以更少的API请求来检索所需的信息。

概要

通过本文中描述的所有更改,在发布应用程序之前,我们能够减少超过90%的渲染次数和总渲染时间(由我们的自定义Babel插件衡量)。正如通过React性能监视器所观察到的,我们还看到丢失的帧少得多。这项工作的一个重要收获是,在许多方面,构建高性能的React Native应用程序与构建高性能的React Web应用程序相同。鉴于移动设备的功能相对有限,并且本机移动应用程序经常需要做更多的事情(例如,保持复杂的导航状态,从而在内存中保留多个屏幕),因此,遵循最佳性能实践对于构建高质量的应用程序至关重要。在过去的几个月中,我们已经走了很长一段路,但是我们还有很多工作要做。


优化React Native最初发布在Medium上的The Coinbase博客上,人们通过突出并响应这个故事来继续对话。

新手快速入金,可以注册币安或okex,不懂可以群里问或私聊我。

在比特币日报读懂区块链和数字货币,加入Telegram获得第一手区块链、加密货币新闻报道。

币安注册邀请链接: Binance 币安全球数字货币交易所

Okex注册邀请链接: OKEX 合约期货、期权交易所

Click to rate this post!
[Total: 0 Average: 0]

人已赞赏
交易所公告

BitMax|BitMax现已恢复BCH充提业务

2020-11-20 11:28:10

交易所公告

芝麻开门|Gate.io今日直播新鲜看,今晚七点半专访KOL摸庄校尉

2020-11-20 11:28:32

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
有新消息 消息中心
搜索