Jetpack ComposeSeniorSystem design

Как поэтапно перенести существующий 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 стека и замедляет поставку фич.

Sources

Related topics