Uma abordagem moderna da concorrência no Android: Corotins no Kotlin

Olá Habr!

Lembramos que já temos uma pré-encomenda do livro tão esperado na língua Kotlin da famosa série Big Nerd Ranch Guides. Hoje decidimos chamar a atenção para a tradução de um artigo que conta sobre as corotinas Kotlin e sobre o trabalho correto com fluxos no Android. O tópico está sendo discutido muito ativamente; portanto, para ser completo, também recomendamos que você consulte este artigo da Habr e esta publicação detalhada do blog da Axmor Software.

A estrutura competitiva moderna em Java / Android inflige muito em retornos de chamada e leva a estados de bloqueio, já que o Android não tem uma maneira bastante simples de garantir a segurança do encadeamento.

As corotinas Kotlin são um conjunto de ferramentas muito eficaz e completo que torna o gerenciamento da concorrência muito mais fácil e produtivo.

Pausar e bloquear: qual a diferença

As corotinas não substituem os threads, mas fornecem uma estrutura para gerenciá-los. A filosofia da corutin é definir um contexto que permita aguardar a conclusão das operações em segundo plano sem bloquear o encadeamento principal.

O objetivo de Corutin, neste caso, é dispensar retornos de chamada e simplificar a concorrência.

Exemplo mais simples

