Inne uwagi

Migracja z widoków do Compose dotyczy wyłącznie interfejsu, ale trzeba wziąć pod uwagę wiele kwestii, aby przeprowadzić ją bezpiecznie i przyrostowo. Na tej stronie znajdziesz kilka kwestii, które warto wziąć pod uwagę podczas migracji aplikacji opartej na widokach do Compose.

Migracja motywu aplikacji

Material Design to zalecany system projektowania motywów aplikacji na Androida.

W przypadku aplikacji opartych na widokach dostępne są 3 wersje Material Design:

  • Material Design 1 z użyciem biblioteki AppCompat (czyli Theme.AppCompat.*)
  • Material Design 2 z użyciem biblioteki MDC-Android (np. Theme.MaterialComponents.*)
  • Material Design 3 z użyciem biblioteki MDC-Android (czyli Theme.Material3.*)

W przypadku aplikacji Compose dostępne są 2 wersje Material:

  • Material Design 2 z użyciem biblioteki Compose Material (czyli androidx.compose.material.MaterialTheme)
  • Material Design 3 z użyciem biblioteki Compose Material 3 (czyli androidx.compose.material3.MaterialTheme)

Jeśli system projektowania aplikacji na to pozwala, zalecamy korzystanie z najnowszej wersji (Material 3). Przewodniki po migracji są dostępne zarówno dla widoków, jak i Compose:

Podczas tworzenia nowych ekranów w Compose, niezależnie od używanej wersji Material Design, przed wszystkimi elementami kompozycyjnymi, które emitują interfejs z bibliotek Compose Material, zastosuj MaterialTheme. Komponenty Material (Button, Text itd.) zależą od obecności elementu MaterialTheme, a bez niego ich zachowanie jest nieokreślone.

Wszystkie przykłady Jetpack Compose korzystają z niestandardowego motywu Compose opartego na MaterialTheme.

Więcej informacji znajdziesz w artykułach Systemy projektowania w Compose i Migracja motywów XML do Compose.

Jeśli w aplikacji używasz komponentu nawigacji, więcej informacji znajdziesz w artykułach Nawigacja z Compose w aplikacji opartej na fragmentach i Migracja z Jetpack Navigation do Navigation Compose.

Testowanie interfejsu łączącego Compose i widoki

Po przeniesieniu części aplikacji do Compose kluczowe jest przeprowadzenie testów, aby upewnić się, że nic nie zostało uszkodzone.

Gdy aktywność lub fragment używa Compose, musisz użyć createAndroidComposeRule zamiast ActivityScenarioRule. createAndroidComposeRule integruje się z ActivityScenarioRule, co pozwala jednocześnie testować kod Compose i View.ComposeTestRule

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

Więcej informacji o testowaniu znajdziesz w artykule Testowanie układu Compose. Informacje o współdziałaniu z platformami do testowania interfejsu znajdziesz w artykułach o współdziałaniu z Espresso i współdziałaniu z UiAutomator.

Integracja Compose z istniejącą architekturą aplikacji

Wzorce architektury jednokierunkowego przepływu danych (UDF) bezproblemowo współpracują z Compose. Jeśli aplikacja używa innych typów wzorców architektury, takich jak Model View Presenter (MVP), zalecamy przeniesienie tej części interfejsu do UDF przed wdrożeniem Compose lub w jego trakcie.

Używanie ViewModel w Compose

Jeśli używasz biblioteki Architecture Components ViewModel, możesz uzyskać dostęp do ViewModel z dowolnej funkcji kompozycyjnej, wywołując funkcję viewModel(), jak wyjaśniono w artykule Compose i inne biblioteki.

Podczas wdrażania Compose zachowaj ostrożność przy używaniu tego samego typu ViewModel w różnych elementach kompozycyjnych, ponieważ elementy ViewModel są zgodne z zakresami cyklu życia widoku. Zakresem będzie aktywność hosta, fragment lub wykres nawigacyjny, jeśli używana jest biblioteka nawigacji.

Jeśli na przykład elementy kompozycyjne są hostowane w aktywności, viewModel() zawsze zwraca tę samą instancję, która jest czyszczona dopiero po zakończeniu aktywności. W tym przykładzie ten sam użytkownik („user1”) jest witany dwukrotnie, ponieważ ta sama instancja GreetingViewModel jest używana we wszystkich funkcjach kompozycyjnych w aktywności hosta. Pierwsza utworzona instancja ViewModel jest ponownie używana w innych elementach kompozycyjnych.

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

Wykresy nawigacyjne określają też zakres elementów ViewModel, dlatego elementy kompozycyjne, które są miejscem docelowym na wykresie nawigacyjnym, mają inną instancję ViewModel. W tym przypadku zakres ViewModel jest ograniczony do cyklu życia miejsca docelowego i jest czyszczony, gdy miejsce docelowe zostanie usunięte ze stosu wstecznego. W tym przykładzie, gdy użytkownik przechodzi do ekranu Profil, tworzona jest nowa instancja GreetingViewModel.

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

