Livello dati

Sebbene il livello UI contenga lo stato relativo all'interfaccia utente e la logica dell'interfaccia utente, il livello dati contiene dati delle applicazioni e logica di business. La logica di business è ciò che dà valore alla tua app: è composta da regole aziendali reali che determinano come i dati dell'applicazione devono essere creati, archiviati e modificati.

Questa separazione dei problemi consente di utilizzare il livello dati su più schermate, condividere informazioni tra diverse parti dell'app e riprodurre la logica di business al di fuori dell'interfaccia utente per il test delle unità. Per ulteriori informazioni sui vantaggi del livello dati, consulta la pagina Panoramica dell'architettura.

Architettura del livello dati

Il livello dati è costituito da repository che ciascuno può contenere da zero a molte origini dati. Devi creare una classe di repository per ogni tipo diverso di dati gestiti nell'app. Ad esempio, potresti creare una classe MoviesRepository per i dati relativi ai film o una classe PaymentsRepository per i dati relativi ai pagamenti.

In un'architettura tipica, i repository del livello dati forniscono dati al resto dell'app e dipendono dalle origini dati.
Figura 1. Il ruolo del livello UI nell'architettura dell'app.

Le classi di repository sono responsabili delle seguenti attività:

  • Esposizione dei dati al resto dell'app.
  • Centralizzazione delle modifiche ai dati.
  • Risoluzione dei conflitti tra più origini dati.
  • Sottrazione di origini di dati dal resto dell'app.
  • Contiene la logica di business.

Ogni classe di origine dati deve avere la responsabilità di lavorare con una sola origine di dati, che può essere un file, un'origine di rete o un database locale. Le classi di origini dati sono il ponte tra l'applicazione e il sistema per le operazioni sui dati.

Gli altri livelli nella gerarchia non devono mai accedere direttamente alle origini dati; i punti di ingresso al livello dati sono sempre le classi di repository. Le classi proprietario di stato (consulta la guida al livello UI) o le classi di casi d'uso (consulta la guida al livello di dominio) non dovrebbero mai avere un'origine dati come dipendenza diretta. L'utilizzo di classi di repository come punti di ingresso consente ai diversi livelli dell'architettura di scalare in modo indipendente.

I dati esposti da questo livello devono essere immutabili, in modo da non poter essere manomessi da altre classi, rischiando di porre i relativi valori in uno stato incoerente. I dati immutabili possono anche essere gestiti in modo sicuro da più thread. Per ulteriori dettagli, consulta la sezione sui thread.

Seguendo le best practice per l'inserimento delle dipendenze, il repository utilizza le origini dati come dipendenze nel suo costruttore:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

Esponi le API

Le classi nel livello dati in genere espongono funzioni per eseguire chiamate one-shot di creazione, lettura, aggiornamento ed eliminazione (CRUD) o per ricevere notifiche in caso di modifiche ai dati nel tempo. Il livello dati dovrebbe mostrare quanto segue per ciascuno di questi casi:

  • Operazioni one-shot: il livello dati deve esporre le funzioni di sospensione in Kotlin; per il linguaggio di programmazione Java, il livello dati deve esporre funzioni che forniscono un callback per notificare il risultato dell'operazione o i tipi RxJava Single, Maybe o Completable.
  • Per ricevere notifiche sulle modifiche ai dati nel tempo: il livello dati deve esporre i flussi in Kotlin, mentre per il linguaggio di programmazione Java il livello dati deve esporre un callback che emette i nuovi dati oppure il tipo RxJava Observable o Flowable.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

Convenzioni di denominazione in questa guida

In questa guida, le classi di repository prendono il nome dai dati di cui sono responsabili. La convenzione è la seguente:

tipo di dati + Repository.

Ad esempio: NewsRepository, MoviesRepository o PaymentsRepository.

Le classi di origini dati prendono il nome in base ai dati di cui sono responsabili e all'origine che utilizzano. La convenzione è la seguente:

tipo di dati + tipo di origine + Origine dati.

Per il tipo di dati, usa Remoto o Local per indicare un tipo di dati più generico, perché le implementazioni possono cambiare. Ad esempio: NewsRemoteDataSource o NewsLocalDataSource. Per essere più specifici nel caso in cui la fonte sia importante, utilizza il tipo. Ad esempio: NewsNetworkDataSource o NewsDiskDataSource.

