Crea un'app offline

Un'app offline è un'app in grado di eseguire tutte le operazioni o un sottoinsieme critico delle sue funzionalità di base senza accesso a internet. Ciò significa che può eseguire alcune o tutte le sue logiche di business offline.

Le considerazioni per la creazione di un'app offline iniziano nel livello dati che offre l'accesso ai dati dell'applicazione e alla logica di business. Di tanto in tanto l'app potrebbe dover aggiornare questi dati da origini esterne al dispositivo. In questo modo, potrebbe essere necessario chiamare le risorse di rete per rimanere al passo con gli aggiornamenti.

La disponibilità della rete non è sempre garantita. In genere, i dispositivi hanno periodi di connessione di rete instabile o lenta. Gli utenti potrebbero riscontrare quanto segue:

  • Larghezza di banda internet limitata
  • Interruzioni transitorie della connessione, ad esempio in un ascensore o in un tunnel.
  • Accesso occasionale ai dati. Ad esempio, tablet solo Wi-Fi.

Indipendentemente dal motivo, spesso un'app può funzionare in modo adeguato in queste circostanze. Per assicurarti che l'app funzioni correttamente offline, deve essere in grado di:

  • Rimani utilizzabile senza una connessione di rete affidabile.
  • Presenta subito agli utenti i dati locali anziché attendere il completamento o l'esito negativo della prima chiamata di rete.
  • Recupera i dati tenendo conto dello stato della batteria e dei dati. Ad esempio, richiedendo il recupero dei dati solo in condizioni ottimali, ad esempio durante la ricarica o tramite Wi-Fi.

Un'app in grado di soddisfare i criteri sopra indicati viene spesso definita app offline.

Progetta un'app offline

Quando progetti un'app offline, devi iniziare dal livello dati e dalle due operazioni principali che puoi eseguire sui dati dell'app:

  • Letture: consente di recuperare dati per l'utilizzo da parte di altre parti dell'app, ad esempio per mostrare informazioni all'utente.
  • Scritture: input utente persistenti per recuperarli in un secondo momento.

I repository nel livello dati sono responsabili della combinazione delle origini dati per fornire i dati dell'app. In un'app offline, deve esistere almeno un'origine dati che non necessita dell'accesso alla rete per eseguire le attività più critiche. Una di queste attività fondamentali è la lettura dei dati.

Modellare i dati in un'app offline-first

Un'app offline ha almeno due origini dati per ogni repository che utilizza le risorse di rete:

  • L'origine dati locale
  • L'origine dati di rete
Un livello dati offline-first è composto da origini dati sia locali che di rete
Figura 1: un repository offline

L'origine dati locale

L'origine dati locale è la fonte attendibile canonica per l'app. Dovrebbe essere la fonte esclusiva di tutti i dati letti dai livelli superiori dell'app. Ciò garantisce la coerenza dei dati tra gli stati di connessione. L'origine dati locale è spesso supportata da uno spazio di archiviazione permanente su disco. Di seguito sono riportati alcuni metodi comuni per conservare i dati su disco:

  • Origini dati strutturati, come database relazionali come Room.
  • Origini dati non strutturate. Ad esempio, il protocollo esegue il buffer con Datastore.
  • File semplici

L'origine dati di rete

L'origine dati di rete indica lo stato effettivo dell'applicazione. L'origine dati locale è sincronizzata al meglio con l'origine dati di rete. Può anche rimanere in ritardo, nel qual caso l'app deve essere aggiornata quando torni online. Al contrario, l'origine dati di rete potrebbe subire un ritardo rispetto all'origine dati locale fino a quando l'app non può aggiornarla quando viene ripristinata la connettività. I livelli di dominio e interfaccia utente dell'app non devono mai collaborare direttamente con il livello di rete. È responsabilità dell'hosting repository comunicare con il sito e utilizzarlo per aggiornare l'origine dati locale.

Esposizione delle risorse

Le origini dati locali e di rete possono differire sostanzialmente per quanto riguarda il modo in cui l'app può eseguire operazioni di lettura e scrittura. Eseguire query su un'origine dati locale può essere rapido e flessibile, ad esempio quando si utilizzano query SQL. Al contrario, le origini dati di rete possono essere lente e limitate, come quando si accede in modo incrementale alle risorse RESTful in base all'ID. Di conseguenza, ogni origine dati spesso ha bisogno di una propria rappresentazione dei dati forniti. L'origine dati locale e l'origine dati di rete possono quindi avere modelli propri.

La struttura di directory riportata di seguito mostra questo concetto. AuthorEntity è la rappresentazione di un autore letto dal database locale dell'app, mentre NetworkAuthor la rappresentazione di un autore serializzato sulla rete:

data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/

Seguono i dettagli di AuthorEntity e NetworkAuthor:

/**
 * Network representation of [Author]
 */
@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

/**
 * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
 * It has a many-to-many relationship with both entities
 */
@Entity(tableName = "authors")
data class AuthorEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    @ColumnInfo(name = "image_url")
    val imageUrl: String,
    @ColumnInfo(defaultValue = "")
    val twitter: String,
    @ColumnInfo(name = "medium_page", defaultValue = "")
    val mediumPage: String,
    @ColumnInfo(defaultValue = "")
    val bio: String,
)

È buona norma mantenere AuthorEntity e NetworkAuthor interni al livello dati ed esporre un terzo tipo da utilizzare per i livelli esterni. In questo modo i livelli esterni sono protetti da piccoli cambiamenti nelle origini dati locali e di rete che non modificano sostanzialmente il comportamento dell'app. Ciò è dimostrato nel seguente snippet:

/**
 * External data layer representation of a "Now in Android" Author
 */
data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

Il modello di rete può quindi definire un metodo di estensione per convertirlo nel modello locale, mentre il modello locale ne ha uno per convertirlo nella rappresentazione esterna, come mostrato di seguito:

/**
 * Converts the network model to the local model for persisting
 * by the local data source
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * Converts the local model to the external model for use
 * by layers external to the data layer
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

Letture

Le letture sono l'operazione fondamentale sui dati dell'app in un'app offline. Devi quindi assicurarti che l'app possa leggere i dati e che l'app possa visualizzarli non appena sono disponibili nuovi dati. Un'app in grado di farlo è un'app reattiva perché espongono API di lettura con tipi osservabili.

Nello snippet seguente, OfflineFirstTopicRepository restituisce Flows per tutte le API di lettura. In questo modo può aggiornare i suoi lettori quando riceve aggiornamenti dall'origine dati di rete. In altre parole, consente le modifiche push di OfflineFirstTopicRepository quando la sua origine dati locale viene invalidata. Di conseguenza, ogni lettore di OfflineFirstTopicRepository deve essere pronto a gestire le modifiche ai dati che possono essere attivate quando viene ripristinata la connettività di rete nell'app. Inoltre, OfflineFirstTopicRepository legge i dati direttamente dall'origine dati locale. Può avvisare i lettori delle modifiche ai dati solo aggiornando prima l'origine dati locale.

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) }
}

Strategie di gestione degli errori

Esistono modi unici di gestire gli errori nelle app offline, a seconda delle origini dati in cui possono verificarsi. Le seguenti sottosezioni descrivono queste strategie.

Origine dati locale

Gli errori durante la lettura dall'origine dati locale dovrebbero essere rari. Per proteggere i lettori da errori, utilizza l'operatore catch nell'elemento Flows da cui il lettore raccoglie i dati.

L'utilizzo dell'operatore catch in ViewModel è il seguente:

class AuthorViewModel(
    authorsRepository: AuthorsRepository,
    ...
) : ViewModel() {
   private val authorId: String = ...

   // Observe author information
    private val authorStream: Flow<Author> =
        authorsRepository.getAuthorStream(
            id = authorId
        )
        .catch { emit(Author.empty()) }
}

Origine dati di rete

Se si verificano errori durante la lettura dei dati da un'origine dati di rete, l'app dovrà utilizzare un'euristica per riprovare a recuperare i dati. Le euristiche comuni includono:

Backoff esponenziale

Nel backoff esponenziale, l'app continua a tentare di leggere dall'origine dati di rete con intervalli di tempo crescenti fino a quando l'operazione non va a buon fine o altre condizioni ne impongono l'arresto.

Lettura dei dati con backoff esponenziale
Figura 2: lettura dei dati con backoff esponenziale

I criteri per valutare se l'app deve continuare a ritirarsi sono:

  • Il tipo di errore indicato dall'origine dati di rete. Ad esempio, dovresti riprovare le chiamate di rete che restituiscono un errore che indica una mancanza di connettività. Al contrario, non devi riprovare le richieste HTTP non autorizzate finché non saranno disponibili le credenziali corrette.
  • Numero massimo di tentativi consentiti.
Monitoraggio della connettività di rete

Con questo approccio, le richieste di lettura vengono messe in coda finché l'app non è sicura di potersi connettere all'origine dati di rete. Una volta stabilita una connessione, la richiesta di lettura viene rimossa dalla coda, i dati letti e l'origine dati locale vengono aggiornati. Su Android questa coda può essere mantenuta con un database della stanza ed essere svuotata come lavoro persistente utilizzando WorkManager.

Lettura dei dati con code e monitor di rete
Figura 3: code di lettura con monitoraggio della rete

Scritture

Sebbene il metodo consigliato per leggere i dati in un'app offline-first utilizzi i tipi osservabili, l'equivalente per le API di scrittura sono le API asincrone, ad esempio le funzioni di sospensione. In questo modo si evita di bloccare il thread dell'interfaccia utente e si semplifica la gestione degli errori, perché le scritture nelle app offline-first potrebbero non riuscire quando si attraversa un confine della rete.

interface UserDataRepository {
    /**
     * Updates the bookmarked status for a news resource
     */
    suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

Nello snippet precedente, l'API asincrona preferita è Coroutines, in quanto il metodo sopra indicato viene sospeso.

Strategie di scrittura

Quando si scrivono dati in app offline-first, ci sono tre strategie da considerare. La scelta dipende dal tipo di dati scritti e dai requisiti dell'app:

Scritture solo online

Prova a scrivere dati attraverso il confine della rete. In caso di esito positivo, aggiorna l'origine dati locale, altrimenti genera un'eccezione e lasciala al chiamante affinché risponda correttamente.

Scritture solo online
Figura 4: scritture solo online

Questa strategia viene spesso utilizzata per le transazioni di scrittura che devono avvenire online quasi in tempo reale. Ad esempio, un bonifico bancario. Poiché le operazioni di scrittura potrebbero non riuscire, spesso è necessario comunicare all'utente che la scrittura non è riuscita o impedire all'utente di tentare inizialmente di scrivere dati. Ecco alcune strategie che puoi adottare in questi scenari:

  • Se un'app richiede l'accesso a internet per scrivere dati, può scegliere di non presentare all'utente una UI che consenta all'utente di scrivere dati o, quanto meno, di disabilitarla.
  • Puoi utilizzare un messaggio popup che l'utente non può ignorare o una richiesta temporanea per notificare all'utente che è offline.

Scritture in coda

Una volta individuato l'oggetto che desideri scrivere, inseriscilo in una coda. Procedi per svuotare la coda con il back-off esponenziale quando l'app torna online. Su Android, lo svuotamento di una coda offline è un lavoro permanente che viene spesso delegato a WorkManager.

Code di scrittura con nuovi tentativi
Figura 5: code di scrittura con nuovi tentativi

Questo approccio è una buona scelta se:

  • Non è essenziale che i dati vengano mai scritti sulla rete.
  • La transazione non è sensibile al momento.
  • Non è essenziale informare l'utente in caso di errore dell'operazione.

I casi d'uso di questo approccio includono il logging e gli eventi di analisi.

Scritture lazy

Scrivi prima nell'origine dati locale, quindi mettila in coda per inviare una notifica alla rete il prima possibile. Questo non è banale, in quanto possono verificarsi conflitti tra la rete e le origini dati locali quando l'app torna online. La prossima sezione sulla risoluzione dei conflitti fornisce ulteriori dettagli.

Scritture lazy con il monitoraggio della rete
Figura 6: scritture lazy

Questo approccio è la scelta corretta quando i dati sono fondamentali per l'app. Ad esempio, in un'app con elenchi di cose da fare offline, è essenziale che tutte le attività aggiunte dall'utente offline vengano archiviate localmente per evitare il rischio di perdita di dati.

Sincronizzazione e risoluzione dei conflitti

Quando un'app offline-first ripristina la propria connettività, deve riconciliare i dati dell'origine dati locale con quelli dell'origine dati di rete. Questo processo è chiamato sincronizzazione. Un'app può sincronizzarsi con la propria origine dati di rete in due modi:

  • Sincronizzazione basata su pull
  • Sincronizzazione basata su push

Sincronizzazione basata su pull

Nella sincronizzazione basata su pull, l'applicazione raggiunge la rete per leggere on demand i dati più recenti dell'applicazione. Un'euristica comune per questo approccio è quella basata sulla navigazione, in cui l'app recupera i dati solo prima di presentarli all'utente.

Questo approccio funziona meglio quando l'app prevede periodi da brevi a intermedi di assenza di connettività di rete. Questo perché l'aggiornamento dei dati è opportunistico e lunghi periodi di assenza di connettività aumentano la possibilità che l'utente provi a visitare le destinazioni delle app con una cache obsoleta o vuota.

Sincronizzazione basata su pull
Figura 7: sincronizzazione basata su pull: il dispositivo A accede alle risorse solo delle schermate A e B, mentre il dispositivo B accede alle risorse solo delle schermate B, C e D

Prendiamo come esempio un'app in cui vengono usati token di pagina per recuperare elementi in un elenco a scorrimento continuo per una determinata schermata. L'implementazione può raggiungere pigramente la rete, rendere persistenti i dati all'origine dati locale e quindi leggere dall'origine dati locale per restituire le informazioni all'utente. In assenza di connettività di rete, il repository può richiedere i dati solo dall'origine dati locale. Questo è il pattern utilizzato dalla Jetpack Paging Library con la relativa API RemoteMediator.

class FeedRepository(...) {

    fun feedPagingSource(): PagingSource<FeedItem> { ... }
}

class FeedViewModel(
    private val repository: FeedRepository
) : ViewModel() {
    private val pager = Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = FeedRemoteMediator(...),
        pagingSourceFactory = feedRepository::feedPagingSource
    )

    val feedPagingData = pager.flow
}

I vantaggi e gli svantaggi della sincronizzazione basata su pull sono riassunti nella seguente tabella:

Vantaggi Svantaggi
Semplicità di implementazione. È soggetto a un utilizzo intensivo dei dati. Questo perché visite ripetute a una destinazione di navigazione attivano il recupero non necessario di informazioni non modificate. Puoi mitigare questo problema attraverso un'adeguata memorizzazione nella cache. Questa operazione può essere eseguita nel livello dell'interfaccia utente con l'operatore cachedIn o nel livello di rete con una cache HTTP.
I dati non necessari non verranno mai recuperati. Non scala bene con i dati relazionali poiché il modello estratto deve essere autosufficiente. Se il modello da sincronizzare dipende da altri modelli che devono essere recuperati per completarsi, il problema dell'utilizzo intensivo dei dati menzionato in precedenza diventerà ancora più significativo. Inoltre, potrebbe causare dipendenze tra i repository del modello padre e quelli del modello nidificato.

Sincronizzazione basata su push

Nella sincronizzazione basata su push, l'origine dati locale cerca di imitare un set di repliche dell'origine dati di rete al meglio delle sue possibilità. Recupera in modo proattivo una quantità appropriata di dati al primo avvio per impostare una base di riferimento, dopodiché si basa sulle notifiche del server per avvisarlo quando quei dati sono inattivi.

Sincronizzazione basata su push
Figura 8: sincronizzazione basata su push: la rete invia una notifica all'app quando i dati cambiano e l'app risponde recuperando i dati modificati

Quando riceve la notifica inattiva, l'app contatta la rete per aggiornare solo i dati contrassegnati come inattivi. Questo lavoro è delegato all'Repository, che si rivolge all'origine dati di rete e rende persistenti i dati recuperati all'origine dati locale. Poiché il repository espone i propri dati con tipi osservabili, i lettori riceveranno una notifica per qualsiasi modifica.

class UserDataRepository(...) {

    suspend fun synchronize() {
        val userData = networkDataSource.fetchUserData()
        localDataSource.saveUserData(userData)
    }
}

Con questo approccio, l'app è molto meno dipendente dall'origine dati di rete e può funzionare senza di essa per lunghi periodi di tempo. Offre l'accesso in lettura e scrittura in modalità offline perché si presume che contenga le informazioni più recenti provenienti dall'origine dati di rete localmente.

I vantaggi e gli svantaggi della sincronizzazione basata su push sono riassunti nella seguente tabella:

Vantaggi Svantaggi
L'app può rimanere offline per un tempo indeterminato. I dati del controllo delle versioni per la risoluzione dei conflitti non sono banali.
Utilizzo minimo dei dati. L'app recupera solo i dati che sono cambiati. Durante la sincronizzazione devi tenere in considerazione i problemi di scrittura.
Funziona bene per i dati relazionali. Ogni repository è responsabile solo del recupero dei dati per il modello che supporta. L'origine dati di rete deve supportare la sincronizzazione.

Sincronizzazione ibrida

Alcune app utilizzano un approccio ibrido che prevede il pull o il push in base ai dati. Ad esempio, un'app di social media può utilizzare la sincronizzazione basata su pull per recuperare il seguente feed dell'utente on demand a causa dell'elevata frequenza di aggiornamenti dei feed. La stessa app può scegliere di utilizzare la sincronizzazione basata su push per i dati relativi all'utente che ha eseguito l'accesso, inclusi nome utente, immagine del profilo e così via.

Essenzialmente, la scelta della sincronizzazione offline dipende dai requisiti del prodotto e dall'infrastruttura tecnica disponibile.

Risoluzione dei conflitti

Se l'app scrive localmente dati offline che non sono allineati all'origine dati di rete, si è verificato un conflitto da risolvere prima che possa avvenire la sincronizzazione.

La risoluzione dei conflitti spesso richiede il controllo delle versioni. L'app dovrà tenere traccia delle modifiche apportate. Ciò consente di passare i metadati all'origine dati di rete. L'origine dati di rete ha quindi la responsabilità di fornire la fonte assoluta dei dati. Esiste un'ampia gamma di strategie da considerare per la risoluzione dei conflitti, a seconda delle esigenze dell'applicazione. Per le app mobile un approccio comune è "l'ultima scrittura vince".

Vince l'ultima scrittura

In questo approccio, i dispositivi associano i metadati timestamp ai dati che scrivono nella rete. Quando l'origine dati di rete li riceve, elimina tutti i dati precedenti al suo stato attuale e accetta quelli più recenti.

L&#39;ultima scrittura vince la risoluzione dei conflitti
Figura 9: "L'ultima scrittura vince". La fonte attendibile per i dati è determinata dall'ultima entità a scrivere i dati

In precedenza, entrambi i dispositivi sono offline e inizialmente sono sincronizzati con l'origine dati di rete. Mentre erano offline, scrivono i dati in locale e tengono traccia del tempo in cui hanno scritto i propri dati. Quando entrambi tornano online e si sincronizzano con l'origine dati di rete, la rete risolve il conflitto mantenendo i dati del dispositivo B perché quest'ultimo ha scritto i dati in un secondo momento.

WorkManager nelle app offline-first

Nelle strategie di lettura e scrittura illustrate sopra, erano presenti due utilità comuni:

  • Code
    • Letture: utilizzato per ritardare le letture fino a quando la connettività di rete non è disponibile.
    • Scritture: utilizzate per ritardare le scritture finché non è disponibile la connettività di rete e per rimettere in coda le scritture per i nuovi tentativi.
  • Monitoraggio della connettività di rete
    • Letture: utilizzato come segnale per svuotare la coda di lettura quando l'app è connessa e per la sincronizzazione
    • Scritture: utilizzate come segnale per svuotare la coda di scrittura quando l'app è connessa e per la sincronizzazione

Entrambi i casi sono esempi del lavoro permanente in cui WorkManager eccelle. Ad esempio, nell'app di esempio Ora in Android, WorkManager viene utilizzato sia come coda di lettura sia come monitoraggio di rete durante la sincronizzazione dell'origine dati locale. All'avvio, l'app esegue le seguenti azioni:

  1. Accoda il lavoro di sincronizzazione di lettura per garantire la parità tra l'origine dati locale e l'origine dati di rete.
  2. Svuota la coda di sincronizzazione di lettura e avvia la sincronizzazione quando l'app è online.
  3. Esegui una lettura dall'origine dati di rete utilizzando il backoff esponenziale.
  4. Mantieni i risultati della lettura nell'origine dati locale risolvendo eventuali conflitti che potrebbero verificarsi.
  5. Esporre i dati dall'origine dati locale per il consumo di altri livelli dell'app.

Quanto sopra è illustrato nel diagramma seguente:

Sincronizzazione dei dati nell&#39;app Now in Android
Figura 10: sincronizzazione dei dati nell'app Now in Android

Segue l'accodamento del lavoro di sincronizzazione con WorkManager specificandolo come lavoro unico con KEEP ExistingWorkPolicy:

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // Queue sync on app startup and ensure only one
           // sync worker runs at any time
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

Dove SyncWorker.startupSyncWork() viene definito come segue:


/**
 Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
 This allows for dependency injection into the SyncWorker in a different
 module than the app module without having to create a custom WorkManager
 configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
    // Run sync as expedited work if the app is able to.
    // If not, it runs as regular work.
   .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
   .setConstraints(SyncConstraints)
    // Delegate to the SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED)
       .build()

In particolare, la Constraints definita da SyncConstraints richiede che la NetworkType sia NetworkType.CONNECTED. Vale a dire, attende che la rete sia disponibile prima di eseguirla.

Quando la rete è disponibile, il Worker svuota la coda di lavoro univoca specificata da SyncWorkName delega alle istanze Repository appropriate. Se la sincronizzazione non va a buon fine, il metodo doWork() restituisce Result.retry(). WorkManager riproverà automaticamente a eseguire la sincronizzazione con backoff esponenziale. In caso contrario, restituisce Result.success() che completa la sincronizzazione.

class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success()
        else Result.retry()
    }
}

Samples

I seguenti esempi di Google mostrano le app offline-first. Esplorale per vedere concretamente queste indicazioni: