No post anterior, aprendemos sobre como o Kotlin/Native exporta uma coleção de .frameworks
no formato XCFramework.
Agora, vamos entender as características desse XCFramework.
Como utilizar um XCFramework no iOS
O pacote XCFramework irá oferecer um .framework
para cada Kotlin Native target. Lá dentro, alvos como o device físico (iosArm64
), simulador (iosSimulatorArm64
) e simuladores para processadores intel (iosX64
) estarão presentes.
Consumir um .framework
varia conforme o ambiente e o codebase existente, mas de forma geral, basta criar um build phase no projeto Xcode para conseguir utilizar o import das classes exportadas pelo Kotlin/Native.
- 🔗 Utilizando o Swift Package Manager
- 🔗 CocoaPods overview and setup
- 🔗 Kotlin/Native as an Apple framework – tutorial
Existem diversas formas que podemos utilizar para importar no projeto.
Todos esses modelos possuem características importantes a serem exploradas.
Entendendo como o XCFramework é gerado
No KMP, o .framework
é do tipo "Fat". Isso significa que ele inclui não apenas seu código, mas também todas as dependências necessárias. Isso difere de outros tipos, que podem incluir menos conteúdo:
- Skinny: Contém apenas o seu código, sem nenhuma dependência externa.
- Thin: Inclui seu código e suas dependências diretas.
- Hollow: O oposto do Thin, contendo apenas as dependências, sem seu código.
- Fat: Inclui tudo: seu código, dependências diretas e tudo o necessário para funcionar de forma independente.
Essa abordagem "Fat" tem implicações importantes para a modularização e o gerenciamento de dependências, como discutiremos a seguir.
A natureza "Fat" dos frameworks no KMP cria um desafio técnico para modularizar nossas distribuições. Isso ocorre porque todas as dependências são empacotadas juntas, forçando-nos a consolidar todo o código do KMP em uma única exportação. Esse modelo pode levar a duplicações de dependências e aumento do tamanho do pacote final, complicando a gestão do projeto, especialmente em ambientes de desenvolvimento colaborativos.
Contexto sobre aplicações Kotlin
Projetos Kotlin possuem uma natureza multi modular para reutilização de cache e desempenho de build. Modularizar projetos influenciam positivamente a experiência de desenvolvimento em projetos Kotlin que utilizam o Gradle.
Nesse artigo eu exploro um pouco mais sobre modularização em projetos Android, que também se aplicam para projetos KMP.
Projetos Kotlin costumam a ter múltiplos módulos como:
- legado
- core/design-system
- core/logging
- core/analytics
- feature1
- feature2
Esses módulos podem ser utilizados individualmente em projetos Kotlin, mas isso não significa que podemos ter um .framework
para correspondente.
Quer dizer, até podemos, porém, tem uma característica a ser observada.
Considere que a feature1
e feature2
utilizam as seguintes dependências em KMP:
// feature1
kotlinx-serialization
kotlinx-coroutines
// feature2
kotlinx-serialization
kotlinx-coroutines
Ao exportar o XCFramework, as dependencias do kotlinx-serialization
e kotlinx-coroutines
estariam duplicadas em cada .framework
, causando:
- Aumento do pacote final (
.ipa
); - Aumento de tempo de build, considerando uma escala de módulos.
Isso acontece por uma característica imposta pelo .framework
no iOS: um .framework
não consegue se comunicar com o outro.
Em um cenário ideal, o kotlinx-serialization
seria um .framework
isolado e nosso .framework
se comunicasse com esse .framework
.
Então, esse modelo "fat" se torna uma característica adotada em projetos KMP, como uma forma de otimização do uso e redução do tamanho final do aplicativo.
Com isso, vamos avançar e entender melhor quais desafios esse modelo impõe.
Utilizando um "fat" KMP no iOS
Consideremos um cenário onde temos um projeto iOS existente e desejamos integrar código KMP. Para ilustrar, vamos supor que fizemos uma alteração em um módulo, como adicionar um novo parâmetro a uma função. Esta mudança, embora pareça simples, pode quebrar o código no iOS, pois o projeto iOS espera a versão anterior da função. Aqui está um exemplo passo a passo:
Primeiro, vamos assumir o seguinte build.gradle.kts
:
kotlin {
val xcFramework = XCFramework(xcFrameworkName = "KotlinShared")
val exportedDependencies = listOf(feature1, feature2, core)
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "KotlinShared"
exportedDependencies.forEach { dependency ->
export(dependency.get())
}
xcFramework.add(this)
}
}
}
Ao executar a task assembleKotlinSharedXCFramework
, teremos um pacotão com todos os módulos exportados.
Para projetos KMP, é essencial ter um módulo central, muitas vezes chamado de ios-interop
. Esse módulo funciona como um ponto de integração que agrupa e exporta todas as dependências necessárias para serem usadas no Xcode. Esse método centraliza a gestão das dependências e facilita a manutenção e atualização do projeto.
Desafios para modularizar o KMP
Como discutimos anteriormente, a natureza "fat" dos frameworks XCFramework no KMP implica que cada módulo exportado inclui todas as suas dependências. Isso resulta em duplicação de dependências comuns entre módulos e um aumento geral no tamanho do pacote final. Além disso, essa abordagem gera desafios significativos na modularização, que são especialmente evidentes em projetos que integram o SwiftUI como interface de usuário no iOS. Vejamos esses desafios mais detalhadamente.
Vamos assumir que a feature1
e feature2
expõem as seguintes classes Kotlin a serem consumidas no iOS:
class Feature1ViewModel(
val repository: Feature1Repository
) {
fun fetch() = Unit
}
class Feature2ViewModel(
val repository: Feature2Repository
) {
fun fetch() = Unit
}
Ao exportar o XCFramework, todas as classes de feature1
e feature2
estarão presentes no .framework
, ou seja, conseguimos utilizar ambas Feature1ViewModel
e Feature2ViewModel
no iOS:
import KotlinShared
class Feature1ViewModelWrapper {
private let viewModel: KotlinSharedFeature1ViewModel
init(repository: Feature1Repository) {
self.viewModel = KotlinSharedFeature1ViewModel(repository: repository)
}
func fetch() {
viewModel.fetch()
}
}
class Feature2ViewModelWrapper {
private let viewModel: KotlinSharedFeature2ViewModel
init(repository: Feature2Repository) {
self.viewModel = KotlinSharedFeature2ViewModel(repository: repository)
}
func fetch() {
viewModel.fetch()
}
}
Até aqui, tudo certo. Nosso código KMP foi integrado no iOS com sucesso e vamos assumir que esse código já está até em produção. Agora, vamos adicionar um novo parâmetro no Feature1ViewModel
:
class Feature1ViewModel(
val repository: Feature1Repository,
val repository2: Feature1Repository2
) {
fun fetch() = Unit
fun fetchRepository2() = Unit
}
Ao exportar o XCFramework, o código no iOS irá quebrar, pois a classe Feature1ViewModelWrapper
não possui o novo parâmetro repository2
:
class Feature1ViewModelWrapper {
private let viewModel: KotlinSharedFeature1ViewModel
init(repository: Feature1Repository) {
//irá quebrar, `repository2` não está sendo enviado
self.viewModel = KotlinSharedFeature1ViewModel(repository: repository)
}
}
Agora, vamos assumir que esse XCFramework já foi gerado e exportado, porém, ainda não foi integrado no repositório do iOS. O time responsável pela feature2
precisa de uma nova funcionalidade e também precisa realizar uma alteração na Feature2ViewModel
:
class Feature2ViewModel(
val repository: Feature2Repository,
val repository2: Feature2Repository2,
) {
fun fetch() = Unit
}
Ao exportar o XCFramework, o código no iOS irá quebrar, pelo mesmo motivo acima, já que a classe Feature2ViewModelWrapper
não possui o novo parâmetro repository2
:
class Feature2ViewModelWrapper {
private let viewModel: KotlinSharedFeature2ViewModel
init(repository: Feature2Repository) {
self.viewModel = KotlinSharedFeature2ViewModel(repository: repository) //irá quebrar, `repository2` não foi passado como parametro
}
func fetch() {
viewModel.fetch()
}
}
Agregando esse cenário acima, temos a seguinte linha do tempo:
-
Feature1ViewModel
eFeature2ViewModel
são integradas ao projeto iOS. -
Feature1ViewModel
é atualizada para incluir um novo parâmetro, causando uma quebra no iOS. - Após o merge das alterações, uma nova versão do
XCFramework
é gerada e publicada através de ferramentas como Swift Package Manager, CocoaPods, controle de versão, etc. - Essa versão, contendo as mudanças em
Feature1ViewModel
, resulta em quebras no iOS. - Antes que essa versão seja integrada ao projeto iOS (corrigindo a quebra), o time de
feature2
realiza alterações noFeature2ViewModel
. - Uma versão subsequente do
XCFramework
é gerada e publicada, incluindo as novas alterações emFeature2ViewModel
que também resultam em quebras no iOS.
Neste cenário complexo:
- O time responsável por
feature2
precisa esperar que o time defeature1
corrija as quebras no iOS antes de poder integrar a correção dafeature2
. Este processo pode criar um ciclo de espera e correção que retarda a entrega de novas funcionalidades.
Para resumir e simplificar a compreensão:
- A versão 1.0.0 do XCFramework, já integrada no iOS, funciona sem problemas.
- A versão 1.1.0 introduz uma mudança significativa (
breaking change
) emfeature1
, causando problemas. - A versão 1.2.0 traz uma mudança significativa em
feature2
. - A versão 1.2.0 só pode ser integrada ao iOS depois que as correções de
feature1
na versão 1.1.0 forem integradas e validadas.
Dores do desenvolvimento KMP
Integrar código KMP em projetos iOS existentes, especialmente aqueles desenvolvidos com SwiftUI, apresenta desafios únicos devido à necessidade de uma comunicação direta entre módulos. Este desafio é menos intenso em projetos que utilizam Compose Multiplatform (CMP), onde a comunicação entre módulos ocorre de forma mais indireta e desacoplada.
O modelo "fat" de frameworks impõe várias complicações no desenvolvimento com KMP, entre elas:
- Gestão de Dependências: É necessário seguir uma linha do tempo específica para incorporar mudanças no código KMP ao repositório iOS, garantindo que todas as dependências estejam sincronizadas.
- Sensibilidade a Mudanças: Qualquer alteração em atributos, parâmetros ou funções pode resultar em quebras no projeto iOS, exigindo correções imediatas para manter a estabilidade do projeto.
- Dependência entre times: Devs frequentemente precisam esperar que outras times corrijam quebras no iOS antes de poderem avançar com a integração de novas funcionalidades do KMP.
Impacto no ciclo de desenvolvimento diário
No dia a dia, esses desafios tornam-se ainda mais evidentes. Por exemplo, ao integrar novas funcionalidades na branch principal (main
) do projeto KMP — geralmente associada ao desenvolvimento Android — e tentar testá-las no iOS, frequentemente nos deparamos com quebras devido a mudanças que ainda não foram integradas ao projeto iOS.
Para mitigar esse problema, geralmente geramos um XCFramework localmente para testes no iOS. No entanto, essa abordagem ainda sofre com o risco de quebras se a branch main contiver alterações não sincronizadas com o iOS, criando um ciclo contínuo de identificação e correção de quebras, o que atrasa significativamente o desenvolvimento.
Isso gera um gargalo enorme no dia a dia, pois temos um desafio enorme de identificar qual time responsável pela quebra e, consequentemente, aguardar a correção para então integrar o código KMP no iOS.
Em times pequenos ou projetos pessoais isso não é um problema, mas isso em escala é definitivamente o maior gargalo do desenvolvimento KMP atualmente.
Como contornar esse problema
- Melhoria na Comunicação: Reforçar a comunicação entre as times de desenvolvimento para planejar e sincronizar mudanças pode reduzir a frequência de quebras inesperadas.
- Automação de Testes: Implementar testes automatizados e processos de integração contínua para detectar e corrigir quebras antes que elas impactem outros desenvolvedores ou o projeto principal.
Existe uma estratégia que podemos adotar, porém, ficará para um artigo futuro. Primeiro, precisamos subir a escadinha de conhecimento em KMP em outros conceitos para conseguirmos compreender melhor essa estratégia alternativa.
Conclusão
É importante sermos realistas e encararmos os problemas reais de uma tecnologia. Às vezes, no calor do "boom" de uma nova tecnologia, deixamos passar alguns aspectos cruciais para escalarmos uma solução, e se não tratarmos esses problemas, podemos ter (teremos!) um gargalo enorme no desenvolvimento. Isso pode gerar um barulho interno no seu time, como pessoas não adotando a tecnologia devido a má experiência de desenvolvimento, e constantes quebras no código causados por outros times em outros contextos.
Entender a natureza do XCFramework é crucial para termos um projeto escalável e saudável, com uma experiência de desenvolvimento de ponta a ponta sem gargalos.
Nos próximos artigos, vamos entender melhor sobre o código que que é exportado para o iOS, algumas características e limitações do código Kotlin > Objective-C e Objective-C > Swift, como escrever nosso código Kotlin para ser idiomático em Swift, e algumas abordagens para melhorarmos a integração Kotlin <--> Swift.
Nos vemos na próxima, tchau!
Referências
https://dzone.com/articles/the-skinny-on-fat-thin-hollow-and-uber
Top comments (0)