Publicado por Sergey Yeshin, Desenvolvedor Strong Middle Android, DataArtMais de um ano e meio se passaram desde que o Google anunciou o suporte oficial ao Kotlin no Android, e os desenvolvedores mais experientes começaram a experimentá-lo em seus combates e não em projetos há mais de três anos.
O novo idioma foi recebido calorosamente na comunidade Android, e a grande maioria dos novos projetos Android começará com o Kotlin a bordo. Também é importante que o Kotlin seja compilado em um bytecode da JVM, portanto, é totalmente compatível com Java. Assim, nos projetos Android existentes escritos em Java, também há uma oportunidade (além disso, uma necessidade) de usar todos os recursos do Kotlin, graças aos quais ele conquistou tantos fãs.
No artigo, falarei sobre a experiência de migrar um aplicativo Android de Java para Kotlin, as dificuldades que precisaram ser superadas no processo e explicarei por que tudo isso não foi em vão. O artigo é voltado principalmente para desenvolvedores do Android que estão começando a aprender o Kotlin e, além da experiência pessoal, conta com materiais de outros membros da comunidade.
Por que Kotlin?
Descreva brevemente os recursos do Kotlin, pelos quais mudei para ele no projeto, deixando o mundo Java "confortável e dolorosamente familiar":
- Compatibilidade total com Java
- Segurança nula
- Inferência de tipo
- Métodos de extensão
- Funções como objetos de primeira classe e lambda
- Genéricos
- Coroutines
- Nenhuma exceção verificada
Aplicativo DISCO
Esta é uma aplicação de tamanho pequeno para a troca de cartões de desconto, composta por 10 telas. Usando seu exemplo, consideraremos a migração.
Brevemente sobre arquitetura
O aplicativo usa a arquitetura MVVM com o Google Architecture Components sob o capô: ViewModel, LiveData, Room.
Além disso, de acordo com os princípios de Arquitetura Limpa do tio Bob, selecionei 3 camadas no aplicativo: dados, domínio e apresentação.
Por onde começar? Então, imaginamos os principais recursos do Kotlin e temos uma idéia mínima do projeto que precisa ser migrado. A questão natural é "por onde começar?".
A página de documentação oficial
Introdução ao Kotlin Android diz que, se você deseja portar um aplicativo existente para o Kotlin, basta começar a escrever testes de unidade. Quando você ganha um pouco de experiência com essa linguagem, escreve um novo código no Kotlin, basta converter o código Java existente.
Mas há um "mas". De fato, uma simples conversão geralmente (embora nem sempre) permite que você obtenha um código funcional no Kotlin; no entanto, seu idioma deixa muito a desejar. Além disso, mostrarei como eliminar essa lacuna devido aos recursos mencionados (e não apenas) da linguagem Kotlin.
Migração de Camadas
Como o aplicativo já está em camadas, faz sentido migrar por camadas, começando pela parte superior.
A sequência de camadas durante a migração é mostrada na figura a seguir:
Não é por acaso que começamos a migração precisamente da camada superior. Assim, evitamos usar o código Kotlin no código Java. Pelo contrário, fazemos com que o código Kotlin da camada superior use as classes Java da camada inferior. O fato é que o Kotlin foi originalmente projetado levando em consideração a necessidade de interagir com o Java. O código Java existente pode ser chamado a partir do Kotlin de maneira natural. Podemos herdar facilmente das classes Java existentes, acessá-las e aplicar anotações Java às classes e métodos Kotlin. O código Kotlin também pode ser usado em Java sem muitos problemas, mas geralmente é necessário um esforço extra, como adicionar uma anotação da JVM. E por que conversões desnecessárias no código Java, se no final ainda serão reescritas no Kotlin?
Por exemplo, vejamos a geração de sobrecarga.
Normalmente, se você escrever uma função Kotlin com valores de parâmetro padrão, ela será visível apenas em Java como uma assinatura completa com todos os parâmetros. Se você desejar fornecer várias sobrecargas para chamadas Java, poderá usar a anotação @JvmOverloads:
class Foo @JvmOverloads constructor(x: Int, y: Double = 0.0) { @JvmOverloads fun f(a: String, b: Int = 0, c: String = "abc") { ... } }
Para cada parâmetro com um valor padrão, isso criará uma sobrecarga adicional, que possui esse parâmetro e todos os parâmetros à direita, na lista de parâmetros remotos. Neste exemplo, o seguinte será criado:
Existem muitos exemplos de uso de anotações da JVM para a operação correta do Kotlin.
Esta página de documentação detalha a chamada para Kotlin a partir de Java.
Agora, descrevemos o processo de migração camada por camada.
Camada de apresentação
Essa é uma camada da interface do usuário que contém telas com visualizações e um ViewModel, por sua vez, contendo propriedades na forma de LiveData com dados do modelo. A seguir, examinamos os truques e ferramentas que se mostraram úteis ao migrar essa camada de aplicativo.
1. Processador de anotação Kapt
Como em qualquer MVVM, o View é vinculado às propriedades do ViewModel por meio da ligação de dados. No caso do Android, estamos lidando com a Biblioteca de dados do Android, que usa o processamento de anotações. Portanto, o Kotlin possui
seu próprio processador de anotação e, se você não fizer alterações no arquivo build.gradle correspondente, o projeto será interrompido. Portanto, faremos essas alterações:
apply plugin: 'kotlin-kapt' android { dataBinding { enabled = true } } dependencies { api fileTree(dir: 'libs', include: ['*.jar'])
É importante lembrar que você deve substituir completamente todas as ocorrências da configuração annotationProcessor em seu build.gradle pelo kapt.
Por exemplo, se você usar as bibliotecas Dagger ou Room no projeto, que também usam o processador de anotações sob o capô para geração de código, deverá especificar kapt como o processador de anotação.
2. Funções embutidas
Ao marcar uma função como embutida, solicitamos ao compilador que a coloque no local de uso. O corpo da função se torna incorporado, ou seja, é substituído pelo uso usual da função. Graças a isso, podemos contornar a restrição de apagamento de tipo, ou seja, apagar um tipo. Ao usar funções embutidas, podemos obter o tipo (classe) em tempo de execução.
Esse recurso do Kotlin foi usado no meu código para "extrair" a classe da atividade iniciada.
inline fun <reified T : Activity> Context?.startActivity(args: Bundle) { this?.let { val intent = Intent(this, T::class.java) intent.putExtras(args) it.startActivity(intent) } }
reified - designação de um tipo reified.
No exemplo descrito acima, também abordamos um recurso da linguagem Kotlin como Extensões.
3. Extensões
Eles são extensões. Os métodos utilitários foram utilizados em extensões, o que ajudou a evitar os utilitários de classe inchados e monstruosos.
Vou dar um exemplo das extensões envolvidas no aplicativo:
fun Context.inflate(res: Int, parent: ViewGroup? = null): View { return LayoutInflater.from(this).inflate(res, parent, false) } fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean { return this != null && isNotEmpty(); } fun Fragment.hideKeyboard() { view?.let { hideKeyboard(activity, it.windowToken) } }
Os desenvolvedores do Kotlin pensaram em extensões úteis do Android com antecedência, oferecendo seu plug-in Kotlin Android Extensions. Entre os recursos que ele oferece estão o suporte à ligação e o pacote de exibição. Informações detalhadas sobre os recursos deste plugin podem ser encontradas
aqui .
4. Funções Lambda e funções de ordem superior
Usando as funções lambda no código do Android, você pode se livrar do desajeitado ClickListener e do retorno de chamada, que em Java foram implementados por meio de interfaces auto-escritas.
Um exemplo de uso de uma lambda em vez de onClickListener:
button.setOnClickListener({ doSomething() })
Os lambdas também são usados em funções de ordem superior, por exemplo, para funções de coleção.
Tome o
mapa como um exemplo:
fun <T, R> List<T>.map(transform: (T) -> R): List<R> {...}
Há um lugar no meu código em que preciso "mapear" o ID dos cartões para sua remoção subseqüente.
Usando a expressão lambda passada para mapear, obtenho a matriz de ID desejada:
val ids = cards.map { it.id }.toIntArray() cardDao.deleteCardsByIds(ids)
Observe que os parênteses podem ser omitidos ao chamar a função, se lambda é o único argumento e a palavra-chave é o nome implícito do único parâmetro.
5. Tipos de plataformas
Você inevitavelmente precisará trabalhar com os SDKs escritos em Java (incluindo, de fato, o Android SDK). Isso significa que você deve sempre estar atento ao Kotlin e ao Java Interop, como os tipos de plataforma.
Um tipo de plataforma é um tipo para o qual o Kotlin não pode encontrar informações de validade nulas. O fato é que, por padrão, o código Java não contém informações sobre a validade de null, e as
anotações NotNull e @ Nullable nem sempre são usadas. Quando não há anotação correspondente em Java, o tipo se torna plataforma. Você pode trabalhar com ele como um tipo que permite nulo e como um tipo que não permite nulo.
Isso significa que, assim como em Java, o desenvolvedor é totalmente responsável por operações com esse tipo. O compilador não adiciona um tempo de execução de verificação nula e permitirá que você faça tudo.
No exemplo a seguir, substituímos onActivityResult em nossa Activity:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent{ super.onActivityResult(requestCode, resultCode, data) val randomString = data.getStringExtra("some_string") }
Nesse caso, dados são um tipo de plataforma que pode conter nulo. No entanto, do ponto de vista do código Kotlin, os dados não podem ser nulos em nenhuma circunstância e, independentemente de você especificar o tipo de Intenção como nulo, você não receberá um aviso ou erro do compilador, pois as duas versões da assinatura são válidas. . Mas como não é garantido o recebimento de dados não vazios, porque nos casos com o SDK você não pode controlar isso, ficar nulo nesse caso levará ao NPE.
Além disso, como exemplo, podemos listar os seguintes locais para o possível surgimento de tipos de plataforma:
- Service.onStartCommand (), onde o Intent pode ser nulo.
- BroadcastReceiver.onReceive ().
- Activity.onCreate (), Fragment.onViewCreate () e outros métodos semelhantes.
Além disso, acontece que os parâmetros do método são anotados, mas por algum motivo o estúdio perde a anulação ao gerar uma substituição.
Camada de domínio
Essa camada inclui toda a lógica de negócios e é responsável pela interação entre a camada de dados e a camada de apresentação. O papel principal aqui é desempenhado pelo Repositório. No Repositório, realizamos as manipulações de dados necessárias, tanto do lado do servidor quanto do local. No andar de cima, para a camada Apresentação, fornecemos apenas o método da interface Repository, que oculta a complexidade das operações de dados.
Como mencionado acima, o RxJava foi usado para implementação.
1. RxJava
O Kotlin é totalmente compatível com o RxJava e mais conciso em conjunto com ele do que o Java. No entanto, mesmo aqui eu tive que enfrentar um problema desagradável. Parece assim: se você passar um lambda como parâmetro do método
andThen , esse lambda não funcionará!
Para verificar isso, basta escrever um teste simples:
Completable .fromCallable { cardRepository.uploadDataToServer() } .andThen { cardRepository.markLocalDataAsSynced() } .subscribe()
AndThen conteúdo falhará. Este é o caso da maioria dos operadores (como
flatMap ,
adiar ,
fromAction e muitos outros); realmente, lambda é esperado como argumento. E com esse registro com
andThen ,
é esperado que Completable / Observable / SingleSource . O problema é resolvido usando parênteses comuns () em vez de encaracolado {}.
Esse problema é descrito em detalhes no artigo
“Kotlin e Rx2. Como perdi 5 horas por causa de colchetes errados .
"2. Reestruturação
Também tocamos em uma sintaxe Kotlin interessante, como
designação de desestruturação ou
desestruturação . Permite atribuir um objeto a várias variáveis ao mesmo tempo, dividindo-o em partes.
Imagine que temos um método na API que retorna várias entidades ao mesmo tempo:
@GET("/foo/api/sync") fun getBrandsAndCards(): Single<BrandAndCardResponse> data class BrandAndCardResponse(@SerializedName("cards") val cards: List<Card>?, @SerializedName("brands") val brands: List<Brand>?)
Uma maneira compacta de retornar o resultado desse método é a desestruturação, conforme mostrado no exemplo a seguir:
syncRepository.getBrandsAndCards() .flatMapCompletable {it-> Completable.fromAction{ val (cards, brands) = it syncCards(cards) syncBrands(brands) } } }
Vale ressaltar que as multi-declarações são baseadas na convenção: as classes que deveriam ser destruídas devem conter funções componentN (), onde N é o número do componente correspondente - um membro da classe. Ou seja, o exemplo acima se traduz no seguinte código:
val cards = it.component1() val brands = it.component2()
Nosso exemplo usa uma classe de dados que declara automaticamente as funções componentN (). Portanto, as multi-declarações trabalham com ele imediatamente.
Falaremos mais sobre a classe de dados na próxima parte, dedicada à camada de dados.
Camada de dados
Essa camada inclui POJO para dados do servidor e da base, interfaces para trabalhar com dados locais e dados recebidos do servidor.
Para trabalhar com dados locais, foi utilizado o Room, que fornece um invólucro conveniente para trabalhar com o banco de dados SQLite.
O primeiro objetivo da migração, que se sugere, são os POJOs, que no código Java padrão são classes em massa com muitos campos e seus métodos get / set correspondentes. Você pode tornar o POJO mais conciso com a ajuda das classes Data. Uma linha de código será suficiente para descrever uma entidade com vários campos:
data class Card(val id:String, val cardNumber:String, val brandId:String,val barCode:String)
Além da concisão, obtemos:
- Substitui os métodos equals () , hashCode () e toString () sob o capô. A geração de iguais para todas as propriedades da classe de dados é extremamente conveniente ao usar o DiffUtil em um adaptador que gera visualizações para o RecyclerView. O fato é que o DiffUtil compara dois conjuntos de dados, duas listas: o antigo e o novo, descobre quais alterações ocorreram e o uso de métodos de notificação atualiza o adaptador de maneira otimizada. E normalmente, os itens da lista são comparados usando iguais.
Assim, após adicionar um novo campo à classe, não precisamos adicioná-lo a igual para que o DiffUtil leve em consideração o novo campo. - Classe imutável
- Suporte para valores padrão, que podem ser substituídos pelo uso do padrão Builder.
Um exemplo:
data class Card(val id : Long = 0L, val cardNumber: String="99", val barcode: String = "", var brandId: String="1") val newCard = Card(id =1L,cardNumber = "123")
Outra boa notícia: com o kapt configurado (como descrito acima), as classes Data funcionam bem com as anotações de Salas, o que permite traduzir todas as entidades do banco de dados em classes Data. O quarto também suporta propriedades anuláveis. É verdade que o Room ainda não suporta os valores padrão do Kotlin, mas o bug correspondente já foi instituído para isso.
Conclusões
Examinamos apenas algumas armadilhas que podem surgir durante a migração do Java para o Kotlin. É importante que, embora surjam problemas, especialmente com falta de conhecimento teórico ou experiência prática, todos eles sejam solucionáveis.
No entanto, o prazer de escrever um código expressivo e seguro conciso no Kotlin mais do que compensará todas as dificuldades que surjam no caminho da transição. Posso dizer com confiança que o exemplo do projeto DISCO certamente confirma isso.
Livros, links úteis, recursos
- O fundamento teórico do conhecimento da língua permitirá a colocação do livro Kotlin in Action dos criadores da linguagem Svetlana Isakova e Dmitry Zhemerov.
Laconicismo, informatividade, ampla cobertura de tópicos, foco em desenvolvedores de Java e a disponibilidade de uma versão em russo o tornam o melhor dos manuais possíveis no início do aprendizado de idiomas. Eu comecei com ela. - Fontes Kotlin com developer.android.
- Guia Kotlin em russo
- Um excelente artigo de Konstantin Mikhailovsky, desenvolvedor Android da Genesis, sobre a experiência de mudar para o Kotlin.