Litho — UI-фреймворк от Facebook, который отвечает за быстрый рендеринг тяжелого UI в топовых приложения с миллиардами загрузок.

Как его использовать, что происходит под капотом, и действительно ли с UI можно работать только из одного потока?


Cookbook по Litho в расшифровке моего доклада с конференции Mobius 2019 Moscow под катом.

С вами Сергей Рябов — разработчик из Лондона, который поставил на паузу свой кочевой образ жизни, чтобы проверить, такой ли Альбион туманный, как о нём любят говорить. И я работаю в команде Native UI Frameworks над декларативным фреймворком Litho.

Структура поста:




Что такое Litho?




Litho — это UI фреймворк, которому уже четыре года. Два года он разрабатывался внутри компании, затем его вывели в опенсорс, и вот уже два с половиной года он растет и развивается как опенсорсный проект. Изначально Litho разрабатывался для основного приложения Facebook, хотя если взять три самых популярных приложения, которые используют Litho в настоящий момент, то только одно из них будет иметь отношение к Facebook. Остальные же — продукты Google.

Когда мы работаем с интерфейсом, у нас есть следующие этапы работы: Inflate, Measure, Layout, Draw. Все эти этапы построения UI должны поместиться в 16 миллисекунд, чтобы приложение не тормозило. Предположим, что у нас тяжелый UI, который не укладывается в 16 миллисекунд: все оптимизации проведены, мы попытались выжать максимум производительности из наших вьюшек, но программа все равно лагает. Можно постараться что-нибудь вынести в фоновый поток, например, Inflate, поскольку это достаточно тяжелая операция. Для этого у нас есть AsyncLayoutInflater, из набора библиотек Android Jetpack.

Заметим, что AsyncLayoutInflater имеет некоторые нюансы: мы не можем создавать вьюшки, которые работают внутри с Handler, нам нужно, чтобы LayoutParams были thread-safe и некоторые другие. Но в целом использовать его можно.

Допустим, в процессе развития приложения на главном экране появляются еще более сложные элементы, и даже оставшиеся три стадии уже не влезают в 16 миллисекунд. В целях оптимизации хочется максимальное количество работы отправить в фоновый поток — сразу и Measure, и Layout, поскольку теоретически это в основном математические расчеты, и они сильно отвязаны от Android. Практически же сделать так нельзя, потому что в Android UI-фреймворке Measure и Layout непосредственно реализованы в классе View, поэтому с ними можно работать только в UI-потоке.

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



Litho дает возможность абстрагироваться от View в фазах Measure и Layout благодаря тому, что под капотом для измерений используется движок Yoga, который тоже разрабатывается в команде Native UI Frameworks. И как следствие, это позволяет всю математику вынести в бэкграунд-поток.

Подобное решение предполагает, что теперь нам нужен другой API для работы с UI-подсистемой, поскольку мы уже не можем создавать вьюшки с помощью XML или конструировать их в коде, как это делалось раньше. XML отличается визуально понятной декларативной природой, но совершенно не гибок. А создание UI в коде предоставляет нам всю мощь нормального языка программирования для обработки любых условий, но View API наглядностью не блещет. Почему бы тогда не взять лучшее из обоих подходов?

Декларативный API в Litho


Декларативный подход предполагает, что UI можно представить в виде некоторой функции, которая зависит от набора входных параметров (props) и возвращает некое описание этого UI. При этом функция может быть «чистой», не иметь внешних зависимостей, кроме явно указанных параметров, и не создавать сайд-эффектов, что позволит создавать UI даже в фоновом потоке, не боясь за потокобезопасность (thread-safety).



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

fun f(
    title: String,
    subtitle: String
): UI {
  return Column.create()
    .child(
        Text.create()
            .text(title))	
    .child(
        Text.create()
            .text(subtitle))
    .build()
}

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

@LayoutSpec
object ListItemSpec {

