Дополнительные рекомендации

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

Перенос темы приложения

Material Design – это рекомендуемая система проектирования для создания тем приложений Android.

Для приложений на основе View доступны три версии Material:

  • Material Design 1 с использованием библиотеки AppCompat (например, Theme.AppCompat.*).
  • Material Design 2 с использованием библиотеки MDC-Android (например, Theme.MaterialComponents.*)
  • Material Design 3 с использованием библиотеки MDC-Android (например, Theme.Material3.*)

Для приложений Compose доступны две версии Material:

  • Material Design 2 с использованием библиотеки Compose Material (androidx.compose.material.MaterialTheme).
  • Material Design 3 с использованием библиотеки Compose Material 3 (androidx.compose.material3.MaterialTheme).

Если система проектирования вашего приложения позволяет, рекомендуем использовать последнюю версию (Material 3). Руководства по переносу доступны как для Views, так и для Compose:

При создании новых экранов в Compose, независимо от используемой версии Material Design, убедитесь, что вы применяете MaterialTheme перед любыми составными элементами, которые создают пользовательский интерфейс из библиотек Compose Material. Компоненты Material (Button, Text и т. д.) зависят от наличия MaterialTheme, и без него их поведение не определено.

Во всех примерах Jetpack Compose используется специальная тема Compose, созданная на основе MaterialTheme.

Подробнее о системах проектирования в Compose и о переносе тем XML в Compose…

Если вы используете в приложении компонент Navigation, ознакомьтесь с информацией в статьях Navigate from Compose in a Fragment-based app (Навигация из Compose в приложении на основе фрагментов) и Migrate Jetpack Navigation to Navigation Compose (Переход с Jetpack Navigation на Navigation Compose).

Тестирование смешанного пользовательского интерфейса Compose/Views

После переноса частей приложения в Compose важно провести тестирование, чтобы убедиться, что все работает правильно.

Если в действии или фрагменте используется Compose, вместо ActivityScenarioRule нужно использовать createAndroidComposeRule. createAndroidComposeRule интегрируется с ActivityScenarioRule с помощью ComposeTestRule, что позволяет одновременно тестировать код Compose и View.

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

Подробнее о тестировании макета Compose… Информацию о совместимости с фреймворками для тестирования интерфейса можно найти в статьях Совместимость с Espresso и Совместимость с UiAutomator.

Интеграция Compose с существующей архитектурой приложения

Однонаправленный поток данных (UDF) – это архитектурный шаблон, который отлично работает с Compose. Если в приложении используются другие типы архитектурных шаблонов, например Model View Presenter (MVP), рекомендуем перенести эту часть пользовательского интерфейса в UDF до или во время внедрения Compose.

Как использовать ViewModel в Compose

Если вы используете библиотеку Architecture Components ViewModel, то можете получить доступ к ViewModel из любой composable-функции, вызвав функцию viewModel(), как описано в статье Compose и другие библиотеки.

При внедрении Compose будьте осторожны с использованием одного и того же типа ViewModel в разных composable-функциях, поскольку элементы ViewModel следуют областям жизненного цикла View. Областью видимости будет хост-активность, фрагмент или граф навигации, если используется библиотека Navigation.

Например, если composable-функции размещены в действии, viewModel() всегда возвращает один и тот же экземпляр, который очищается только после завершения действия. В следующем примере один и тот же пользователь (user1) получает приветствие дважды, потому что один и тот же экземпляр GreetingViewModel повторно используется во всех composable-функциях в рамках хост-активности. Первый созданный экземпляр ViewModel повторно используется в других composable-функциях.

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

Поскольку графы навигации также определяют область действия элементов ViewModel, composable-функции, которые являются пунктом назначения в графе навигации, имеют другой экземпляр ViewModel. В этом случае ViewModel привязывается к жизненному циклу пункта назначения и очищается, когда пункт назначения удаляется из стека переходов назад. В следующем примере, когда пользователь переходит на экран Профиль, создается новый экземпляр GreetingViewModel.

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

Источник информации о штате

Когда вы внедряете Compose в одну часть пользовательского интерфейса, возможно, что Compose и код системы View должны обмениваться данными. По возможности рекомендуем инкапсулировать это общее состояние в другой класс, который соответствует рекомендациям по UDF, используемым обеими платформами, например в ViewModel, который предоставляет поток общих данных для передачи обновлений данных.

Однако это не всегда возможно, если данные, которыми нужно поделиться, изменяемы или тесно связаны с элементом пользовательского интерфейса. В этом случае одна из систем должна быть источником достоверных данных и передавать обновления в другую систему. Как правило, источник истины должен принадлежать тому элементу, который находится ближе к корню иерархии пользовательского интерфейса.

Compose как источник достоверных данных

Используйте composable-функцию SideEffect, чтобы публиковать состояние Compose в коде, не относящемся к Compose. В этом случае источник истины хранится в composable-функции, которая отправляет обновления состояния.

Например, библиотека аналитики может позволить вам сегментировать пользователей, добавляя настраиваемые метаданные (свойства пользователя в этом примере) ко всем последующим событиям аналитики. Чтобы передать тип текущего пользователя в библиотеку аналитики, используйте SideEffect.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

Дополнительную информацию можно найти в статье Побочные эффекты в Compose.

Просмотр системы как источника достоверных данных

Если состояние принадлежит системе View и используется совместно с Compose, рекомендуем обернуть состояние в объекты mutableStateOf, чтобы сделать его потокобезопасным для Compose. Если вы используете этот подход, composable-функции упрощаются, поскольку у них больше нет источника истины, но системе View необходимо обновить изменяемое состояние и представления, которые используют это состояние.

В примере ниже CustomViewGroup содержит TextView и ComposeView с composable-функцией TextField внутри. В TextView должен отображаться контент, который пользователь вводит в TextField.

class CustomViewGroup @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

Перенос общего интерфейса

Если вы постепенно переходите на Compose, вам может потребоваться использовать общие элементы пользовательского интерфейса как в Compose, так и в системе View. Например, если в вашем приложении есть настраиваемый компонент CallToActionButton, вам может потребоваться использовать его как на экранах Compose, так и на экранах на основе View.

В Compose общие элементы пользовательского интерфейса становятся составными элементами, которые можно повторно использовать в приложении, независимо от того, стилизован ли элемент с помощью XML или является пользовательским представлением. Например, вы можете создать composable-функцию CallToActionButton для собственного компонента с призывом к действию Button.

Чтобы использовать composable-функцию на экранах на основе View, создайте пользовательскую оболочку представления, которая расширяет AbstractComposeView. В переопределенной composable-функции Content разместите созданную composable-функцию, обернутую в тему Compose, как показано в примере ниже.

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf("")
    var onClick by mutableStateOf({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

Обратите внимание, что параметры composable-функции становятся изменяемыми переменными внутри пользовательского представления. Это делает пользовательское представление CallToActionViewButton раздуваемым и пригодным для использования, как традиционное представление. Ниже приведен пример с привязкой представлений.

class ViewBindingActivity : ComponentActivity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.greeting)
            onClick = { /* Do something */ }
        }
    }
}

Если специальный компонент содержит изменяемое состояние, ознакомьтесь с разделом Источник достоверных данных о состоянии.

Приоритет разделения состояния и представления

Традиционно View сохраняет состояние. View управляет полями, которые описывают, что и как показывать. При преобразовании View в Compose разделите отображаемые данные, чтобы получить однонаправленный поток данных, как описано в разделе Передача состояния.

Например, у View есть свойство visibility, которое описывает, является ли он видимым, невидимым или исчезнувшим. Это неотъемлемое свойство View. Другие фрагменты кода могут изменять видимость View, но только сам View знает, виден ли он в данный момент. Логика, обеспечивающая видимость View, может быть подвержена ошибкам и часто связана с самим View.

