Por que escolhemos o Kotlin como um dos nossos idiomas de destino. Parte 2: Multiplataforma Kotlin

Continuamos a série de artigos sobre a introdução da linguagem Kotlin em nosso processo de desenvolvimento. Procure a primeira parte aqui .

Em 2017, o ambicioso projeto da Jetbrains viu a luz do dia, oferecendo uma nova perspectiva sobre o desenvolvimento de plataforma cruzada. Compilando o código kotlin no código nativo de várias plataformas! Em Domklik, por sua vez, estamos sempre procurando maneiras de otimizar o processo de desenvolvimento. O que poderia ser melhor do que reutilizar código, pensamos? É isso mesmo - não escreva código. E para que tudo funcione como você deseja. Mas enquanto isso não acontece. E se existe uma solução que nos permita, sem gastar muito esforço, usar uma única base de código para diferentes plataformas, por que não tentar?

Então olá pessoal! Meu nome é Gennady Vasilkov, sou desenvolvedor Android da Domklik e hoje quero compartilhar com você nossa experiência no desenvolvimento da Kotlin Multiplatform para dispositivos móveis, conte-nos as dificuldades que encontramos, como resolvemos e com o que acabamos. O tópico certamente será interessante para quem deseja experimentar o Kotlin MPP (projetos de multiplataforma), ou já o experimentou, mas não concluiu a produção. Ou trouxe, mas não como eu gostaria. Tentarei transmitir nossa visão de como o processo de desenvolvimento e entrega de bibliotecas desenvolvidas deve ser organizado (usando uma delas como exemplo, mostrarei o início de nosso caminho de desenvolvimento no Kotlin MPP).

Gostaria de histórias sobre como fizemos isso? Nos os temos!



Brevemente sobre tecnologia


Para aqueles que ainda não ouviram ou não mergulharam no tópico, ele está no mundo Java e apenas vai para o mundo Kotlin (ou não vai, mas espreita): o Kotlin MPP é uma tecnologia que permite que você use um código escrito em várias plataformas ao mesmo tempo.

O desenvolvimento é, sem surpresa, na linguagem Kotlin no IntelliJ IDEA ou no Android Studio. A vantagem é que todos os desenvolvedores de Android (pelo menos conosco) conhecem e amam tanto a linguagem quanto esses maravilhosos ambientes de desenvolvimento.

Também uma grande vantagem na compilação do código resultante em idiomas nativos de cada plataforma (OBJ-C, JS, tudo fica claro na JVM).

Ou seja, tudo é legal. Na minha opinião, o Kotlin Multiplatform é ideal para levar a lógica de negócios às bibliotecas. Também é possível escrever um aplicativo completamente, mas por enquanto parece muito extremo, mas o desenvolvimento da biblioteca é adequado para projetos grandes como o nosso.

Um pouco técnico, para entender como o projeto funciona no MPP Kotlin


  • O sistema de compilação é o Gradle , ele suporta sintaxe nos scripts groovy e kotlin (kts).
  • O MPP da Kotlin tem o conceito de alvos - plataformas de alvos. Nesses blocos, os sistemas operacionais de que precisamos são configurados, cujo código nativo compilará nosso código no Kotlin. Na imagem abaixo, dois objetivos são implementados, jvm e js (a imagem é parcialmente retirada do site ): O

    suporte para outras plataformas é implementado da mesma maneira.
  • Conjuntos de fontes - o nome em si é claro que os códigos-fonte das plataformas são armazenados aqui. Existe um conjunto e uma plataforma de origem comum (existem tantos quanto existem destinos no projeto, isso é seguido pelo IDE). É impossível não mencionar o mecanismo esperado-real aqui.



    O mecanismo permite acessar o código específico da plataforma a partir do módulo Comum. Declaramos a expectativa de declaração no módulo Comum e a implementamos nas plataformas. Abaixo está um exemplo de como usar o mecanismo para obter a data nos dispositivos:

    Common:
    internal expect val timestamp: Long
    
    Android/JVM:
    internal actual val timestamp: Long 
        get() = java.lang.System.currentTimeMillis()
    
    iOS:
    internal actual val timestamp: Long 
        get() = platform.Foundation.NSDate().timeIntervalSince1970.toLong()
    

    Como você pode ver para os módulos da plataforma, as bibliotecas do sistema Java e iOS Foundation estão disponíveis, respectivamente.

Esses pontos são suficientes para entender o que será discutido mais adiante.

Nossa experiência


Então, em algum momento, decidimos que tudo era aceito pelo Kotlin MPP (então também era chamado Kotlin / Native) como padrão e começamos a escrever bibliotecas nas quais compartilhamos o código comum. No início, era código apenas para plataformas móveis; em algum momento, eles adicionaram suporte ao back-end da jvm. No android, o desenvolvimento e a publicação das bibliotecas desenvolvidas não causaram problemas; no iOS, na prática, eles encontraram alguns problemas, mas foram resolvidos com sucesso e um modelo de trabalho para o desenvolvimento e publicação de estruturas foi desenvolvido.

