Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. 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)
  2. 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) } }
  3. // for Native only projects apply plugin: 'kotlin-native-platform' // for

    projects targetting multiple platforms apply plugin: 'kotlin-multiplatform' Native vs Multiplatform
  4. 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>
  5. 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
  6. 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
  7. 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" }
  8. 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"
  9. 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" } } }
  10. 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" } } }
  11. 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
  12. 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
  13. 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
  14. androidMain commonMain iOSMain actual fun platformName(): String = "Android Shared

    Photos" Platform.kt Platform.kt expect fun platformName(): String Multiplatform Library
  15. 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
  16. 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
  17. 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
  18. 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) } }
  19. } } 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) } }
  20. 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
  21. 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
  22. 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() }
  23. 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() }
  24. 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() }
  25. 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"))
  26. 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)
  27. // 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)
  28. object TokenRepository { fun getToken(): Token { val tokenQueries =

    SharedDb.transactor.tokenQueries val token = tokenQueries.selectInstagramToken().executeAsOne() return token } commonMain/data/TokenRepository
  29. 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
  30. class InstagramPhotosPresenter(val view: InstagramPhotosView) : CoroutinePresenter() { fun loadInstagramPhotos(): List<InstagramPhotos>

    { view.showLoading() ... val photos = repository.loadInstagramPhotos() view.showPhotos(photos) } commonMain/presentation/InstagramPresenter
  31. 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
  32. 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() } }
  33. // 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() } }
  34. 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) } }
  35. // 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
  36. @Test fun shouldShowLoadingIndicator() = platformRunBlocking { val view = TestInstagramPhotosView()

    val presenter = PhotosPresenter(view) val photos = presenter.loadInstagramPhotos() assertTrue(view.loadingIndicatorStarted) ...
  37. 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…)
  38. 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