  @OnCreateLayout
  fun onCreateLayout(
      c: ComponentContext,
      @Prop title: String,
      @Prop subtitle: String
  ): Component {
    return Column.create(c)
        .child(
            Text.create(c)
                .text(title))
        .child(
            Text.create(c)
                .text(subtitle))
        .build()
   }
}

Разница в том, что над функцией появляется аннотация @OnCreateLayout, которая сообщает, за что отвечает эта функция. Входящие свойства тоже помечаются специальной аннотацией @Prop, чтобы по ним сгенерировать правильный Builder, для конструирования UI, а также везде прокидывается специальный контекст ComponentContext. И всё это помещается в класс с аннотацией @LayoutSpec, который может содержать и некоторые другие методы.

Этот класс написан на Kotlin, поэтому используется object, но если бы он был на Java, то метод был бы статическим. Это обусловлено вышеупомянутым упором на потокобезопасность. Мы описываем только то, как должен выглядеть UI, а то, что для этого будет происходить под капотом, генерируется фреймворком, на это нельзя повлиять прямым образом, а потому шансы случайно ошибиться, например, плохо обработав локальное состояние (state) UI-компонента, сокращаются.


Комбинирование UI-элементов


У нас уже есть одна функция описывающая простой UI, а как теперь создать на ее основе что-то более сложное? Например, добавить картинку слева от текста, чтобы в итоге получился такой header с данными человека, который написал комментарий: его аватар, имя и дата комментария.



Решение похоже на использование горизонтального LinearLayout, но в данном случае мы горизонтально располагаем картинку и предыдущий UI-компонент ListItem, заворачивая их в Row. Также добавился еще один входной параметр @Prop image, который отвечает за саму картинку, а те параметры, которые отвечают за текстовые данные, просто прокидываются в компонент ListItem.

Стоит отметить, что описываются UI-компоненты в классах с суффиксом Spec, а для создания инстансов этих компонентов используются классы без этого суффикса. Всё потому, что Litho по декларативным Spec-файлам генерирует настоящие реализации компонентов с правильной обработкой многопоточности и удобным API в виде Builder-ов, которые и используются для задания входных параметров, объявленных в UI-спеке.

Если вы хотите использовать созданный UI-компонент не как часть большего компонента, а просто показать его внутри Activity, то выглядеть это будет следующим образом:



В публичном API Litho есть только одна View — LithoView. Это контейнер, в котором идет отрисовка всех Litho-компонентов. Чтобы отобразить на экране заданный компонент, нужно создать ComponentContext, передав ему Android Context, и создать LithoView, передав в метод create контекст и сам отображаемый компонент. С LithoView можно делать всё, что вы привыкли делать с другими вьюшками, например, передать в метод setContentView у Activity.

С API на основе Builder-ов работать легко, механика создания UI-компонента похожа на описание вью в XML. Разница лишь в том, что вместо проставления XML-атрибутов вы вызываете методы Builder-а. Но раз уж это все так сильно отвязано от Android-системы, то что же происходит под капотом?


Litho: под капотом


Возьмем ListItemWithImageSpec, с которым мы уже встречались ранее. В нём три компонента: Row, Image и кастомный ListItem:

@LayoutSpec
object ListItemWithImageSpec { 
    // ...

    Row.create(c)
        .child(
            Image.create(c)
                  .drawable(image))
        .child(
            ListItem.create(c)
                  .title(title)
                  .subtitle(subtitle))
        .build()
  }
}

И чтобы отобразить его на экране, добавим его в LithoView таким образом:

setContentView(LithoView.create(c,
    ListItemWithImage.create(c)
        .image(user.avatar)
        .title(user.name)
        .subtitle(comment.formatDate())
        .build()))

Рендеринг UI-компонента проходит в три основных шага:

  1. Построение Internal Tree — внутреннего представления UI.
  2. Получение LayoutState — набора Drawable и View, которые будут отрисованы на экране
  3. Отрисовка LayoutState на Canvas-е

