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!