Nadrzędne źródło danych o stanie

Gdy zastosujesz Compose w jednej części interfejsu, może się okazać, że Compose i kod systemu widoku muszą udostępniać dane. Jeśli to możliwe, zalecamy umieszczenie tego udostępnionego stanu w innej klasie, która jest zgodna ze sprawdzonymi metodami UDF stosowanymi na obu platformach, np. w ViewModel, która udostępnia strumień udostępnionych danych do emitowania aktualizacji danych.

Nie zawsze jest to jednak możliwe, jeśli dane do udostępnienia są zmienne lub ściśle powiązane z elementem interfejsu. W takim przypadku jeden system musi być źródłem informacji i musi udostępniać wszelkie aktualizacje danych drugiemu systemowi. Zgodnie z ogólną zasadą źródło prawdy powinno należeć do elementu, który znajduje się bliżej katalogu głównego hierarchii interfejsu.

Compose jako źródło wiarygodnych danych

Użyj funkcji kompozycyjnej SideEffect, aby opublikować stan Compose w kodzie innym niż Compose. W tym przypadku źródło prawdy jest przechowywane w funkcji kompozycyjnej, która wysyła aktualizacje stanu.

Na przykład biblioteka analityczna może umożliwiać segmentowanie populacji użytkowników przez dołączanie niestandardowych metadanych (w tym przykładzie właściwości użytkownika) do wszystkich kolejnych zdarzeń analitycznych. Aby przekazać typ bieżącego użytkownika do biblioteki Analytics, użyj SideEffect, aby zaktualizować jego wartość.

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

Więcej informacji znajdziesz w artykule Efekty uboczne w Compose.

System wyświetlania jako źródło wiarygodnych danych

Jeśli system widoku jest właścicielem stanu i udostępnia go Compose, zalecamy opakowanie stanu w obiekty mutableStateOf, aby zapewnić bezpieczeństwo wątków w Compose. Jeśli zastosujesz to podejście, funkcje typu „composable” będą uproszczone, ponieważ nie będą już miały źródła prawdy, ale system widoków będzie musiał aktualizować stan zmienny i widoki, które go używają.

W tym przykładzie CustomViewGroup zawiera TextView i ComposeView z elementem kompozycyjnym TextField wewnątrz. TextView musi wyświetlać treść wpisywaną przez użytkownika w 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
    }
}

Migracja udostępnionego interfejsu użytkownika

Jeśli stopniowo przechodzisz na Compose, może być konieczne użycie udostępnionych elementów interfejsu zarówno w Compose, jak i w systemie widoków. Jeśli na przykład aplikacja ma niestandardowy komponent CallToActionButton, może być konieczne użycie go zarówno na ekranach opartych na Compose, jak i na widokach.

W Compose udostępnione elementy interfejsu stają się elementami kompozycyjnymi, które można ponownie wykorzystać w aplikacji, niezależnie od tego, czy element jest stylizowany za pomocą kodu XML, czy jest widokiem niestandardowym. Możesz na przykład utworzyć element kompozycyjny CallToActionButton dla niestandardowego komponentu wezwania do działania Button.

Aby używać elementu kompozycyjnego na ekranach opartych na widoku, utwórz niestandardowy kod otaczający widoku, który rozszerza AbstractComposeView. W zastąpionej funkcji kompozycyjnej Content umieść utworzoną funkcję kompozycyjną opakowaną w motyw Compose, jak w przykładzie poniżej:

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

Zwróć uwagę, że parametry funkcji kompozycyjnej stają się zmiennymi w widoku niestandardowym. Dzięki temu niestandardowy widok CallToActionViewButton można rozszerzać i używać go jak tradycyjnego widoku. Poniżej znajdziesz przykład użycia wiązania widoku:

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

Jeśli komponent niestandardowy zawiera stan zmienny, zapoznaj się z artykułem Źródło prawdy o stanie.

Priorytetowe traktowanie oddzielania stanu od prezentacji

Tradycyjnie View jest stanowy. View zarządza polami, które opisują, co i jak ma być wyświetlane. Podczas konwertowania View na Compose spróbuj oddzielić renderowane dane, aby uzyskać jednokierunkowy przepływ danych, jak wyjaśniono w sekcji Przenoszenie stanu.

Na przykład View ma właściwość visibility, która określa, czy jest on widoczny, niewidoczny czy usunięty. Jest to nieodłączna właściwość View. Inne fragmenty kodu mogą zmieniać widoczność elementu View, ale tylko sam element View wie, jaka jest jego bieżąca widoczność. Logika zapewniająca widoczność elementu View może być podatna na błędy i często jest powiązana z samym elementem View.

Z kolei Compose ułatwia wyświetlanie zupełnie różnych elementów kompozycyjnych przy użyciu logiki warunkowej w języku Kotlin:

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

Z założenia funkcja CautionIcon nie musi wiedzieć, dlaczego jest wyświetlana, ani się tym przejmować. Nie ma też pojęcia visibility: albo jest w kompozycji, albo nie.

