4

Architecting mobile apps with Kotlin Multiplatform

 2 years ago
source link: https://www.rafagarcia.dev/development/architecting-mobile-apps-with-kotlin-multiplatform/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Architecting mobile apps with Kotlin Multiplatform

12 minute read

Note: This article focuses on Kotlin Multiplatform Mobile (KMM), which can be seen as a subset of Kotlin Multiplatform (KMP).

KMM is a hot topic in mobile development. Its main advantages can be summarised as:

  • Sharing code across platforms: meaning less code to maintain, fewer bugs (or at least consistent bugs across platforms! 😄) and, in general fewer efforts to ship new features.
  • UI remains managed by platform specialists: Android or iOS devs can do their magic interacting directly with their preferred UI toolkits, just the way they’re used to.

As KMM’s popularity grows and more teams adopt it, questions about how to architecture mobile apps around it arise. While adoption might sound like a big shift on how we normally build mobile apps, it actually turns out that the change can be relatively straightforward if we already have a decent architecture in place (🤞).

Quick refresher on mobile architecturePermalink

Mobile architecture/design is a vast topic that would be impossible to cover here, however, I’d like to layout at a very high level the main layers a mobile app would normally have before we start seeing code:

  • UI: views that are displayed in the screen, rendered using UIKit or SwiftUI in iOS and the Android UI toolkit or Jetpack Compose in Android.
  • Presentation: logic about what is about to be presented in the screen (i.e ViewModels or Presenters).
  • Domain: the logic/rules of our business, normally driven by the usage of UseCases.
  • Data: repositories to orchestrate fetching data from different data sources, like a database or a remote API.

Modern patterns focus on having a reactive uni-directional data flow through these layers: UI -> Presentation -> Domain -> Data -> Domain -> Presentation -> UI, where a layer only knows its lower neighbour (i.e Data is only known by Domain and not the other way around).

Let’s get to businessPermalink

To showcase how an app using KMM could look like I’ve built Stonks, a small open-source KMM library with a companion Android app consuming it. Stonks is a small app to track stocks: it has a search screen where the user can add favourite stocks and the main screen where you can see the stock’s current price (aka Quote) and its change in the last 24 hours.

stonks-faved-light.png | stonks-search-light.png

What layers could we reuse?Permalink

Looking at our architecture layers, we need to start from the lowest and ask ourselves:

Is this something that iOS and Android can reuse?

The first layer we should consider is Data (aka repositories). To know if it can be part of a shared KMM library, we need to understand its responsibilities:

  • Fetch data from a remote data source: we need to fetch our stocks from somewhere, right? Luckily for us, Ktor client is available on KMM, which allows us to do API calls to any given server.  ✅
  • Fetch data from a persistence data source: being online is great, but sometimes our phone won’t have internet connection and we want our app to still work. KMM to the rescue again thanks to the awesome library SQLDelight, which allows us to persist our data in a database (along with goodies such as typesafe APIs from SQL statements, migrations checks at compile time, etc). ✅

Sounds like we are good to put the data layer in our shared KMM library, let’s get to it 🎉.

Implementing our data sourcesPermalink

Before we get to work on our first repository, let’s make sure we get both remote and local data sources working.

The remote data source is quite straightforward, we can create a http client by doing this:

private const val BASE_URL = "https://myurl/api/v1"

internal class StonksHttpClient(
    private val client: HttpClient = defaultHttpClient(),
    private val baseUrl: String = BASE_URL,
) {

    internal suspend inline fun <reified T> execute(request: HttpRequest<T>): T {
        return client.request("$baseUrl${request.url}") {
            this.method = HttpMethod.Get
            this.body = request.body
        }
    }
}

private fun defaultHttpClient() = HttpClient {
    install(JsonFeature) {
        serializer = KotlinxSerializer(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
        })
    }
    install(Logging) {
        level = LogLevel.HEADERS
        logger = object : Logger {
            override fun log(message: String) {
                print("Api: message = $message")
            }
        }
    }
}

internal data class HttpRequest<T>(
    val url: String,
    val method: Method,
    val body: OutgoingContent = EmptyContent,
)