Hora de algo por aí!


Executamos várias funcionalidades em bibliotecas separadas que usamos no projeto principal.

1) Analytics


Antecedentes da ocorrência


Não é segredo que todos os aplicativos móveis coletam várias análises. Os eventos são interrompidos com todos os eventos significativos e não muito (por exemplo, em nosso aplicativo, mais de 600 várias métricas são coletadas). E o que é coleção de métricas? De uma maneira simples, essa é uma chamada para uma função que envia um evento com uma certa chave para as entranhas do mecanismo de análise. E então entra em uma variedade de sistemas de análise como firebase, appmetrica e outros. Que problemas existem com isso? Duplicação constante do mesmo código (mais ou menos) em duas plataformas! Sim, e o fator humano não pode ser encontrado em nenhum lugar - os desenvolvedores podem estar enganados, tanto no nome das chaves quanto no conjunto de metadados transmitidos com o evento. Isso claramente precisa ser escrito uma vez e usado em cada plataforma. Um candidato ideal para criar lógica geral e tecnologia de teste (esta é a nossa caneta de teste no Kotlin Mpp).

Como implementar


Transferimos os próprios eventos para a biblioteca (na forma de funções com uma chave com fio e um conjunto de metadados necessários nos argumentos) e escrevemos uma nova lógica para processar esses eventos. O formato do evento foi unificado e um mecanismo manipulador (barramento) foi criado, no qual todos os eventos foram lançados. E para vários sistemas de análise, os ouvintes do adaptador escreveram (uma solução muito útil acabou sendo, ao adicionar qualquer análise, podemos facilmente redirecionar tudo, ou seletivamente, cada evento para uma nova análise).



Interessante no processo de implementação


Devido aos recursos da implementação do trabalho com fluxos no Kotlin / Native para iOS, você não pode simplesmente pegar e trabalhar com o objeto Kotlin como um singleton e gravar dados a qualquer momento. Todos os objetos que transferem seu estado entre threads devem ser congelados (existe uma função freeze () para isso). Mas a biblioteca, entre outras coisas, armazena um estado que pode mudar durante a vida do aplicativo. Existem várias maneiras de resolver essa situação, decidimos pelo mais simples:

Common:

expect class AtomicReference<T>(value_: T) {
    var value: T
}

Android:

actual class AtomicReference<T> actual constructor(value_: T) {
    actual var value: T = value_
}

iOS:

actual typealias AtomicReference<V> = kotlin.native.concurrent.AtomicReference<V>

Esta opção é adequada para armazenamento de estado simples. No nosso caso, a configuração da plataforma é armazenada:

object Config {
    val platform = AtomicReference<String?>("")
    var manufacturer = AtomicReference<String?>("")
    var model = AtomicReference<String?>("")
    var deviceId = AtomicReference<String?>("")
    var appVersion = AtomicReference<String?>("")
    var sessionId = AtomicReference<String?>("")
    var debug = AtomicReference<Boolean>(false)
}

Por que isso é necessário? Não obtemos algumas informações no início do aplicativo, quando o módulo já deve estar carregado e seus objetos já estão no estado congelado. Para adicionar essas informações à configuração e, em seguida, elas podem ser lidas em diferentes fluxos no iOS, e é necessária uma mudança.

Também enfrentamos a questão da publicação da biblioteca. E se o Android não teve problemas com isso, o método proposto pela documentação oficial da época para conectar a estrutura montada diretamente ao projeto iOS não nos convinha por vários motivos, eu queria obter novas versões o mais simples e transparente possível. Além disso, o controle de versão é suportado.
A solução é conectar a estrutura através de um arquivo pod. Para fazer isso, eles geraram o arquivo podspec e a própria estrutura, os colocaram no repositório e a conexão da biblioteca ao projeto tornou-se muito simples e conveniente para os desenvolvedores do iOS. Além disso, para maior conveniência do desenvolvimento, coletamos um único artefato para todas as arquiteturas, a chamada estrutura gorda (na verdade, um binário em negrito no qual todas as arquiteturas e metarquivos adicionados gerados pelo plugin kotlin se dão bem). A implementação de tudo isso sob o spoiler:

Quem se importa com o que fizemos antes da decisão oficial
Após o plug-in do kotlin coletar para nós várias estruturas diferentes para arquiteturas diferentes, criamos manualmente uma nova estrutura universal na qual copiamos metadados de qualquer (no nosso caso, pegamos arm64):
//add meta files (headers, modules and plist) from arm64 platform
task copyMeta() {
    dependsOn build

    doLast {
        copy {
            from("$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework")
            into "$buildDir/iphone_universal/${frameworkName}.framework"
        }
    }
}

, , , . lipo, :
//merge binary files into one
task lipo(type: Exec) {
    dependsOn copyMeta

    def frameworks = files(
            "$buildDir/bin/iphone32/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphoneSim/main/release/framework/${frameworkName}.framework/$frameworkName"
    )
    def output = file("$buildDir/iphone_universal/${frameworkName}.framework/$frameworkName")
    inputs.files frameworks
    outputs.file output
    executable = 'lipo'
    args = frameworks.files
    args += ['-create', '-output', output]
}

