Passer à Kotlin dans un projet Android: trucs et astuces


Publié par Sergey Yeshin, Strong Middle Android Developer, DataArt

Plus d'un an et demi s'est écoulé depuis que Google a annoncé le support officiel de Kotlin dans Android, et les développeurs les plus aguerris ont commencé à l'expérimenter dans leur combat et pas dans les projets il y a plus de trois ans.

La nouvelle langue a été chaleureusement accueillie dans la communauté Android, et la grande majorité des nouveaux projets Android commencera avec Kotlin à bord. Il est également important que Kotlin compile dans un bytecode JVM, par conséquent, est entièrement compatible avec Java. Ainsi, dans les projets Android existants écrits en Java, il y a aussi une opportunité (d'ailleurs, un besoin) d'utiliser toutes les fonctionnalités de Kotlin, grâce auxquelles il a gagné tant de fans.

Dans l'article, je vais parler de l'expérience de la migration d'une application Android de Java vers Kotlin, des difficultés qui ont dû être surmontées dans le processus, et expliquer pourquoi tout cela n'a pas été vain. L'article est principalement destiné aux développeurs Android qui commencent tout juste à apprendre Kotlin et, en plus de son expérience personnelle, s'appuie sur des documents d'autres membres de la communauté.

Pourquoi Kotlin?


Décrivez brièvement les fonctionnalités de Kotlin, à cause desquelles je suis passé à lui dans le projet, laissant le monde Java "confortable et douloureusement familier":

  1. Compatibilité Java complète
  2. Sécurité nulle
  3. Inférence de type
  4. Méthodes d'extension
  5. Fonctionne comme objets de première classe et lambda
  6. Génériques
  7. Coroutines
  8. Aucune exception vérifiée

Application DISCO


Il s'agit d'une application de petite taille pour l'échange de cartes de réduction, composée de 10 écrans. En utilisant son exemple, nous considérerons la migration.

En bref sur l'architecture


L'application utilise l'architecture MVVM avec les composants d'architecture Google sous le capot: ViewModel, LiveData, Room.


De plus, selon les principes de Clean Architecture d'Oncle Bob, j'ai sélectionné 3 couches dans l'application: données, domaine et présentation.


Par où commencer? Ainsi, nous imaginons les principales fonctionnalités de Kotlin et avons une idée minimale du projet à migrer. La question naturelle est «par où commencer?».

La page officielle de la documentation de prise en main de Kotlin Android indique que si vous souhaitez porter une application existante sur Kotlin, il vous suffit de commencer à écrire des tests unitaires. Lorsque vous acquérez un peu d'expérience avec ce langage, écrivez un nouveau code dans Kotlin, il vous suffit de convertir le code Java existant.

Mais il y a un «mais». En effet, une simple conversion permet généralement (mais pas toujours) d'obtenir un code de travail sur Kotlin, cependant, son idiome laisse beaucoup à désirer. De plus, je vous dirai comment éliminer cet écart en raison des caractéristiques mentionnées (et pas seulement) du langage Kotlin.

Migration de couche


Étant donné que l'application est déjà en couches, il est logique de migrer par couches, en commençant par le haut.

La séquence des couches pendant la migration est illustrée dans l'image suivante:


Ce n'est pas un hasard si nous avons commencé la migration précisément depuis la couche supérieure. Nous nous épargnons ainsi l'utilisation du code Kotlin dans le code Java. Au contraire, nous faisons utiliser le code Kotlin de la couche supérieure aux classes Java de la couche inférieure. Le fait est que Kotlin a été initialement conçu en tenant compte de la nécessité d'interagir avec Java. Le code Java existant peut être appelé depuis Kotlin de manière naturelle. Nous pouvons facilement hériter des classes Java existantes, y accéder et appliquer des annotations Java aux classes et méthodes Kotlin. Le code Kotlin peut également être utilisé en Java sans trop de problèmes, mais cela prend souvent des efforts supplémentaires, comme l'ajout d'une annotation JVM. Mais pourquoi faire des conversions supplémentaires en code Java si à la fin il sera toujours réécrit dans Kotlin?

Pour un exemple, regardons la génération de surcharge.

Habituellement, si vous écrivez une fonction Kotlin avec des valeurs de paramètres par défaut, elle ne sera visible en Java qu'en tant que signature complète avec tous les paramètres. Si vous souhaitez fournir plusieurs surcharges aux appels Java, vous pouvez utiliser l'annotation @JvmOverloads:

class Foo @JvmOverloads constructor(x: Int, y: Double = 0.0) { @JvmOverloads fun f(a: String, b: Int = 0, c: String = "abc") { ... } } 

Pour chaque paramètre avec une valeur par défaut, cela créera une surcharge supplémentaire, qui a ce paramètre et tous les paramètres à sa droite, dans la liste des paramètres distants. Dans cet exemple, les éléments suivants seront créés:

 // Constructors: Foo(int x, double y) Foo(int x) // Methods void f(String a, int b, String c) { } void f(String a, int b) { } void f(String a) { } 

Il existe de nombreux exemples d'utilisation d'annotations JVM pour le bon fonctionnement de Kotlin. Cette page de documentation détaille l'appel à Kotlin depuis Java.

Nous décrivons maintenant le processus de migration couche par couche.

Couche de présentation


Il s'agit d'une couche d'interface utilisateur qui contient des écrans avec des vues et un ViewModel, à son tour, contenant des propriétés sous la forme de LiveData avec des données du modèle. Ensuite, nous examinons les astuces et les outils qui se sont révélés utiles lors de la migration de cette couche d'application.

1. Processeur d'annotation Kapt


Comme avec n'importe quel MVVM, View est lié aux propriétés ViewModel via la liaison de données. Dans le cas d'Android, nous avons affaire à la bibliothèque Android Databind, qui utilise un traitement d'annotation. Ainsi, Kotlin a son propre processeur d'annotation , et si vous n'apportez pas de modifications au fichier build.gradle correspondant, le projet s'arrêtera de se construire. Par conséquent, nous apporterons ces modifications:

 apply plugin: 'kotlin-kapt' android { dataBinding { enabled = true } } dependencies { api fileTree(dir: 'libs', include: ['*.jar']) ///… kapt "com.android.databinding:compiler:$android_plugin_version" } 

Il est important de se rappeler que vous devez remplacer complètement toutes les occurrences de la configuration annotationProcessor dans votre build.gradle par kapt.

Par exemple, si vous utilisez les bibliothèques Dagger ou Room dans le projet, qui utilisent également le processeur d'annotation sous le capot pour la génération de code, vous devez spécifier kapt comme processeur d'annotation.

2. Fonctions en ligne


Lorsque nous marquons une fonction comme étant en ligne, nous demandons au compilateur de la placer sur le lieu d'utilisation. Le corps de la fonction s'encastre, en d'autres termes, il se substitue à l'usage habituel de la fonction. Grâce à cela, nous pouvons contourner la restriction de l'effacement de type, c'est-à-dire l'effacement d'un type. Lorsque vous utilisez des fonctions en ligne, nous pouvons obtenir le type (classe) lors de l'exécution.

Cette fonctionnalité de Kotlin a été utilisée dans mon code pour «extraire» la classe de l'activité lancée.

 inline fun <reified T : Activity> Context?.startActivity(args: Bundle) { this?.let { val intent = Intent(this, T::class.java) intent.putExtras(args) it.startActivity(intent) } } 

réifié - désignation d'un type réifié.

Dans l'exemple décrit ci-dessus, nous avons également abordé une telle fonctionnalité du langage Kotlin que les extensions.

3. Extensions


Ce sont des extensions. Les méthodes utilitaires ont été supprimées dans les extensions, ce qui a permis d'éviter les utilitaires de classe gonflés et monstrueux.

Je vais donner un exemple des extensions impliquées dans l'application:

 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) } } 

Les développeurs de Kotlin ont pensé à l'avance aux extensions Android utiles en proposant leur plugin Kotlin Android Extensions. Parmi les fonctionnalités qu'il propose, citons la reliure View et le support Parcelable. Des informations détaillées sur les fonctionnalités de ce plugin peuvent être trouvées ici .

4. Fonctions Lambda et fonctions d'ordre supérieur


En utilisant les fonctions lambda dans le code Android, vous pouvez vous débarrasser du ClickListener et du rappel maladroits, qui en Java ont été implémentés via des interfaces auto-écrites.

Un exemple d'utilisation d'un lambda au lieu de onClickListener:

 button.setOnClickListener({ doSomething() }) 

Les lambdas sont également utilisés dans les fonctions d'ordre supérieur, par exemple, pour les fonctions de collecte.

Prenons l'exemple de la carte :

 fun <T, R> List<T>.map(transform: (T) -> R): List<R> {...} 

Il y a un endroit dans mon code où j'ai besoin de "mapper" l'ID des cartes pour leur retrait ultérieur.

En utilisant l'expression lambda passée à la carte, j'obtiens le tableau d'ID souhaité:

  val ids = cards.map { it.id }.toIntArray() cardDao.deleteCardsByIds(ids) 

Veuillez noter que les parenthèses peuvent être omises du tout lors de l'appel de la fonction, si lambda est le seul argument et le mot-clé c'est le nom implicite du seul paramètre.

5. Types de plateformes


Vous devrez inévitablement travailler avec des SDK écrits en Java (y compris, en fait, le SDK Android). Cela signifie que vous devez toujours être sur vos gardes avec Kotlin et Java Interop tels que les types de plate-forme.

Un type de plate-forme est un type pour lequel Kotlin ne peut pas trouver d'informations de validité nulles. Le fait est que, par défaut, le code Java ne contient pas d'informations sur la validité de null et que les annotations NotNull et @ Nullable ne sont pas toujours utilisées. Lorsqu'il n'y a pas d'annotation correspondante en Java, le type devient plateforme. Vous pouvez l'utiliser avec un type qui autorise null et un type qui n'autorise pas null.


Cela signifie que, tout comme en Java, le développeur est entièrement responsable des opérations avec ce type. Le compilateur n'ajoute pas un runtime de vérification nulle et vous permettra de tout faire.

Dans l'exemple suivant, nous remplaçons onActivityResult dans notre activité:

 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent{ super.onActivityResult(requestCode, resultCode, data) val randomString = data.getStringExtra("some_string") } 

Dans ce cas, les données sont un type de plate-forme qui peut contenir null. Cependant, du point de vue du code Kotlin, les données ne peuvent en aucun cas être nul, et que vous spécifiez le type d'intention comme nullable, vous ne recevrez pas d'avertissement ou d'erreur du compilateur, car les deux versions de la signature sont valides . Mais comme la réception de données non vides n'est pas garantie, car dans les cas avec le SDK, vous ne pouvez pas contrôler cela, obtenir null dans ce cas conduira à NPE.

Aussi, à titre d'exemple, nous pouvons lister les endroits suivants pour l'émergence possible de types de plateformes:

  1. Service.onStartCommand (), où l'intention peut être nulle.
  2. BroadcastReceiver.onReceive ().
  3. Activity.onCreate (), Fragment.onViewCreate () et d'autres méthodes similaires.

De plus, il arrive que les paramètres de la méthode soient annotés, mais pour une raison quelconque, le studio perd la nullité lors de la génération d'un remplacement.

Couche de domaine


Cette couche comprend toute la logique métier; elle est responsable de l'interaction entre la couche de données et la couche de présentation. Le rôle clé ici est joué par le référentiel. Dans Repository, nous effectuons les manipulations de données nécessaires, à la fois côté serveur et local. A l'étage, vers la couche Présentation, nous ne donnons que la méthode d'interface Repository, qui masque la complexité des opérations de données.

Comme indiqué ci-dessus, RxJava a été utilisé pour la mise en œuvre.

1. RxJava


Kotlin est entièrement compatible avec RxJava et plus concis avec Java que Java. Cependant, même ici, j'ai dû faire face à un problème désagréable. Cela ressemble à ceci: si vous passez un lambda comme paramètre de la méthode andThen , ce lambda ne fonctionnera pas!

Pour le vérifier, écrivez simplement un test simple:

 Completable .fromCallable { cardRepository.uploadDataToServer() } .andThen { cardRepository.markLocalDataAsSynced() } .subscribe() 

Et puis le contenu échouera. C'est le cas avec la plupart des opérateurs (comme flatMap , defer , fromAction et bien d'autres), vraiment lambda est attendu comme argument. Et avec un tel enregistrement avec andThen , Completable / Observable / SingleSource est attendu . Le problème est résolu en utilisant des parenthèses ordinaires () au lieu de bouclés {}.

Ce problème est décrit en détail dans l'article «Kotlin et Rx2. Comment j'ai perdu 5 heures à cause de mauvais supports . "

2. Restructuration


Nous abordons également des syntaxes Kotlin intéressantes telles que la déstructuration ou l' affectation de la déstructuration . Il vous permet d'affecter un objet à plusieurs variables à la fois, en le divisant en parties.

Imaginez que nous avons une méthode dans l'API qui renvoie plusieurs entités à la fois:

 @GET("/foo/api/sync") fun getBrandsAndCards(): Single<BrandAndCardResponse> data class BrandAndCardResponse(@SerializedName("cards") val cards: List<Card>?, @SerializedName("brands") val brands: List<Brand>?) 

Une méthode compacte pour renvoyer le résultat de cette méthode est la déstructuration, comme le montre l'exemple suivant:

 syncRepository.getBrandsAndCards() .flatMapCompletable {it-> Completable.fromAction{ val (cards, brands) = it syncCards(cards) syncBrands(brands) } } } 

Il convient de mentionner que les multi-déclarations sont basées sur la convention: les classes qui sont censées être détruites doivent contenir des fonctions componentN (), où N est le numéro de composant correspondant - un membre de la classe. Autrement dit, l'exemple ci-dessus se traduit par le code suivant:

 val cards = it.component1() val brands = it.component2() 

Notre exemple utilise une classe de données qui déclare automatiquement les fonctions componentN (). Par conséquent, les multi-déclarations fonctionnent avec lui hors de la boîte.

Nous parlerons davantage de la classe de données dans la partie suivante, consacrée à la couche Data.

Couche de données


Cette couche comprend POJO pour les données du serveur et de la base, des interfaces pour travailler avec les données locales et les données reçues du serveur.

Pour travailler avec des données locales, nous avons utilisé Room, qui nous fournit un wrapper pratique pour travailler avec la base de données SQLite.

Le premier objectif de la migration, qui se suggère, est les POJO, qui dans le code Java standard sont des classes en vrac avec de nombreux champs et leurs méthodes get / set correspondantes. Vous pouvez rendre POJO plus concis à l'aide des classes de données. Une ligne de code sera suffisante pour décrire une entité à plusieurs champs:

 data class Card(val id:String, val cardNumber:String, val brandId:String,val barCode:String) 

En plus de la concision, nous obtenons:

  • Substitue les méthodes equals () , hashCode () et toString () sous le capot. La génération d'égaux pour toutes les propriétés de la classe de données est extrêmement pratique lorsque vous utilisez DiffUtil dans un adaptateur qui génère des vues pour RecyclerView. Le fait est que DiffUtil compare deux ensembles de données, deux listes: l'ancien et le nouveau, découvre les changements qui se sont produits et l'utilisation de méthodes de notification met à jour de manière optimale l'adaptateur. Et généralement, les éléments de liste sont comparés en utilisant des égaux.

    Ainsi, après avoir ajouté un nouveau champ à la classe, nous n'avons pas besoin de l'ajouter à égal pour que DiffUtil prenne en compte le nouveau champ.
  • Classe immuable
  • Prise en charge des valeurs par défaut, qui peuvent être remplacées par l'utilisation du modèle Builder.

    Un exemple:

     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") 

Autre bonne nouvelle: avec kapt configuré (comme décrit ci-dessus), les classes de données fonctionnent correctement avec les annotations de salle, ce qui vous permet de traduire toutes les entités de base de données en classes de données. Room prend également en charge les propriétés nullables. Certes, Room ne prend pas encore en charge les valeurs par défaut de Kotlin, mais le bogue correspondant a déjà été institué pour cela.

Conclusions


Nous n'avons examiné que quelques pièges pouvant survenir lors de la migration de Java vers Kotlin. Il est important que, bien que des problèmes surviennent, en particulier en raison d'un manque de connaissances théoriques ou d'expérience pratique, ils soient tous résolubles.

Cependant, le plaisir d'écrire un code expressif concis et sûr dans Kotlin fera plus que payer toutes les difficultés qui se posent sur le chemin de transition. Je peux dire avec confiance que l'exemple du projet DISCO le confirme certainement.

Livres, liens utiles, ressources


  1. Le fondement théorique de la connaissance de la langue permettra de poser le livre Kotlin in Action des créateurs de la langue Svetlana Isakova et Dmitry Zhemerov.

    Laconicisme, informativité, large couverture des sujets, concentration sur les développeurs Java et la disponibilité d'une version russe en font le meilleur des manuels possibles au début de l'apprentissage des langues. J'ai commencé avec elle.
  2. Sources Kotlin avec developer.android.
  3. Guide Kotlin en russe
  4. Un excellent article de Konstantin Mikhailovsky, un développeur Android de Genesis, sur l'expérience du passage à Kotlin.

Source: https://habr.com/ru/post/fr433074/


All Articles