enum class Method { GET, POST, DELETE }

For the persistence data source, it gets a bit more complicated. We need to provide multiple platform-specific implementations of the SQLite Driver. We can do this by using the expect / actual mechanism of KMM.

In the folder commonMain (where common code for all platforms live), we can have the following interface:

expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

We now provide both android (androidMain) and iOS (iosMain) implementations:

actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(StonksDatabase.Schema, context, "android.db")
    }
}
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver {
        return NativeSqliteDriver(StonksDatabase.Schema, "ios.db")
    }
}

And voila, we’re now ready to create our database tables, which we’ll do shortly.

Building our first repositoryPermalink

Let’s focus on building the QuoteRepository, which will be used to fetch a stock quote given a symbol (i.e GOOGL for Google). From now on we’ll be only using commonMain folder since everything is going to be common for both platforms.

The first step is creating our repository’s interface, which will technically live in a package called domain since repositories interfaces are considered to be part of this layer.

interface QuoteRepository {
    suspend fun quote(symbol: String): Quote
}

data class Quote(
    val symbol: String,
    val current: Double,
    val high: Double,
    val low: Double,
    val open: Double,
    val previousClose: Double,
)

We’ve got our StonksHttpClient ready to make API calls, however, we haven’t created how a request looks like yet. Let’s do it.

Note: For Stonks, we’re using an open source stock provider called Finnhub to fetch stock quotes.

internal object QuoteApi {

    fun quoteRequest(symbol: String) = HttpRequest<ApiQuoteResponse>(
        method = GET,
        url = "quote?symbol=${symbol}"
    )
}

@Serializable
internal data class ApiQuoteResponse(
    @SerialName("c") val current: Double,
    @SerialName("h") val high: Double,
    @SerialName("l") val low: Double,
    @SerialName("o") val open: Double,
    @SerialName("pc") val previousClose: Double,
)

And similarly, for the persistence data source, we’ve got our DB setup but we haven’t created any tables. We can create our DbQuote table by creating a DbQuote.sq file.

CREATE TABLE DbQuote (
  symbol TEXT NOT NULL PRIMARY KEY,
  current REAL NOT NULL,
  high REAL NOT NULL,
  low REAL NOT NULL,
  open REAL NOT NULL,
  previousClose REAL NOT NULL
);

get:
SELECT *
FROM DbQuote
WHERE symbol = ?;

upsert:
INSERT OR REPLACE INTO DbQuote
VALUES (?, ?, ?, ?, ?, ?);

In here, we’ve specified how the table should look like and how we want to interact with it (get to fetch a quote, upsert to update/insert new ones). This file will generate the table and Kotlin APIs to interact with it. For better readability, we can wrap its usage under a QuotePersistence class like this:

internal class QuotePersistence(
    private val db: StonksDatabase,
) {

    fun upsert(symbol: String, apiResponse: ApiQuoteResponse) {
        db.dbQuoteQueries.upsert(
            symbol = symbol,
            current = apiResponse.current,
            high = apiResponse.high,
            low = apiResponse.low,
            open_ = apiResponse.open,
            previousClose = apiResponse.previousClose
        )
    }

    fun get(symbol: String): DbQuote {
        return db.dbQuoteQueries.get(symbol).executeAsOne()
    }
}

Aaaand we’re now in a great position to create the implementation of our QuoteRepository 💯

internal class QuoteRepositoryImpl(
    private val httpClient: StonksHttpClient,
    private val persistence: QuotePersistence,
) : QuoteRepository {

    override suspend fun quote(symbol: String): Quote {
        return try {
            fromApi(symbol)
        } catch (exception: Exception) {
            fromDb(symbol)
        }
    }

    private suspend fun fromApi(symbol: String): Quote {
        val apiResponse = httpClient.execute(QuoteApi.quoteRequest(symbol))
        persistence.upsert(symbol, apiResponse)
        return apiResponse.toModel(symbol)
    }

    private fun fromDb(symbol: String): Quote {
        return try {
            persistence.get(symbol).toModel()
        } catch (exception: Exception) {
            throw ErrorFetchingQuote(symbol, exception)
        }
    }
}

