Введение

В компании Иви, в одном из лучших онлайн-кинотеатров в России, наша команда разработчиков занимается созданием приложения для платформы Android. Недавно мы успешно выпустили версию для Android TV, используя инновационный инструмент — Jetpack Compose. Так как библиотека Compose для ТВ еще находится в альфа-версии, мы хотим поделиться своим опытом, рассказать о трудностях, с которыми столкнулись, и обратиться к вопросу: стоит ли использовать Jetpack Compose, особенно для ТВ-приложений?

Развитие Android TV и общая архитектура

Версия для Android TV
Версия для Android TV

В Иви мы развиваем как мобильное приложение, так и ТВ-версии, в особенности столь популярное в последнее время Android TV. По мере роста аудитории ТВ-приложения, мы столкнулись с проблемой дублирования бизнес-логики и интерфейсов. Мы решили объединить архитектуры мобильного и ТВ-приложений для того, чтобы избежать двойной работы над одним и тем же функционалом. За основу мы взяли архитектуру мобильной версии и стали адаптировать ее под ТВ. С применением архитектурной модели Unidirectional Data Flow в мобильной части это оказалось вполне выполнимым заданием — нам оставалось лишь написать рендеринг слой UI для уже готовой модели данных.

Создание UI для TV: выбор между Leanback и Jetpack Compose

Перед нами стоял вопрос о выборе инструмента для создания интерфейса на Android TV. Несмотря на то, что Jetpack Compose находился на начальной стадии разработки для ТВ, его потенциал не оставил нас равнодушными. Альтернативой была библиотека Leanback, но у нее нашлись серьезные недостатки.

Недостатки Leanback

Самый существенный недостаток этой библиотеки — ее закрытость для кастомизации и отсутствие обновлений, что может стать узким местом в будущем.

Почему решились на Jetpack Compose

Учитывая, что библиотека находилась в альфа-версии, решение использовать Jetpack Compose казалось смелым. Наше мобильное приложение уже содержало несколько экранов, реализованных с использованием Compose. Почему бы не попробовать его и для ТВ-версии?

Фокус в Leanback и его реализация в Jetpack Compose

Фокус в Android TV — это комплексное поведение. Leanback управляет перемещением фокуса определенным образом: сначала смещается рамка фокуса, а затем выделенный элемент анимированно прокручивается к центру экрана, управление фокусом осуществляется через пульт. Нам нужно было реализовать похожее поведение в Jetpack Compose. Ниже приведены гифки, демонстрирующие поведение фокуса в Leanback, и результат управления фокусом в Jetpack Compose:

  • Фокус в Leanback:

  • Управление фокусом в Jetpack Compose (наше решение):

Фокус в Jetpack Compose управляется с использованием FocusRequester-a, focusable() Modifier-а и onFocusChanged { ... } коллбэка. Вот пример использования фокуса в Jetpack Compose:

@Composable
fun FocusingText() {
    var color by remember { mutableStateOf(Color.Black) }
    val focusRequester = remember { FocusRequester() }
    Text(
        modifier = Modifier
            .focusRequester(focusRequester)
            .onFocusChanged { color = if (it.isFocused) Color.Green else Color.Black }
            .focusable()
            .pointerInput(Unit) { detectTapGestures { focusRequester.requestFocus() } },
        text = "Text",
        color = color
    )
}

В Compose нельзя напрямую спрашивать у элемента, есть ли на нем фокус, вместо этого нужно подписаться на коллбэк и сохранить эту информацию в переменной. Управлять фокусом можно, вызвав метод requestFocus() у желаемого элемента, но здесь есть подвох: если элемент не успел произвести recompose до вызова requestFocus(), происходит краш, который может убить движок Jetpack Compose до перезапуска, и обработать его никак нельзя.

Недостатки Jetpack Compose для Android TV и несоответствие ожиданиям

Мы рассчитывали на гибкость и инновации, но первые шаги в мире Compose оказались более сложными, чем мы предполагали. Из коробки на тот момент не существовало решения подобного Leanback.
При размещении элементов в Lazy - списке и перемещении фокуса его поведение отличается от Leanback: фокус уходит за пределы экрана и медленно прокручивает галерею, иногда фокус теряется и происходит сбой. Такое поведение было недопустимо.

Наше решение

У нас возникла идея управлять перемещением и фокусом независимо друг от друга. Учитывая, что у нас двумерная галерея, мы можем сохранять виртуальный фокус в виртуальной двумерной сетке и перемещать его с помощью нажатий клавиатуры - так появился DpadFocusController.

DpadFocusController — это подписчик событий клавиатуры, который умеет управлять виртуальной позицией фокуса.

Поскольку DpadFocusController видит всю карту галереи, то он сам принимает решения о перемещении фокуса при нажатии клавиш и о том, насколько прокрутить галерею. Мы задаем настройки анимации и контролируем анимацию прокрутки полностью.

