Inne uwagi

Migracja z Widoków do Jetpack Compose dotyczy wyłącznie interfejsu, ale aby przeprowadzić ją bezpiecznie i stopniowo, musisz wziąć pod uwagę wiele kwestii. 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 biblioteką AppCompat (np.Theme.AppCompat.*)
  • Material Design 2 z użyciem biblioteki MDC-Android (np.Theme.MaterialComponents.*).
  • Material Design 3 z użyciem biblioteki MDC-Android (np. Theme.Material3.*)

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

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

Jeśli system projektowania aplikacji na to pozwala, zalecamy używanie najnowszej wersji (Material 3). Dostępne są przewodniki po migracji zarówno w przypadku widoków, jak i Compose:

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

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

Więcej informacji znajdziesz w artykułach Systemy projektowe w ComposeMigracja motywów XML do Compose.

Jeśli w aplikacji używasz komponentu Navigation, więcej informacji znajdziesz w artykułach Nawigacja w Compose – interoperacyjnośćMigracja z Jetpack Navigation na Navigation Compose.

Testowanie interfejsu mieszanego Compose/Views

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

Gdy aktywność lub fragment używa Compose, musisz użyć createAndroidComposeRule zamiast ActivityScenarioRule. createAndroidComposeRule jest zintegrowanyActivityScenarioRuleComposeTestRule, który umożliwia jednoczesne testowanie kodu Compose i widoku.

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 testowania interfejsu znajdziesz w sekcjach Współdziałanie z EspressoWspółdziałanie z UiAutomator.

Integracja Compose z dotychczasową architekturą aplikacji

Wzorce architektury jednokierunkowego przepływu danych (UDF) działają bezproblemowo z Compose. Jeśli aplikacja korzysta z innych wzorców architektury, np. Model-View-Presenter (MVP), zalecamy przeniesienie tej części interfejsu do UDF przed wdrożeniem Compose lub w jego trakcie.

Używanie ViewModel w komponowaniu wiadomości

Jeśli używasz biblioteki Architecture ComponentsViewModel, możesz uzyskać dostęp do ViewModel z dowolnego komponentu kompozycyjnego, wywołując funkcję viewModel(), jak wyjaśniono w sekcji Compose i inne biblioteki.

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

Jeśli np. funkcje kompozycyjne są hostowane w aktywności, funkcja viewModel() zawsze zwraca tę samą instancję, która jest czyszczona dopiero po zakończeniu aktywności. W poniższym przykładzie ten sam użytkownik („user1”) jest witany 2 razy, ponieważ ten sam obiekt GreetingViewModel jest ponownie używany we wszystkich komponentach kompozycyjnych w ramach aktywności hosta. Pierwsza utworzona instancja ViewModel jest ponownie używana w innych komponentach.

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

W przypadku wykresów nawigacji elementy ViewModel również mają określony zakres, więc funkcje kompozycyjne, które są miejscem docelowym na wykresie nawigacji, mają inną instancję ViewModel. W tym przypadku ViewModel jest ograniczony do cyklu życia miejsca docelowego i jest usuwany, gdy miejsce docelowe zostanie usunięte z listy wstecznej. W poniższym przykładzie, gdy użytkownik przejdzie do ekranu Profil, zostanie utworzona nowa instancja GreetingViewModel.

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

Źródło danych o stanie

Gdy w jednej części interfejsu użytkownika zastosujesz Compose, może się okazać, że kod Compose i systemu widoków musi współdzielić dane. Jeśli to możliwe, zalecamy umieszczenie tego stanu współdzielonego w innej klasie, która jest zgodna ze sprawdzonymi metodami UDF używanymi przez obie platformy, np. w ViewModel, która udostępnia strumień danych współdzielonych 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, a wszelkie aktualizacje danych muszą być udostępniane drugiemu systemowi. Z zasady źródło informacji powinno należeć do elementu, który znajduje się bliżej korzenia hierarchii interfejsu.

Tworzenie jako źródło wiarygodnych informacji

Użyj funkcji SideEffect composable, 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 użytkowników przez dołączanie do wszystkich kolejnych zdarzeń analitycznych niestandardowych metadanych (w tym przykładzie właściwości użytkownika). Aby przekazać do biblioteki analitycznej typ użytkownika, który obecnie korzysta z aplikacji, użyj funkcji 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.

Traktuj system jako źródło wiarygodnych informacji

Jeśli stan jest własnością systemu widoków i jest udostępniany Compose, zalecamy opakowanie stanu w obiekty mutableStateOf, aby był bezpieczny dla Compose w wielowątkowym środowisku. Jeśli użyjesz tego podejścia, funkcje kompozycyjne zostaną uproszczone, ponieważ nie będą już zawierać źródła prawdy, ale system widoków będzie musiał aktualizować stan modyfikowalny i widoki, które z niego korzystają.

W tym przykładzie CustomViewGroup zawiera TextViewComposeView z elementem TextField, który można komponować. TextView musi wyświetlać treść wpisaną 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

Jeśli stopniowo przechodzisz na Compose, możesz potrzebować wspólnych elementów interfejsu w Compose i systemie widoków. Jeśli na przykład Twoja aplikacja ma niestandardowy komponent CallToActionButton, możesz go używać zarówno na ekranach opartych na Compose, jak i na ekranach opartych na widokach.

W Compose udostępnione elementy interfejsu stają się funkcjami kompozycyjnymi, których można używać ponownie w całej aplikacji, niezależnie od tego, czy element jest stylizowany za pomocą XML, czy jest widokiem niestandardowym. Na przykład możesz utworzyć CallToActionButtonkomponent kompozycyjny dla niestandardowego komponentu Button wezwania do działania.

Aby użyć komponentu w przypadku ekranów opartych na widokach, utwórz niestandardowy element opakowujący widok, który rozszerza AbstractComposeView. W zastąpionym elemencie kompozycyjnym Content umieść utworzony element kompozycyjny opakowany w motyw Compose, jak pokazano 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 kompozycyjne stają się w niestandardowym widoku zmiennymi, które można modyfikować. Dzięki temu widok niestandardowy CallToActionViewButton jest rozszerzalny i można go używać jak tradycyjnego widoku. Przykład znajdziesz poniżej w sekcji View Binding:

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 modyfikowalny, zapoznaj się z sekcją Źródło stanu.

Nadaj priorytet oddzieleniu stanu od prezentacji

Tradycyjnie View jest stanowy. View zarządza polami, które opisują co ma być wyświetlane, a także jak ma to być wyświetlane. Gdy przekształcasz View w Compose, staraj się rozdzielać renderowane dane, aby uzyskać jednokierunkowy przepływ danych, co zostało dokładniej opisane w sekcji przenoszenie stanu.

Na przykład element View ma właściwość visibility, która określa, czy jest widoczny, niewidoczny czy usunięty. Jest to nieodłączna właściwość View. Chociaż inne fragmenty kodu mogą zmieniać widoczność elementu View, 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 jest często powiązana z samym elementem View.

Natomiast Compose ułatwia wyświetlanie zupełnie różnych komponentów kompozycyjnych za pomocą logiki warunkowej w Kotlinie:

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

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

Dzięki wyraźnemu rozdzieleniu zarządzania stanem i logiki prezentacji możesz swobodniej zmieniać sposób wyświetlania treści jako konwersji stanu na interfejs. Możliwość przenoszenia stanu w górę w razie potrzeby sprawia też, że funkcje kompozycyjne są bardziej wielokrotnego użytku, ponieważ własność stanu jest bardziej elastyczna.

Promowanie hermetyzowanych komponentów do wielokrotnego użytku

Elementy View często mają pewne pojęcie o tym, gdzie się znajdują: w elemencie Activity, Dialog, Fragment lub w innym miejscu w hierarchii View. Ponieważ są one często tworzone na podstawie statycznych plików układu, ogólna struktura View jest zwykle bardzo sztywna. Powoduje to silniejsze powiązanie i utrudnia zmianę lub ponowne użycie View.

Na przykład niestandardowy View może zakładać, że ma widok podrzędny określonego typu o określonym identyfikatorze, i bezpośrednio zmieniać jego właściwości w odpowiedzi na jakąś czynność. Powoduje to ścisłe powiązanie tych elementów View: niestandardowy element View może ulec awarii lub uszkodzeniu, jeśli nie może znaleźć elementu podrzędnego, a elementu podrzędnego prawdopodobnie nie można ponownie użyć bez elementu nadrzędnego View.

W przypadku komponentów wielokrotnego użytku w Compose ten problem jest mniejszy. Rodzice mogą łatwo określać stan i wywołania zwrotne, dzięki czemu możesz pisać komponenty wielokrotnego użytku bez konieczności poznania 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 od siebie odseparowane i mniej ze sobą powiązane:

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

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

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

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

Obsługa zmian rozmiaru ekranu

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

Więcej informacji o technikach, które Compose oferuje do tworzenia adaptacyjnych interfejsów, znajdziesz w artykule Obsługa różnych rozmiarów ekranów.

Zagnieżdżone przewijanie w przypadku widoków

Więcej informacji o włączaniu zagnieżdżonego przewijania między elementami View z możliwością przewijania a komponentami z możliwością przewijania zagnieżdżonymi w obu kierunkach znajdziesz w artykule Zagnieżdżone przewijanie.

Tworzenie w RecyclerView

Funkcje kompozycyjne w RecyclerView są wydajne od wersji RecyclerView 1.3.0-alpha02. Aby zobaczyć te korzyści, musisz mieć co najmniej wersję 1.3.0-alpha02 biblioteki RecyclerView.

WindowInsets zgodność 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 element ma korzystać z wcięć, a który ma je ignorować.

Jeśli np. najbardziej zewnętrzny układ to układ widoku Androida, musisz wykorzystać wstawki w systemie widoku i zignorować je w Compose. Jeśli najbardziej zewnętrzny układ jest komponentem, możesz użyć wstawek w Compose i odpowiednio dodać do komponentów AndroidView dopełnienie.

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

Więcej informacji znajdziesz w dokumentacji WindowInsets w Compose.