Android MVVM, nawigacja i odbieranie argumentów w modelu widoku.

Użycie Android Architecture Components oraz Android MVVM do odbierania argumentów nawigacji w modelach widoków.

Android MVVM, nawigacja i odbieranie argumentów w modelu widoku.

Oto nowoczesny Android MVVM. Biblioteka Navigation z pakietu Android Architecture Components jest naprawdę świetna! Zapomnij o stosowaniu intencji podczas nawigacji. Nowe rozwiązanie wynosi nawigację w Androidzie na nowy, lepszy poziom. Silne typowanie przy przekazywaniu parametrów do widoku czy wizualny reprezentacja nawigacji w aplikacji to tylko dwie z wielu nowości.

Czego dowiesz się w tym artykule?

  1. W jaki sposób odbierać argumenty nawigacji w modelu widoku.
  2. Jak przesłać argumenty podczas powrotu do widoku poprzedniego.
  3. Dowiesz się  jak spiąć to wszystko w architekturze MVVM i przy wykorzystaniu komponentów Jetpack.

Android MVVM oraz opis projektu

Stworzysz dwie klasy bazowe: dla modeli widoków oraz fragmentów. Bazowy model widoku będzie zawierał metodę, w której będziesz odbierać argumenty do niego przesłane. Tę metodę przeciążysz w pochodnych VM (modelach widoku), gdzie jej parametr (obiekt nawigacji) zrzutujesz już na odpowiedni typ.

Sama metoda zostanie wywołana dzięki odpowiedniej LiveData w bazowym fragmencie. Tak naprawdę bazowy fragment będzie odpowiedzialny za nawigację. Jego zadaniem będzie nasłuchiwanie na żądanie nawigacji, a on sam ją wykona i jednocześnie wywoła metodę odpowiedzialną za przekazanie argumentu nawigacji.

Zadanie się skomplikuje, gdy będziemy chcieli przekazać argument do poprzedniego widoku, ale wkrótce wszystkiego się dowiesz 🙂

Przygotowania

Dodaj następujące biblioteki do pliku gradle modułu głównego aplikacji. Plugin SafeArgs.Kotlin umieść u góry pliku pod deklaracją użycia pluginu com.android.application.

apply plugin: "androidx.navigation.safeargs.kotlin"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.navigation:navigation-fragment-ktx:2.3.0-beta01"
implementation "androidx.navigation:navigation-ui-ktx:2.3.0-beta01"
implementation "androidx.lifecycle:lifecycle-viewmodel ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'

Nastomiast w pliku gradle projektu dodaj następujące zależności:

dependencies **{** 
classpath
'com.android.tools.build:gradle:3.6.3'classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.71"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.0-beta01"}

1. Odbieranie argumentów w modelu widoku

Zacznijmy od stworzenia bazy i odbierania argumentów w modelu widoku. Będziemy potrzebować dwóch klas BaseFragment oraz BaseViewModel.

open class BaseViewModel : ViewModel()
{
    val navigationCommands = SingleLiveEvent<NavigationCommand>()

    open fun prepare(args: Bundle?)
    {
        Log.w("App", "prepare: ${this.javaClass.simpleName}")
    }

    fun navigateTo(directions: NavigationCommand)
    {
        navigationCommands.postValue(directions)
    }
}

sealed class NavigationCommand
{
    data class To(val directions: NavDirections) : NavigationCommand()
}

Do omówienia są trzy składowe. Właściwość navigationCommand posłuży nam do uruchomienia nawigacji. Na zmiany w niej będzie nasłuchiwać nasz bazowy fragment, który wkrótce stworzymy. Jej typem bazowym jest LiveData, ale używamy implementacji SingleLiveData.

Jaka jest różnica?

Gdybyśmy użyli zwykłej LiveData do wykonania nawigacji z widoku A do B, następnie wrócili do A, zawierałaby ona już wcześniej przypisaną do niej wartość, co oznaczałoby ponowne wykonanie nawigacji. Dzięki użyciu typu SingleLiveEvent ten problem nie wystąpi, ponieważ tutaj obserwatorzy zostaną powiadomieni podczas przypisania wartości, ale już nie podczas zmian konfiguracji (np. na wskutek obrotu urządzenia, czy powrotu do widoku).