Построение Internal Tree


Начинаем с корневого компонента ListItemWithImage:

  • Создаем корневой элемент дерева, InternalNode, ассоциируем с ним компонент ListItemWithImage. Так как ListItemWithImage — это фактически просто обертка, то смотрим на его содержимое.
  • Внутри метода onCreateLayout в ListItemWithImageSpec мы первым делом создаем Row. Ассоциируем его с той же самой нодой.
  • У Row 2 потомка: Image и ListItem — для обоих создаются отдельные InternalNode-ы. Image — это листовой элемент дерева компонентов, на этом обработка его поддерева окончена.
  • ListItem в свою очередь тоже компонент-обертка, чтобы добраться до сути смотрим в метод onCreateLayout его спеки. Там мы видим Column, ассоциируем её с той же нодой.
  • У Column есть 2 потомка: Text и Text — создаём для них две новые ноды. Оба элемента листовые — построение Internal Tree окончено.

Получилось следующее дерево:



Тут наглядно видно, что в результате ноды создаются только для листовых компонентов, таких как Image и Text, или для компонентов-контейнеров, таких как Row и Column. Так мы упростили иерархию: было три уровня относительно корня, осталось — два.

Получение LayoutState


Следующим шагом нам нужно создать LayoutState. Для этого сначала измерим Internal Tree с помощью Yoga. Yoga присвоит координаты х, y, а также ширину и высоту каждому узлу дерева. Затем, с учетом этой полной информации, мы создадим список того, что будет непосредственно отрисовано на экране (уже зная, где оно будет отрисовано и какого размера).

Происходит это следующим образом: мы снова обходим Internal Tree, и смотрим, нужно ли отрисовывать каждую следующую ноду. Row не отрисовывается, он фактически нужен был только для измерения и размещения дочерних элементов. Image отрисовывается, поэтому добавляем его LayoutOutput в список. Column тоже нужен был только для того, чтобы померять и расположить элементы и отрисовывать там нечего, а вот Text-ы, как и Image, тоже важны для отрисовки.

Получившийся в итоге список LayoutOutput-ов — это наш LayoutState.

Отрисовка LayoutState


Теперь полученный LayoutState осталось нарисовать на экране. И тут важно подчеркнуть, что в данном примере элементы Image и два Text-a будут отрисованы не с помощью View, а с помощью Drawable. Если мы можем не использовать сложные элементы Android UI Toolkit, если можно обойтись простыми и легковесными примитивами типа Drawable, то эффективнее использовать именно их. Если же какие-то элементы должны уметь реагировать, например, на клики, то они будут отрисованы с помощью View, чтобы переиспользовать всю непростую логику обработки UI-событий.



Шаг отрисовки — это единственный этап, который должен выполняться на UI-потоке, все остальные шаги могут быть выполнены в фоне.

На рассмотренном примере мы познакомились с несколькими ключевыми элементами:

  • @LayoutSpec — компоненты, комбинирующие другие компоненты. В итоге они превращаются в поддеревья в Internal Tree. Аналог кастомных ViewGroup.
  • Row и Column — компоненты-контейнеры, служащие для задания расположения UI-элементов на экране. Это примитивы Flexbox — основного Layout Engine в Litho. А Yoga — это его кроссплатформенная реализация, которая используется не только в Litho, но также и в других библиотеках под Android, под iOS и в Web.
  • @MountSpec — это те самые листовые ноды в Internal Tree — Image, Text и другие. Это второй тип Spec, который описывает примитивные элементы, которые будут отрисованы на экране с помощью Drawable или View.

Как будет выглядеть код кастомной @MountSpec-и? Примерно так:

@MountSpec
object GradientSpec {

  @OnCreateMountContent
  fun onCreateMountContent(context: Context): GradientDrawable {
      return GradientDrawable()
  }

