Architektura aplikacji z Android Jetpack PDF
Document Details
Uploaded by SafeBluebell1961
Tags
Summary
Dokument omawia architekturę aplikacji mobilnych z użyciem biblioteki Android Jetpack. Prezentuje podstawowe zasady architektoniczne, takie jak separacja zagadnień i pojedyncze źródło prawdy. Opisuje również jednokierunkowy przepływ danych (UDF).
Full Transcript
Architektura aplikacji Architektura aplikacji z Android Jetpack Działanie aplikacji mobilnej Typowa aplikacja na Androida zawiera wiele elementów: aktywności, fragmenty, usługi, dostawcy treści i odbiorcy rozgłoszeń Użytkownicy często wchodzą w interakcję z wiel...
Architektura aplikacji Architektura aplikacji z Android Jetpack Działanie aplikacji mobilnej Typowa aplikacja na Androida zawiera wiele elementów: aktywności, fragmenty, usługi, dostawcy treści i odbiorcy rozgłoszeń Użytkownicy często wchodzą w interakcję z wieloma aplikacjami w krótkim czasie -> aplikacje muszą dostosowywać się do różnych rodzajów przepływów pracy i zadań sterowanych przez użytkownika Urządzenia mobilne mogą mieć ograniczone zasoby → dowolnym momencie system operacyjny może zakończyć niektóre procesy aplikacji Możliwe jest, że komponenty aplikacji będą uruchamiane pojedynczo i poza kolejnością, a system operacyjny lub użytkownik może je w każdej chwili zniszczyć Programista na te zdarzenia nie ma wpływu → nie należy przechowywać (długo) w pamięci żadnych danych ani stanów aplikacji w komponentach aplikacji; komponenty aplikacji nie powinny być od siebie zależne Podstawowe zasady architektoniczne Architektura aplikacji definiuje granice pomiędzy częściami aplikacji oraz obowiązki, jakie powinna mieć każda część Separacja zagadnień – bardzo ważna zasada częsty błąd - zapisywanie całego kodu w aktywności lub fragmencie aktywności/fragmenty powinny zawierać wyłącznie logikę obsługującą interakcje interfejsu użytkownika i systemu operacyjnego programista nie tworzy implementacji aktywności czy Fragmentu (dziedziczy po nich); są to klasy spajające system z aplikacją; ponieważ mogą być zniszczone najlepiej zminimalizować zależność działania aplikacji od nich Podstawowe zasady architektoniczne Sterowanie UI przez dane - interfejs użytkownika powinien opierać się na modelach danych, najlepiej trwałych Modele danych są niezależne od elementów interfejsu użytkownika i innych komponentów aplikacji → nie są one powiązane z cyklem życia UI i komponentów aplikacji, ale nadal zostaną zniszczone, gdy system operacyjny podejmie decyzję o usunięciu procesu aplikacji z pamięci. Modele trwałe są idealne z następujących powodów: użytkownicy nie stracą danych, jeśli system zakończy aplikację aplikacja będzie nadal działać w przypadkach, gdy połączenie sieciowe jest niestabilne lub niedostępne Dzięki architekturze aplikacji opartej na klasach modelu danych aplikacja będzie bardziej testowalna i niezawodna Podstawowe zasady architektoniczne Pojedyncze źródło prawdy - nowy typ danych powinien być związany z pojedynczym źródłem prawdy (single source of truth = SSOT), które jest właścicielem tych danych i tylko SSOT może je modyfikować Aby to osiągnąć, SSOT udostępnia dane przy użyciu niezmiennego typu Aby zmodyfikować dane, SSOT udostępnia funkcje lub odbiera zdarzenia Ten wzór przynosi wiele korzyści: Centralizuje wszystkie zmiany dotyczące określonego typu danych w jednym miejscu Chroni dane przed niekontrolowanymi modyfikacjami W aplikacji offline źródłem prawdy dla danych aplikacji jest zazwyczaj baza danych; w niektórych innych przypadkach ViewModel lub nawet interfejs użytkownika Podstawowe zasady architektoniczne Jednokierunkowy przepływ danych (unidirectional data flow = UDF) często stosowany w połączeniu z zasadą pojedynczego źródła prawdy w UDF stan płynie tylko w jednym kierunku; zdarzenia modyfikujące przepływ danych w przeciwnym kierunku w Androidzie stan/dane zwykle przepływają z typów hierarchii o szerszym zakresie (żyjących dłużej) do typów o krótszym zakresie (żyjących krócej) zdarzenia są zwykle wyzwalane w typach o krótszym niższym zakresie i są przekazywane dopóki nie osiągną SSOT dla odpowiedniego typu danych Wzorzec ten lepiej gwarantuje spójność danych, jest mniej podatny na błędy, jest łatwiejszy do debugowania i zapewnia wszystkie zalety wzorca SSOT Zalecana architektura aplikacji Z omówionych zasad wynika, że każda aplikacja powinna składać się z co najmniej dwóch warstw: Warstwy interfejsu użytkownika - wyświetlająca dane aplikacji na ekranie Warstwa danych - zawierająca logikę biznesową aplikacji i udostępniająca dane aplikacji Opcjonalnie można dodać warstwę https://developer.android.com domeny, aby uprościć i ponownie wykorzystać interakcje między interfejsem użytkownika a warstwami danych Współczesna architektura aplikacji Współczesna architektura aplikacji (Modern App Architecture) zachęca do stosowania między innymi następujących technik: Architektury reaktywnej i warstwowej Jednokierunkowego przepływu danych (UDF) we wszystkich warstwach aplikacji Warstwy interfejsu użytkownika z posiadaczami stanów do zarządzania złożonością interfejsu użytkownika Współprogramów (coroutines) i przepływ (flows) Najlepszych praktyk wstrzykiwania zależności Zarządzanie zależnościami między komponentami Klasy w aplikacji zależą od innych klas. Aby uzyskać zależności określonej klasy, można użyć jednego z następujących wzorców projektowych: wstrzykiwanie zależności (dependency injection) - umożliwia klasom definiowanie ich zależności bez ich konstruowania; inna klasa jest odpowiedzialna za zapewnienie tych zależności w trakcie wykonania aplikacji lokalizator usług (service locator) - udostępnia rejestr, w którym klasy mogą uzyskać swoje zależności zamiast je konstruować Zalecane jest stosowanie wzorca wstrzykiwania zależności i korzystanie z biblioteki Hilt Ogólne najlepsze praktyki W większości przypadków poniższych zaleceń sprawi, że kod aplikacji lepszy, testowalny i łatwiejszy w utrzymaniu w dłuższej perspektywie Nie należy przechowywać danych w komponentach aplikacji Należy unikać sytuacji, w której punkty wejścia aplikacji (aktywności, usługi i odbiorcy rozgłoszeń) są źródłami danych Każdy komponent aplikacji jest raczej krótkotrwały (w zależności od interakcji z użytkownikiem i stanu systemu) Punkty wejścia powinny tylko pobierać podzbiory danych istotnych dla tego punktu wejścia z innych elementów aplikacji Ogólne najlepsze praktyki Należy ograniczać zależność kodu od klas typowych dla Androida Komponenty aplikacji powinny być jedynymi klasami, które opierają się na API Androida (np. na klasach Context czy Toast) Oddzielenie od nich innych klas w aplikacji pomaga w testowalności i ogranicza zależności w aplikacji Granice odpowiedzialności pomiędzy różnymi modułami w aplikacji powinny być dobrze zdefiniowane Np. nie należy umieszczać kodu ładującego dane z sieci w wielu klasach lub pakietach Nie należy implementować wielu funkcjonalności takich jak buforowanie danych i wiązanie danych - w tej samej klasie Moduły powinny udostępniać tylko niezbędne elementy Nie należy tworzyć skrótów, które udostępniają szczegóły wewnętrznej implementacji modułu (zyski są krótkotrwałe) Ogólne najlepsze praktyki Warto skoncentrować się na rozwoju unikalnych cech aplikacji i korzystać z gotowych rozwiązań Do typowych problemów warto wykorzystać biblioteki Jetpack (i inne zalecane) Należy zapewnić możliwość testowania każdej części aplikacji oddzielnie interfejsy API powinny być dobrze zdefiniowane – wtedy np. dobry interfejs do pobierania danych z sieci ułatwia testowanie modułu utrwalającego te dane w lokalnej bazie danych Typy powinny być odpowiedzialne za swoją politykę współbieżności Jeśli typ wykonuje długotrwałe prace blokujące, powinien być odpowiedzialny za przeniesienie tych obliczeń do odpowiedniego wątku Typ zna rodzaj obliczeń, które wykonuje i w którym wątku powinny być wykonanie Typy powinny być bezpieczne dla głównego wątku (main-safe) = można je bezpiecznie wywoływać z głównego wątku bez jego blokowania Należy zapisać jak najwięcej istotnych i świeżych danych Użytkownicy będą mogli korzystać z funkcjonalności aplikacji nawet wtedy, gdy ich urządzenie będzie w trybie offline Warstwa UI Rola i elementy warstwy UI Rolą warstwy UI (lub warstwy prezentacji) jest wyświetlanie danych aplikacji na ekranie (interfejs użytkownika jest wizualną reprezentacją stanu aplikacji pobraną z warstwy danych) Za każdym razem, gdy dane się zmieniają (interakcja użytkownika, zmiana danych z zewnątrz) interfejs użytkownika powinien zostać zaktualizowany, aby odzwierciedlić zmiany Warstwa interfejsu użytkownika to potok, który konwertuje zmiany danych aplikacji do postaci, którą może zaprezentować interfejs użytkownika, a następnie je wyświetla Warstwa UI składa się z dwóch elementów: elementy interfejsu użytkownika renderujące dane na ekranie https://developer.android.com (widoki / compose) posiadacze stanów (np. ViewModel) przechowują dane, udostępniają je interfejsowi użytkownika i obsługują logikę Architektura warstwy UI Termin interfejs użytkownika odnosi się do elementów interfejsu użytkownika, takich jak aktywności i fragmenty, które wyświetlają dane (nieważne jakiego API używają Views czy Jetpack Compose) Ponieważ rolą warstwy danych jest przechowywanie danych aplikacji, zarządzanie nimi i zapewnianie dostępu do nich, warstwa interfejsu użytkownika musi wykonać następujące kroki: Pobrać dane aplikacji i przekształcić je w dane, które interfejs użytkownika może wyrenderować Korzystając z danych dla interfejsu użytkownika utworzyć elementy interfejsu użytkownika Reagować na zdarzenia wejściowe użytkownika z elementów interfejsu użytkownika i w razie potrzeby odzwierciedlić ich skutki w danych interfejsu użytkownika Powtarzać kroki od 1 do 3 tak długo, jak to konieczne Dalej zostanie pokazany sposób realizacji tych zadań Definiowanie stanu UI https://developer.android.com Definicja stanu interfejsu użytkownika powinna być niezmienna (właściwości definiowane za pomocą val, List (a nie MutableList)…) Główną zaletą takiego rozwiązania jest to, że niezmienne obiekty zapewniają gwarancję stanu aplikacji w danym momencie Dzięki temu interfejs użytkownika może skupić się na jednej roli: odczytywaniu stanu i odpowiedniej aktualizacji elementów interfejsu użytkownika Nigdy nie należy modyfikować stanu interfejsu użytkownika bezpośrednio w interfejsie użytkownika (chyba że sam interfejs użytkownika jest jedynym źródłem jego danych) Naruszenie tej zasady skutkuje powstaniem wielu źródeł prawdy dla tej samej informacji, co prowadzi do niespójności danych i subtelnych błędów Zarządzanie tworzeniem stanu poprzez jednokierunkowy przepływ danych Stan interfejsu użytkownika jest niezmienną migawką szczegółów wymaganych do renderowania interfejsu Stan ten może z czasem się zmieniać Przydatny może być mediator, który przetworzy zdarzenia, zdefiniuje logikę, którą należy zastosować do każdego z nich i wykona wymagane przekształcenia w źródłach danych w celu utworzenia stanu interfejsu użytkownika Logika obsługi zdarzeń nie powinna być umieszczana w samym interfejsie użytkownika bo UI stanie się: staje się właścicielem danych, producentem danych, przekształca dane… O ile stan interfejsu użytkownika nie jest bardzo prosty, wyłączną odpowiedzialnością interfejsu użytkownika powinno być wykorzystanie i wyświetlenie stanu interfejsu użytkownika Jednokierunkowy przepływ danych Właściciele stanu = klasy odpowiedzialne za tworzenie stanu interfejsu użytkownika i zawierające niezbędną logikę Typ ViewModel jest zalecaną implementacją do zarządzania stanem interfejsu użytkownika na poziomie ekranu z dostępem do warstwy danych Klasy ViewModel definiują logikę, która ma być stosowana do zdarzeń w aplikacji i w rezultacie generuje zaktualizowany stan Interakcję między interfejsem użytkownika a jego klasą ViewModel można w rozumieć jako „wejście zdarzenia” i wynikające z niego „wyjście stanu” Jednokierunkowy przepływ danych Wzorzec, w którym stan płynie w dół, a zdarzenia w górę, nazywany jest jednokierunkowym przepływem danych (unidirectional data flow = UDF) ViewModel przechowuje i udostępnia stan, który ma być używany przez UI (stan UI = dane aplikacji przekształcone przez ViewModel) Interfejs użytkownika powiadamia ViewModel o zdarzeniach użytkownika ViewModel obsługuje działania użytkownika i aktualizuje stan (w tym powiadamia warstwę danych https://developer.android.com o zmianach) UDF modeluje cykl produkcji stanu Zaktualizowany stan jest przesyłany z powrotem do interfejsu użytkownika w celu renderowania Rozdziela: (1) miejsce, w którym powstają zmiany stanu, (2) miejsce, w którym ulegają Powyższe powtarza się dla każdego zdarzenia one przemianie, i (3) miejsce, w którym są powodującego zmianę stanu ostatecznie skonsumowane Rodzaje logiki Typy logiki Logika biznesowa - to implementacja wymagań produktu dotyczących danych aplikacji (np. dodanie artykułu do zakładek w aplikacji); logika biznesowa zazwyczaj jest umieszczana w warstwie domeny lub danych (chociaż w warstwie UI (ViewModel) mogą też występować jej elementy np. łączenie danych z kilku repozytoriów) Logika UI - określa sposób wyświetlania zmian stanu na ekranie (np. odczytanie odpowiedniego tekstu z zasobów do wyświetlenia na ekranie, przejście do określonego ekranu po kliknięciu przycisku, wyświetlenie komunikatu użytkownika za pomocą tostu lub snack bara) Logika interfejsu użytkownika, szczególnie jeśli wykorzystuje typy interfejsu użytkownika, takie jak Context, powinna znajdować się w interfejsie użytkownika, a nie w ViewModelu W przypadku złożonego UI można delegować logikę UI do prostej klasy posiadacza stanu - proste klasy utworzone w interfejsie użytkownika mogą przyjmować zależności SDK Androida (zgodny cykl życia z cyklem UI); Obiekty ViewModel mają dłuższą żywotność. Udostępnianie stanu UI W przypadku zastosowania UDF można uznać wytworzony stan za strumień = biegiem czasu zostanie utworzonych wiele wersji stanu Stan UI należy udostępniać użytkownika w obserwowalnym pojemniku na dane, takim jak LiveData lub StateFlow → UI może reagować na zmiany bez ciągłego pobierania danych z ViewModelu Typy te zawsze pamiętają najnowszą wersję stanu interfejsu użytkownika (przydatne do szybkiego przywracania UI po zmianach konfiguracji) W aplikacjach Jetpack Compose można używać API obserwowalnego stanu Compose, takich jak mutableStateOf() lub snapshotFlow(), do udostępniania stanu interfejsu użytkownika Dowolny obserwowalny nośnik danych, taki jak StateFlow lub LiveData można łatwo wykorzystać w Compose przy użyciu odpowiednich funkcji rozszerzających Gdy udostępniane dane są stosunkowo proste, często warto opakować dane w prostą klasę (reprezentującą cały stan UI) - w miarę jak UI staje się coraz bardziej złożony, można łatwo jest dodać do definicji stanu UI dodatkowe informacje Udostępnianie stanu UI Typowym sposobem tworzenia strumienia UiState jest udostępnienie zmiennego strumienia wspierającego jako niezmiennego strumienia odczytywanego z ViewModel class MyViewModel : ViewModel() { var uiState by mutableStateOf(MyUiState()) private set private val _uiState2 = MutableStateFlow(MyUiState()) val uiState2: StateFlow = _uiState2.asStateFlow() //... } Udostępnianie stanu UI ViewModel może też udostępniać metody, które wewnętrznie modyfikują stan, publikując aktualizacje do wykorzystania przez interfejs użytkownika class MyViewModel(private val repository: MyRepository) : ViewModel() { var uiState by mutableStateOf(UiState()) private set private var fetchJob: Job? = null fun fetchArticles(category: String) { fetchJob?.cancel() fetchJob = viewModelScope.launch { try { val myItems = repository.fetchItemsForCategory(category) uiState = uiState.copy(myItems = myItems) } catch (ioe: IOException) { //obsługa błędu i powiadomienie UI val messages = getMessagesFromThrowable(ioe) uiState = uiState.copy(userMessages = messages) } } }} Zawartość obiektu stanu UI Obiekt stanu UI powinien obsługiwać stany, które są ze sobą powiązane (mniejsza liczba niespójności, ułatwia zrozumienie kodu) Udostępnienie danych w dwóch różnych strumieniach może spowodować że jeden zostanie zaktualizowany, a drugi nie Niektóre elementy logiki mogą wymagać kombinacji źródeł np. przycisk zakładki jest pokazywany, gdy użytkownik jest zalogowany i jest subskrybentem płatnego serwisu data class MyUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val myItems: List = listOf() ) val MyUiState.canBookmark: Boolean get() = isSignedIn && isPremium Wiele czy jeden strumieni stanu UI? Kluczową zasadą przy wyborze między udostępnianiem stanu UI w pojedynczym lub w wielu strumieniach jest relacja między emitowanymi elementami Największą zaletą udostępniania jednego strumienia jest wygoda i spójność danych (konsumenci stanu zawsze mają dostęp do najnowszych informacji w dowolnym momencie) Istnieją przypadki, w których odpowiednie mogą być oddzielne strumienie stanu z ViewModel: Niepowiązane typy danych - niektóre stany potrzebne do renderowania UI mogą być całkowicie niezależne od siebie Różnicowanie UiState (UiState diffing) więcej pól w obiekcie UiState → większa szansa że strumień zostanie wyemitowany w wyniku aktualizacji jednego z jego pól Widoki nie mają mechanizmu różnicującego, pozwalającego sprawdzić, czy kolejne emisje są różne, czy takie same, każda emisja powoduje aktualizację widoku Konieczne może być złagodzenie skutków za pomocą API przepływów (Flow) lub metod takich jak distinctUntilChanged() w LiveData (odfiltrowuje powtórzenia tej samej wartości) Konsumowanie stanu UI Aby wykorzystać strumień obiektów UiState w interfejsie użytkownika należy użyć końcowego operatora dla używanego obserwowalnego typu danych LiveData – metoda observe(), przepływy Kotlina – metoda collect() lub jej odmiana @Composable fun MyScreen( viewModel: MyViewModel = viewModel() ) { // Wyświetlenie elementów UI na podstawie viewModel.uiState } Zdarzenia UI Zdarzenia UI to akcje, które powinny być obsługiwane w warstwie UI przez sam UI lub przez ViewModel Najpopularniejszym rodzajem zdarzeń są zdarzenia użytkownika (wywołane jego interakcją z aplikacją) - UI wykorzystuje te zdarzenia za pomocą wywołań zwrotnych, takich jak wywołania zwrotne np. onClick() ViewModel jest zwykle odpowiedzialny za obsługę logiki biznesowej (zapewnia dostęp do logiki biznesowej) konkretnego zdarzenia użytkownika np. kliknięcia przycisku odświeżenia danych zwykle udostępnia metody, które interfejs użytkownika może wywołać, delegujące / deleguje operacje do warstwy domenowej lub warstwy danych Zdarzenia użytkownika mogą również mieć logikę zachowania UI, którą UI obsługuje bezpośrednio (np. nawiguje do innego ekranu, wyświetla Snackbar) Drzewo decyzyjne zdarzeń UI https://developer.android.com Zmniejszenie zależności między UI a ViewModelem nie każdy element UI nie zawsze potrzebuje bezpośredniego dostępu do ViewModelu Logikę można przekazać do elementu stanu jako lambdę – element UI korzysta z lambdy a nie z view modelu Pozwala to zmniejszyć prawdopodobieństwo nadużywania funkcjonalności udostępnianej przez ViewModel i osłabienia zależności między elementami aplikacji data class ItemUiState(val id: Int, val title: String, val text: String, val bookmarked: Boolean = false, val onBookmark: () -> Unit) class LatestNewsViewModel(private val repository: ItemRepository): ViewModel() { val uiItems = repository.latestItems.map { item -> ItemUiState( id = item.id, title = item.title, text = item.text, bookmarked = item.bookmarked, // przekazanie logiki biznesowej jako lambdy do wywołania przez UI przy //obsłudze zdarzeń onBookmark = { repository.addBookmark(item.id) } ) }} Obsługa zdarzeń ViewModelu Działania UI wynikające ze zdarzeń pochodzących z ViewModelu powinny zawsze skutkować aktualizacją stanu UI Powoduje to, że zdarzenia są powtarzalne po zmianach konfiguracji i gwarantuje, że działania interfejsu użytkownika nie zostaną utracone data class LoginUiState( val isLoading: Boolean = false, val errorMessage: String? = null, val isUserLoggedIn: Boolean = false ) class LoginViewModel : ViewModel() { var uiState by mutableStateOf(LoginUiState()) private set //... } Obsługa zdarzeń ViewModelu @Composable fun LoginScreen(viewModel: LoginViewModel = viewModel(),onUserLogIn: () -> Unit ) { //remember nie wykonuje się przy rekompozycji; rememberUpdatedState - zawsze //pamięta najnowszą wartość parametru (w tym wypadku najnowszą lambdę) val currentOnUserLogIn by rememberUpdatedState(onUserLogIn) // Za każdym razem gdy zmienia się uiState sprawdzamy czy użytkownik jest //zalogowany LaunchedEffect(viewModel.uiState) { if (viewModel.uiState.isUserLoggedIn) { //nawigowanie do właściwego celu w zależności od stanu zalogowania currentOnUserLogIn() } } //... } Konsumowanie zdarzeń może wyzwalać aktualizacje stanu Konsumowanie zdarzeń ViewModelu w interfejsie użytkownika może skutkować innymi aktualizacjami stanu UI Np. podczas wyświetlania przejściowych komunikatów na ekranie, UI musi powiadomić ViewModel, aby wyzwolił kolejną aktualizację stanu, gdy komunikat zostanie wyświetlony na ekranie Zdarzenie, które ma miejsce, gdy użytkownik skonsumuje wiadomość (poprzez odrzucenie jej lub po przekroczeniu limitu czasu), można potraktować jako „wprowadzanie danych przez użytkownika” i o tym ViewModel powinien być poinformowany Konsumowanie zdarzeń może wyzwalać aktualizacje stanu ViewModel nie musi wiedzieć, w jaki sposób interfejs użytkownika wyświetla komunikat na ekranie; po prostu wie, że istnieje komunikat użytkownika, który należy wyświetlić class LatestItemsViewModel : ViewModel() { var uiState by mutableStateOf(LatestItemsUiState()) private set fun refreshItems() { viewModelScope.launch { //jeżeli nie ma połączenia - wyświetlić komunikat if (!internetConnection()) { uiState = uiState.copy(userMessage = "No Internet connection") return@launch } //pobranie nowych danych... } } fun userMessageShown() { uiState = uiState.copy(userMessage = null) } } Konsumowanie zdarzeń może wyzwalać aktualizacje stanu Po wyświetleniu komunikatu przejściowego interfejs użytkownika musi powiadomić o tym ViewModel, powodując kolejną aktualizację stanu interfejsu użytkownika, która wyczyści właściwość userMessage @Composable fun LatestItemsScreen( snackbarHostState: SnackbarHostState, viewModel: LatestItemsViewModel = viewModel(), ) { //jakieś UI... //jeżeli są jakieś wiadomości do pokazania - wyświetlić i powiadomić //ViewModel viewModel.uiState.userMessage?.let { userMessage -> LaunchedEffect(userMessage) { snackbarHostState.showSnackbar(userMessage) //powiadomienie viewModelu viewModel.userMessageShown() } }} Rodzaje stanu UI Istnieją dwa typy stanu interfejsu użytkownika: Stan ekranu UI – stan do wyświetlenia na ekranie np. klasa stanu zawierająca informacje do wyświetlenia; ten stan jest zwykle połączony z innymi warstwami hierarchii, ponieważ zawiera dane aplikacji. Stan elementu UI - właściwość nieodłącznie związana z elementami UI, które wpływają na sposób ich renderowania (np. widoczny/ukryty) W systemie widoków stan elementu jest wewnętrzny W Compose stan jest zewnętrzny w stosunku do elementu komponowalnego Cykl życia elementów aplikacji oraz typy stanu i logiki interfejsu użytkownika Warstwa UI składa się z dwóch części: jednej zależnej i drugiej niezależnej od cyklu życia interfejsu użytkownika To rozdzielenie określa źródła danych dostępne dla każdej części i dlatego wymaga różnych typów stanu i logiki interfejsu użytkownika: Niezależna od cyklu życia UI – ta część warstwy UI zajmuje się warstwami aplikacji generującymi dane (danych lub domeny) i jest zdefiniowana przez logikę biznesową; Cykl życia, zmiany konfiguracji i odtwarzanie aktywności w UI mogą mieć wpływ na to, czy potok produkujący stanu UI jest aktywny, ale nie mają wpływu na ważność generowanych danych Zależna od cyklu życia UI - ta część warstwy UI zajmuje się logiką interfejsu użytkownika; zmiany konfiguracji i cykl życia bezpośrednio wpływają na ważność źródeł danych → stan może ulec zmianie jedynie wtedy, gdy aktywny jest ich cykl życia (przykład pobieranie zasobów zależnych od konfiguracji, takich jak zlokalizowane ciągi znaków) Cykl życia elementów aplikacji oraz typy stanu i logiki interfejsu użytkownika Część niezależna od cyklu życia UI Część zależna od cyklu życia UI Logika biznesowa Logika UI Stan ekranu --- Potok produkcji stanu UI Potok = kroki podjęte w celu wytworzenia stanu interfejsu użytkownika Niektóre interfejsy użytkownika mogą korzystać zarówno z części potoku niezależnych od cyklu życia interfejsu użytkownika, jak i zależnych od cyklu życia interfejsu użytkownika, albo z żadnej z nich. Prawidłowe są następujące permutacje potoku warstwy interfejsu użytkownika: Stan interfejsu użytkownika tworzony i zarządzany przez sam interfejs użytkownika np. prosty element komponowalny realizujący podstawowy licznik (przyciski ++/--) Logika UI → UI np. pokazanie lub ukrycie przycisku umożliwiającego użytkownikowi przeskoczenie na początek listy Logika biznesowa → UI np. element UI wyświetlający na ekranie zdjęcie bieżącego użytkownika Logika biznesowa → Logika UI → UI np. element UI, który przewija się, aby wyświetlić na ekranie odpowiednie informacje dla danego stanu interfejsu użytkownika Potok produkcji stanu UI Gdy oba rodzaje logiki są stosowane w potoku produkcji stanu interfejsu użytkownika, logika biznesowa musi być zawsze stosowana przed logiką UI Próba zastosowania logiki biznesowej po logice UI oznaczałaby, że logika biznesowa zależy od logiki interfejsu użytkownika – jest to problem, który jest związany z rodzajami logiki i właścicielami ich https://developer.android.com stanów Posiadacze stanu i ich obowiązki Obowiązkiem posiadacza stanu jest przechowywanie stanu, aby aplikacja mogła go odczytać W przypadkach, gdy potrzebna jest logika, pełni ona rolę pośrednika i zapewnia dostęp do źródeł danych, w których znajduje się wymagana logika - posiadacz stanu deleguje logikę do odpowiedniego źródła danych Korzyści: UI jest proste (po prostu czyta swój stan); łatwość konserwacji (modyfikacja logiki bez zmiany UI); testowalność (UI i logikę produkcji stanu można testować niezależnie); czytelność kodu Niezależnie od rozmiaru i zakresu UI, każdy element interfejsu użytkownika ma relację 1:1 z odpowiadającym mu posiadaczem stanu Rodzaje posiadaczy stanu (w zależności od tego jak są związane z cyklem życia UI): posiadacz stanu logiki biznesowej posiadacz stanu logiki UI Lokiga biznesowa i jej posiadacz stanu Posiadacze stanów logiki biznesowej przetwarzają zdarzenia użytkownika i przekształcają dane z warstw danych lub domeny w celu ich wyświetlenia na ekranie Posiadacz stanu logiki biznesowej jest zwykle implementowany za pomocą instancji ViewModel, ponieważ obiekty ViewModel zapewniają wiele funkcji opisanych dalej Aby zapewnić właściwe działanie aplikacji i wygodę użytkownika posiadacze stanów korzystający z logiki biznesowej powinni mieć następujące właściwości (następny slajd) Lokiga biznesowa i jej posiadacz stanu Właściwość Opis właściwości posiadacza stanu logiki biznesowej Tworzyć stan UI Są odpowiedzialni za utworzenie stanu UI dla swoich interfejsów. Stan UI jest często wynikiem przetwarzania zdarzeń użytkownika i odczytywania danych z warstwy domeny i danych Zachowywać stan Zachowują swoje stany i potoki przetwarzania stanów podczas po odtworzeniu odtwarzania aktywności; gdy nie można zachować posiadacza stanu i aktywności zostaje on odtworzony (zwykle po zakończeniu procesu), posiadacz stanu musi być w stanie łatwo odtworzyć swój ostatni stan Przechowywać Posiadacze stanów logiki biznesowej są często używani do zarządzania długotrwały stan stanami celów nawigacji; często zachowują swój stan po zmianach nawigacji, dopóki nie zostaną usunięte z grafu nawigacji Unikalny dla Tworzą stan dla określonej funkcji aplikacji np. TaskEditViewModel i swojego interfejsu zawsze mają zastosowanie tylko do tej funkcji aplikacji. Jednakże ten użytkownika i nie sam posiadacz stanu może obsługiwać konkretną funkcję w różnych nadaje się do wersjach aplikacji (np. na urządzenia mobilne i na telewizory) ponownego użycia ViewModel jako posiadacz stanu logiki biznesowej ViewModel w aplikacjach dla Androida nadaje się do zapewnienia dostępu do logiki biznesowej i przygotowania danych aplikacji do prezentacji na ekranie. Zalety ViewModel: Operacje wyzwalane przez ViewModels przetrwają zmiany konfiguracji Integracja z mechanizmami nawigacji Komponent nawigacji buforuje ViewModele, gdy ekran znajduje się na stosie (back-stack) - po powrocie do celu nawigacji zapewniony jest dostęp do wcześniej załadowanych danych ViewModel jest usuwany, gdy cel nawigacji zostanie usunięte ze stosu (back-stack), co zapewnia automatyczne oczyszczenie stanu (nie jest to równoważne nasłuchiwaniu usunięcia elementu komponowalnego, które może zdarzyć się z wielu powodów np. nawigacja, zmiana konfiguracji) Integracja z innymi bibliotekami Jetpack, takimi jak Hilt (wstrzykiwanie zależności) Logika interfejsu użytkownika i jej posiadacz stanu Logika UI działa na danych dostarczanych przez sam UI (stan elementów, źródła danych UI np. API uprawnień lub zasoby) Posiadacze stanów z logiką UI zazwyczaj mają następujące właściwości: Tworzą stan UI i zarządzają stanem elementów UI Nie są zachowywani przy odtworzeniu aktywności - posiadacze stanów w logice UI są często uzależnieni od źródeł danych z samego UI -> próby zachowania mogą spowodować wyciek pamięci (np. przy zmianie konfiguracji); jeśli istnieje potrzeba długotrwałego zachowania danych należy przekazać je innemu komponentowi (przykładem jest rememberSaveable() w Compose) Logika interfejsu użytkownika i jej posiadacz stanu Zawierają referencje do źródeł danych o zakresie jakim jak UI - można bezpiecznie odwoływać się do źródeł danych, takich jak API związane z cyklem życia i zasoby (cykl życia jest taki sam jak posiadacza) Można ich wielokrotnie używać w wielu interfejsach użytkownika - różne instancje tego samego posiadacza stanu logiki UI mogą być ponownie wykorzystywane w różnych częściach aplikacji Posiadacz stanu logiki UI jest zwykle implementowany za pomocą zwykłej klasy ponieważ sam UI jest odpowiedzialny za utworzenie posiadacza i posiadają taki sam cykl życia np. w Compose posiadacz stanu jest częścią kompozycji i podąża za cyklem życia kompozycji Uwaga: Zwykłe klasy posiadacza stanu są używane, gdy logika UI jest na tyle złożona, że można ją przenieść poza UI; w przeciwnym razie logikę UI można zaimplementować w bezpośrednio w UI Zależności między posiadaczami stanu Posiadacze stanów mogą zależeć na innych posiadaczach stanów, o ile zależności mają równy lub krótszy czas życia np. posiadacz stanu logiki UI może zależeć od innego posiadacza stanu logiki UI posiadacz stanu ekranu może zależeć od posiadacza stanu logiki UI Przykład sytuacji, w której jeden posiadacz żyje dłużej niż inny - posiadacz stanu logiki UI, zależny od posiadacza stanu na poziomie ekranu (np. ViewModelu) → zmniejszałoby to możliwość ponownego użycia posiadacza stanu o krótszym czasie życia i dałoby mu dostęp do większej ilości logiki i stanu, niż faktycznie potrzebuje Jeśli posiadacz stanu o krótszym okresie życia potrzebuje danych od posiadacza stanu o większym zasięgu, jako parametr należy przekazać tylko te informacje, których potrzebuje, zamiast przekazywać instancję posiadacza stanu Przykład zależności @Stable class DrawerState() { //stan szuflady (z Compose) //zależy od innego, wewnętrznego stanu Swipeable internal val swipeableState = SwipeableState() //... } @Stable class MyAppState( // stan aplikacji zależy od stanu szuflady private val drawerState: DrawerState, private val navController: NavHostController ) { } @Composable fun rememberMyAppState( drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), navController: NavHostController = rememberNavController() ): MyAppState = remember(drawerState, navController) { MyAppState(drawerState, navController) } Przykład zależności (posiadacz o dłuższym czasie życia zależny od innego) @Stable class MyScreenState( // NIE PRZEKAZYWAĆ ViewModelu do zwykłej klasy posiadacza stanu // private val viewModel: MyScreenViewModel, // Unit, scaffoldState: ScaffoldState = rememberScaffoldState() ): MyScreenState = remember(someState, doSomething, scaffoldState) { MyScreenState(someState, doSomething, scaffoldState) } @Composable fun MyScreen( modifier: Modifier = Modifier, viewModel: MyScreenViewModel = viewModel(), state: MyScreenState = rememberMyScreenState(someState = viewModel.uiState.map { it.toSomeState() }, doSomething = viewModel::doSomething), //... ) { } Potok produkujący stan UI Produkcja stanu w aplikacjach na Androida może być traktowana jako potok przetwarzania obejmujący: Wejścia - są źródłami zmiany stanu; mogą być: lokalne (w warstwie UI): zdarzenia użytkownika (np. wprowadzenie tekstu) lub API zapewniające dostęp do logiki UI, która steruje zmianami stanu UI (np. wywołanie metody open() na DrawerState) zewnętrzne (dla warstwy UI): źródła z warstwy domeny lub danych, które powodują zmiany stanu UI (np. wiadomości, które zakończyły ładowanie z NewsRepository) kombinacja powyższych Posiadacze stanów - typy, które stosują logikę biznesową i/lub logikę interfejsu użytkownika do źródeł zmiany stanu i przetwarzają zdarzenia użytkownika w celu wygenerowania stanu interfejsu użytkownika Wyjścia - stan UI, który aplikacja ma renderować Potok produkujący stan UI https://developer.android.com API używane w tworzeniu stanu Istnieją dwa główne API używane w produkcji stanów Wybór asynchronicznego API dla wejścia ma większy wpływ na charakter potoku produkcyjnego stanu niż wybór obserwowalnego API dla wyjścia (dane wejściowe decydują o rodzaju przetwarzania) Etap potoku API Wejście Należy używać asynchronicznych API do wykonywania pracy poza wątkiem UI; np. współprogramy lub przepływy (flows) Kotlina, RxJava lub wywołania w Javie Wyjście Należy używać obserwowalnych API posiadaczy danych, aby unieważnić i ponownie wyrenderować UI gdy stan się zmieni; np. StateFlow, State z Compose lub LiveData Jednorazowe API jako źródła zmiany stanu Jednorazowe (one-shot) API to MutableStateFlow i mutableStateOf (w Compose) Obydwa API oferują metody umożliwiające bezpieczne, niepodzielne aktualizacje wartości, które przechowują, niezależnie od tego, czy aktualizacje są synchroniczne, czy asynchroniczne @Stable //użycie mutableStateOf w produkcji stanu interface DiceUiState { val diceValue: Int? //immutable } class MutableDiceUiState : DiceUiState { override var diceValue: Int? by mutableStateOf(null) //mutable } class DiceRollViewModel : ViewModel() { private val _uiState = MutableDiceUiState() //udostępnienie jako niezmienny stan val uiState: DiceUiState = _uiState // wywoływane z UI fun rollDice() { _uiState.diceValue = Random.nextInt(from = 1, until = 7) } } Jednorazowe API jako źródła zmiany stanu //użycie mutableStateFlow w produkcji stanu data class DiceUiState2(val firstDiceValue: Int? = null) class DiceRollViewModel2 : ViewModel() { private val _uiState = MutableStateFlow(DiceUiState2()) // udostępnianie jako niezmienny StateFlow val uiState: StateFlow = _uiState.asStateFlow() // wywoływane z UI fun rollDice() { _uiState.update { currentState -> currentState.copy(firstDiceValue = Random.nextInt(from = 1, until = 7)) } } } Modyfikacja stanu z wywołań asynchronicznych W przypadku zmian stanu, które wymagają wyniku wywołania asynchronicznego należy uruchomić współprogram w odpowiednim zakresie (CoroutineScope). Dzięki temu aplikacja może odrzucić pracę w przypadku anulowania Uwaga: współprogramy uruchomione w viewModelScope działają do zakończenia (normalne lub z wyjątkiem), niezależnie od tego, czy UI jest widoczny, czy nie, chyba że współprogramy zostaną jawnie anulowane lub ViewModel zostanie usunięty Zwykle to nie stanowi problemu ponieważ żądania najczęściej są krótkotrwałe (nie należy używać viewModelScope do żądań trwających 5 sekund lub dłużej) @Stable interface AddEditTaskUiState { val title: String val description: String val userMessage: String? val isTaskSaved: Boolean } private class MutableAddEditTaskUiState : AddEditTaskUiState { override var title: String by mutableStateOf("") override var description: String by mutableStateOf("") override var userMessage: String? by mutableStateOf(null) override var isTaskSaved: Boolean by mutableStateOf(false) } Modyfikacja stanu z wywołań asynchronicznych class AddEditTaskViewModel(val repository:Repository) : ViewModel() { private val _uiState = MutableAddEditTaskUiState() val uiState: AddEditTaskUiState = _uiState private fun createNewTask() { viewModelScope.launch { //ViemModel ma zakres do uruchamiania współprogramów val newTask = Task(uiState.title, uiState.description) try { repository.saveTask(newTask) //zapisane danych (asynchroniczne) _uiState.isTaskSaved = true //modyfikacja stanu } catch(cancellationException: CancellationException) { throw cancellationException //przerwanie współprogramu } catch(exception: Exception) { _uiState.userMessage = getErrorMessage(exception)) } } } } Modyfikacja stanu z wątku działającego w tle Preferowane jest uruchamianie współprogramów w głównym dyspozytorze (main dispacher) w celu wygenerowania stanu interfejsu użytkownika (czyli poza blokiem withContext w przykładzie poniżej) Jeżeli konieczna jest aktualizacja stanu UI w innym kontekście w tle to można: użyć metody withContext(), aby uruchomić współprogram w innym współbieżnym kontekście przy korzystaniu z MutableStateFlow użyć metody update() aby zaktualizować stan (jak zwykle) przy korzystaniu ze stanu w Compose użyć Snapshot.withMutableSnapshot, aby zagwarantować niepodzielne aktualizacje stanu w współbieżnym kontekście Modyfikacja stanu z wątku działającego w tle class DiceRollViewModel3( private val defaultDispatcher: CoroutineContext = Dispatchers.Default //pula wątków ) : ViewModel() { private val _uiState = MutableDiceUiState() val uiState: DiceUiState = _uiState //wywoływane z UI fun rollDice() { viewModelScope.launch() { //inne współprogramy które można wywołać z bierzącego kontekstu //... //zmiana kontekstu withContext(defaultDispatcher) { //aby zagwarantować atomowe aktualizacje Snapshot.withMutableSnapshot { //SlowRandom - klasa, która wykonuje powolne obliczenia _uiState.diceValue = SlowRandom.nextInt(from = 1, until = 7) } } } } } Strumienie jako źródła zmiany stanu W przypadku źródeł zmiany stanu, które z biegiem czasu wytwarzają w strumieniach wiele wartości, agregowanie wyników wszystkich źródeł w spójną całość jest proste do zrealizowania za pomocą combine() class InterestsViewModel(authorsRepository: AuthorsRepository, topicsRepository: TopicsRepository) : ViewModel() { //agregowanie zmian stanu val uiState = combine( authorsRepository.getAuthorsStream(), //Flow topicsRepository.getTopicsStream(), //Flow ) { availableAuthors, availableTopics -> InterestsUiState.Interests(authors = availableAuthors, topics = availableTopics) }.stateIn( //konwersja połączonego Flow do StateFlow //(typ, który może być obserwowany przez UI) scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), //świadomy cyklu życia initialValue = InterestsUiState.Loading //początkowa wartość ) } Strumienie jako źródła zmiany stanu Użycie operatora stateIn do tworzenia StateFlows zapewnia UI bardziej szczegółową kontrolę nad aktywnością potoku produkującego stan. Jako parametr started należy użyć: SharingStarted.WhileSubscribed() - jeśli potok powinien być aktywny tylko wtedy, gdy interfejs użytkownika jest widoczny SharingStarted.Lazily - jeśli potok powinien być aktywny tak długo, jak użytkownik może wrócić do danego UI (tzn. gdy UI na stosie (back-stack) lub w innej zakładce) Jednorazowe i strumieniowe API jako źródła zmiany stanu Gdy potok produkujący stanu zależy zarówno od wywołań jednorazowych (one- shot), jak i strumieni należy przekonwertować wywołania jednorazowe na strumienie i przetwarzać jak w poprzednim przykładzie class TaskDetailViewModel(private val tasksRepository: Repository,savedStateHandle: SavedStateHandle) : ViewModel() { //... private var _isDeleted by mutableStateOf(false) private val _task = tasksRepository.getTaskStream(taskId) val uiState: StateFlow = combine( snapshotFlow { _isDeleted }, //konwersja State do Flow _task //Flow ) { isDeleted, task -> TaskDetailUiState( task = task.data, isDeleted = isDeleted ) } Jednorazowe i strumieniowe API jako źródła zmiany stanu // konwersja na MutableStateFlow - obserwowalny typ do użycia w UI.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = TaskDetailUiState() ) fun deleteTask() = viewModelScope.launch { tasksRepository.deleteTask(taskId) _isDeleted = true } } Inicjalizacja potoku produkującego stan Inicjalizacja polega na ustawieniu warunków początkowych; może obejmować podanie początkowych wartości wejściowych niezbędnych do uruchomienia potoku np. identyfikatora w widoku szczegółowego artykułu lub rozpoczęcia ładowania asynchronicznego Jeśli to możliwe, należy leniwie zainicjować potok ; w praktyce często oznacza to czekanie, aż pojawi się odbiorca produktu - API Flow pozwala na to za pomocą argumentu start w metodzie stateIn Gdy stateIn() nie ma to zastosowania, należy zdefiniować idempotentną funkcję initialize(), aby jawnie uruchomić potok produkujący stanu Inicjalizacja potoku produkującego stan class MyViewModel : ViewModel() { private var initializeCalled = false // metoda jest idempotenta tylko jeżeli jest wywoływana z głównego wątku @MainThread fun initialize() { if(initializeCalled) return initializeCalled = true viewModelScope.launch { //ustawienie wartości początkowych potoku produkującego stan } }} Należy unikać uruchamiania operacji asynchronicznych w bloku init lub konstruktorze ViewModelu Operacje asynchroniczne nie powinny być efektem ubocznym tworzenia obiektu, ponieważ kod asynchroniczny może odczytywać lub zapisywać obiekt przed jego pełną inicjalizacją Nazywa się to „wyciekiem obiektu” i może prowadzić do subtelnych i trudnych do zdiagnozowania błędów Jest to szczególnie ważne podczas pracy ze stanem Compose - gdy ViewModel przechowuje pola stanu Compose, nie wolno uruchamiać współprogramów w bloku inicjującym ViewModel, który aktualizuje pola stanu Compose (wystąpi wyjątek IllegalStateException) Warstwa danych Rola i elementy warstwy danych Warstwa danych aplikacji zawiera logikę biznesową, która składa się z reguł określających, w jaki sposób aplikacja tworzy, przechowuje i zmienia dane Warstwa danych składa się z repozytoriów Każde repozytorium zawierać zero lub więcej źródeł danych Należy utworzyć klasę repozytorium dla każdego typu danych w swojej aplikacji (np. https://developer.android.com MoviesRepository dla filmów i PaymentsRepository dla płatności) Rola i elementy warstwy danych Klasy repozytorium odpowiadają za następujące zadania: Udostępnianie danych pozostałej części aplikacji Centralizacja zmian w danych Rozwiązywanie konfliktów między wieloma źródłami danych Abstrahowanie źródeł danych od reszty aplikacji Realizowanie logiki biznesowej Każda klasa źródła danych powinna odpowiadać za pracę tylko z jednym źródłem danych (plik, sieć, lokalna baza) https://developer.android.com Klasy źródeł danych stanowią pomost pomiędzy aplikacją a faktycznym źródłem danych Rola i elementy warstwy danych Inne warstwy w hierarchii nigdy nie powinny mieć bezpośredniego dostępu do źródeł danych; punktami wejścia do warstwy danych są zawsze klasy repozytorium Posiadacze stanów lub klasy przypadków użycia (warstwa domenowa) nigdy nie powinny mieć źródła danych jako bezpośredniej zależności Używanie klas repozytoriów jako punktów wejścia umożliwia niezależne skalowanie różnych warstw architektury Dane udostępniane przez warstwę danych powinny być niezmienne; wtedy: inne klasy nie mogą nimi manipulować (nie będzie niespójności) mogą być również bezpiecznie obsługiwane przez wiele wątków API udostępniania danych Klasy w warstwie danych zazwyczaj udostępniają funkcje umożliwiające wykonywanie jednorazowych wywołań Create, Read, Update i Delete (CRUD) lub otrzymywanie powiadomień o zmianach danych w czasie Warstwa danych powinna udostępniać następujące elementy w każdym z tych przypadków: operacje jednorazowe - funkcje wstrzymujące (suspend) w Kotlinie; lub wywołania zwrotne albo typy RxJava Single, Maybe lub Completable w Javie dane zmieniające się w czasie – przepływy (Flow) w Kotlinie; lub wywołania zwrotne, które emitujące nowe dane, lub typy RxJava Observable lub Flowable w Javie Wiele poziomów repozytoriów W przypadkach obejmujących bardziej złożone wymagania biznesowe repozytorium może wymagać zależności od innych repozytoriów (np. dane są agregacją z wielu źródeł) Przykładowo repozytorium obsługujące dane uwierzytelniające użytkownika, UserRepository, może zależeć od LoginRepository i https://developer.android.com RegistrationRepository Uwaga: Czasami klasy repozytorium zależne innych repozytoriów są nazywane Manager czyli UserManager zamiast UserRepository Wielowątkowość Odwołania do źródeł danych i repozytoriów powinno być bezpieczne dla głównego wątku (main-safe) tzn. można bezpiecznie wywoływać ich metody z głównego wątku; klasy te odpowiadają za przeniesienie wykonania swojej logiki do odpowiedniego wątku podczas wykonywania długotrwałych operacji blokujących; Większość źródeł danych udostępnia już bezpieczne API, takie jak wywołania metod wstrzymujących (suspend) np. Room, Retrofit lub Ktor; wskazane jest aby repozytoria korzystały z tych API Reprezentowanie modeli biznesowych Modele danych udostępniane z warstwy danych, mogą stanowić podzbiór informacji uzyskanych z różnych źródeł danych Aplikacja często nie potrzebuje wszystkich informacji udostępnianych przez źródło Dobrą praktyką jest oddzielanie klas modeli – pobieranych ze źródła i udostępnianych aplikacji (udostępniane są dane tylko takie, których wymagają inne warstwy hierarchii) Oddzielenie klas modeli jest korzystne z następujących powodów: Oszczędza pamięć aplikacji, redukując dane tylko do tych potrzebnych Dostosowuje typy danych zewnętrznych do typów danych używanych przez aplikację (np. inna reprezentacja dat) Zapewnia lepsze rozdzielenie problemów (separation of concerns) – zespół może podzielić się pracą nad różnymi warstwami jeśli klasa modelu została wcześniej zdefiniowana Można rozszerzyć tę praktykę i zdefiniować osobne klasy modeli także w innych częściach architektury aplikacji — na przykład w klasach źródeł danych i ViewModels Rodzaje operacji na danych Warstwa danych może obsługiwać operacje: Operacje zorientowane na interfejs użytkownika są istotne tylko wtedy, gdy użytkownik znajduje się na określonym ekranie i są anulowane, gdy użytkownik przejdzie gdzie indziej (np. wyświetlanie danych z bazy) są zwykle wyzwalane przez warstwę UI i podążają za cyklem życia obiektu wywołującego - np. cyklem życia ViewModelu Operacje zorientowane na aplikację są istotne, gdy aplikacja jest otwarta; jeśli zostanie zamknięta/proces zostanie zabity, operacje te są anulowane (np. buforowanie wyniku żądania sieciowego) zazwyczaj podążają za cyklem życia klasy aplikacji lub warstwy danych Operacje biznesowe nie można ich anulować; powinny przetrwać śmierć procesu (np. zakończenie przesyłania zdjęcia profilowego użytkownika) zaleca się używanie WorkManagera Typowe zadania – żądanie sieciowe – operacja zorientowana na UI Tworzenie źródła danych; w przykładzie źródło udostępnia metodę zwracającą najnowsze wiadomości źródło wykorzystuje CoroutineDispatcher aby uruchamiać zadania class NewsRemoteDataSource(private val newsApi: NewsApi, private val ioDispatcher: CoroutineDispatcher ) { //pobiera najnowsze wiadomości z sieci //jest bezpieczna dla głównego wątku suspend fun fetchLatestNews(): List = //przeniesienie do wątku (z puli zoptymalizowanej pod kątem IO) //zakładamy, że API nie obsługuje żądań asynchronicznych withContext(ioDispatcher) { newsApi.fetchLatestNews() //jednorazowe (one-shot) wywołanie } } // wykonuje synchroniczne żądania interface NewsApi { fun fetchLatestNews(): List } Typowe zadania – żądanie sieciowe – operacja zorientowana na UI Tworzenie repozytorium repozytorium może po prostu być pośrednikiem dla źródła danych jest też dobrym miejscem do zaimplementowania buforowania pobranych danych class NewsRepository(private val newsRemoteDataSource: NewsRemoteDataSource) { // Mutex zabezpiecza zapisy buforowanych wartości private val latestNewsMutex = Mutex() // bufor na dane z sieci private var latestNews: List = emptyList() suspend fun getLatestNews(refresh: Boolean = false): List { if (refresh || latestNews.isEmpty()) { val networkResult = newsRemoteDataSource.fetchLatestNews() // bezpieczny zapis do latestNews latestNewsMutex.withLock { this.latestNews = networkResult } } return latestNewsMutex.withLock { this.latestNews } }} Typowe zadania – operacja żyjąca dłużej niż ekran – operacja zorientowana na aplikację Aby bufor nie przepadł gdy użytkownik opuści ekran NewsRepository powinno używać CoroutineScope powiązanego z jego cyklem życia NewsRepository powinno otrzymać zakres jako parametr konstruktora (zamiast tworzyć własny CoroutineScope) async() służy do uruchamiania współprogramu w zakresie zewnętrznym wait() jest wywoływany w nowym współprogramie w celu zawieszenia do czasu, aż żądanie sieciowe zwróci wynik, który zostanie zapisany w pamięci podręcznej - jeśli do tego czasu użytkownik będzie nadal na ekranie, zobaczy najnowsze wiadomości; jeśli użytkownik opuści ekran, oczekiwanie na wynik zostanie anulowane, ale logika wewnątrz async() będzie nadal wykonywana Typowe zadania – operacja żyjąca dłużej niż ekran – operacja zorientowana na aplikację class NewsRepository2(private val newsRemoteDataSource: NewsRemoteDataSource, // może być CoroutineScope(SupervisorJob() + Dispatchers.Default) – pula wątków private val externalScope: CoroutineScope) { //... suspend fun getLatestNews(refresh: Boolean = false): List { return if (refresh) { externalScope.async { newsRemoteDataSource.fetchLatestNews().also { networkResult -> latestNewsMutex.withLock { latestNews = networkResult } } }.await() } else { return latestNewsMutex.withLock { this.latestNews } } } } Typowe zadania – operacje, które przetrwają śmierć procesu - operacje biznesowe WorkManager ułatwia planowanie asynchronicznej i niezawodnej pracy i może zarządzać ograniczeniami (np. wykonanie pracy w trakcie ładowania) Logika biznesowa dla tego typu zadań powinna być zamknięta we własnej klasie i traktowana jako osobne źródło danych; WorkManager będzie odpowiedzialny jedynie za zapewnienie wykonania pracy w wątku w tle, gdy zostaną spełnione wszystkie ograniczenia //klasa reprezentująca pracę do wykonania class RefreshLatestNewsWorker( private val newsRepository: NewsRepository, context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = try { newsRepository.refreshLatestNews() Result.success() } catch (error: Throwable) { Result.failure() }} Typowe zadania – operacje, które przetrwają śmierć procesu - operacje biznesowe private const val REFRESH_RATE_HOURS = 4L private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask" private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag" class NewsTasksDataSource(private val workManager: WorkManager) { fun fetchNewsPeriodically() { val fetchNewsRequest = PeriodicWorkRequestBuilder( REFRESH_RATE_HOURS, TimeUnit.HOURS ).setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED).setRequiresCharging(true).build() ).addTag(TAG_FETCH_LATEST_NEWS) workManager.enqueueUniquePeriodicWork( FETCH_LATEST_NEWS_TASK, ExistingPeriodicWorkPolicy.KEEP, fetchNewsRequest.build() ) } fun cancelFetchingNewsPeriodically() { workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS) } } Warstwa domeny (opcjonalna) Rola i elementy warstwy domeny Warstwa domeny - opcjonalna warstwa znajdująca się pomiędzy interfejsem użytkownika a warstwami danych Jest odpowiedzialna za hermetyzację złożonej logiki biznesowej lub prostej logiki biznesowej, która jest ponownie wykorzystywana przez wiele modeli widoków Jest opcjonalna – należy jej używać gdy to konieczne np. aby ograniczyć złożoność lub ułatwić wielokrotne użycie kodu Klasy w tej warstwie typowo są nazywane przypadkami użycia lub interakcjami https://developer.android.com Każdy przypadek użycia powinien odpowiadać za pojedynczą funkcjonalność np. GetTimeZoneUseCase (jeżeli wiele modeli widoków korzysta ze stref czasowych)