Em aplicativos móveis de redes sociais, o usuário gosta, escreve um comentário, depois folheia o feed, inicia o vídeo e coloca o mesmo novamente. Tudo isso é rápido e quase simultâneo. Se a implementação da lógica de negócios do aplicativo estiver completamente bloqueada, o usuário não poderá acessar a fita até que o upload para gravação com selos seja semelhante. Mas o usuário não esperará, portanto, na maioria dos aplicativos móveis, tarefas assíncronas funcionam, que começam e terminam independentemente. O usuário executa várias tarefas ao mesmo tempo e não se bloqueiam. Uma tarefa assíncrona é iniciada e executada enquanto o usuário inicia a próxima.

Ao decifrar o relatório de
Stepan Goncharov no
AppsConf, abordaremos a assincronia: nos aprofundaremos na arquitetura de aplicativos móveis, discutiremos por que precisamos separar uma camada separada para executar tarefas assíncronas, analisaremos os requisitos e soluções existentes, passaremos pelos prós e contras e consideraremos uma das implementações dessa abordagem. Também aprendemos como gerenciar tarefas assíncronas, por que cada tarefa tem seu próprio ID, quais são as estratégias de execução e como elas ajudam a simplificar e acelerar o desenvolvimento de todo o aplicativo.
Sobre o palestrante: Stepan Goncharov (
stepango ) trabalha na Grab - é como o Uber, mas no sudeste da Ásia. Ele está envolvido no desenvolvimento do Android há mais de 9 anos. Interessado em Kotlin desde 2014 e desde 2016 - usa-o no prod. Organizado pelo Kotlin User Group em Singapura. Essa é uma das razões pelas quais todos os exemplos de código estarão no Kotlin, e não porque esteja na moda.
Veremos uma abordagem para projetar os componentes do seu aplicativo. Este é um guia de ação para quem deseja adicionar novos componentes ao aplicativo, projetá-los convenientemente e expandi-los. Os desenvolvedores do iOS podem usar a abordagem do iOS. A abordagem também se aplica a outras plataformas. Estou interessado no Kotlin desde 2014, então todos os exemplos estarão neste idioma. Mas não se preocupe - você pode escrever a mesma coisa em Swift, Objective-C e outros idiomas.
Vamos começar com os problemas e as desvantagens das
extensões reativas . Os problemas são típicos de outras primitivas assíncronas, por isso dizemos RX - lembre-se do futuro e da promessa, e tudo funcionará da mesma forma.
Problemas de RX
Limite de entrada alto . O RX é bastante complexo e amplo - possui 270 operadores e não é fácil ensinar a toda a equipe como usá-los corretamente. Não discutiremos esse problema - está além do escopo do relatório.
No RX, você deve
gerenciar manualmente suas assinaturas, bem como monitorar o ciclo de vida do aplicativo . Se você já se inscreveu em Único ou Observável, não
poderá compará-lo com outro SIngle , porque sempre receberá um novo objeto e sempre haverá diferentes inscrições para o tempo de execução.
No RX, não há como comparar assinaturas e fluxos .
Vamos tentar resolver alguns desses problemas. Vamos resolver cada problema uma vez e depois reutilizar o resultado.
Problema número 1: executando uma tarefa mais de uma vez
Um problema comum no desenvolvimento é trabalho desnecessário e repetição das mesmas tarefas mais de uma vez. Imagine que temos um formulário para inserir dados e um botão salvar. Quando pressionada, uma solicitação é enviada, mas se você clicar várias vezes enquanto o formulário está sendo salvo, várias solicitações idênticas serão enviadas. Demos o botão para testar o controle de qualidade, eles pressionaram 40 vezes em um segundo - recebemos 40 solicitações, porque, por exemplo, a animação não tinha tempo para trabalhar.
Como resolver o problema? Cada desenvolvedor tem sua própria abordagem favorita para resolver: um fará uma
debounce
, o outro bloqueará o botão apenas por meio de
clickable = false
. Não existe uma abordagem geral, portanto esses bugs aparecerão ou desaparecerão do nosso aplicativo. Só resolvemos o problema quando o controle de qualidade nos diz: “Ah, eu cliquei aqui e ele quebrou”!
Uma solução escalável?
Para evitar tais situações, agruparemos o RX ou outra estrutura assíncrona -
adicionaremos IDs a todas as operações assíncronas . A ideia é simples - precisamos de uma maneira de compará-los, porque geralmente esse método não está no framework. Podemos concluir a tarefa, mas não sabemos se ela já foi concluída ou não.
Vamos chamar nosso invólucro de "Ato" - outros nomes já estão sendo usados. Para fazer isso, crie uma pequena
typealias
e uma
interface
simples na qual haja apenas um campo:
typealias Id = String interface Act { val id: Id }
Isso é conveniente e reduz um pouco a quantidade de código. Mais tarde, se String não gostar, a substituiremos por outra coisa. Neste pequeno pedaço de código, observamos um fato engraçado.
As interfaces podem conter propriedades.
Para programadores que vêm de Java, isso é inesperado. Geralmente eles adicionam métodos
getId()
dentro da interface, mas esta é a solução errada, do ponto de vista do Kotlin.
Como vamos projetar?
Uma pequena digressão. Ao projetar, eu aderir a dois princípios. A primeira é
dividir os requisitos e a implementação dos componentes em pedaços pequenos . Isso permite controle granular sobre a escrita de código. Quando você cria um componente grande e tenta fazer tudo de uma vez, isso é ruim. Normalmente, esse componente não funciona e você começa a inserir muletas, por isso peço que você escreva em pequenas etapas controladas e aproveite. O segundo princípio é
verificar a operacionalidade após cada etapa e
repetir o procedimento novamente.
Por que não há ID suficiente?
Vamos voltar ao problema. Demos o primeiro passo - adicionamos um ID e tudo era simples - a interface e o campo. Isso não nos deu nada, porque a interface não contém nenhuma implementação e não funciona por si só, mas permite comparar operações.
Em seguida, adicionaremos componentes que nos permitirão usar a interface e entenderemos que queremos executar algum tipo de solicitação uma segunda vez, quando isso não for necessário. A primeira coisa que faremos é
introduzir novas abstrações .
Apresentando novas abstrações: MapDisposable
É importante escolher o nome e a abstração certos, familiares aos desenvolvedores que trabalham em sua base de código. Como tenho exemplos no RX, usaremos o conceito e nomes de RX semelhantes aos usados pelos desenvolvedores da biblioteca. Assim, podemos explicar facilmente aos colegas o que eles fizeram, por que e como deve funcionar. Para selecionar um nome, consulte a
documentação CompositeDiposable .
Vamos criar uma pequena interface MapDisposable que
contém informações sobre as tarefas atuais e as
chamadas dispose () na exclusão . Não darei a implementação, você pode ver todas as fontes
no meu GitHub .
Chamamos MapDisposable dessa maneira porque o componente funcionará como um mapa, mas terá propriedades CompositeDiposable.
Apresentando novas abstrações: ActExecutor
O próximo componente abstrato é
ActExecutor. Inicia ou não inicia novas tarefas, depende do MapDisposable e delega a manipulação de erros. Como escolher um nome -
consulte a documentação .
Faça a analogia mais próxima do JDK. Possui um Executor no qual você pode passar o thread e fazer alguma coisa. Parece-me que este é um componente interessante e bem projetado, então vamos tomá-lo como base.
Criamos o ActExecutor e uma interface simples para ele, aderindo ao princípio de pequenos passos simples. O próprio nome diz que é um componente ao qual transmitimos algo e começa a fazer algo. ActExecutor tem um método no qual passamos o
Act
e, apenas no caso, tratamos dos erros, porque sem eles não há como.
interface ActExecutor { fun execute( act: Act, e: (Throwable) -> Unit = ::logError) } interface MapDisposable { fun contains(id: Id): Boolean fun add(id: Id, disposable: () -> T) fun remove(id: Id) }
O MapDisposable também é limitado: pegue a interface do Mapa e copie os métodos
contains
,
add
e
remove
. O método
add
difere do Map: o segundo argumento é o lambda para beleza e conveniência. A conveniência é que podemos sincronizar o lambda para evitar
condições inesperadas de
corrida . Mas não vamos falar sobre isso, vamos continuar sobre arquitetura.
Implementação de interface
Declaramos todas as interfaces e tentaremos implementar algo simples. Tome
CompletableAct e
SingleAct .
class CompletableAct ( override val id: Id, override val completable: Completable ) : Act class SingleAct<T : Any>( override val id: Id, override val single: Single<T> ) : Act
CompletableAct é um wrapper sobre Completable. No nosso caso, ele simplesmente contém um ID - que é o que precisamos. SingleAct é quase o mesmo. Também podemos implementar o Maybe e o Flowable, mas nos concentramos nas duas primeiras implementações.
Para Single, especificamos o tipo genérico
<T : Any>
. Como desenvolvedor do Kotlin, prefiro usar essa abordagem.
Tente usar genéricos não nulos.
Agora que temos um conjunto de interfaces, implementamos alguma lógica para impedir a execução dos mesmos pedidos.
class ActExecutorImpl ( val map: MapDisposable ): ActExecutor { fun execute( act: Act, e: (Throwable) -> Unit ) = when { map.contains(act.id) -> { log("${act.id} - in progress") } else startExecution(act, e) log("${act.id} - Started") } }
Tomamos um mapa e verificamos se há um pedido nele. Caso contrário, começamos a executar a solicitação e a adicionamos ao Mapa apenas em tempo de execução. Após a execução com qualquer resultado: erro ou sucesso, exclua a solicitação do Mapa.
Para muito atencioso - não há sincronização, mas a sincronização está no código fonte no GitHub.
fun startExecution(act: Act, e: (Throwable) -> Unit) { val removeFromMap = { mapDisposable.remove(act.id) } mapDisposable.add(act.id) { when (act) { is CompletableAct -> act.completable .doFinally(removeFromMap) .subscribe({}, e) is SingleAct<*> -> act.single .doFinally(removeFromMap) .subscribe({}, e) else -> throw IllegalArgumentException() } }
Use lambdas como o último argumento para melhorar a legibilidade do código. É lindo e seus colegas vão agradecer.
Usaremos mais alguns chips Kotlin e adicionaremos
funções de extensão para Completable e Single. Com eles, não precisamos procurar um método de fábrica para criar um CompletableAct e SingleAct - nós os criaremos por meio de funções de extensão.
fun Completable.toAct(id: Id): Act = CompletableAct(id, this) fun <T: Any> Single<T>.toAct(id: Id): Act = SingleAct(id, this)
Funções de extensão podem ser adicionadas a qualquer classe.
Resultado
Implementamos vários componentes e lógica muito simples. Agora, a principal regra que devemos seguir
não é
forçar uma assinatura manualmente . Quando queremos executar algo - oferecemos através do Executor. Assim como com o thread - ninguém os inicia sozinho.
fun act() = Completable.timer(2, SECONDS).toAct("Hello") executor.apply { execute(act()) execute(act()) execute(act()) } Hello - Act Started Hello - Act Duplicate Hello - Act Duplicate Hello - Act Finished
Uma vez concordamos com a equipe e agora há sempre uma garantia de que os recursos de nosso aplicativo não serão gastos na execução de solicitações idênticas e desnecessárias.
O primeiro problema foi resolvido. Agora vamos expandir a solução para dar flexibilidade.
Problema número 2: que tarefa cancelar?
Assim como nos casos em que é necessário
cancelar uma solicitação subsequente , podemos precisar cancelar a
solicitação anterior. Por exemplo, editamos as informações sobre nosso usuário pela primeira vez e as enviamos ao servidor. Por alguma razão, o envio demorou muito tempo e não foi concluído. Editamos o perfil do usuário novamente e enviamos a mesma solicitação uma segunda vez. Nesse caso, não faz sentido gerar um ID especial para a solicitação - as informações da segunda tentativa são mais relevantes e a
solicitação anterior é cancelada .
A solução atual não funcionará, porque sempre cancelará a execução da solicitação com informações relevantes. De alguma forma, precisamos expandir a solução para solucionar o problema e adicionar flexibilidade. Para fazer isso, entenda o que todos queremos? Mas queremos entender qual tarefa cancelar, como não copiar e colar e como chamá-la.
Adicionar componentes
Chamamos estratégias de comportamento de consulta e criamos duas interfaces para elas:
StrategyHolder e
Strategy . Também criamos 2 objetos responsáveis por qual estratégia aplicar.
interface StrategyHolder { val strategy: Strategy } sealed class Strategy object KillMe : Strategy() object SaveMe : Strategy()
Eu não uso
enum - eu gosto
mais da classe selada . Eles são mais leves, consomem menos memória e são mais fáceis e convenientes de expandir.
A classe selada é mais fácil de estender e escrever mais curta.
Atualizando componentes existentes
Neste ponto, tudo é simples. Tínhamos uma interface simples, agora ela será a herdeira do StrategyHolder. Como essas são interfaces, não há problema com herança. Na implementação do CompletableAct, inseriremos outra
override
e adicionaremos o valor padrão para garantir que as alterações permaneçam compatíveis com o código existente.
interface Act : StrategyHolder { val id: String } class CompletableAct( override val id: String, override val completable: Completable, override val strategy: Strategy = SaveMe ) : Act
Estratégias
Eu escolhi a estratégia
SaveMe , que me parece óbvia. Essa estratégia cancela apenas as seguintes solicitações - a primeira solicitação sempre estará ativa até que seja concluída.
Nós trabalhamos um pouco em nossa implementação. Tínhamos um método de execução e agora adicionamos uma verificação de estratégia lá.
- Se a estratégia SaveMe for a mesma que fizemos antes, nada mudou.
- Se a estratégia for KillMe , elimine a solicitação anterior e inicie uma nova.
override fun execute(act: Act, e: (Throwable) -> Unit) = when { map.contains(act.id) -> when (act.strategy) { KillMe -> { map.remove(act.id) startExecution(act, e) } SaveMe -> log("${act.id} - Act duplicate") } else -> startExecution(act, e) }
Resultado
Conseguimos gerenciar estratégias facilmente, escrevendo um mínimo de código. Ao mesmo tempo, nossos colegas estão felizes e podemos fazer algo assim.
executor.apply { execute(Completable.timer(2, SECONDS) .toAct("Hello", KillMe)) execute(Completable.timer(2, SECONDS) .toAct("Hello", KillMe)) execute(Completable.timer(2, SECONDS) .toAct("Hello«, KillMe)) } Hello - Act Started Hello - Act Canceled Hello - Act Started Hello - Act Canceled Hello - Act Started Hello - Act Finished
Criamos uma tarefa assíncrona, passamos a estratégia e toda vez que iniciamos uma nova tarefa, todas as anteriores, e não as próximas, serão canceladas.
Problema número 3: estratégias não são suficientes
Vamos passar para um problema interessante que encontrei em alguns projetos. Expandiremos nossa solução para lidar com casos mais complicados. Um desses casos, especialmente relevante para as redes sociais, é
"gostar / não gostar" . Há uma postagem e queremos gostar, mas como desenvolvedores, não queremos bloquear a interface do usuário inteira e mostrar a caixa de diálogo em tela cheia com carregamento até que a solicitação seja concluída. Sim, e o usuário ficará infeliz. Queremos enganar o usuário: ele pressiona o botão e, como se isso já tivesse acontecido - uma bela animação começou. Mas, de fato, não havia como - esperamos até que a decepção se torne verdadeira. Para evitar fraudes, devemos lidar com a antipatia de forma transparente pelo usuário.
Seria bom lidar com isso corretamente para que o usuário obtenha o resultado desejado. Mas é difícil para nós, como desenvolvedores, lidar com
solicitações diferentes e mutuamente exclusivas a cada vez.
Há muitas perguntas. Como entender que as consultas estão relacionadas? Como armazenar essas conexões? Como lidar com scripts complexos e não copiar e colar? Como nomear novos componentes? As tarefas são complexas e o que já implementamos não é adequado para a solução.
Grupos e estratégias para grupos
Crie uma interface simples chamada
GroupStrategyHolder . É um pouco mais complicado - dois campos em vez de um.
interface GroupStrategyHolder { val groupStrategy: GroupStrategy val groupKey: String } sealed class GroupStrategy object Default : GroupStrategy() object KillGroup : GroupStrategy()
Além da estratégia para uma solicitação específica, apresentamos uma nova entidade - um grupo de solicitações. Este grupo também terá estratégias. Consideraremos apenas a opção mais simples com duas estratégias:
Padrão - a estratégia padrão quando não fazemos nada com consultas e
KillGroup - mata todas as consultas existentes do grupo e inicia uma nova.
interface Act : StrategyHolder, GroupStrategyHolder { val id: String } class CompletableAct( override val id: String, override val completable: Completable, override val strategy: Strategy = SaveMe, override val groupStrategy: GroupStrategy = Default override val groupKey: String = "" ) : Act
Repetimos as etapas de que falei anteriormente: pegamos a interface, expandimos e adicionamos dois campos adicionais a CompletableAct e SingleAct.
Atualizar implementação
Retornamos ao método Execute. A terceira tarefa é mais complicada, mas a solução é bem simples: verificamos a estratégia do grupo para uma solicitação específica e, se for o KillGroup, matamos o grupo inteiro e executamos a lógica usual.
MapDisposable -> GroupDisposable ... override fun execute(act: Act, e: (Throwable) -> Unit) { if (act.groupStrategy == KillGroup) groupDisposable.removeGroup(act.groupKey) return when { groupDisposable.contains(act.groupKey, act.id) -> when (act.strategy) { KillMe -> { stop(act.groupKey, act.id) startExecution(act, e) } SaveMe -> log("${act.id} - Act duplicate") } else -> startExecution(act, e) } }
O problema é complexo, mas já temos uma infraestrutura bastante adequada - podemos expandi-la e resolver o problema. Se você olhar para o nosso resultado, o que precisamos fazer agora?
Resultado
fun act(id: String)= Completable.timer(2, SECONDS).toAct( id = id, groupStrategy = KillGroup, groupKey = "Like-Dislike-PostId-1234" ) executor.apply { execute(act(“Like”)) execute(act(“Dislike”)) execute(act(“Like”)) } Like - Act Started Like - Act Canceled Dislike - Act Started Dislike - Act Canceled Like - Act Started Like - Act Finished
Se precisarmos de consultas tão complexas, adicionaremos dois campos: groupStrategy e ID do grupo. O ID do grupo é um parâmetro específico, pois, para oferecer suporte a muitos pedidos paralelos de curtir / não gostar, é necessário criar um grupo para cada par de pedidos que pertencer ao mesmo objeto. Nesse caso, você pode nomear o grupo Like-Dislike-PostId e adicionar o ID da postagem. Cada vez que gostamos das postagens vizinhas, teremos certeza de que tudo funcionará corretamente para a postagem anterior e para a próxima.
Em nosso exemplo sintético, estamos tentando executar uma sequência de gostar-não gostar. Quando executamos a primeira ação, e depois a segunda - a anterior é cancelada e a próxima como cancela a antipatia anterior. Era isso que eu queria.
No último exemplo, usamos parâmetros nomeados para criar Atos. Isso ajuda a facilitar a legibilidade do código, especialmente quando existem muitos parâmetros.
Para uma leitura mais fácil, use parâmetros nomeados.
Arquitetura
Vamos ver como essa decisão pode afetar nossa arquitetura. Em projetos, muitas vezes vejo que o View Model ou o Presenter assumem muita responsabilidade, como hacks, de alguma forma lidar com a situação com gostar / não gostar. Normalmente, toda essa lógica no modelo de exibição: muito código duplicado com bloqueio de botão, manipuladores do LifeCycle, assinaturas.

Tudo o que nosso Executor está fazendo agora foi no Presenter ou no View Model. Se a arquitetura estiver madura, os desenvolvedores poderão levar essa lógica a algum tipo de interator ou caso de uso, mas a lógica foi duplicada em vários locais.
Após a adoção do Executor, o View Model se torna mais simples e toda a lógica fica oculta. Se você trouxe isso para o Presenter e o interator, você sabe que o interator e o Presenter estão ficando mais fáceis. Em geral, fiquei satisfeito.

O que mais a acrescentar?
Outra vantagem da solução atual é que ela é extensível. O que mais gostaríamos de adicionar como desenvolvedores que trabalham em um aplicativo móvel e enfrentam bugs e muitas solicitações simultâneas todos os dias?
As possibilidades
A
implementação do ciclo de vida permaneceu nos bastidores, mas como desenvolvedores de dispositivos móveis, todos sempre pensamos nisso e nos preocupamos para que nada flua. Gostaria
de salvar e restaurar solicitações de reinicialização de aplicativos.
Cadeias de chamadas. Devido ao empacotamento das cadeias RX, torna-se possível serializá-las, porque, por padrão, o RX não serializa.
Poucas pessoas sabem quantas solicitações simultâneas estão sendo executadas em um determinado momento em seus aplicativos. Eu não diria que este é um grande problema para aplicativos de pequeno e médio porte. Mas para um aplicativo grande que trabalha muito em segundo plano, é bom entender as causas de falhas e reclamações de usuários. Sem infraestrutura adicional, os desenvolvedores simplesmente não têm informações para entender o motivo: talvez o motivo esteja na interface do usuário ou talvez em um grande número de solicitações constantes em segundo plano. Podemos expandir nossa solução e adicionar algum tipo de
métrica .
Vamos considerar as possibilidades em mais detalhes.
Processamento do ciclo de vida
class ActExecutorImpl( lifecycle: Lifecycle ) : ActExecutor { inir { lifecycle.doOnDestroy { cancelAll() } } ...
Este é um exemplo de implementação de ciclo de vida. No caso mais simples - com fragmentos
Destroy
ou cancelados com
Activity
,
passamos o manipulador do ciclo de vida ao nosso Executor e, quando
o evento onDestroy ocorre, excluímos todas as solicitações . Essa é uma solução simples que elimina a necessidade de copiar e colar código semelhante no View Models. LifeData faz aproximadamente a mesma coisa.
Salvando / Restaurando
Como temos wrappers, podemos criar
classes separadas para o Act , dentro das quais haverá lógica para criar tarefas assíncronas. Além disso, podemos
salvar esse nome no banco de dados e
restaurá-lo no banco de dados na inicialização do aplicativo usando o método de fábrica ou algo semelhante.
Ao mesmo tempo, teremos a oportunidade de trabalhar offline e reiniciaremos as solicitações que foram concluídas com erros quando a Internet aparecer. Na ausência da Internet ou com erros de solicitação, os salvamos no banco de dados e, em seguida, restauramos e executamos novamente. Se você puder fazer isso com o RX comum sem invólucros adicionais, escreva nos comentários, seria interessante.
Cadeias de chamada
Também podemos
vincular nossos atos . Outra opção de extensão é
executar cadeias de consulta . Por exemplo, você tem uma entidade que precisa ser criada no servidor e outra entidade, que depende da primeira, deve ser criada exatamente no momento em que temos certeza de que a primeira solicitação foi concluída com êxito. Isso também pode ser feito. Obviamente, isso não é tão trivial, mas é possível ter uma classe que controla o lançamento de todas as tarefas assíncronas. Usar o RX simples é mais difícil de fazer.
Métricas
É interessante ver
quantas consultas paralelas são executadas, em média, em segundo plano . Com métricas, você pode entender a causa das reclamações dos usuários sobre letargia. , , .
, , ,
, - - 10% . , .
Conclusão
— , . «» . , , , , .
, , . — - , , — . — . , . .
.
. Kotlin, . ,
.
AppsConf 2018, AppsConf 2019 . 38 : , Android, UX, , - , , Kotlin.
, youtube- 22–23 .