Modelo de arquitetura MVI na multiplataforma Kotlin, parte 1



Há cerca de um ano, fiquei interessado na nova tecnologia Kotlin Multiplatform. Ele permite que você escreva um código comum e o compile para diferentes plataformas, enquanto tem acesso à API deles. Desde então, tenho experimentado ativamente nesta área e promovido essa ferramenta em nossa empresa. Um resultado, por exemplo, é a nossa biblioteca Reaktive - Extensões reativas para multiplataforma Kotlin.

Nos aplicativos Badoo e Bumble para desenvolvimento para Android, usamos o modelo de arquitetura MVI (para obter mais detalhes sobre nossa arquitetura, consulte o artigo de Zsolt Kocsi: “Arquitetura moderna de MVI baseada em Kotlin"). Trabalhando em vários projetos, me tornei um grande fã dessa abordagem. Obviamente, não pude perder a oportunidade de experimentar o MVI no Kotlin Multiplatform. Além disso, o caso era adequado: precisávamos escrever exemplos para a biblioteca Reaktive. Após essas minhas experiências, fiquei ainda mais inspirado pelo MVI.

Eu sempre presto atenção em como os desenvolvedores usam o Kotlin Multiplatform e como eles constroem a arquitetura desses projetos. De acordo com minhas observações, o desenvolvedor médio do Kotlin Multiplatform é na verdade um desenvolvedor Android que usa o modelo MVVM em seu trabalho simplesmente porque está acostumado a isso. Alguns aplicam adicionalmente “arquitetura limpa”. No entanto, na minha opinião, o MVI é mais adequado para a Kotlin Multiplatform, e “arquitetura limpa” é uma complicação desnecessária.

Por isso, decidi escrever esta série de três artigos sobre os seguintes tópicos:

  1. Uma breve descrição do modelo MVI, declaração do problema e criação de um módulo comum usando o Kotlin Multiplatform.
  2. Integração de um módulo comum em aplicativos iOS e Android.
  3. Teste de unidade e integração.

Abaixo está o primeiro artigo da série. Será de interesse de todos que já usam ou estão planejando usar o Kotlin Multiplatform.

Percebo imediatamente que o objetivo deste artigo não é ensinar como trabalhar com a própria Multiplataforma Kotlin. Se você acha que não possui conhecimento suficiente nessa área, recomendo que você se familiarize primeiro com a introdução e a documentação (especialmente as seções " Concorrência " e " Imutabilidade " para entender os recursos do modelo de memória Kotlin / Native). Neste artigo, não descreverei a configuração do projeto, módulos e outras coisas que não estão relacionadas ao tópico.

MVI


Primeiro, vamos lembrar o que é MVI. A abreviação significa Model-View-Intent. Existem apenas dois componentes principais no sistema:

  • modelo - uma camada de lógica e dados (o modelo também armazena o estado atual do sistema);
  • (View) — UI-, (states) (intents).

O diagrama a seguir provavelmente já é familiar para muitos:



Então, vemos esses componentes muito básicos: o modelo e a apresentação. Tudo o resto são os dados que circulam entre eles.

É fácil ver que os dados se movem apenas em uma direção. Os estados vêm do modelo e caem na visualização para exibição, as intenções vêm da visualização e entram no modelo para processamento. Essa circulação é chamada de fluxo de dados unidirecional.

Na prática, um modelo é frequentemente representado por uma entidade chamada Store (é emprestada do Redux). No entanto, isso nem sempre acontece. Por exemplo, em nossa biblioteca MVICore, o modelo é chamado de Recurso.

Também é importante notar que o MVI está intimamente relacionado à reatividade. A apresentação dos fluxos de dados e sua transformação, bem como o gerenciamento do ciclo de vida das assinaturas, é muito conveniente para implementar usando bibliotecas de programação reativas. Um número bastante grande deles já está disponível, no entanto, ao escrever código geral no Kotlin Multiplatform, podemos usar apenas bibliotecas de várias plataformas. Precisamos de abstração para fluxos de dados, precisamos da capacidade de conectar e desconectar suas entradas e saídas, além de realizar transformações. No momento, conheço duas dessas bibliotecas:

  • nossa biblioteca Reaktive - implementação de extensões reativas na multiplataforma Kotlin;
  • Corotinas e Fluxo - implementação de correntes de frio com corotinas Kotlin.

Formulação do problema


O objetivo deste artigo é mostrar como usar o modelo MVI no Kotlin Multiplatform e quais são as vantagens e desvantagens dessa abordagem. Portanto, não vou me apegar a nenhuma implementação específica do MVI. No entanto, usarei o Reaktive, porque os fluxos de dados ainda são necessários. Se desejado, tendo entendido a idéia, o Reaktive pode ser substituído por corotinas e Flow. Em geral, tentarei tornar nosso MVI o mais simples possível, sem complicações desnecessárias.

Para demonstrar o MVI, tentarei implementar o projeto mais simples que atenda aos seguintes requisitos:

  • suporte para Android e iOS;
  • Demonstração de operação assíncrona (entrada-saída, processamento de dados, etc.);
  • o máximo de código comum possível;
  • Implementação de UI com ferramentas nativas de cada plataforma;
  • falta de Rx no lado da plataforma (para que você não precise especificar dependências no Rx como uma "API").

Como exemplo, escolhi um aplicativo muito simples: uma tela com um botão, clicando na qual uma lista com imagens arbitrárias de gatos será baixada e exibida. Para fazer upload de imagens, usarei a API aberta: https://thecatapi.com . Isso permitirá que você cumpra os requisitos de operação assíncrona, pois é necessário fazer o download de listas da Web e analisar o arquivo JSON.

Você pode encontrar todo o código-fonte do projeto em nosso GitHub .

Introdução: abstrações para MVI


Primeiro, precisamos apresentar algumas abstrações para o nosso MVI. Vamos precisar dos componentes básicos - o modelo e a visualização - e algumas tipealias.

Typealiases


Para processar intenções, apresentamos um ator (Ator) - uma função que aceita a intenção e o estado atual e retorna um fluxo de resultados (Efeito):

typealias Ator < Estado , Intenção , Efeito > = ( Estado , Intenção ) - > Observável < Efeito >
ver cru MviKmpActor.kt hospedado com ❤ por GitHub

Também precisamos de um redutor (Redutor) - uma função que produz efeito e estado atual e retorna um novo estado:

typealias Redutor < Estado , Efeito > = ( Estado , Efeito ) - > Estado
ver cru MviKmpReducer.kt hospedado com ❤ por GitHub

Loja


A loja apresentará um modelo da MVI. Ele deve aceitar intenções e distribuir uma corrente de estados. Ao assinar um fluxo de estado, o estado atual deve ser emitido.

Vamos apresentar a interface apropriada:

Interface loja < em Intenção : Qualquer , fora Estado : Qualquer > : Consumidor < Intenção >, Observable < Estado >, descartável
ver cru MviKmpStoreInterface.kt hospedado com ❤ por GitHub

Portanto, nossa loja possui as seguintes propriedades:

  • possui dois parâmetros genéricos: Intenção de entrada e Estado de saída;
  • é consumidor de intenções (Consumidor <Intent>);
  • é um fluxo de estado (Observable <State>);
  • é destrutível (descartável).

Como não é muito conveniente implementar essa interface a cada vez, precisaremos de um certo assistente:

classe StoreHelper < in Intenção : Qualquer , fora Estado : Qualquer , com efeito : Qualquer > (
initialState : State ,
privada val ator : Ator < Estado , Intenção , Efeito >,
private val reducer: Reducer<State, Effect>
) : Observable<State>, DisposableScope by DisposableScope() {
init {
ensureNeverFrozen()
}
private val subject = BehaviorSubject(initialState)
fun onIntent(intent: Intent) {
actor(subject.value, intent).subscribeScoped(isThreadLocal = true, onNext = ::onEffect)
}
fun onEffect ( efeito : Efeito ) {
subject.onNext (redutor (subject.value, effect))
}
substituir a inscrição divertida ( observador : ObservableObserver < Estado >) {
subject.subscribe (observador)
}
}
ver cru MviKmpStoreHelper.kt hospedado com ❤ por GitHub


StoreHelper é uma classe pequena que facilitará a criação de uma loja. Possui as seguintes propriedades:

  • possui três parâmetros genéricos: intenção de entrada e efeito e estado de saída;
  • aceita o estado inicial através do construtor, do ator e da caixa de velocidades;
  • é um fluxo de estados;
  • destrutível (descartável);
  • sem congelamento (para que os assinantes também não sejam congelados );
  • implementa DisposableScope (interface da Reaktive para gerenciar assinaturas);
  • aceita e processa intenções e efeitos.

Veja o diagrama da nossa loja. O ator e a caixa de velocidades nele são detalhes de implementação:



Vamos considerar o método onIntent com mais detalhes:

  • aceita intenções como argumento;
  • chama o ator e passa a intenção e o estado atual para ele;
  • assina o fluxo de efeitos retornado pelo ator;
  • direciona todos os efeitos para o método onEffect;
  • A assinatura dos efeitos é realizada usando o sinalizador isThreadLocal (isso evita o congelamento no Kotlin / Native).

Agora vamos dar uma olhada no método onEffect:

  • toma efeitos como argumento;
  • chama a caixa de velocidades e transfere o efeito e o estado atual para ela;
  • passa o novo estado para BehaviorSubject, o que leva ao recebimento do novo estado por todos os assinantes.

Visão


Agora vamos entrar na apresentação. Ele deve aceitar modelos para exibição e fornecer um fluxo de eventos. Também criaremos uma interface separada:

interface MviView < no modelo : Qualquer , fora Evento : Qualquer > {
eventos val : Observable < Event >
diversão render ( modelo : Modelo )
}
ver cru MviKmpMviView.kt hospedado com ❤ por GitHub


Uma visualização possui as seguintes propriedades:

  • possui dois parâmetros genéricos: Modelo de entrada e Evento de saída;
  • aceita modelos para exibição usando o método render;
  • despacha um fluxo de eventos usando a propriedade events

Adicionei o prefixo Mvi ao nome do MviView para evitar confusão com o Android View. Além disso, não expandi as interfaces Consumer e Observable, mas simplesmente usei a propriedade e o método. Isso é para que você possa definir a interface de apresentação para a plataforma de implementação (Android ou iOS) sem exportar o Rx como uma dependência "api". O truque é que os clientes não interagem diretamente com a propriedade "events", mas implementam a interface MviView, expandindo a classe abstrata.

Adicione imediatamente esta classe abstrata para representar:

abstract class AbstractMviView<in Model : Any, Event : Any> : MviView<Model, Event> {
private val subject = PublishSubject<Event>()
override val events: Observable<Event> = subject
protected fun dispatch(event: Event) {
subject.onNext(event)
}
}

Essa classe nos ajudará na emissão de eventos e também evitará que a plataforma interaja com o Rx.

Aqui está um diagrama que mostra como isso funcionará: A



Loja produz estados que são transformados em modelos e exibidos pela visualização. O último produz eventos que são convertidos em intenções e entregues à Loja para processamento. Essa abordagem remove a coerência entre a loja e a apresentação. Mas, em casos simples, uma visão pode funcionar diretamente com estados e intenções.

É tudo o que precisamos para implementar o MVI. Vamos escrever o código geral.

Código comum


Plano


  1. Faremos um módulo geral cuja tarefa é fazer o download e exibir uma lista de imagens de gatos.
  2. Na interface do usuário, abstraímos a interface e transferiremos sua implementação para fora.
  3. Esconderemos nossa implementação atrás de uma fachada conveniente.

Kittenstore


Vamos começar com o principal - crie um KittenStore que carregará uma lista de imagens:

interface interna KittenStore : Store < Intent , State > {
classe selada Intenção {
objeto Recarregar : Intent ()
}
classe de dados State (
val isLoading : Boolean = false ,
val data : Data = Data . Imagens ()
) {
Classe selada Data {
classe de dados Images ( val urls : List < String > = emptyList ()) : Data ()
erro de objeto : Data ()
}
}
}


Expandimos a interface da loja com tipos de intenção e tipos de estado. Atenção: a interface é declarada como interna. Nossa KittenStore são os detalhes de implementação do módulo. Nossa intenção é apenas uma - Recarregar, causa o carregamento de uma lista de imagens. Mas vale a pena considerar a condição com mais detalhes:

  • o sinalizador isLoading indica se o download está em andamento ou não;
  • A propriedade data pode ter uma de duas opções:
    • Imagens - uma lista de links para imagens;
    • Erro - significa que ocorreu um erro.

Agora vamos começar a implementação. Faremos isso em etapas. Primeiro, crie uma classe KittenStoreImpl vazia que implementará a interface KittenStore:

classe interna KittenStoreImpl (
) : KittenStore , DisposableScope por DisposableScope () {
substituir diversão onNext ( valor : Intent ) {
}
substituir a inscrição divertida ( observador : ObservableObserver < Estado >) {
}
}


Também implementamos a interface familiar DisposableScope. Isso é necessário para um gerenciamento conveniente de assinaturas.

Precisamos baixar a lista de imagens da Web e analisar o arquivo JSON. Declare as dependências correspondentes:

classe interna KittenStoreImpl (
rede val privada : Rede ,
o privado val analisador : Analisador
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
análise divertida ( json : String ) : Talvez < Lista < String >>
}
}

