Switching to Kotlin in the Android project: Tips and Tricks
- Tutorial
By Sergey Eshin, Strong Middle Android Developer, DataArt
More than a year and a half has passed since Google announced official support for Kotlin on Android, while the most experienced developers began experimenting with it in their combat and not-so-many projects more than three years ago.
The new language was warmly received in the Android community, and the overwhelming part of new Android projects starts with Kotlin on board. It is also important that Kotlin is compiled into JVM bytecode, therefore, it is fully compatible with Java. So, in existing Android projects written in Java, there is also the possibility (moreover, the need) to use all the features of Kotlin, thanks to which he gained so many fans.
In the article I will talk about the experience of migrating an Android application from Java to Kotlin, the difficulties that had to be overcome in the process, and explain why all this was not in vain. The article is mostly designed for Android developers who are just starting to learn Kotlin, and besides personal experience, relies on materials from other members of the community.
Why Kotlin?
I will briefly describe the features of Kotlin, because of which I switched to it in the project, having left the “cozy and painfully familiar” world of Java:
- Full Java Compatibility
- Null safety
- Type inference
- Extension methods
- Functions as first class and lambda objects
- Generics
- Coroutines
- Absence checked exception
DISCO application
This is a small application for the exchange of discount cards, consisting of 10 screens. On his example, we consider the migration.
Architecture in brief
The application uses the MVVM architecture with the Google Architecture Components under the hood: ViewModel, LiveData, Room.
Also, according to the principles of Clean Architecture from Uncle Bob, I selected 3 layers in the application: data, domain and presentation.
Where to begin? So, we imagine the main features of Kotlin and have a minimal idea of the project that needs to be migrated. There is a natural question "where to start?".
On the Android Getting Started with Kotlin page , it’s written that if you want to transfer an existing application to Kotlin, you just have to start writing unit tests. When you get a little experience with this language, write new code to Kotlin, you will just have to convert existing Java code.
But there is one "but." Indeed, simple conversion usually (although not always) allows you to get a working code on Kotlin, but its idiomaticity leaves much to be desired. Then I will tell you how to eliminate this gap due to the mentioned (and not only) features of the Kotlin language.
Layer Migration
Since the application is already split into layers, it makes sense to perform the migration by layers, starting with the top one.
The order of the layers during the migration is shown in the following picture:
It is no coincidence that we started the migration from the upper layer. We thereby save ourselves from using Kotlin-code in Java-code. On the contrary, we make the upper layer Kotlin code use the lower layer Java classes. The fact is that Kotlin was originally designed taking into account the need to interact with Java. Existing Java code can be invoked from Kotlin in a natural way. We can easily inherit from existing Java classes, access them and apply Java annotations to Kotlin classes and methods. Kotlin code can also be used in Java without any special problems, but additional efforts are often required, for example, adding JVM annotations. And why do extra conversions in Java-code, if in the end it will still be rewritten to Kotlin?
For example, let's look at the generation of overloads.
Usually, if you write a Kotlin function with default parameter values, it will be visible in Java only as a complete signature with all parameters. If you want to provide multiple overloads to Java calls, you can use the @JvmOverloads annotation:
classFoo @JvmOverloadsconstructor(x: Int, y: Double= 0.0) {
@JvmOverloadsfun f(a: String, b: Int = 0, c: String = "abc"){ ... }
}
For each parameter with a default value, this will create one additional overload, which has this parameter and all parameters to its right in the remote parameter list. In this example, the following will be created:
// Constructors:
Foo(int x, double y)
Foo(int x)
// Methodsvoidf(String a, int b, String c){ }
voidf(String a, int b){ }
voidf(String a){ }
There are many examples of using JVM annotations for Kotlin to work correctly. On this page of documentation in detail disclosed Kotlin call theme from Java.
Now we describe the process of migration layer by layer.
Presentation Layer
This is a user interface layer that contains screens with views and ViewModel, in turn, containing properties in the form of LiveData with data from the model. Next, we look at the techniques and tools that have proven useful in migrating this layer of the application.
1. Kapt annotation processor
As with any MVVM, View binds to the ViewModel properties through databinding. In the case of Android, we are dealing with Android Databind Library, which uses annotation processing. So, Kotlin has its own annotation processor , and if you do not make changes to the corresponding build.gradle file, the project will stop building. Therefore, we will make these changes:
apply plugin: 'kotlin-kapt'
android {
dataBinding {
enabled = true
}
}
dependencies {
api fileTree(dir: 'libs', include: ['*.jar'])///…
kapt "com.android.databinding:compiler:$android_plugin_version"
}
It is important to remember that you need to completely replace all occurrences of the annotationProcessor configuration in your build.gradle with kapt.
For example, if you are using Dagger or Room libraries in a project, which also use the annotation processor for code generation under the hood, you need to specify kapt as the annotation processor.
2. Inline functions
Marking a function as inline, we ask the compiler to place it at the place of use. The body of the function becomes embedded, in other words, it replaces the usual use of the function. Thanks to this, we can bypass the type erasure constraint, i.e., erase type. When using inline-functions, we can get the type (class) in runtime.
This feature of Kotlin was used in my code to "extract" the class of the launched Activity.
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 - the designation of the materialized type.
In the example described above, we also touched upon such a feature of the Kotlin language as Extensions.
3. Extensions
They are extensions. Utility methods were imposed in extensions, which made it possible to avoid bloated and monstrous class utilities.
I will give an example of the extensions involved in the application:
fun Context.inflate(res: Int, parent: ViewGroup? = null): View {
returnLayoutInflater.from(this).inflate(res, parent, false)
}
fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean {
returnthis != null && isNotEmpty();
}
fun Fragment.hideKeyboard() {
view?.let { hideKeyboard(activity, it.windowToken) }
}
Kotlin developers have thought of useful extensions for Android in advance by offering their Kotlin Android Extensions plugin. Among the features that it offers, you can highlight View binding and support for Parcelable. Detailed information about the capabilities of this plugin can be found here .
4. Lambda functions and higher order functions
With the help of lambda-functions in Android-code, you can get rid of clumsy ClickListener and callback, which were implemented in Java through self-written interfaces.
An example of using lambda instead of onClickListener:
button.setOnClickListener({ doSomething() })
Lambda is also used in higher-order functions, for example, for functions for working with collections.
Take for example the map :
fun<T, R> List<T>.map(transform: (T) -> R): List<R> {...}
In my code there is a place where you need to “wrap” id cards for their subsequent removal.
Using the lambda expression passed to the map, I get the required id array:
val ids = cards.map { it.id }.toIntArray()
cardDao.deleteCardsByIds(ids)
Note that parentheses can be omitted at all when calling a function, if lambda is the only argument, and the keyword it is the implicit name of the only parameter.
5. Platform Types
You will inevitably have to work with SDKs written in Java (including, in fact, the Android SDK). So, you should always stay alert with such a feature of Kotlin and Java Interop as platform types.
Platform type is a type for which Kotlin cannot find null validity information. The fact is that by default the Java code does not contain information about the validity of null, and the NotNull and @ Nullable annotations are not always used. When the corresponding annotation is missing in Java, the type becomes platform. You can work with it as with a type that allows null, and as with a type that does not allow null.
This means that, just like in Java, the developer is solely responsible for operations with this type. The compiler does not add a runtime check to null and will allow you to do everything.
In the following example, we override onActivityResult in our Activity:
overridefunonActivityResult(requestCode: Int, resultCode: Int, data: Intent{
super.onActivityResult(requestCode, resultCode, data)val randomString = data.getStringExtra("some_string")
}
In this case, data is a platform type that can contain null. However, from the point of view of the Kotlin code, data cannot be null under any circumstances, and regardless of whether you specify the type Intent as nullable, you will receive neither a warning nor an error from the compiler, since both signature variants are valid . But since the receipt of non-empty data is not guaranteed, since in cases with the SDK you cannot control this, obtaining null in this case will result in NPE.
Also, as an example, you can list the following places of possible appearance of platform types:
- Service.onStartCommand (), where the Intent can be null.
- BroadcastReceiver.onReceive ().
- Activity.onCreate (), Fragment.onViewCreate () and other similar methods.
Moreover, it happens that the parameters of the method are annotated, but for some reason the studio loses its Nullability when generating override.
Domain layer
This layer includes all business logic; it is responsible for the interaction between the data layer and the presentation layer. The key role here is played by the Repository. In the Repository, we perform the necessary manipulations with data, both with server and local. To the top, in the Presentation layer, we give only the Repository interface method, which hides all the complexity of actions with data.
As mentioned above, RxJava was used for implementation.
1. RxJava
Kotlin is fully compatible with RxJava and more concise in conjunction with it than Java. However, here I had to face one unpleasant problem. It sounds like this: if you pass lambda as a parameter of the method andThen , this lambda will not be executed!
To verify this, it is enough to write a simple test:
Completable
.fromCallable { cardRepository.uploadDataToServer() }
.andThen { cardRepository.markLocalDataAsSynced() }
.subscribe()
Content andThen fails. This is the case with most operators (such as flatMap , defer , fromAction, and many others) as arguments that are really lambda expected. And when such fixation andThen expected Completable / Observable / SingleSource . The problem is solved using ordinary parentheses () instead of curly {}.
This problem is described in detail in the article “Kotlin and Rx2. How I was 5 hours because of wrong brackets . ”
2. Destructuring
Also let's touch on such interesting Kotlin syntax as destructurization or destructurizing assignment . It allows you to assign an object to several variables at once, breaking it apart.
Imagine that we have a method in the API that returns several entities at once:
@GET("/foo/api/sync")fungetBrandsAndCards(): Single<BrandAndCardResponse>
dataclassBrandAndCardResponse(@SerializedName("cards")val cards: List<Card>?,
@SerializedName("brands")val brands: List<Brand>?)
A compact way to return the result from this method is destructuring, as shown in the following example:
syncRepository.getBrandsAndCards()
.flatMapCompletable {it->
Completable.fromAction{
val (cards, brands) = it
syncCards(cards)
syncBrands(brands)
}
}
}
It is worth mentioning that multi-declarations are based on a convention: the classes that are supposed to be structured must contain the componentN () functions, where N is the corresponding number of the component being a member of the class. That is, the example above is translated into the following code:
val cards = it.component1()
val brands = it.component2()
In our example, a data class is used that automatically declares the componentN () function. Therefore, multi-declarations work with it out of the box.
In more detail about a data-class we will talk in the following part devoted to the Data layer.
Data layer
This layer includes the POJO for data from the server and the base, interfaces for working with local data and data received from the server.
To work with local data, Room was used, providing us with a convenient wrapper for working with SQLite database.
The first goal for migration, which suggests itself, is POJOs, which in standard Java code are three-dimensional classes with many fields and corresponding get / set methods. You can make POJOs more concise with the help of Data classes. One line of code will be enough to describe an entity with several fields:
dataclassCard(val id:String, val cardNumber:String,
val brandId:String,val barCode:String)
In addition to brevity, we get:
- Overridden methods equals () , hashCode () and toString () under the hood. Generating equals over all properties of the data class is extremely convenient when using DiffUtil in an adapter that generates views for RecyclerView. The fact is that DiffUtil compares two data sets, two lists: the old and the new, it finds out what changes have occurred, and with the help of notify-methods it optimally updates the adapter. And as a rule, the list items are compared using equals.
Thus, after adding a new field to a class, we don’t need to add it to equals in order that DiffUtil take into account a new field. - Immmtable class
- Support for defaults that can be replaced by using the Builder pattern.
Example:dataclassCard(val id : Long = 0L, val cardNumber: String="99", val barcode: String = "", var brandId: String="1") val newCard = Card(id =1L,cardNumber = "123")
Another good news: with a configured kapt (as described above), the Data classes work fine with Room annotations, which allows all database entities to be translated into Data classes. Also Room supports nullable properties. True, Room does not yet support the default values from Kotlin, but this is already the corresponding bug.
findings
We have considered only a few pitfalls that may arise during the migration process from Java to Kotlin. It is important that, although problems arise, especially with a lack of theoretical knowledge or practical experience, they are all solvable.
However, the pleasure of writing a short expressive and secure code on Kotlin will more than pay off all the difficulties that arise in the transition. I can say with confidence that the example of the DISCO project certainly confirms this.
Books, useful links, resources
- The theoretical foundation of knowledge of the language will lay the book Kotlin in Action from the creators of the language Svetlana Isakova and Dmitry Zhemerov.
Conciseness, informative, wide coverage of topics, focus on Java-developers and the availability of a version in Russian make it the best possible tool at the start of language learning. I started it from her. - Sources on Kotlin from developer.android.
- Kotlin Guide in Russian
- Excellent article by Konstantin Mikhailovsky, an Android developer from Genesis, about the experience of switching to Kotlin.