Strong Skipping — новый режим контроля стабильности классов в Jetpack Compose — меняет правила игры при оптимизации рекомпозиций в приложении. В этой статье  выясним, какие случаи он решает за разработчика, а какие необходимо контролировать вручную. Кроме того, поищем ответы на часто возникающие вопросы, например нужно ли по-прежнему помнить о лямбда-функциях, нужны ли неизменяемые коллекции kotlinx и как стабилизировать все классы доменной модели. Для уточнения понятия стабильности можно обратиться к документации Compose

Стабильность до введения режима Strong Skipping 

Компилятор Compose может считать класс нестабильным, если:

  • это мутабельный класс, например он содержит мутабельное свойство (не поддерживаемое состоянием снапшота);
  • это класс, определенный в модуле Gradle, который не использует Compose (не имеет зависимости от компилятора Compose);
  • это класс, содержащий нестабильное свойство (вложенность нестабильности).

Рассмотрим следующий класс:

data class Subscription(          // нестабилен
val id: Int, // стабилен
val planName: String, // стабилен
val renewalOn: LocalDate // нестабилен
)

Свойства id и planName стабильны, поскольку содержат примитивный тип, который является иммутабельным. Однако свойство renewalOn нестабильно, потому что java.time.LocalDate взято из стандартной библиотеки Java, которая не имеет зависимости от компилятора Compose. Из-за этого весь класс Subscription является нестабильным.

Рассмотрим следующий пример со свойством state, которое использует классом Subscription, передаваемый в SubscriptionComposable:

// создание в держателе состояния (например, ViewModel)
var state by mutableStateOf(Subscription(
id = 1,
planName = "30 days",
renewalOn = LocalDate.now().plusDays(30)
))

@Composable
fun SubscriptionComposable(input: Subscription) {
// всегда перекомпоновывается независимо от того, изменились ли входные данные или нет
}

Исторически сложилось так, что  Composable-функция с параметром input этого нестабильного класса не определялась как пропускаемая, и всегда перекомпоновывалась независимо от того, менялись ли входные параметры или нет.

Стабильность в режиме Strong Skipping 

Компилятор Jetpack Compose 1.5.4 и выше поставляется с опцией включения режима Strong Skipping, который всегда генерирует логику пропуска независимо от стабильности входных параметров. Этот режим позволяет пропускать Composable-функции с нестабильными классами. Подробнее о режиме Strong Skipping и о том, как его включить, можно прочитать в документации Compose.

Режим Strong Skipping имеет два способа определения того, изменился ли входной параметр по сравнению с предыдущей Composable-функцией:

  • если класс стабилен, используется структурное равенство (.equals());
  • если класс нестабилен, используется ссылочное равенство (===).

Включение режима Strong Skipping в проекте приведет к тому, что Composable-функции, использующие нестабильный класс Subscription, не будут перекомпоновываться, если экземпляр такой же, как и в предыдущей Composable-функции.

Допустим, вы используете SubscriptionComposable в другой Composable-функции Screen, которая принимает параметр inputText. Если параметр inputText изменится, а параметр subscription — нет, SubscriptionComposable не будет перекомпонована и будет пропущена:

@Composable
fun Screen(inputText: String, subscription: Subscription) {
Text(inputText)

// пропускается, если параметр subscription не изменился
SubscriptionComposable(subscription)
}

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

fun renewSubscription() {
state = state.copy(renewalOn = LocalDate.now().plusDays(30))
}

Функция copy создает новый экземпляр класса с теми же структурными свойствами (если это происходит в тот же день). А значит, SubscriptionComposable будет снова перекомпонована, потому что режим Strong Skipping сравнивает нестабильные классы с помощью ссылочного равенства (===), а copy создает новый экземпляр Subscription. Несмотря на то что дата одна и та же, из-за использования ссылочного равенства Subscription все равно перекомпонуется.

Контроль стабильности с помощью аннотаций

Чтобы предотвратить перекомпоновку SubscriptionComposable, когда структурные данные не меняются (equals() возвращает тот же результат), нужно вручную пометить класс Subscription как стабильный.

В данном случае это просто исправить, аннотировав класс с помощью @Immutable, поскольку представленный здесь класс не может быть мутирован:

+@Immutable           
-data class Subscription( // нестабильный
+data class Subscription( // стабильный
val id: Int, // стабильный
val planName: String, // стабильный
val renewalOn: LocalDate // нестабильный
)

В этом примере при вызове renewSubscription SubscriptionComposable снова будет пропущена, потому что теперь она использует функцию equals() вместо ===, которая вернет true по сравнению с предыдущим состоянием.

Когда необходимы подобные аннотации?

Реальный пример того, когда может понадобиться аннотировать классы как @Immutable, — это использование сущностей, поступающих из периферийных устройств системы, таких как сущности баз данных, сущности API, изменения Firestore и другие.

Эти сущности каждый раз парсятся из базовых данных и каждый раз создают новые экземпляры. Поэтому без аннотации они будут перекомпоновываться.

Примечание: перекомпоновка может быть быстрее, чем вызов equals() для каждого параметра. При оптимизации стабильности всегда следует измерять эффект от изменений.

Контроль стабильности с помощью файла конфигурации стабильности

Классы, которые не являются частью кодовой базы проекта, раньше рекомендовалось стабилизировать только обертыванием класса в класс, являющийся частью кодовой базы, и аннотировать этот класс как @Immutable.

Рассмотрим пример, в котором есть компонент, непосредственно принимающий параметр java.time.LocalDate:

@Composable
fun LatestChangeOn(updated: LocalDate) {
// представление параметра day на экране
}

Если вызовем функцию renewSubscription для обновления последнего изменения, то окажемся в ситуации, аналогичной прежней, — Composable-функция LatestChangeOn продолжает перекомпоновываться, не учитывая, тот же это день или нет. Однако в такой ситуации нет возможности аннотировать этот класс, поскольку он является частью стандартной библиотеки.

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

Чтобы включить его, добавим stabilityConfigurationFile в конфигурацию composeCompiler:

composeCompiler {
...

// Задайте путь к файлу конфигурации
stabilityConfigurationFile = rootProject.file("stability_config.conf")
}

И создадим файл stability_config.conf в корневой папке проекта, куда добавим класс LocalDate:

// добавьте неизменяемые классы за пределами вашей кодовой базы
java.time.LocalDate

// в качестве альтернативы можете стабилизировать все классы java.time с помощью *
java.time.*

Стабилизация классов доменной модели

Кроме классов, которые не являются частью кодовой базы проекта, файл конфигурации стабильности может быть полезен для стабилизации всех классов данных или доменной модели (при условии, что они неизменяемы). Таким образом, модуль домена может быть модулем Java Gradle, не нуждаясь в зависимости от компилятора Compose.

// стабилизируйте все классы в пакете model
com.example.app.domain.model.*

Помните о нарушении правил

Помните, что аннотирование мутабельного класса аннотацией @Immutable или добавление класса в файл конфигурации стабильности может стать источником ошибок в кодовой базе, поскольку компилятор Compose не способен проверить контракт и что-то может не перекомпоноваться, как ожидалось.

Забудьте об оборачивании лямбд с помощью remember() 

Еще одно преимущество режима Strong Skipping заключается в том, что он «запоминает» все лямбды, используемые в композиции, даже те, которые имеют нестабильные захваты данных. Раньше лямбды, использующие нестабильный класс, например ViewModel, могли стать причиной перекомпоновки. Одним из распространенных обходных путей было запоминание лямбда-функций.

Поэтому, если в вашей кодовой базе есть лямбды, обернутые с помощью remember, можете смело удалять вызов remember, поскольку это делается автоматически компилятором Compose:

Screen(
-removeItem = remember(viewModel){ { id -> viewModel.removeItem(id) } }
+removeItem = { id -> viewModel.removeItem(id) }
)

Нужны ли еще иммутабельные коллекции?

Коллекции kotlinx.collections.immutable, такие как ImmutableList, могли использоваться в прошлом для того, чтобы сделать список элементов (List) стабильным и тем самым предотвратить перекомпоновку Composable-функции. Если вы используете их в кодовой базе исключительно для предотвращения перекомпоновки Composable с параметрами List, рассмотрите возможность рефакторинга их в обычный List и добавьте java.util.List в файл конфигурации стабильности.

Возможность проблем с производительностью

После рефакторинга компонент может работать медленнее, чем если бы параметр List был нестабильным!

Добавление List в файл конфигурации стабильности означает, что параметр List сравнивается с вызовом equals, и в конечном итоге приводит к вызову equals для каждого элемента этого списка. В контексте ленивого списка та же самая проверка equals затем вызывается снова с точки зрения элемента Composable, что приводит к вычислению вызова equals() дважды для многих видимых элементов и, возможно, без необходимости для всех элементов, которые не видны!

Если компонуемый элемент, содержащий параметр List, не содержит много других компонентов пользовательского интерфейса, его перекомпоновка может быть быстрее, чем вычисление проверки equals().

Однако здесь нет универсального подхода, поэтому следует определиться с выбором с помощью бенчмарков!

Резюме

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

Надеемся, эти изменения снизят умственную нагрузку при решении проблем со стабильностью в Compose.

Хотите больше? Загляните в лабораторию кода Compose, чтобы ознакомиться с практическими примерами решения проблем производительности в Compose.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Tomáš Mlynarič: New ways of optimizing stability in Jetpack Compose

Предыдущая статья14 вопросов по валидациям на Ruby on Rails
Следующая статьяРоль метода Stream.map() в Java