Front-End Web & Mobile

Focusing on Amplify’s Kotlin Experience

By now, you’ve surely heard the news: developers love Kotlin. In the 2020 StackOverflow Developer Survey, Kotlin was ranked as the fourth-most loved programming language, and the sixth-most desirable as a language to learn next. As Android developers, we’re fortunate to have Kotlin as a native platform language. Kotlin has been the preferred language for Android development since May 2019, when Google announced a deemphasis of Java at the Google I/O developer conference.

Amplify’s History with Kotlin

Amplify’s Android library was first announced publicly in December 2019, and became generally available in May 2020. We started building the library in Java before Google’s 2019 Kotlin announcement. Like many Android teams at that time, we were confronted with a decision: do we continue developing the project in Java, or should we adopt Kotlin now? After weighing pros and cons and considering constraints, we ultimately decided to keep building in Java. As is so often the case in Engineering, the decision came with tradeoffs.

It is commonly stated that Kotlin interoperates with Java. In an academic sense, that’s certainly true. Both languages compile to portable JVM byte code. But in practical application, there are a variety of interoperability issues to consider. As part of our decision to stick with Java, we set out to proactively address some of these interoperability challenges, incorporating best practices into our codebase. As one illustrative example: Amplify Android makes exhaustive use of nullability annotations. The annotations enable Kotlin consumers to see accurate nullability cues from Android Studio.

But interoperability is just that: a boundary of communication between two things. Meanwhile, we put out a Request For Comments (RFC) to solicit customer input on Kotlin support. Customers told us that they wanted better support for native Kotlin features.

Key Concept: Amplify’s Facade Architecture

Our decision to stick with Java required us to think ahead, beyond just interoperability. In order to support different programming models in the future, we needed to build some provisions into Amplify’s architecture. Instead of mandating that customers use Coroutines, RxJava, Futures, Promises, or any other specific paradigm, we decided to express our core APIs using a least common denominator: traditional callbacks. Callbacks are a central theme on the Android platform, and require no external dependencies. With a simplistic set of core APIs, we could then support fancier paradigms — today’s and tomorrow’s — by placing framework adapters in front of these “vanilla” callbacks.

Soon after Amplify entered General Availability, we built our first facade module, which added support for RxJava. Love RxJava? Great! Include the optional rxbindings library, and interact with Amplify through the RxAmplify facade class. Don’t like RxJava? No worries, then. You don’t have to use it.

The new Core Kotlin facade works very much the same way. Instead of using the Amplify facade class in the core library, you interact with Amplify by using the Amplify facade class in the core-kotlin library.

You can think of the library in three layers: optional facades, core library logic, and delegate plugins. To use Amplify, you select your preferred programming paradigm (callbacks, RxJava, Kotlin Coroutines), register some plugins, and start calling APIs. The tiered architecture is illustrated in the diagram below. The current facade classes are shown in green:

Improved API Ergonomics

The new Kotlin facade expresses most functionality through suspend functions. The use of suspending functions allows us to remove the callback parameters from the vast majority of APIs in the library. As an example, consider how you would call the signUp API provided by Amplify out-of-the-box:

Amplify.Auth.signUp("username", "password",
    { result -> Log.i("Demo", "Sign up succeeded! $result") },
    { failure -> Log.e("Demo", "Sign up failed.", failure) }

The Core Kotlin version includes a more familiar construct. At first glance, it even looks like regular, blocking code:

try {
    val result = Amplify.Auth.signUp("username", "password")
    Log.i("Demo", "Sign up succeeded! $result")
} catch (failure: AuthException) {
    Log.e("Demo", "Sign up failed.", failure)

Yes, the code above looks like ordinary, synchronous code. However, the signUp function is a suspending function. Suspending functions are the Kotlin language primitive that power Coroutines. If you are new to coroutines, I suggest watching an Introduction to Coroutines from Kotlin’s Project Lead, Roman Elizarov.

But, there’s something else going on above, too. The signUp function actually takes a third, invisible options argument. But we didn’t provide one! Many of Amplify’s new Kotlin APIs make use of default arguments to cut down on boilerplate. If you don’t have any interesting options to declare, Amplify selects some reasonable defaults for you.


Core Kotlin

Built for Android Architecture Components

Amplify’s Kotlin APIs also have a number of useful integration points with the Android Architecture Components. The Architecture Components are part of Android Jetpack and are recommended in Android’s Guide to App Architecture.

The Components provide a variety of lifecycle-aware scopes in which you can run coroutines. For example, to perform a sign-in bound to a ViewModel’s lifespan:

class MyViewModel: ViewModel() {
    init {
        viewModelScope.launch {
            Amplify.Auth.signIn("jameson", "safePass74")

Amplify also includes some APIs which output a stream of multiple values. In the Kotlin APIs, these are expressed as Asynchronous Flows:

    .collect { change -> Log.i("Demo", "Change observed: $change") }

The Architecture Components include an asLiveData() extension. It is used to convert a Flow into a LiveData:

val liveData = Amplify.DataStore.observe().asLiveData()
liveData.observe(this) { change -> print("Change observed: $change") }

Likewise, you can convert an existing LiveData into a Flow using asFlow():

    .collect { Amplify.DataStore.delete(User::class, id) }

Simplified Asynchrony

Lastly — perhaps most importantly — the Kotlin APIs can greatly simplify your asynchronous code.

Oftentimes, your code needs to wait for an operation to complete before it can safely begin executing another. Using Amplify’s traditional callback APIs, you can quickly end up in a situation known as “callback hell” where your code starts growing diagonally, instead of vertically. Consider the following code which saves objects in a many-to-many relationship. It saves a blog, info about the blog’s editor, and lastly, info about the blog-editor relationship. The code waits for each previous save to complete before beginning the next.,
        Log.i("MyAmplifyApp", "Post saved"),
                Log.i("MyAmplifyApp", "Editor saved")
                    { Log.i("MyAmplifyApp", "PostEditor saved") },
                    { Log.e("MyAmplifyApp", "PostEditor not saved", it) }
            { Log.e("MyAmplifyApp", "Editor not saved", it) }
    { Log.e("MyAmplifyApp", "Post not saved", it) }

Coroutines can vastly simplify this code. Since coroutines execute sequentially by default, we can write the same thing very compactly. The below code looks like regular synchronous code. However, the save call will suspend between each iteration, waiting for each individual operation to complete.

try {
    listOf(post, editor, postEditor)
        .forEach { }
} catch (failure: DataStoreException) {
    Log.e("MyAmplifyApp", Failed to save a model.", failure)

Wrapping Up

And that’s about it! I hope this article has whetted your appetite to get started with the new Kotlin facade. As a next step, check out our documentation. If you have additional ideas to improve the experience of using Amplify from Kotlin, please leave us a feature request on GitHub, or come chat with us on Discord.