Ник Черри, штатный инженер-программист

За последние восемь месяцев Coinbase переписала свое приложение для Android с нуля с помощью React Native. Прочтите о некоторых проблемах производительности, с которыми мы столкнулись и преодолели на этом пути.

Если вас интересуют подобные технические проблемы, ознакомьтесь с нашими открытыми ролями и подайте заявку на вакансию.

За последние восемь месяцев Coinbase переписывала свое приложение для Android с нуля, используя React Native. На прошлой неделе новое и переработанное приложение было развернуто для 100% пользователей. Мы гордимся тем, что наша небольшая команда смогла выполнить за короткий промежуток времени, и мы по-прежнему очень оптимистичны в отношении React Native как технологии, ожидая, что она будет приносить постоянные дивиденды в отношении как скорости разработки, так и качество продукции.

При этом не все было легко. Одной из областей, где мы столкнулись с серьезными проблемами, была производительность, особенно на устройствах Android. В течение следующих нескольких месяцев мы планируем опубликовать серию сообщений в блогах, в которых будут описаны различные проблемы, с которыми мы столкнулись, и способы их устранения. Сегодня мы сосредоточимся на том, что больше всего повлияло на нас: на ненужные рендеры.

Вещи были прекрасными, пока не стали

В начале проекта приложение работало нормально. Он был почти неотличим от полностью нативного продукта, хотя мы не тратили время на оптимизацию нашего кода. Мы знали, что другие команды сталкиваются (и преодолевают) проблемы с производительностью с помощью React Native, но ни один из наших предварительных тестов не дал нам повода для беспокойства. В конце концов, приложение, которое мы планировали создать, было в основном предназначено только для чтения, не требовало отображения каких-либо массивных списков и не требовало анимации, которую нельзя было выгрузить в собственный драйвер.

Однако по мере добавления новых функций мы начали замечать снижение производительности. Сначала деградация была незаметной. Например, даже с нашей производственной сборкой переход к новым экранам может показаться вялым, а обновления пользовательского интерфейса будут немного задерживаться. Но вскоре переключение между вкладками заняло больше секунды, и после перехода на новый экран пользовательский интерфейс мог перестать отвечать на запросы в течение длительного периода времени. Пользовательский опыт ухудшился до такой степени, что запуск был заблокирован.

Выявление проблемы

Нам не потребовалось много времени, чтобы распознать корреляцию между задержкой пользовательского интерфейса и частотой кадров JavaScript. После взаимодействия с пользователем мы обычно наблюдаем, как JS FPS падает до низких (или отрицательных!) Однозначных цифр в течение нескольких секунд. Для нас было не так очевидно почему. Всего месяцем ранее приложение работало относительно неплохо, и ни одна из добавленных нами функций сама по себе не казалась особенно обременительной. Мы использовали Profiler React для тестирования больших компонентов, которые, как мы предполагали, могли быть медленными, и обнаружили, что многие отрисовывают больше, чем нужно. Нам удалось уменьшить количество повторных рендеров для этих более крупных компонентов с помощью мемоизации, но наши улучшения не сдвинули с мертвой точки. Мы также рассмотрели продолжительность рендеринга нескольких атомных и молекулярных компонентов, ни один из которых не казался неоправданно дорогим.

Чтобы получить более целостное представление о том, где повторный рендеринг был наиболее затратным, мы написали специальный плагин Babel, который обернул каждый элемент JSX в приложении знаком Profiler. Каждому Profiler была назначена функция onRender, которая сообщала поставщику контекста в верхней части дерева React. Этот провайдер контекста верхнего уровня будет агрегировать количество и продолжительность рендеринга - группируя по типу компонентов, - а затем каждые несколько секунд регистрировать наихудших нарушителей. Ниже приведен снимок экрана с результатами нашей первоначальной реализации:

Как мы наблюдали в наших предыдущих тестах, среднее время рендеринга для большинства наших атомных / молекулярных компонентов было адекватным. Например, нашему PortfolioListCell компоненту потребовалось около 2 мс для рендеринга. Но когда есть 11 экземпляров PortfolioListCell и каждый рендерит 17 раз, эти 2 мс рендеринга складываются. Наша проблема заключалась не в том, что отдельные компоненты были такими медленными, а в том, что мы слишком много повторно рендерили все.

Мы сделали это сами

Чтобы объяснить, почему это произошло, нам нужно сделать шаг назад и поговорить о нашем стеке. Приложение в значительной степени полагается на библиотеку выборки данных под названием rest-hooks, которую веб-команда Coinbase с радостью использует уже более года. Внедрение rest-хуков позволило нам поделиться значительной частью нашего кода уровня данных с Web, включая автоматически сгенерированные типы для конечных точек API. Одной из примечательных характеристик библиотеки является то, что она использует глобальный контекст для хранения своего кеша. Одна примечательная характеристика контекста, описанная в React docs, заключается в том, что:

Все потребители, являющиеся потомками провайдера, будут повторно отображаться при изменении value свойства провайдера.

Для нас это означало, что каждый раз, когда данные записывались в кеш (например, когда приложение получает ответ API), каждый компонент, обращающийся к хранилищу, будет повторно визуализироваться, независимо от того, был ли компонент мемоизирован или ссылался на измененные данные. Повторный рендеринг усугублял тот факт, что мы применили шаблон совмещения обработчиков данных с компонентами. Например, мы часто использовали перехватчики данных, такие как useLocale() и useNativeCurrency(), в компонентах нижнего уровня, которые форматировали информацию в соответствии с предпочтениями пользователя. Это было здорово для разработчиков, но это также означало, что каждый компонент, использующий эти хуки - прямо или косвенно - будет повторно отрисовывать при записи в кеш, даже если они были запомнены.

Еще одна часть нашего стека, о которой стоит упомянуть, - это реакция-навигация, которая в настоящее время является наиболее широко используемым навигационным решением в экосистеме React Native. Инженеры, работающие в Интернете, могут быть удивлены, узнав, что по умолчанию каждый экран в Navigator остается подключенным, даже если пользователь не просматривает его активно. Это позволяет несфокусированным экранам сохранять свое локальное состояние и положение прокрутки бесплатно. Это также практично в мобильном контексте, где мы обычно хотим показывать пользователю несколько экранов во время переходов, например при надевании / выскакивании из стопки. К сожалению для нас, это также означает, что наш и без того проблемный повторный рендеринг может стать экспоненциально хуже, когда пользователь будет перемещаться по приложению. Например, если у нас есть четыре стека вкладок, и пользователь переместился на один экран в глубину каждого стека, мы бы повторно отображали большую часть из восьми экранов каждый раз, когда возвращался ответ API!

Компоненты контейнера

Как только мы поняли основную причину наших самых серьезных проблем с производительностью, нам нужно было выяснить, как ее исправить. Нашей первой линией защиты от повторного рендеринга была агрессивная мемоизация. Как мы упоминали ранее, когда компонент использует контекст, он будет повторно визуализироваться при изменении value этого контекста, независимо от того, мемоизирован ли компонент. Это привело нас к принятию функционального паттерна контейнера, в котором мы поднимаем потребляющие данные перехватчики на тонкий компонент-оболочку, а затем передаем возвращаемые значения этих перехватчиков вниз презентационным компонентам, которым может быть полезна мемоизация. Рассмотрим суть ниже. Каждый раз, когда ловушка useWatchList() запускает повторный рендеринг (т. Е. Каждый раз при обновлении хранилища данных), нам также необходимо повторно рендерить наши компоненты Card и AssetSummaryCell, даже если значение watchList не изменилось.

При применении шаблона контейнера мы перемещаем вызов useWatchList() в его собственный компонент, а затем запоминаем презентационную часть нашего представления. Мы по-прежнему будем повторять WatchListContainer рендеринг каждый раз при обновлении хранилища данных, но это будет сравнительно дешево, поскольку компонент выполняет очень мало функций.

Стабилизирующие опоры

Шаблон контейнера был хорошим началом, но было несколько подводных камней, которых мы должны были избегать. Взгляните на пример ниже:

Может показаться, что мы защищаем мемоизированный Asset от повторного рендеринга, связанного с данными, перемещая и useAsset(assetId), и useWatchListToggler() в компонент-контейнер. Однако мемоизация никогда не сработает, потому что мы передаем нестабильное значение для toggleWatchList. Другими словами, каждый раз, когда AssetContainer повторно обрабатывается, toggleWatchList будет новой анонимной функцией. Когда memo выполняет поверхностное сравнение на равенство между предыдущими реквизитами и текущими реквизитами, значения никогда не будут равны, и Asset всегда будет повторно визуализироваться.

Чтобы извлечь пользу из мемоизации Asset, нам нужно стабилизировать нашу toggleWatchList функцию с помощью useCallback. С обновленным кодом, приведенным ниже, Asset будет повторно визуализироваться, только если asset действительно изменится:

Однако обратные вызовы - не единственный способ случайно нарушить мемоизацию. Те же принципы применимы и к объектам. Рассмотрим другой пример:

С помощью приведенного выше кода, даже если компонент Search был мемоизирован, он всегда будет повторно отрисовываться при отрисовке PricesSearch. Это происходит потому, что spacing и icon будут разными объектами при каждом рендеринге.

Чтобы исправить это, мы будем полагаться на useMemo, чтобы запоминать наш icon элемент. Помните, что каждый тег JSX компилируется в React.createElement вызов, который при каждом вызове возвращает новый объект. Нам нужно запомнить этот объект, чтобы поддерживать ссылочную целостность при рендеринге. Поскольку spacing действительно постоянный, мы можем просто определить значение вне нашего функционального компонента, чтобы стабилизировать его.

После следующих изменений наш Search компонент можно эффективно запоминать:

Короткое замыкание рендеров на несфокусированных экранах

Мемоизация значительно сократила количество / продолжительность рендеринга для каждого экрана. Однако из-за того, что при интерактивной навигации смонтированы несфокусированные экраны, мы по-прежнему тратили ценные ресурсы на повторную визуализацию большого количества контента, который не был виден пользователю. Это побудило нас начать копаться в документации по react-navigation в поисках варианта, который мог бы решить эту проблему. Мы обнадежили, когда обнаружили unmountOnBlur. При переключении флага на true наши отрисовки значительно сократились, но это применимо только к экранам несфокусированных вкладок, сохраняя смонтированным весь экран текущего стека вкладок. Что еще более ужасно, это приводило к мерцанию при переключении между вкладками и к потере позиции прокрутки экрана и локального состояния, когда пользователь уходил.

Наша вторая попытка заключалась в том, чтобы перевести экраны в состояние ожидания (возврат к полноэкранному запуску счетчика загрузки), бросая обещание, когда пользователь ушел, а затем выполняя обещание, когда пользователь вернулся, позволяя экрану снова отображаться. При таком подходе мы могли бы исключить ненужные отрисовки и сохранить локальное состояние для всех несфокусированных экранов. К сожалению, опыт был неудобным, потому что пользователи на короткое время видели индикатор загрузки, возвращаясь к уже посещенному экрану. Кроме того, без некоторых грубых хаков их положение прокрутки было бы потеряно.

В конце концов мы нашли универсальное решение, которое предотвращало повторный рендеринг на всех несфокусированных экранах без каких-либо негативных побочных эффектов. Мы достигаем этого, помещая каждый экран в компонент, который переопределяет указанный контекст (rest-hooks ’StateContext в данном случае) с« замороженным »значением, когда экран не сфокусирован. Поскольку это замороженное значение (которое используется всеми компонентами / перехватчиками на дочернем экране) остается стабильным даже при обновлении «реального» контекста, мы можем сократить все отрисовки, относящиеся к данному контексту. Когда пользователь возвращается к экрану, замороженное значение обнуляется, а реальное значение контекста передается через него, вызывая начальную повторную визуализацию для синхронизации всех подписанных компонентов. Пока экран находится в фокусе, он будет получать все обновления контекста, как обычно. Суть ниже показывает, как мы достигаем этого с помощью DeactivateContextOnBlur:

А вот демонстрация того, как можно использовать DeactivateContextOnBlur:

Уменьшение сетевых запросов

Используя DeactivateContextOnBlur и все наши мемоизации, мы резко снизили стоимость ненужных повторных отрисовок в нашем приложении. Однако было несколько ключевых экранов (например, Home и Asset), которые все еще перегружали поток JavaScript при первом монтировании. Частично это происходило потому, что на каждом из этих экранов требовалось сделать около дюжины сетевых запросов. Это было связано с ограничениями нашего существующего API, который в некоторых случаях требовал n + 1 запросов для получения данных об активах, необходимых нашему пользовательскому интерфейсу. Эти запросы не только приводили к вычислительным накладным расходам и задержкам, но и всякий раз, когда приложение получало ответ API, ему приходилось обновлять хранилище данных, вызывая больше повторных отрисовок, уменьшая наш JavaScript FPS и, в конечном итоге, делая пользовательский интерфейс менее отзывчивым.

В духе быстрого предоставления ценности мы выбрали недорогое решение, добавив две новые конечные точки - одну для возврата ресурсов списка наблюдения для главного экрана, а другую - для возврата коррелированных ресурсов для экрана активов. Теперь, когда мы встраивали все данные, относящиеся к этим компонентам пользовательского интерфейса, в один ответ, больше не было необходимости выполнять дополнительный запрос для каждого актива в списке. Это изменение заметно улучшило TTI и частоту кадров для обоих соответствующих экранов.

Хотя для специальных конечных точек используются два из наших самых важных экранов, в приложении все еще есть несколько областей, которые страдают от неэффективных схем доступа к данным. Наша команда в настоящее время изучает более фундаментальные решения, которые могут решить проблему в целом, позволяя нашему приложению получать необходимую информацию с гораздо меньшим количеством запросов API.

Резюме

Со всеми изменениями, описанными в этом посте, мы смогли сократить количество рендеров и общее время, затрачиваемое на рендеринг, более чем на 90% (по данным нашего настраиваемого плагина Babel) перед выпуском приложения. Мы также видим гораздо меньше пропущенных кадров, как это видно с помощью монитора производительности React. Одним из ключевых выводов этой работы является то, что создание производительного приложения React Native во многом аналогично созданию производительного веб-приложения на React. Учитывая сравнительно ограниченную мощность мобильных устройств и тот факт, что нативным мобильным приложениям часто требуется больше (например, поддерживать сложное состояние навигации, которое сохраняет в памяти несколько экранов), следование передовым практикам в области производительности имеет решающее значение для создания высококачественного приложения. Мы прошли долгий путь за последние несколько месяцев, но впереди еще много работы.

Этот веб-сайт содержит ссылки на сторонние веб-сайты или другой контент только в информационных целях («Сторонние сайты»). Сторонние сайты не находятся под контролем Coinbase, Inc. и ее дочерних компаний (далее «Coinbase»), и Coinbase не несет ответственности за содержание любых Сторонних сайтов, включая, помимо прочего, любую ссылку, содержащуюся в Сторонних сайтах. Сторонний сайт, а также любые изменения или обновления Стороннего сайта. Coinbase не несет ответственности за веб-трансляции или любые другие формы передачи, полученные с любых Сторонних сайтов. Coinbase предоставляет вам эти ссылки только для удобства, и включение любой ссылки не означает одобрения, одобрения или рекомендации Coinbase сайта или какой-либо связи с его операторами.

Все изображения, представленные здесь, принадлежат Coinbase.