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

Best Practices for Developing Amazing Kotlin Multiplatform Apps

pahill
March 14, 2024

Best Practices for Developing Amazing Kotlin Multiplatform Apps

Appdevcon edition

pahill

March 14, 2024
Tweet

More Decks by pahill

Other Decks in Programming

Transcript

  1. • It’s not an introduction to Kotlin Multiplatform (that was

    Marco’s talk). • It’s not about all the best practices of the technology. • It’s not about processes and tooling. What this talk is not about
  2. Three sets of best practices to address some common questions

    and mistakes. 1. Question: How do I make my Kotlin/Swift interop look more like idiomatic Swift? 2. Question: Can I / how do I consume suspend functions and Flows from Swift? 3. Mistake: Misusing expect/actual building blocks. What this talk is about
  3. • Decision made in ~2018 for Kotlin to be interoperable

    with Objective-C, not Swift • Why? ◦ Can be used in all iOS projects, not just Swift ones ◦ Swift was still on the road to maturity/adoption at that stage Kotlin/Objective-C interop
  4. Some Kotlin features: • Work exactly as expected • Work

    with a small workaround • Work better with a community solution • Don’t work (small minority) What this means for Swift developers
  5. JetBrains is working on Swift export, which will solve the

    Kotlin-to-Swift interop challenges. 󰠁󰳕
  6. • Interoperability encyclopedia • Explains how Kotlin features currently work

    from Swift • Lots of code samples • Gives workarounds / community solutions • Includes playground app with runnable samples Kotlin/Swift Interopedia [kotl.in/interopedia]
  7. ✅ Some features work exactly as expected ✏ Some features

    need a slight modification 🤝 Some features need a community solution 🛑 Some features don’t work
  8. • Classes • Properties • Functions & constructors • Higher-order

    functions • Some basic types • Some collections • Etc. Examples
  9. ✅ Some features work exactly as expected ✏ Some features

    need a slight modification 🤝 Some features need a community solution 🛑 Some features don’t work
  10. • Basic types like integers and Char • Collections with

    basic types • Objects • Companion objects • Some generics • Etc. Examples
  11. object AnalyticsLogger { const val LOGIN_SCREEN_NAME = "login_screen" fun logLogin()

    { println(LOGIN_SCREEN_NAME) } } Slight modification example - Objects ✓ Property called ‘shared’ appears Used from Kotlin: AnalyticsLogger.LOGIN_SCREEN_NAME AnalyticsLogger.logLogin() Used from Swift:
  12. ✅ Some features work exactly as expected ✏ Some features

    need a slight modification 🤝 Some features need a community solution 🛑 Some features don’t work
  13. Some community rock stars 🎸 @TouchlabHQ • Created SKIE @RickClephas

    • Created KMP-NativeCoroutines • Contributed several Swift interop annotations
  14. • Kotlin compiler plugin • Narrows gap between Kotlin and

    Swift • Stepping stone to official Swift interop • List of features always growing, but for now: ◦ Enumerations (enums, sealed hierarchies) ◦ Functions (default arguments etc) ◦ Reactive (coroutines and Flows) [next section] SKIE [https://skie.touchlab.co/]
  15. func colorAbbreviations(colors: Colors) -> String { switch colors { case

    .red: return "R" case .green: return "G" case .blue: return "B" default: return "D" } } Enum Interop - from Swift Somewhere in Swift
  16. func colorAbbreviations(colors: Colors) -> String { switch colors { case

    .red: return "R" case .green: return "G" case .blue: return "B" default: return "D" } } Enum Interop - from Swift Somewhere in Swift Required: Switch must be exhaustive
  17. __attribute__((swift_name("Colors"))) @interface ComposeAppColors : ComposeAppKotlinEnum<ComposeAppColors *> @property (class, readonly) ComposeAppColors

    *red __attribute__((swift_name("red"))); @property (class, readonly) ComposeAppColors *green __attribute__((swift_name("green"))); @property (class, readonly) ComposeAppColors *blue __attribute__((swift_name("blue"))); + (ComposeAppKotlinArray<ComposeAppColors *> *)values __attribute__((swift_name("values()"))); @property (class, readonly) NSArray<ComposeAppColors *> *entries __attribute__((swift_name("entries"))); @end Enum Interop - The Investigation 󰡸 Somewhere in Objective-C
  18. public enum Colors : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { case red

    case green case blue … } SKIE Enum Interop - The Investigation 󰡸
  19. public enum Colors : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { case red

    case green case blue … } SKIE Enum Interop - The Investigation 󰡸
  20. func colorAbbreviations(colors: Colors) -> String { switch colors { case

    .red: return "R" case .green: return "G" case .blue: return "B" } } SKIE Enum interop Somewhere in Swift No default case needed!
  21. sealed class NetworkResponse { data class Data(val body: String) :

    NetworkResponse() data object Error : NetworkResponse() } Sealed Classes Interop Somewhere in Kotlin
  22. func processNetworkResponse(response: NetworkResponse){ switch response { case is NetworkResponse.Data: print("Data

    \(body)"); case is NetworkResponse.Error: print("Error"); default: print("Default") } } Sealed Classes Interop - from Swift Somewhere in Swift
  23. func processNetworkResponse(response: NetworkResponse){ switch response { case is NetworkResponse.Data: print("Data

    \(body)"); case is NetworkResponse.Error: print("Error"); default: print("Default") } } Sealed Classes Interop - from Swift Somewhere in Swift Required: Switch must be exhaustive
  24. __attribute__((swift_name("NetworkResponse"))) @interface ComposeAppNetworkResponse : ComposeAppBase @end __attribute__((swift_name("NetworkResponse.Data"))) @interface ComposeAppNetworkResponseData :

    ComposeAppNetworkResponse … @property (readonly) NSString *body __attribute__((swift_name("body"))); @end __attribute__((swift_name("NetworkResponse.Error"))) @interface ComposeAppNetworkResponseError : ComposeAppNetworkResponse … @end Sealed Classes Interop - The Investigation 󰡸 Somewhere in Objective-C
  25. __attribute__((swift_name("NetworkResponse"))) @interface ComposeAppNetworkResponse : ComposeAppBase @end __attribute__((swift_name("NetworkResponse.Data"))) @interface ComposeAppNetworkResponseData :

    ComposeAppNetworkResponse … @property (readonly) NSString *body __attribute__((swift_name("body"))); @end __attribute__((swift_name("NetworkResponse.Error"))) @interface ComposeAppNetworkResponseError : ComposeAppNetworkResponse … @end Sealed Classes Interop - The Investigation 󰡸 Somewhere in Objective-C
  26. __attribute__((swift_name("NetworkResponse"))) @interface ComposeAppNetworkResponse : ComposeAppBase @end __attribute__((swift_name("NetworkResponse.Data"))) @interface ComposeAppNetworkResponseData :

    ComposeAppNetworkResponse … @property (readonly) NSString *body __attribute__((swift_name("body"))); @end __attribute__((swift_name("NetworkResponse.Error"))) @interface ComposeAppNetworkResponseError : ComposeAppNetworkResponse … @end Sealed Classes Interop - The Investigation 󰡸 Somewhere in Objective-C
  27. public extension ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse { @frozen enum __Sealed : Swift.Hashable {

    case data(ComposeApp.NetworkResponse.Data) case error(ComposeApp.NetworkResponse.Error) } } SKIE Sealed Classes Interop - The Investigation 󰡸 Somewhere in Swift
  28. public func onEnum<__Sealed : ComposeApp.NetworkResponse>(of sealed: __Sealed) -> ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed {

    if let sealed = sealed as? ComposeApp.NetworkResponse.Data { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.data(seal ed) } else if let sealed = sealed as? ComposeApp.NetworkResponse.Error { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.error(sea led) } else { fatalError("Unknown subtype. This error should not happen under normal circumstances since SirClass: ComposeApp.NetworkResponse is sealed.") } } SKIE Sealed Classes Interop - The Investigation 󰡸 Somewhere in Swift
  29. public func onEnum<__Sealed : ComposeApp.NetworkResponse>(of sealed: __Sealed) -> ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed {

    if let sealed = sealed as? ComposeApp.NetworkResponse.Data { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.data(seal ed) } else if let sealed = sealed as? ComposeApp.NetworkResponse.Error { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.error(sea led) } else { fatalError("Unknown subtype. This error should not happen under normal circumstances since SirClass: ComposeApp.NetworkResponse is sealed.") } } SKIE Sealed Classes Interop - The Investigation 󰡸 Somewhere in Swift
  30. public func onEnum<__Sealed : ComposeApp.NetworkResponse>(of sealed: __Sealed) -> ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed {

    if let sealed = sealed as? ComposeApp.NetworkResponse.Data { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.data(seal ed) } else if let sealed = sealed as? ComposeApp.NetworkResponse.Error { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.error(sea led) } else { fatalError("Unknown subtype. This error should not happen under normal circumstances since SirClass: ComposeApp.NetworkResponse is sealed.") } } SKIE Sealed Classes Interop - The Investigation 󰡸 Somewhere in Swift
  31. public func onEnum<__Sealed : ComposeApp.NetworkResponse>(of sealed: __Sealed) -> ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed {

    if let sealed = sealed as? ComposeApp.NetworkResponse.Data { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.data(seal ed) } else if let sealed = sealed as? ComposeApp.NetworkResponse.Error { return ComposeApp.Skie.KotlinProject__composeApp.NetworkResponse.__Sealed.error(sea led) } else { fatalError("Unknown subtype. This error should not happen under normal circumstances since SirClass: ComposeApp.NetworkResponse is sealed.") } } SKIE Sealed Classes Interop - The Investigation 󰡸 Somewhere in Swift
  32. func processNetworkResponse(response: NetworkResponse){ switch onEnum(of: response) { case .data: print("Data

    \(body)"); case .error: print("Error"); } } SKIE Sealed Classes interop Somewhere in Swift No default case needed!
  33. connect() Default Args Interop - from Swift Somewhere in Swift

    Missing argument for parameter ‘retries’ in call
  34. @DefaultArgumentInterop.Enabled fun connect(retries: Int = 2) { … } SKIE

    Default Args Interop - The Investigation 󰡸 Somewhere in Kotlin
  35. public func connect() { return ComposeApp.PlatformKt.connect() } public func connect(retries:

    Swift.Int32) { return ComposeApp.PlatformKt.connect(retries: retries) } SKIE Default Args Interop - The Investigation 󰡸 Somewhere in Swift
  36. ✅ Some features work exactly as expected ✏ Some features

    need a slight modification 🤝 Some features need a community solution 🛑 Some features don’t work
  37. Android with Compose Use LaunchedEffect var rocketLaunches by remember {

    mutableStateOf(emptyList<RocketLaunch>()) } LaunchedEffect(Unit) { rocketLaunches = sdk.getLaunches() } PullToRefreshRocketLaunches( … listRocketLaunch = rocketLaunches, … )
  38. Android with Compose Use LaunchedEffect var rocketLaunches by remember {

    mutableStateOf(emptyList<RocketLaunch>()) } LaunchedEffect(Unit) { rocketLaunches = sdk.getLaunches() } PullToRefreshRocketLaunches( … listRocketLaunch = rocketLaunches, … )
  39. Android with Compose Use LaunchedEffect var rocketLaunches by remember {

    mutableStateOf(emptyList<RocketLaunch>()) } LaunchedEffect(Unit) { rocketLaunches = sdk.getLaunches() } PullToRefreshRocketLaunches( … listRocketLaunch = rocketLaunches, … )
  40. iOS with SwiftUI Approach 1: Completion Handlers sdk.getLaunches(completionHandler: { launches,

    error in if let launches = launches { self.launches = .result(launches) } else { self.launches = .error(error?.localizedDescription ?? "error") } })
  41. iOS with SwiftUI Approach 1: Completion Handlers sdk.getLaunches(completionHandler: { launches,

    error in if let launches = launches { self.launches = .result(launches) } else { self.launches = .error(error?.localizedDescription ?? "error") } })
  42. iOS with SwiftUI Approach 2: async/await @MainActor class ViewModel: ObservableObject

    { … Task { do { let launches = try await sdk.getLaunches() self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }
  43. iOS with SwiftUI Approach 2: async/await @MainActor class ViewModel: ObservableObject

    { … Task { do { let launches = try await sdk.getLaunches() self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }
  44. iOS with SwiftUI Approach 2: async/await @MainActor class ViewModel: ObservableObject

    { … Task { do { let launches = try await sdk.getLaunches() self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }
  45. iOS with SwiftUI Approach 2: async/await @MainActor class ViewModel: ObservableObject

    { … Task { do { let launches = try await sdk.getLaunches() self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }
  46. iOS with SwiftUI Approach 3: KMP-NativeCoroutines class SpaceXSDK () {

    private val api = SpaceXApi() @NativeCoroutines @Throws(Exception::class) suspend fun getLaunches(): List<RocketLaunch> { return api.getAllLaunches() } }
  47. iOS with SwiftUI Approach 3: KMP-NativeCoroutines @MainActor class ViewModel: ObservableObject

    { … let task = Task { do { let launches = try await asyncFunction(for: sdk.getLaunches()) self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } task.cancel() } }
  48. iOS with SwiftUI Approach 3: KMP-NativeCoroutines @MainActor class ViewModel: ObservableObject

    { … let task = Task { do { let launches = try await asyncFunction(for: sdk.getLaunches()) self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } task.cancel() } }
  49. iOS with SwiftUI Approach 4: SKIE class SpaceXSDK () {

    private val api = SpaceXApi() @Throws(Exception::class) suspend fun getLaunches(): List<RocketLaunch> { return api.getAllLaunches() } }
  50. iOS with SwiftUI Approach 4: SKIE @MainActor class ViewModel: ObservableObject

    { … let task = Task { do { let launches = try await sdk.getLaunches() self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } task.cancel() } }
  51. The problem with Flows on iOS • Objective-C does not

    support generics on protocols • So, flow interfaces lose their generic value type • Still need cancellation support and main thread safety
  52. iOS with SwiftUI Solution 1: KMP-NativeCoroutines @MainActor class ViewModel: ObservableObject

    { @Published var launches = LoadableLaunches.loading … func startObserving(){ self.launches = .loading Task { do { let sequence = asyncSequence(for: self.sdk.getLaunches()) for try await data in sequence { self.launches = .result(data) } } catch { print("Failed with error: \(error)") self.launches = .error("Error") } }
  53. iOS with SwiftUI Solution 1: KMP-NativeCoroutines @MainActor class ViewModel: ObservableObject

    { @Published var launches = LoadableLaunches.loading … func startObserving(){ self.launches = .loading Task { do { let sequence = asyncSequence(for: self.sdk.getLaunches()) for try await data in sequence { self.launches = .result(data) } } catch { print("Failed with error: \(error)") self.launches = .error("Error") } }
  54. class ViewModel: ObservableObject { @Published var launches = LoadableLaunches.loading …

    func startObserving(){ self.launches = .loading Task { let sequence = self.sdk.getLaunches() for await data in sequence { self.launches = .result(data) } } } } iOS with SwiftUI Solution 2: SKIE Becomes Swift AsyncSequence Doesn’t need @MainActor annotation
  55. KMP-NativeCoroutines • Generates needed wrappers • Requires annotation, function wrappers

    • Supports async/await, Combine, and RxSwift • Available for longer than SKIE, and may encounter fewer edge-cases
  56. SKIE • Augments the Objective-C API produced by the Kotlin

    compiler • Requires no additional work after setup • Supports async/await, while Combine, RxSwift require adapters • Offers other features to produce a Swift-friendly API from Kotlin
  57. iosMain Kotlin/Native androidMain Kotlin/JVM commonMain Common Kotlin import java.util.* actual

    fun randomUUID() = UUID.randomUUID().toString() import platform.Foundation.NSUUID actual fun randomUUID(): String = NSUUID().UUIDString() expect fun randomUUID(): String Expect/actual functions
  58. class AndroidPlatform: Platform class iOSPlatform: Platform interface Platform { val

    name: String } Interfaces iosMain Kotlin/Native androidMain Kotlin/JVM commonMain Common Kotlin
  59. class AndroidPlatform: Platform … actual fun getPlatform() = AndroidPlatform() class

    iOSPlatform: Platform … actual fun getPlatform() = iOSPlatform() interface Platform { val name: String } expect fun getPlatform(): Platform iosMain Kotlin/Native androidMain Kotlin/JVM commonMain Common Kotlin Factory functions
  60. class AndroidPlatform: Platform … class MyApp : Application() { override

    fun onCreate() { super.onCreate() app(AndroidPlatform()) } } class iOSPlatform: Platform … @main struct iOSApp : App { init() { app(IOSPlatform()) } } interface Platform { val name: String } fun app(p: Platform) { // app logic } Early Entry Points iosMain Kotlin/Native androidMain Kotlin/JVM commonMain Common Kotlin
  61. class AndroidPlatform: Platform … actual val platformModule: Module = module

    { single<Platform> { AndroidPlatform() } } class iOSPlatform: Platform … actual val platformModule = module { single<Platform> { IOSPlatform() } } interface Platform { val name: String } expect val platformModule: Module iosMain Kotlin/Native androidMain Kotlin/JVM commonMain Common Kotlin Dependency Injection with Koin