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.