A Rede fará o download do texto do arquivo JSON da Rede, e o Analisador analisará o arquivo JSON e retornará uma lista de links de imagem. No caso de um erro, talvez acabe simplesmente sem resultado. Neste artigo, não estamos interessados ​​no tipo de erro.

Agora declare os efeitos e redutor:

classe interna KittenStoreImpl (
rede val privada : Rede ,
o privado val analisador : Analisador
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reduce(state: State, effect: Effect): State =
when (effect) {
is Effect.LoadingStarted -> state.copy(isLoading = true)
is Effect.LoadingFinished -> state.copy(isLoading = false, data = State.Data.Images(urls = effect.imageUrls))
is Effect.LoadingFailed -> state.copy(isLoading = false, data = State.Data.Error)
}
private sealed class Effect {
object LoadingStarted : Effect()
data class LoadingFinished(val imageUrls: List<String>) : Effect()
object LoadingFailed : Effect()
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
análise divertida ( json : String ) : Talvez < Lista < String >>
}
}

Antes de iniciar o download, fornecemos o efeito LoadingStarted, que faz com que o sinalizador isLoading seja definido. Após a conclusão do download, emitimos LoadingFinished ou LoadingFailed. No primeiro caso, limpamos o sinalizador isLoading e aplicamos a lista de imagens; no segundo, também limpamos o sinalizador e aplicamos o estado de erro. Observe que os efeitos são a API privada da nossa KittenStore.