, - Kotlin MPP Info.plist UIRequiredDeviceCapabilities, ( ). PlistBuddy:
//workaround
//remove UIRequiredDeviceCapabilities key from plist file (because we copy this file from arm64, only arm64 architecture was available)
task editPlistFile(type: Exec) {
    dependsOn lipo

    executable = "/bin/sh"
    def script = './scripts/edit_plist_file.sh'
    def command = "Delete :UIRequiredDeviceCapabilities"
    def file = "$buildDir/iphone_universal/${frameworkName}.framework/Info.plist"

    args += [script, command, file]
}

edit_plist_file.sh:
/usr/libexec/PlistBuddy -c "$1" "$2"

, zip ( podspec ):
task zipIosFramework(type: Zip) {
    dependsOn editPlistFile
    from "$buildDir/iphone_universal/"
    include '**/*'
    archiveName = iosArtifactName
    destinationDir(file("$buildDir/iphone_universal_zipped/"))
}

podspec :
task generatePodspecFile(type: Exec) {
    dependsOn zipIosFramework

    executable = "/bin/sh"

    def script = './scripts/generate_podspec_file.sh'

    def templateStr = "version_name"
    def sourceFile = './podspec/template.podspec'
    def replaceStr = "$version"

    args += [script, templateStr, replaceStr, sourceFile, generatedPodspecFile]
}

generate_podscpec_file.sh :
sed -e s/"$1"/"$2"/g <"$3" >"$4"

curl :
task uploadIosFrameworkToNexus(type: Exec) {
    dependsOn generatePodspecFile

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $buildDir/iphone_universal_zipped/$iosArtifactName $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

task uploadPodspecFileToNexus(type: Exec) {
    dependsOn uploadIosFrameworkToNexus

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $generatedPodspecFile $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

:
task createAndUploadUniversalIosFramework() {
    dependsOn uploadPodspecFileToNexus
    doLast {
        println 'createAndUploadUniversalIosFramework complete'
    }
}


Profit!


No momento da redação deste artigo, existem soluções oficiais para criar uma estrutura gorda e gerar um arquivo podspec (e geralmente integração com o CocoaPods). Portanto, basta carregar a estrutura e o arquivo podspec criados no repositório e conectar o projeto no iOS da mesma maneira que antes:




Após implementar esse esquema com um único local para armazenar eventos, um mecanismo para processar e enviar métricas, nosso processo de adicionar novas métricas foi reduzido ao fato de um desenvolvedor adicionar uma nova função à biblioteca, que descreve a chave do evento e os metadados necessários que o desenvolvedor deve transmitir para um evento específico . Além disso, o desenvolvedor de outra plataforma simplesmente baixa uma nova versão da biblioteca e usa a nova função no lugar certo. Tornou-se muito mais fácil manter a consistência de todo o sistema de análise; agora também é muito mais fácil alterar a implementação interna do barramento de eventos. Por exemplo, em algum momento, para um de nossos analistas, foi necessário implementar nosso próprio buffer de eventos, o que foi feito imediatamente na biblioteca, sem a necessidade de alterar o código nas plataformas. Lucro!

Continuamos a transferir cada vez mais código para o Kotlin MPP. Nos artigos a seguir, continuarei a história de nossa introdução ao mundo do desenvolvimento de plataforma cruzada usando mais duas bibliotecas como exemplos, nas quais a serialização e o banco de dados foram usados, e trabalhando com o tempo. Vou falar sobre as restrições encontradas ao usar várias estruturas kotlin em um projeto iOS e como contornar essa limitação.

E agora brevemente sobre os resultados de nossa pesquisa


+ Tudo funciona de maneira estável na produção.
+ Base de código unificada da lógica do serviço comercial para todas as plataformas (menos erros e discrepância).
+ Custos de suporte reduzidos e tempo de desenvolvimento reduzido para novos requisitos.
+ Aumentamos a experiência dos desenvolvedores.

Links Relacionados


Site da Kotlin Multiplatform: www.jetbrains.com/lp/mobilecrossplatform

ZY:
Não quero repetir sobre o laconicismo da linguagem e, portanto, focar na lógica de negócios ao escrever código. Posso aconselhar vários artigos que

abordam este tópico: “Kotlin por baixo do capô - veja bytecode descompilado” - um artigo descrevendo a operação do compilador Kotlin e cobrindo a estrutura básica da linguagem.
habr.com/en/post/425077
Duas partes do artigo "Kotlin, compilação em código de bytes e desempenho", complementando o artigo anterior.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
O relatório de Pasha Finkelstein "Kotlin - dois anos em produção e nem uma única lacuna", baseado na experiência de usar e implementar o Kotlin em nossa empresa.
www.youtube.com/watch?v=nCDWb7O1ZW4

All Articles