Este artigo tem como objetivo mostrar como usar Kotlin Coroutines e remover Reaxtive eXtensions (Rx) .
Benefícios
Para começar, vamos considerar quatro benefícios das Coroutines sobre Rx:
Suspensão sobre bloqueio
Para executar código sem bloqueio usando Rx, você escreveria algo como isto:
Observable.interval(1, TimeUnit.SECONDS) .subscribe { textView.text = "$it seconds have passed" }
O que está criando efetivamente um novo thread. Threads são objetos pesados em termos de memória e desempenho.
Ambos são críticos no mundo do desenvolvimento móvel.
Você pode obter o mesmo comportamento usando o seguinte snippet:
launch { var i = 0 while (true){ textView.text = "${it++} seconds have passed" delay(1000) } }
Essencialmente, as Coroutines são threads leves, mas não criamos nenhum thread real.
Aqui estamos usando a função delay () sem bloqueio, que é uma função de suspensão especial que não bloqueia um thread, mas suspende a Coroutine.
Manuseio de contrapressão natural sobre manual
Contrapressão é quando observáveis produzem itens mais rapidamente do que seus observadores os consomem.
Ao usar o Rx, é necessário especificar explicitamente como você lidará com a contrapressão.
Existem 2 abordagens básicas:
- Use operadores de limitação, buffers ou janelas
- O modelo de tração reativa
Enquanto as corotinas podem suspender, elas fornecem uma resposta natural para lidar com a contrapressão.
Portanto, nenhuma ação adicional é necessária.
Estilo de código de sincronização sobre async
A natureza básica de um aplicativo móvel é reagir às ações do usuário. É por isso que as eXtensions reativas seriam uma boa escolha.
No entanto, você precisa escrever um código em um estilo funcional. Se você costumava escrever em estilo imperativo, poderia ser um pouco difícil.
Enquanto as Coroutines permitem que você escreva código assíncrono como se fossem funções usuais de sincronização. Por exemplo,
suspend fun showTextFromRemote() { val text = remote.getText() textView.text = text }
Mesmo que eu esteja trabalhando com estilo funcional por um longo tempo, ainda é mais fácil ler e depurar um código imperativo.
Nativo acima da biblioteca de terceiros
As corotinas são um recurso nativo do Kotlin.
Você não precisa adicionar nenhuma dependência adicional. Atualmente, todas as principais bibliotecas podem lidar com corotinas.
Por exemplo,
Retrofit
interface Api { @Get("users") suspend fun loadUsers() : List<User> }
Quarto
interface Dao { @Update suspend fun update(user: UserEntity) }
Portanto, você pode criar um aplicativo que seja totalmente suspenso - iniciando a camada da interface do usuário, passando pelo domínio e terminando na camada de dados.
O aplicativo
Vamos ao que interessa. Criaremos um aplicativo clássico de detalhes mestre.
A primeira página conteria uma lista infinita de entregas.
No clique do item, abriremos uma página de detalhes.
Além disso, ofereceremos suporte ao modo offline - todos os dados serão armazenados em cache.
Além disso, usarei a arquitetura MVVM onde a função ViewModel é desempenhada pelo Fragment, em vez do ViewModel do AAC. Existem várias razões:
Fragmentos geralmente são muito carecas - basta vincular o viewModel ao XML.
Recursos como definir a cor da barra de status não podem ser feitos no AAC ViewModel - é necessário acionar o método do fragmento. O uso de fragmentos como ViewModel nos permitiria armazenar toda a funcionalidade relacionada (gerenciando uma determinada tela) em uma classe.
Primeiro, vamos criar o BaseViewModel:
abstract class BaseViewModel<B : BaseBindings, V : ViewDataBinding> : Fragment(), CoroutineScope by CoroutineScope(Dispatchers.IO){ protected abstract val layoutId: Int protected abstract val bindings: B protected lateinit var viewBinding: V override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = true } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { viewBinding = DataBindingUtil.inflate(inflater, layoutId, container, false) return viewBinding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewBinding.lifecycleOwner = viewLifecycleOwner viewBinding.setVariable(BR.bindings, bindings) } override fun onDestroy() { cancel() super.onDestroy() } }
Marcamos nosso ViewModel como CoroutineScope, para que possamos iniciar corotinas dentro dos modelos de vista e quaisquer corotinas lançadas seriam limitadas ao ciclo de vida de um fragmento.
Temos que especificar explicitamente o método cancel()
chamada do ciclo de vida do final do escopo para cancelar todas as solicitações em execução para evitar vazamentos de memória.
Definimos retainInstance = true
para que, na configuração, o fragmento não seja recriado, para que possamos concluir todas as solicitações de execução longa.
Além disso, precisamos definir o lifecycleOwner como binding para ativar a ligação de dados bidirecional .
Manipulação de exceção
De acordo com a documentação da Coroutines:
Coroutine builders come in two flavors: propagating exceptions automatically (launch and actor) or exposing them to users (async and produce). The former treat exceptions as unhandled, similar to Java's Thread.uncaughtExceptionHandler
Como estamos usando o construtor de lançamentos na maioria dos casos, precisamos especificar CoroutineExceptionHandler
CoroutineExceptionHandler é CoroutineContext.Element que pode ser usado para criar um contexto de corotina usando o operador plus.
Declararei o manipulador estático da seguinte maneira:
val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Timber.e(throwable) }
E mude o BaseViewModel:
abstract class BaseViewModel<B : BaseBindings, V : ViewDataBinding> : Fragment(), CoroutineScope by CoroutineScope(Dispatchers.IO + exceptionHandler)
A partir daqui, qualquer exceção ocorrida na rotina lançada dentro do escopo do ViewModel seria entregue ao manipulador fornecido.
Em seguida, preciso declarar minha API e DAO:
interface DeliveriesApi { @GET("deliveries") suspend fun getDeliveries(@Query("offset") offset: Int, @Query("limit") limit: Int): List<DeliveryResponse> } @Dao interface DeliveryDao { @Query("SELECT * FROM ${DeliveryEntity.TABLE_NAME}") fun getAll(): DataSource.Factory<Int, DeliveryEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(delivery: DeliveryEntity) }
Como você pode ver, marquei os métodos como suspensos, para que possamos declarar apenas os objetos de resposta esperados. Além disso, o cancelamento da rotina principal também cancelará as chamadas de rede.
O mesmo para o DAO.
A única diferença é que queremos fornecer a capacidade de observar o banco de dados.
A maneira mais fácil é usar o suporte integrado a dados ao vivo. Mas se marcarmos getAll () como suspenso, isso causaria um erro de compilação
erro:
Not sure how to convert a Cursor to this method's return type ...
Aqui não precisamos suspender porque:
- Solicitações de banco de dados são executadas em segundo plano por padrão
- O LiveData resultante reconhece o ciclo de vida, para que não seja necessário cancelá-lo manualmente
De alguma forma, precisamos combinar fontes de dados remotas e locais.
Vale a pena lembrar - deve haver um único ponto de verdade.
De acordo com o design offline primeiro , seria o armazenamento local. Portanto, observaríamos o estado do banco de dados. Quando não há nada para recuperar, solicitamos dados remotos e os inserimos no banco de dados.
Vamos apresentar a classe Listing
data class Listing<T>( val pagedList: LiveData<PagedList<T>>, val dataState: LiveData<DataState>, val refreshState: LiveData<DataState>, val refresh: () -> Unit, val retry: () -> Unit )
Vamos val por val:
- pagedList - os principais dados que são construídos como PagedList para permitir rolagem infinita e agrupados com LiveData para permitir a observação de dados
- dataState - um dos três estados em que nossos dados podem estar: Sucesso, Execução, Erro. Também agrupado no LiveData para observar alterações
- refreshState - quando acionamos a atualização de dados através do deslize para atualizar, precisamos de alguma ferramenta pela qual distinguimos entre o feedback da solicitação de atualização e o feedback da solicitação da próxima página. Para o primeiro, queremos mostrar um erro no final da lista, mas, para o erro de atualização, queremos mostrar uma mensagem do sistema e ocultar um carregador.
- refresh () - retorno de chamada a ser acionado ao deslizar para atualizar
retry () - retorno de chamada para disparar em erro de carregamento de pagedList
Em seguida, modelo de exibição de lista:
class DeliveryListViewModel : BaseViewModel<DeliveryListBindings, DeliveryListBinding>(), DeliveryListBindings, DeliveryListItemBindings, DeliveryListErrorBindings { override val layoutId: Int = R.layout.delivery_list override val bindings: DeliveryListBindings = this private val deliveryGateway: DeliveryGateway by inject { parametersOf(this) } private val listing = deliveryGateway.getDeliveries() override val dataState = listing.dataState override val isRefreshing = Transformations.switchMap(listing.refreshState) { MutableLiveData(it == DataState.Loading) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupList() setupRefresh() } private fun setupList() { val adapter = DeliveriesAdapter(this, this) viewBinding.deliveries.adapter = adapter viewBinding.deliveries.setHasFixedSize(true) listing.pagedList.observe(viewLifecycleOwner, Observer { adapter.submitList(it) }) listing.dataState.observe(viewLifecycleOwner, Observer { adapter.updateDataState(it) }) } private fun setupRefresh() { listing.refreshState.observe(viewLifecycleOwner, Observer { if (it is DataState.Error) { Toast.makeText(context, it.message, LENGTH_SHORT).show() } }) } override fun refresh() { listing.refresh() } override fun onDeliveryClicked(delivery: Delivery) { view?.findNavController()?.navigate(DeliveryListViewModelDirections.toDetails(delivery)) } override fun onRetryClicked() { listing.retry() } }
Vamos começar com a declaração de classe.
Primeiro de tudo DeliveryListBindings e DeliveryListBinding. Primeiro é a nossa interface declarada para colar o modelo de visualização com a visualização XML. O segundo é a classe gerada automaticamente com base em XML. Precisamos do segundo para definir nossa interface de ligações e ciclo de vida para XML.
Além disso, é uma boa prática referenciar vistas usando essa ligação gerada automaticamente em vez de usar o sintético de kotlin.
Pode haver um caso em que a referência sintética não existe na visualização atual. Com a ligação de dados, você falhará rapidamente, mesmo no estágio de compilação.
A seguir, três interfaces: DeliveryListBindings, DeliveryListItemBindings, DeliveryListErrorBindings.
- DeliveryListBindings - ligações para a própria tela. Por exemplo, ele contém o método refresh () chamado de furto vertical.
- DeliveryListItemBindings - ligações para um item na lista. Por exemplo, onClicked ()
- DeliveryListErrorBindings - ligações para exibição de erro, que também é o item de lista mostrado no estado de erro. Por exemplo, ele contém o método retry ()
Assim, estamos lidando com tudo no modelo de visualização única, pois é uma tela única, mas também seguindo o princípio de Segregação de Interface
Vamos dar atenção especial a esta linha:
private val deliveryGateway: DeliveryGateway by inject { parametersOf(this) }
DeliveryGateway precisa executar solicitações fora do encadeamento principal. Portanto, ele precisa declarar métodos como suspensos ou CoroutineScope para lançar novas corotinas nesse escopo. Escolheríamos a segunda abordagem, pois precisamos dos nossos LiveData desde o início e esperaríamos apenas atualizações. É muito semelhante a se inscrever na instância liveData quando estamos passando o lifecycleOwner (que geralmente se refere a 'this'). Aqui estão da mesma maneira que estamos passando 'isso' como CoroutineScope
A interface CoroutineScope consiste em um único campo - CoroutineContext. Em essência, um escopo e um contexto são as mesmas coisas. A diferença entre um contexto e um escopo está na finalidade pretendida.
Para saber mais sobre isso, eu recomendaria um artigo de Roman Elizarov. Portanto, fornecer escopo ao DeliveryGateway também resultará no uso do mesmo contexto. Especificamente thread, trabalho e manipulador de exceções.
Agora vamos dar uma olhada no DeliveryGateway:
class DeliveryBoundGateway( private val db: DataBase, private val api: DeliveriesApi, private val deliveryDao: DeliveryDao, private val coroutineScope: CoroutineScope ) : DeliveryGateway { private val boundaryCallback = DeliveriesBoundaryCallback( api = api, coroutineScope = coroutineScope, handleResponse = { insertIntoDatabase(it) } ) @MainThread override fun getDeliveries(): Listing<Delivery> { val refreshTrigger = MutableLiveData<Unit>() val refreshState = Transformations.switchMap(refreshTrigger) { refresh() } val pagingConfig = Config( initialLoadSizeHint = PAGE_SIZE, pageSize = PAGE_SIZE, prefetchDistance = PAGE_SIZE ) val deliveries = deliveryDao.getAll() .toLiveData( config = pagingConfig, boundaryCallback = boundaryCallback ) return Listing( pagedList = deliveries, dataState = boundaryCallback.dataState, retry = { boundaryCallback.helper.retryAllFailed() }, refresh = { refreshTrigger.value = null }, refreshState = refreshState ) } @MainThread private fun refresh(): LiveData<DataState> { boundaryCallback.refresh() val dataState = MutableLiveData<DataState>() dataState.value = DataState.Loading coroutineScope.launch { try { val deliveries = api.getDeliveries(0, PAGE_SIZE) db.withTransaction { deliveryDao.clear() insertIntoDatabase(deliveries) } dataState.postValue(DataState.Loaded) } catch (throwable: Throwable) { Timber.w(throwable) dataState.postValue(DataState.Error(throwable.message)) } } return dataState } private suspend fun insertIntoDatabase(deliveries: List<DeliveryResponse>) { deliveries.forEach { delivery -> val entity = deliveryConverter.fromNetwork(delivery) deliveryDao.insert(entity) } } companion object { const val PAGE_SIZE = 20 } }
Aqui estamos construindo a estrutura do LiveData desde o início e, em seguida, usando corotinas para carregar dados e publicá-los no LiveData. Além disso, estamos usando a implementação de PagedList.BoundaryCallback () para colar o banco de dados local e a API remota. Quando chegamos ao final da lista paginada, boundaryCallback é acionado e carrega a próxima parte dos dados.
Como você pode ver, estamos usando o coroutineScope para lançar novas coroutines.
Como esse escopo é igual ao ciclo de vida do fragmento - todas as solicitações pendentes seriam canceladas no retorno de chamada onDestroy()
do fragmento.
A página de detalhes da entrega é bem direta - passamos um objeto Delivery como Parcelable da tela principal, usando o plug-in save args do componente de navegação. Na tela de detalhes, basta ligar um dado objeto a um XML.
class DeliveryViewModel : BaseViewModel<DeliveryBindings, DeliveryBinding>(), DeliveryBindings { override val layoutId: Int = R.layout.delivery override val bindings: DeliveryBindings = this private val args: DeliveryViewModelArgs by navArgs() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewBinding.delivery = args.delivery viewBinding.image.clipToOutline = true } }
Aqui está o link para o código-fonte do github.
Você pode deixar comentários e questões em aberto.