Non assegnare un nome all'origine dati in base a un dettaglio di implementazione, ad esempio UserSharedPreferencesDataSource, perché i repository che utilizzano quell'origine dati non devono sapere come vengono salvati i dati. Se segui questa regola, puoi modificare l'implementazione dell'origine dati, ad esempio la migrazione da SharedPreferences a DataStore senza influire sul livello che chiama l'origine.

Più livelli di repository

In alcuni casi che richiedono requisiti aziendali più complessi, un repository potrebbe dover dipendere da altri repository. Il motivo potrebbe essere che i dati coinvolti sono un'aggregazione da più origini dati o perché la responsabilità deve essere incapsulata in un'altra classe di repository.

Ad esempio, un repository che gestisce i dati di autenticazione degli utenti, UserRepository, potrebbe dipendere da altri repository come LoginRepository e RegistrationRepository per soddisfare i suoi requisiti.

Nell&#39;esempio, UserRepository dipende da altre due classi di repository: LoginRepository, che dipende da altre origini dati di accesso, e RegistrationRepository, che dipende da altre origini dati di registrazione.
Figura 2. Grafico delle dipendenze di un repository che dipende da altri repository.

Fonte attendibile

È importante che ogni repository definisca un'unica fonte attendibile. La fonte attendibile contiene sempre dati coerenti, corretti e aggiornati. In effetti, i dati esposti dal repository dovrebbero essere sempre quelli che provengono direttamente dalla fonte attendibile.

La fonte attendibile può essere un'origine dati, ad esempio il database, o anche una cache in memoria che potrebbe contenere il repository. I repository combinano diverse origini dati e risolvono eventuali conflitti tra le origini dati per aggiornare la singola fonte attendibile regolarmente o a causa di un evento di input dell'utente.

Repository diversi nella tua app potrebbero avere fonti attendibili diverse. Ad esempio, la classe LoginRepository potrebbe utilizzare la propria cache come fonte attendibile e la classe PaymentsRepository potrebbe utilizzare l'origine dati di rete.

Per fornire supporto offline, un'origine dati locale, ad esempio un database, è la fonte attendibile consigliata.

Threading

Le chiamate a origini dati e repository devono essere protette dal thread principale, in modo da poterle chiamare in sicurezza. Queste classi sono responsabili dello spostamento dell'esecuzione della loro logica nel thread appropriato durante l'esecuzione di operazioni di blocco a lunga esecuzione. Ad esempio, dovrebbe essere sicuro che un'origine dati legga da un file o che un repository esegua filtri costosi su un lungo elenco.

Tieni presente che la maggior parte delle origini dati fornisce già API sicure per l'uso principale, come le chiamate al metodo di sospensione fornite da Room, Retrofit o Ktor. Il repository può sfruttare queste API non appena sono disponibili.

Per scoprire di più sull'organizzazione in thread, consulta la guida all'elaborazione in background. Per gli utenti di Kotlin, le coroutine sono l'opzione consigliata. Consulta Esecuzione di attività Android in thread in background per le opzioni consigliate per il linguaggio di programmazione Java.

Ciclo di vita

Le istanze di classi nel livello dati rimangono in memoria finché sono raggiungibili da una radice di garbage collection, di solito facendo riferimento ad altri oggetti nell'app.

Se una classe contiene dati in memoria, ad esempio una cache, puoi riutilizzare la stessa istanza di quella classe per un periodo di tempo specifico. Denominato anche ciclo di vita dell'istanza della classe.

Se la responsabilità della classe è fondamentale per l'intera applicazione, puoi applicare un'istanza di quella classe alla classe Application. In questo modo, l'istanza segue il ciclo di vita dell'applicazione. In alternativa, se devi riutilizzare la stessa istanza solo in un determinato flusso nella tua app, ad esempio il flusso di registrazione o di accesso, devi limitare l'ambito dell'istanza alla classe proprietaria del ciclo di vita di quel flusso. Ad esempio, puoi impostare l'ambito di un elemento RegistrationRepository contenente dati in memoria in RegistrationActivity o nel grafico di navigazione del flusso di registrazione.