В Compose же можно легко показывать совершенно разные composable-функции, используя условную логику в Kotlin:

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

По задумке, CautionIcon не нужно знать или заботиться о том, почему он отображается, и нет концепции visibility: он либо находится в композиции, либо нет.

Четко разделив управление состоянием и логику представления, вы сможете более свободно изменять способ отображения контента в качестве преобразования состояния в пользовательский интерфейс. Возможность поднимать состояние при необходимости также делает composable-функции более пригодными для повторного использования, поскольку владение состоянием становится более гибким.

Продвижение инкапсулированных и многократно используемых компонентов

Элементы View часто имеют представление о том, где они находятся: внутри Activity, Dialog, Fragment или где-то внутри другой иерархии View. Поскольку они часто раздуваются из статических файлов макета, общая структура View имеет тенденцию быть очень жесткой. Это приводит к более сильному зацеплению и усложняет изменение или повторное использование View.

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

В Compose с многократно используемыми composable-функциями эта проблема менее актуальна. Родительские элементы могут легко указывать состояние и обратные вызовы, поэтому вы можете писать многократно используемые composable-функции, не зная точного места, где они будут использоваться.

@Composable
fun AScreen() {
    var isEnabled by rememberSaveable { mutableStateOf(false) }

    Column {
        ImageWithEnabledOverlay(isEnabled)
        ControlPanelWithToggle(
            isEnabled = isEnabled,
            onEnabledChanged = { isEnabled = it }
        )
    }
}

В примере выше все три части более инкапсулированы и менее связаны:

  • ImageWithEnabledOverlay нужно знать только текущее состояние isEnabled. Ему не нужно знать, что ControlPanelWithToggle существует или как им управлять.

  • ControlPanelWithToggle не знает о существовании ImageWithEnabledOverlay. isEnabled может отображаться по-разному, и ControlPanelWithToggle не нужно будет менять.

  • Для родительского элемента не имеет значения, насколько глубоко вложены элементы ImageWithEnabledOverlay или ControlPanelWithToggle. Эти дочерние элементы могут анимировать изменения, заменять контент или передавать его другим дочерним элементам.

Этот шаблон называется инверсией управления. Подробнее о нем можно прочитать в документации по CompositionLocal.

Обработка изменений размера экрана

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

Также рекомендуем ознакомиться со статьей Поддержка экранов разных размеров, в которой рассказывается о том, как создавать адаптивные интерфейсы с помощью Compose.

Вложенная прокрутка с представлениями

Подробнее о том, как включить взаимодействие вложенной прокрутки между прокручиваемыми элементами View и прокручиваемыми composable-функциями, вложенными в обоих направлениях, читайте в статье Взаимодействие вложенной прокрутки.

Написать в приложении "RecyclerView"

Composables-функции в RecyclerView работают эффективно, начиная с версии RecyclerView 1.3.0-alpha02. Чтобы воспользоваться этими преимуществами, убедитесь, что у вас установлена версия RecyclerView не ниже 1.3.0-alpha02.

WindowInsets: совместимость с Views

Если на экране есть и представления, и код Compose в одной иерархии, может потребоваться переопределить отступы по умолчанию. В этом случае вам нужно явно указать, какой из них должен использовать вставки, а какой должен их игнорировать.

Например, если ваш самый внешний макет представляет собой макет Android View, вам следует использовать вставки в системе View и игнорировать их для Compose. Если же внешний макет – это composable-функция, то отступы нужно использовать в Compose и соответствующим образом дополнять composable-функции AndroidView.

По умолчанию каждый ComposeView использует все вставки на уровне WindowInsetsCompat. Чтобы изменить этот режим работы по умолчанию, задайте для параметра ComposeView.consumeWindowInsets значение false.

Дополнительную информацию можно найти в документации по WindowInsets в Compose.