/**
 * A lifecycle-aware observable that sends only new updates after subscription, used for events like
 * navigation and Snackbar messages.
 *
 *
 * This avoids a common problem with events: on configuration change (like rotation) an update
 * can be emitted if the observer is active. This LiveData only calls the observable if there's an
 * explicit call to setValue() or call().
 *
 *
 * Note that only one observer is going to be notified of changes.
 */
class SingleLiveEvent<T> : MutableLiveData<T>() {
    private val pending = AtomicBoolean(false)
    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        if (hasActiveObservers()) {
            Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
        }

        super.observe(owner, Observer { t ->
            if (pending.compareAndSet(true, false)) {
                observer.onChanged(t)
            }
        })
    }

    @MainThread
    override fun setValue(t: T?) {
        pending.set(true)
        super.setValue(t)
    }
    /**
     * Used for cases where T is Void, to make calls cleaner.
     */
    @MainThread
    fun call() {
        value = null
    }
    companion object {
        private val TAG = "SingleLiveEvent"
    }
}
☝️
Oryginalny kod tej klasy napisanie w Javie możesz znaleźć w samplach od Googla (o, tutaj).

To rozwiązanie przyda Ci się w czasie przygody z Androidem i korzystaniem z LiveData wszędzie tam, gdzie nie chcesz, aby obserwatorzy nie zostali poinformowani o zmianie po zmianach konfiguracji urządzenia lub cyklu życia.

Metodę prepare(), będziemy przeciążać w pochodnych VM, aby odbierać w niej parametry z nawigacji. Przejdźmy teraz do bazowego fragmentu.

open class BaseFragment<T : BaseViewModel>(private val vmType: Class<T>) : Fragment()
{
    protected lateinit var viewModel: T
    private val navController by lazy {
        requireActivity().findNavController(R.id.nav_host_fragment_container)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?)
    {
        super.onActivityCreated(savedInstanceState)
        setupViewModel()
    }

    private fun setupViewModel()
    {
        viewModel = ViewModelProvider(this).get(vmType)
        this.viewModel.prepare(arguments)

        this.viewModel.navigationCommands.observe(viewLifecycleOwner, Observer { command ->
            resolveNavigationCommand(command)
        })
    }

    private fun resolveNavigationCommand(command: NavigationCommand)
    {
        when (command)
        {
            is NavigationCommand.To ->
            {
                navController.navigate(command.directions)
            }
        }
    }
}

Bazowy fragment jest generyczny i przyjmuje jako typ VM konkretnego fragmentu. Dla zgodności typów zostało dodane zabezpieczenie polegające na tym, że przypisywany VM rzeczywiście musi nim być, co oznacza, że musi dziedziczyć po BaseViewModel.

W metodzie setupViewModel jest tworzony VM odpowiedniego typu dzięki wykorzystaniu metody get instancji obiektu ViewModelProvider. Jest to standardowy sposób na instancjonowanie VM przy wykorzystaniu Architecture Components. Kolejnym krokiem jest wywołanie metody prepare() VM i przekazanie argumentów (o ile zostały przekazane w nawigacji). Później dodana zostaje subskrypcja navigationCommand modelu widoku, która posłuży do wykonania nawigacji, a w metodzie resolveNavigationCommand() sprawdzamy typ nawigacji. Później rozbudujesz tę metodę o logikę dla akcji powrotu.

Boathouse on a mountain lake
Photo by Luca Bravo / Unsplash

Widoki testowe

Zanim przejdziemy do właściwej nawigacji, musimy przygotować widoki oraz ich modele.  W tym celu dodaj następujące pliki do projektu:

Aktywność

class MainActivity : AppCompatActivity()
{
    val navController: NavController by lazy { findNavController(R.id.nav_host_fragment_container) }

