Skip to content

KMM Smarter kid in town

Updated: at 06:59 PM (13 min read)

image

KMP has been here for the while, and idea of having shared code and different UI implementation for different platforms is refreshing and meeting the expectation of native look and feel across the platform. Which means Android App would have google material theme, and IOS would have its own IOS like look and feel.

And what would be shared? All api calls, database management, Unit Tests and integration test upto presentation level (state test cases), and we can have shared state management as well, if all user flow is same across Android and IOS.

This is interesting in cross platform space because

From the docs

The Kotlin Multiplatform technology is designed to simplify the development of cross-platform projects. It reduces time spent writing and maintaining the same code for different platforms while retaining the flexibility and benefits of native programming.

image

Architecture Setup for KMP

As we want to have most of the things shared like

And we would have corresponding UI for each platform

image

taken from here

A first class app needs to keep UI/UX patterns authentic on each platform.

With above approach where even state-management is shared, views in all specific platform becomes dumb.

A classic MVI architecture to make 85% sharable code, let’s build it.

Navigation won’t be present in shared code, although it can be achieved, you can view it here

Let’s build it up

I am gonna be discuss the high level overview of the library choices and architecture of the application, and most is inspired from many open source repositories out there in github. Scroll to reference section for it.

Libraries

From bottom up

Setting up DI

So that they can provide and create ktor client to make API calls

Ktor engine would get OKHTTP or Darwin client for Android and IOS project.


fun <T : HttpClientEngineConfig> createHttpClient(  
   engineFactory: HttpClientEngineFactory<T>,  
   json: Json,  
   block: T.() -> Unit,  
): HttpClient = HttpClient(engineFactory) {  
   engine(block)  
  
   install(HttpTimeout) {  
      requestTimeoutMillis = 15_000  
      connectTimeoutMillis = 10_000  
      socketTimeoutMillis = 10_000  
   }  
  
   install(ContentNegotiation) {  
      json(json)  
      register(  
         ContentType.Text.Plain,  
         KotlinxSerializationConverter(json),  
      )  
   }  
  
   install(Logging) {  
      level = LogLevel.ALL  
      logger = object : Logger {  
         override fun log(message: String) {  
            Napier.d(message = message, tag = "[HttpClient]")  
         }  
      }  
   }  
}

Dagger’s provider for creating Http Client


@Provides  
@Singleton  
internal fun httpClient(json: Json, userManager: UserManager): HttpClient = createHttpClient(  
   engineFactory = OkHttp,  
   json = json,  
) {  
   addInterceptor { chain ->  
      val request = chain.request().newBuilder()  
      val user = userManager.getCurrentUser()  
      if (user != null) {  
         request.addHeader("Authorization", "Token ${user.token}")  
      }  
      val response = chain.proceed(request.build())  
      response  
   }  
}

Koin’s code to provide Http Client


single {  
   val userManager: UserManager = get()  
   createHttpClient(  
      engineFactory = Darwin,  
      json = get()  
   ) {  
      val user = userManager.getCurrentUser()  
      Napier.d("sending user")  
      configureRequest {  
         this.setAllHTTPHeaderFields(  
            mapOf(  
               "Authorization" to "Token ${user?.token}"  
            )  
         )  
      }  
   }}

the data flow from UI component to api call would like following, let’s take notes screen for example

image

The famous Data goes down and event goes up like in REACT or classic MVI setup. Or this is also called Uni-directional flow because data is flow in one direction that from parent to child, or from up to down. And child never mutates the data. Mutation will be done by parent or upstream, so parent is source of truth. And this same concept of React is being adopted by Android for compose. Although this can be achieved in XML as well 😎.

Let’s see how this above dependency is provided by Dagger or koin to android and IOS respectively

image

So Dagger provides

And Koin provides

Code snippets to get more of gist, code removed or brevity