Il ciclo di vita di ogni istanza è un fattore critico per decidere come fornire le dipendenze all'interno della tua app. Ti consigliamo di seguire le best practice per l'inserimento delle dipendenze, in cui le dipendenze sono gestite e possono limitare l'ambito ai container di dipendenze. Per scoprire di più sull'ambito in Android, leggi il post del blog Scoping in Android and Hilt.

Rappresentare i modelli di business

I modelli di dati che vuoi esporre dal livello dati potrebbero essere un sottoinsieme delle informazioni che ottieni dalle diverse origini dati. Idealmente, le diverse origini dati (di rete e locali) dovrebbero restituire solo le informazioni necessarie per la tua applicazione, ma non è così spesso.

Ad esempio, immagina un server dell'API News che restituisce non solo le informazioni sull'articolo, ma anche la cronologia delle modifiche, i commenti degli utenti e alcuni metadati:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

L'app non ha bisogno di così tante informazioni sull'articolo perché mostra soltanto il contenuto dell'articolo sullo schermo, insieme alle informazioni di base sull'autore. È buona norma separare le classi di modello e fare in modo che i repository espongano solo i dati richiesti dagli altri livelli della gerarchia. Ad esempio, ecco come puoi tagliare ArticleApiModel dalla rete per esporre una classe del modello Article ai livelli del dominio e dell'interfaccia utente:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

La separazione delle classi di modelli è utile nei seguenti modi:

  • Consente di risparmiare memoria dell'app riducendo i dati solo a ciò che è necessario.
  • Adatta i tipi di dati esterni a quelli utilizzati dalla tua app; ad esempio, l'app potrebbe utilizzare un tipo di dati diverso per rappresentare le date.
  • Fornisce una migliore separazione dei problemi; ad esempio, i membri di un grande team potrebbero lavorare individualmente sui livelli di rete e UI di una funzionalità se la classe del modello è stata definita prima.

Puoi estendere questa pratica e definire classi di modelli separate anche in altre parti dell'architettura dell'app, ad esempio nelle classi delle origini dati e nei ViewModel. Tuttavia, questo richiede la definizione di classi e logiche aggiuntive che dovresti documentare e testare correttamente. Come minimo, ti consigliamo di creare nuovi modelli ogni volta che un'origine dati riceva dati che non corrispondono a ciò che si aspetta il resto della tua app.

Tipi di operazioni sui dati

Il livello dati può gestire tipi di operazioni che variano in base alla loro importanza: orientate all'interfaccia utente, alle app e orientate all'attività.

Operazioni orientate all'interfaccia utente

Le operazioni orientate all'interfaccia utente sono pertinenti solo quando l'utente si trova su una schermata specifica e vengono annullate quando l'utente si allontana da quella schermata. Un esempio è la visualizzazione di alcuni dati ottenuti dal database.

Le operazioni orientate all'interfaccia utente vengono generalmente attivate dal livello UI e seguono il ciclo di vita del chiamante, ad esempio il ciclo di vita di ViewModel. Consulta la sezione Eseguire una richiesta di rete per un esempio di operazione orientata all'interfaccia utente.

Operazioni orientate alle app

Le operazioni orientate alle app sono pertinenti finché l'app è aperta. Se l'app viene chiusa o il processo viene interrotto, queste operazioni vengono annullate. Un esempio è la memorizzazione nella cache del risultato di una richiesta di rete, in modo da poter essere utilizzato in un secondo momento, se necessario. Per saperne di più, consulta la sezione Implementare la memorizzazione nella cache dei dati in memoria.

Queste operazioni in genere seguono il ciclo di vita della classe Application o del livello dati. Ad esempio, consulta la sezione Rendere un'operazione attiva più a lungo della schermata.

Operazioni orientate al business

Le operazioni orientate all'attività non possono essere annullate. Dovrebbero sopravvivere alla morte. Un esempio è il completamento del caricamento di una foto che l'utente vuole pubblicare sul proprio profilo.

Per le operazioni orientate al business è consigliabile utilizzare WorkManager. Consulta la sezione Pianificare le attività utilizzando WorkManager per ulteriori informazioni.

Esponi errori

Le interazioni con repository e origini dati possono avere esito positivo o generare un'eccezione in caso di errore. Per coroutine e flussi, devi utilizzare il meccanismo di gestione degli errori integrato di Kotlin. Per gli errori che potrebbero essere attivati dalle funzioni di sospensione, utilizza i blocchi try/catch quando opportuno e nei flussi, utilizza l'operatore catch. Con questo approccio, il livello UI dovrebbe gestire le eccezioni durante la chiamata del livello dati.