    override fun onCreate(savedInstanceState: Bundle?)
    {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/nav_host_fragment_container"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_main" />

</androidx.constraintlayout.widget.ConstraintLayout>

Właściwość navController przyda się nam w dalszej części artykułu. W definicji widoku zwróć uwagę na element fragment, jego typem jest NavHostFragment. Będzie on naszym kontenerem, w którym będziemy umieszczać kolejne fragmenty zgodnie z nawigacją w aplikacji. Jest to wymagane przez bibliotekę Navigation. Dodatkowo wykorzystujemy atrybut app:navGraph, w którym określamy definicję nawigacji. Wkrótce stworzysz ten plik.

Fragmenty

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical">


    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="Fragment A"
        android:textSize="20sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/argument_value"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="-"
        android:textSize="20sp"
        android:textStyle="bold" />

    <Button
        android:id="@+id/btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Navigate to B" />

</LinearLayout>

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:background="@android:color/holo_green_light"
    android:orientation="vertical">

    <Button
        android:id="@+id/bck"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Back" />


    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="Fragment B"
        android:textSize="20sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/argument_value"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="-"
        android:textSize="20sp"
        android:textStyle="bold" />

</LinearLayout>

Poniżej znajduje się definicja nawigacji w naszej testowej nawigacji. Są tu dodane dwa fragmenty. Zwróć uwagę na atrybuty android:name, ich wartością jest ścieżka do konkretnego fragmentu. Upewnij się,  że podałeś tutaj poprawne wartości.

FragmentA posiada akcję, która po wywołaniu wykona nawigację do FragmentB. Natomiast FragmentB deklaruje, że przyjmuje parametr typu MyDataNavParam oraz nazwie go data. Tutaj też zwróć uwagę na poprawność ścieżki do klasy modelu.

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_main"
    app:startDestination="@id/fragmentA">

    <fragment
        android:id="@+id/fragmentA"
        android:name="krzbb.com.viewmodelnavigationparameters.fragmentA.FragmentA"
        android:label="FragmentA"
        tools:layout="@layout/fragment_a">
        <action
            android:id="@+id/action_fragmentA_to_fragmentB"
            app:destination="@id/fragmentB" />
    </fragment>

    <fragment
        android:id="@+id/fragmentB"
        android:name="krzbb.com.viewmodelnavigationparameters.fragmentB.FragmentB"
        android:label="FragmentB"
        tools:layout="@layout/fragment_b">

        <argument
            android:name="data"
            app:argType="krzbb.com.viewmodelnavigationparameters.common.MyDataNavParam" />
    </fragment>
</navigation>

@Parcelize
data class MyDataNavParam(val customData: String) : Parcelable

Aby można było korzystać z argumentów nawigacji własnego typu, należy skorzystać z adnotacji Parcelize oraz klasy bazowej Parcelable.

Kolej na kod fragmentu. Znajdziesz go poniżej.

class FragmentA : BaseFragment<ViewModelA>(ViewModelA::class.java)
{
    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?): View?
    {
        return inflater.inflate(R.layout.fragment_a, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?)
    {
        super.onActivityCreated(savedInstanceState)

        btn?.setOnClickListener {
            viewModel.navigateToB()
        }

        viewModel.myParameter.observe(viewLifecycleOwner, Observer {
            argument_value.text = it
        })
    }
}

class ViewModelA : BaseViewModel()
{
    val myParameter = MutableLiveData<String>()

    fun navigateToB()
    {
        val customData = "Data: ${Random.nextInt(5, 1000)}"
        val navigationData = MyDataNavParam(customData)

        //Odkomentuj tę linijkę po dodaniu definicji nawigacji w pliku nav_main.xml i przebudowaniu projektu.
       // navigateTo(NavigationCommand.To(FragmentADirections.actionFragmentAToFragmentB(navigationData)))
    }

class FragmentB : BaseFragment<ViewModelB>(ViewModelB::class.java)
{
    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View?
    {
        return inflater.inflate(R.layout.fragment_b, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?)
    {
        super.onActivityCreated(savedInstanceState)

        viewModel.myParameter.observe(viewLifecycleOwner, Observer {
            argument_value.text = it
        })

        bck.setOnClickListener {
        }
    }
}

class ViewModelB : BaseViewModel()
{
    val myParameter = MutableLiveData<String>()

    override fun prepare(args: Bundle?)
    {
        args?.let {
            val navigationParam = FragmentBArgs.fromBundle(it).data
            myParameter.postValue(navigationParam.customData)
        }
    }
}

Odbieranie argumentów jest gotowe.

Jeśli podążałeś krok po kroku za tym, co przedstawiłem do tej pory, po uruchomieniu aplikacji i po kliknięciu przycisku, widok docelowy powinien wyświetlić losową liczbę.

Teraz czas na odebranie argumentu nawigacyjnego przekazanego przez FragmentB we FragmentA, a tak naprawdę w jego modelu widoku 🙂

2. Przekazywanie argumentów na akcji powrotu

Wykorzystamy naszą jedyną w aplikacji aktywność. Kiedy użytkownik będzie chciał się cofnąć do poprzedniego widoku, my wyszukamy w stosie nawigacyjnym fragment poprzedzający względem aktualnego i wywołamy na nim metodę onNavigationResult, która to wywoła odpowiednią metodę w jego VM. Jest to możliwe, ponieważ biblioteka Navigation odkłada na stos kolejne fragmenty (więc ich nie niszczy), do których możemy wrócić. Za chwilę zobaczysz to na przykładzie.  Utwórz następujący interfejs:

interface NavigationBackListener
{
    fun onNavigationResult(result: Any?)
}

Jeżeli chodzi o akcję powrotu, to mamy dwie sytuacje. Pierwsza dotyczy powrotu poprzez kliknięcie jakiegoś elementy w UI, a druga kiedy użytkownika kliknie fizyczny przycisk back. Musimy rozpatrzeć obie sytuacje.

Przejdź do BaseFragment.  Zaktualizuj klasę o następujący kod:

open class BaseFragment<T : BaseViewModel>(private val vmType: Class<T>) : Fragment(),
        NavigationBackListener
{
    private var backCallback: OnBackPressedCallback? = null

    //    *
    //    *
    //    *
    private fun setupViewModel()
    {
        //poprzednio dodany kod
        //
        //
        backCallback =
                requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, true) {
                    viewModel.onBackAction()

                    val data = viewModel.getOnBackNavData()
                    resolveNavigationCommand(NavigationCommand.Back(data))
                }
    }

    private fun resolveNavigationCommand(command: NavigationCommand)
    {
        when (command)
        {
            is NavigationCommand.To ->
            {
                navController.navigate(command.directions)
            }

            is NavigationCommand.Back ->
            {
                if (command.backArgs == null)
                    navController.popBackStack()
                else
                {
                    (requireActivity() as NavigationBackListener).onNavigationResult(command.backArgs)
                }
            }
        }
    }

    override fun onNavigationResult(result: Any?)
    {
        this.viewModel.onBackNavigationResult(result)
    }

    override fun onDestroyView()
    {
        super.onDestroyView()

        backCallback?.remove()
        backCallback = null
    }
}

Zmiany w BaseViewModel:

   open fun onBackNavigationResult(result: Any?)
    {
        Log.w("App", "onBackNavigationResult: ${this.javaClass.simpleName}")
    }

    fun onBackAction() {
        Log.w("App", "onBackAction: ${this.javaClass.simpleName}")
    }

    open fun getOnBackNavData(): Any? = null

Tak teraz będzie wyglądała klasa NavigationCommand:

sealed class NavigationCommand
{
    data class To(val directions: NavDirections) : NavigationCommand()
    data class Back(val backArgs: Any?) : NavigationCommand()
}

Świetnie! Zaimplementowałeś właśnie logikę odpowiedzialną za dwa przypadki przedstawione wcześniej. W metodzie setupViewModel() dodałeś listener na kliknięcie backa systemowego.

Za pomocą metody getOnBackNavData() aktualnego VM, pobieramy argumenty, które należy przekazać do widoku poprzedniego. Wystarczy, że ta metoda zostanie przeciążona i zwróci odpowiedni obiekt. Może też tego nie robić wtedy będzie zwrócony domyślny null. Dalej te dane przekazujemy do obiektu NavigationCommand.Back i dajemy do wykonania w metodzie resolveNavigationCommand().

W niej dodaliśmy wymagane sprawdzenie nowego typu. Jeżeli argumentem jest null, od razu wykonujemy powrót. W innym przypadku rzutujemy aktywność powiązaną z obecnym fragmentem na typ NavigationBackListener i przekazujemy argument nawigacji.

Dwie ostatnie metody fragmentu to onDestroyView(), w którym usuwamy nasłuchiwanie na kliknięcia backa systemowego oraz druga, onNavigationResult(), który zostanie wykonany w tym fragmencie, który ma uzyskać argument przekazany do niego w akcji powrotu. W następnej sekcji opisuję ten przypadek.

Zaimplementuj go w głównej aktywności i uzupełnij jego metodę.

override fun onNavigationResult(result: Any?)
    {
        val childFragmentManager = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_container)?.childFragmentManager

        var backStackListener: FragmentManager.OnBackStackChangedListener by Delegates.notNull()

        backStackListener = FragmentManager.OnBackStackChangedListener {
            (childFragmentManager?.fragments?.get(0) as NavigationBackListener).onNavigationResult(result)

            childFragmentManager.removeOnBackStackChangedListener(backStackListener)
        }

        childFragmentManager?.addOnBackStackChangedListener(backStackListener)
        navController.popBackStack()
    }

Spieszę z wyjaśnieniem. Ta metoda zostanie wywołana we fragmencie w momencie kliknięcia powrotu w aplikacji. Najpierw uzyskujemy dostęp do menadżera fragmentów, aby móc odnaleźć fragment poprzedzający. Kolejnym krokiem jest nasłuchiwanie na wykonanie powrotu w aplikacji.

Przejdźmy do ciała listenera. Kiedy powrót zostanie wykonany, pobieramy pierwszy fragment i rzutujemy go na NavigationBackListener, co pozwoli nam na wywołanie metody onNavigationResult() z przekazanym wcześniej argumentem. Potem usuwamy naszego listenera, aby uniknąć potencjalnego wycieku pamięci.

Na samym końcu wykonujemy zrzucenie aktualnego fragmentu ze stosu nawigacji, co oznacza tyle, że wykonany został powrót.

Fragmenty i modele widoków

Upewnij się, że model widoku pierwszego fragmentu wygląda następująco:

class ViewModelA : BaseViewModel()
{
    val myParameter = MutableLiveData<String>()

    fun navigateToB()
    {
        val customData = "Data: ${Random.nextInt(5, 1000)}"
        val navigationData = MyDataNavParam(customData)

        navigateTo(NavigationCommand.To(FragmentADirections.actionFragmentAToFragmentB(navigationData)))
    }

    override fun onBackNavigationResult(result: Any?)
    {
        if(result is MyDataNavParam)
        {
            myParameter.postValue(result.customData)
        }
    }
}

Pierwsza metoda zostanie wywołana po kliknięciu przycisku we fragmencie. Metoda onBackNavigationResult zostanie wywołana z odpowiednim parametrem w momencie powrotu do tego fragmentu z innego.

Poniżej kod drugiego modelu widoku.

class ViewModelB : BaseViewModel()
{
    var backData = "Fragment B result ${Random.nextInt(5, 1000)}"

    val myParameter = MutableLiveData<String>()

    override fun prepare(args: Bundle?)
    {
        args?.let {
            val navigationParam = FragmentBArgs.fromBundle(it).data
            myParameter.postValue(navigationParam.customData)
        }
    }

    fun onDemandBack() {
        navigateTo(NavigationCommand.Back(getOnBackNavData()))
    }

    override fun getOnBackNavData(): Any? = MyDataNavParam(backData)
}

W metodzie prepare() jak już wiesz, możemy odebrać argument nawigacji. Przesłonięta metoda getOnBackNavData() pozwoli na przekazanie argumentu do widoku poprzedniego. Powrotu do poprzedniego widoku możemy dokonać na dwa sposoby: fizyczny przycisk back lub przycisk na UI. Metoda onDemandBack() służy do wykonania tego drugiego.

Nie zapomnij dodać wywołania metody onDemandBack() we fragmencie w listenerze przycisku powrotu.

Photo by Ken Cheung / Unsplash

To już jest koniec.

Z przedstawionego tutaj sposobu korzystam w swoich aplikacjach natywnych Android. Sądzę, że odbieranie argumentów w modelu widoku jest znacznie bardziej funkcjonalne niż w przypadku odbierania ich w każdym fragmencie i ręczne przekazywanie do VM. Zresztą i tak w większości przypadków argumenty nawigacji będziesz wykorzystywał w VM a rzadko kiedy bezpośrednio we fragmencie.

Gdybyś miał pytania lub pomysł jak można ulepszyć to rozwiązanie, napisz komentarz lub wyślij po prostu do mnie maila 🙂

GitHub - krzbbaranowski/AndroidMVVMViewModelNavigationParameters
Contribute to krzbbaranowski/AndroidMVVMViewModelNavigationParameters development by creating an account on GitHub.
Navigation | Android Developers
Use the Navigation component in Android Jetpack to implement navigation in your app.