Как поэтапно перенести существующий UI на основе View в Jetpack Compose?
Миграцию выполняют поэтапно: сначала встраивают Compose в существующие экраны через ComposeView, используют AndroidView для legacy-виджетов, разделяют ViewModel, и в конце переходят на Navigation Compose.
Поэтапный перенос View UI в Jetpack Compose
Ключевой принцип миграции — не переписывать всё разом, а двигаться постепенно, используя механизмы взаимодействия между View-системой и Compose. Google рекомендует стратегию «снизу вверх» (leaf-first): начинать с атомарных компонентов (кнопки, текстовые поля, карточки), постепенно продвигаясь к экранам и навигации.
Фаза 1: Внедрение Compose в существующие View-экраны
Используйте ComposeView — специальный View, способный хостить composable-функции. Его можно добавить прямо в XML-лейаут или создать программно:
// В XML-лейауте
// <androidx.compose.ui.platform.ComposeView
// android:id="@+id/compose_view"
// android:layout_width="match_parent"
// android:layout_height="wrap_content" />
// В Fragment/Activity
binding.composeView.apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme {
MyComposableComponent()
}
}
}
Стратегия DisposeOnViewTreeLifecycleDestroyed обязательна для Fragment, чтобы Compose-состояние корректно освобождалось при возврате в backstack.
Фаза 2: Использование View внутри Compose
Если какой-то виджет сложно воссоздать в Compose (например, MapView, AdView, legacy custom view), используйте AndroidView:
@Composable
fun LegacyChartView(data: List<Float>) {
AndroidView(
factory = { context ->
CustomChartView(context).apply {
// начальная настройка
setChartStyle(ChartStyle.LINE)
}
},
update = { view ->
// вызывается при каждой рекомпозиции
view.setData(data)
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
}
// Для ViewGroup с дочерними элементами
@Composable
fun LegacyContainer() {
AndroidViewBinding(MyLegacyBinding::inflate) {
// доступ к binding внутри лямбды
tvTitle.text = "Hello"
}
}
Фаза 3: Общий ViewModel и состояние
Как View, так и Compose-компоненты могут разделять один ViewModel. Это ключ к постепенной миграции без переписывания бизнес-логики:
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun onNameChanged(name: String) {
_uiState.update { it.copy(name = name) }
}
}
// В Fragment (View-мир)
fragment.viewModel.uiState
.onEach { state -> binding.tvName.text = state.name }
.launchIn(viewLifecycleOwner.lifecycleScope)
// В Compose-мире
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Text(text = uiState.name)
}
Фаза 4: Миграция навигации
Параллельное использование Navigation Component (Fragment) и Navigation Compose возможно через NavHostFragment, у которого один из пунктов навигации — ComposeNavHost фрагмент. Финальная цель — полная замена Navigation Component на Navigation Compose:
// Промежуточный вариант: Compose-экран как Fragment
class ProfileFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
AppTheme { ProfileScreen() }
}
}
}
// Финальный вариант: чистый NavHost
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
ProfileScreen(userId = userId)
}
}
}
Рекомендуемый порядок миграции
- Начните с листовых UI-компонентов (кнопки, иконки, теги) — они не имеют зависимостей
- Перейдите к составным компонентам (карточки, list item'ы)
- Перепишите целые экраны, оставив Fragment-оболочку временно
- Мигрируйте навигацию на Navigation Compose в последнюю очередь
- Удалите Fragment-оболочки и перейдите на Activity с единственным
ComposeView
Подводные камни
- Отсутствие
setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)в Fragment приводит к утечкам памяти: Composition живёт дольше, чем View Fragment'а при возврате из backstack. AndroidViewне поддерживает Preview в Android Studio —factory-лямбда требует реальногоContext, а Preview-контекст не даёт доступа к ресурсам.- Вызов
View.invalidate()илиView.requestLayout()изupdate-лямбдыAndroidViewможет вызвать бесконечный цикл перекомпозиции. - Смешивание
WindowInsetsиз View-мира и Compose — разные API.ViewCompat.setOnApplyWindowInsetsListenerиWindowInsets.systemBarsиз Compose конфликтуют, если применяются одновременно. - Анимации View (
ObjectAnimator,ValueAnimator) и Compose Animation API (animate*AsState,Transition) работают независимо — нельзя синхронизировать анимации разных миров без дополнительного оркестратора. - RecyclerView внутри
AndroidViewплохо работает с Compose-скроллингом: вложенный скролл требует настройкиNestedScrollInteroperabilityчерезrememberNestedScrollInteropConnection(). - Databinding-лейауты при
AndroidViewBindingне реагируют на Compose-состояние автоматически — нужно вручную обновлять binding вupdate-блоке. - Тестирование:
ComposeViewв UI-тестах требует использованияcreateAndroidComposeRule<Activity>, а неActivityScenarioнапрямую — иначе Compose-узел не будет виден Espresso.
Common mistakes
- Объяснять «миграция View UI на Compose» только как синтаксис и не описывать поведение runtime/compiler.
- Игнорировать важный риск: Big-bang миграция без ownership и метрик создает два UI стека и замедляет поставку фич.
- Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.
What the interviewer is testing
- Формулирует суть темы «миграция View UI на Compose» своими словами и связывает ее с кодом.
- Называет механизм: Нужно определить interop boundaries, theme mapping, navigation, testing, performance baseline и стратегию удаления XML.
- Видит production-последствие: Big-bang миграция без ownership и метрик создает два UI стека и замедляет поставку фич.