Il livello dati può comprendere e gestire diversi tipi di errori e mostrarli mediante eccezioni personalizzate, ad esempio un UserNotAuthenticatedException.

Per scoprire di più sugli errori nelle coroutine, leggi il post del blog Eccezioni nelle coroutine.

Attività comuni

Le seguenti sezioni presentano esempi di come utilizzare e progettare l'architettura del livello dati per eseguire determinate attività comuni nelle app per Android. Gli esempi si basano sulla tipica app di News menzionata in precedenza nella guida.

Inviare una richiesta di rete

Effettuare una richiesta di rete è una delle operazioni più comuni che un'app Android potrebbe eseguire. L'app News deve presentare all'utente le ultime notizie recuperate dalla rete. Di conseguenza, l'app richiede una classe di origine dati per gestire le operazioni di rete: NewsRemoteDataSource. Per esporre le informazioni al resto dell'app, viene creato un nuovo repository che gestisce le operazioni sui dati di notizie: NewsRepository.

Il requisito è che le ultime notizie debbano essere sempre aggiornate quando l'utente apre la schermata. Pertanto, si tratta di un'operazione orientata all'interfaccia utente.

Creare l'origine dati

L'origine dati deve esporre una funzione che restituisce le ultime notizie: un elenco di istanze ArticleHeadline. L'origine dati deve fornire un modo sicuro principale per ricevere le ultime notizie dalla rete. A questo scopo, deve dipendere da CoroutineDispatcher o Executor su cui eseguire l'attività.

L'esecuzione di una richiesta di rete è una chiamata one-shot gestita da un nuovo metodo fetchLatestNews():

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

L'interfaccia NewsApi nasconde l'implementazione del client dell'API di rete; non fa differenza se l'interfaccia è supportata da Retrofit o HttpURLConnection. L'utilizzo delle interfacce rende le implementazioni delle API scambiabili nella tua app.

Crea il repository

Poiché non è necessaria alcuna logica aggiuntiva nella classe di repository per questa attività, NewsRepository agisce come proxy per l'origine dati di rete. I vantaggi dell'aggiunta di questo ulteriore livello di astrazione sono spiegati nella sezione relativa alla memorizzazione nella cache.

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

Per scoprire come utilizzare la classe di repository direttamente dal livello UI, consulta la guida al livello UI.

Implementare la memorizzazione nella cache dei dati in memoria

Supponiamo che venga introdotto un nuovo requisito per l'app News: quando l'utente apre la schermata, le notizie memorizzate nella cache devono essere presentate all'utente se è stata già effettuata una richiesta. In caso contrario, l'app dovrebbe effettuare una richiesta di rete per recuperare le ultime notizie.

Dato il nuovo requisito, l'app deve conservare le ultime notizie in memoria mentre l'utente è aperta. Di conseguenza, si tratta di un'operazione orientata alle app.

Cache

Puoi conservare i dati mentre l'utente si trova nella tua app aggiungendo la memorizzazione nella cache dei dati. Le cache hanno lo scopo di salvare alcune informazioni in memoria per un determinato periodo di tempo, in questo caso purché l'utente utilizzi l'app. Le implementazioni delle cache possono assumere forme diverse. Può variare da una semplice variabile modificabile a una classe più sofisticata che protegge dalle operazioni di lettura/scrittura su più thread. A seconda del caso d'uso, la memorizzazione nella cache può essere implementata nel repository o nelle classi di origini dati.

Memorizza nella cache il risultato della richiesta di rete

Per semplicità, NewsRepository utilizza una variabile modificabile per memorizzare nella cache le notizie più recenti. Per proteggere le operazioni di lettura e scrittura da diversi thread, viene utilizzato un Mutex. Per ulteriori informazioni su stato modificabile e contemporaneità condivisi, consulta la documentazione di Kotlin.

La seguente implementazione memorizza nella cache le informazioni più recenti sulle ultime notizie in una variabile del repository protetta dalla scrittura con Mutex. Se il risultato della richiesta di rete ha esito positivo, i dati vengono assegnati alla variabile latestNews.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

Rendere un'operazione attiva più a lungo dello schermo

