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

Your Multiplatform Captain has Arrived

Ahmed El-Helw
December 05, 2019

Your Multiplatform Captain has Arrived

Talk given at KotlinConf 2019 about what we learned writing and shipping sharing code between iOS and Android using Kotlin Multiplatform at Careem.

Ahmed El-Helw

December 05, 2019
Tweet

More Decks by Ahmed El-Helw

Other Decks in Technology

Transcript

  1. Copenhagen
    Denmark
    YOUR MULTIPLATFORM
    CAPTAIN HAS ARRIVED
    AHMED EL-HELW
    @ahmedre

    View Slide

  2. • Trip Management

    • Metering

    • Synchronizing with
    Backend
    Captain Apps

    View Slide

  3. • Directly impact captains livelihood

    • Captains that can’t work are unhappy

    • Mistakes could negatively affect captain earnings
    Captain Apps

    View Slide

  4. • Over 25k lines of shared code

    • In production on Android for 1 year

    • In production on iOS for 7 months

    • In cities with both apps, ~30% are on iOS

    • 5 developers on iOS team and 11 on Android
    Kotlin Multiplatform

    View Slide

  5. Origins

    View Slide

  6. Origins
    • Android only

    • Code was not well written

    • Modifying code was very difficult

    • Production issues

    • Heavy reliance on manual testing

    View Slide

  7. An In-Place Rewrite
    • Business logic rewrite (in Kotlin)

    • Redux-inspired MVI architecture

    • Support for the existing code base in the meanwhile

    • Fear - Brand new team, highly sensitive code

    View Slide

  8. iOS
    • Business wanted an iOS app for captains

    • Began as a migration of Kotlin to Swift

    • Difficult to keep up with Android / Kotlin code changes

    • Inadvertently implemented bugs

    • Assumptions that weren’t obvious

    • Tedious

    View Slide

  9. Kotlin Multiplatform
    • Management skepticism and excitement

    • Lots of unknowns, especially on iOS

    • Risk mitigation

    View Slide

  10. A Multiplatform Library
    • Copying work from Android app to a new repository

    • Library repository primarily contains common code

    • Worked with iOS devs to set up and test iOS build

    • Continued migrating code into this library

    • Teams began working on UI in parallel behind an interface

    View Slide

  11. Lessons learned from a
    Multiplatform Journey

    View Slide

  12. Avoid Android Driven Development

    View Slide

  13. Problems
    • Chasms between iOS and Android Devs

    • distrust of non-Swift by iOS devs

    • understanding that it’s native helped

    • more belief in it as they saw it in production

    • working in a silo caused problems

    View Slide

  14. Problems
    • Legacy code caused us to maintain a fork

    • updates would go to the local copy first

    • sometimes it would take time to merge to the library

    • frustrating experience for iOS devs using the library

    View Slide

  15. Communicating Changes
    • Require an RFC for larger features

    • One review per platform

    • Change log with explicit versioned changes

    View Slide

  16. Considerations
    • iOS developers signed up to work on iOS

    • be mindful of goals of engineers

    • empower engineers to learn and grow

    View Slide

  17. Lessons
    • Developing a shared library needs a different mindset

    • Needs special awareness when you’re also the customer

    • Your developers are your primary customers

    • Facilitate iOS developers feeling ownership

    View Slide

  18. Code Sharing

    View Slide

  19. What to Share
    • Business logic and rules

    • Troubleshooting and bug fixing

    • Tooling

    View Slide

  20. General Sharing Tips
    • Share what developers on both platforms are comfortable sharing

    • Start at what you can agree on and build trust

    • Study open source libraries and carefully consider before using

    View Slide

  21. Dependencies
    • Expect/Actual

    View Slide

  22. package com.careem.captain
    expect class Date() {
    val current: Long
    }
    commonMain/kotlin/com/careem/captain/Date.kt

    View Slide

  23. package com.careem.captain
    actual class Date actual constructor() {
    actual val current: Long = System.currentTimeMillis()
    }
    jvmMain/kotlin/com/careem/captain/Date.kt

    View Slide

  24. package com.careem.captain
    import platform.Foundation.NSDate
    import platform.Foundation.timeIntervalSince1970
    actual class Date actual constructor () {
    // timeIntervalSince1970 returns seconds
    actual val current: Long
    get() = NSDate().timeIntervalSince1970().toLong() * 1000
    }
    iosX64Main/kotlin/Date.kt

    View Slide

  25. Dependencies
    • Expect/Actual

    • iOS implementation must be in Kotlin

    • implementation may not be “pure” Kotlin (ex alloc, etc)

    • Dependency Injection

    View Slide

  26. interface BookingConfigurations {
    fun isPoolingEnabled(): Boolean
    fun shouldAcknowledgeAssignments(): Boolean
    // ...
    }

    View Slide

  27. class ConfigurationAdapter @Inject constructor(
    private val repository: Repository
    ) : BookingConfigurations {
    override fun isPoolingEnabled(): Boolean {
    return repository.get().isPoolingEnabled
    }
    override fun shouldAcknowledgeAssignments(): Boolean {
    return repository.get().shouldAcknowledgeAssignments
    }
    // ...
    }

    View Slide

  28. class ConfigurationAdapter: BookingConfigurations {
    private let configuration: BookingConfiguration
    init(configuration: BookingConfiguration) {
    self.configuration = configuration
    }
    func getPoolingEnabled() -> Bool {
    return configuration.isPoolingEnabled && can(.pooling)
    }
    func getShouldAcknowledgeAssignments() -> Bool {
    return configuration.shouldAcknowledgeAssignments
    }
    }

    View Slide

  29. class SampleCommand(private val bookingConfigurations: BookingConfigurations) {
    fun execute() {
    if (bookingConfigurations.isPoolingEnabled()) {
    // do something
    }
    }
    }

    View Slide

  30. Architectural Considerations

    View Slide

  31. Architectural Considerations
    • Limit the impact of changes on the SDK layer

    • Library usage restricted to a handful of classes

    • Classes expose alternative interfaces to rest of the code

    • Consider whether or not sharing architectures makes sense

    View Slide

  32. Architecture
    • Android Library was initially built as MVI

    • subscribe to an Observable

    View Slide

  33. private var state: S by Delegates.observable(initialState) { _, old, new ->
    bookingStateListener.onStateChanged(StateChange(old, new))
    }

    View Slide

  34. @Singleton
    class BookingStateListener @Inject constructor() : CMEBookingStateListener {
    private val internalSubject: Subject = BehaviorSubject.create()
    val bookingStateStream: Observable = internalSubject.hide()
    override fun onStateChanged(state: BookingState) {
    internalSubject.onNext(state)
    }
    }

    View Slide

  35. @Singleton
    class BookingStateListener @Inject constructor() : CMEBookingStateListener {
    private val internalSubject: Subject = BehaviorSubject.create()
    val bookingStateStream: Observable = internalSubject.hide()
    override fun onStateChanged(state: BookingState) {
    internalSubject.onNext(state)
    }
    }

    View Slide

  36. private let _state = ReplaySubject.create(bufferSize: 1)
    func onStateChanged(state newState: CMEBookingState) {
    currentState = newState
    _state.onNext(newState)
    }

    View Slide

  37. Architecture
    • Android Library was initially built as MVI

    • subscribe to an Observable
    • call methods on the library to make changes

    View Slide

  38. interface BookingStore {
    fun onBookingOfferAccepted(bookingId: Long)
    fun bookingOfferExpired(bookingId: Long)
    fun onBookingAssignment(booking: Booking)
    }

    View Slide

  39. private fun onBookingOfferAccepted(offer: BookingOffer) {
    bookingStore.onBookingOfferAccepted(offer.bookingInfo.bookingId)
    }

    View Slide

  40. private fun subscribeBookingAssignment() {
    compositeDisposable.add(
    bookingStateManager.bookingStateStream
    // This will only let booking assignments pass
    .distinctUntilChanged(assignmentComparator())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({ bookingState ->
    // handle assignment
    }, { throwable ->
    // handle error
    })
    )
    }

    View Slide

  41. private fun subscribeBookingAssignment() {
    compositeDisposable.add(
    bookingStateManager.bookingStateStream
    // This will only let booking assignments pass
    .distinctUntilChanged(assignmentComparator())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({ bookingState ->
    // handle assignment
    }, { throwable ->
    // handle error
    })
    )
    }

    View Slide

  42. private fun subscribeBookingAssignment() {
    compositeDisposable.add(
    bookingStateManager.bookingStateStream
    // This will only let booking assignments pass
    .distinctUntilChanged(assignmentComparator())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe({ bookingState ->
    // handle assignment
    }, { throwable ->
    // handle error
    })
    )
    }

    View Slide

  43. Architecture
    • Android Library was initially built as MVI

    • iOS devs wanted calls with results instead

    View Slide

  44. func accept(offer: Offer) -> Booking

    View Slide

  45. Architecture
    • Android Library was initially built as MVI

    • iOS devs wanted calls with results instead

    • iOS devs opted to write a layer on the iOS side

    View Slide

  46. public func accept(offer: Offer) -> Single {
    let expired = bookingStateManager.expiredOffers
    .filter { $0.booking.bookingId == offer.bookingInfo.bookingId }
    .map {_ -> Booking in throw BookingError.offerExpired }
    let assigned = bookingStateManager.lastAssignedBooking
    .distinctUntilChanged { $0?.bookingId == $1?.bookingId && $0?.bookingStatus == $1?.bookingStatus }
    .map { cmeBooking -> Booking? in
    return Booking.from(cmeBooking: cmeBooking, with: currency)
    }
    return Observable
    .create { [unowned self] observer in
    self.bookingStore.onBookingOfferAccepted(bookingId: offer.bookingInfo.bookingId)
    observer.onCompleted()
    return Disposables.create()
    }
    .concat(Observable.merge(expired, assigned))
    .take(1)
    .asSingle()
    }

    View Slide

  47. public func accept(offer: Offer) -> Single {
    let expired = bookingStateManager.expiredOffers
    .filter { $0.booking.bookingId == offer.bookingInfo.bookingId }
    .map {_ -> Booking in throw BookingError.offerExpired }
    let assigned = bookingStateManager.lastAssignedBooking
    .distinctUntilChanged { $0?.bookingId == $1?.bookingId && $0?.bookingStatus == $1?.bookingStatus }
    .map { cmeBooking -> Booking? in
    return Booking.from(cmeBooking: cmeBooking, with: currency)
    }
    return Observable
    .create { [unowned self] observer in
    self.bookingStore.onBookingOfferAccepted(bookingId: offer.bookingInfo.bookingId)
    observer.onCompleted()
    return Disposables.create()
    }
    .concat(Observable.merge(expired, assigned))
    .take(1)
    .asSingle()
    }

    View Slide

  48. public func accept(offer: Offer) -> Single {
    let expired = bookingStateManager.expiredOffers
    .filter { $0.booking.bookingId == offer.bookingInfo.bookingId }
    .map {_ -> Booking in throw BookingError.offerExpired }
    let assigned = bookingStateManager.lastAssignedBooking
    .distinctUntilChanged { $0?.bookingId == $1?.bookingId && $0?.bookingStatus == $1?.bookingStatus }
    .map { cmeBooking -> Booking? in
    return Booking.from(cmeBooking: cmeBooking, with: currency)
    }
    return Observable
    .create { [unowned self] observer in
    self.bookingStore.onBookingOfferAccepted(bookingId: offer.bookingInfo.bookingId)
    observer.onCompleted()
    return Disposables.create()
    }
    .concat(Observable.merge(expired, assigned))
    .take(1)
    .asSingle()
    }

    View Slide

  49. Architecture
    • Android Library was initially built as MVI

    • iOS devs wanted calls with results instead

    • iOS devs wrote a layer on the iOS side

    • be intentional about the impact of design decisions on the platforms

    View Slide

  50. Platform Differences

    View Slide

  51. Platform Differences
    • Not all platform features are analogous

    • on Android, you can get fused location or GPS location

    • on iOS, you have a location from CoreLocation, but no control over
    the source of locations.

    View Slide

  52. Things to Consider
    • Platform Infrastructures or Libraries may differ

    • ex on Android, we were using WorkManager and transparently retrying
    tasks.

    • we did not communicate this to the iOS engineers.

    View Slide

  53. Code Organization

    View Slide

  54. Code Organization
    • Gradle modules make a good separation of business logic

    • Helps achieve faster development and testing time

    • internal allows encapsulating logic within a module

    View Slide

  55. Building for iOS
    • On iOS, the target is to have one framework

    • Building a single framework from a repository is straight forward

    • Using submodules to make frameworks from multiple repositories is
    tedious and error prone.

    View Slide

  56. klibs to the Rescue
    • Multiple repositories with multiple modules in each repository

    • Each module builds as an artifact

    • jars for Android

    • klibs per architecture for iOS

    • Push to Artifactory / Sonatype

    View Slide

  57. iosArm64("iosArm64").binaries {
    framework {
    baseName = "base"
    export project(":local")
    export "com.careem.captain:dep:1.0.0"
    freeCompilerArgs += "-Xobjc-generics"
    transitiveExport = true
    }
    }

    View Slide

  58. Notes
    • Must have at least one source file inside the monolithic module.

    • Classes are namespaced with baseName in Objective-C.

    • If it’s not there, it uses the monolith module’s name.

    • In Swift, classes are not namespaced.

    View Slide

  59. Notes
    • Duplicate classes from different modules will have their names changed

    • in Objective-C (ex BaseCore and BaseCore_).

    • in Swift (ex Core and Core_).

    • Export is non-transitive by default. Set transitiveExport to make it
    transitive.

    View Slide

  60. Building for iOS

    View Slide

  61. Lost in Translation
    • inter-op is with Objective-C instead of Swift

    • default parameter values

    • copy methods from data classes

    • generics

    View Slide

  62. Things are getting Better!

    View Slide

  63. data class Node(val value: T, val next: Node? = null)

    View Slide

  64. __attribute__((objc_subclassing_restricted))
    __attribute__((swift_name("Node")))
    @interface EverythingNode : KotlinBase
    - (instancetype)initWithValue:(id _Nullable)value next:(EverythingNode * _Nullable)next
    __attribute__((swift_name("init(value:next:)")))
    __attribute__((objc_designated_initializer));
    // more declarations here
    @end;

    View Slide

  65. let node = Node(value: "hello!", next: nil)

    View Slide

  66. object CarNode {
    fun getCarNode(): Node = Node(Car(2019, "Tesla", "Model S"))
    }

    View Slide

  67. func test() {
    let currentCarNode = CarNode.init().getCarNode()
    let currentCar = currentCarNode.value
    print(currentCar.model)
    }

    View Slide

  68. func test() {
    let currentCarNode = CarNode.init().getCarNode()
    let currentCar = currentCarNode.value as! Car
    print(currentCar.model)
    }

    View Slide

  69. kotlin {
    iosX64 {
    binaries {
    framework {
    freeCompilerArgs += "-Xobjc-generics"
    }
    }
    }
    }

    View Slide

  70. __attribute__((objc_subclassing_restricted))
    __attribute__((swift_name("Node")))
    @interface EverythingNode : KotlinBase
    - (instancetype)initWithValue:(T _Nullable)value next:(EverythingNode * _Nullable)next
    __attribute__((swift_name("init(value:next:)")))
    __attribute__((objc_designated_initializer));
    // more declarations here
    @end;

    View Slide

  71. let node = Node(value: "hello!", next: nil)
    let length = node.value.length

    View Slide

  72. let node = Node(value: "hello!", next: nil)
    let length = node.value?.length

    View Slide

  73. data class Node(val value: T, val next: Node? = null)

    View Slide

  74. __attribute__((objc_subclassing_restricted))
    __attribute__((swift_name("Node")))
    @interface EverythingNode : KotlinBase
    - (instancetype)initWithValue:(T)value next:(EverythingNode * _Nullable)next
    __attribute__((swift_name("init(value:next:)")))
    __attribute__((objc_designated_initializer));
    // more declarations here
    @end;

    View Slide

  75. func test() {
    let node = Node(value: "hello!", next: nil)
    let length = node.value.length
    print(length)
    let currentCarNode = CarNode.init().getCarNode()
    let currentCar = currentCarNode.value
    print(currentCar.model)
    }

    View Slide

  76. Difficulties
    • Debugging between Kotlin/Native and iOS is tough

    • Touchlab Xcode plugin helps (xcode-kotlin)

    • Symbolication only in debug builds

    • experimentally available for release builds in 1.3.60

    • Touchlab’s CrashKiOS

    View Slide

  77. Multithreading
    • Coroutines are single threaded on iOS - but not for long.

    • Other options exist (Workers, etc)

    • Opted to multithread with delegation and interfaces

    View Slide

  78. interface BookingInteractor {
    fun handleBooking(bookingId: Long, callback: (Error?) -> Unit)
    }

    View Slide

  79. extension NetworkAdapter: BookingInteractor {
    func handleBooking(bookingId: Int64, callback: @escaping (KotlinError?) -> Void) {
    let endpoint = BookingRequest(bookingId: bookingId)
    // called on the Kotlin thread
    service.request(endpoint) { result in
    let error: KotlinError?
    if case .failure(let requestError) = result {
    error = KotlinError(message: requestError.localizedDescription)
    } else {
    error = nil
    }
    callback(error)
    }
    }
    }

    View Slide

  80. extension NetworkAdapter: BookingInteractor {
    func handleBooking(bookingId: Int64, callback: @escaping (KotlinError?) -> Void) {
    let endpoint = BookingRequest(bookingId: bookingId)
    // called on the Kotlin thread
    service.request(endpoint) { result in
    let error: KotlinError?
    if case .failure(let requestError) = result {
    error = KotlinError(message: requestError.localizedDescription)
    } else {
    error = nil
    }
    callback(error)
    }
    }
    }

    View Slide

  81. Multithreading
    • Access to all mutable objects needs to be single threaded

    • object creation

    • using the object

    • releasing the object

    View Slide

  82. Things we’re thinking About
    • Our library tests are currently JVM only

    • mockk doesn’t support multiplatform yet

    • What more can we share besides business logic?

    • How do we avoid inheriting technical debt?

    • Potential impact of sharing common tools

    • Multithreaded coroutines

    View Slide

  83. Summary

    View Slide

  84. Summary
    • Avoid Android Driven Development

    • Communication is everything

    • Give a sense of ownership

    • Encourage iOS participation

    • Treat developers as customers

    View Slide

  85. Summary
    • Architect for Success

    • Share what both platforms agree on

    • Limit shared library’s impact on code

    • Be cognizant of architecture potentially influencing the platforms

    View Slide

  86. Other Lessons
    • Multiplatform can be as risky or not-risky as you want

    • Start small and expand from there

    • Build trust

    • With time, increase impact

    • Invest in building and supporting open source libraries

    View Slide

  87. Final Summary
    (oh, and btw, we’re hiring!)

    View Slide

  88. For engineers at Careem,
    multiplatform was just a dream,
    Kotlin multiplatform made it true,
    and it can work for you too!
    We share on Android and iOS,
    our code is clean and not a mess,
    business logic is what we share,
    while showing our iOS devs that we care.
    Most of our code lives in commonMain,
    to keep our dependencies nice and sane,
    dependencies implemented on the platform side,
    we use Dagger on Android with nothing to hide,
    on Swift we roll our own DI,
    I speak the truth and do not lie.
    We build libraries and version them too,
    updating a changelog with whatever is new,
    push them to a maven repo to use,
    and add a tag with nothing to lose.
    klibs are uploaded for iOS,
    jars on Android without a mess,
    the clients use them as you'd expect,
    and best of all, bugs that we detect -
    can be fixed on both platforms with ease,
    saving us half the development fees.
    With multiplatform coroutines coming soon,
    the limits are really beyond the moon,
    so if you haven't considered sharing code,
    it's a good time to get into this thought mode,
    that is all i have to say,
    i hope you enjoyed this talk today.

    View Slide

  89. #KotlinConf
    THANK YOU
    AND
    REMEMBER
    TO VOTE
    Ahmed El-Helw @ahmedre

    View Slide