Neuroróżnorodność w miejscu pracy
15.07.20197 min
Michał Baran

Michał BaranAndroid DeveloperOcado Technology

Tak nie używaj Architecture Components - 5 błędów

Poznaj 5 częstych błędów, jakie popełniane są przy korzystaniu z Architecture Components.

Tak nie używaj Architecture Components - 5 błędów

Lekkie przeoczenia mają mniej lub bardziej poważne konsekwencje - nawet jeśli ich nie popełniasz, to warto o nich pamiętać, aby uniknąć innych problemów w przyszłości. Ten artykuł obejmuje:

  • Wycieki z obserwatorów LiveData we fragmentach
  • Ładowanie danych po każdej rotacji urządzenia
  • Wycieki z ViewModel
  • Wystawienie mutowalnych LiveData do widoków
  • Odtwarzanie zależności ViewModel po każdej zmianie konfiguracji

1. Wycieki z obserwatorów LiveData we fragmentach

Fragmenty mają skomplikowany cykl życia. Kiedy jeden zostaje odłączony i dołączony ponownie, nie zawsze jest on faktycznie niszczony. Np. nie są niszczone zachowane (retained) fragmenty, gdy zmienia się konfiguracja. Kiedy tak się stanie, instancja fragmentu przetrwa i tylko widok fragmentu zostanie zniszczony, wskutek czego onDestroy() nie jest wywoływany i stan DESTROYED nie zostanie osiągnięty.

Oznacza to, że jeśli zaczniemy obserwować LiveData w onCreateView() lub później (zazwyczaj w onActivityCreated()) i przekażemy Fragment jako LifecycleOwner:

class BooksFragment: Fragment() {

    private lateinit var viewModel: BooksViewModel

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_books, container)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(this).get(BooksViewModel::class.java)

        viewModel.liveData.observe(this, Observer { updateViews(it) })  // Risky: Passing Fragment as LifecycleOwner
    }
    
    ...
}


to skończymy przekazując nową, identyczną instancję obserwatora za każdym razem, gdy fragment jest ponownie dołączany, przy czym LiveData nie usunie poprzednich obserwatorów, ponieważ LifecycleOwner (Fragment) nie osiągnął stanu DESTROYED. To ostatecznie prowadzi do zwiększenia liczby identycznych obserwatorów aktywnych w tym samym czasie i wielokrotnego wykonywania tego samego kodu z onChanged().

Kwestia ta została pierwotnie zgłoszona tutaj, a bardziej szczegółowe wyjaśnienia można znaleźć tutaj.

Zalecanym rozwiązaniem jest wykorzystanie cyklu życia widoku fragmentu viagetViewLifecycleOwner() lub getViewLifecycleOwnerLiveData(), które zostały dodane w Support Library 28.0.0 i AndroidX 1.0.0, tak aby LiveData usuwał obserwatory za każdym razem, gdy widok fragmentu zostanie zniszczony:

class BooksFragment : Fragment() {

    ...

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(this).get(BooksViewModel::class.java)

        viewModel.liveData.observe(viewLifecycleOwner, Observer { updateViews(it) })    // Usually what we want: Passing Fragment's view as LifecycleOwner
    }
    
    ...
}

2. Ładowanie danych po każdej rotacji urządzenia

Jesteśmy przyzwyczajeni do umieszczania logiki inicjalizacji i konfiguracji w onCreate() w aktywnościach (i analogicznie w onCreateView() lub później we Fragmentach), więc może być kuszące, aby również w tym momencie uruchomić ładowanie niektórych danych w modelach ViewModel. W zależności od logiki, może to jednak spowodować przeładowanie danych po każdym obrocie (nawet jeśli użyto ViewModel), co w większości przypadków jest niezamierzone i po prostu bezsensowne.

Przykłady:

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val productDetails = MutableLiveData<Resource<ProductDetails>>()
    private val specialOffers = MutableLiveData<Resource<SpecialOffers>>()

    fun getProductsDetails(): LiveData<Resource<ProductDetails>> {
        repository.getProductDetails()  // Loading ProductDetails from network/database
        ...                             // Getting ProductDetails from repository and updating productDetails LiveData
        return productDetails
    }

    fun loadSpecialOffers() {
        repository.getSpecialOffers()   // Loading SpecialOffers from network/database
        ...                             // Getting SpecialOffers from repository and updating specialOffers LiveData
    }
}

class ProductActivity : AppCompatActivity() {

    lateinit var productViewModelFactory: ProductViewModelFactory

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel = ViewModelProviders.of(this, productViewModelFactory).get(ProductViewModel::class.java)

        viewModel.getProductsDetails().observe(this, Observer { /*...*/ })  // (probable) Reloading product details after every rotation
        viewModel.loadSpecialOffers()                                       // (probable) Reloading special offers after every rotation
    }
}