data class ErrorFetchingQuote(val symbol: String, override val cause: Throwable) : Throwable("Could not fetch favourites -  $cause")

internal fun ApiQuoteResponse.toModel(symbol: String) = Quote(
    symbol = symbol,
    current = current,
    high = high,
    low = low,
    open = open,
    previousClose = previousClose
)

internal fun DbQuote.toModel() = Quote(
    symbol = symbol,
    current = current,
    high = high,
    low = low,
    open = open_,
    previousClose = previousClose
)

As you can see, the repository specifies a policy in which it will always check first the remote data source and then fall back to persistence in case of error. Boom, we’re done 💥

Propagating DB changes with Kotlin FlowPermalink

One of my coding dreams as an Android Developer has always been having an architecture where table updates are immediately propagated to ‘connected’ views by reactive patterns. I was surprised at how easy this is using Kotlin Flow.

For example, we want Stonks users to be able to select favourites stocks. For simplicity, this information will only live in the device (persistence data source). The final implementation of this FavouritesRepository would look like this:

interface FavouritesRepository {

    suspend fun getAll(): Flow<List<String>>
    suspend fun save(symbol: String)
    suspend fun unsave(symbol: String)
}

internal class FavouritesRepositoryImpl(
    private val persistence: FavouritesPersistence
) : FavouritesRepository {

    override suspend fun getAll(): Flow<List<String>> {
        return try {
            persistence.getAll()
        } catch (exception: Exception) {
            throw ErrorFetchingFavourites(exception)
        }
    }

    override suspend fun save(symbol: String) {
        try {
            persistence.save(symbol)
        } catch (exception: Exception) {
            throw ErrorSavingFavourite(symbol, exception)
        }
    }

    override suspend fun unsave(symbol: String) {
        try {
            persistence.unsave(symbol)
        } catch (exception: Exception) {
            throw ErrorUnSavingFavourite(symbol, exception)
        }
    }

    data class ErrorFetchingFavourites(override val cause: Throwable) : Throwable("Could not fetch favourites -  $cause")
    data class ErrorSavingFavourite(val symbol: String, override val cause: Throwable) : Throwable("Symbol $symbol couldn't be saved - $cause")
    data class ErrorUnSavingFavourite(val symbol: String, override val cause: Throwable) : Throwable("Symbol $symbol couldn't be unsaved -  $cause")
}

And our FavouritesPersistence, similar to what we saw for Quotes, will be like this:

internal class FavouritesPersistence(
    private val db: StonksDatabase,
) {

    fun getAll() = db.dbFavouritesQueries.getAll().asFlow().mapToList()

    fun save(symbol: String) = db.dbFavouritesQueries.save(symbol)

    fun unsave(symbol: String) = db.dbFavouritesQueries.unsave(symbol)
}

Keen eyes might have noticed that our getAll() method returns a Flow<List<String>>. This is because our persistence getAll returns favourites as .asFlow().mapToList(), which basically will trigger the method and introduce its returned content into the Flow’s pipeline as soon as there is any update in the table. This means that any client using the Flow could render live updates as they happen.

For example, in Android we could have something like this to unwrap the DB updates:

//Code sitting in an Android ViewModel for example, similar for iOS
fun load() {
        viewModelScope.launch {
            fetchFavedQuotesUseCase.invoke() //---> the method which returns our Flow
                .catch { _state.value = FavedState.Error }
                .collect { favedQuotes ->
                    _state.value = FavedState.Content(
                        quotes = favedQuotes.map {
                            it.toFavedQuoteUi()
                        }
                    )
                }
        }
    }
}

To see this in action in Stonks, we can go to the search page, mark a stonk as favourite and come back to the main page. The stock will be there waiting for us magically without us having to refresh the page 👇.

stonks-flow.gif

Could we go even further?Permalink

giphy.gif

Next layer on our diagram would be Domain. Can we move it to a KMM library?