open class NotesScreenViewModel(  
   val repository: NotesRepository,  
) : BaseViewModel<NotesScreenState, NotesAction, NotesSingleEvents>(  
   NotesScreenState()  
) {  
   private val debouncerClass = DebouncerClass()  
  
   init {  
      getAllNotes()  
   }  
  
   private fun getAllNotes() {  
      viewModelScope.launch(Dispatchers.Default) {  
         setState {  
            viewState.value.copy(  
               isLoading = true,  
            )  
         }  
         val result = repository.getAllFiles()  
         result.fold(  
            ifRight = {  
               setState {  
                  viewState.value.copy(  
                     allFiles = it,  
                     totalCount = it.size,  
                  )  
               }  
            },  
            ifLeft = {  
               emitSingleEvent(NotesSingleEvents.ApiFailed(it))  
            },  
         )  
         setState {  
            viewState.value.copy(  
               isLoading = false,  
            )  
         }  
      }   }  
  
   private fun searchAKeyword(word: String) {  
      viewModelScope.launch(Dispatchers.Default) {  
         setState {  
            viewState.value.copy(  
               isLoading = true,  
            )  
         }  
         val result = repository.searchKeyword(word)  
         result.fold(  
            ifRight = {  
               setState {  
                  viewState.value.copy(  
                     wordSearchResults = it,  
                  )  
               }  
            },  
            ifLeft = {  
               emitSingleEvent(NotesSingleEvents.ApiFailed(it))  
            },  
         )  
         setState {  
            viewState.value.copy(  
               isLoading = false,  
            )  
         }  
      }   }  
  
   override fun dispatchEvent(action: NotesAction) {  
      setState {  
         action.reduce(viewState.value)  
      }  
      when (action) {  
         is NotesAction.GetNoteContent -> {  
            getFileContent(action.name)  
         }  
         is NotesAction.UpdateNotes -> {  
            updateNotesInServer()  
         }  
         is NotesAction.OnWordSearchChange -> {  
            debouncerClass.execute(scope = viewModelScope, function = {  
               searchAKeyword(action.name)  
            })  
         }  
  
         else -> {}  
      }  
   }  
  
   private fun updateNotesInServer() {  
      // todo here  
   }  
  
   private fun getFileContent(fileName: String) {  
      viewModelScope.launch(Dispatchers.Default) {  
         setState {  
            viewState.value.copy(  
               isLoading = true,  
            )  
         }  
         val result = repository.getFileContent(fileName)  
         result.fold(  
            ifLeft = {  
               emitSingleEvent(NotesSingleEvents.ApiFailed(it))  
            },  
            ifRight = {  
               emitSingleEvent(NotesSingleEvents.NavigateToDetailScreen(it, fileName))  
            },  
         )  
         setState {  
            viewState.value.copy(  
               isLoading = false,  
            )  
         }  
      }   
    }  
    
}

interface NotesRepository {  
  // .... removed for brevity .....
   suspend fun searchKeyword(query: String): Either<AppError, List<String>>  
  // .... removed for brevity .....
}

open class NotesRepositoryImpl(  
   private val service: NotesService,  
   private val errorMapper: AppErrorMapper,  
   private val appCoroutineDispatchers: AppCoroutineDispatchers,  
) : NotesRepository {  
	// .... removed for brevity .....
   override suspend fun getAllFiles(): Either<AppError, List<String>> = withContext(  
      appCoroutineDispatchers.io  
   ) {  
      service.getAllFiles()  
         .mapLeft(errorMapper).tapLeft {  
            Napier.e(  
               message = it.message ?: "",  
               throwable = it,  
               tag = "NotesRepositoryImpl: getAllFiles",  
            )  
         }  
   }
   // .... removed for brevity .....
}

interface NotesService {  
   // .... removed for brevity .....
   suspend fun searchKeyword(query: String): Either<Throwable, List<String>>  
   // .... removed for brevity .....
}

open class NotesServiceImpl(  
   private val httpClient: HttpClient,  
   private val baseUrl: Url,  
   private val appCoroutineDispatchers: AppCoroutineDispatchers,  
) : NotesService {  
  // .... removed for brevity .....
   override suspend fun getAllFiles(): Either<Throwable, List<String>> =  
      withContext(appCoroutineDispatchers.io) {  
         Either.catch {  
            val response = httpClient.get(  
               URLBuilder(baseUrl).apply {  
                  path("notes")  
               }.build(),  
            ) {  
               contentType(ContentType.Application.Json)  
            }  
            response.throwServerErrorMessageIfInNon200Range()  
            response.body()  
         }  
      }
    }
    // .... removed for brevity .....
}

@HiltViewModel  
class AndroidNotesViewModels @Inject constructor(  
   repository: NotesRepository,  
) : NotesScreenViewModel(  
   repository  
)

class AndroidNotesRepository @Inject constructor(  
   service: NotesService,  
   errorMapper: AppErrorMapper,  
   appCoroutineDispatchers: AppCoroutineDispatchers,  
) : NotesRepositoryImpl(  
   service,  
   errorMapper,  
   appCoroutineDispatchers  
)

