Irbisa · Шпаргалка

Производительность и стабильность Compose

Стабильность, пропуск рекомпозиции, фазы, ключи Lazy, Modifier.Node, метрики компилятора

Senior-разработчик · 19 вопросов

  1. 01

    Что вы делаете по умолчанию, ещё до профайлера, чтобы экран на Compose работал быстро?

    Делать композицию дешёвой и редкой: давать lazy-спискам key и contentType, кешировать тяжёлую работу через remember, читать быстро меняющееся состояние как можно позже и как можно ниже по дереву, а оценивать результат только на release-сборке.

  2. 02

    Почему список на Compose дёргается в debug-сборке и плавно скроллится в release, и какую сборку профилировать вместо debug?

    Debug-сборка запускает рантайм Compose без R8 и с выключенными оптимизациями ART, поэтому она может быть в разы медленнее того, что получают пользователи: jank, замеренный там, в release может вообще не существовать.

    // app/build.gradle.kts: release code, debug signing, profileableandroid {  buildTypes {    create("profiling") {      initWith(getByName("release"))          // R8 and release flags      signingConfig = signingConfigs.getByName("debug")…
  3. 03

    Что не так с этим composable и почему экран держит процессор занятым, хотя на нём ничего не меняется?

    @Composablefun Feed(posts: List<Post>) {  var renders by remember { mutableIntStateOf(0) }  Text("Rendered $renders times")  LazyColumn {    items(posts, key = { it.id }) { PostRow(it) }  }  renders++}

    renders++ выполняет обратную запись (backwards write): она меняет состояние, которое уже прочитано выше в этой же композиции.

  4. 04

    Найдите баг: пока баннер сети выезжает, Layout Inspector показывает рекомпозицию ChatScreen на каждом кадре анимации.

    @Composablefun ChatScreen(messages: List<Message>, showBanner: Boolean) {  val bannerOffset by animateDpAsState(    targetValue = if (showBanner) 0.dp else (-64).dp,    label = "banner",  )  Box(Modifier.fillMaxSize()) {    MessageList(messages)    NetworkBanner(Modifier.offset(y = bannerOffset).height(64.dp))  }}

    bannerOffset читается в теле ChatScreen (контент Box выполняется inline), поэтому каждый кадр анимации инвалидирует композицию всего экрана.

  5. 05

    Найдите баг: список контактов не меняется, когда пользователь вводит текст в поле поиска.

    @Composablefun ContactList(contacts: List<Contact>, query: String) {  // filtering is expensive, so cache it  val visible by remember {    derivedStateOf {      contacts.filter { it.name.contains(query, ignoreCase = true) }    }  }  LazyColumn {    items(visible, key = { it.id }) { ContactRow(it) }  }}

    remember без ключей выполняет свой блок один раз, а derivedStateOf пересчитывается только при изменении snapshot-состояния, прочитанного внутри.

  6. 06

    Что именно контролируют key и contentType в LazyColumn и что идёт не так при быстром скролле, если contentType не задан?

    key отвечает за идентичность, а contentType отвечает за переиспользование: без него любая строка подходит под любой освободившийся слот, так что слот из-под текстовой строки достанется строке с фото, и рантайм выбросит всё поддерево вместо того, чтобы обновить его.

    sealed interface FeedItem {  data class Header(val id: String, val title: String) : FeedItem  data class Photo(val id: String, val url: String) : FeedItem  data class Comment(val id: String, val text: String) : FeedItem}…
  7. 07

    Layout Inspector показывает 300 рекомпозиций у шапки, содержимое которой никогда не меняется. Как читать это число и дойти от него до чтения состояния?

    Счётчик рекомпозиций показывает, сколько раз restart scope реально перезапустился, и 300 у статичной шапки означают, что её инвалидировали 300 раз; соседняя колонка Skips говорит, удалось ли рантайму пропустить тело.

    // A count readable without the inspector, in any build:@Composablefun LogRecompositions(tag: String) {  val count = remember { IntArray(1) }  SideEffect { Log.d("recompose", "$tag = ${++count[0]}") }}…
  8. 08

    Экран ленты перекомпонуется гораздо чаще, чем должен. Как заставить сам Compose-компилятор назвать виновный параметр?

    Включите отчёты и метрики компилятора в блоке composeCompiler, соберите тот вариант, который выпускаете, и откройте <module>-composables.txt: там каждый composable напечатан с заголовком restartable/skippable, а каждый параметр помечен как stable или unstable.

    // build.gradle.ktscomposeCompiler {  // ./gradlew assembleRelease -PcomposeReports  if (project.findProperty("composeReports") != null) {    reportsDestination = layout.buildDirectory.dir("compose_reports")    metricsDestination = layout.buildDirectory.dir("compose_metrics")…
  9. 09

    Что делает тип стабильным в Compose и что на самом деле обещают @Stable и @Immutable?

    Тип стабилен, когда Compose может доверять equals в вопросе «изменился ли он», а любое изменение публичного свойства сообщается через snapshot-состояние; @Immutable обещает, что не меняется ничего, а @Stable обещает, что изменения идут через snapshot-состояние.

    // <module>-classes.txt from the compiler report://   unstable class Article {//     stable val id: String//     unstable val tags: List<Tag>    <- this field makes the class unstable//   }…
  10. 10

    Strong skipping включён по умолчанию с Compose-компилятора из Kotlin 2.0.20. Что он изменил и что по-прежнему не чинит?

    Strong skipping делает пропускаемым любой restartable-composable: нестабильные параметры сравниваются по идентичности ссылки вместо полного отказа от пропуска, а лямбды мемоизируются автоматически.

    data class Article(val id: String, val tags: List<Tag>)   // unstable param @Composablefun Feed(articles: List<Article>) {  LazyColumn {    items(articles, key = { it.id }) { article ->…
  11. 11

    progress меняется каждый кадр. Почему Controls тоже рекомпозируется каждый кадр и как это исправить, не получив устаревший колбэк?

    @Composablefun Player(progress: Float, onSeek: (Float) -> Unit) {  Text("At ${progress.toInt()} s")  Controls(onSkip = { onSeek(progress + 10f) })}

    Strong skipping мемоизирует лямбду, но с ключом по тому, что она захватывает: progress каждый кадр новый, значит, и лямбда каждый кадр новая, и Controls видит изменившийся параметр.

  12. 12

    Что плагин Compose-компилятора делает с @Composable-функцией и что такое $composer, $changed, группы и slot table?

    Он переписывает каждый composable в обычный код со скрытыми $composer и битовой маской $changed, оборачивает тело в группы slot table и добавляет логику пропуска и перезапуска; так Compose запоминает значения по позиции.

  13. 13

    Когда wide переключается (складной телефон раскладывается, меняется размер окна), Column меняется на Row, и видеоплеер начинает с нуля. Как перенести поддерево к новому родителю, не потеряв состояние?

    Состояние в Compose определяется позицией в композиции, поэтому вызов того же composable из другого места вызова создаёт другую группу: старая уничтожается, новая строится с нуля.

    // Two call sites: when `wide` flips, the player is disposed and rebuilt@Composablefun ScreenBroken(wide: Boolean, url: String) {  if (wide) Row { Player(url); Details() }  else Column { Player(url); Details() }}…
  14. 14

    Ваш собственный Modifier.pulsingBorder() написан через composed { }. Чего это стоит и что изменится, если переписать его на Modifier.Node?

    Модификатор на composed { } представляет собой composable, который выполняется на каждое место применения при каждой композиции, а поскольку два экземпляра composed никогда не равны, он вдобавок ломает сравнение, которым цепочка модификаторов могла бы пропустить работу.

    // composed: a composition group per usage, no equals; the read of `alpha`// in the factory body invalidates compositionfun Modifier.pulsingBorderSlow(color: Color) = composed {  val alpha by rememberInfiniteTransition(label = "pulse")    .animateFloat(0f, 1f, infiniteRepeatable(tween(800)), label = "alpha")  Modifier.border(2.dp, color.copy(alpha = alpha))   // read in the factory body…
  15. 15

    Кто-то поставил BoxWithConstraints внутрь каждого элемента LazyColumn, и скролл стал хуже. Чего на самом деле стоит субкомпозиция и что надо было использовать?

    BoxWithConstraints построен на SubcomposeLayout: он переносит композицию своих детей в проход измерения, поэтому каждая строка теперь композируется во время layout, при каждом измерении, а не один раз при композиции.

    // A SubcomposeLayout per row: composition moves into the measure passLazyColumn {  items(posts, key = { it.id }) { post ->    BoxWithConstraints {      if (maxWidth > 600.dp) WideRow(post) else NarrowRow(post)    }…
  16. 16

    Отчёт компилятора говорит, что строка ленты не skippable из-за модели из стороннего SDK. Какие есть варианты и чего стоит каждый?

    Есть три реальных варианта: объявить класс стабильным в stability configuration file, отобразить его в собственный тип или подключить Compose-компилятор к модулю, который его производит.

    // build.gradle.ktscomposeCompiler {  stabilityConfigurationFiles.add(    rootProject.layout.projectDirectory.file("compose_stability.conf")  )  reportsDestination = layout.buildDirectory.dir("compose_reports")…
  17. 17

    Свежеустановленная сборка лагает на первом скролле списка на Compose и идеально гладкая на всех последующих. Что делает рантайм и что это реально чинит?

    На первом проходе всё холодное: ART интерпретирует код, который никогда не компилировался, классы загружаются на потоке кадра, а slot table, узлы layout и кеши строятся с нуля.

    // Generate the profile for THIS journey, not just for cold start@RunWith(AndroidJUnit4::class)class FeedBaselineProfile {  @get:Rule val rule = BaselineProfileRule()   @Test fun feedScroll() = rule.collect(packageName = "com.example.app") {…
  18. 18

    Счётчики рекомпозиций чистые, но кадры всё равно опаздывают, а время висит на RenderThread. Где Compose-интерфейс обычно жжёт время GPU?

    Ищите offscreen-буферы и fill rate: alpha или blur, заставляющие рендерер выделить слой, тени и непрямоугольные обрезки, а также наслоенные непрозрачные фоны, которые красят одни и те же пиксели по несколько раз.

    // An offscreen buffer for a fade that did not need oneBox(Modifier.alpha(fade)) { Row { Icon(icon, null); Text(label) } } // Children do not overlap -> modulate alpha per draw op, no offscreen bufferBox(  Modifier.graphicsLayer {…
  19. 19

    Как не дать регрессии производительности Compose попасть в main и что делает число со скролла достаточно стабильным, чтобы валить на нём pull request?

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