At NortonLifeLock, we've been trying to modularize & share our code as there have been requests from external teams wanting to integrate some of our features. We were then toying with the idea of creating thin libraries to expose our API calls for us or any internal teams within the company. Around the same time we came across this article by Netflix & news came out that KMP was in alpha. Obviously it was worth checking this out as we were planning to develop shared non-ui libraries for android & iOS separately.
I have been developing Android applications for about 6 years but have zero knowledge on any iOS development. I wanted to write about our journey trying out kotlin Multiplatform and getting it to production.
Spike
Whenever we are exploring any kind of new technology, we do a spike for a week/two based on the complexity. For KMP, we started out thinking a week should be enough to evaluate but it took us almost a sprint. This is what we did in our spike.
- First built a sample app with the help of tutorial from the KMP-set up page.
- Added our own API call in the sample app & call the API from our normal Android app & do this call from the sample iOS app (Ive never done iOS development before, so i tested the framework integration within the iOS sample app itself)
- I presented this to the team and got my team/manager buy into this & we wanted an iOS developer to try out if this framework integration would be smooth in the production app.
- Integration of the API was smoother on the Android side as the data classes were just moved from the consuming app to the SDK.
- This was not the case on iOS app, hence there were lot more changes on the iOS side than Android side. So, for the spike purposes we just tried if we're able to integrate the framework from KMP module and make the network call from the iOS app. The response was checked in logs & once this was done successfully, we decided that we can move forward and productionize it.
Implementation
- We started a new KMP project & I moved some code from the sample app to this project. However, this was no spike and I faced a lot of road blocks.
- Since we had working code from the spike, we thought that it shouldn't be lot of work to take it to production. We were so wrong!
- First roadblock was getting the builds out. We use Jenkins for CI with CentOS machines for Android & Mac machines for iOS builds. None of the Mac machines have Android-SDK/Java installed. So, getting the builds team to set up a machine that can build both android & iOS modules was the first step.
- Next task was to integrate the iOS framework generated by KMP into the iOS app. During the spike, we did this manually by copying the framework into Xcode. Obviously, this was not the path we wanted to take going forward.
- It was also first time for our iOS team to use a binary framework from within the company/team. The iOS app uses Carthage and only 3rd party dependencies from Github, nothing from within Norton. Our repo in Artifactory is password protected and apparently Carthage doesn't support password authentication. It took us a day to figure it out. So, our next task was to get our repo given anonymous access for the iOS app to be able download the generated framework from the KMP Module.
- Below are some tasks that took us a bit of time to figure out and get them working.
Publishing FatFramework with resources to Artifactory
- We generated a FatFramework so that it will work on both simulator & device, but discovered that the FatFramework task provided by gradle doesn't copy resources. However our framework requires moko resources to be packaged and the task they provided didn't work for us.
- I created an issue on their github page & posted a solution that worked for us.
open class FatFrameworkWithResources : org.jetbrains.kotlin.gradle.tasks.FatFrameworkTask() {
@TaskAction
protected fun copyBundle() {
super.createFatFramework()
frameworks.first().outputFile.listFiles()
?.asSequence()
?.filter { it.name.contains(".bundle") }
?.forEach { bundleFile ->
project.copy {
from(bundleFile)
into("${fatFrameworkDir.absolutePath}/${bundleFile.name}")
}
}
}
}
- Gradle provides an easy way to zip the framework & we can create a simple task using it.
val zipFramework = tasks.register("zipFatFramework", Zip::class.java) {
it.dependsOn(tasks.named("releaseFatFramework"))
it.from("$buildDir/fat-framework")
it.archiveFileName.set("shared.framework.zip")
it.destinationDirectory.set(file("$buildDir/fat-framework-zip"))
}
- Carthage requires a json file that points to versions of framework, so we came up with a gradle task that downloads/creates the json and upload it to artifactory again.
val downloadAndModifyJson = tasks.register("downloadAndModifyFrameworkJson") {
it.dependsOn("downloadJson")
val buildNumber = project.properties["buildNumber"]
it.doLast {
file("$buildDir/shared.json").apply {
if (exists()) {
val currentContent = readText()
val jsonObject = JsonParser.parseString(currentContent).asJsonObject
jsonObject.addProperty("$version.$buildNumber", "https://artifactory.corp.com/artifactory/${pathToFramework}/$version.$buildNumber/shared.framework.zip")
writeText(jsonObject.toString())
} else {
parentFile.mkdirs()
createNewFile()
writeText(
"""{ "$version.$buildNumber" : "https://artifactory.corp.com/artifactory/${pathToFramework}/$version.$buildNumber/shared.framework.zip" }""".trimIndent()
)
}
}
}
}
Freezing & ThreadLocal
- I did read about issues with threading in Kotlin Native, but didn't face any problems when working on the sample iOS app. So, I thought I don't have to deal with it (we're mostly reading data and not modifying it at any place).
- When integrating it into the actual iOS app, we did face this issue. iOS app was crashing while trying to access data and our colleagues said there might be a change it's accessing it from different thread.
- This (blogpost)[https://helw.net/2020/04/16/multithreading-in-kotlin-multiplatform-apps/] really helped me understand what's going on, but we did have to use different API classes for Android & iOS as we wanted to freeze the object for iOS layer and it's only available in kotlin native but not in common layer. (Hence no sharing of common API that's actually being exposed :( ). This was a big bummer for me as I have to maintain two API classes.
API class in commonMain
expect class Api
Example API in androidMain:
actual class Api : KoinComponent {
internal val api: NetworkingClient by inject()
@Throws(Exception::class)
suspend fun getSampleJson(
param1: String,
param2: String = "en_US"
): SampleData {
return api.getUser(param1, param2)
}
}
Example API in iOSMain
actual class Api : KoinComponent {
internal val api: NetworkingClient by inject()
@Throws(Exception::class)
suspend fun getSampleJson(
param1: String,
param2: String = "en_US"
): SampleData {
return api.getUser(param1, param2).freeze()
}
}
Custom Ktor Interceptors
- We use OkHttp client like a number of other android apps for networking and had a bunch of custom okHttp interceptors. Sadly we can't use okHttp client for ktor if we want to share it across android & iOS.
- We rely heavily on Mockey to mock our api responses for testing and one of the first interceptor we ported to work with Ktor client. Below is sample code of our interceptor. We toggle the use of interceptor using debug preferences.
internal class MockeyUrlSelectionInterceptor(
private val preferences: DebugPreferences?
) {
class Config {
constructor(
preferences: DebugPreferences? = null
) {
this.preferences = preferences
}
var preferences: DebugPreferences?
fun build(): MockeyUrlSelectionInterceptor = MockeyUrlSelectionInterceptor(preferences)
}
companion object Feature :
HttpClientFeature<Config, MockeyUrlSelectionInterceptor> { // Creates a unique key for the feature.
override val key =
AttributeKey<MockeyUrlSelectionInterceptor>("MockeyUrlSelectionInterceptor")
override fun prepare(block: Config.() -> Unit): MockeyUrlSelectionInterceptor =
Config().apply(block).build()
override fun install(feature: MockeyUrlSelectionInterceptor, scope: HttpClient) {
scope.requestPipeline.intercept(HttpRequestPipeline.Transform) {
if (feature.preferences?.shouldMockApi == true) {
val newHttpUrl = Url("http://${feature.preferences.mockIP}:8080/service/${context.url.protocol.name}://${context.url.host}/${context.url.encodedPath}")
context.url(url = newHttpUrl)
}
proceedWith(subject)
}
}
}
}
- To manage common preferences across android & iOS, we use this great library by Touchlab
I hope this post is useful to anyone considering KMP for their future projects! This is my first time writing any blog, so any feedback is appreciated!
I am a NortonLifeLock employee. My opinions on this site are my own and do not necessarily reflect NortonLifeLock.
Top comments (1)
To use freezes only in iOS you can put all your API code to common and use decorator pattern to decorate it with freeze() in iOSmain. Easier to test and use