Agora, implementamos o próprio download:

classe interna KittenStoreImpl (
private val network: Network,
private val parser: Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
network
.load()
.flatMap(parser::parse)
.map(Effect::LoadingFinished)
.observeOn(mainScheduler)
.asObservable()
.defaultIfEmpty(Effect.LoadingFailed)
.startWithValue(Effect.LoadingStarted)
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse(json: String): Maybe<List<String>>
}
}

Aqui, vale a pena prestar atenção ao fato de que passamos o Network e o Parser para a função recarregar, apesar de eles já estarem disponíveis para nós como propriedades do construtor. Isso é feito para evitar referências a isso e, como resultado, congelar toda a KittenStore.

Bem, finalmente, use o StoreHelper e termine a implementação do KittenStore:

classe interna KittenStoreImpl (
rede val privada : Rede ,
o privado val analisador : Analisador
) : KittenStore , DisposableScope por DisposableScope () {
private val helper = StoreHelper(State(), ::handleIntent, ::reduce).scope()
override fun onNext(value: Intent) {
helper.onIntent(value)
}
override fun subscribe(observer: ObservableObserver<State>) {
helper.subscribe(observer)
}
private fun handleIntent(state: State, intent: Intent): Observable<Effect> =
when (intent) {
is Intent.Reload -> reload(network, parser)
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
// Omitted code
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
divertido load () : talvez < String >
}
Analisador de interface {
análise divertida ( json : String ) : Talvez < Lista < String >>
}
}

