Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

expect / actual mechanism

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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)

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

No content

Slide 13

Slide 13 text

Native vs Multiplatform

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

Platform-Specific declarations

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

No content

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

No content

Slide 21

Slide 21 text

No content

Slide 22

Slide 22 text

Root Folder

Slide 23

Slide 23 text

Root Folder AndroidApp SharedCode iOSApp

Slide 24

Slide 24 text

Root Folder AndroidApp SharedCode iOSApp build.gradle build src …

Slide 25

Slide 25 text

Root Folder AndroidApp SharedCode iOSApp iosApp iosApp.xcodeproj iosAppTests

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

SharedCode Src androidMain androidTest commonMain commonTest iosMain iosTest

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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"

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

No content

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

No content

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

No content

Slide 42

Slide 42 text

No content

Slide 43

Slide 43 text

androidMain commonMain iOSMain Multiplatform Library

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

No content

Slide 51

Slide 51 text

No content

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

No content

Slide 54

Slide 54 text

internal expect val ApplicationDispatcher: CoroutineDispatcher CloudPhotos/commonMain

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

No content

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

No content

Slide 69

Slide 69 text

No content

Slide 70

Slide 70 text

https://github.com/square/sqldelight

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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)

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

No content

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

AndroidApp Multiplatform Library iOSApp

Slide 79

Slide 79 text

AndroidApp Multiplatform Library iOSApp InstagramPhotosView

Slide 80

Slide 80 text

AndroidApp Multiplatform Library iOSApp InstagramPhotosView InstagramActivity: InstagramPhotosView

Slide 81

Slide 81 text

AndroidApp Multiplatform Library iOSApp InstagramPhotosView InstagramActivity: InstagramPhotosView InstagramViewController: InstagramPhotosView

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

No content

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

commonTest works if there are no Coroutines involved

Slide 88

Slide 88 text

runBlocking does not exist in all platforms

Slide 89

Slide 89 text

// 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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

Testing androidMain also tests commonMain

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

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

Slide 96

Slide 96 text

https://github.com/touchlab/CrashKiOS

Slide 97

Slide 97 text

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

Slide 98

Slide 98 text

No content

Slide 99

Slide 99 text

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