Para começar, vamos usar o exemplo mais simples: execute coroutine no contexto de Main (main thread). Nele, extrairemos a imagem do fluxo de IO e enviaremos esta imagem para processamento de volta ao Main .

 launch(Dispatchers.Main) { val image = withContext(Dispatchers.IO) { getImage() } //    IO imageView.setImageBitmap(image) //     } 

O código é simples como uma função de thread único. Além disso, enquanto getImage é executado no pool alocado de threads de IO , o thread principal é gratuito e pode executar qualquer outra tarefa! A função withContext interrompe a atual rotina enquanto sua ação está em execução ( getImage() ). Assim que getImage() retornar e o looper do encadeamento principal se tornar disponível, a corotina continuará o trabalho no encadeamento principal e chamará imageView.setImageBitmap(image) .

O segundo exemplo: agora precisamos concluir duas tarefas em segundo plano para que possam ser usadas. Usaremos o dueto async / waitit para que essas duas tarefas sejam executadas em paralelo e usaremos seu resultado no thread principal assim que as duas tarefas estiverem prontas:

 val job = launch(Dispatchers.Main) { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } job.join() //    ,      

async é semelhante ao launch , mas retorna deferred (uma entidade Kotlin equivalente a Future ), para que seu resultado possa ser obtido usando await() . Quando chamado sem parâmetros, ele funciona no contexto padrão para o escopo atual.

Novamente, o encadeamento principal permanece livre enquanto aguardamos nossos 2 valores.
Como você pode ver, a função de launch retorna Job , que pode ser usado para aguardar a conclusão da operação - isso é feito usando a função join() . Funciona como em qualquer outro idioma, com a ressalva de que simplesmente suspende a corotina e não bloqueia o fluxo .

Despachar

Despachar é um conceito-chave ao trabalhar com corotinas. Esta ação permite "pular" de um segmento para outro.

Considere como é o equivalente ao envio no Main em java, ou seja,

 runOnUiThread: public final void runOnUiThread(Runnable action) { if (Thread.currentThread() != mUiThread) { mHandler.post(action); //  } else { action.run(); //   } } 

Main Implementação do Contexto Main para Android é um Handler baseado em Handler . Portanto, esta é realmente uma implementação muito adequada:

 launch(Dispatchers.Main) { ... } vs launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { ... } //   kotlinx 0.26: launch(Dispatchers.Main.immediate) { ... } 

launch(Dispatchers.Main) envia Runnable para Handler , para que seu código não seja executado imediatamente.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) executará imediatamente sua expressão lambda no encadeamento atual.

Dispatchers.Main assegura que, quando a corotina retomar o trabalho, ela será direcionada para o thread principal ; Além disso, Handler é usado aqui como uma implementação nativa do Android para enviar para o loop de eventos do aplicativo.

A implementação exata é assim:

 val Main: HandlerDispatcher = HandlerContext(mainHandler, "Main") 

Aqui está um bom artigo para ajudá-lo a entender os meandros da expedição no Android:
Noções básicas sobre o Android Core: Looper, Handler e HandlerThread .

Contexto da Corotina

O contexto da corotina (também conhecido como gerenciador de corotina) determina em qual segmento seu código será executado, o que fazer se uma exceção for lançada e se refere ao contexto pai para propagar o cancelamento.

 val job = Job() val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> whatever(throwable) } launch(Disaptchers.Default+exceptionHandler+job) { ... } 

job.cancel() cancelará todas as corotinas cujo pai é job . Um exceptionHandler receberá todas as exceções lançadas nessas corotinas.

Âmbito de aplicação

A interface coroutineScope simplifica o tratamento de erros:
Se alguma de suas corotinas filhas falhar, o escopo inteiro e todas as corotinas filhas também serão canceladas.

No exemplo async , se não foi possível extrair o valor, enquanto outra tarefa continuava funcionando, temos um estado danificado e precisamos fazer algo sobre isso.

Ao trabalhar com coroutineScope , a função useValues será chamada apenas se a extração de ambos os valores for bem-sucedida. Além disso, se o deferred2 falhar, o deferred1 será cancelado.

 coroutineScope { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } 

Você também pode "colocar no escopo" uma classe inteira para definir um CoroutineContext padrão e usá-lo.

Um exemplo de classe que implementa a interface CoroutineScope :

 open class ScopedViewModel : ViewModel(), CoroutineScope { protected val job = Job() override val coroutineContext = Dispatchers.Main+job override fun onCleared() { super.onCleared() job.cancel() } } 

Corutin em execução no CoroutineScope:

O gerenciador de launch ou async padrão agora se torna o gerenciador de escopo atual.

 launch { val foo = withContext(Dispatchers.IO) { … } // -    CoroutineContext   … } launch(Dispatchers.Default) { // -        … } 

Lançamento autônomo da corotina (fora de qualquer CoroutineScope):

 GlobalScope.launch(Dispatchers.Main) { // -    . … } 

Você pode até definir o escopo de um aplicativo definindo o distribuidor Main padrão:

 object AppScope : CoroutineScope by GlobalScope { override val coroutineContext = Dispatchers.Main.immediate } 

Observações

  • Corotinas limitam a interoperabilidade com Java
  • Limitar a mutabilidade para evitar bloqueios
  • As corotinas são projetadas para esperar, não para organizar threads
  • Evite E / S em Dispatchers.Default (e Main ...) - é para isso que Dispatchers.IO é
  • Os fluxos consomem recursos, portanto, contextos de thread único são usados
  • Dispatchers.Default baseado no ForkJoinPool , introduzido no Android 5+
  • Corotinas podem ser usadas através de canais

Livrar-se de bloqueios e retornos de chamada usando canais

Definição de canal da documentação do JetBrains:

Canal Channel conceitualmente muito semelhante ao BlockingQueue . A principal diferença é que ela não bloqueia a operação de venda, fornece uma suspensão de send (ou offer sem bloqueio) e, em vez de bloquear a operação de entrega, fornece um receive suspensão.


Atores

Considere uma ferramenta simples para trabalhar com canais: Actor .

Actor , novamente, é muito semelhante ao Handler : definimos o contexto da corotina (ou seja, o encadeamento no qual vamos executar as ações) e trabalhamos com ela em uma ordem seqüencial.

A diferença, é claro, é que as corutinas são usadas aqui; Você pode especificar a potência e o código executado - pausa .

Em princípio, o actor redirecionará qualquer comando para o canal da corotina. Garante a execução de um comando e restringe as operações em seu contexto . Essa abordagem ajuda perfeitamente a se livrar das chamadas synchronize e a manter todos os threads livres!

 protected val updateActor by lazy { actor<Update>(capacity = Channel.UNLIMITED) { for (update in channel) when (update) { Refresh -> updateList() is Filter -> filter.filter(update.query) is MediaUpdate -> updateItems(update.mediaList as List<T>) is MediaAddition -> addMedia(update.media as T) is MediaListAddition -> addMedia(update.mediaList as List<T>) is MediaRemoval -> removeMedia(update.media as T) } } } //  fun filter(query: String?) = updateActor.offer(Filter(query)) //  suspend fun filter(query: String?) = updateActor.send(Filter(query)) 

Neste exemplo, usamos as classes seladas de Kotlin, escolhendo qual ação executar.

 sealed class Update object Refresh : Update() class Filter(val query: String?) : Update() class MediaAddition(val media: Media) : Update() 

Além disso, todas essas ações serão enfileiradas, nunca serão executadas em paralelo. Essa é uma maneira conveniente de atingir limites de variabilidade .

Ciclo de vida do Android + Corotinas

Os atores também podem ser muito úteis para controlar a interface do usuário do Android, simplificar o cancelamento de tarefas e impedir a sobrecarga do encadeamento principal.
Vamos implementar isso e chamar job.cancel() quando a atividade for destruída.

 class MyActivity : AppCompatActivity(), CoroutineScope { protected val job = SupervisorJob() //  Job    override val coroutineContext = Dispatchers.Main.immediate+job override fun onDestroy() { super.onDestroy() job.cancel() //      } } 

A classe SupervisorJob é semelhante à Job regular, com a única exceção de que o cancelamento se estende apenas na direção downstream.

Portanto, não cancelamos todas as corotinas em uma Activity quando uma delas falha.

As coisas estão um pouco melhores com uma função de extensão que permite acessar esse CoroutineContext partir de qualquer View no CoroutineScope .

 val View.coroutineContext: CoroutineContext? get() = (context as? CoroutineScope)?.coroutineContext 

Agora podemos combinar tudo isso, a função setOnClick cria um ator combinado para controlar suas ações onClick . No caso de vários toques, as ações intermediárias serão ignoradas, eliminando erros de ANR (o aplicativo não responde) e essas ações serão executadas no escopo da Activity . Portanto, quando a atividade é destruída, tudo isso será cancelado.

 fun View.setOnClick(action: suspend () -> Unit) { //         val scope = (context as? CoroutineScope)?: AppScope val eventActor = scope.actor<Unit>(capacity = Channel.CONFLATED) { for (event in channel) action() } //       setOnClickListener { eventActor.offer(Unit) } } 

Neste exemplo, configuramos o Channel como Conflated para que ele ignore alguns eventos, se houver muitos deles. Você pode substituí-lo por Channel.UNLIMITED se preferir enfileirar eventos sem perder nenhum deles, mas ainda assim desejar proteger o aplicativo contra erros de ANR.

Você também pode combinar as estruturas e as estruturas de ciclo de vida para automatizar o cancelamento de tarefas relacionadas à interface com o usuário:

 val LifecycleOwner.untilDestroy: Job get() { val job = Job() lifecycle.addObserver(object: LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { job.cancel() } }) return job } //  GlobalScope.launch(Dispatchers.Main, parent = untilDestroy) { /*    ! */ } 

Simplifique a situação com retornos de chamada (parte 1)

Veja como transformar o uso de APIs baseadas em retorno de chamada com o Channel .

A API funciona assim:

  1. requestBrowsing(url, listener) analisa a pasta localizada em url .
  2. O listener recebe onMediaAdded(media: Media) para qualquer arquivo de mídia encontrado nesta pasta.
  3. listener.onBrowseEnd() é chamado ao analisar a pasta

Aqui está a função de refresh antiga no provedor de conteúdo para o navegador VLC:

 private val refreshList = mutableListOf<Media>() fun refresh() = requestBrowsing(url, refreshListener) private val refreshListener = object : EventListener{ override fun onMediaAdded(media: Media) { refreshList.add(media)) } override fun onBrowseEnd() { val list = refreshList.toMutableList() refreshList.clear() launch { dataset.value = list parseSubDirectories() } } } 

Como melhorá-lo?

Crie um canal que será executado na refresh . Agora, os retornos de chamada do navegador direcionarão apenas a mídia para esse canal e depois o fecharão.

Agora a função de refresh ficou mais clara. Ela cria um canal, liga para o navegador VLC, depois forma uma lista de arquivos de mídia e os processa.

Em vez das consumeEach select ou consumeEach você pode usar for aguardar a mídia, e esse loop será interrompido assim que o browserChannel fechar.

 private lateinit var browserChannel : Channel<Media> override fun onMediaAdded(media: Media) { browserChannel.offer(media) } override fun onBrowseEnd() { browserChannel.close() } suspend fun refresh() { browserChannel = Channel(Channel.UNLIMITED) val refreshList = mutableListOf<Media>() requestBrowsing(url) //        for (media in browserChannel) refreshList.add(media) //   dataset.value = refreshList parseSubDirectories() } 

Simplificando a situação com retornos de chamada (parte 2): Retrofit

A segunda abordagem: não usamos corotinas da kotlinx, mas usamos uma estrutura principal da corotina.

Veja como as corotinas realmente funcionam!

A função retrofitSuspendCall agrupa uma solicitação de Retrofit Call para torná-la uma função de suspend .

Usando suspendCoroutine chamamos o método Call.enqueue e pausamos a corotina. O retorno de chamada fornecido dessa maneira chamará continuation.resume(response) para retomar a corotina com uma resposta do servidor assim que for recebida.

Em seguida, precisamos apenas combinar nossas funções Retrofit em retrofitSuspendCall para retornar resultados da consulta usando-as.

 suspend inline fun <reified T> retrofitSuspendCall(request: () -> Call <T> ) : Response <T> = suspendCoroutine { continuation -> request.invoke().enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { continuation.resume(response) } override fun onFailure(call: Call<T>, t: Throwable) { continuation.resumeWithException(t) } }) } suspend fun browse(path: String?) = retrofitSuspendCall { ApiClient.browse(path) } //  (   Main) livedata.value = Repo.browse(path) 

Assim, a chamada que bloqueia a rede é feita no encadeamento Retrofit dedicado, a corotina está aqui, aguardando uma resposta do servidor e não há lugar para usá-lo no aplicativo!

Esta implementação é inspirada na biblioteca gildor / kotlin-coroutines-retrofit .

Há também um adaptador JakeWharton / retrofit2-kotlin-coroutines com outra implementação que fornece um resultado semelhante.

Epílogo

Channel pode ser usado de várias outras maneiras; Confira BroadcastChannel para implementações mais poderosas que você pode achar úteis.

Você também pode criar canais usando a função Produzir .

Por fim, usando canais, é conveniente organizar a comunicação entre os componentes da interface do usuário: o adaptador pode transmitir eventos de clique para seu fragmento / atividade via Channel ou, por exemplo, através do Actor .

Source: https://habr.com/ru/post/pt457224/


All Articles