Важно отметить, что вместе с DpadFocusController мы не можем использовать нативные FocusRequesters, потому что они могут лишить возможности LazyRow слушать нажатия на D-Pad. Поэтому мы сделали обертку над нативными FocusRequesters и проставляем их каждому элементу галереи.

API нашего решения выглядит так:

@Preview
@Composable
fun testGrid() {
	val items = listOf("1", "2", "3", "4", "5")
	val scope = rememberCoroutineScope()
	val state = rememberLazyListState()
	val dpad = remember {
		DpadFocusController(
			mScope = scope,
			lazyStateY = state,
		)
	}
	LaunchedEffect(Unit) { dpad.restoreFocusAfterDelay(100) }
	dpad.updateLastIndexY(items.size - 1)

	LazyColumn(
		modifier = Modifier.dpad(dpad),
		state = dpad.lazyStateY
	) {
		itemsIndexed(items = items) { i, str ->
			val outState = remember { mutableStateOf(TouchState.Idle) }
			val color = when (outState.value) {
				TouchState.Idle -> Color.Red
				TouchState.Focused -> Color.Green
				TouchState.Touched -> Color.Blue
				else -> Color.Gray
			}
			Box(
				modifier = Modifier
					.touchAndFocus(
						outState = outState,
						focusRequester = dpad.focusRequester(rowPos = i, posX = 0)
					)
					.background(color = color)
					.padding(10.dp)
			) {
				Text(text = str, color = Color.White, fontSize = 170.sp)
			}
		}
	}
}
  • На каждый элемент внутри LazyColumn/LazyRow, которому мы хотим сообщить о фокусном состоянии, применяется DpadFocusRequester из DpadFocusController.

  • Применяя Modifer.dpad(DpadFocusController) к Lazy-списку, мы подписываем контроллер на события клавиатуры:

    modifier.onKeyEvent(mOnKeyEventCb)
  • Получив направление движения, контроллер ищет первый подходящий элемент в заданном направлении, и, если находит, сообщает ему через DpadFocusController.requestFocus() о фокусе, затем прокручивает галерею к найденной позиции. При этом анимацией скролла мы полностью управляем сами:

private suspend fun ScrollScope.scrollToVisibleItem(
	targetItem: LazyListItemInfo,
	targetOffset: Int,
	animDurationMs: Int,
) = animate(
		targetValue = (targetItem.offset - targetOffset).toFloat(),
		animationSpec = tween( <-- мы сами задаем настройки анимации
				durationMillis = animDurationMs,
				easing = FastInSlowOut
			),
	) { value, velocity ->
		...
		scrollBy(delta)
	}
  • однако, LazyListState не дает информации по элементам вне области видимости, поэтому нам нужно сперва проскроллить наугад:

private suspend fun ScrollScope.findOrScrollToItem(
	lazyState: LazyListState,
	itemIndex: Int,
): LazyListItemInfo? {
	var targetItem = lazyState.layoutInfo.visibleItemsInfo
		.firstOrNull { it.index == itemIndex }
	while (targetItem == null) {
		val itemsCountNext = lazyState.layoutInfo.visibleItemsInfo.count { it.index > itemIndex }
		// 0 1 2 3 4 5 6 7 8
		//     *    [visible] = [4 5 6 7 8]
		//     ^
		//     | itemIndex = 2
		val scrolledBy = scrollBy(if (itemsCountNext > 0) -50f else 50f)
		targetItem = lazyState.layoutInfo.visibleItemsInfo
			.firstOrNull { it.index == itemIndex }
		if (abs(scrolledBy) < 1f) {
			break
		}
	}
	return targetItem
}

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

Различние вертикальных оффсетов тематических подборок и "Смотреть позже"
Различние вертикальных оффсетов тематических подборок и "Смотреть позже"

Если элемент не загрузился, то фокус-контроллер будет знать об этом и не будет устанавливать на него фокус. Контроллер также запоминает позицию фокуса в каждом ряду.

Решение от Google

По иронии судьбы, уже после нашего релиза, Google выпустил альфа-версию Jetpack Compose для Android TV. Использовать ее у себя в проекте можно так:

@Preview
@Composable
fun TestTvComposeBlock() {
	val fr = remember { FocusRequester() }
	LaunchedEffect(Unit) {
		delay(100)
		fr.requestFocus()
	}

	val listState = rememberTvLazyListState(0, 0)
	TvLazyColumn(
		modifier = Modifier.width(200.dp),
		state = listState,
		verticalArrangement = Arrangement.spacedBy(10.dp),
		pivotOffsets = PivotOffsets(0.5f, 0f)
	) {
		item { TestBlock(fr, -1) }

		items(
			count = 5,
			key = { it.hashCode() }
		) {
			val state = rememberTvLazyListState(0, 0)
			TvLazyRow(
				state = state,
				pivotOffsets = PivotOffsets(0f, 0f),
				horizontalArrangement = Arrangement.spacedBy(10.dp),
				userScrollEnabled = false,
			) {
				items(20, key = { it.hashCode() }) { TestBlock(index = it) }
			}
		}
	}
}

@Composable
private fun TestBlock( fr: FocusRequester? = null, index: Int) {
	var color by remember { mutableStateOf(Color.Red) }
	val focusRequester = fr ?: remember { FocusRequester() }
	Box(
		modifier = Modifier
			.size(50.dp)
			.background(color = color)
			.focusRequester(focusRequester)
			.onFocusChanged { color = if (it.isFocused) Color.Blue else Color.Red }
			.focusable(true),
		contentAlignment = Alignment.Center
	) {
		Text(text = index.toString())
	}
}

Мы обнаружили, что некоторые из наших багов с фокусом повторились. Быстрые скроллы вызывали краши и потери фокуса. Кроме того, поведение перехода фокуса между разными рядами было довольно нестандартным: не сохраняются уникальные позиции скролла (на гифке при переходе фокусом вниз c 19 на нижний ряд, ставится фокус на 2 и проскролливается до этого элемента):

Баг в TvLazyRow от Google
Баг в TvLazyRow от Google

Наблюдать за последними багами можно в Google IssueTracker IssueTracker - Compose TV.

Мощь фреймворка Jetpack Compose

Jetpack Compose упрощает переиспользование логики UI между разными версиями приложений для Android Mobile и Android TV. Например, код главного экрана приложения Иви меняется минимально для разных устройств:

		// Код в версии для Mobile
		PagesGrid(
			modifier = Modifier,
			page = pageState,
			screenWidth = screenWidthDp(),
			eventSender = { event -> fireEvent(event) },
			dpad = dpad,
			...
		)

		// Код в версии для TV
		PagesGrid(
			modifier = Modifier,
			header = {
					NewTvNavigationMenu(
						menu = menu,
						focusGetter = dpad::provideUpGridFocusRequester,
						onMenuClick = { fireEvent(MainMenuClickEvent(it)) },
					)
			},
			page = pageState,
			screenWidth = screenWidthDp(),
			eventSender = { fireEvent(it) },
			dpad = dpad,
			...
		)

Используя Modifier drawWithContent, можно легко реализовать частичное скрытие галереи с градиентом, как на данной иллюстрации:

Обрезка галереи с градиентной полупрозрачностью
Обрезка галереи с градиентной полупрозрачностью
		PagesGrid(
			modifier = Modifier
				.graphicsLayer {
					compositingStrategy = CompositingStrategy.Offscreen
				}
				.drawWithContent {
					drawContent()
					if (previewAlpha.value > 0f) {
						drawRect(
							brush = Brush.verticalGradient(
								listOf(
									Color.Black,
									Color.Transparent,
								),
								264.dp.toPx(),
								324.dp.toPx(),
							),
							blendMode = BlendMode.DstOut
						)
					}
				},
			...

Производительность и оптимизации

Производительность может снижаться в режиме отладки, но для релизного билда она близка к оптимальной. Для оптимизации производительности нужно следить за тем, чтобы все функции были skippable, а также включить Compose-метрики в проекте, используя сниппет из статьи Chris Banes:

	subprojects {
		tasks.withType(KotlinCompile).configureEach {
			kotlinOptions {
				freeCompilerArgs += [
						"-P",
						"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
								project.buildDir.absolutePath + "/compose_metrics"
				]
				freeCompilerArgs += [
						"-P",
						"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
								project.buildDir.absolutePath + "/compose_metrics"
				]
			}
		}
	}

Jetpack Compose продолжает улучшаться с каждым релизом. Например, сообщается об оптимизации Modifier, ускорившей рекомпозицию на 80%, особенно для Lazy-списков. Также, начиная с версии Android Gradle Plugin 8.2+, доступны Baseline Profiles, способные увеличить скорость до 40% благодаря профилированию горячих мест исполняемого кода и оптимальной упаковки dex байткода (What’s new in the Jetpack Compose August ’23 release, Improving App Performance with Baseline Profiles).

Выводы

Jetpack Compose облегчает переиспользование логики в UI, что удобно при разработке разных версий приложения для Mobile и TV с незначительными отличиями. Фреймворк помогает интегрировать сложные дизайны, создавать анимации и улучшать пользовательский опыт.

Использование Jetpack Compose для разработки ТВ-версии приложения Иви успешно доказало работоспособность данного фреймворка. Это позволило использовать его возможности на полную мощь, и, несмотря на некоторые недостатки, открыть «дверь» новым фичам с анимациями, сложными дизайнами и другими усовершенствованиями, которые нам не придется переводить с кастомных вью на Jetpack Compose. Ведь мы уже сделали это раньше!

Комментарии (0)