  @OnMount
  fun onMount(
      c: ComponentContext,
      drawable: GradientDrawable,
      @Prop @ColorInt colors: IntArray) {
      drawable.colors = colors
  }
 
}

В данном примере мы берем некоторый массив цветов и отрисовываем созданный на его основе градиент на экране. В андроиде для работы с градиентами есть специальный GradientDrawable. Именно его мы и используем для рендеринга этого компонента. Инстанс данного типа нужно вернуть из специального lifecycle-метода, который помечается аннотацией @OnCreateMountContent и отвечает за создание контента для рендеринга.

Напомню, что компонент, описанный как MountSpec, может отрисовывать всего два типа контента: View или Drawable. В данном простом случае нам достаточно легковесного Drawable. Кроме операции создания контента мы также должны определить метод с аннотацией @OnMount для биндинга с данными перед тем, как компонент станет видимым. В нашем случае данными является тот массив цветов, который мы получаем на вход. Всё остальное Litho берет на себя и отрисовывает GradientDrawable c заданными цветами на экран. Для облегчения понимания можно сравнить методы, помеченные @OnCreateMountContent и @OnMount, с методами RecyclerView.AdapteronCreateViewHolder и onBindViewHolder соответственно.


Аннотация @MountSpec


В аннотации @MountSpec есть два параметра:

  1. poolSize — параметр, который отвечает за то, сколько инстансов данного компонента будет создано заранее и помещено в пул, чтобы потом быстро использовать их при рендеринге интерфейса. По умолчанию этот параметр равен 3.
  2. isPureRender — это булевый флаг, показывающий, что при пересоздании компонента с неизменными значениями Prop-параметров, результат его отрисовки всегда будет оставаться прежним. При обновлении UI-дерева компонентов это позволяет не пересоздавать и не перерисовывать такие «чистые» компоненты.

Конкретные значения будут зависеть от того, что за компонент вы описываете. Рассмотрим в качестве примера ImageSpec — компонент для показа картинки:

@MountSpec (poolSize = 30, isPureRender = true)
class ImageSpec {

  @ShouldUpdate
  static boolean shouldUpdate(...) {}
}


public @interface MountSpec {
  int poolSize() default 3;
  boolean isPureRender() default false;
}

У него очень большой poolSize (30). Cегодня типичная ситуация, когда приложение нагружено картинками, поэтому UI-компоненты для них лучше подготовить заранее в достаточном количестве. В то же время, если входной параметр Drawable, не меняется, то и вывод на экран такого компоненты тоже не поменяется, и, чтобы не делать лишних действий, можно установить флаг isPureRender… В этом случае решение об обновлении компонента принимается на основании сравнения Prop-параметров с помощью equals(), если же вы хотите использовать кастомную логику сравнения, то её нужно поместить в функцию с аннотацией @ShouldUpdate.


Оптимизации в Litho


В Litho есть две ключевые оптимизации при построении Layout:

  1. Layout/Mount Diffing — позволяет переиспользовать размеры (measurements) элементов с предыдущего рендеринга и переиспользовать LayoutOutput-ы (то, что выводится на экран) с предыдущего рендеринга.
  2. IncrementalMount — позволяет превратить ваш UI в RecyclerView на стероидах без каких-либо дополнительных усилий.

Layout/Mount Diffing


Как это работает? При построении Internal Tree для нового компонента, также учитывается оставшееся с предыдущего рендеринга Diff Tree с готовыми размерами и LayoutOutput-ами всех узлов дерева.



Если входящие параметры для некоторого поддерева не изменились, то размеры и LayoutOutput для него просто копируются из Diff Tree в новый Internal Tree, минуя шаг измерения с помощью Yoga. Таким образом, LayoutState готов уже к концу построения Internal Tree.

IncrementalMount


Допустим, у вас есть своя социальная сеть с новостной лентой со сложными UI-элементами, например, постами с большим количеством, деталей. Не хотелось бы, чтобы при прокрутке экрана mount и отрисовка выполнялись для всего тяжелого UI поста, сразу как только первый пиксель покажется из-за края экрана. Если mount такого сложного элемента не уложится в 16мс, то мы будем видеть дерганый UI, особенно при быстрой прокрутке.
mount
Mount — процесс получения контента для рендеринга (View или Drawable) и его добавление в текущую иерархию View

IncrementalMount в этом случае позволяет рендерить новостной пост не целиком, а постепенно, выполняя mount только для тех дочерних примитивных элементов, которые действительно попадают в видимую область экрана. А для тех же элементов, которые покидают её, выполнять unmount и возвращать их в пул, не дожидаясь, пока весь пост скроется за краем экрана, таким образом экономя память. Скролл существенно ускоряется за счёт того, что отрисовка тяжёлого поста разбивается на несколько фреймов. Всё это напоминает работу RecyclerView, но в Litho вам не надо как-то по-особому менять UI или использовать другие компоненты — это работает из коробки.

Выводы на заметку:


Если вы определяете кастомную MountSpec-у, то:

  • можно использовать параметр isPureRender и метод @ShouldUpdate, чтобы не делать лишнюю работу при обновлении UI компонента.
  • зная, в каком объёме компонент будет использован в приложении, вы можете подготовить нужное количество инстансов заранее, настроив размер пула с помощью poolSize.


Управление Состоянием


Классическая работа с декларативным UI, это когда есть входные параметры и есть вывод на экран, но также полезно иметь возможность работать с локальным состоянием конкретно этого компонента.

Рассмотрим простой пример — компонент со счётчиком и кнопкой для его увеличения:



Чтобы реализовать такой компонент, в первую очередь нам необходим State-параметр — count. По нему не создаются Builder-методы, потому что вы не можете предоставить его снаружи, в отличие от значений Prop-ов, и система управляет им изнутри. В данном случае мы используем этот стейт для создания текста с текущим значением.

Затем нам нужен метод для изменения стейта. Он помечается аннотацией @OnUpdateState и на вход принимает все тот же самый стейт, но не в виде неизменяемого значения, а завернутый в холдер StateValue, в котором стейт реально можно поменять.

Наконец, нам надо связать все это с нажатием на кнопку. Для этого есть event-хендлеры: метод с аннотацией @OnEvent определяет обработчик событий определённого типа (в данном случае, кликов), и в нём вызывается сгенерированный метод для изменения стейта — увеличения счётчика.



В данном примере видно, что наборы параметров в описании метода и в месте его вызова не совпадают. Это происходит, потому что вы вызываете метод, определенный в Spec-классе не руками, а неявно, через сгенерированный метод в сгенерированном Component-классе, и все значения, необходимые для Spec-метода (в данном случае, StateValue), Litho подставит сам.

Каждое обновление стейта вызывает тот же эффект, что и передача новых значений Prop-параметров: снова нужно построить Internal Tree, получить LayoutState и отрисовать его на экран.

А что если у нас есть пара переменных для состояния и разные для них методы обновления? Допустим, у нас есть профиль супергероя, в котором нам надо поменять цвет и имя. Мы хотим сменить зеленого Халка на красного Железного Человека. У нас есть две «переменные» состояния — color и name, и мы делаем два обновления путем присвоения переменных.

@OnUpdateState
fun changeColor(color: StateValue<Color>, @Param newColor: Color) {
  color.set(newColor)
}

@OnUpdateState
fun changeName(name: StateValue<String>, @Param newName: String) {
  name.set(newName)
}
...
RootComponent.changeColor(c, Color.RED)
RootComponent.changeName(c, "IronMan")

Если мы посмотрим на работу этого кода в профайлере, то мы увидим, что у нас есть первый блок, который пересчитывает дерево и выводит UI на экран, а потом второй такой же блок. Таким образом, UI отображается два раза, а это значит, что где-то посередине у нас получится красный Халк, но ведь все же знают, что

красного Халка не существует!
Поймали пасхалку? С меня стикер ;)



Отсюда Best Practice: важно помнить про группировку State-апдейтов. Если у вас есть несколько State-переменных, которые вы обычно меняете отдельно в разных методах, но есть ситуации, когда необходимо поменять их вместе, то стоить завести отдельный метод для их модификации целой группой (или несколько методов для разных комбинаций переменных).

@OnUpdateState
fun changeHero(
    color: StateValue<Color>, name: StateValue<String>,
    @Param newColor: Color, @Param newName: String) {
  color.set(newColor)
  name.set(newName)
}
...
RootComponent.changeHero(c, Color.RED, "IronMan")

При профилировании такого метода мы увидим, что остался лишь один блок, который обновляет UI-дерево. Можно провести аналогию с транзакциями: один метод с @UpdateState — одна транзакция по изменению UI.

Отложенное обновление состояния


Часто какие-то части состояния компонента могут не влиять на рендеринг этого компонента.
Вернемся к примеру со счетчиком. Изменим его так, чтобы можно было задавать шаг увеличения count.



Для шага заведем отдельный State-параметр step, в котором будем хранить текущее значение, и сделаем возможность вводить его с клавиатуры в поле TextInput. Так как при изменении этого значения в поле ввода новое число мы увидим сразу, то обновлять UI с новым значением step не надо, но запомнить его необходимо. Для этого надо выставить флаг canUpdateLazily, давая Litho понять, что этот State можно изменять без перестроения UI, «лениво». В этом случае, помимо всех явно определенных @UpdateState методов, которые отвечают за обычные обновления состояния, сгенерируется ещё один метод — lazyUpdateStep, выполняющий как раз такое «ленивое» обновление step. Префикс lazyUpdate общий для всех таких методов, а суффикс (Step) однозначно соответствует имени State-переменной.

@State(canUpdateLazily = true) step: Int

RootComponent.lazyUpdateStep(c, value)

Выводы на заметку



  • не забывайте группировать стейт апдейты, когда вы знаете, какие наборы State-переменных будут меняться вместе.
  • используйте Lazy State-апдейты для State-переменных, которые не влияют на отображение UI.


Анимация в Litho


Давайте теперь перейдем от статического отображения UI к динамическому — как в декларативном API Litho будет выглядеть описание анимации?



Рассмотрим простой пример (видео доклада 28:33-28:44) с кнопкой, которая меняет своё расположение при клике. Она прижимается то к правому, то к левому краю, но происходит это моментально, скачкообразно — в этом случае пользователю не очевидно, что произошло.

Однако мы можем это исправить, добавить контекста и анимировать кнопку. Для этого надо сделать две вещи: надо пометить то, ЧТО мы анимируем, и описать, КАК надо анимировать. Мы двигаем кнопку, поэтому задаем ей свойство transitionKey.

Button.create(c)
    .text(“Catch me!”)
    .transitionKey(“button”)
    .alignSelf(align)
    .clickHandler(RootComponent.onClick(c))

Затем реализуем метод с аннотацией @OnCreateTransition, который создаёт описание анимации изменений между двумя отрисовками этого компонента, Transition между предыдущим и следующим состоянием UI. В данном случае Transition простой: мы создаём его с тем же transitionKey, которым пометили цель анимации (в данном случае, кнопку), и просим проанимировать только изменения горизонтальной координаты цели — координаты X кнопки. В результате оно действительно анимируется (видео доклада 29:25-29.33).

@OnCreateTransition
fun onCreateTransition(c: ComponentContext): Transition {
  return Transition.create("button")
      .animate(AnimatedProperties.X)
}

Такое описание анимации отлично, если вы четко знаете, что нужно анимировать и хотите полного контроля над тем, как это анимировать, но может стать многословным в сложных компонентах. Если же вы хотите проанимировать любые изменения в layout-е и сделать это автоматически, то в Litho есть специальный Transition.allLayout() для этих целей. Это нечто похожее на установку animateLayoutChanges = true для анимации всех изменений в нативной ViewGroup.

@OnCreateTransition
fun onCreateTransition(c: ComponentContext): Transition {
  return Transition.allLayout()
}

Важно подчеркнуть, что метод с @OnCreateTransition определяет, как надо проанимировать изменения между двумя последовательными отрисовками UI. Это значит, что этот метод будет вызван на каждый @OnCreateLayout, даже если ничего анимировать не нужно.

Чтобы самим определять, надо или нет анимировать конкретное изменение компонента, можно использовать Diff для Prop и State-параметров.

Diff — это такой холдер, который содержит одновременно и текущее, и предыдущее значение для конкретного Prop/State, давая возможность их сравнить произвольным методом и сделать вывод, стоит анимировать или нет.



И если вернуть null из @OnCreateTransition, то анимироваться ничего не будет. Более того, будет пропущен весть этап подготовки анимации, что положительно скажется на производительности.

Обратите внимание, что и аннотации, и имена соответствующих Prop/State остаются такими же, как в @OnCreateLayout, меняется лишь тип с T на Diff<T>.

Выводы на заметку


Используйте Diff параметры для более тонкой настройки анимации изменения значений Prop и State.


Пошаговое внедрение


Вряд ли в существующем проекте кто-то решится в одночасье переписать весь UI на Litho. Поэтому возникаю логичные вопросы: можно ли осуществлять внедрение по частям? Могут ли Litho-компоненты жить бок о бок с нативным Android UI? И тут у меня для вас хорошие новости!

Да, ваш сложный UI можно портировать на Litho по частям:

  • С одной стороны можно использовать Litho-компоненты внутри существующего UI на View. Можно последовательно заменять сложные UI-поддеревья в вашей разметке на LithoView с аналогичной иерархией компонентов. Таким образом вы упростите изначальный UI и уменьшите глубину дерева элементов.
  • С другой стороны можно использовать кастомные View — сложные графики, анимированные круговые диаграммы, видео-плееры, которые нелегко переписать на компоненты, — в Litho-интерфейсах. Для этого View нужно обернуть в MountSpec-у (помните, что метод с @OnCreateMountContent может возвращать не только Drawable, но и View?), которую потом легко можно будет включать в иерархию компонентов.


Дебаггинг и тулы в Litho


А что же нам делать, если вдруг что-то не будет работать? Если будут вопросы, то где смотреть примеры? Как отладить интерфейс на Litho? Как быстро верстать и тюнить UI? Обо всём этом ниже.

Yoga playground


Litho использует концепцию и терминологию Flexbox для задания расположения элементов. Если вы с ней не знакомы, то для вас есть Yoga Playground. Это интерактивная песочница, где на схематичном представлении UI с виде прямоугольников можно поиграться с параметрами, подготовить макет вашего UI и даже экспортировать его в виде Litho-кода на Java.

Flipper + Litho plugins


Для Litho, к сожалению, нет поддержки в UI Preview. Стандартный Layout Inspector тоже не сможет показать иерархию компонентов Litho. Всё потому, что эти инструменты работают только с Android View. Но к счастью коллеги из команды UI Tools разрабатывают замечательный инструмент для разносторонней отладки мобильных приложений — Flipper. Layout-плагин для Flipper умеет отображать иерархию UI-элементов интерфейса, который отображается на экране телефона, и распознаёт не только обычные View, но и компоненты Litho. Кроме того, при выделении какого-либо компонента, в боковой панели можно увидеть список свойств Props компонента, большую часть из которых можно менять в реальном времени и проверять изменения на устройстве. Это сильно упрощает финальную подстройку UI, во многом заменяя UI Preview.



Для демонстрации работы плагина взгляните на демку из доклада. Справа сэмпл приложение с простым списком, а слева — Flipper.

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



