
Как мы сокращаем время кадра в чате на 8% с одним JetPack, составляющей оптимизацию
9 июня 2025 г.Введение
Плавная прокрутка имеет решающее значение для приложений для чата - задержка или заикание могут серьезно повлиять на удовлетворенность и удержание пользователей. Интерфейсы чата сталкиваются с уникальными проблемами из-за динамического содержания высокой плотности, таких как пузырьки текста, изображения, смайлики и временные метки.
Наша команда недавно столкнулась с тонким, но сложным требованием, работая над нашей реализацией в чате: динамически позиционирование временных метров в соответствии с последней строкой текста, когда пространство разрешает, или сбрасывая их на новую линию, когда текст слишком широкий. Это, казалось бы, незначительное дизайнерское решение выявило значительные узкие места производительности.
В этой статье я проведу вас по двум подходам, которые мы использовали - Subcomposelayout и оптимизированная альтернатива макета - чтобы продемонстрировать, как, казалось бы, небольшие варианты реализации могут резко повлиять на производительность вашего приложения. Независимо от того, создаете ли вы пользовательский интерфейс чата или какой -либо сложный пользовательский макет в Compose, эти методы помогут вам определить и разрешить критические узкие места.
Понимание технической проблемы
Почему динамическое позиционирование на основе текстового содержания является сложным
Динамическое позиционирование элементов относительно текста представляет несколько уникальных проблем в развитии пользовательского интерфейса. В нашем случае, позиционирование временных метров на основе доступного пространства в последней строке текста особенно сложна по нескольким причинам:
1Переменные свойства текста:Текст сообщения варьируется по длине, содержанию и форматированию. Каждое сообщение может иметь разные размеры шрифтов, веса или даже смешанное форматирование в одном сообщении.
- Неопределенность линии. Неопределенность:Текстовая упаковка непредсказуема во время дизайна. То же самое сообщение может охватить по -разному в зависимости от:
- Размер и ориентация экрана
- Настройки масштабирования шрифта
- Динамический размер контейнера
- Настройки доступности текста
- Зависимости измерения:Чтобы определить, подходит ли временная метка, нам нужно:
- Сначала измерьте полный текстовый макет
- Рассчитайте ширину последней строки конкретно
- Измерьте элемент временной метки
- Сравните эти измерения с шириной контейнера
- Принимать решения о позиционировании на основе этих расчетов
Начальная реализация с использованием subcomposelayout
SubcomposeLayout
является одним из самых мощных, но интенсивных API-интерфейсов JetPack Compose, разработанных специально для сложных макетов, требующих множественных проходов измерения и композиции.
По сути,SubcomposeLayout
работает на двух критических этапах:
- Подкомпозиция:Составьте компоненты индивидуально по мере необходимости, а не все сразу.
- Измерение:Измерьте эти индивидуально составленные компоненты, прежде чем определить окончательное расположение.
Для нашей задачи по позиционированию временных метров,SubcomposeLayout
казалось идеальным решением. Нам нужно:
Сначала измерьте текстовое содержание, чтобы определить метрики строки
Затем решите, разместить ли установку временной метки или на новую линию
Наконец -то составьте и позиционирует временную метку на основе этого решения
Вот упрощенная версия того, как мы первоначально реализовали динамическое позиционирование временных метров с использованиемSubcomposeLayout
:
@Composable
fun TextMessage_subcompose(
modifier: Modifier = Modifier,
message: Message,
textColor: Color,
bubbleMaxWidth: Dp = 280.dp
) {
val maxWidthPx = with(LocalDensity.current) { bubbleMaxWidth.roundToPx() }
SubcomposeLayout(modifier) { constraints ->
// ━━━ Phase 1: Subcompose and measure text ━━━
var textLayoutResult: TextLayoutResult? = null
val textPlaceable = subcompose("text") {
Text(
text = message.text,
color = textColor,
onTextLayout = { textLayoutResult = it }
)
}[0].measure(constraints.copy(maxWidth = maxWidthPx))
// Extract text metrics after measurement
val textLayout = requireNotNull(textLayoutResult) {
"Text layout should be available after subcomposition"
}
val lineCount = textLayout.lineCount
val lastLineWidth = ceil(
textLayout.getLineRight(lineCount - 1) -
textLayout.getLineLeft(lineCount - 1)
).toInt()
val widestLineWidth = (0 until lineCount).maxOf { lineIndex ->
ceil(
textLayout.getLineRight(lineIndex) -
textLayout.getLineLeft(lineIndex)
).toInt()
}
// ━━━ Phase 2: Subcompose and measure footer ━━━
val footerPlaceable = subcompose("footer") {
MessageFooter(message = message)
}[0].measure(constraints)
// ━━━ Calculate container dimensions ━━━
val canFitInline = lastLineWidth + footerPlaceable.width <= maxWidthPx
val containerWidth = max(widestLineWidth, lastLineWidth + footerPlaceable.width)
.coerceAtMost(maxWidthPx)
val containerHeight = if (canFitInline) {
max(textPlaceable.height, footerPlaceable.height)
} else {
textPlaceable.height + footerPlaceable.height
}
// ━━━ Layout and placement ━━━
layout(containerWidth, containerHeight) {
textPlaceable.place(x = 0, y = 0)
if (canFitInline) {
footerPlaceable.place(
x = containerWidth - footerPlaceable.width,
y = textPlaceable.height - footerPlaceable.height
)
} else {
footerPlaceable.place(
x = containerWidth - footerPlaceable.width,
y = textPlaceable.height
)
}
}
}
}
Логика казалась простой:
- Сначала измерьте текстЧтобы получить метрики линии и определить ширину последней строки
- Измерьте нижний колонтитул(TimeStamp и значки статуса), чтобы узнать его измерения
- Рассчитайте размеры контейнераИсходя из того, подходит ли нижний колонтитул встроенный
- Поместите оба элементав соответствии с встроенным/отдельным решением
Этот подход работал функционально - временные метки были расположены правильно на основе доступного пространства. Однако, поскольку мы масштабировали нашу реализацию чата, внедрив дополнительные функции, новые элементы пользовательского интерфейса и повышенную сложность, наше тестирование эффективности выявило значительные проблемы. Хотя эти проблемы были не исключительно из -заSubcomposeLayout
Сама, но скорее возник из -за кумулятивного взаимодействия нескольких компонентов в масштабе, мы определили необходимость всесторонне пересмотреть наш подход.
После тщательного анализа нашей реализации TextMessage было обнаружено несколько узких мест производительности:
- Повышенная композиция над головой
Каждый вызов функции вызывает Subcompose («Text») и Subcompose («нижний колонтитул»), эффективно запуская две отдельные фазы композиции на сообщение на каждом проходе макета - удвоив работу композиции по сравнению с традиционным подходом с одним проходом.
- Увеличение давления GC
Каждое подкоманное вызов выделяет посреднические списки и экземпляры Lambda. В рамках тяжелых сценариев прокрутки (сотни сообщений) эти временные объекты накапливаются, что приводит к более частым коллекциям мусора и кадрам кадра.
- Сложность прохода макета
SubcomposeLayout
По своей природе требуется более сложная логика макета, потому что состав и измерение чередованы.
Эта сложность умножается на все видимые элементы во время прокрутки, создавая кумулятивное воздействие на производительность, которое становится выраженным в условиях производственного чата с сотнями сообщений. Эти результаты заставили нас исследовать более эффективный подход с использованием стандартного API компоновки Compose, который может поддерживать такое же поведение динамического позиционирования, в то же время значительно снижая вычислительные накладные расходы.
Оптимизированная реализация с макетом
После определения узких мест производительности в нашем подходе Subcomposelayout мы обратились к стандартному API компоновки. В отличие отSubcomposeLayout
, стандартLayout
Следует за обычной композицией Compose → Измерение → трубопровод размещения, который предлагает несколько ключевых преимуществ:
- Фаза единого композиции:Все дочерние композиции создаются на фазе нормальной композиции, а не во время макета. Это позволяет оптимизации отмены Compose для эффективной работы - стабильные композиции могут быть пропущены, когда их входные данные не изменяются.
- Заранее определенные дети:
Layout
Работает с фиксированным набором измерения, которые известны во время композиции. Это устраняет динамические накладные расходы подкомпозиции и снижает давление сбора мусора. - Упрощенный поток управления:Логика макета становится более простой, поскольку нам не нужно переплетать композицию и операции измерения.
Стратегия реализации
Наш оптимизированный подход сохраняет то же визуальное поведение при реструктуризации реализации для работы в рамках ограничений макета. Вот упрощенный фрагмент нашего оптимизированного подхода:
@Composable
fun TextMessage_layout(
modifier: Modifier = Modifier,
message: Message,
textColor: Color,
bubbleMaxWidth: Dp = 260.dp
) {
// Shared reference for accessing text layout metrics during measurement
val textLayoutRef = remember { Ref<TextLayoutResult>() }
val density = LocalDensity.current
Layout(
modifier = modifier,
content = {
// Primary text content
Text(
text = message.text,
color = textColor,
onTextLayout = { result -> textLayoutRef.value = result }
)
// Footer containing timestamp and status indicators
MessageFooter(message = message)
}
) { measurables, constraints ->
val maxWidthPx = with(density) { bubbleMaxWidth.roundToPx() }
// ━━━ Single-pass measurement of all children ━━━
val textPlaceable = measurables[0].measure(
constraints.copy(maxWidth = maxWidthPx)
)
val footerPlaceable = measurables[1].measure(constraints)
// ━━━ Extract text metrics for positioning logic ━━━
val textLayout = requireNotNull(textLayoutRef.value) {
"TextLayoutResult must be available after text measurement"
}
val lineCount = textLayout.lineCount
val lastLineWidth = ceil(
textLayout.getLineRight(lineCount - 1) -
textLayout.getLineLeft(lineCount - 1)
).toInt()
val widestLineWidth = (0 until lineCount).maxOf { lineIndex ->
ceil(
textLayout.getLineRight(lineIndex) -
textLayout.getLineLeft(lineIndex)
).toInt()
}
// ━━━ Determine layout strategy ━━━
val canFitInline = lastLineWidth + footerPlaceable.width <= maxWidthPx
val containerWidth = if (canFitInline) {
max(widestLineWidth, lastLineWidth + footerPlaceable.width)
} else {
max(widestLineWidth, footerPlaceable.width)
}.coerceAtMost(maxWidthPx)
val containerHeight = if (canFitInline) {
max(textPlaceable.height, footerPlaceable.height)
} else {
textPlaceable.height + footerPlaceable.height
}
// ━━━ Element placement ━━━
layout(containerWidth, containerHeight) {
// Position text at top-left
textPlaceable.place(x = 0, y = 0)
// Position footer based on available space
if (canFitInline) {
// Inline: bottom-right of the text area
footerPlaceable.place(
x = containerWidth - footerPlaceable.width,
y = textPlaceable.height - footerPlaceable.height
)
} else {
// Separate line: below text, right-aligned
footerPlaceable.place(
x = containerWidth - footerPlaceable.width,
y = textPlaceable.height
)
}
}
}
}
Но почему это лучше?
Разделение композиции
content = {
Text(
text = message.text,
color = textColor,
onTextLayout = { result -> textLayoutRef.value = result }
)
MessageFooter(message = message)
}
Оба дочерних композиционных продуктов создаются на фазе нормального состава. Это позволяет композиции применять свои стандартные оптимизации - еслиmessage.text
иtextColor
не изменился,Text
Композитный может быть полностью пропущен во время переоборудования.
2. Одиночный проход измерения
val textPlaceable = measurables[0].measure(rawConstraints.copy(maxWidth = maxWidthPx))
val footerPlaceable = measurables[1].measure(rawConstraints)
Каждый ребенок измеряется ровно один раз на проход на макет. Список измерения является предопределенным и стабильным, что устраняет накладные расходы на распределение динамической субкомпозиции.
- Общий результат макета
val textLayoutRef = remember { Ref<TextLayoutResult>() }
//… later in Text composable:
onTextLayout = { result -> textLayoutRef.value = result }
Мы используем рефери, чтобы поделиться текстовым слоем между измерением композиции текста и нашими последующими расчетами строки. Это избегает избыточных операций макета текста, сохраняя при этом доступные данные для нашей логики позиционирования.
4. Уточненный логический потокЛогика макета следует четкой, предсказуемой последовательности:
Измерение детей → извлечение текстовых метрик → Рассчитайте размер контейнера → Поместите элементы
Это устраняет сложность чередующегося состава и измерения, которые характеризовали наш подход Subcomposelayout.
Полученная реализация достигает идентичного визуального поведения при работе в рамках оптимизированного композиционного трубопровода Compose, создавая основу для значительных улучшений производительности, которые мы рассмотрим в наших контрольных результатах.
Сравнительный анализ эффективности
Понимание макробенчанга в Android
Прежде чем погрузиться в наши результаты, важно понять, почему MacRobenchMarking предоставляет наиболее точное представление о производительности для реальных сценариев приложений. В отличие от Microbenchmars, которые измеряют изолированные фрагменты кода, Macrobenchmarks оценивают производительность вашего приложения в реалистичных условиях, включая накладные расходы Android Framework, системные взаимодействия и фактические шаблоны поведения пользователя.
Macrobenchmarking особенно важна для анализа производительности пользовательского интерфейса, поскольку он отражает полный конвейер рендеринга: от композиции до макета до рисования и отображения. Этот комплексный подход показывает узкие места производительности, которые могут быть невидимыми в изолированных средах тестирования.
Сравнительный анализ и результаты
Мы провели тесты на макро-оплоте, сравнивая обе реализации (Subcomposelayout и макет). Цифры четко указали на существенные улучшения производительности, в том числе:
- Уменьшенные капли рамки:Оптимизированный подход значительно уменьшил заикание во время быстрой прокрутки.
- Нижнее давление сбора мусора:Меньше промежуточных творений объекта значительно улучшило метрики GC.
- Проще и более поддерживаемый код: Сокращая сложность, логика макета стала более ясной и проще для постоянного технического обслуживания.
Цитрицы были структурированы с использованием теста Macrobenchmark, аналогичного следующему фрагменту:
@Test
fun scrollTestLayoutImplementation() = benchmarkRule.measureRepeated(
packageName = "ai.aiphoria.pros",
metrics = listOf(FrameTimingMetric()),
iterations = 10,
setupBlock = {
pressHome()
device.waitForIdle(1000)
startActivityAndWait(setupIntent(useSubcompose = false))
},
startupMode = StartupMode.WARM
) {
performEnhancedScrollingActions(device)
}
private fun performEnhancedScrollingActions(device: UiDevice, scrollCycles: Int = 40) {
val width = device.displayWidth
val height = device.displayHeight
val centerX = width / 2
val swipeContentDownStartY = (height * 0.70).toInt()
val swipeContentDownEndY = (height * 0.3).toInt()
val swipeSteps = 3
val pauseBetweenScrolls = 15L
repeat(scrollCycles) {
device.swipe(centerX, swipeContentDownEndY, centerX, swipeContentDownStartY, swipeSteps) // Scrolls content up
SystemClock.sleep(pauseBetweenScrolls)
}
repeat(scrollCycles) {
device.swipe(centerX, swipeContentDownStartY, centerX, swipeContentDownEndY, swipeSteps) // Scrolls content down
SystemClock.sleep(pauseBetweenScrolls)
}
}
Наши тесты на Macrobenchmark выявили существенные улучшения производительности при переходе от Subcomposelayout к оптимизированному подходу макета. Результаты демонстрируют последовательный рост на все процентили производительности:
Улучшения продолжительности кадры
Наиболее важная метрика для пользовательского опыта - время рендеринга кадров - показал значительные улучшения:
- P50 (медиана): 5,9 мс против 6,3 мс (улучшение 6,7%)
- P90: 10,5 мс против 11,0 мс (улучшение 4,7%)
- P95: 12,3 мс против 12,9 мс (улучшение 4,8%)
- P99: 15,0 мс против 16,2 мс (улучшение 8%)
Хотя эти улучшения могут показаться скромными в абсолютных терминах, они представляют собой значимые успехи в интерфейсе чата, где плавная прокрутка 60 кадров в секунду имеет решающее значение. Улучшение P99 особенно важно - те времена кадров в худшем случае, которые вызывают заметное заикание, уменьшаются почти на 8%.
Анализ переиздания кадров
Переполнения кадров происходят, когда рендеринг занимает больше времени, чем бюджет 16,67 мс для 60 кадров в секунду. Наша оптимизированная реализация макета показывает лучшие характеристики производительности:
- Меньше тяжелых переполнений: переполнение кадра P99 улучшилось с 1,4 мс до 0,2 мс.
- Лучшая последовательность: более предсказуемое время кадра во всех процентах
- Снижение заикания: меньше случаев кадров упускают срок их VSYNC
Улучшения переполнения кадров особенно важны для поддержания плавного прокрутки во время интенсивных взаимодействий пользователей, таких как жесты быстрого прокрутки или когда система находится под давлением памяти.
Ключевые уроки извлечены
Когда избегать субкомпоселаута
Наш опыт показывает конкретные сценарии, в которых гибкость Subcomposelayout составляет слишком высокую стоимость производительности:
- Высокочастотные макеты:В списках прокрутки, где операции макета происходят десятки раз в секунду, накладные расходы подкомпозиции становится непомерно высоким.
- Простое динамическое позиционирование:Когда ваши требования к макету могут быть достигнуты посредством измерения и расчета, а не условного состава, стандартная компоновка является более эффективной.
- Критичный интерфейс: пользовательский интерфейс:Интерфейсы чата, игровые интерфейсы или любой контекст, где кадр отпускает непосредственное влияние на удовлетворенность пользователями, требуют усилия по оптимизации.
Когда SubCompoSelayout все еще имеет смысл
SubcomposeLayout
остается правильным выбором для:
- Сложный условная композиция:Когда вам нужно составить совершенно разные компонентные деревья на основе измеренного контента.
- Нечастого макета операции:Для диалогов, экранов конфигурации или другого пользовательского интерфейса, которые не требуют высокочастотных проходов.
- Прототипирование:
SubcomposeLayout's
Гибкость делает его превосходным для быстрой итерации во время разработки.
Контрольный список оптимизации производительности
Основываясь на нашем путешествии по оптимизации, вот практический контрольный список для выявления и разрешения аналогичных узких мест производительности:
Обнаружение
- Производительность профиля с использованием Macrobenchmarks, а не только микробанкмары
- Мониторинг метрик времени кадра во время реалистичных взаимодействий с пользователями
- Проверка на устройствах более низких классов, где различия в производительности усиливаются
- Измерение в масштабе - тест с сотнями предметов, а не с несколькими
Анализ
- Определите частоту композиции - как часто измеряются ваши пользовательские макеты?
- Расчетные проходы измерения - измеряются ли дети несколько раз излишне?
- Проверить шаблоны распределения - вы создаете временные объекты во время макета?
Оптимизация
- Предпочитайте измерение с одним проходом, когда это возможно
- Результаты обмена измерениями между композицией и фазами макета эффективно
- Минимизировать распределение объектов во время операций макета
- Проверьте тесты - убедитесь, что оптимизации обеспечивают измеримые преимущества
Заключение
Ключевой вывод для разработчиков Android, создающих высокопроизводительные пользовательские интерфейсы: всегда измеряйте свои предположения. То, что представляется незначительной детализацией реализации, может оказать существенное влияние на пользовательский опыт в масштабе. Инвестируйте в правильную инфраструктуру сравнительного анализа рано и не стесняйтесь вернуться к выбору внедрения по мере развития требований вашего приложения.
Счастливого кодирования!
Оригинал