Autres points à prendre en compte

Bien que la migration des vues vers Compose soit purement liée à l'interface utilisateur, de nombreux éléments doivent être pris en compte pour effectuer une migration incrémentielle et sûre. Cette page présente quelques points importants à retenir lors de la migration d'une application basée sur les vues vers Compose.

Migration du thème de votre application

Material Design est le système de conception recommandé pour la thématisation des applications Android.

Pour les applications basées sur des vues, trois versions de Material sont disponibles :

  • Material Design 1 avec la bibliothèque AppCompat (c.-à-d. Theme.AppCompat.*)
  • Material Design 2 avec la bibliothèque MDC-Android (c.-à-d. Theme.MaterialComponents.*)
  • Material Design 3 avec la bibliothèque MDC-Android (c.-à-d. Theme.Material3.*)

Pour les applications Compose, deux versions de Material sont disponibles :

  • Material Design 2 avec la bibliothèque Compose Material (c.-à-d. androidx.compose.material.MaterialTheme)
  • Material Design 3 avec la bibliothèque Compose Material 3 (c.-à-d. androidx.compose.material3.MaterialTheme)

Nous vous recommandons d'utiliser la dernière version (Material 3) si le système de conception de votre application le permet. Des guides de migration sont disponibles pour les vues et Compose :

Lorsque vous créez des écrans dans Compose, quelle que soit la version de Material Design que vous utilisez, veillez à appliquer un MaterialTheme avant tous les composables qui émettent des UI depuis les bibliothèques Material de Compose. Les composants Material (Button, Text, etc.) dépendent de la mise en place d'un MaterialTheme, sans lequel leur comportement n'est pas défini.

Tous les exemples avec Jetpack Compose utilisent un thème Compose personnalisé basé sur un MaterialTheme.

Pour en savoir plus, consultez les sections Concevoir des systèmes dans Compose et Migrer des thèmes XML vers Compose.

Si vous utilisez le composant Navigation dans votre application, consultez les pages Naviguer avec Compose : interopérabilité et Migrer Jetpack Navigation vers Navigation Compose pour en savoir plus.

Tester votre interface utilisateur mixte Compose/Vues

Après avoir migré certaines parties de votre application vers Compose, vous devez effectuer des tests pour vous assurer que vous n'avez rien endommagé.

Lorsqu'une activité ou un fragment utilise Compose, vous devez utiliser createAndroidComposeRule au lieu d'ActivityScenarioRule. createAndroidComposeRule intègre ActivityScenarioRule avec une ComposeTestRule qui vous permet de tester Compose et d'afficher le code dans le même temps.

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()
    }
}

Pour en savoir plus sur les tests, consultez la section Tester votre mise en page Compose. Pour en savoir plus sur l'interopérabilité avec les frameworks de test de l'UI, consultez la section Interopérabilité avec Espresso et Interopérabilité avec UiAutomator.

Intégrer Compose dans l'architecture existante de votre application

Les schémas d'architecture de flux de données unidirectionnel (UDF) fonctionnent parfaitement avec Compose. Si l'application utilise d'autres types de schémas d'architecture à la place, par exemple un modèle MVP (Model View Presenter), nous vous recommandons de migrer cette partie de l'interface utilisateur vers l'UDF avant ou pendant l'adoption de Compose.

Utiliser un ViewModel dans Compose

Si vous utilisez la bibliothèque Architecture Components ViewModel, vous pouvez accéder à un ViewModel à partir de n'importe quel composable en appelant la fonction viewModel(), comme expliqué dans la section Compose et autres bibliothèques.

Lorsque vous adoptez Compose, veillez à utiliser le même type ViewModel dans différents composables, car les éléments ViewModel suivent les champs d'application du cycle de vie View. Le champ d'application correspondra à l'activité hôte, au fragment ou au graphique de navigation si la bibliothèque Navigation est utilisée.

Par exemple, si les composables sont hébergés dans une activité, viewModel() renvoie toujours la même instance qui ne sera effacée qu'une fois l'activité terminée. Dans l'exemple suivant, le même utilisateur ("user1") sera accueilli deux fois, car la même instance GreetingViewModel est réutilisée dans tous les composables de l'activité hôte. La première instance ViewModel créée est réutilisée dans d'autres composables.

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
    }
}

Comme les graphiques de navigation couvrent également les éléments ViewModel, les composables qui sont une destination dans un graphique de navigation ont une instance différente de ViewModel. Dans ce cas, le champ d'application de ViewModel est limité au cycle de vie de la destination et est effacé lorsque la destination est supprimée de la pile "Retour". Dans l'exemple suivant, lorsque l'utilisateur accède à l'écran Profil, une instance de GreetingViewModel est créée.

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

Source de référence de l'état

Lorsque vous adoptez Compose dans une partie de l'interface utilisateur, il est possible qu'il doive partager des données avec le code du système de vues. Dans la mesure du possible, nous vous recommandons d'encapsuler cet état partagé dans une autre classe qui respecte les bonnes pratiques d'UDF utilisées par les deux plates-formes, par exemple dans un ViewModel qui expose un flux de données partagées afin d'émettre des mises à jour de données.

Toutefois, ce n'est pas toujours possible si les données à partager sont mutables ou si elles sont étroitement liées à un élément d'interface utilisateur. Dans ce cas, l'un des systèmes doit être la source de référence et ce système doit partager toutes les mises à jour de données avec l'autre système. En règle générale, la source de référence doit appartenir à l'élément le plus proche de la racine de la hiérarchie de l'interface utilisateur.

Compose comme source de référence

Utilisez le composable SideEffect pour publier l'état de Compose dans du code non spécifique à Compose. Dans ce cas, la source de référence est conservée dans un composable qui envoie des mises à jour d'état.

Par exemple, votre bibliothèque d'analyse peut vous permettre de segmenter votre population d'utilisateurs en associant des métadonnées personnalisées (propriétés utilisateur dans cet exemple) à tous les événements d'analyse suivants. Pour communiquer le type d'utilisateur de l'utilisateur actuel à votre bibliothèque d'analyse, utilisez SideEffect afin de mettre à jour sa valeur.

@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
}

Pour en savoir plus, consultez Effets secondaires dans Compose.

Système de vues comme source de référence

Si le système de vues est propriétaire de l'état et le partage avec Compose, nous vous recommandons de l'encapsuler dans des objets mutableStateOf afin qu'il soit thread-safe pour Compose. Si vous utilisez cette approche, les fonctions modulables sont simplifiées, car elles ne disposent plus de la source de référence. Toutefois, le système View doit mettre à jour l'état modifiable et les vues qui utilisent cet état.

Dans l'exemple suivant, CustomViewGroup contient une TextView et une ComposeView contenant un composable TextField. TextView doit afficher le contenu de ce que l'utilisateur saisit dans 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
    }
}

Migrer une interface utilisateur partagée

Si vous migrez progressivement vers Compose, vous devrez peut-être utiliser des éléments d'interface utilisateur partagés par Compose et le système de vues. Par exemple, si votre application dispose d'un composant CallToActionButton personnalisé, vous devrez peut-être l'utiliser à la fois dans des écrans Compose et des écrans basés sur les vues.

Dans Compose, les éléments d'interface utilisateur partagés deviennent des composables qui peuvent être réutilisés dans l'application, que leur style soit défini par du code XML ou qu'ils'agisse d'une vue personnalisée. Par exemple, vous pouvez créer un composable CallToActionButton pour votre composant d'incitation à l'action Button personnalisé.

Pour utiliser le composable sur les écrans basés sur une vue, créez un wrapper de vue personnalisé qui s'étend depuis AbstractComposeView. Dans son composable Content remplacé, placez le composable que vous avez créé encapsulé dans votre thème Compose, comme illustré dans l'exemple ci-dessous :

@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)
        }
    }
}

Notez que les paramètres du composable deviennent des variables modifiables dans la vue personnalisée. Ainsi, la vue CallToActionViewButton personnalisée peut être gonflée et utilisée comme une vue traditionnelle. Voici un exemple avec View Binding ci-dessous :

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 */ }
        }
    }
}

Si le composant personnalisé contient un état modifiable, consultez la section Source de référence de l'état.

En priorité : séparer l'état de la présentation

Traditionnellement, une View est associée à un état. La View gère des champs qui décrivent ce qui doit être affiché, mais aussi comment l'afficher. Lorsque vous convertissez une View en Compose, cherchez à séparer les données affichées afin d'obtenir un flux de données unidirectionnel, comme expliqué plus en détail dans la section Hissage d'état.

Par exemple, une View comporte une propriété visibility qui indique si elle est visible, invisible ou supprimée. Cette propriété est inhérente à la View. Bien que d'autres éléments de code puissent affecter la visibilité d'une View, seule la View concernée connaît son état de visibilité actuel. La logique qui détermine la visibilité d'une View peut être sujette aux erreurs et est souvent liée à la View elle-même.

En revanche, Compose permet d'afficher facilement des composables complètement différents à l'aide de logiques conditionnelles en langage Kotlin :

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

