Building a Multiplatform library for iOS and Android

Building a Multiplatform library for iOS and Android

Sharing code between platforms is a powerful technique, but it may be hard to accomplish without rich APIs that we have in Android, JVM, or iOS platforms. Kotlin Multiplatform libraries can be used to fix that, bringing rich APIs directly in the common Kotlin code.

What an exciting frontier!

In this talk we'll go over the creation of a Kotlin Multiplatform library. You'll learn how modules for each platform work, decide what parts of the code makes sense to share and what doesn't. All based in the experiences gathered after creating a library at Mixtiles used to fetch images from different cloud providers.

820df515de752bffa0ce2644a7927186?s=128

David González

April 24, 2019
Tweet

Transcript

  1. Building a Multiplatform Library for Android and iOS David González

    @dggonzalez malmstein@gmail.com
  2. None
  3. None
  4. None
  5. expect / actual mechanism

  6. expect / actual mechanism internal expect fun writeLogMessage(message: String, logLevel:

    LogLevel)
  7. expect / actual mechanism fun logDebug(message: String) = writeLogMessage(message, LogLevel.DEBUG)

    fun logWarn(message: String) = writeLogMessage(message, LogLevel.WARN) fun logError(message: String) = writeLogMessage(message, LogLevel.ERROR)
  8. expect / actual mechanism internal actual fun writeLogMessage(message: String, logLevel:

    LogLevel) { println("[$logLevel]: $message") }
  9. expect / actual mechanism internal actual fun writeLogMessage(message: String, logLevel:

    LogLevel) { when (logLevel) { LogLevel.DEBUG -> console.log(message) LogLevel.WARN -> console.warn(message) LogLevel.ERROR -> console.error(message) } }
  10. None
  11. None
  12. None
  13. Native vs Multiplatform

  14. Native vs Multiplatform apply plugin: ‘kotlin-native-platform' apply plugin: 'kotlin-multiplatform'

  15. // for Native only projects apply plugin: 'kotlin-native-platform' // for

    projects targetting multiple platforms apply plugin: 'kotlin-multiplatform' Native vs Multiplatform
  16. Platform-Specific declarations

  17. Platform-Specific declarations expect class AtomicRef<V>(value: V) { fun get(): V

    fun set(value: V) fun getAndSet(value: V): V fun compareAndSet(expect: V, update: V): Boolean } actual typealias AtomicRef<V> = java.util.concurrent.atomic.AtomicReference<V>
  18. None
  19. None
  20. None
  21. None
  22. Root Folder

  23. Root Folder AndroidApp SharedCode iOSApp

  24. Root Folder AndroidApp SharedCode iOSApp build.gradle build src …

  25. Root Folder AndroidApp SharedCode iOSApp iosApp iosApp.xcodeproj iosAppTests

  26. Root Folder AndroidApp SharedCode iOSApp Src androidMain androidTest commonMain commonTest

    iosMain iosTest
  27. SharedCode Src androidMain androidTest commonMain commonTest iosMain iosTest

  28. kotlin { sourceSets { commonMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlin_version" implementation “org….:kotlinx-coroutines-core-common:$coroutines_version”

    implementation "io.ktor:ktor-client:$ktor_version" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-json:$ktor_version" } commonTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test-common:$version" implementation "org.jetbrains.kotlin:kotlin-test-annotations:$version" } androidMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" SharedPhotos/build.gradle
  29. kotlin { sourceSets { commonMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlin_version" implementation “org….:kotlinx-coroutines-core-common:$coroutines_version”

    implementation "io.ktor:ktor-client:$ktor_version" implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-json:$ktor_version" } commonTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test-common:$version" implementation "org.jetbrains.kotlin:kotlin-test-annotations:$version" } androidMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" SharedPhotos/build.gradle
  30. sourceSets { commonMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlin_version" implementation “org….:kotlinx-coroutines-core-common:$coroutines_version” implementation "io.ktor:ktor-client:$ktor_version"

    implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-json:$ktor_version" } commonTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test-common:$version" implementation "org.jetbrains.kotlin:kotlin-test-annotations:$version" } androidMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" implementation "io.ktor:ktor-client-android:$ktor_version" implementation "io.ktor:ktor-client-core-jvm:$ktor_version" implementation "io.ktor:ktor-client-json-jvm:$ktor_version" }
  31. commonTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test-common:$version" implementation "org.jetbrains.kotlin:kotlin-test-annotations:$version" } androidMain.dependencies { api

    "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" implementation "io.ktor:ktor-client-android:$ktor_version" implementation "io.ktor:ktor-client-core-jvm:$ktor_version" implementation "io.ktor:ktor-client-json-jvm:$ktor_version" } androidTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } iosMain.dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:$version" implementation "io.ktor:ktor-client-ios:$ktor_version"
  32. androidMain.dependencies { api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" implementation "io.ktor:ktor-client-android:$ktor_version"

    implementation "io.ktor:ktor-client-core-jvm:$ktor_version" implementation "io.ktor:ktor-client-json-jvm:$ktor_version" } androidTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } iosMain.dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:$version" implementation "io.ktor:ktor-client-ios:$ktor_version" implementation "io.ktor:ktor-client-core-native:$ktor_version" implementation "io.ktor:ktor-client-json-native:$ktor_version" } } }
  33. implementation "io.ktor:ktor-client-core-jvm:$ktor_version" implementation "io.ktor:ktor-client-json-jvm:$ktor_version" } androidTest.dependencies { implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" implementation

    "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" } iosMain.dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:$version" implementation "io.ktor:ktor-client-ios:$ktor_version" implementation "io.ktor:ktor-client-core-native:$ktor_version" implementation "io.ktor:ktor-client-json-native:$ktor_version" } } }
  34. None
  35. apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' dependencies

    { implementation project(':CloudPhotos') implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" ... } androidApp/build.gradle
  36. None
  37. targets { fromPreset(presets.jvm, 'android') def buildForDevice = project.findProperty("device")?.toBoolean() ?: false

    def iOSTarget = System.getenv('SDK_NAME')?.startsWith('iphoneos') ? presets.iosArm64 : presets.iosX64 fromPreset(iOSTarget, 'ios') { compilations.main.outputKinds('FRAMEWORK') } } CloudPhotos/build.gradle
  38. task packForXCode(type: Sync) { final File frameworkDir = new File(buildDir,

    "xcode-frameworks") final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG' final def framework = kotlin.targets.iOS.binaries.getFramework(“CloudPhotos", mode) inputs.property "mode", mode dependsOn framework.linkTask from { framework.outputFile.parentFile } into frameworkDir doLast { new File(frameworkDir, 'gradlew').with { text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd ‘$. {rootProject.rootDir}'\n./gradlew \$@\n" setExecutable(true) } } } tasks.build.dependsOn packForXCode CloudPhotos/build.gradle
  39. None
  40. https://github.com/AlecStrong/kotlin-native-cocoapods

  41. None
  42. None
  43. androidMain commonMain iOSMain Multiplatform Library

  44. androidMain commonMain iOSMain Platform.kt expect fun platformName(): String Multiplatform Library

  45. androidMain commonMain iOSMain actual fun platformName(): String = "Android Shared

    Photos" Platform.kt Platform.kt expect fun platformName(): String Multiplatform Library
  46. androidMain commonMain iOSMain Platform.kt expect fun platformName(): String actual fun

    platformName(): String = "Android Shared Photos" Platform.kt Platform.kt actual fun platformName(): String = "iOS Shared Photos” Multiplatform Library
  47. // androidApp/...MainActivity.kt import com.malmstein.sharedphotos.platform.platformName title = platformName() // iosApp/… ViewController.swift

    import shared self.label.text = PlatformKt.platformName()
  48. // androidApp/...MainActivity.kt import com.malmstein.sharedphotos.platform.platformName title = platformName() // iosApp/… ViewController.swift

    import shared self.label.text = PlatformKt.platformName()
  49. None
  50. None
  51. None
  52. https://github.com/Kotlin/kotlinx.serialization

  53. None
  54. internal expect val ApplicationDispatcher: CoroutineDispatcher CloudPhotos/commonMain

  55. internal actual val ApplicationDispatcher: CoroutineDispatcher = Dispatchers.Main CloudPhotos/androidMain

  56. internal actual val ApplicationDispatcher: CoroutineDispatcher = NsQueueDispatcher() internal class NsQueueDispatcher:

    CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { val queue = dispatch_get_main_queue() dispatch_async(queue) { block.run() } } } CloudPhotos/iosMain
  57. None
  58. None
  59. class CloudPhotosRepository(private val api: Api = Api()) { private val

    client: HttpClient = HttpClient { install(JsonFeature) { serializer = KotlinxSerializer(Json.nonstrict).apply { setMapper(InstagramResponseJson::class, InstagramResponseJson.serializer()) } } } suspend fun callInstagram(token: String): InstagramResponseJson = client.get { url("https://api.instagram.com/v1/users/self/media/recent? access_token=$token") } fun loadInstagramAsync(callback: (List<InstagramPhoto>) -> Unit) { CloudPhotosRepository
  60. private val client: HttpClient = HttpClient { install(JsonFeature) { serializer

    = KotlinxSerializer(Json.nonstrict).apply { setMapper(InstagramResponseJson::class, InstagramResponseJson.serializer()) } } } suspend fun callInstagram(token: String): InstagramResponseJson = client.get { url("https://api.instagram.com/v1/users/self/media/recent? access_token=$token") } fun loadInstagramAsync(callback: (List<InstagramPhoto>) -> Unit) { GlobalScope.apply { launch(ApplicationDispatcher) { val photos = api.instagramPhotos(apiToken) .data.map { InstagramPhoto.fromJson(it) } callback(photos) } }
  61. } } suspend fun callInstagram(token: String): InstagramResponseJson = client.get {

    url("https://api.instagram.com/v1/users/self/media/recent? access_token=$token") } fun loadInstagramAsync(callback: (List<InstagramPhoto>) -> Unit) { GlobalScope.apply { launch(ApplicationDispatcher) { val photos = api.instagramPhotos(apiToken) .data.map { InstagramPhoto.fromJson(it) } callback(photos) } }
  62. import com.malmstein.sharedphotos.data.CloudPhotosRepository val repository = CloudPhotosRepository() repository.loadInstagramAsync { photosAdapter.setResults(it) }

    AndroidApp/PhotosActivity.kt
  63. import shared let api = Api() let repository = CloudPhotosRepository(api:

    api) typealias PhotoClosure = ([InstagramPhoto]) -> Void func requestPhotos(_ closure: @escaping PhotoClosure) { repository.loadInstagramAsync { photos -> KotlinUnit in closure(gifs) return KotlinUnit() } } requestPhotos { [weak self] photos in iOSApp/ViewController.swift
  64. import shared let api = Api() let repository = CloudPhotosRepository(api:

    api) typealias PhotoClosure = ([InstagramPhoto]) -> Void func requestPhotos(_ closure: @escaping PhotoClosure) { repository.loadInstagramAsync { photos -> KotlinUnit in closure(gifs) return KotlinUnit() } } requestPhotos { [weak self] photos in iOSApp/ViewController.swift
  65. import shared let api = Api() let repository = CloudPhotosRepository(api:

    api) typealias PhotoClosure = ([InstagramPhoto]) -> Void func requestPhotos(_ closure: @escaping PhotoClosure) { repository.loadInstagramAsync { photos -> KotlinUnit in closure(gifs) return KotlinUnit() } } requestPhotos { [weak self] photos in guard let self = self else { return } self.photos = photos self.collectionView?.reloadData() }
  66. import shared let api = Api() let repository = CloudPhotosRepository(api:

    api) typealias PhotoClosure = ([InstagramPhoto]) -> Void func requestPhotos(_ closure: @escaping PhotoClosure) { repository.loadInstagramAsync { photos -> KotlinUnit in closure(gifs) return KotlinUnit() } } requestPhotos { [weak self] photos in guard let self = self else { return } self.photos = photos self.collectionView?.reloadData() }
  67. let api = Api() let repository = CloudPhotosRepository(api: api) typealias

    PhotoClosure = ([InstagramPhoto]) -> Void func requestPhotos(_ closure: @escaping PhotoClosure) { repository.loadInstagramAsync { photos -> KotlinUnit in closure(gifs) return KotlinUnit() } } requestPhotos { [weak self] photos in guard let self = self else { return } self.photos = photos self.collectionView?.reloadData() }
  68. None
  69. None
  70. https://github.com/square/sqldelight

  71. import com.squareup.sqldelight.db.SqlDriver expect object SharedDb { val transacter: CloudPhotosTransacter fun

    setupDatabase(driver: SqlDriver) fun clearDatabase() } // Called from Swift @Suppress("unused") import com.squareup.sqldelight.drivers.ios.NativeSqliteDriver SharedDb.setupDatabase(NativeSqliteDriver(DatabaseSchema, "SharedPhotosDB"))
  72. val transacter: CloudPhotosTransacter fun setupDatabase(driver: SqlDriver) fun clearDatabase() } //

    Called from Swift @Suppress("unused") import com.squareup.sqldelight.drivers.ios.NativeSqliteDriver SharedDb.setupDatabase(NativeSqliteDriver(DatabaseSchema, "SharedPhotosDB")) // Called from Android import com.squareup.sqldelight.android.AndroidSqliteDriver val androidDriver: SqlDriver = AndroidSqliteDriver(DatabaseSchema, context,"SharedPhotosDB") SharedDb.setupDatabase(androidDriver)
  73. // Called from Swift @Suppress("unused") import com.squareup.sqldelight.drivers.ios.NativeSqliteDriver SharedDb.setupDatabase(NativeSqliteDriver(DatabaseSchema, "SharedPhotosDB")) //

    Called from Android import com.squareup.sqldelight.android.AndroidSqliteDriver val androidDriver: SqlDriver = AndroidSqliteDriver(DatabaseSchema, context,"SharedPhotosDB") SharedDb.setupDatabase(androidDriver)
  74. object TokenRepository { fun getToken(): Token { val tokenQueries =

    SharedDb.transactor.tokenQueries val token = tokenQueries.selectInstagramToken().executeAsOne() return token } commonMain/data/TokenRepository
  75. None
  76. package org.jetbrains.kotlinconf.presentation import kotlinx.coroutines.* import kotlin.coroutines.* // TODO: Use Dispatchers.Main

    instead when it will be supported on iOS open class CoroutinePresenter( private val mainContext: CoroutineContext, private val baseView: BaseView ): CoroutineScope { private val job = Job() private val exceptionHandler = CoroutineExceptionHandler { _, throwable - baseView.showError(throwable) } commonMain/presentation/CoroutinesPresenter
  77. class InstagramPhotosPresenter(val view: InstagramPhotosView) : CoroutinePresenter() { fun loadInstagramPhotos(): List<InstagramPhotos>

    { view.showLoading() ... val photos = repository.loadInstagramPhotos() view.showPhotos(photos) } commonMain/presentation/InstagramPresenter
  78. AndroidApp Multiplatform Library iOSApp

  79. AndroidApp Multiplatform Library iOSApp InstagramPhotosView

  80. AndroidApp Multiplatform Library iOSApp InstagramPhotosView InstagramActivity: InstagramPhotosView

  81. AndroidApp Multiplatform Library iOSApp InstagramPhotosView InstagramActivity: InstagramPhotosView InstagramViewController: InstagramPhotosView

  82. interface InstagramPhotosView { fun showLoading() fun showPhotos(photos: List<InstagramPhoto>) fun showError(message:

    String) } // Calling from Android class InstagramActivity: FragmentActivity(), InstagramPhotosView override fun showLoading() { loadingSpinner.setVisibility(View.VISIBLE) } commonMain/presentation/InstagramPhotosView.kt
  83. fun showPhotos(photos: List<InstagramPhoto>) fun showError(message: String) } // Calling from

    Android class InstagramActivity: FragmentActivity(), InstagramPhotosView override fun showLoading() { loadingSpinner.setVisibility(View.VISIBLE) } // Calling from iOS class InstagramViewController: UIViewController { extension PhotosViewController: InstagramPhotosView { func showLoading() { self.loadingSpinner.startAnimating() } }
  84. // Calling from Android class InstagramActivity: FragmentActivity(), InstagramPhotosView override fun

    showLoading() { loadingSpinner.setVisibility(View.VISIBLE) } // Calling from iOS class InstagramViewController: UIViewController { extension PhotosViewController: InstagramPhotosView { func showLoading() { self.loadingSpinner.startAnimating() } }
  85. None
  86. class DateTest { @Test fun testParse() { val date =

    "2017-10-24T13:31:19".parseDate() assertEquals(2017, date.year) assertEquals(Month.OCTOBER, date.month) assertEquals(24, date.dayOfMonth) assertEquals(13, date.hours) assertEquals(31, date.minutes) } }
  87. commonTest works if there are no Coroutines involved

  88. runBlocking does not exist in all platforms

  89. // commonTest expect fun <T> platformRunBlocking( block: suspend CoroutineScope.() ->

    T) : T // androidTest + iosTest actual fun <T> platformRunBlocking( block: suspend CoroutineScope.() -> T): T { return runBlocking { block() } } https://github.com/bakkenbaeck/PorchPirateProtector
  90. @Test fun shouldShowLoadingIndicator() = platformRunBlocking { val view = TestInstagramPhotosView()

    val presenter = PhotosPresenter(view) val photos = presenter.loadInstagramPhotos() assertTrue(view.loadingIndicatorStarted) ...
  91. Testing androidMain also tests commonMain

  92. What is missing Concurrency Date / Time Locale File I

    / O Networking
  93. Conclusions Use immutable objects instead of mutable (although…) Keep everyone

    engaged, not just the Android team Types in Swift / Obj-C can be different in Kotlin and vice-versa Experience developing on iOS is not as good as on Android Can’t debug Kotlin from Xcode (or maybe…)
  94. None
  95. https://github.com/touchlab/xcode-kotlin https://github.com/touchlab/KotlinXcodeSync

  96. https://github.com/touchlab/CrashKiOS

  97. References Kotlin Conf App: https://github.com/JetBrains/kotlinconf-app SharedPhotosLibrary: https://github.com/malmstein/cloudphotos-mpp Droidcon App Multiplatform:

    https://github.com/touchlab/DroidconKotlin Repositories: Talks: Kotlin Multiplatform Libraries by Kevin Galligan: Vimeo
  98. None
  99. Thank you! David González @dggonzalez malmstein@gmail.com