Skip to content

Project Architecture (EN)

Thanh-Son-Philippe Lam edited this page Aug 25, 2019 · 44 revisions

Introduction

ÉTSMobile was initially planned to have two different code base: one for Android and one for iOS. However, the Android app progressed much faster than the iOS app. As a result, we had already released a beta version of the Android app while the iOS lagged behind. You can download the Android app from the Play Store. Most of the code is based on Android specific components and libraries. If you are interested, you can check out the architecture here for more information.

It has then be decided to migrate the project to Kotlin Multiplatform, so that code can shared between the two platforms and help the iOS app progress at a much faster rate. The goal is to be able to share code between the two platforms and replace most of the initial Android source code (except the UI stuff) with the new shared code, so that less code have to be maintained.

The Modules

The project has three main modules:

Module Description
android The android module contains code used for the Android app.
ios The ios module contains code used for the iOS app.
shared The shared module contains common code used by the android module and the ios module. To access platform APIs, the code in this module make use of the expect/actual mechanism provided by Kotlin. The code in this module also rely on multiplatform libraries such as Ktor and Kotlin Coroutines.

Architecture Diagram

ViewModel

ViewModels retrieve the necessary data, applies the UI logic and then exposes relevant data for the view to consume. ViewModels expose streams of events to which the Views can bind to. The views also notify the ViewModels about different actions. In this project, ViewModels expose Coroutines Channels to the View.

The view has a reference to its ViewModel but the ViewModel has no information about the View. A ViewModel must never reference a view.

The base class ViewModel expects an actual CoroutineScope from each platform.

expect open class ViewModel() {
    val vmScope: CoroutineScope
}
Android

A viewModelScope extension is available for the ViewModel class. We can use it to launch coroutines inside our ViewModels.

iOS

TBD

How can we consume Channels?

Android

We convert the Channel by calling toLiveData. Check out the implementation here.

iOS

You can't consume them from Swift. In order to be able to consume Channels, an idea would be to convert them into callbacks. Consume each value emitted by the Channel by calling the callback with it.

UseCase

Use cases define the operations that the app needs. Each use case has a single task responsability and should expose a single method that, when called, retrieve data from Repository modules and/or other use cases, process it and return it to the ViewModels. Use cases implement the invoke operator, so that the classes can be called as functions. Use cases avoid God ViewModels, since ViewModels will only execute use cases.

Finally, the name should be meaningful and starts with an action verb. For example, an use case that fetches the current session should have a name like the following: FetchCurrentSessionUseCase.

Here is video that talks a little bit about use cases: https://youtu.be/Sy6ZdgqrQp0?t=1102

Repository

The repository module implements the repository pattern. The repository module serves as a clean API so that that the rest of the app can retrieve data easily. It acts as a mediator for between the DB and the API modules. The module handle data operations by using Coroutines Suspend Functions.

The repository creates and returns a Flow that the app "subscribes" to in order to be notified of further updates. The Flow will be updated with relevant data and a status (loading, success or failure). The status allows the UI to update its state accordingly. For example, for the loading status, the UI can display a loading bar.

Here are the operations that are generally done by the repository module:

  1. The process starts when the rest of the app calls-in the repository.
  2. The repository starts by calling the DB module which will fetch the data stored in the DB.
  3. The DB module exposes suspend functions. Each function used to query data returns a Flow so that it can be observed. This allows the Flow to be updated automatically whenever the related data change in the database.
  4. The query finishes and the Flow is updated with cached data and a "loading" status. The app should have displayed a loading state by now.
  5. The repository calls the API module to start fetching data from the webservice.
  6. The webservice returns a response. If the call succeeded, the repository updates the DB with the new data. The Flow is also updated with a "success status" and new data coming from the DB. If the call failed, the Flow is updated with a "failure" status.

How to deliver the data and its status?

To do so, use the Resource class. It encapsulates both the data and its state.

class Resource<T> private constructor(val status: Status, val data: T?, val message: String?) {
    enum class Status {
        SUCCESS,
        ERROR,
        LOADING
    }

    companion object {
        fun <T> success(data: T): Resource<T> = Resource(Status.SUCCESS, data, null)
        fun <T> error(msg: String, data: T?): Resource<T> = Resource(Status.ERROR, data, msg)
        fun <T> loading(data: T?): Resource<T> = Resource(Status.LOADING, data, null)
    }

    ...
}

Use one of the static methods to create a Resource instance. For example, to encapsulate a data called "myData" and a loading status, you can do like so:

Resource.loading(myData)

DB

The shared classes uses the expect / actual mechanism. Here is an example :

expect class DashboardCardDatabase {
    fun dashboardCards(): Flow<List<DashboardCard>>
    ...
}

Each platform needs to provide an actual implementation. For example, Android makes use of Room to implement the methods.

actual class DashboardCardDatabase @Inject constructor(
    private val dao: DashboardCardDao
) : DashboardCardDatabase {
    actual fun dashboardCards(): Flow<List<DashboardCard>> = dao.getAll()
        .map { entities ->
            entities.toDashboardCards()
        }

    ...
}

For reference, here it's the DAO for the actual Room database:

@Dao
interface DashboardCardDao {
    @Query("SELECT * FROM dashboardcardentity ORDER BY position")
    fun getAll(): Flow<List<DashboardCardEntity>>

    ...
}

Take note that the method getAll returns a Flow which is part of Kotlin Coroutines.

API

This projects uses Ktor to make API calls. The HTTP Client supports several platforms using the experimental multiplatform support of Kotlin.

Addendum

Coroutines in Kotlin/Native

You should be aware of Kotlin/Native's concurrency model when working with coroutines in Kotlin/Native.

Read the following document for more information: https://github.com/ApplETS/Notre-Dame/wiki/Coroutines-and-Concurrency-in-Kotlin-Native-(EN)