Dzięki wyraźnemu oddzieleniu zarządzania stanem od logiki prezentacji możesz swobodniej zmieniać sposób wyświetlania treści w ramach konwersji stanu na interfejs. Możliwość przeniesienia stanu w razie potrzeby zwiększa też możliwość wielokrotnego wykorzystania funkcji kompozycyjnych, ponieważ własność stanu jest bardziej elastyczna.

Promowanie komponentów hermetyzowanych i wielokrotnego użytku

Elementy View często mają pewne pojęcie o tym, gdzie się znajdują: wewnątrz Activity, Dialog, Fragment lub gdzieś w innej hierarchii View. Ponieważ często są one rozwijane ze statycznych plików układu, ogólna struktura View jest zwykle bardzo sztywna. Prowadzi to do ściślejszego sprzężenia i utrudnia zmianę lub ponowne użycie View.

Na przykład niestandardowy element View może zakładać, że ma widok podrzędny określonego typu z określonym identyfikatorem, i zmieniać jego właściwości bezpośrednio w odpowiedzi na jakieś działanie. To ściśle łączy te elementy View: niestandardowy element View może ulec awarii lub zostać uszkodzony, jeśli nie znajdzie elementu podrzędnego, a elementu podrzędnego prawdopodobnie nie można użyć ponownie bez niestandardowego elementu nadrzędnego View.

W Compose jest to mniejszy problem dzięki komponentom wielokrotnego użytku. Rodzice mogą łatwo określać stan i wywołania zwrotne, dzięki czemu możesz pisać komponenty wielokrotnego użytku bez konieczności znajomości dokładnego miejsca, w którym będą używane.

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

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

W powyższym przykładzie wszystkie 3 części są bardziej hermetyczne i mniej powiązane:

  • ImageWithEnabledOverlay musi tylko znać bieżący stan isEnabled. Nie musi wiedzieć, że ControlPanelWithToggle istnieje, ani nawet jak można nim sterować.

  • ControlPanelWithToggle nie wie, że ImageWithEnabledOverlay istnieje. Może być 0, 1 lub więcej sposobów wyświetlania isEnabled, a ControlPanelWithToggle nie musi się zmieniać.

  • Dla elementu nadrzędnego nie ma znaczenia, jak głęboko zagnieżdżone są elementy ImageWithEnabledOverlay lub ControlPanelWithToggle. Te dzieci mogą animować zmiany, wymieniać treści lub przekazywać je innym dzieciom.

Ten wzorzec jest znany jako odwrócenie sterowania. Więcej informacji na ten temat znajdziesz w dokumentacji CompositionLocal.

Obsługa zmian rozmiaru ekranu

Używanie różnych zasobów dla różnych rozmiarów okien to jeden z głównych sposobów tworzenia elastycznych układów View. Chociaż kwalifikowane zasoby są nadal opcją w przypadku decyzji dotyczących układu na poziomie ekranu, Compose znacznie ułatwia zmianę układów całkowicie w kodzie za pomocą normalnej logiki warunkowej. Więcej informacji znajdziesz w artykule Używanie klas rozmiaru okna.

Zapoznaj się też z artykułem Obsługa różnych rozmiarów wyświetlacza, aby dowiedzieć się więcej o technikach, które Compose oferuje do tworzenia adaptacyjnych interfejsów użytkownika.

Zagnieżdżone przewijanie w widokach

Więcej informacji o tym, jak włączyć współdziałanie przewijania zagnieżdżonego między przewijanymi elementami View i przewijanymi elementami kompozytowymi, zagnieżdżonymi w obu kierunkach, znajdziesz w artykule o współdziałaniu przewijania zagnieżdżonego.

Utwórz w aplikacji RecyclerView

Komponenty kompozycyjne w RecyclerView są wydajne od wersji RecyclerView 1.3.0-alpha02. Aby korzystać z tych funkcji, musisz mieć wersję RecyclerView co najmniej 1.3.0-alpha02.

Interoperacyjność WindowInsets z widokami

Może być konieczne zastąpienie domyślnych wcięć, gdy ekran zawiera zarówno widoki, jak i kod Compose w tej samej hierarchii. W takim przypadku musisz wyraźnie określić, który z nich powinien wykorzystywać wstawki, a który powinien je ignorować.

Jeśli na przykład najbardziej zewnętrzny układ jest układem widoku Androida, należy wykorzystać wstawki w systemie widoku i zignorować je w Compose. Jeśli natomiast układ zewnętrzny jest funkcją kompozycyjną, należy wykorzystać wstawki w Compose i odpowiednio wypełnić funkcje kompozycyjne AndroidView.

Domyślnie każdy element ComposeView wykorzystuje wszystkie wstawki na poziomie wykorzystania WindowInsetsCompat. Aby zmienić to domyślne działanie, ustaw ComposeView.consumeWindowInsets na false.

Więcej informacji znajdziesz w dokumentacji dotyczącej WindowInsets w Compose.