Nossa KittenStore está pronta! Passamos à apresentação.

Kittenview


Declare a seguinte interface:

interface KittenView : MviView < Modelo , Evento > {
modelo de classe de dados (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
Evento de classe selada {
objeto RefreshTriggered : Event ()
}
}

Anunciamos um modelo de exibição com sinalizadores de carga e erro e uma lista de links de imagens. Temos apenas um evento - RefreshTriggered. É emitido sempre que o usuário chama uma atualização. KittenView é a API pública do nosso módulo.

KittenDataSource


A tarefa dessa fonte de dados é fazer o download de texto para um arquivo JSON da Web. Como sempre, declare a interface:

interface interna KittenDataSource {
carga divertida ( limite : Int , deslocamento : Int ) : Talvez < String >
}

As implementações de fonte de dados serão feitas para cada plataforma separadamente. Portanto, podemos declarar um método de fábrica usando expect / real:

diversão interna esperada KittenDataSource () : KittenDataSource

As implementações da fonte de dados serão discutidas na próxima parte, onde implementaremos aplicativos para iOS e Android.

Integração


O estágio final é a integração de todos os componentes.

Implementação da interface de rede:

classe interna KittenStoreNetwork (
private val dataSource : KittenDataSource
) : KittenStoreImpl . Rede {
substituir diversão load () : Talvez < String > = dataSource.load (limit = 50 , offset = 0 )
}
ver cru KittenStoreNetwork.kt hospedado com ❤ por GitHub


Implementação da interface do analisador:

objeto interno KittenStoreParser : KittenStoreImpl . Analisador {
substituir análise divertida ( json : String ) : Talvez < List < String >> =
maybeFromFunction {
Json ( JsonConfiguration . Estável )
.parseJson (json)
.jsonArray
.map {it.jsonObject.getPrimitive ( " url " ) .content}
}
.subscribeOn (computationScheduler)
.onErrorComplete ()
}
ver cru KittenStoreParser.kt hospedado com ❤ por GitHub

Aqui usamos a biblioteca kotlinx.serialization . A análise é realizada em um planejador de computação para evitar o bloqueio do encadeamento principal.

Converter estado para visualizar o modelo:

Estado divertido interno . toModel () : Model =
Modelo (
isLoading = isLoading,
isError = quando ( dados ) {
é Estado . Os dados . Imagens - > false
é Estado . Os dados . Erro - > verdadeiro
}
imageUrls = quando ( dados ) {
é Estado . Os dados . Imagens - > dados .urls
é Estado . Os dados . Erro - > emptyList ()
}
)
ver cru MviKmpStateToModel.kt hospedado com ❤ por GitHub

Converta eventos em intenções:

Evento interno divertido . toIntent () : Intent =
quando ( isso ) {
é evento . RefreshTriggered - > Intent . recarregar
}
ver cru MviKmpEventToIntent.kt hospedado com ❤ pelo GitHub

Preparação da fachada:

classe KittenComponent {
diversão onViewCreated ( exibição : KittenView ) {
}
divertido onStart () {
}
divertido onStop () {
}
divertido onViewDestroyed () {
}
divertido onDestroy () {
}
}

Familiar para muitos desenvolvedores do Android. É ótimo para iOS e até JavaScript. O diagrama da transição entre os estados do ciclo de vida de nossa fachada se parece com o seguinte:


explicarei brevemente o que está acontecendo aqui:

  • primeiro, o método onCreate é chamado, depois de - onViewCreated e, em seguida - onStart: isso coloca a fachada em condição de trabalho (iniciada);
  • em algum momento após isso, o método onStop é chamado: isso coloca a fachada em um estado parado (parado);
  • em um estado parado, um dos dois métodos pode ser chamado: onStart ou onViewDestroyed, ou seja, a fachada pode ser iniciada novamente ou a visualização pode ser destruída;
  • quando a vista é destruída, ela pode ser criada novamente (onViewCreated) ou a fachada inteira pode ser destruída (onDestroy).

A implementação da fachada pode ser assim:

classe KittenComponent {
private val store =
KittenStoreImpl (
rede = KittenStoreNetwork (dataSource = KittenDataSource ()),
parser = KittenStoreParser
)
visualização var privada : KittenView? = nulo
private var startStopScope : DisposableScope? = nulo
diversão onViewCreated ( exibição : KittenView ) {
this .view = view
}
fun onStart() {
val view = requireNotNull(view)
startStopScope = disposableScope {
store.subscribeScoped(onNext = view::render)
view.events.map(Event::toIntent).subscribeScoped(onNext = store::onNext)
}
}
fun onStop() {
startStopScope?.dispose()
}
fun onViewDestroyed() {
view = null
}
fun onDestroy() {
store.dispose()
}
}

Como funciona:

  • primeiro, criamos uma instância do KittenStore;
  • no método onViewCreated, lembramos do link para o KittenView;
  • no onStart, assinamos o KittenStore e o KittenView;
  • no onStop, nós os espelhamos;
  • no onViewDestroyed, limpamos o link da visualização;
  • em onDestroy, destrua o KittenStore.

Conclusão


Este foi o primeiro artigo da minha série MVI na Kotlin Multiplatform. Nele nós:

  • lembrou o que é o MVI e como ele funciona;
  • fez a implementação mais simples de MVI no Kotlin Multiplatform usando a biblioteca Reaktive;
  • criou um módulo comum para carregar uma lista de imagens usando o MVI.

Observe as propriedades mais importantes do nosso módulo comum:

  • conseguimos colocar todo o código no módulo multiplataforma, exceto o código da interface do usuário; toda a lógica, além das conexões e conversões entre a lógica e a interface do usuário, é comum;
  • lógica e interface do usuário são completamente independentes;
  • a implementação da interface do usuário é muito simples: você só precisa exibir os modelos de exibição recebidos e lançar eventos;
  • A integração do módulo também é simples: tudo o que você precisa é:
    • implementar interface KittenView (protocolo);
    • crie uma instância do KittenComponent;
    • chame seus métodos de ciclo de vida na hora certa;
  • essa abordagem evita o "fluxo" de Rx (ou corotina) nas plataformas, o que significa que não precisamos gerenciar nenhuma assinatura no nível do aplicativo;
  • todas as classes importantes são abstraídas por interfaces e testadas.

Na próxima parte, mostrarei na prática como é a integração do KittenComponent nos aplicativos iOS e Android.

Siga-me no Twitter e fique conectado!

All Articles