Konstantin Dzuin

Интерфейс для React native-приложения: быстро и без боли

Всем привет! Меня зовут Константин Дзюин. Я c 2003 года занимаюсь разработкой интерфейсов и получаю от этого огромное удовольствие.

Этот пост — транскрипт и логическое продолжение с дополнениями моего выступления на митапе Mobile JS. Видео доклада доступно на YouTube.

Я расскажу вам историю одного проекта, над которым я работал UI-разработчиком в команде фронтенда Badoo.

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

Под катом я расскажу, чем React похож и не похож на React Native, в чем особенности UI-разработки для него, какие решения уже существуют — и какие подходы и инструменты помогли именно нам. Я буду вести повествование со стороны разработчика UI, и в этой истории будет много личных эмоций, переживаний, боли и радости.

В том самом 2017 году, силами энтузиастов-экспериментаторов было создано пилотное приложение на базе нашего мобильного сайта. Какой же восторг мы испытали, когда все заработало практически с первого раза! Ни о каком стройном интерфейсе и замысловатых анимациях мы в тот момент и не думали, нам надо было получить MVP от MVP — приложение, которое позволяло авторизоваться на Badoo и пролистать людей поблизости. Ура! Вот оно, уже в телефоне!

Для меня как UI-разработчика в тот раз задача была простая: пара стандартных кнопок и сделать квадрат круглым. Тогда я начал подозревать, что-то особенное есть в этом React Native. Хорошее или плохое — предстояло выяснить позже.

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

Примерно через полгода компания поставила перед нами непростую задачу: за шесть недель создать абсолютно новое приложение, и пусть это будет тот самый React Native. Идея приложения была вдохновляющей: мы готовились создать дейтинг-приложение для аудитории в возрасте от 50 лет, которую незаслуженно обходят вниманием в сфере знакомств. Мы хотели собрать его с минимальными усилиями и быстро выпустить на рынок, чтобы оценить идею. Так родилось приложение Lumen.

Для нас это означало проверку в деле технологии React Native и проверку гипотезы о быстром запуске приложения на рынок.

Итак, у нас есть:

  • 6 недель. Жесткий тайминг как испытание для команды и для технологии.
  • Команда из 3 человек. Нам нужно было сделать максимальный результат минимальными ресурсами.
  • Готовый дизайн и инфраструктура (API).

Отсчёт пошёл

Разработка UI для React Native оказалась для меня абсолютно новой, незнакомой и непривычной. Но это не уменьшило моего желания добиться идеального результата.

Я изучил дизайны, составил план разработки интерфейса и его интеграции (а как же без плана, если всего 6 недель?), и отправился составлять UI Kit для приложения.

Сходства и отличия

Сначала мне показалось, что React и React Native похожи: и тот, и другой использует компонентный подход, один синтаксис, одна база техник и практически одна база технологий.

Давайте посмотрим на код.

import React from 'react';
import PropTypes from 'prop-types';

import ButtonGroup from 'Components/_UI/ButtonGroup/ButtonGroup';
import Button from 'Components/_UI/Button/Button';

const ContinueBlock = props => (
  <ButtonGroup>
    <Button text={'Continue'} onClick={props.onClickContinue} />
    <Button
      text={'Dismiss'}
      isMonochrome={true}
      onClick={props.onClickDismiss}
    />
  </ButtonGroup>
);

ContinueBlock.propTypes = {
  onClickContinue: PropTypes.func.isRequired,
  onClickDismiss: PropTypes.func.isRequired
};

export default ContinueBlock;

Это может быть как React, так и React Native, различий для нас на этом этапе нет никаких. Набор зависимостей стандартный, такое мы видим в любом React-приложении.

В этот момент мне и показалось, что всё будет просто.

Но когда я начал погружаться, я понял, что они разные. Мир React Native отличается от привычного мира web и React.

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

Наборы примитивов

Давайте начнём с самого начала, с примитивов. Для веб у нас есть привычные <div /> и <span />, <style />, <table />. С помощью div мы делаем блоки, с помощью span делаем inline-элементы, style — тут все понятно. С таблицами тоже все понятно.

В React Native мы используем <View /> для блоков, <Text /> для текста, StyleSheet для управления стилями. Для таблиц вообще ничего не придумали, приходится их делать самим.

За внешне небольшими отличиями прячутся огромные фундаментальные особенности платформ. К примеру, React Native отличается тем, как мы оформляем текст и как его используем в проекте.

Для веба мы можем запросто вставить текст в блочный элемент, и все будет работать. В React Native мы этого сделать не можем: каждый текст нужно оборачивать в текстовую ноду, только в этом случае он работает. Эту особенность собирались устранить, но этого светлого будущего нам еще подождать придётся.

import React from 'react';
import { View, Text } from 'react-native';

export const SpecWebPrimitives = () => (
  <React.Fragment>
    <div />
    <div>
      <div />
      <span>inline element</span>
    </div>
    <div>text inside block element</div>
    <span>inline element on its own</span>
  </React.Fragment>
);

export const SpecReactNativePrimitives = () => (
  <React.Fragment>
    <View />
    <View>
      <View />
      <Text>text node content</Text>
    </View>
    {/* <View>this will fail</View> */}
    <Text>text node on its own</Text>
  </React.Fragment>
);

Кроме того, есть ограничение на использование блочных элементов в текстовых нодах. Об этом нам грустно сообщает этот кусок кода из библиотеки React Native:

<TextAncestor.Consumer>
  {hasTextAncestor => {
    invariant(
      !hasTextAncestor,
      'Nesting of <View> within <Text> is not currently supported.'
    );
    return <ViewNativeComponent {...props} ref={forwardedRef} />;
  }}
</TextAncestor.Consumer>

Особенности применения стилей и наследование

В стилях для веба у нас работает наследование. У нас могут быть глобальные стили, которые применяются для всех потомков, — это касается не только типографики, но и любых стилей, которые могут наследоваться. В вебе у нас все хорошо и привычно: текст красный, если где-то выше по дереву мы указали, что цвет для текста красный. И, в конце концов, мы можем указать значение inherit для достаточно большого количества свойств.

В React Native наследования нет, каждый компонент начинает свою историю с нуля. Да, есть влияние родительского элемента на дочерний, но это скорее про layout. Мы можем получить простое наследование только в рамках Text > Text.

Но в React Native, на уровне блочного элемента (View), мы не можем установить стили для текста и не можем влиять на типографику в принципе. Манипуляции со стилями текста нужно делать прямо на уровне текстовой ноды.

Наследование для оформления текста работает только только от текстовой ноды к текстовой ноде: ни о каких глобальных настройках речи не идет.

import React from 'react';
import { View, Text } from 'react-native';

export const SpecTextStylesWeb = () => (
  <div style={{ color: 'red' }}>
    <div>Very red text via inheritance</div>
  </div>
);

/* this will fail */
export const SpecTextStylesReactNativeFail = () => (
  <View style={{ color: 'red' }}>
    <Text>Text color set on the text-node level</Text>
  </View>
);

export const SpecTextStylesReactNative = () => (
  <View>
    <Text style={{ color: 'red' }}>Text color set on the text-node level</Text>
  </View>
);

Layout и особенности оформления компонентов

Создание layout в React Native тоже отличается от привычного. Отличаются модели позиционирования и юниты: для React Native у нас есть только relative и absolute позиция. Нет fixed: нам нужно понимать, относительно какого View нужный нам элемент будет абсолютно позиционирован.

import React from 'react';
import { View, Text } from 'react-native';

export const SpecFixedView = () => (
  <View style={{ flex: 1 }}>
    {/* это наш корневой элемент на всю высоту */}
    <ScrollView>
      {/* это содержимое, которое можно скролить */}
      {/* ScrollView по умолчанию занимает всю доступную высоту */}
    </ScrollView>
    <View style={{ position: 'absolute', top: 10, start: 10 }}>
      {/* это абсолютно-позиционированный блок */}
      {/* он не смещается при скролле, получается эффект fixed */}
    </View>
  </View>
);

Единицы измерения и юниты

В то же время, нам нужно помнить, что многие свойства в стилях не работают с относительными единицами — мы ничего не знаем о типографике. В нашем арсенале будут только логические пиксели (без указания единиц измерения), местами работают проценты. Как я уже писал, у нас нет никаких настроек типографики, которые «текут» через приложение, все указывается на местах; для элементов View нельзя указывать стили для текста (см. ViewStyle и TextStyle в типизации или документации). Именно поэтому мы не можем для расчета размеров использовать единицы, основанные на типографских размерах — нет ни em, ни ex, ни rem.

Создание разметки

Давайте перейдём непосредственно к построению разметки приложения в React Native. Layout в React Native строится на движке Yoga, техника верстки чем-то похожа на использование flex в web.

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

Работа с разметкой в React Native очень похожа на использование одной из спецификаций. Не последней и стабильной, но всё же.

По умолчанию, направление флекс-контейнера — колонка, а не ряд, как мы привыкли. После нескольких дней фрустрации, приходит понимание, что на деле это удобно и для построения layout работает как надо. И да, про запись вида flex: growFactor shrinkFactor basis можно забыть на время.

В React Native есть приятные бонусы — например, aspectRatio: мы можем указать, какое соотношение ширины и высоты мы устанавливаем для блока. В CSS до сих пор этого нет. Возможно, где-то есть в черновиках спецификации, но в React Native это работает из коробки, в отличие от CSS.

Точная настройка и тюнинг под разные платформы

Когда мы начинаем работу с React Native, мы думаем, что будем писать один код для всех платформ, и все будет работать. Ну конечно!

Как я говорил, я 17 лет занимаюсь интерфейсами. И я думал, такого уже не будет. Но на дворе 2019 год, а для некоторых примитивов из React Native нам по-прежнему нужно применять подход с костылями и подпорками, чтобы добиться нужного отображения. Нужен очень тонкий тюнинг: буквально работаем маленькими отвертками, подкручивая значения в зависимости от состояния примитива и от платформы.

Один из вопиющих примеров, где мне потребовалось поколдовать над стилями — поле ввода для текста.

Note that some props are only available with multiline={true/false}. Additionally, border styles that apply to only one side of the element (e.g., borderBottomColor, borderLeftWidth, etc.) will not be applied if multiline={false}. To achieve the same effect, you can wrap your TextInput in a View.

 — Origin: https://facebook.github.io/react-native/docs/textinput

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

Перейдём к оформлению компонента. Сначала я применил стандартные стили — там совсем немного, буквально размер шрифта и высота строки…

 todo

import React from 'react';
import { View, TextInput } from 'react-native';
import UIKit from 'ui-kit';

export const TextField = props => (
  <View
    style={{
      paddingVertical: UIKit.TextField.paddingVertical,
      paddingHorizontal: UIKit.TextField.paddingHorizontal,
      borderBottom: UIKit.TextField.borderWidth,
      borderBottomColor: UIKit.TextField.borderColor
    }}
  >
    <TextInput
      style={{
        fontSize: UIKit.TextField.fontSize,
        lineHeight: UIKit.TextField.lineHeight
      }}
    />
  </View>
);

В этот момент, я мысленно представляю, как здорово и нарядно выглядит мое поле для ввода текста. Но что же у нас вообще происходит?

TextInput has by default a border at the bottom of its view. This border has its padding set by the background image provided by the system, and it cannot be changed. Solutions to avoid this is to either not set height explicitly, case in which the system will take care of displaying the border in the correct position, or to not display the border by setting underlineColorAndroid to transparent.

 — Origin: https://facebook.github.io/react-native/docs/textinput

Так вот как, значит? Хорошо. «Неприятность эту мы переживём», спасибо, что написали об этом на первой странице документации.

import React from 'react';
import { View, TextInput } from 'react-native';
import UIKit from 'ui-kit';

export const TextField = props => (
  <View
    style={{
      paddingVertical: UIKit.TextField.paddingVertical,
      paddingHorizontal: UIKit.TextField.paddingHorizontal,
      borderBottom: UIKit.TextField.borderWidth,
      borderBottomColor: UIKit.TextField.borderColor
    }}
  >
    <TextInput
      style={{
        fontSize: UIKit.TextField.fontSize,
        lineHeight: UIKit.TextField.lineHeight
      }}
      underlineColorAndroid={'transparent'}
    />
  </View>
);

Теперь-то все точно должно быть хорошо!

Но нет. Высота строки не работает.

Хорошо, я знаю как это решить, добавить дополнительную вертикальную отбивку. Все будет здорово.

Но нет. Есть Android-устройства. Для них нужно чуть-чуть подкрутить значения. При этом, числа — магические. Подкрутили, все проверили. Вот сейчас заживем!

Но нет. Есть iOS-устройства, в которых цифры другие. Все поле ввода смещено на 0, px — совсем немного, но не по дизайну. Нам надо красиво и «по дизайну». А ещё есть однострочные и многострочные поля ввода. Ещё немного подкрутим.

Весело, правда?

multiline

If true, the text input can be multiple lines. The default value is false. It is important to note that this aligns the text to the top on iOS, and centers it on Android. Use with textAlignVertical set to top for the same behavior in both platforms.

 — Origin: https://facebook.github.io/react-native/docs/textinput#multiline

Если поле ввода пустое, происходит вообще чистая магия: у нас пропадают 5 пикселей высоты. Я не знаю, куда деваются эти недостающие 5 пикселей! И почему именно 5, я тоже не знаю. Это значение я просто сидел и подбирал, чтобы сделать внешний вид везде одинаковым.

А давайте добавим placeholder! Не буду писать много строк, скажу просто: оно не работает как надо. Мы можем изменить только цвет текста для него, никаких других вариантов нет. Мы же помним про отсутствие наследования текстовых параметров?

Во всем этом зоопарке значений самое страшное — разрешить пользователю менять размер шрифта. Пользователь может поставить такой масштаб, что все точно пойдет не «по дизайну».

Железные компоненты

Некоторые компоненты из комплекта React Native мы в принципе не можем разукрасить.

Посмотрим на кнопки: у нас есть два свойства, которые мы можем использовать для оформления наших кнопок, стандартные, из комплекта React Native.

Это цвет и состояние — disabled или не disabled. Выглядят они абсолютно по-разному, при этом стили я применить не могу. Вот поэтому нам нужно создавать наши собственные компоненты.

Отладка

Мы приняли, что многое, а то и почти все, нам придётся писать самим. Примитивы нам только несколько облегчают эту задачу, спасибо, что они вообще есть.

Двигаемся дальше. К нам приходит следующая боль — отладка. Мы хотим инспектор, хотим посмотреть, как устроен компонент, какие у него соседи, какие свойства, откуда они, и что мы отправляем дальше.

Вот так выглядит инспектор, и это совсем не то, что мы хотим. Нам серьезно не хватает информации.

Окей, запустим React Native Devtools.

Мне кажется, при таких средствах отладки у нас не много остаётся вариантов, кроме как писать код на ощупь и без ошибок. Если вы ниндзя, то для вас это будет легко. К сожалению, я так не умею.

Так что же требовал от нас React Native?

Перед нами стояли три серьезные задачи:

  • Найти способ писать компоненты быстро и надежно, а не подкручивать каждое значение на глазок.
  • Найти способ эффективной отладки компонент, а не гадать, как они работают.
  • Запустить приложение за 6 недель.

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

Наши решения

1. Меняем подход к разработке

Так исторически сложилось, что во фронтенде компании Badoo мы применяем separations of concerns: мы разделяем ответственность между разработчиками бизнес-логики и UI-разработчиками. Разработчики бизнес-логики отвечают за логику и клиент-серверное взаимодействие. UI-разработчики отвечают за презентационное состояние, внешний вид приложения — все, что человек видит. Совершенно случайно это совпало с одним из React-паттернов Container-View.

