Koncepcje i implementacja Jetpack Compose
Zdarzenia interfejsu to działania, które powinny być obsługiwane w warstwie interfejsu – przez interfejs lub ViewModel. Najczęstszym typem zdarzeń są zdarzenia użytkownika. Użytkownik generuje zdarzenia użytkownika, wchodząc w interakcję z aplikacją – na przykład dotykając ekranu lub wykonując gesty. Interfejs korzysta z tych zdarzeń za pomocą wywołań zwrotnych, takich jak słuchacze onClick().
ViewModel jest zwykle odpowiedzialny za obsługę logiki biznesowej konkretnego zdarzenia użytkownika – na przykład kliknięcia przez użytkownika przycisku odświeżania danych. Zwykle ViewModel obsługuje to, udostępniając funkcje, które może wywoływać interfejs. Zdarzenia użytkownika mogą też mieć logikę zachowania interfejsu, którą interfejs może obsługiwać bezpośrednio – na przykład przechodzenie do innego ekranu lub wyświetlanie Snackbar.
O ile logika biznesowa pozostaje taka sama w przypadku tej samej aplikacji na różnych platformach mobilnych lub urządzeniach, o tyle logika zachowania interfejsu jest szczegółem implementacji, który może się różnić w zależności od tych przypadków. Na stronie warstwy interfejsu użytkownika te typy logiki są zdefiniowane w ten sposób:
- Logika biznesowa odnosi się do tego, co należy zrobić ze zmianami stanu – na przykład dokonać płatności lub zapisać preferencje użytkownika. Zwykle ta logika jest obsługiwana przez warstwy domeny i danych. W tym przewodniku klasa Architecture Components ViewModel jest używana jako rozwiązanie dla klas obsługujących logikę biznesową.
- Logika zachowania interfejsu lub logika interfejsu odnosi się do tego, jak wyświetlać zmiany stanu – na przykład logika nawigacji lub sposób wyświetlania komunikatów użytkownikowi. Ta logika jest obsługiwana przez interfejs.
Obsługa zdarzeń użytkownika
Interfejs może bezpośrednio obsługiwać zdarzenia użytkownika, jeśli są one związane z modyfikowaniem stanu elementu interfejsu – na przykład stanu elementu rozwijanego. Jeśli zdarzenie wymaga wykonania logiki biznesowej, np. odświeżenia danych na ekranie, powinno być przetwarzane przez ViewModel.
Poniższy przykład pokazuje, jak różne przyciski służą do rozwijania elementu interfejsu (logika interfejsu) i odświeżania danych na ekranie (logika biznesowa):
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand details event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
Zdarzenia użytkownika w RecyclerView
Jeśli działanie jest wykonywane w dalszej części drzewa interfejsu, np. w elemencie RecyclerView
lub niestandardowym View, to ViewModel powinien nadal obsługiwać zdarzenia użytkownika.
Załóżmy na przykład, że wszystkie artykuły w NewsActivity zawierają przycisk dodawania do zakładek. ViewModel musi znać identyfikator artykułu dodanego do zakładek. Gdy użytkownik doda artykuł do zakładek, adapter RecyclerView nie wywoła udostępnionej funkcji addBookmark(newsId) z ViewModel, co wymagałoby zależności od ViewModel. Zamiast tego ViewModel udostępnia obiekt stanu o nazwie NewsItemUiState, który zawiera implementację obsługi zdarzenia:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
W ten sposób adapter RecyclerView działa tylko z potrzebnymi mu danymi – listą obiektów NewsItemUiState. Adapter nie ma dostępu do całego ViewModel, co zmniejsza prawdopodobieństwo nadużycia funkcji udostępnianych przez ViewModel. Gdy zezwolisz tylko klasie aktywności na pracę z ViewModel, rozdzielisz obowiązki. Dzięki temu obiekty specyficzne dla interfejsu, takie jak widoki czy adaptery RecyclerView, nie będą wchodzić w bezpośrednią interakcję z ViewModel.
Konwencje nazewnictwa funkcji zdarzeń użytkownika
W tym przewodniku funkcje ViewModel, które obsługują zdarzenia użytkownika, są nazywane czasownikiem na podstawie działania, które obsługują – na przykład addBookmark(id) lub logIn(username, password).
Obsługa zdarzeń ViewModel
Działania interfejsu, które pochodzą z ViewModel – zdarzenia ViewModel – powinny zawsze powodować aktualizację stanu interfejsu. Jest to zgodne z zasadami jednokierunkowego przepływu danych. Dzięki temu zdarzenia są powtarzalne po zmianach konfiguracji i gwarantują, że działania interfejsu nie zostaną utracone. Opcjonalnie, możesz też sprawić, że zdarzenia będą powtarzalne po śmierci procesu, jeśli używasz modułu zapisanego stanu.
Mapowanie działań interfejsu na stan interfejsu nie zawsze jest proste, ale prowadzi do prostszej logiki. Twój proces myślowy nie powinien kończyć się na określeniu, jak sprawić, aby interfejs przechodził do określonego ekranu. Musisz pomyśleć dalej i zastanowić się, jak przedstawić ścieżkę użytkownika w stanie interfejsu. In nymi słowy: nie zastanawiaj się, jakie działania musi wykonać interfejs, ale jak te działania wpływają na stan interfejsu.
Rozważmy na przykład przypadek przejścia do ekranu głównego, gdy użytkownik jest zalogowany na ekranie logowania. Możesz modelować to w stanie interfejsu w ten sposób:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
Ten interfejs reaguje na zmiany stanu isUserLoggedIn i w razie potrzeby przechodzi do odpowiedniego miejsca docelowego:
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Zużywanie zdarzeń może wywoływać aktualizacje stanu
Zużywanie niektórych zdarzeń ViewModel w interfejsie może powodować inne aktualizacje stanu interfejsu. Na przykład podczas wyświetlania na ekranie komunikatów tymczasowych, aby poinformować użytkownika o tym, że coś się stało, interfejs musi powiadomić ViewModel, aby wywołać kolejną aktualizację stanu, gdy komunikat zostanie wyświetlony na ekranie. Zdarzenie, które występuje, gdy użytkownik zużyje komunikat (odrzuci go lub po upływie limitu czasu), można traktować jako „dane wejściowe użytkownika”, dlatego ViewModel powinien o tym wiedzieć. W takiej sytuacji stan interfejsu można modelować w ten sposób:
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
ViewModel zaktualizuje stan interfejsu w ten sposób, gdy logika biznesowa wymaga wyświetlenia użytkownikowi nowego komunikatu tymczasowego:
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
ViewModel nie musi wiedzieć, jak interfejs wyświetla komunikat na ekranie. Wie tylko, że jest komunikat użytkownika, który trzeba wyświetlić. Gdy komunikat tymczasowy zostanie wyświetlony, interfejs musi powiadomić o tym ViewModel, co spowoduje kolejną aktualizację stanu interfejsu, aby wyczyścić właściwość userMessage:
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
Mimo że komunikat jest tymczasowy, stan interfejsu jest wiernym odzwierciedleniem tego, co jest wyświetlane na ekranie w każdym momencie. Komunikat użytkownika jest wyświetlany lub nie.
Zdarzenia nawigacji
W sekcji Zużywanie zdarzeń może wywoływać aktualizacje stanu opisujemy, jak używać stanu interfejsu do wyświetlania komunikatów użytkownika na ekranie. Zdarzenia nawigacji są też częstym typem zdarzeń w aplikacji na Androida.
Jeśli zdarzenie jest wywoływane w interfejsie, ponieważ użytkownik kliknął przycisk, interfejs zajmuje się tym, wywołując kontroler nawigacji.
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.helpButton.setOnClickListener {
navController.navigate(...) // Open help screen
}
}
}
Jeśli dane wejściowe wymagają sprawdzenia logiki biznesowej przed przejściem do innej strony, ViewModel musi udostępnić ten stan interfejsowi. Interfejs zareaguje na tę zmianę stanu i odpowiednio przejdzie do innej strony. W sekcji Obsługa zdarzeń ViewModel opisujemy ten przypadek użycia. Oto podobny kod:
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
W powyższym przykładzie aplikacja działa zgodnie z oczekiwaniami, ponieważ bieżące miejsce docelowe (Logowanie) nie będzie przechowywane na stosie wstecznym. Użytkownicy nie mogą do niego wrócić, naciskając przycisk Wstecz. W przypadkach, w których może się to zdarzyć, rozwiązanie będzie wymagać dodatkowej logiki.
Zdarzenia nawigacji, gdy miejsce docelowe jest przechowywane na stosie wstecznym
Gdy ViewModel ustawi stan, który powoduje zdarzenie nawigacji z ekranu A na ekran B, a ekran A jest przechowywany na stosie wstecznym nawigacji, może być potrzebna dodatkowa logika, aby nie przechodzić automatycznie do ekranu B. Aby to zaimplementować, wymagany jest dodatkowy stan, który wskazuje, czy interfejs powinien przejść do innego ekranu. Zwykle ten stan jest przechowywany w interfejsie, ponieważ logika nawigacji jest kwestią interfejsu, a nie ViewModel. Aby to zilustrować, rozważmy ten przypadek użycia.
Załóżmy, że jesteś w procesie rejestracji w aplikacji. Na ekranie sprawdzania poprawności daty urodzenia , gdy użytkownik wpisze datę, ViewModel sprawdzi ją, gdy użytkownik kliknie przycisk „Dalej”. ViewModel przekazuje logikę sprawdzania poprawności do warstwy danych. Jeśli data jest prawidłowa, użytkownik przechodzi do następnego ekranu. Dodatkowo użytkownicy mogą przechodzić między różnymi ekranami rejestracji, jeśli chcą zmienić niektóre dane. Dlatego wszystkie miejsca docelowe w procesie rejestracji są przechowywane na tym samym stosie wstecznym. Biorąc pod uwagę te wymagania, możesz zaimplementować ten ekran w ten sposób:
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
Sprawdzanie poprawności daty urodzenia to logika biznesowa , za którą odpowiada ViewModel. W większości przypadków ViewModel przekazuje tę logikę do warstwy danych. Logika przejścia użytkownika do następnego ekranu to logika interfejsu , ponieważ te wymagania mogą się zmieniać w zależności od konfiguracji interfejsu. Na przykład możesz nie chcieć automatycznie przechodzić do innego ekranu na tablecie, jeśli wyświetlasz jednocześnie kilka kroków rejestracji. Zmienna validationInProgress w powyższym kodzie implementuje tę funkcję i określa, czy interfejs powinien automatycznie przechodzić do następnego kroku rejestracji, gdy data urodzenia jest prawidłowa, a użytkownik chce kontynuować.
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy język JavaScript jest wyłączony.
- Warstwa interfejsu
- Obiekty stanu i stan interfejsu {:#mad-arch}
- Przewodnik po architekturze aplikacji