The answer is YES! And we should definitely do it! Before we get to the code, I want to highlight what are the advantages of moving this into our KMM library. Also, for simplicity, I think we can simplify the term domain logic to ‘use cases’.

  • By sharing use cases we get consistency on how they’re consumed in the presentation layer across Android & iOS, which helps to more consistent features across teams.
  • We no longer need to expose repositories to clients (i.e they can have internal visibility in our shared library), which avoid bad usage of them - hands up who hasn’t worked in an app where repositories were injected all over the place…
  • It could already be the case without using a KMM library, but in general, we contribute to a Use Case Driven architecture, where each app action has a use case and potentially each repository method has a Use Case associated to it.
  • And of course, we reduce at least by half the number of code to write, and therefore the bugs 🐛.

Back to Stonks, our main screen will display a list of quotes for all the stocks that have been faved by the user, meaning we need to make use of two repositories to fetch the data we need: QuoteRepository and FavouritesRepository.

Our cross-platform use case would look like this:

class FetchFavedQuotesUseCase(
    private val quoteRepository: QuoteRepository,
    private val favouritesRepository: FavouritesRepository
) {

    suspend fun invoke(): Flow<List<FavedQuote>> {
        return favouritesRepository.getAll().flatMapConcat { favedSymbols ->
            flowOf(quotes(favedSymbols).toFavedQuote())
        }
    }

    private suspend fun quotes(symbols: List<String>): List<Quote> {
        return symbols.map {
            quoteRepository.quote(it)
        }
    }
}

fun List<Quote>.toFavedQuote() = this.map { it.toFavedQuote() }

fun Quote.toFavedQuote() = FavedQuote(
    symbol = this.symbol,
    current = this.current,
    open = this.open,
)

data class FavedQuote(
    val symbol: String,
    val current: Double,
    val open: Double,
)

And to finish giving the whole example, it would be consumed in Android and iOS presentation layers to then render the data using our preferred UI toolkits (Compose ❤️ SwiftUI). A very simple Android example could look similar to this:

class FavedViewModel(
    private val fetchFavedQuotesUseCase: FetchFavedQuotesUseCase,
    private val toggleFavouriteUseCase: ToggleFavouriteUseCase,
) : ViewModel() {

    private val _state = MutableStateFlow<FavedState>(FavedState.Loading)
    val state: StateFlow<FavedState> get() = _state

    fun load() {
        viewModelScope.launch {
            fetchFavedQuotesUseCase.invoke()
                .catch { _state.value = FavedState.Error }
                .collect { favedQuotes ->
                    _state.value = FavedState.Content(
                        quotes = favedQuotes.map {
                            it.toFavedQuoteUi()
                        }
                    )
                }
        }
    }

    fun onDeleteStonkClicked(item: FavedQuoteUi) {
        viewModelScope.launch {
            toggleFavouriteUseCase.unsaved(item.symbol)
        }
    }
}

sealed class FavedState {
    object Loading : FavedState()
    object Error : FavedState()
    data class Content(val quotes: List<FavedQuoteUi>) : FavedState()
}

Next stepsPermalink

  • The iOS companion app implementation is missing. In general, its implementation wouldn’t differ much from Android, but there are some oddities depending on the Swift version used. More information about this can be found on KMO-NativeCorouttines or Using Swift’s new async/await when invoking Kotlin Multiplatform code.
  • In Stonks, we have a mono-repo where the shared library and both Android and iOS live. This is fine for a pet project but not realistic in the real world. I’ll create another article in which I’ll explain how to decouple the library, so clients can consume it as another regular dependency, getting library updates with Dependabot raising PRs.

Summing upPermalink

KMM is a fantastic tool reaching stable levels. Writing entire features twice, one for Android and one for iOS is expensive and bug-prone, and probably not acceptable for engineering teams in a not too far future. KMM provides a relatively straightforward solution to this problem while leaving the UI work to be built natively, which Native Mobile Developers are passionate about, especially with the arrival of declarative frameworks like Jetpack Compose or SwiftUI.

Hope you enjoyed the article. For any questions, my twitter account is the best place, but feel free to reach me anywhere you like!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK