Padrão arquitetural MVI no Kotlin Multiplatform, parte 2





Este é o segundo de três artigos sobre a aplicação do padrão arquitetural MVI no Kotlin Multiplatform. No primeiro artigo, lembramos o que é MVI e o aplicamos para escrever um código comum para iOS e Android. Introduzimos abstrações simples, como Store and View e algumas classes auxiliares, e as usamos para criar um módulo comum.



O objetivo deste módulo é fazer o download de links para imagens da Web e associar a lógica de negócios a uma interface do usuário representada como uma interface Kotlin, que deve ser implementada nativamente em cada plataforma. É isso que faremos neste artigo.



Implementaremos partes específicas da plataforma do módulo comum e as integraremos aos aplicativos iOS e Android. Como antes, presumo que o leitor já tenha conhecimento básico da Kotlin Multiplatform, portanto não falarei sobre configurações de projetos e outras coisas não relacionadas ao MVI no Kotlin Multiplatform.



Um projeto de amostra atualizado está disponível em nosso GitHub .



Plano



No primeiro artigo, definimos a interface KittenDataSource em nosso módulo Kotlin genérico. Essa fonte de dados é responsável por baixar links para imagens da web. Agora é hora de implementá-lo para iOS e Android. Para fazer isso, usaremos o recurso Kotlin Multiplatform como esperado / real . Em seguida, integramos nosso módulo genérico de gatinhos aos aplicativos iOS e Android. Para iOS, usamos SwiftUI, e para Android, usamos Android Views regulares.



Portanto, o plano é o seguinte:



  • Implementação do lado KittenDataSource

    • Para iOS
    • Para Android
  • Integrando o módulo Kittens no aplicativo iOS

    • Implementação do KittenView usando SwiftUI
    • Integrando o KittenComponent no SwiftUI View
  • Integrando o módulo Kittens no aplicativo Android

    • Implementação do KittenView usando o Android Views
    • Integrando KittenComponent no fragmento Android




Implementação KittenDataSource



Vamos primeiro lembrar como é essa interface:



internal interface KittenDataSource {
    fun load(limit: Int, offset: Int): Maybe<String>
}


E aqui está o cabeçalho da função de fábrica que vamos implementar:



internal expect fun KittenDataSource(): KittenDataSource


A interface e sua função de fábrica são declaradas internas e são detalhes de implementação do módulo Kittens. Usando expect / real, podemos acessar a API de cada plataforma.



KittenDataSource para iOS



Vamos implementar uma fonte de dados para iOS primeiro. Para acessar a API do iOS, precisamos colocar nosso código no conjunto de fontes "iosCommonMain". Está configurado para depender do commonMain. Os conjuntos de destino do código-fonte (iosX64Main e iosArm64Main), por sua vez, dependem do iosCommonMain. Você pode encontrar a configuração completa aqui .



Aqui está a implementação da fonte de dados:




internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybe<String> { emitter ->
            val callback: (NSData?, NSURLResponse?, NSError?) -> Unit =
                { data: NSData?, _, error: NSError? ->
                    if (data != null) {
                        emitter.onSuccess(NSString.create(data, NSUTF8StringEncoding).toString())
                    } else {
                        emitter.onComplete()
                    }
                }

            val task =
                NSURLSession.sharedSession.dataTaskWithURL(
                    NSURL(string = makeKittenEndpointUrl(limit = limit, offset = offset)),
                    callback.freeze()
                )
            task.resume()
            emitter.setDisposable(Disposable(task::cancel))
        }
            .onErrorComplete()
}



O uso do NSURLSession é a principal maneira de baixar dados da Web no iOS. Como é assíncrono, não é necessária nenhuma troca de encadeamento. Acabamos de encerrar a chamada no Maybe e adicionamos resposta, erro e tratamento de cancelamento.



E aqui está a implementação da função de fábrica:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


Neste ponto, podemos compilar nosso módulo comum para iosX64 e iosArm64.



KittenDataSource para Android



Para acessar a API do Android, precisamos colocar nosso código no conjunto de códigos-fonte androidMain. É assim que a implementação da fonte de dados se parece:



internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybeFromFunction {
            val url = URL(makeKittenEndpointUrl(limit = limit, offset = offset))
            val connection = url.openConnection() as HttpURLConnection

            connection
                .inputStream
                .bufferedReader()
                .use(BufferedReader::readText)
        }
            .subscribeOn(ioScheduler)
            .onErrorComplete()
}


Para o Android, implementamos o HttpURLConnection. Novamente, essa é uma maneira popular de carregar dados no Android sem usar bibliotecas de terceiros. Essa API está bloqueando, portanto, precisamos mudar para o encadeamento em segundo plano usando o operador subscribeOn.



A implementação da função de fábrica para Android é idêntica à usada para iOS:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


Agora podemos compilar nosso módulo comum para Android.



Integrando o módulo Kittens no aplicativo iOS



Esta é a parte mais difícil (e mais interessante) do trabalho. Digamos que compilamos nosso módulo como descrito no aplicativo iOS README . Também criamos um projeto básico do SwiftUI no Xcode e adicionamos nossa estrutura Kittens. Chegou a hora de integrar o KittenComponent ao seu aplicativo iOS.



Implementação do KittenView



Vamos começar implementando o KittenView. Primeiro, vamos lembrar como é sua interface no Kotlin:



interface KittenView : MviView<Model, Event> {
    data class Model(
        val isLoading: Boolean,
        val isError: Boolean,
        val imageUrls: List<String>
    )

    sealed class Event {
        object RefreshTriggered : Event()
    }
}


Portanto, nosso KittenView pega modelos e aciona eventos. Para renderizar o modelo no SwiftUI, precisamos fazer um proxy simples:



import Kittens

class KittenViewProxy : AbstractMviView<KittenViewModel, KittenViewEvent>, KittenView, ObservableObject {
    @Published var model: KittenViewModel?
    
    override func render(model: KittenViewModel) {
        self.model = model
    }
}


O proxy implementa duas interfaces (protocolos): KittenView e ObservableObject. O KittenViewModel é exposto usando a propriedade @ Published do modelo, para que nossa visualização SwiftUI possa se inscrever. Usamos a classe AbstractMviView que criamos no artigo anterior. Não precisamos interagir com a biblioteca Reaktive - podemos usar o método de despacho para despachar eventos.



Por que estamos evitando as bibliotecas Reaktive (ou coroutines / Flow) no Swift? Porque a compatibilidade Kotlin-Swift tem várias limitações. Por exemplo, parâmetros genéricos não são exportados para interfaces (protocolos), funções de extensão não podem ser chamadas da maneira usual etc. A maioria das limitações se deve ao fato de a compatibilidade do Kotlin-Swift ser feita através do Objective-C (você pode encontrar todas as limitações aqui) Além disso, devido ao complicado modelo de memória Kotlin / Native, acho que é melhor ter o mínimo possível de interação Kotlin-iOS.



Agora é hora de fazer uma visualização do SwiftUI. Vamos começar criando um esqueleto:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


Declaramos nossa visualização SwiftUI, que depende do KittenViewProxy. Uma propriedade de proxy marcada com @ObservedObject assina um ObservableObject (KittenViewProxy). Nosso KittenSwiftView será atualizado automaticamente sempre que o KittenViewProxy for alterado.



Agora vamos começar a implementar a visualização:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
    
    private var content: some View {
        let model: KittenViewModel! = self.proxy.model

        return Group {
            if (model == nil) {
                EmptyView()
            } else if (model.isError) {
                Text("Error loading kittens :-(")
            } else {
                List {
                    ForEach(model.imageUrls) { item in
                        RemoteImage(url: item)
                            .listRowInsets(EdgeInsets())
                    }
                }
            }
        }
    }
}


A parte principal aqui é o conteúdo. Pegamos o modelo atual do proxy e exibimos uma das três opções: nada (EmptyView), uma mensagem de erro ou uma lista de imagens.



O corpo da visualização pode ficar assim:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
        NavigationView {
            content
            .navigationBarTitle("Kittens KMP Sample")
            .navigationBarItems(
                leading: ActivityIndicator(isAnimating: self.proxy.model?.isLoading ?? false, style: .medium),
                trailing: Button("Refresh") {
                    self.proxy.dispatch(event: KittenViewEvent.RefreshTriggered())
                }
            )
        }
    }
    
    private var content: some View {
        // Omitted code
    }
}


Mostramos o conteúdo no NavigationView adicionando um título, um carregador e um botão para atualizar.



Cada vez que o modelo muda, a exibição é atualizada automaticamente. Um indicador de carregamento é exibido quando o sinalizador isLoading está definido como true. O evento RefreshTriggered é despachado quando o botão de atualização é clicado. Uma mensagem de erro será exibida se o sinalizador isError for verdadeiro; caso contrário, uma lista de imagens é exibida.



Integração com KittenComponent



Agora que temos um KittenSwiftView, é hora de usar nosso KittenComponent. O SwiftUI não tem nada além de View, portanto, teremos que agrupar KittenSwiftView e KittenComponent em uma visualização SwiftUI separada.



O ciclo de vida da visualização SwiftUI consiste em apenas dois eventos: onAppear e onDisappear. O primeiro é disparado quando a vista é mostrada na tela e o segundo é disparado quando está oculto. Não há aviso explícito de destruição do envio. Portanto, usamos o bloco "deinit", chamado quando a memória ocupada pelo objeto é liberada.



Infelizmente, as estruturas Swift não podem conter blocos deinit; portanto, teremos que agrupar nosso KittenComponent em uma classe:



private class ComponentHolder {
    let component = KittenComponent()
    
    deinit {
        component.onDestroy()
    }
}


Por fim, vamos implementar nossa visão principal de gatinhos:



struct Kittens: View {
    @State private var holder: ComponentHolder?
    @State private var proxy = KittenViewProxy()

    var body: some View {
        KittenSwiftView(proxy: proxy)
            .onAppear(perform: onAppear)
            .onDisappear(perform: onDisappear)
    }

    private func onAppear() {
        if (self.holder == nil) {
            self.holder = ComponentHolder()
        }
        self.holder?.component.onViewCreated(view: self.proxy)
        self.holder?.component.onStart()
    }

    private func onDisappear() {
        self.holder?.component.onViewDestroyed()
        self.holder?.component.onStop()
    }
}


O importante aqui é que o ComponentHolder e o KittenViewProxy estão marcados como Estado. As estruturas de exibição são recriadas toda vez que a interface do usuário é atualizada, mas as propriedades marcadas comoEstadosão salvos.



O resto é bem simples. Estamos usando o KittenSwiftView. Quando onAppear é chamado, passamos o KittenViewProxy (que implementa o protocolo KittenView) para o KittenComponent e iniciamos o componente chamando onStart. Quando onDisappear é acionado, chamamos os métodos opostos do ciclo de vida do componente. O KittenComponent continuará funcionando até que seja removido da memória, mesmo se mudarmos para uma visão diferente.



É assim que um aplicativo iOS se parece:



Integrando o módulo Kittens no aplicativo Android



Essa tarefa é muito mais fácil do que com o iOS. Suponha novamente que criamos um módulo de aplicativo básico para Android . Vamos começar implementando o KittenView.



Não há nada de especial no layout - apenas SwipeRefreshLayout e RecyclerView:



<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/swype_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@null"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


Implementação do KittenView:



internal class KittenViewImpl(root: View) : AbstractMviView<Model, Event>(), KittenView {
    private val swipeRefreshLayout = root.findViewById<SwipeRefreshLayout>(R.id.swype_refresh)
    private val adapter = KittenAdapter()
    private val snackbar = Snackbar.make(root, R.string.error_loading_kittens, Snackbar.LENGTH_INDEFINITE)

    init {
        root.findViewById<RecyclerView>(R.id.recycler).adapter = adapter

        swipeRefreshLayout.setOnRefreshListener {
            dispatch(Event.RefreshTriggered)
        }
    }

    override fun render(model: Model) {
        swipeRefreshLayout.isRefreshing = model.isLoading
        adapter.setUrls(model.imageUrls)

        if (model.isError) {
            snackbar.show()
        } else {
            snackbar.dismiss()
        }
    }
}


Como no iOS, usamos a classe AbstractMviView para simplificar a implementação. O evento RefreshTriggered é despachado ao atualizar com um furto. Quando ocorre um erro, a Snackbar é mostrada. O KittenAdapter exibe imagens e é atualizado sempre que o modelo muda. O DiffUtil é usado dentro do adaptador para evitar atualizações desnecessárias da lista. O código completo do KittenAdapter pode ser encontrado aqui .



É hora de usar o KittenComponent. Neste artigo, vou usar os snippets do AndroidX com os quais todos os desenvolvedores do Android estão familiarizados. Mas eu recomendo verificar nossos RIBs , um fork dos RIBs do Uber. Essa é uma alternativa mais poderosa e segura aos fragmentos.



class MainFragment : Fragment(R.layout.main_fragment) {
    private lateinit var component: KittenComponent

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        component = KittenComponent()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        component.onViewCreated(KittenViewImpl(view))
    }

    override fun onStart() {
        super.onStart()
        component.onStart()
    }

    override fun onStop() {
        component.onStop()
        super.onStop()
    }

    override fun onDestroyView() {
        component.onViewDestroyed()
        super.onDestroyView()
    }

    override fun onDestroy() {
        component.onDestroy()
        super.onDestroy()
    }
}


A implementação é muito simples. Instanciamos o KittenComponent e chamamos seus métodos de ciclo de vida no momento certo.



E aqui está a aparência de um aplicativo Android:



Conclusão



Neste artigo, integramos o módulo genérico Kittens nos aplicativos iOS e Android. Primeiro, implementamos uma interface KittensDataSource interna responsável pelo carregamento de URLs de imagem da web. Usamos o NSURLSession para iOS e o HttpURLConnection para Android. Em seguida, integramos o KittenComponent ao projeto iOS usando o SwiftUI e ao projeto Android usando o Android Views normal.



No Android, a integração do KittenComponent era muito simples. Criamos um layout simples com RecyclerView e SwipeRefreshLayout e implementamos a interface KittenView estendendo a classe AbstractMviView. Depois disso, usamos o KittenComponent em um fragmento: acabamos de criar uma instância e chamamos seus métodos de ciclo de vida.



Com o iOS, as coisas eram um pouco mais complicadas. Os recursos do SwiftUI nos forçaram a escrever algumas classes adicionais:



  • KittenViewProxy: Esta classe é KittenView e ObservableObject ao mesmo tempo; ele não exibe o modelo de vista diretamente, mas o expõe por meio do modelo de propriedade @ Published;
  • ComponentHolder: Esta classe mantém uma instância de KittenComponent e chama seu método onDestroy quando removida da memória.


No terceiro (e final) artigo desta série, mostrarei como essa abordagem é testável, demonstrando como escrever testes de unidade e integração.



Siga-me no Twitter e fique conectado!



All Articles