Раз уж мы два отдельных лагеря, мы можем разойтись в разные углы и делать каждый свое, даже практически не переговариваясь. У нас уже есть общий язык — common language: мы используем одни термины для отображения, одни термины для структур данных, у нас все общее. Исходя из этого, мы можем планировать задачи в два потока: например, верстка или логика могут уйти вперед, главное — изначально договориться на берегу.

Это очень помогло нам сэкономить время на разработку.

2. Styleguide: пишем компоненты быстро

Чтобы сделать UI приложения быстро, неплохо бы иметь дизайн-систему в самом начале, до того, как фича пришла в разработку. Готовь сани летом, а дизайн-систему — перед фичами: если у разработчиков есть система до того, как система появляется у дизайнеров, разработчик может с ней не угадать. Поэтому тот самый common language должен быть еще и между UI-разработчиками и дизайнерами.

Есть множество инструментов для styleguide: React Story Book, React Styleguidist. За ними разные подходы и разные истории. Все они решают одну проблему: они показывают либо пользовательскую историю, либо просто компоненты, композиции компонент, композиции композиций. В Badoo для этого есть инхаус-решение, так как у нас своя специфика. Сразу хочу успокоить читателя: это in-house решение нам не пришлось писать за эти же 6 недель. Styleguide мы начали разрабатывать до этого, так как видели возможности ускорения разработки с помощью этого инструмента. За эти шесть недель нам удалось в очередной раз подтвердить эту гипотезу.

Мы можем собрать целые системы компонент, можем просто собирать компоненты с вариациями. А можно собирать вьюшки и экраны целиком.

Когда мы делали Paywall, мне не нужно было разговаривать с разработчиком, я ему отдал вьюшку. В ней есть PropTypes, описаны все состояния, есть документация. Примерно через 3-4 дня я узнал, что фича готова: мне даже ничего править не пришлось.

3. Тестируем на визуальные регрессии (VRT)

Что же такое VRT и визуальная регрессия? Беглым взглядом на двух этих экранах я не замечу разницы.

Сравним поближе. Розовым подсвечены пиксели, которые отличаются. Машина способна увидеть такое, человек — не с первого раза. Выходит, комбинация styleguide и VRT сокращает наше время тестирования. Особенно это важно, когда у вас всего 6 недель.

Для того чтобы styleguide был живым, безопасным и надежным инструментом, желательно покрыть это визуальной регрессией. Инструментов есть огромное количество: можно не привязываться к конкретному решению, а просто сделать универсальное для себя — в зависимости от того, какие задачи вы решаете.

4. Организуем рабочее пространство

То, что я предложу дальше, может оказаться вам неожиданным и странным, но это может увеличить производительность в разы. Давайте организуем рабочее пространство.

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

Слева IDE, справа styleguide, который отображает наши компоненты React Native с помощью React Native Web в браузере. Там я могу спокойно отлаживать, смотреть DOM дерево или дерево компонент React, стили. По сути, это те же самые React Native компоненты, просто в браузере. Учитывая, что они описаны в разных вариантах — получается супер-круто и эффективно.

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

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

Итоги

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

Наша команда провела огромную работу за этот короткий период и вывела приложение на рынки. У нас было много разговоров о том, как и что мы делаем, многое мы решали «на лету», многое делалось экспериментально. Зачастую нам везло с выбором идеи, и она взлетала.

Итак, что же произошло с вызовом, который нам бросила компания чуть меньше года назад?

  • Приложение запущено. По практике Badoo мы проводим множество A/B-тестов: мы —  data-driven-компания. Это показатель: если в новом приложении начались A/B тесты, значит, оно живое. Значит, через какое-то время пользователь увидит лучший результат.
  • Шесть недель — это реальный срок, даже если вас всего трое. Нам очень помог React Native в сочетании с нашими подходами и инструментами: эта комбинация позволила нам уложиться в сроки.
  • Мы получили бесценный опыт и расширение нашего сознания, мышления, образа мысли. Это не только история команды, это личная история. Я сам впервые занялся React Native, проходил через всю эту боль и страдания.

Повторил бы я опыт с React Native в реальной жизни еще раз?

Да.

Даже если еще раз придётся учить его с нуля.

И тем более, если у вас всего 6 недель.