Se l'utente esce dallo schermo mentre è in corso la richiesta di rete, questa viene annullata e il risultato non viene memorizzato nella cache. NewsRepository non deve utilizzare CoroutineScope del chiamante per eseguire questa logica. NewsRepository dovrebbe invece utilizzare un elemento CoroutineScope collegato al suo ciclo di vita. Il recupero delle ultime notizie deve essere un'operazione orientata alle app.

Per seguire le best practice per l'inserimento delle dipendenze, NewsRepository dovrebbe ricevere un ambito come parametro nel proprio costruttore anziché creare il proprio CoroutineScope. Poiché i repository dovrebbero svolgere la maggior parte del loro lavoro in thread in background, devi configurare CoroutineScope con Dispatchers.Default o con il tuo pool di thread.

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

Poiché NewsRepository è pronto per eseguire operazioni orientate alle app con l'elemento CoroutineScope esterno, deve eseguire la chiamata all'origine dati e salvare il risultato con una nuova coroutine avviata da questo ambito:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        } 
    }
}

async viene utilizzato per avviare la coroutine nell'ambito esterno. await viene chiamato sulla nuova coroutine per sospenderlo finché la richiesta di rete non torna e il risultato viene salvato nella cache. Se a quel punto l'utente è ancora sullo schermo, vedrà le ultime notizie; se l'utente si allontana dallo schermo, await viene annullato, ma la logica all'interno di async continua a essere eseguita.

Leggi questo post del blog per scoprire di più sui pattern per CoroutineScope.

Salva e recupera dati dal disco

Supponiamo che tu voglia salvare dati come le notizie aggiunte ai preferiti e le preferenze utente. Questo tipo di dati deve sopravvivere alla morte del processo ed essere accessibile anche se l'utente non è connesso alla rete.

Se i dati con cui lavori devono sopravvivere alla morte del processo, devi archiviarli su disco in uno dei seguenti modi:

  • Per i set di dati di grandi dimensioni che richiedono query, che richiedono l'integrità referenziale o che richiedono aggiornamenti parziali, salva i dati in un database delle stanze. Nell'esempio dell'app News, gli articoli o gli autori potrebbero essere salvati nel database.
  • Per i set di dati di piccole dimensioni che devono essere solo recuperati e impostati (non per le query o l'aggiornamento parziale), utilizza DataStore. Nell'esempio dell'app News, il formato della data o altre preferenze di visualizzazione preferito dall'utente potrebbero essere salvati nel Datastore.
  • Per blocchi di dati come un oggetto JSON, utilizza un file.

Come indicato nella sezione Fonte attendibile, ogni origine dati funziona con una sola origine e corrisponde a un tipo di dati specifico (ad esempio News, Authors, NewsAndAuthors o UserPreferences). I corsi che utilizzano l'origine dati non devono sapere come vengono salvati i dati, ad esempio in un database o in un file.

Stanza come origine dati

Poiché ogni origine dati deve avere la responsabilità di lavorare con una sola origine per un tipo specifico di dati, un'origine dati camera riceverebbe un oggetto di accesso ai dati (DAO) o il database stesso sotto forma di parametro. Ad esempio, NewsLocalDataSource potrebbe utilizzare un'istanza di NewsDao come parametro e AuthorsLocalDataSource potrebbe prendere un'istanza di AuthorsDao.

In alcuni casi, se non è necessaria alcuna logica aggiuntiva, potresti inserire il DAO direttamente nel repository, dato che è un'interfaccia facilmente sostituibile nei test.

Per scoprire di più sull'utilizzo delle API Room, consulta la Guida alle room.

Datastore come origine dati

DataStore è perfetto per la memorizzazione di coppie chiave-valore come le impostazioni utente. Alcuni esempi possono includere il formato dell'ora, le preferenze di notifica e se mostrare o nascondere le notizie dopo che l'utente le ha lette. Il DataStore può anche archiviare oggetti digitati con buffer di protocollo.

Come con qualsiasi altro oggetto, un'origine dati supportata da DataStore deve contenere dati corrispondenti a un certo tipo o a una determinata parte dell'app. Questo è ancora più vero con DataStore, perché le letture del DataStore vengono esposte come un flusso che emette ogni volta che viene aggiornato un valore. Per questo motivo, devi archiviare le relative preferenze nello stesso datastore.

Ad esempio, potresti avere un elemento NotificationsDataStore che gestisce solo le preferenze relative alle notifiche e un NewsPreferencesDataStore che gestisce solo le preferenze relative alla schermata Notizie. In questo modo, puoi definire meglio l'ambito degli aggiornamenti, poiché il flusso newsScreenPreferencesDataStore.data viene emesso solo quando viene modificata una preferenza relativa a quella schermata. Significa anche che il ciclo di vita dell'oggetto può essere più breve perché può essere pubblicato solo finché viene visualizzata la schermata delle notizie.

Per ulteriori informazioni sull'utilizzo delle API DataStore, consulta le guide di Datastore.

Un file come origine dati

Quando lavori con oggetti di grandi dimensioni come un oggetto JSON o una bitmap, devi lavorare con un oggetto File e gestire il cambio di thread.

Per ulteriori informazioni sull'utilizzo dell'archiviazione di file, consulta la pagina Panoramica dello spazio di archiviazione.

Pianificare le attività con WorkManager

Supponiamo che venga introdotto un altro nuovo requisito per l'app News: l'app deve offrire all'utente la possibilità di recuperare le ultime notizie in modo regolare e automatico, purché il dispositivo sia in carica e connesso a una rete non a consumo. Per questo motivo questa operazione è orientata al business. Questo requisito fa sì che l'utente possa comunque vedere le notizie recenti anche se il dispositivo non dispone di connettività quando l'utente apre l'app.

WorkManager semplifica la pianificazione del lavoro asincrono e affidabile e può occuparsi della gestione dei vincoli. È la libreria consigliata per il lavoro persistente. Per eseguire l'attività definita sopra, viene creata una classe Worker: RefreshLatestNewsWorker. Questa classe prende NewsRepository come dipendenza per recuperare le ultime notizie e memorizzarle nella cache su disco.

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()
    }
}

La logica di business per questo tipo di attività deve essere incapsulata nella propria classe e considerata come un'origine dati separata. In seguito, WorkManager sarà responsabile di garantire che il lavoro venga eseguito in un thread in background solo quando tutti i vincoli vengono soddisfatti. Se aderisci a questo pattern, puoi scambiare rapidamente le implementazioni in ambienti diversi in base alle esigenze.

In questo esempio, questa attività relativa alle notizie deve essere richiamata da NewsRepository, che userebbe una nuova origine dati come dipendenza: NewsTasksDataSource, implementata come segue:

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<RefreshLatestNewsWorker>(
            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)
    }
}

Questi tipi di classi hanno il nome dei dati di cui sono responsabili, ad esempio NewsTasksDataSource o PaymentsTasksDataSource. Tutte le attività relative a un determinato tipo di dati devono essere incapsulate nella stessa classe.

Se l'attività deve essere attivata all'avvio dell'app, si consiglia di attivare la richiesta WorkManager utilizzando la libreria Avvio app, che chiama il repository da un Initializer.

Per saperne di più sull'utilizzo delle API WorkManager, consulta le guide di WorkManager.

Test

Le best practice per l'inserimento delle dipendenze sono utili durante il test dell'app. Inoltre, è utile fare affidamento su interfacce per le classi che comunicano con risorse esterne. Quando testi un'unità, puoi inserire versioni false delle sue dipendenze per rendere il test deterministico e affidabile.

Test delle unità

Durante il test del livello dati si applicano le linee guida generali per i test. Per i test delle unità, utilizza oggetti reali quando necessario e falso tutte le dipendenze che si rivolgono a fonti esterne, come la lettura da un file o la lettura dalla rete.

Test di integrazione

I test di integrazione che accedono a origini esterne tendono a essere meno deterministici perché devono essere eseguiti su un dispositivo reale. Ti consigliamo di eseguire questi test in un ambiente controllato per rendere più affidabili i test di integrazione.

Per i database, Room consente di creare un database in memoria di cui puoi controllare completamente i test. Per scoprire di più, consulta la pagina Testare il database ed eseguirne il debug.

Per il networking, esistono librerie note come WireMock o MockWebServer che consentono di falsificare chiamate HTTP e HTTPS e verificare che le richieste siano state effettuate come previsto.

Samples

I seguenti esempi di Google mostrano l'utilizzo del livello dati. Esplorale per vedere concretamente queste indicazioni: