$30 off During Our Annual Pro Sale. View Details »

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.

David González

April 24, 2019
Tweet

More Decks by David González

Other Decks in Programming

Transcript

  1. Building a Multiplatform Library
    for Android and iOS
    David González
    @dggonzalez
    [email protected]

    View Slide

  2. View Slide

  3. View Slide

  4. View Slide

  5. expect / actual mechanism

    View Slide

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

    View Slide

  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)

    View Slide

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

    View Slide

  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)
    }
    }

    View Slide

  10. View Slide

  11. View Slide

  12. View Slide

  13. Native vs Multiplatform

    View Slide

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

    View Slide

  15. // for Native only projects
    apply plugin: 'kotlin-native-platform'
    // for projects targetting multiple platforms
    apply plugin: 'kotlin-multiplatform'
    Native vs Multiplatform

    View Slide

  16. Platform-Specific declarations

    View Slide

  17. Platform-Specific declarations
    expect class AtomicRef(value: V) {
    fun get(): V
    fun set(value: V)
    fun getAndSet(value: V): V
    fun compareAndSet(expect: V, update: V): Boolean
    }
    actual typealias AtomicRef = java.util.concurrent.atomic.AtomicReference

    View Slide

  18. View Slide

  19. View Slide

  20. View Slide

  21. View Slide

  22. Root Folder

    View Slide

  23. Root Folder
    AndroidApp
    SharedCode
    iOSApp

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  27. SharedCode
    Src
    androidMain
    androidTest
    commonMain
    commonTest
    iosMain
    iosTest

    View Slide

  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

    View Slide

  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

    View Slide

  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"
    }

    View Slide

  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"

    View Slide

  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"
    }
    }
    }

    View Slide

  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"
    }
    }
    }

    View Slide

  34. View Slide

  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

    View Slide

  36. View Slide

  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

    View Slide

  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

    View Slide

  39. View Slide

  40. https://github.com/AlecStrong/kotlin-native-cocoapods

    View Slide

  41. View Slide

  42. View Slide

  43. androidMain
    commonMain
    iOSMain
    Multiplatform Library

    View Slide

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

    View Slide

  45. androidMain
    commonMain
    iOSMain
    actual fun platformName(): String = "Android Shared Photos"
    Platform.kt
    Platform.kt expect fun platformName(): String
    Multiplatform Library

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  49. View Slide

  50. View Slide

  51. View Slide

  52. https://github.com/Kotlin/kotlinx.serialization

    View Slide

  53. View Slide

  54. internal expect val ApplicationDispatcher: CoroutineDispatcher
    CloudPhotos/commonMain

    View Slide

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

    View Slide

  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

    View Slide

  57. View Slide

  58. View Slide

  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) -> Unit) {
    CloudPhotosRepository

    View Slide

  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) -> Unit) {
    GlobalScope.apply {
    launch(ApplicationDispatcher) {
    val photos = api.instagramPhotos(apiToken)
    .data.map { InstagramPhoto.fromJson(it) }
    callback(photos)
    }
    }

    View Slide

  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) -> Unit) {
    GlobalScope.apply {
    launch(ApplicationDispatcher) {
    val photos = api.instagramPhotos(apiToken)
    .data.map { InstagramPhoto.fromJson(it) }
    callback(photos)
    }
    }

    View Slide

  62. import com.malmstein.sharedphotos.data.CloudPhotosRepository
    val repository = CloudPhotosRepository()
    repository.loadInstagramAsync {
    photosAdapter.setResults(it)
    }
    AndroidApp/PhotosActivity.kt

    View Slide

  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

    View Slide

  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

    View Slide

  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()
    }

    View Slide

  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()
    }

    View Slide

  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()
    }

    View Slide

  68. View Slide

  69. View Slide

  70. https://github.com/square/sqldelight

    View Slide

  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"))

    View Slide

  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)

    View Slide

  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)

    View Slide

  74. object TokenRepository {
    fun getToken(): Token {
    val tokenQueries = SharedDb.transactor.tokenQueries
    val token = tokenQueries.selectInstagramToken().executeAsOne()
    return token
    }
    commonMain/data/TokenRepository

    View Slide

  75. View Slide

  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

    View Slide

  77. class InstagramPhotosPresenter(val view: InstagramPhotosView)
    : CoroutinePresenter() {
    fun loadInstagramPhotos(): List {
    view.showLoading()
    ...
    val photos = repository.loadInstagramPhotos()
    view.showPhotos(photos)
    }
    commonMain/presentation/InstagramPresenter

    View Slide

  78. AndroidApp
    Multiplatform Library
    iOSApp

    View Slide

  79. AndroidApp
    Multiplatform Library
    iOSApp
    InstagramPhotosView

    View Slide

  80. AndroidApp
    Multiplatform Library
    iOSApp
    InstagramPhotosView
    InstagramActivity: InstagramPhotosView

    View Slide

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

    View Slide

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

    View Slide

  83. fun showPhotos(photos: List)
    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()
    }
    }

    View Slide

  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()
    }
    }

    View Slide

  85. View Slide

  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)
    }
    }

    View Slide

  87. commonTest works if
    there are no Coroutines
    involved

    View Slide

  88. runBlocking does not
    exist in all platforms

    View Slide

  89. // commonTest
    expect fun platformRunBlocking(
    block: suspend CoroutineScope.() -> T) : T
    // androidTest + iosTest
    actual fun platformRunBlocking(
    block: suspend CoroutineScope.() -> T): T {
    return runBlocking { block() }
    }
    https://github.com/bakkenbaeck/PorchPirateProtector

    View Slide

  90. @Test
    fun shouldShowLoadingIndicator() = platformRunBlocking {
    val view = TestInstagramPhotosView()
    val presenter = PhotosPresenter(view)
    val photos = presenter.loadInstagramPhotos()
    assertTrue(view.loadingIndicatorStarted)
    ...

    View Slide

  91. Testing androidMain
    also tests
    commonMain

    View Slide

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

    View Slide

  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…)

    View Slide

  94. View Slide

  95. https://github.com/touchlab/xcode-kotlin
    https://github.com/touchlab/KotlinXcodeSync

    View Slide

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

    View Slide

  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

    View Slide

  98. View Slide

  99. Thank you!
    David González
    @dggonzalez
    [email protected]

    View Slide