De par sa conception, CautionIcon n'a pas besoin de connaître ni de considérer ce qui est affiché, et n'a aucun concept de visibility. L'élément fait simplement partie de la composition, ou non.

En séparant clairement la gestion de l'état et la logique de présentation, vous pouvez modifier plus librement la manière dont le contenu est affiché en tant que conversion d'un état en UI. La possibilité de hisser l'état en cas de besoin rend également les composables plus réutilisables, car la propriété de l'état est plus flexible.

Privilégier les composants encapsulés et réutilisables

Les éléments View ont souvent une notion de leur emplacement : dans une Activity, un Dialog, un Fragment ou quelque part dans la hiérarchie d'une autre View. Souvent gonflée à partir de fichiers de mise en page statiques, la structure globale de la View a tendance à être très rigide. Cela entraîne un couplage plus fort et rend la View plus difficile à modifier ou réutiliser.

Par exemple, une View personnalisée peut supposer qu'elle dispose d'une vue enfant d'un type donné, associée à un ID donné, et modifier ses propriétés directement en réponse à une action. Cela permet un couplage fort de ces éléments View : la View personnalisée peut planter ou dysfonctionner si elle ne parvient pas à trouver l'enfant, et celui-ci ne pourra probablement pas être réutilisé sans la View parent.

Grâce aux composables réutilisables, ce problème est moins contraignant dans Compose. Les parents peuvent facilement spécifier l'état et les rappels. Vous pouvez donc écrire des composables réutilisables sans avoir à connaître l'emplacement exact de leur utilisation.

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

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

Dans l'exemple ci-dessus, les trois parties sont davantage encapsulées et moins couplées :

  • ImageWithEnabledOverlay a uniquement besoin de connaître l'état isEnabled actuel. Cet élément n'a pas besoin de savoir que ControlPanelWithToggle existe ni comment il est contrôlable.

  • ControlPanelWithToggle ne sait pas que ImageWithEnabledOverlay existe. Quelles que soient les possibilités d'affichage de l'élément isEnabled, ControlPanelWithToggle n'a pas besoin de changer.

  • Pour l'élément parent, la profondeur d'imbrication des éléments ImageWithEnabledOverlay ou ControlPanelWithToggle n'a pas d'importance. Ces enfants peuvent animer des changements, remplacer des contenus ou les transmettre à d'autres enfants.

Cette approche est connue sous le nom d'inversion du contrôle. Pour en savoir plus, consultez la documentation CompositionLocal.

Gérer les changements de taille d'écran

L'un des principaux moyens pour créer des mises en page de View responsives consiste à disposer de ressources différentes pour selon les formats de fenêtre. Les ressources qualifiées restent une option pour les décisions de mise en page au niveau de l'écran, mais Compose permet de modifier beaucoup plus facilement les mises en page, entièrement depuis le code, grâce à une logique conditionnelle normale. Pour en savoir plus, consultez la section Utiliser des classes de taille de fenêtre.

Reportez-vous également à la page Compatibilité avec différentes tailles d'écran pour en savoir plus sur les techniques prises en charge par Compose pour la création d'interfaces utilisateur adaptatives.

Défilement imbriqué avec des vues

Pour plus d'informations sur la mise en œuvre de l'interopérabilité du défilement imbriqué entre des éléments de vue et des composables à défilement, avec une imbrication bidirectionnelle, consultez la section Interopérabilité des défilements imbriqués.

Compose dans RecyclerView

Les composables dans RecyclerView sont performants depuis la version 1.3.0-alpha02 de RecyclerView. Pour bénéficier de ces avantages, assurez-vous d'utiliser au moins la version 1.3.0-alpha02 de RecyclerView.

Interopérabilité WindowInsets avec Views

Vous devrez peut-être remplacer les marges intérieures par défaut lorsque votre écran contient à la fois des vues et du code Compose dans la même hiérarchie. Dans ce cas, vous devez indiquer explicitement lequel doit consommer les encarts et lequel doit les ignorer.

Par exemple, si votre mise en page la plus externe est une mise en page de vue Android, vous devez utiliser les marges intérieures dans le système de vue et les ignorer pour Compose. Si votre mise en page la plus externe est un composable, vous devez consommer les marges intérieures dans Compose et ajouter un espace aux composables AndroidView en conséquence.

Par défaut, chaque ComposeView consomme tous les insets au niveau de consommation WindowInsetsCompat. Pour modifier ce comportement par défaut, définissez ComposeView.consumeWindowInsets sur false.

Pour en savoir plus, consultez la documentation sur WindowInsets dans Compose.