Skip to main content

Adopting KMM in Existing Mobile Apps

· 9 min read

How Fleetio introduced KMM into our existing mobile apps.

Background

Earlier this year, we introduced rich text comments to our web and mobile apps (https://updates.fleetio.com/add-links-and-formatting-to-your-comments-4rNMzu). Rich text editor libraries are plentiful for web developers, but few options exist for mobile developers, particularly for SwiftUI and Jetpack Compose which we use for any new UIs.

There are a few HTML-based options for developers. On Android, we landed on RichEditor(https://github.com/wasabeef/richeditor-android). We found an iOS fork RichEditorView(https://github.com/T-Pro/RichEditorView) which provides a similar experience in Swift. Aside from basic rich text editing, we also needed to include some features specific to Fleetio, such as @ mentioning. Instead of writing the HTML/CSS/JS twice or copying-and-pasting from one platform, to another, we decided to leverage Kotlin Multiplatform Mobile (KMM) to share code between platforms.

What is KMM?

From Jetbrains (https://kotlinlang.org/docs/multiplatform-mobile-faq.html):

Kotlin Multiplatform Mobile (KMM) is an SDK for cross-platform mobile development. You can develop multiplatform mobile applications and share parts of your applications between Android and iOS, such as core layers, business logic, presentation logic, and more.

Why KMM?

We've had interest in exploring KMM at Fleetio as a way to share common code between our two platforms. Using KMM to share a rich text editor allows us to experiment with building and deploying KMM libraries.

Compared to other cross-platform technologies, KMM gives mobile teams and easy way to deploy shared libraries without much change to existing native apps.

  • Shared libraries can be deployed via Maven for Android and SPM or Cocoapods for iOS devs
  • Library code is compiled to native binaries
  • Easy to use development with Kotlin Multiplatform plugin in Android Studio
  • Code is written in Kotlin allowing Android devs to jump right in and provides a familiar syntax to Swift for iOS devs
  • UI code is native. This was a big plus for our devs as we are heavily invested in native-specific UI toolkits (SwiftUI and Jetpack Compose)

Classic KMM Getting Started

The most common way to start with KMM is using Android Studio. This path is most beneficial for creating brand new mobile apps.

  1. Install the Kotlin Multiplatform Plugin in Android Studio

image

  1. From Android Studio, you can use the new project wizard, and choose Kotlin Multiplatform App or Library

image

  1. Configure how your iOS framework will be distributed

image

Using KMMBridge

KMMBridge (https://github.com/touchlab/KMMBridge) is toolset for deploying prebuilt KMM libraries, specifically for Xcode devs. Using KMMBridge allows library developers to set up KMM build and deployment processes without signficant changes to existing mobile apps.

For us, this meant creating a new shared library for rich text editing without the complexities of refactoring and merging our existing Android and iOS codebases to fit the classic KMM model. Touchlab provides a one hour quick start guide here: https://touchlab.co/quick-start-with-kmmbridge-1-hour-tutorial/. Here's the basics in a nutshell:

  1. Use the KmmBridge template from Github: https://github.com/touchlab/KMMBridgeKickStart
  2. Change your GROUP in gradle.properties

The template provides three projects:

  1. shared: the shared KMM library project
  2. androidApp: the "sample" Android project. We use this debug new library features and show example usage.
  3. iosApp: the "sample" iOS Xcode project. We use this debug new library features and show example usage.

KMMBridge Configuration

Our mobile dev team is using KMMBridge with its Github and SPM configurations. KMMBridge provides built-in Github actions to get CI/CD started quickly.

Fleetio's RichTextEditor library

Our library, RichTextEditor uses KMM to package and distribute webview-based rich text editors. There is no data storage, network calls, etc. Here's a high level overview for our RichTextEditor library.

image

Copy, Refactor, Repeat

We started our project by combining code from the richeditor-android and RichEditorView libraries. Because RichEditorView forks richeditor-android , much of the HTML, CSS and JS is the same. We took that logic and put them into internal Kotlin constant Strings:

  • RTE_HTML
  • CSS

For JavaScript, we have shared JavaScript contained in PRE_PLATFORM_JS. We also need to add an expect fun platformJavascript function which returns platform-specific JS using actual since there we need to handle minor differences between Chrome and Safari.

RichTextEditorView

This expect class defines the fields and functions the UI view. The actual Android and iOS code will implement. Note that all the function are internal. Devs will interact with this view using RTEViewInteractor

expect class RichTextEditorView {
internal var ready: Boolean
internal fun getHtmlContent(onFetched: ((String) -> Unit))
internal fun executeJs(script: String, onComplete: ((String) -> Unit))
internal fun encodeURL(url: String): String
internal fun changeFocus(focused: Boolean)
}

Android: This implementation extends WebView with additional logic to handle the HTML/CSS/JS used in the editor. In Fleetio Go, we wrap this view with Compose.

iOS: This implementation extends WKWebView with additional logic to handle the HTML/CSS/JS used in the editor. In Fleetio Go, we wrap this view with SwiftUI.

RichTextEditorViewFactory

This expect class defines a factory class to build RichTextEditorView. The actual Android and iOS code will implement.

RTEViewInteractor

It's not possible to have an expect class with some shared functions so we need to create a wrapper around RichTextEditorView to handle these interactions. Here's a snippet


class RTEViewInteractor {

private var view: RichTextEditorView? = null
private var eventHandler: ((RTEEvent) -> Unit)? = null

fun setBold() {
view?.executeJs("javascript:RE.setBold();") {

}
}

fun createLink(url: String, title: String) {
view?.executeJs("javascript:RE.prepareInsert()") {
view?.executeJs("javascript:RE.insertLink(\"$url\", \"$title\");") {}
}
}
// end snippet
}

Sample Usage

Here's a sample of how to use the editor from Compose on Android:

@Composable
fun RichTextEditor(initialHtml: String = ""){
AndroidView(
modifier = Modifier
.background(Color.LightGray)
.padding(16.dp),
factory = { context ->

val factory = RichTextEditorViewFactory(
context = context,
initialContent = initialHtml,
logListener = RTELogging,
onEvent = { event ->
println(event)
)

factory.view.also {
interactor = factory.interactor
}
},
update = { webby ->

}
}

And where's Swift usage with SwiftUI:


// Create ObservableOject to handle factory and interactor
final class RichTextEditorService: ObservableObject {
var factory: RichTextEditorViewFactory?

func configFactory(intialContent: String) {
factory = RichTextEditorViewFactory(
initialContent: intialContent,
logListener: RTELog(),
onEvent: onRTEEvent)

factory!.interactor.setPlaceholderText(placeholder: "Add a comment...")
}

func onRTEEvent(event: RTEEvent) {
print(event)
}
}

// Wrap generated UIView so it can be displayed in SwiftUI
struct RTETextView: UIViewRepresentable {

@EnvironmentObject var rteService: RichTextEditorService

private var service: RichTextEditorService

init(service: RichTextEditorService) {
self.service = service
}

func makeUIView(context: Context) -> WKWebView {
rteService.configFactory(intialContent: "")
let webView = rteService.factory!.view
return webView
}

func updateUIView(_ uiView: WKWebView, context: Context) {
}
}

// Use wrapper in SwiftUI
@StateObject var rteService = RichTextEditorService()
RTETextView(service: rteService)

Tips and Tricks

Learn how KMM compiles for iOS

KMM uses Kotlin/Native to compile Kotlin into native binaries. For iOS, this is accomplished using Objective-C. For much of KMM development, you will just be writing Kotlin shared code, but on occasion, you will need to interact with native platform features. It's helpful to know how Kotlin code maps to/from Objective-C. https://kotlinlang.org/docs/native-objc-interop.html

Don't overuse Expect and Actual

From: https://kotlinlang.org/docs/multiplatform-connect-to-apis.html#examples

Use expected and actual declarations only for Kotlin declarations that have platform-specific dependencies. Implementing as much functionality as possible in the shared module is better, even if doing so takes more time.

Don't overuse expected and actual declarations – in some cases, an interface may be a better choice because it is more flexible and easier to test.

An expected class cannot have any implementation

If you building an expect/actual class, you cannot have any functions implemented. The expect class is basically an interface. If need to have shared functions, create a separate common class.

You can’t subclass an Objective-C class and a Kotlin class

Let’s say you are trying to extend a view (View in Android or UIView in Swift). You define an interface which adds functions, or use use a delegation pattern. This will not compile. Currently, you cannot mix Kotlin and Objective-C supertypes. https://kotlinlang.org/docs/native-objc-interop.html#subclassing. This was a primary reason we build RTEViewInteractor.

Concurrent Development

Using a single shared library for your entire app allows for a much easier deployment process but can present challenges when multiple developers need to work on the shared library. Here are some tips and tricks that should help our dev team avoid conflicts and be more productive:

Use a Consistent Branch Naming Scheme

At Fleetio, we use this scheme for naming shared library branches

<type>/<ticket>-<description>

  • <type> - The type of issue this branch addresses. The different types are
    • feature - Branch for new feature work
    • chore - Branch for engineering improvements or minor non-feature work
    • bugfix - Bug fix
  • <ticket> - The Jira ticket number. If you don’t have a Jira ticket, use the Go parent ticket as the ticket number or consider creating a task in Jira
  • <description> - brief hyphen-separated description of the changes.

Examples

  • chore/GO-1234-add-some-component
  • feature/GO-2345-shared-api-component

Version your Branch Changes

Using Gradle, set up your library versioning to use a unique identifier for your builds. At Fleetio simply append the Jira ticket key to the version string. For subsequent builds, you can also append another number.

LIBRARY_VERSION=1.0.1-GO-1234-build-0

LIBRARY_VERSION=1.0.1-GO-1234-build-1

Before merging into your main branch, remove the Jira ticket and build number and increment the main library version accordingly:

LIBRARY_VERSION=1.0.2

Final Thoughts

When introducing KMM to your team, I suggest the following points:

  1. Start Small: Even if your first update in your existing apps is to add an empty shared KMM library, that is a big step in verifying your deployment works and helps foster confidence moving forward.
  2. Include iOS Devs: From the get-go, make sure your iOS devs understand how KMM works and are included in the process. Helping to understand that the framework will work with Swift but that there are Objective-C considerations to be aware of.