Rozwiązanie zależy również od Twojej logiki. Jeśli na przykład repozytorium będzie cache'ować, to powyższy kod będzie prawdopodobnie w porządku. Jako inne rozwiązanie możemy też:

  • Użyć czegoś podobnego do AbsentLiveData i rozpocząć ładowanie tylko wtedy, gdy dane nie zostały ustawione.
  • Rozpocząć ładowanie danych, gdy są one rzeczywiście potrzebne np. w OnClickListener
  • I prawdopodobnie najprostsze: umieścić wywołania ładowania w konstruktorze ViewModel i eksponować czyste gettery.

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val productDetails = MutableLiveData<Resource<ProductDetails>>()
    private val specialOffers = MutableLiveData<Resource<SpecialOffers>>()

    init {
        loadProductsDetails()           // ViewModel is created only once during Activity/Fragment lifetime
    }

    private fun loadProductsDetails() { // private, just utility method to be invoked in constructor
        repository.getProductDetails()  // Loading ProductDetails from network/database
        ...                             // Getting ProductDetails from repository and updating productDetails LiveData
    }

    fun loadSpecialOffers() {           // public, intended to be invoked by other classes when needed
        repository.getSpecialOffers()   // Loading SpecialOffers from network/database
        ...                             // Getting SpecialOffers from repository and updating _specialOffers LiveData
    }

    fun getProductDetails(): LiveData<Resource<ProductDetails>> {   // Simple getter
        return productDetails
    }

    fun getSpecialOffers(): LiveData<Resource<SpecialOffers>> {     // Simple getter
        return specialOffers
    }
}

class ProductActivity : AppCompatActivity() {

    lateinit var productViewModelFactory: ProductViewModelFactory

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel = ViewModelProviders.of(this, productViewModelFactory).get(ProductViewModel::class.java)

        viewModel.getProductDetails().observe(this, Observer { /*...*/ })    // Just setting observer
        viewModel.getSpecialOffers().observe(this, Observer { /*...*/ })     // Just setting observer

        button_offers.setOnClickListener { viewModel.loadSpecialOffers() }
    }
}

3. Wycieki z ViewModel

Wyraźnie zaznaczyłem, że nie powinniśmy przekazywać referencji ViewModel do ViewModel.

Jednak należy również zachować ostrożność przy przekazywaniu odniesień do modeli ViewModel innym klasom. Po zakończeniu działania (lub analogicznie przez Fragment) do ViewModel nie powinno być żadnej referencji w obiekcie, który będzie żyć dłużej niż aktywność. Tylko wtedy ViewModel będzie mógł być sprzątnięty przez garbage collector.

Przykładem wycieku może być przekazanie wewnątrz ViewModel słuchacza do repozytorium, które jest singletonem, i nie wyczyszczenie słuchacza po wszystkim:

@Singleton
class LocationRepository() {

    private var listener: ((Location) -> Unit)? = null

    fun setOnLocationChangedListener(listener: (Location) -> Unit) {
        this.listener = listener
    }

    private fun onLocationUpdated(location: Location) {
        listener?.invoke(location)
    }
}


class MapViewModel: AutoClearViewModel() {

    private val liveData = MutableLiveData<LocationRepository.Location>()
    private val repository = LocationRepository()

    init {
        repository.setOnLocationChangedListener {   // Risky: Passing listener (which holds reference to the MapViewModel)
            liveData.value = it                     // to singleton scoped LocationRepository
        }
    }
}


Rozwiązaniem tutaj może być usunięcie słuchacza w metodzie onCleared(), zapisanie go jako WeakReference w Repozytorium, wykorzystanie LiveData do komunikacji pomiędzy Repozytorium a ViewModel - lub w zasadzie wszystko, co Ci odpowiada i zapewnia poprawne odśmiecanie.

@Singleton
class LocationRepository() {

    private var listener: ((Location) -> Unit)? = null

    fun setOnLocationChangedListener(listener: (Location) -> Unit) {
        this.listener = listener
    }

    fun removeOnLocationChangedListener() {
        this.listener = null
    }

    private fun onLocationUpdated(location: Location) {
        listener?.invoke(location)
    }
}


class MapViewModel: AutoClearViewModel() {

    private val liveData = MutableLiveData<LocationRepository.Location>()
    private val repository = LocationRepository()

    init {
        repository.setOnLocationChangedListener {   // Risky: Passing listener (which holds reference to the MapViewModel)
            liveData.value = it                     // to singleton scoped LocationRepository
        }
    }
  
    override onCleared() {                            // GOOD: Listener instance from above and MapViewModel
        repository.removeOnLocationChangedListener()  //       can now be garbage collected
    }  
}

4. Wystawienie mutowalnych LiveData do widoków

To nie jest bug, ale kłóci się z naszym podziałem odpowiedzialności

Widoki - Fragmenty i działania - nie powinny być w stanie aktualizować LiveData i tym samym własnego stanu, ponieważ za to odpowiadają ViewModels. Widoki powinny być w stanie tylko obserwować LiveData.

Dlatego też powinniśmy hermetyzować dostęp do danych MutableLive Data przez użycie np: getterów lub właściwości wspierających (backing property):

class CatalogueViewModel : ViewModel() {

    // BAD: Exposing mutable LiveData
    val products = MutableLiveData<Products>()


    // GOOD: Encapsulate access to mutable LiveData through getter
    private val promotions = MutableLiveData<Promotions>()

    fun getPromotions(): LiveData<Promotions> = promotions


    // GOOD: Encapsulate access to mutable LiveData using backing property
    private val _offers = MutableLiveData<Offers>()
    val offers: LiveData<Offers> = _offers


    fun loadData(){
        products.value = loadProducts()     // Other classes can also set products value
        promotions.value = loadPromotions() // Only CatalogueViewModel can set promotions value
        _offers.value = loadOffers()        // Only CatalogueViewModel can set offers value
    }
}

5. Odtwarzanie zależności ViewModel po każdej zmianie konfiguracji

ViewModels przetrwają zmiany konfiguracji, takie jak rotacje, więc tworzenie ich zależności za każdym razem, gdy ma miejsce jakaś zmiana, jest po prostu zbędne i może czasami prowadzić do niezamierzonego zachowania, zwłaszcza jeśli w zależnościach będzie jakaś logika w konstruktorach.

Choć może się to wydawać oczywiste, jest to coś, co łatwo przeoczyć podczas korzystania z ViewModelFactory, który zazwyczaj ma te same zależności co ViewModel, który tworzy.

ViewModelProvider zachowuje instancję ViewModel, ale nie instancję ViewModelFactory, więc jeśli mamy taki kod:

class MoviesViewModel(
    private val repository: MoviesRepository,
    private val stringProvider: StringProvider,
    private val authorisationService: AuthorisationService
) : ViewModel() {
    
    ...
}


class MoviesViewModelFactory(   // We need to create instances of below dependencies to create instance of MoviesViewModelFactory
    private val repository: MoviesRepository,
    private val stringProvider: StringProvider,
    private val authorisationService: AuthorisationService
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {  // but this method is called by ViewModelProvider only if ViewModel wasn't already created
        return MoviesViewModel(repository, stringProvider, authorisationService) as T
    }
}


class MoviesActivity : AppCompatActivity() {

    @Inject
    lateinit var viewModelFactory: MoviesViewModelFactory

    private lateinit var viewModel: MoviesViewModel

    override fun onCreate(savedInstanceState: Bundle?) {    // Called each time Activity is recreated
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movies)

        injectDependencies() // Creating new instance of MoviesViewModelFactory

        viewModel = ViewModelProviders.of(this, viewModelFactory).get(MoviesViewModel::class.java)
    }
    
    ...
}


za każdym razem, gdy nastąpi zmiana konfiguracji, będziemy tworzyć nową instancję ViewModelFactory i dlatego niepotrzebnie będziemy tworzyć nowe instancje wszystkich jej zależności (zakładając, że nie mają w jakiś sposób ograniczonego zasięgu).

Rozwiązanie tym razem polega na odroczeniu tworzenia zależności do momentu faktycznego wywołania metody create(), ponieważ jest ona wywoływana tylko raz w czasie trwania aktywności/cyklu życia fragmentu. Możemy to osiągnąć używając leniwej inicjalizacji z np. providerami:

class MoviesViewModel(
    private val repository: MoviesRepository,
    private val stringProvider: StringProvider,
    private val authorisationService: AuthorisationService
) : ViewModel() {
    
    ...
}


class MoviesViewModelFactory(
    private val repository: Provider<MoviesRepository>,             // Passing Providers here 
    private val stringProvider: Provider<StringProvider>,           // instead of passing directly dependencies
    private val authorisationService: Provider<AuthorisationService>
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {  // This method is called by ViewModelProvider only if ViewModel wasn't already created
        return MoviesViewModel(repository.get(),                    
                               stringProvider.get(),                // Deferred creating dependencies only if new insance of ViewModel is needed
                               authorisationService.get()
                              ) as T
    }
}


class MoviesActivity : AppCompatActivity() {

    @Inject
    lateinit var viewModelFactory: MoviesViewModelFactory

    private lateinit var viewModel: MoviesViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movies)
      
        injectDependencies() // Creating new instance of MoviesViewModelFactory

        viewModel = ViewModelProviders.of(this, viewModelFactory).get(MoviesViewModel::class.java)
    }
    
    ...
}

Dodatkowe zasoby

ViewModels i LiveData: Wzorce + Antywzorce
Pułapki Architecture Components - Część 1
Projekt Android Architecture
7 Pro-tipów dla Room
Oficjalna dokumentacja


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>