На данном скриншоте видны изменения UI-элементов и данные, которые этим элементам отвечают. Таким образом легко отладить различные проблемы со списками: поймать излишние обновления UI, проверить, какие элементы были удалены, переиспользованы или добавлены, понять, в чём причина неверного UI — в некорректных данных или в ошибках в самих UI-компонентах.

Litho IntelliJ plugin


В Litho сильно отличающийся от стандартного подход к написанию UI, свои аннотации и lifecycle-методы — много нового. Есть, конечно, документация, но чтобы при написании каждой новой Spec-и не обращаться к ней для уточнения любых вопросов, а стартовать быстро, наша команда также предоставляет IntelliJ / Android Studio плагин. Он добавляет шаблоны для создания LayoutSpec и MountSpec, шаблоны для генерации отдельных lifecycle-методов, а также возможность навигации между Spec-классом и сгенерированным по нему классом компонента.



Плагин можно установить через IntelliJ Plugin Marketplace.

Lithography Sample app


Ну а кроме всего вышеназванного, конечно же в репозитории есть sample-приложение — Lithography. В нём можно посмотреть рецепты по реализации каких-то реальных примеров: создать UI карточки, загрузить картинку из интернета, реализовать Fast Scroll. Есть целые секции по работе со списками, различным способам анимации и так далее.

Один из интересных примеров — Error Boundaries. Это техника, с помощью которой можно на ходу подменять компоненты, которые крэшатся при рендеринге на произвольный компонент. Например, вместо того, чтобы крэшить всё приложение как происходит обычно, вы можете показать ошибку прямо в UI на месте неисправного компонента. Подробнее о возможностях в документации.


Резюме


Ключевые достоинства Litho в том, что обработку UI можно частично проводить на фоновом-потоке, его декларативный API позволяет проще описывать все возможные состояния UI, а при рендеринге получаются более плоские иерархии View. Несмотря на то, что мы все оборачиваем в Row, Column и прочие компоненты, на самом деле рисоваться будут только листовые элементы дерева и каждый пиксель как правило будет рисоваться по одному разу. Incremental Mount предоставляет возможность более гранулярного переиспользования отдельных атомарных MountSpec, а не только целых LayoutSpec компонентов — элементов списка.


Бонус: Litho и Kotlin


С учётом завязки Litho на процессинг аннотаций и кодогенерацию, использование его с Kotlin может дать некоторое замедление сборки, так как KAPT печально известен своей неторопливостью. Ну и чего скрывать, для такого модного молодежного языка, как Kotlin, обилие аннотаций в API не выглядит очень удобно, когда везде правят разнообразные DSL-и. А хотелось бы вот как-то так просто создать UI в одной функции, да может даже прямо в Activity, и там же его в Activity и отрендерить, без плясок с LithoView:

class PlaygroundActivity : Activity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
      val counter by useState { 1 }

      Clickable(onClick = { updateState { counter.value = counter.value + 1 } }) {
        Padding(all = 16.dp) {
          Column {
            +Text(text = "Hello, Kotlin World!", textSize = 20.sp)
            +Text(
                text = "with ${"".repeat(counter.value)} from London",
                textStyle = Typeface.ITALIC)
          }   
        }
      }
    }
  }
}

Так вот всё это реальный код! Пока что Kotlin API находится в активной разработке, но экспериментировать с ним можно уже сейчас — Kotlin-артефакты выкладываются с каждым релизом Litho, а кроме того доступны их Snapshot-версии. Также, вы можете следить за развитием проекта на Github-е.

Настоятельно рекомендую ознакомиться с материалами по ссылкам:


Уже на следующей неделе состоится Mobius 2020 Piter. Там для Android-разработчиков тоже будет много интересного: например, выступит хорошо знакомый им Chet Haase из Google. Многие помнят его по выступлениям на Google I/O, а в этом году I/O отменили, но благодаря Mobius есть шанс всё равно увидеть Чета — и даже лично задать ему вопрос.