Concetti e implementazione di Jetpack Compose
Gli eventi UI sono azioni che devono essere gestite nel livello UI, dall'UI o dal ViewModel. Il tipo di eventi più comune sono gli eventi utente. L'utente genera eventi utente interagendo con l'app, ad esempio toccando lo schermo o generando gesti. L'UI utilizza quindi questi eventi tramite callback come i listener onClick().
Il ViewModel è normalmente responsabile della gestione della logica di business di un determinato evento utente, ad esempio l'utente che fa clic su un pulsante per aggiornare alcuni dati. In genere, il ViewModel gestisce questa operazione esponendo le funzioni che l'UI può chiamare. Gli eventi utente potrebbero anche avere una logica di comportamento dell'UI che l'UI può gestire
direttamente, ad esempio passare a un'altra schermata o mostrare un
Snackbar.
Sebbene la logica di business rimanga la stessa per la stessa app su piattaforme mobili o fattori di forma diversi, la logica di comportamento dell'UI è un dettaglio di implementazione che potrebbe variare a seconda dei casi. La pagina del livello UI definisce questi tipi di logica come segue:
- La logica di business si riferisce a cosa fare con le modifiche dello stato, ad esempio effettuare un pagamento o memorizzare le preferenze dell'utente. In genere, questa logica viene gestita dai livelli di dominio e dati. In questa guida, la classe ViewModel dei componenti dell'architettura viene utilizzata come soluzione basata su opinioni per le classi che gestiscono la logica di business.
- La logica di comportamento dell'UI o logica dell'UI si riferisce a come visualizzare le modifiche dello stato, ad esempio la logica di navigazione o come mostrare i messaggi all'utente. Questa logica viene gestita dall'UI.
Gestire gli eventi utente
L'UI può gestire direttamente gli eventi utente se questi eventi riguardano la modifica dello stato di un elemento UI, ad esempio lo stato di un elemento espandibile. Se l'evento richiede l'esecuzione della logica di business, ad esempio l'aggiornamento dei dati sullo schermo, deve essere elaborato dal ViewModel.
L'esempio seguente mostra come vengono utilizzati diversi pulsanti per espandere un elemento UI (logica dell'UI) e per aggiornare i dati sullo schermo (logica di business):
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()
}
}
}
Eventi utente in RecyclerView
Se l'azione viene generata più in basso nell'albero dell'UI, ad esempio in un RecyclerView
elemento o in una View personalizzata, il ViewModel deve comunque essere quello che gestisce gli eventi
utente.
Supponiamo, ad esempio, che tutti gli articoli di notizie di NewsActivity contengano un pulsante per i preferiti. Il ViewModel deve conoscere l'ID dell'articolo di notizie aggiunto ai preferiti. Quando l'utente aggiunge un articolo di notizie ai preferiti, l'adattatore RecyclerView non chiama la funzione addBookmark(newsId) esposta dal ViewModel, che richiederebbe una dipendenza dal ViewModel. Il ViewModel espone invece un oggetto di stato chiamato NewsItemUiState che contiene l'implementazione per la gestione dell'evento:
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)
}
)
}
}
In questo modo, l'adattatore RecyclerView funziona solo con i dati di cui ha bisogno: l'elenco degli oggetti NewsItemUiState. L'adattatore non ha accesso all'intero ViewModel, il che riduce la probabilità di un utilizzo improprio della funzionalità esposta dal ViewModel. Quando consenti solo alla classe di attività di lavorare con il ViewModel, separi le responsabilità. In questo modo, gli oggetti specifici dell'UI, come le visualizzazioni o gli adattatori RecyclerView, non interagiscono direttamente con il ViewModel.
Convenzioni di denominazione per le funzioni degli eventi utente
In questa guida, le funzioni ViewModel che gestiscono gli eventi utente vengono denominate con un verbo basato sull'azione che gestiscono, ad esempio: addBookmark(id) o logIn(username, password).
Gestire gli eventi ViewModel
Le azioni dell'UI che hanno origine dal ViewModel, ovvero gli eventi ViewModel, devono sempre comportare un aggiornamento dello stato dell'UI. Ciò è conforme ai principi del flusso di dati unidirezionale. Rende gli eventi riproducibili dopo le modifiche alla configurazione e garantisce che le azioni dell'UI non vadano perse. Se utilizzi il modulo dello stato salvato , puoi anche rendere gli eventi riproducibili dopo l'interruzione del processo.
Il mapping delle azioni dell'UI allo stato dell'UI non è sempre un processo semplice, ma porta a una logica più semplice. Il tuo processo di pensiero non dovrebbe terminare con la determinazione di come fare in modo che l'UI passi a una schermata specifica, ad esempio. Devi pensare ulteriormente e considerare come rappresentare il flusso utente nello stato dell'UI. In altre parole: non pensare alle azioni che l'UI deve eseguire, ma a come queste azioni influiscono sullo stato dell'UI.
Considera, ad esempio, il caso di passaggio alla schermata Home quando l'utente ha eseguito l'accesso nella schermata di accesso. Potresti modellare questa situazione nello stato dell'UI come segue:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
Questa UI reagisce alle modifiche dello stato isUserLoggedIn e passa alla destinazione corretta in base alle esigenze:
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.
}
...
}
}
}
}
}
Il consumo di eventi può attivare aggiornamenti dello stato
Il consumo di determinati eventi ViewModel nell'UI potrebbe comportare altri aggiornamenti dello stato dell'UI. Ad esempio, quando mostra messaggi temporanei sullo schermo per informare l'utente che è successo qualcosa, l'UI deve notificare al ViewModel di attivare un altro aggiornamento dello stato quando il messaggio è stato mostrato sullo schermo. L'evento che si verifica quando l'utente ha consumato il messaggio (chiudendolo o dopo un timeout) può essere considerato come "input utente" e, di conseguenza, il ViewModel deve esserne a conoscenza. In questa situazione, lo stato dell'UI può essere modellato come segue:
// 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
)
Il ViewModel aggiornerebbe lo stato dell'UI come segue quando la logica di business richiede la visualizzazione di un nuovo messaggio temporaneo all'utente:
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)
}
}
}
Il ViewModel non deve sapere come l'UI mostra il messaggio sullo schermo, ma sa solo che è presente un messaggio utente che deve essere mostrato. Una volta visualizzato il messaggio temporaneo, l'UI deve notificare al ViewModel, causando un altro aggiornamento dello stato dell'UI per cancellare la proprietà 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()
}
...
}
}
}
}
}
Anche se il messaggio è temporaneo, lo stato dell'UI è una rappresentazione fedele di ciò che viene visualizzato sullo schermo in ogni singolo momento. Il messaggio utente viene visualizzato o meno.
Eventi di navigazione
La sezione Il consumo di eventi può attivare aggiornamenti dello stato descrive in dettaglio come utilizzare lo stato dell'UI per visualizzare i messaggi utente sullo schermo. Gli eventi di navigazione sono anche un tipo di evento comune in un'app per Android.
Se l'evento viene attivato nell'UI perché l'utente ha toccato un pulsante, l'UI se ne occupa chiamando il controller di navigazione.
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
}
}
}
Se l'input dei dati richiede una convalida della logica di business prima della navigazione, il ViewModel deve esporre questo stato all'UI. L'UI reagisce alla modifica dello stato e naviga di conseguenza. La sezione Gestire gli eventi ViewModel tratta questo caso d'uso. Ecco un codice simile:
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.
}
...
}
}
}
}
}
Nell'esempio precedente, l'app funziona come previsto perché la destinazione corrente, Accesso, non viene mantenuta nello stack Back. Gli utenti non possono tornare indietro se premono il pulsante Indietro. Tuttavia, nei casi in cui ciò potrebbe accadere, la soluzione richiederebbe una logica aggiuntiva.
Eventi di navigazione quando la destinazione viene mantenuta nello stack Back
Quando un ViewModel imposta uno stato che genera un evento di navigazione dalla schermata A alla schermata B e la schermata A viene mantenuta nello stack Back di navigazione, potrebbe essere necessaria una logica aggiuntiva per non avanzare automaticamente a B. Per implementare questa funzionalità, è necessario uno stato aggiuntivo che indichi se l'UI deve o meno considerare la navigazione verso l'altra schermata. In genere, questo stato viene mantenuto nell'UI perché la logica di navigazione è di competenza dell'UI, non del ViewModel. Per illustrare questo concetto, consideriamo il seguente caso d'uso.
Supponiamo che tu stia seguendo il flusso di registrazione della tua app. Nella schermata di convalida della data di nascita, quando l'utente inserisce una data, questa viene convalidata dal ViewModel quando l'utente tocca il pulsante "Continua". Il ViewModel delega la logica di convalida al livello dati. Se la data è valida, l'utente passa alla schermata successiva. Come funzionalità aggiuntiva, gli utenti possono andare avanti e indietro tra le diverse schermate di registrazione nel caso in cui vogliano modificare alcuni dati. Pertanto, tutte le destinazioni nel flusso di registrazione vengono mantenute nello stesso stack Back. Dati questi requisiti, potresti implementare questa schermata come segue:
// 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)
}
}
La convalida della data di nascita è una logica di business di cui è responsabile il ViewModel. La maggior parte delle volte, il ViewModel delega questa logica al livello dati. La logica per passare l'utente alla schermata successiva è una logica dell'UI perché questi requisiti potrebbero variare a seconda della configurazione dell'UI. Ad esempio, potresti non voler passare automaticamente a un'altra schermata su un tablet se stai mostrando più passaggi di registrazione contemporaneamente. La variabile validationInProgress nel codice precedente implementa questa funzionalità e gestisce se l'UI deve o meno navigare automaticamente ogni volta che la data di nascita è valida e l'utente voleva continuare al passaggio di registrazione successivo.
Consigliati per te
- Nota: il testo del link viene visualizzato quando JavaScript è disattivato
- Livello UI
- Titolari dello stato e stato dell'UI {:#mad-arch}
- Guida all'architettura dell'app