Разработчик ПО
Jetpack Compose упрощает разработку качественных пользовательских интерфейсов, но отслеживать проблемы, влияющие на их производительность, может быть сложно. Это может привести к проблемам, которые заметят пользователи, например к задержке запуска и пропущенным кадрам, из-за которых прокрутка и анимация будут выглядеть прерывистыми.
В этом докладе мы расскажем об инструментах, которые помогут вам понять, что влияет на производительность интерфейса. Особое внимание мы уделим Perfetto и отслеживанию рекомпозиции. Затем мы рассмотрим конкретные антипаттерны, которые вы, возможно, используете и которые могут незаметно увеличивать ваши расходы, а также расскажем, как найти их в коде и какими паттернами их можно заменить. Вы узнаете, как создавать приложения с помощью Compose, учитывая производительность, а также как профилировать, анализировать и проверять оптимизацию производительности в приложении.
Расшифровка
Здравствуйте!
Здравствуйте!
Надеемся, вы успели отдохнуть.
Надеюсь, вы выпили кофе, нанесли крем и все такое.
Сегодня мы подробнее рассмотрим
Да, давайте продолжим.
Сегодня мы погрузимся в тему немного глубже.
Меня зовут Андрей Шиков, и сегодня со мной на сцене Эндрю Бейли.
Мы оба работаем в команде, которая занимается компилятором Compose, а также многими другими вещами, включая производительность.
Сегодня мы поговорим о производительности, поскольку Jetpack Compose стал набором инструментов для разработки пользовательского интерфейса Android по умолчанию.
И сейчас многие спрашивают, как сделать так, чтобы интерфейс Compose был максимально производительным.
Сегодня мы подробно разберем эту тему.
Мы также расскажем об инструментах, которые помогут вам превзойти самих себя в Compose.
Для начала я хочу рассказать, почему производительность так важна для нас.
Когда вы думаете о качестве и создании качественных приложений, вы обычно думаете об анимации, мы думаем о богатом наборе функций, и производительность иногда ускользает, но восприятие пользователя действительно зависит от производительности вашего приложения.
Поэтому, если анимация заикается, восприятие сразу падает.
Поэтому все показатели и цифры, о которых мы сегодня будем говорить, – это прокси-метрики, которые мы выбрали, чтобы понять, как пользователи относятся к приложению, анимации и всему, что происходит на экране.
Поэтому оптимизация приложения – это, по сути, улучшение его работы.
Это просто образ мышления, который я хочу, чтобы у вас был, когда мы сегодня углубимся во многие темы.
Теперь, когда вы начинаете анализировать эффективность своего приложения, я хочу, чтобы вы сделали три быстрых действия.
Во-первых, если вы измеряете производительность, вы измеряете ее в режиме выпуска, поэтому у нас не так много отладочной информации, и все это замедляет работу нашего приложения.
Во-вторых, включите R8 или другие минификаторы.
Только в этом году мы представили оптимизацию, которая вдвое повышает производительность сопрограмм, и мы также делаем много других вещей.
При оптимизации Compose мы во многом полагаемся на R8.
И последнее, особенно для показателей запуска, вы должны убедиться, что используете базовый профиль.
Поэтому классы, которые вы загружаете при запуске, предварительно компилируются и загружаются при запуске этого приложения.
Как видите, разница с обычной отладочной сборкой приложения составляет около шести раз.
И, когда вы измерите все эти вещи, они будут сочетаться с другими победами, которые вы делаете внутри своего кода.
Вы все это сделали, но приложение по-прежнему немного тормозит или есть другие проблемы с производительностью. Вот тут-то и нужно запустить цикл оптимизации производительности.
Вы хотите начать изучать некоторые показатели по конкретным сценариям.
Например, если приложение загружается слишком долго, вы можете перезапустить его, запустить из панели запуска и проверить, сколько времени требуется для перехода к первому экрану с данными.
Или, может быть, приложение немного заикается, поэтому вы прокручиваете его и убеждаетесь, что эта прокрутка повторяема и стабильна.
Поэтому стабильная среда, показанная на этом слайде, действительно важна, потому что, когда вы будете проводить оптимизацию позже, вы захотите убедиться, что видите улучшения в своих показателях, и вы сможете подтвердить, что эти оптимизации действительно работают так, как задумано.
Для этого очень хорошо подходят тесты производительности, потому что они уже собирают много данных, они делают много итераций, чтобы убедиться, что на вашем устройстве не так много шума, и они также делают много настроек устройства, которые вам помогают.
Кроме того, контрольные показатели по умолчанию являются повторяемыми сценариями, и это то, к чему мы здесь стремимся.
Но вы также можете сделать это с помощью нескольких скриптов, если хотите.
И последнее: вы профилируете, улучшаете что-то в приложении, смотрите на данные, а затем повторяете снова и, возможно, повторяете снова с другими показателями сценария, в которых ваше приложение испытывает трудности.
Первый инструмент, который мы сегодня представим, – это трассировка Perfetto.
Трассировки Perfetto записывают все, что происходит на устройстве во время записи этой трассировки, например, во время запуска макротеста или, возможно, какого-то скрипта, который вы записываете.
и очень помогает всем инженерам, которые работают над производительностью Android.
Я, например, большую часть рабочего дня провожу за изучением трассировок Perfetto.
Они выглядят примерно так.
Это большой объем данных.
Как я уже говорил, они фиксируют все, что происходит на устройстве.
Поэтому поначалу вам может быть трудно понять, где именно находится ваше приложение, и я рекомендую использовать эту удобную строку поиска.
Поищите Compose, пакет приложения и т. д.
Просто заземлитесь в знакомых вещах.
И когда вы заземлитесь и найдете процесс своего приложения, вы увидите временные рамки формирования кадра и то, сколько времени занимают многие вещи в вашем приложении.
Если мы изолируем один из них, вы увидите что-то вроде этого.
Первый – это работа основного потока.
Здесь Compose выполняет большую часть своей работы.
Затем идет RenderThread.
Здесь мы обрабатываем команды canvas и кодируем их для графического процессора.
Затем графический процессор выполняет свою работу, а система объединяет результаты.
Сегодня мы будем рассматривать только основной поток.
Потому что именно здесь Compose выполняет свою работу.
Мы говорим о Compose, поэтому разумно сосредоточиться только на этом.
И когда мы изолируем этот один кадр в основном потоке, мы начинаем видеть срезы Compose.
Я выбрала для вас несколько.
Это как композиция, измерение, макет, рисунок.
Если вы слышали о фазах Compose, вы, вероятно, увидите их, когда будете анализировать производительность.
В трассировке Perfetto они представлены отдельными фрагментами, поэтому вы легко можете узнать, сколько времени занимает каждая фаза и какие фазы выполняются в каждом конкретном кадре.
Итак, мы много и подробно говорили об оптимизации некоторых из этих работ, и вы хотите убедиться, что выполняете правильную работу на правильном этапе.
Например, если у вас есть анимация, обновляющая цвет вашего поля или что-то в этом роде, вы хотите сделать недействительной только фазу рисования, а не композицию, макет и тому подобное.
Это основы эффективности, и сегодня мы поговорим об этом подробнее, или мы углубимся в некоторые вещи, и мы сосредоточимся, в частности, на композиции.
Теперь передаю слово Эндрю, который расскажет об этом подробнее.
Спасибо, Андрей.
Как уже говорил Андрей, мы много времени уделили обсуждению оптимизации производительности, например избеганию обратного чтения, откладыванию чтения состояния до нужного этапа жизненного цикла композиции, оптимизации и обеспечению того, чтобы composable-функции становились недействительными, как ожидается, чтобы не выполнять слишком много работы в каждом кадре.
Это отличная отправная точка, но у нас уже есть много материалов на эту тему, и мы хотим немного продвинуться вперед.
Сегодня мы поговорим о том, что остается в композиции после оптимизации, и о том, что делают сами composable-функции.
К сожалению, все, что вы добавляете в код Compose и в приложение, влияет на производительность.
Никакой код в вашем приложении не является бесплатным, и даже такие тривиальные вещи, как чтение состояния и создание таких вещей, как побочный эффект внутри вашей композиции, связаны с некоторыми ощутимыми затратами, и важно помнить о них, особенно когда вы начинаете анализировать, что на самом деле делают ваши компонуемые объекты, и начинаете выжимать больше производительности внутри самого вашего приложения.
Чтобы показать, как это выглядит на микроскопическом уровне, вот некоторые из самых мелких операций, которые вы можете выполнять в любом месте своего кода, и особенно внутри composable-функций. На этом уровне мы говорим о наносекундном масштабе с точки зрения того, сколько примерно стоят эти вещи.
Если вы хотите повысить эффективность, у вас есть два варианта.
Вы можете удалить либо большое количество недорогих операций, либо меньшее количество более дорогих.
На этой диаграмме сопрограммы – это более ресурсоемкие операции, и это хороший пример того, с чего начать, потому что, если мы удалим несколько таких экземпляров, мы сможем быстрее увидеть более ощутимые преимущества в нашем приложении.
Ещё одна вещь, которую следует иметь в виду в отношении сопрограмм, заключается в том, что, как я показал на слайде, не только запуск сопрограммы связан с затратами, но и ее отмена.
Стоимость отмены особенно важна, поскольку она в основном возникает, когда вы удаляете контент и, возможно, переходите в другие разделы приложения.
Переход на другую страницу – это очень ресурсоемкая операция во многих приложениях, где нужно выжать максимум из времени формирования кадра и убедиться, что вы используете большую часть того, что у вас есть.
Поэтому важно учитывать оба этих фактора.
Все эти действия, независимо от того, что мы рассматриваем, будут складываться в этот масштаб, составляющий около 16 миллионов наносекунд.
У нас есть бюджет, который нужно уменьшить, и сопрограммы – хороший пример для начала.
В Compose сопрограммы чаще всего появляются вместе с API эффектов.
Существует целый спектр API, которые можно использовать с SideEffects, от высокой функциональности до низкой задержки.
На самом функциональном конце этого спектра у нас есть LaunchedEffect, который включает в себя все для асинхронной работы, особенно анимацию, все, для чего вам нужна область сопрограммы.
Если же вам это не нужно и вы можете решить свою задачу с помощью менее дорогого API, возможно, стоит использовать его.
Чтобы показать, как это выглядит на практике, я создал composable-функцию медиапроигрывателя, которая демонстрирует все побочные эффекты и время их появления.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}Начнем с LaunchedEffect.
В этом примере он используется для асинхронной загрузки данных в медиапроигрыватель.
Если вам нужна область сопрограммы, сопрограммы в целом или приостанавливающий вызов, используйте LaunchedEffect.
Хотя это по-прежнему более дорогой вариант и с сопрограммами связаны определенные затраты, если вы используете его и обосновываете это, используйте правильный API для работы.
Но если вы используете LaunchedEffect, но не используете сопрограмму, возможно, стоит рассмотреть другие API, которые могут делать то же самое, но с меньшими затратами.
Далее по спектру мы видим другие примеры побочных эффектов, таких как DisposableEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}DisposableEffect отлично подходит, если у вас есть процесс настройки и удаления, связанный с вашим эффектом.
В том же примере с медиапроигрывателем я заблокировал ориентацию действия в альбомном режиме, когда composable-функция присутствует, и разблокировал ее, когда она исчезает.
Если вам нужен однократный эффект LaunchedEffect и DisposableEffect, но не нужны сопрограммы и эффект удаления, вы также можете использовать новый API SideEffect с ключами, который мы представили в Compose 1.12.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}Это отличная альтернатива двум другим API, если вы уже использовали их. Независимо от того, что вы выбрали – LaunchedEffect для удобства или DisposableEffect для однократного эффекта, – переход на SideEffect с ключами API позволит добиться того же результата с меньшими затратами и более высокой производительностью, особенно если вы переходите несколько раз в течение всего приложения.
И, наконец, самый дешевый API – это просто стандартный API SideEffect.
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}Это наиболее уместно, когда вы хотите синхронизировать внешнее состояние мира с тем, что происходит в композиции.
Итак, здесь, опять же на примере медиаплеера, у меня есть ещё один пример, который показывает синхронизацию некоторого состояния воспроизведения с некоторым параметром, который принимается композицией.
Если вы решите проверить использование побочных эффектов и перейти на менее ресурсоемкий, помните, что помимо других вариантов поведения и использования, о которых мы говорили, существует также разница в гарантиях упорядочения с побочными эффектами.
Сначала выполняются все одноразовые эффекты, затем обычные побочные эффекты, с ключом или без него, а затем, после завершения кадра, запускаются все запущенные эффекты, поскольку задействована диспетчеризация.
Если вы используете это время для запущенных эффектов, учитывайте это при переходе.
При любом переносе обязательно протестируйте код и убедитесь, что вы не зависите от каких-либо из этих гарантий времени и что ваш код по-прежнему работает.
Если вы не можете уменьшить сложность побочного эффекта, вы можете уменьшить количество побочных эффектов, объединив их.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}Это особенно важно, если у вас есть несколько эффектов, использующих один и тот же ключ.
Их можно объединить, поскольку они могут выполняться одновременно.
В этом примере у меня есть несколько запущенных эффектов, каждый из которых выполняет асинхронный вызов в некоторую модель представления.
Если я заменю их одним LaunchedEffect, а затем другими вызовами запуска внутри него, даже если у меня по-прежнему будет работать то же количество сопрограмм, это все равно будет иметь меньше накладных расходов, потому что это выводит работу из композиции в сопрограммы.
@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}Поэтому в таких ситуациях рекомендуется рассмотреть возможность переноса, чтобы по возможности уменьшить количество эффектов.
Эффекты и сопрограммы – это лишь один из примеров того, что можно проверить в приложении. Каждая composable-функция и каждое приложение уникальны и предоставляют разные возможности.
Чтобы понять, что актуально для вашего приложения, нам нужно поговорить о следующем инструменте, который представит Андрей, – трассировке композиции.
Теперь
Итак, трассировка композиции. Если вы посмотрите на это точное воссоздание кадра Perfetto, который мы видели ранее на другом слайде, вы увидите, что он дает нам информацию о времени с разными фазами, но на самом деле не дает нам много подробностей о том, что происходит.
Что?
Да, что происходит внутри композиции, какие composable-функции выполняются.
И в этом нам помогает трассировка композиции.
Поэтому мы вводим некоторые маркеры в компилятор Compose, которые в основном аннотируют каждую функцию и делают ее видимой в трассировке Perfetto, чтобы вы могли проверить, сколько времени занимает каждая функция.
Вы можете использовать это, чтобы выяснить, какие именно функции рекомпонуются.
Однако это требует дополнительных ресурсов, поэтому, если вы хотите получить максимально точные данные об эффективности, используйте обычный Perfetto или просто запустите приложение без Perfetto и используйте другие показатели.
Но отслеживание композиции действительно полезно, если вы хотите выяснить, сколько времени занимает каждая отдельная компонуемая функция.
Иногда вам также нужно выяснить, почему происходит рекомпозиция. Именно эту проблему мы пытаемся решить в следующем выпуске Compose Tracing runtime tracing, а точнее, в версии 1.13.
Мы представляем трассировку рекомпозиции, которая аннотирует каждое считывание и запись состояния, приведшие к рекомпозиции.
В результате в трассировке Perfetto вы увидите что-то подобное. Здесь показаны все записи состояний и стеки вызовов, в которых происходит запись и чтение. Вы можете сопоставить их с фактическими строками в коде, где осуществлялся доступ к этим состояниям или их изменение.
Если вы хотите узнать больше, перейдите по ссылке.
В ней есть вся документация по отслеживанию композиции и рекомпозиции.
Теперь Эндрю расскажет, как использовать это более эффективно, чтобы избежать записи состояния.
Спасибо, Андрей.
На основе результатов этого исследования вы можете обнаружить, что в вашей композиции есть недействительные элементы, которые не нужны для вашего приложения и которых можно было бы избежать в первую очередь.
Вот, например, самый распространенный пример, который у меня есть, – это кнопка прокрутки вверх, которая зависит от видимости или прогресса прокрутки для ее видимости.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}Здесь, поскольку я считываю значение состояния прокрутки внутри композиции, это состояние меняется очень часто, оно может меняться даже один раз за кадр, и это приведет к множеству рекомпозиций, которых можно было бы избежать, поскольку это условие, даже несмотря на то, что само состояние прокрутки меняется, фактическое условие, которое нас здесь волнует, меняется не так уж часто.
В таких случаях мы рекомендуем использовать вычисляемое состояние, чтобы избежать аннулирования из-за изменений состояния, которые не имеют значения для вашего приложения.
В этом примере я поместил вычисление в вычисляемое состояние. Когда мы считываем это состояние, вместо того чтобы признавать недействительным на основе позиции прокрутки, оно условно признает недействительной эту composable-функцию на основе всего условия и выводит его из другого состояния.
@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}Если вы уже видели этот шаблон, скорее всего, вы видели, как он используется с производным состоянием.
ComputedState – это новая функция в Compose 1.13. Хотя ComputedState и DerivedState имеют схожую цель – условную недействительность при изменении их вычислений, они имеют очень тонкое различие в том, как они это делают.
DerivedState, который мы поставляем с самого начала Compose, кэширует вычисляемое значение и повторно использует его до тех пор, пока не изменятся его зависимости.
При использовании ComputedState значение вычисляется каждый раз, когда вы его считываете.
Кеширование может быть полезным, но во многих ситуациях оно обходится дороже, чем просто чтение результата.
Чтобы понять, как это происходит, мы можем посмотреть на пример того, как эти состояния могут использоваться внутри вашего приложения, и разобраться, что на самом деле делает каждое из них.
Здесь у меня то же состояние, которое примерно отражает то, что мы делали в кнопке прокрутки вверх, где у него есть некоторое условие, основанное на каком-то другом состоянии, и у меня оно есть и как DerivedState, и как ComputedState.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valueЗатем я дважды считываю состояние, и оба раза мы получаем одно и то же значение, но то, что происходит под капотом, в этих двух случаях различается.
Чтобы упростить трассировку того, что на самом деле будет отображаться здесь в чем-то вроде Perfetto, мы можем изучить, что на самом деле происходит, когда вы читаете оба этих состояния.
Сначала мы вызываем computedState.value.
Она выполнит лямбда-функцию вычисления, которая затем считает значение состояния, а также немного поработает, чтобы просто уведомить о том, что чтение произошло.
Это будет верно для всех состояний снимков в Compose.
Второе чтение выполняется точно так же, как и первое, и имеет те же характеристики производительности.
Для сравнения, первое чтение DerivedState делает то же самое, где ему все равно нужно вызвать лямбду вычисления и все равно сделать другое уведомление, но у него есть небольшие накладные расходы на управление кэшем, которые немного сокращаются в первый раз, потому что нет кэша для проверки.
Поэтому он выполняет небольшое обслуживание самого кэширования, а затем выполняет то же самое за чуть большее время.
Однако второе чтение для DerivedState на самом деле выигрывает у второго чтения для ComputedState, хотя оба эти состояния довольно тривиальны.
Как я уже говорил на предыдущих слайдах, стоимость чтения снимка в некоторых случаях нетривиальна, и чтение из кэша на самом деле происходит быстрее, чем само чтение, поэтому мы видим этот очень небольшой выигрыш для второго чтения для DerivedState.
Но самое интересное происходит, когда состояние нужно аннулировать.
Это так называемый сценарий чтения после записи, когда вы обновляете одно из состояний, используемых в вычислении, и вычисление должно быть выполнено снова.
В коде это выглядит так: мы что-то делаем, например, изменяем a, возможно, увеличиваем его, а затем снова считываем значение или, возможно, перекомпонуем.
var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.valueНа диаграмме это будет выглядеть точно так же, как и для ComputedState.
У него нет кеша, которым нужно управлять, поэтому он выглядит точно так же, как и при первом, втором, третьем или любом другом чтении.
DerivedState, though, has its work cut out for it.
Она проходит очень дорогой путь, чтобы увидеть, что кэш недействителен, прежде чем она даже обновит и вызовет сам расчет, а затем ей нужно отслеживать зависимости и управлять множеством зависимых наблюдений, чтобы записать значение кэша и понять, что все эти значения действительно изменились, и управлять самим обслуживанием.
Все это суммируется и делает стоимость аннулирования DerivedState очень высокой по сравнению с ComputedState.
Если говорить о цифрах, то ComputedState обычно быстрее при первом чтении примерно на 200 нс.
ComputedState проигрывает примерно 350 нс на Pixel 6, в частности, при определенных условиях тестирования.
Поэтому при втором чтении ComputedState будет немного проигрывать DerivedState.
Но в случае, который нас действительно интересует, когда мы делаем недействительным и, вероятно, в том, где у вас действительно есть работа над вашим фреймом, ComputedState работает быстрее почти и фактически более чем в два раза по сравнению с DerivedState.
Поэтому, в зависимости от того, для чего вы используете свои состояния, ComputedState может быть очень хорошим первым выбором.
В целом, при использовании DerivedState и ComputedState все равно необходимо принять решение.
Мы не отказываемся от ComputedState, оба варианта по-прежнему используются.
Если вы не знаете, какой вариант выбрать, рекомендуем использовать вычисленное состояние.
Производное состояние наиболее полезно, когда вы действительно используете преимущества кэширования, то есть делаете что-то нетривиальное, например сложный алгоритм, большое распределение, множество чтений состояния внутри, которые на самом деле выигрывают от кэширования здесь, или что-то нетривиальное или зависящее от таких вещей, как равенство объектов и равенство идентичности объектов, где вы компенсируете разницу и получаете преимущества кэширования, которые предлагает производное состояние.
Ещё один важный момент, о котором я хочу поговорить, заключается в том, что и производное, и вычисляемое состояние пытаются избежать аннулирования самой composable-функции.
Это отличается от пропуска composable-функции, о котором мы обычно говорим, когда думаем об условной недействительности или невыполнении работы внутри composable-функции.
Я хочу показать, что хотя о стабильности и пропуске в Compose говорят много, это не самое главное и не всегда нужно оптимизировать именно их.
Как мы уже говорили, у всего в композиции есть своя цена, и у пропуска тоже.
// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}Пропуск не обязательно тривиален.
Для этого требуется много работы.
Нам нужно сделать такие вещи, как хранение всех ваших параметров и поиск места для них в исходной композиции, а затем обновление этой записи при рекомпозиции.
И нам также нужно сравнить ваши значения с тем, какими они были ранее, чтобы понять, должна ли ваша composable-функция пропускаться или нет.
А затем, после всего этого, все это просто сила проверки if, которая пропускает код или нет.
Это может быть дорого, в зависимости от того, что здесь происходит.
В компиляторе есть оптимизация, при которой, если вы передаете один и тот же параметр, мы можем повторно использовать проверки на равенство, но это не конец, и это довольно легко победить.
Чтобы увидеть пример того, как пропуск немного предает нас, мы можем взглянуть на действительно классический пример простого прокручиваемого списка, который, что важно, содержит некоторый массивный объект, содержащий все данные, которые должны быть составлены на экране.
data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }В данном случае модель экрана содержит что-то вроде заголовка, а затем весь список контактов, который она передает через пару composable-функций, которые в конечном итоге вызывают ленивый список, а затем выводят их на экран с некоторыми ячейками элементов.
Иерархия композиции для этого кода выглядит примерно так: мы снова начинаем с экрана контактов, немного расширяем его, а затем у нас есть другие composable-функции, которые вызывают и представляют наши элементы.
Если мы посмотрим на то, как передаются наши данные, мы начнем с модели сверху.
Мы деструктурируем его в строку, а затем в список, который затем преобразуется в лямбду элементов для ленивого списка, а затем ленивый список будет составлять все элементы, которые появляются на экране.
Давайте посмотрим, что произойдет, если мы изменим последний контакт в этом списке.
Мы хотим, чтобы недействительным стал только последний элемент в списке, но этого не произойдет.
Если мы посмотрим на трассировку композиции для этой рекомпозиции, она будет выглядеть примерно так, где мы начинаем с экрана контактов, а затем в конечном итоге переходим к этому элементу списка.
И это довольно дорогой способ спуститься сюда.
Мы также видим, что есть разделы, в которых ничего не происходит.
И хотя здесь это не очевидно, именно в этот момент composable-функции решают, будут ли они пропущены.
В этом примере пропускаются только две composable-функции из-за того, как ленивый список выполняет рекомпозицию.
Когда вы передаете ему новую лямбду контента, он аннулирует все имеющиеся у него элементы.
Поэтому он аннулирует два контакта, которые не изменились, в дополнение к тому, который изменился, и эти два контакта – единственные элементы, которые можно пропустить.
Все остальные элементы пропускают сравнение на равенство, чтобы понять, что им действительно нужно выполнить.
Если ваши объекты стабильны, то это сравнение на самом деле может быть довольно дорогостоящим.
Если вы заставите нас пройти через все ваши структуры данных, это может фактически сложиться и стать нетривиальной стоимостью, просто чтобы сказать, равны ли два объекта.
И что ещё хуже, из-за деструктуризации нам приходится дважды выполнять одно и то же сравнение в списке.
Если вы оставите режим работы компилятора по умолчанию и будете использовать строгое пропуски, вы по-прежнему сможете пропускать и проверять условный пропуск composable-функций, но они будут использовать гораздо более быстрый маршрут, используя равенство идентификаторов объектов вместо структурного равенства.
Это позволяет нам выполнять вычисления гораздо быстрее. И хотя мы рекомпонуем те же элементы в этом фрейме, где нам действительно важно быстро войти, мы не тратим слишком много времени на то, чтобы понять, что на самом деле мы не могли пропустить эти компонуемые элементы.
Поэтому мы рекомендуем использовать такие инструменты, как тесты производительности и трассировки, чтобы понять, что происходит внутри приложения, а не просто подсчитывать количество рекомпозиций.
Они могут быть полезны, но не стоит зацикливаться на них при оптимизации приложений.
Помните, что вместо того, чтобы пытаться пропустить composable-функцию, лучше вообще не допускать ее инвалидации.
В этом примере мы увидели, что трассировки компоновки могут дать много информации о том, что происходит, но они также не обязательно показывают всю картину.
Об этом нам расскажет Андрей.
Теперь
Как уже сказал Эндрю, трассировка композиции не всегда дает полную картину, поскольку она аннотирует только composable-функции. В некоторых случаях вы можете увидеть пустое пространство в трассировке композиции, которая уже дополняет трассировку Perfetto, но все равно не показывает все, что там происходит.
Иногда это может быть что-то, что делает Compose, иногда это может быть что-то в вашем коде, это действительно трудно понять, просто глядя на трассировку композиции здесь.
Теперь вам нужно получить трассировку метода.
В профилировщике Android Studio есть специальный инструмент, но вы также можете использовать команды ADB, чтобы получить трассировку. В результате вы увидите каждый вызов функции, который происходит в Java.
Это все, что кодирует компилятор Compose, все, что делает среда выполнения Compose, и все, что делает ваш код. Это действительно полезно для отслеживания и исследования того, что именно выполняется, какие функции были вызваны в этом пространстве.
Ещё кое-что. Я хочу… Посмотрите на этот элемент в середине.
Хочу отметить, что трассировка методов требует больших ресурсов.
У вас в 10–20 раз больше накладных расходов в зависимости от того, насколько сложно дерево функций, потому что каждый вызов функции добавляет немного накладных расходов.
Чтобы проиллюстрировать это, мы можем посмотреть на скриншот.
Вот как обычно выглядит трассировка метода для запуска приложения. Вы можете спросить: "Как в этом разобраться?"
Обычно вы начинаете с трассировки композиции, вы смотрите на функции, которые у вас есть здесь.
Это упрощенная трассировка композиции, в которой есть ScreenContent, Button и другие функции.
Допустим, мы хотим выяснить, что происходит в ScreenContent.
В Perfetto есть строка поиска.
Вы загружаете трассировку метода в средство просмотра Perfetto, ищете ту же функцию, что и здесь, ScreenContent, и получаете что-то вроде этого, то есть трассировку метода для экрана.
Как видите, здесь есть Composer.startGroup и групповые звонки.
Это то, что среды выполнения и компилятор делают для вас в фоновом режиме, чтобы убедиться, что ваша композиция действительно работает, но интересный вызов здесь – Composer.changed.
Так мы проверяем, изменились ли параметры между предыдущими вызовами и текущим вызовом composable-функции.
Большинство из этих Composer.changed – это ScreenModel.equals.
Возможно, ScreenModel – это какой-то класс больших данных, и мы сделали его стабильным, чтобы помочь с количеством рекомпозиций, поэтому, возможно, это что-то, что появилось в отчете компилятора.
И, возможно, удаление этой аннотации поможет повысить производительность в этом конкретном случае.
Поэтому вам нужно искать именно это, и трассировки методов компоновки – лучший инструмент для этого.
Теперь
Сегодня мы обсудили много вопросов. Теперь я передаю слово Андрею, который подведет итоги.
В заключение и в качестве краткого резюме всего, о чём мы сегодня говорили, следует помнить о нескольких вещах, когда вы разрабатываете composable-функции, а затем оптимизируете уже имеющиеся приложения.
Прежде всего помните, что более дешевые composable-функции и composable-функции, которые вы не аннулируете, лучше, чем composable-функции, которые вы пропускаете.
Помните, что при разработке composable-функций лучше использовать аргументы, которые меньше и проще сравнивать, и, возможно, повторно использовать сравнения с другими вызовами функций, чтобы воспользоваться преимуществами оптимизации.
При использовании API в composable-функциях помните, что производительность или время формирования кадра будут зависеть от стоимости самих API и частоты их использования.
Помните, что эти расходы могут накапливаться, и что с ними могут быть связаны затраты на демонтаж, которые могут возникнуть в самый неподходящий для вашего приложения момент.
Также помните, что при оптимизации приложения важно соблюдать правила.
Начните с тестирования производительности, чтобы принимать обоснованные решения. Профилируйте приложение, чтобы убедиться, что вы нацелены на правильные области кода. Проверьте, что вы вносите изменения и видите результаты, которые ожидаете от модификаций, которые вы делаете в результате тестирования производительности и профилирования.
Также помните, что между удобством обслуживания и производительностью приложения может быть компромисс.
Поэтому делайте то, что лучше всего подходит вам и вашей команде, чтобы не переусердствовать с оптимизацией и не загнать себя в угол с точки зрения удобства обслуживания.
Кроме того, у вас есть ещё один инструмент – ИИ-агент.
В рамках Android CLI и наших навыков мы запустили навык производительности Jetpack Compose, который содержит много информации, о которой мы говорили сегодня, и может пройти и проверить ваше приложение и дать предложения по такого рода оптимизации.
Вы можете установить его в существующий агент с помощью Android CLI.
android skills add jetpack-compose-performanceДополнительную информацию об Android CLI можно найти на сайте developer.android.com.
Вы также можете узнать больше о навыках в нашем хранилище GitHub.
На этом всё. Безопасного тестирования и удачной разработки!
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Asynchronous work and animations
LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val activity = LocalActivity.current
// Setup and teardown
DisposableEffect(Unit) {
activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
onDispose {
activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
}
}
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// One-shot events
SideEffect(Unit) { mediaPlayer.play() }
}@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
val mediaPlayer = rememberMediaPlayer()
// Synchronize the outside world to the state of composition
SideEffect { mediaPlayer.playbackRate = playbackRate }
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) { viewModel.loadCart() }
LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
CartContent(...)
}@Composable
fun ShoppingCartScreen(
viewModel: CartViewModel
) {
LaunchedEffect(Unit) {
launch { viewModel.loadCart() }
launch { viewModel.loadPaymentMethods() }
launch { viewModel.loadDeliveryAddresses() }
}
CartContent(...)
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton = scrollState.value > 0
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}@Composable
fun ScrollToTopButton(
scrollState: ScrollState
) {
val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
val coroutineScope = rememberCoroutineScope()
if (showScrollToTopButton) {
FloatingActionButton(
onClick = {
coroutineScope.launch { scrollState.animateScrollTo(0) }
}
) {
Icon(Icons.Default.ArrowUpward, "Scroll to Top")
}
}
}var a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
// First read
derivedState.value
computedState.value
// Second read
derivedState.value
computedState.valuevar a by mutableStateOf(0)
val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }
...
// Read-after-write
a++
derivedState.value
computedState.value// What you write
@Composable fun ContactListItem(
contact: Contact
) { ... }
// What you get
@Composable fun ContactListItem(
contact: Contact,
$composer: Composer?
$changed: Int
) {
val $dirty = $changed or
$composer.changed(contact)
if ($dirty) { /* Your function body */ }
else $composer.skipToGroupEnd()
}data class ContactsModel(
val header: String,
val contacts: List<Contact>
)
data class Contact(...)
@Composable
fun ContactsScreen(model: ContactsModel) {
Column {
TopAppBar(title = { Text(model.header) })
ContactsList(model.contacts)
}
}
@Composable
fun ContactsList(contacts: List<Contact>) {
LazyColumn {
items(contacts) { ContactListItem(it) }
}
}
@Composable
fun ContactListItem(contact: Contact) { ... }android skills add jetpack-compose-performance