class AndroidNotesServiceImpl @Inject constructor(  
   httpClient: HttpClient,  
   @ApiEndPoint baseUrl: Url,  
   appCoroutineDispatchers: AppCoroutineDispatchers,  
) : NotesServiceImpl(  
   httpClient,  
   baseUrl,  
   appCoroutineDispatchers,  
)

The code above is self explanatory in itself.

Android implementation wrapper are pretty trivial, as they are created so that dagger can provide them, whereas koin provide Impl only no IOS wrapper over it. The whole idea behind adding hilt as DI for Android was dagger-hilt advantages in Jetpack Compose integration and TDD as well.

The baseViewModel, delegating Vm responsibilities, MVI Actions, Single time events


abstract class BaseViewModel<S : ViewState, A : UiAction, E : UiEvents>(initialState: S) : com.dalakoti.multiplatformViewmodel.ViewModel() {  
   // Used to post transient events to the View  
   private val _viewState = MutableStateFlow(initialState)  
   val viewState: StateFlow<S> = _viewState  
  
   private val _events = Channel<UiEvents>(Channel.BUFFERED)  
   val events = _events.receiveAsFlow() // expose as flow  
  
   fun emitSingleEvent(event: E) {  
      _events.trySend(event)  
   }  
  
   abstract fun dispatchEvent(action: A)  
  
   private var lastState = initialState  
  
   /**  
    * Always Use [update] to emit new state    
    * https://medium.com/geekculture/atomic-updates-with-mutablestateflow-dc0331724405    
    * */   protected fun setState(reducer: S.() -> S) {  
      val newState = reducer(lastState)  
      lastState = newState  
      _viewState.update {  
         newState  
      }  
   }  
}

Idea of delegating some responsibilities to model such that viewModel does not bloat, and we have separation of concern in lot of cases. Here Action data class is acting like a reducer, concept of reducer in Redux.


data class NotesScreenState(  
   var isLoading: Boolean = true,  
   var allFiles: List<String> = listOf(),  
   var suggestions: List<String> = listOf(),  
   var query: String = "",  
   var fileContent: String = "",  
   var totalCount: Int = 0,  
   var wordSearchQuery: String = "",  
   var wordSearchResults: List<String> = listOf(),  
) : ViewState

@KSwiftExclude  
sealed interface NotesAction : UiAction {  
  
   fun reduce(state: NotesScreenState): NotesScreenState  
  
   data class GetNoteContent(val name: String) : NotesAction {  
      override fun reduce(state: NotesScreenState): NotesScreenState = state  
   }  
  
   object UpdateNotes : NotesAction {  
      override fun reduce(state: NotesScreenState): NotesScreenState {  
         return state  
      }  
   }  
  
   data class OnQueryChange(val name: String) : NotesAction {  
      override fun reduce(state: NotesScreenState): NotesScreenState {  
         if (name.isEmpty()) {  
            return state.copy(  
               suggestions = emptyList(),  
               query = "",  
            )  
         }  
         return state.copy(  
            suggestions = state.allFiles.filter {  
               it.lowercase().contains(name.lowercase())  
            },  
            query = name,  
         )  
      }  
   }  
  
   data class OnWordSearchChange(val name: String) : NotesAction {  
      override fun reduce(state: NotesScreenState): NotesScreenState {  
         return state.copy(  
            wordSearchQuery = name,  
            wordSearchResults = emptyList(),  
         )  
      }  
   }  
  
   object RefreshHomeScreen : NotesAction {  
      override fun reduce(state: NotesScreenState): NotesScreenState {  
         // reset all data  
         return NotesScreenState()  
      }  
   }  
}

interface NotesSingleEvents : UiEvents {  
  
   class ApiFailed(val appError: AppError) : NotesSingleEvents  
  
   class NavigateToDetailScreen(val content: String, val fileName: String) : NotesSingleEvents  
}

Observing single events and view State in compose


@Destination  
@NotesGraph(start = true)
@Composable  
fun NotesScreen(  
   viewModel: AndroidNotesViewModels = hiltViewModel(),  
   navigator: DestinationsNavigator,  
) {  
   val eventFlow = rememberFlowWithLifecycle(flow = viewModel.events)  
   val context = LocalContext.current  
   val state by viewModel.viewState.collectAsStateWithLifecycle()  
   val coroutineScope = rememberCoroutineScope()  
   val snackBarHostState = remember { SnackbarHostState() }  
  
   LaunchedEffect(  
      key1 = eventFlow,  
      block = {  
         eventFlow.collect {  
            when (it) {  
               is NotesSingleEvents.ApiFailed -> {  
                  coroutineScope.launch {  
                     snackBarHostState.showSnackbar(it.appError.getReadableMessage(context))  
                  }  
               }  
  
               is NotesSingleEvents.GotContent -> {  
                  context.startActivity(  
                     Intent(context, ViewNoteActivity::class.java).apply {  
                        putExtra("notes", it.content)  
                        putExtra("fileName", it.fileName.getFileNameFromPath())  
                     },  
                  )  
               }  
            }  
         }  
      },  
   )  
  
   UniverseScaffold(  
      modifier = Modifier  
         .fillMaxSize()  
         .paddingForAppBar(),  
      snackbarHost = {  
         SnackbarHost(  
            hostState = snackBarHostState,  
            modifier = Modifier  
               .systemBarsPadding()  
               .fillMaxWidth()  
               .wrapContentHeight(Alignment.Bottom),  
         )  
      }  
   ) {  
      NotesScreenContent(  
         state = state,  
         dispatchEvent = {  
            viewModel.dispatchEvent(it)  
         },  
      )  
   }  
}

This is also good, great and trivial as per Android development state in 2023. Let us see how IOS gels with this KMM’s kotlin part, and how many changes are required for IOS integration.

IOS Part

I am not an IOS developer, so code or methodologies used here are something I found works for the project and serves the functional purpose. Although I found some article using rxSwift + coroutine flow to achieve the same results which are being achieved below. But I decided to go with callbacks and flows, no rxSwift

Koin setting things up


object DIContainer : KoinComponent {  
   fun init(appDeclaration: KoinAppDeclaration = {}) {  
      Napier.base(  
         if (isDebug()) DebugAntilog()  
         else object : Antilog() {  
            override fun performLog(  
               priority: LogLevel,  
               tag: String?,  
               throwable: Throwable?,  
               message: String?  
            ) {  
               // TODO: Crashlytics  
            }  
         }  
      )  
  
      startKoin {  
         appDeclaration()  
         modules(  
            dataModule,  
            domainModule,  
            appModule,  
            presentationModule,  
         )  
         printLogger(  
            if (isDebug()) {  
               Level.DEBUG  
            } else {  
               Level.ERROR  
            },  
         )  
      }  
   }  
  
   fun get(  
      type: ObjCObject,  
      qualifier: Qualifier? = null,  
      parameters: ParametersDefinition? = null  
   ): Any? = getKoin().get(  
      clazz = when (type) {  
         is ObjCProtocol -> getOriginalKotlinClass(type)!!  
         is ObjCClass -> getOriginalKotlinClass(type)!!  
         else -> error("Cannot convert $type to KClass<*>")  
      },  
      qualifier = qualifier,  
      parameters = parameters,  
   )  
}

// swift App
@main
struct iOSApp: App {
    
    private let userManager: UserManager
    
    init(){
        DIContainer.shared.doInit{ _ in
            
        }
        self.userManager = DIContainer.shared.get()
    }
    
    var body: some Scene {
		WindowGroup {
            if userManager.getCurrentUser() == nil{
                LoginScreen()
            }else{
                NotesScreen()
            }
		}
	}
}

// NotesScreen

struct NotesList: View {
    @State private var userInput: String = ""
    @State private var searchWordInput: String = ""

    var suggestedFileName: [String]
    var onSearchFile: ((String)-> Void)
    var isLoading: Bool
    var onKeywordSearch: ((String)-> Void)
    
    var searchResults: [String]
    
    var body: some View {
        VStack{
            
            if isLoading{
                ProgressView()
            }
            
            Text("Search Notes").padding(15)
            TextField("Search File Name", text: $userInput).padding()
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .autocapitalization(/*@START_MENU_TOKEN@*/.none/*@END_MENU_TOKEN@*/)
                .keyboardType(.emailAddress)
                .onChange(of: userInput, perform: { newText in
                    print("value is \(newText)")
                    onSearchFile(newText)
                })

            // suggestions
            if suggestedFileName.count>0{
                List {
                    ForEach(suggestedFileName, id: \.self) { item in
                        Text(item)
                    }
                }
                .padding(.top, -8)
                .background(Color.white)
                .cornerRadius(8)
                .shadow(radius: 4)
            }
            
            Spacer().frame(height: 20)
            TextField("Search Keyword", text: $searchWordInput).padding()
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .autocapitalization(/*@START_MENU_TOKEN@*/.none/*@END_MENU_TOKEN@*/)
                .keyboardType(.emailAddress)
                .onChange(of: searchWordInput, perform: { newText in
                    print("value is \(newText)")
                    onKeywordSearch(newText)
                })
            List {
                ForEach(searchResults, id: \.self) { item in
                    Text(item)
                }
            }
            .padding(.top, -8)
            .background(Color.white)
            .cornerRadius(8)
            .shadow(radius: 1)
            Spacer()
        }.padding()
    }
}

struct NotesScreen: View {
    
    @ObservedObject var viewModel =  IosNotesScreenViewModel(
        viewModel: DIContainer.shared.get()
    )
 
    var body: some View{
        TabView {
            
            // First Tab
            NotesList(
                suggestedFileName: viewModel.state.suggestions,
                onSearchFile: { text in
                    viewModel.dispatch(action: NotesActionOnQueryChange(name: text)
                    )
                },
                isLoading: viewModel.state.isLoading,
                onKeywordSearch: { text in
                    viewModel.dispatch(action:
                        NotesActionOnWordSearchChange(name: text)
                    )
                },
                searchResults: viewModel.state.wordSearchResults
            )
                .tabItem {
                    Image(systemName: "1.circle")
                    Text("Notes")
                }
            
            // Second Tab
            Text("The Graph content")
                .tabItem {
                    Image(systemName: "2.circle")
                    Text("Graphs")
                }
        }
    }
}

IOSNotesScreenViewModel, which delegate API call and state management to shared Viewmodel and repository.


// NotesScreenViewModel

@MainActor
class IosNotesScreenViewModel: ObservableObject{

    private let viewModel: NotesScreenViewModel
    
    @Published var state: NotesScreenState
    
    init(viewModel: NotesScreenViewModel) {
        self.viewModel = viewModel
        self.state = viewModel.viewState.value as! NotesScreenState
        addStateListener()
    }
    
    func addStateListener(){
        viewModel.viewState.collect(collector: UiStateCollector<NotesScreenState>(
            onNotify: { newState in
                DispatchQueue.main.async {
                    self.state = newState
                }
            }
        ), completionHandler: { err in
            print("got error \(String(describing: err))")
        })
    }
    
    func observeDataFlow(completion: @escaping (NotesSingleEvents)-> Void){
        viewModel.events.collect(collector: SingleEventCollector<NotesSingleEvents>(
            onNotify: completion
        ), completionHandler: { err in
            print("got error in completion handler \(String(describing: err))")
        })
    }
    
    func dispatch(action: NotesAction)-> Void{
        Napier.d("action -> \(action)")
        self.viewModel.dispatchEvent(action: action)
    }
    
    deinit{
        viewModel.clear()
    }
    
}

Or that looks great, but lets see the magical classes which make all these eassy peassy. Well the idea is that use callbacks and mutate @Published which would make View aware.


// SingleEventCollector
class SingleEventCollector<T>: Subscriber, FlowCollector{
    typealias Input = T
    
    typealias Failure = Never
    
    private var onNotify: ((T)-> Void)
    
    init(onNotify:@escaping ( (T) -> Void)) {
        self.onNotify = onNotify
    }
    
    func receive(subscription: Subscription) {
        subscription.request(.unlimited)
    }
    
    func receive(_ input: Input) -> Subscribers.Demand {
        return .unlimited
    }
    
    func receive(completion: Subscribers.Completion<Never>) {
        switch completion{
        case .finished:
            print("flow completed")
        case .failure(let error):
            print("error \(error)")
        }
    }
    
    func emit(value: Any?) async throws {
        let event = value as! T
        // print("value \(String(describing: event))")
        onNotify(event)
    }
}


// StateCollector
class UiStateCollector<T>: Subscriber, FlowCollector{
    typealias Input = T
    
    typealias Failure = Never
    
    private var onNotify: ((T)-> Void)
    
    init(onNotify:@escaping ( (T) -> Void)) {
        self.onNotify = onNotify
    }
    
    func receive(subscription: Subscription) {
        subscription.request(.unlimited)
    }
    
    func receive(_ input: Input) -> Subscribers.Demand {
        return .unlimited
    }
    
    func receive(completion: Subscribers.Completion<Never>) {
        switch completion{
        case .finished:
            print("flow completed")
        case .failure(let error):
            print("error \(error)")
        }
    }
    
    func emit(value: Any?) async throws {
        let event = value as! T
        // print("value \(String(describing: event))")
        onNotify(event)
    }
}

Ah ha moment, after all these things 🔥

IMAGE ALT TEXT

Video is here on Vimeo

Verdicts

Why KMM?

Resources

If you found this article helpful, please do give a clap and don’t forget to say hi on Twitter or Linkedin