Criar um app que prioriza o modo off-line

Os apps que priorizam o modo off-line conseguem executar todos os recursos principais, ou a parte fundamental desses recursos, mesmo sem acesso à Internet. Ou seja, o app pode realizar toda a lógica de negócios, ou parte dela, estando off-line.

As considerações para criar um app que prioriza o modo off-line começam na camada de dados, que oferece acesso aos dados do aplicativo e à lógica de negócios. O app pode precisar atualizar esses dados de vez em quando usando origens externas ao dispositivo. Para isso, pode ser necessário chamar recursos de rede para atualizar o app.

A disponibilidade de rede nem sempre é garantida. Os dispositivos geralmente têm períodos de conexão de rede lenta ou instável. Os usuários podem estar nestas situações:

  • Largura de banda de Internet limitada.
  • Interrupções de conexão temporárias, como ao entrar em um elevador ou túnel.
  • Acesso a dados ocasional, por exemplo, em tablets que operam somente em rede Wi-Fi.

Independente do motivo, de maneira geral os apps podem funcionar normalmente nessas circunstâncias. Para garantir que seu app funcione corretamente quando estiver off-line, ele precisa:

  • Permanecer utilizável quando não houver uma conexão de rede confiável.
  • Apresentar dados locais imediatamente ao usuário, em vez de esperar que a primeira chamada de rede seja concluída ou falhe.
  • Buscar dados sem gerar grande impacto no uso da bateria e de dados móveis do usuário. Para isso, o app pode solicitar buscas de dados apenas em condições ideais, como quando o dispositivo está conectado ao carregador ou a uma rede Wi-Fi.

Apps que atendem aos critérios acima geralmente são chamados de apps que priorizam o modo off-line.

Projetar um app que prioriza o modo off-line

Para projetar um app que prioriza o modo off-line, foque na camada de dados e nas duas principais operações que podem ser realizadas nos dados do app:

  • Leitura: acessar dados para uso em outras partes do app, como mostrar informações ao usuário.
  • Gravação: armazenar entradas do usuário localmente para as extrair mais tarde.

Os repositórios na camada de dados são responsáveis por combinar as fontes de dados para fornecer dados do app. Em um app que prioriza o modo off-line, é necessário existir pelo menos uma fonte de dados que não precise de acesso à rede para realizar as tarefas mais importantes. Uma dessas tarefas importantes é a leitura de dados.

Modelar dados em um app que prioriza o modo off-line

Um app que prioriza o modo off-line tem no mínimo duas fontes de dados para cada repositório que usa recursos de rede:

  • A fonte de dados local.
  • A fonte de dados de rede.
Uma camada de dados que prioriza o modo off-line é composta por fontes de dados local e de rede.
Figura 1: repositório que prioriza o modo off-line

A fonte de dados local

A fonte de dados local é a fonte de verdade canônica do app e precisa ser a única lida pelas camadas mais altas dele. Isso garante a consistência de dados entre diferentes estados de conexão. A fonte de dados local geralmente tem um backup no armazenamento mantido no disco. Algumas maneiras comuns de manter dados no disco são:

  • Em fontes de dados estruturados. Por exemplo: bancos de dados relacionais, como o Room.
  • Em fontes de dados não estruturados. Por exemplo, buffers de protocolo com Datastore.
  • Em arquivos simples.

A fonte de dados de rede

A fonte de dados de rede corresponde ao estado real do aplicativo. Na melhor das hipóteses, a fonte de dados local e a fonte de dados de rede ficam sincronizadas. Também é possível que haja uma defasagem e, nesse caso, o app precisará ser atualizado quando voltar a ficar on-line. Por outro lado, a fonte de dados de rede também pode ficar em defasagem em relação à fonte de dados local até que o app possa a atualizar quando retoma a conexão. As camadas de domínio e IU do app não podem entrar em contato diretamente com a camada de rede. O repository host é o responsável por se comunicar com ela e atualizar a fonte de dados local.

Como expor recursos

A maneira como o app lê e grava dados nas fontes de dados local e de rede pode ser fundamentalmente diferente. A consulta a uma fonte de dados local pode ser rápida e flexível, como ao usar consultas SQL. Por outro lado, as fontes de dados de rede podem ser lentas e restritas, como ao acessar recursos RESTful por ID de forma incremental. Como resultado, geralmente cada fonte precisa da própria representação dos dados fornecidos. As fontes de dados de rede e local podem ter os próprios modelos.

A estrutura de diretórios abaixo demonstra esse conceito. A AuthorEntity é uma representação de um autor lido no banco de dados local do app, enquanto o NetworkAuthor é uma representação de um autor serializado pela rede:

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

Confira os detalhes de 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,
)

Recomendamos manter a AuthorEntity e o NetworkAuthor internos à camada de dados e expor um terceiro tipo de dado para uso pelas camadas externas. Isso protege as camadas externas contra mudanças mínimas nas fontes de dados local e de rede que não afetam o comportamento do app. Essa abordagem é demonstrada no snippet abaixo:

/**
 * 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,
)

O modelo de rede pode definir um método de extensão para conversão em modelo local, enquanto o modelo local também define um método para o converter em uma representação externa, conforme mostrado abaixo:

/**
 * 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,
)

Leituras

A leitura é uma operação fundamental para os dados em um aplicativo que prioriza o modo off-line. É necessário garantir que o app consiga ler e mostrar novos dados assim que forem disponibilizados. Apps que podem fazer isso são considerados reativos, porque expõem APIs de leitura com tipos observáveis.

No snippet abaixo, o OfflineFirstTopicRepository retorna Flows para todas as APIs de leitura. Isso permite modificar os leitores ao receber atualizações da fonte de dados de rede. Em outras palavras, ele permite que o OfflineFirstTopicRepository envie mudanças quando a fonte de dados local for invalidada. Cada leitor de OfflineFirstTopicRepository precisa estar preparado para processar as mudanças de dados que podem ser acionadas ao restaurar a conectividade de rede no app. Além disso, OfflineFirstTopicRepository lê os dados diretamente da fonte de dados local. Para notificar os leitores sobre as mudanças de dados, ele antes precisa atualizar a fonte de dados local.

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

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

Estratégias para tratamento de erros

Existem maneiras específicas de processar erros em apps que priorizam o modo off-line, dependendo das fontes de dados em que o erro ocorrer. As subseções abaixo apresentam essas estratégias.

Fonte de dados local

Erros na leitura da fonte de dados local são raros. Para proteger os leitores contra erros, use o operador catch nos respectivos Flows em que os dados são coletados.

O uso do operador catch (link em inglês) em um ViewModel funciona assim:

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

Fonte de dados de rede

Se ocorrer um erro durante a leitura de uma fonte de dados de rede, o app vai precisar implementar uma heurística para tentar buscar dados novamente. Heurísticas comuns incluem:

Espera exponencial

Na espera exponencial (link em inglês), o aplicativo continua tentando ler a fonte de dados de rede em intervalos cada vez maiores até conseguir realizar a leitura ou até que outras condições façam o app parar.

Leitura de dados com espera exponencial
Figura 2: leitura de dados com espera exponencial.

Os critérios para avaliar se o app precisa continuar aguardando incluem:

  • O tipo de erro indicado pela fonte de dados de rede. Por exemplo, tente refazer as chamadas de rede quando elas retornarem um erro indicando a falta de conexão. Por outro lado, não tente fazer novas solicitações HTTP não autorizadas até que as credenciais adequadas estejam disponíveis.
  • Limite de tentativas permitidas.
Monitoramento da conectividade de rede

Nessa abordagem, as solicitações de leitura são colocadas em fila até que o app tenha certeza de que pode se conectar à fonte de dados de rede. Depois de estabelecer a conexão, a solicitação de leitura é removida da fila, a leitura de dados é realizada e a fonte de dados local é atualizada. No Android, essa fila pode ser mantida usando um banco de dados do Room e pode ser esvaziada com a implementação de trabalho persistente, com o WorkManager.

Leitura de dados com filas e monitoramento de rede
Figura 3: filas de leitura com monitoramento de rede.

Gravações

A leitura de dados recomendada para apps que priorizam o modo off-line usa os tipos observáveis. O equivalente disso para APIs de gravação são as APIs assíncronas, por exemplo, funções de suspensão. O uso dessas APIs evita que a linha de execução de interface seja bloqueada e ajuda no processamento de erros, já que as gravações em apps que priorizam o modo off-line podem falhar ao perder a conexão com a rede.

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

No snippet acima, a API assíncrona escolhida é a Coroutines, porque o método apresentado é de suspensão.

Estratégias de gravação

É importante considerar três estratégias ao gravar dados em apps que priorizam o modo off-line. A escolha vai depender do tipo de dados gravado e dos requisitos do app:

Gravações somente on-line

Tente gravar os dados quando o dispositivo tiver acesso à rede. Se for possível, atualize a fonte de dados local. Caso contrário, gere uma exceção e aguarde o autor da chamada responder da maneira adequada.

Gravações somente on-line
Figura 4: gravações somente on-line.

Essa estratégia costuma ser usada para operações de gravação que precisam acontecer on-line quase em tempo real. Por exemplo, para transferências bancárias. Como as gravações podem falhar, é necessário informar ao usuário que ocorreu uma falha ou impedir que o usuário tente inserir novos dados. Algumas estratégias que podem ser adotadas nesses casos incluem:

  • No caso de apps que precisam de acesso à Internet para gravar dados, é possível não mostrar ao usuário uma IU que permite a inserção de dados ou, então, desativar essa IU.
  • Use uma mensagem pop-up que não pode ser dispensada ou uma solicitação temporária para informar ao usuário que ele está off-line.

Gravações em fila

Coloque em uma fila os objetos que quer gravar. Continue o processo de esvaziar a fila com espera exponencial quando o app ficar on-line novamente. No Android, o esvaziamento de uma fila off-line é um trabalho persistente, geralmente delegado ao WorkManager.

Gravar filas com novas tentativas
Figura 5: gravar filas com novas tentativas.

Essa abordagem é uma boa opção para casos em que:

  • Não é essencial que os dados sejam gravados na rede.
  • A transação não é urgente.
  • Não é essencial informar ao usuário se a operação falhar.

Os casos de uso dessa abordagem incluem geração de registros e eventos de análise.

Gravações lentas

Primeiro, grave na fonte de dados local e, em seguida, coloque a gravação em fila para notificar a rede assim que possível. Esse não é um processo simples, porque pode haver conflitos entre a rede e as fontes de dados locais quando o app volta a ficar on-line. A próxima seção sobre resolução de conflitos apresenta mais informações sobre esse assunto.

Gravações lentas com monitoramento de rede
Figura 6: gravações lentas.

Essa é a escolha certa quando os dados são essenciais para o app. Por exemplo, em um app de lista de tarefas que prioriza o modo off-line, é essencial que as tarefas adicionadas pelo usuário quando o app não está conectado sejam armazenadas localmente para evitar qualquer perda de dados.

Sincronização e resolução de conflitos

Quando um app que prioriza o modo off-line restaura a conectividade, ele precisa reconciliar os dados na fonte de dados local com os da fonte de dados de rede. Esse processo é chamado de sincronização. A sincronização com a fonte de dados de rede pode ser feita de duas maneiras principais:

  • Sincronização por extração
  • Sincronização por envio

Sincronização por extração

Na sincronização por extração, o app acessa a rede para ler os dados mais recentes de que ele precisa. Uma heurística comum para essa abordagem se baseia na da navegação: o app busca os dados pouco antes de os mostrar ao usuário.

Essa abordagem funciona melhor quando o app consegue prever que vai haver períodos curtos ou intermediários sem conectividade de rede. Isso ocorre porque a atualização de dados depende da possibilidade de conexão. Períodos longos sem conectividade aumentam a chance do usuário tentar acessar destinos do app com caches desatualizados ou vazios.

Sincronização por extração
Figura 7: sincronização por extração: o dispositivo A acessa recursos apenas para as telas A e B, enquanto o dispositivo B acessa recursos somente para as telas B, C e D.

Considere um app em que os tokens de página são usados para buscar itens de uma lista de rolagem interminável para uma tela específica. Ao implementar essa abordagem, o app pode acessar a rede lentamente, armazenar os dados na fonte de dados local e, então, ler a fonte de dados local para mostrar as informações ao usuário. Quando não houver conectividade de rede, o repositório só poderá solicitar dados da fonte de dados local. Esse é o padrão usado pela Biblioteca Jetpack Paging com a 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
}

As vantagens e desvantagens da sincronização por extração podem ser conferidas na tabela abaixo:

Vantagens Desvantagens
Relativamente fácil de implementar. Costuma usar muitos dados. Isso acontece porque visitas repetidas a um destino de navegação acionam novas buscas desnecessárias de informações que não mudaram. É possível mitigar esse problema com o armazenamento em cache adequado. Isso pode ser feito na camada da IU com o operador cachedIn ou na camada de rede com um cache HTTP.
Dados desnecessários não são extraídos. Essa sincronização não funciona bem para dados relacionais, porque o modelo extraído precisa ser autossuficiente. Caso o modelo sincronizado precise buscar outros modelos para ser preenchido, o problema do grande uso de dados mencionado anteriormente vai ser ainda mais grave. Além disso, esse comportamento pode causar dependências entre repositórios do modelo pai e repositórios do modelo aninhado.

Sincronização por envio

Na sincronização por envio, a fonte de dados local tenta ao máximo imitar um conjunto de réplicas da fonte de dados de rede. Essa sincronização busca ativamente a quantidade necessária de dados na primeira inicialização para definir um valor de referência. Depois disso, ela passa a depender de notificações do servidor que informam quando os dados estão desatualizados.

Sincronização por envio
Figura 8: na sincronização por envio, a rede notifica o app quando os dados são modificados, e o app responde buscando os dados.

Quando o app recebe a notificação, ele acessa a rede para atualizar apenas os dados marcados como desatualizados. Esse trabalho é delegado ao Repository, que acessa a fonte de dados de rede e armazena os dados extraídos para uso na fonte de dados local. Como o repositório expõe os dados com tipos observáveis, os leitores são notificados quando ocorre qualquer modificação.

class UserDataRepository(...) {

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

Nessa abordagem, o app é muito menos dependente da fonte de dados de rede, podendo funcionar sem ela por longos períodos. Com ela, o app oferece acesso de leitura e gravação mesmo quando está off-line, porque presume que as informações mais recentes da fonte de dados de rede estão armazenadas localmente.

As vantagens e desvantagens da sincronização por envio podem ser conferidas na tabela abaixo:

Vantagens Desvantagens
O app pode ficar off-line indefinidamente. O controle de versões de dados para resolução de conflitos é mais complexo.
Uso mínimo de dados. O app só busca dados que foram modificados. É necessário considerar as questões relacionadas à gravação durante a sincronização.
Funciona bem para dados relacionais. Cada repositório é responsável apenas por buscar dados para o modelo correspondente. A fonte de dados de rede precisa oferecer suporte à sincronização.

Sincronização híbrida

Alguns apps usam uma abordagem híbrida e implementam a sincronização por extração ou envio de acordo com o tipo de dado. Por exemplo, um app de rede social pode usar a sincronização por extração para buscar dados relacionados ao feed do usuário quando solicitado, já que o feed é atualizado com muita frequência. Esse mesmo app pode usar a sincronização por envio para atualizar dados sobre a conta conectada, incluindo nome de usuário, foto do perfil, entre outros.

No fim das contas, a escolha de sincronização para um app que prioriza o modo off-line depende dos requisitos do produto e da infraestrutura técnica disponível.

Resolução de conflitos

Se os dados gravados localmente pelo app enquanto ele estava off-line não corresponderem àqueles da fonte de dados de rede, vai ocorrer um conflito que precisa ser resolvido para que a sincronização seja realizada.

A resolução de conflitos geralmente exige o controle de versões. O app precisa fazer alguns registros para acompanhar quando as mudanças ocorreram. Isso permite que ele transmita os metadados para a fonte de dados de rede. A fonte de dados de rede é responsável por definir a fonte de verdade absoluta. Existem muitas estratégias que podem ser implementadas para a resolução de conflitos, dependendo das necessidades do aplicativo. No caso de apps para dispositivos móveis, uma abordagem comum é: a gravação mais recente prevalece.

A gravação mais recente prevalece

Nessa abordagem, os dispositivos anexam metadados de carimbo de data/hora aos dados gravados. Quando a fonte de dados de rede recebe esses dados, ela descarta todos os que são anteriores ao estado atual e aceita somente os mais recentes.

A gravação mais recente prevalece na resolução de conflitos
Figura 9: "A gravação mais recente prevalece". A fonte da verdade é determinada pela última entidade a gravar dados.

Na figura acima, os dois dispositivos estão off-line e inicialmente sincronizados com a fonte de dados de rede. Nesse estado, ambos gravam dados localmente e registram o momento de gravação de cada um. Ao ficar on-line e executar a sincronização com a fonte de dados de rede, a rede mantém os dados do dispositivo B para resolver o conflito, porque eles correspondem à gravação mais recente.

WorkManager em apps que priorizam o modo off-line

As estratégias de leitura e gravação apresentadas acima incluem dois utilitários em comum:

  • Filas
    • Na leitura: usadas para adiar leituras até que a rede esteja disponível.
    • Na gravação: usadas para adiar gravações até que a rede esteja disponível e colocar gravações em fila novamente para novas tentativas.
  • Monitoramento de conectividade de rede
    • Na leitura: usado para sincronização e como sinal para esvaziar a fila de leitura quando o app está conectado.
    • Na gravação: usado para sincronização e como sinal para esvaziar a fila de gravação quando o app está conectado.

Ambos os casos são exemplos de trabalho persistente que o WorkManager executa muito bem. No app de exemplo Now in Android (link em inglês), o WorkManager é usado para organizar a fila de leitura e monitorar a rede ao executar a sincronização com a fonte de dados local. No momento de inicialização, o app executa as ações abaixo:

  1. Coloca o trabalho de sincronização de leitura em fila, para garantir que haja paridade entre a fonte de dados local e a de rede.
  2. Esvazia a fila de sincronização de leitura e inicia a sincronização quando o app fica on-line.
  3. Realiza uma leitura da fonte de dados de rede usando a espera exponencial.
  4. Mantém os resultados da leitura na fonte de dados local e resolve qualquer conflito que possa ocorrer.
  5. Expõe os dados da fonte de dados local para serem usados por outras camadas do app.

As ações apresentadas acima estão ilustradas neste diagrama:

Sincronização de dados no app Now in Android
Figura 10: sincronização de dados no app Now in Android.

Após o trabalho de sincronização ser colocado em fila pelo WorkManager, ele é definido como um trabalho específico usando 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
   }
}

SyncWorker.startupSyncWork() é definido desta forma:


/**
 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()

Mais especificamente, as Constraints definidas por SyncConstraints exigem que o NetworkType seja NetworkType.CONNECTED. Ou seja, ele espera até que a rede esteja disponível para ser executado.

Quando a rede fica disponível, o Worker esvazia a fila de trabalho especificada pelo SyncWorkName, delegando os dados às instâncias Repository adequadas. Quando a sincronização falha, o método doWork() retorna Result.retry(). Nesse caso, o WorkManager automaticamente tenta realizar a sincronização com a espera exponencial. Caso contrário, ele retorna Result.success() e conclui a sincronização.

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

Exemplos

Acesse os exemplos do Google abaixo que demonstram apps que priorizam o modo off-line para conferir as orientações na prática: