Your Multiplatform Captain has Arrived

F60e42d94f99f029b590206076dbd354?s=47 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.

F60e42d94f99f029b590206076dbd354?s=128

Ahmed El-Helw

December 05, 2019
Tweet

Transcript

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

  2. • Trip Management • Metering • Synchronizing with Backend Captain

    Apps
  3. • Directly impact captains livelihood • Captains that can’t work

    are unhappy • Mistakes could negatively affect captain earnings Captain Apps
  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
  5. Origins

  6. Origins • Android only • Code was not well written

    • Modifying code was very difficult • Production issues • Heavy reliance on manual testing
  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
  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
  9. Kotlin Multiplatform • Management skepticism and excitement • Lots of

    unknowns, especially on iOS • Risk mitigation
  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
  11. Lessons learned from a Multiplatform Journey

  12. Avoid Android Driven Development

  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
  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
  15. Communicating Changes • Require an RFC for larger features •

    One review per platform • Change log with explicit versioned changes
  16. Considerations • iOS developers signed up to work on iOS

    • be mindful of goals of engineers • empower engineers to learn and grow
  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
  18. Code Sharing

  19. What to Share • Business logic and rules • Troubleshooting

    and bug fixing • Tooling
  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
  21. Dependencies • Expect/Actual

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

    commonMain/kotlin/com/careem/captain/Date.kt
  23. package com.careem.captain actual class Date actual constructor() { actual val

    current: Long = System.currentTimeMillis() } jvmMain/kotlin/com/careem/captain/Date.kt
  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
  25. Dependencies • Expect/Actual • iOS implementation must be in Kotlin

    • implementation may not be “pure” Kotlin (ex alloc, etc) • Dependency Injection
  26. interface BookingConfigurations { fun isPoolingEnabled(): Boolean fun shouldAcknowledgeAssignments(): Boolean //

    ... }
  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 } // ... }
  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 } }
  29. class SampleCommand(private val bookingConfigurations: BookingConfigurations) { fun execute() { if

    (bookingConfigurations.isPoolingEnabled()) { // do something } } }
  30. Architectural Considerations

  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
  32. Architecture • Android Library was initially built as MVI •

    subscribe to an Observable<State>
  33. private var state: S by Delegates.observable(initialState) { _, old, new

    -> bookingStateListener.onStateChanged(StateChange(old, new)) }
  34. @Singleton class BookingStateListener @Inject constructor() : CMEBookingStateListener { private val

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

    internalSubject: Subject<BookingState> = BehaviorSubject.create() val bookingStateStream: Observable<BookingState> = internalSubject.hide() override fun onStateChanged(state: BookingState) { internalSubject.onNext(state) } }
  36. private let _state = ReplaySubject<CMEBookingState>.create(bufferSize: 1) func onStateChanged(state newState: CMEBookingState)

    { currentState = newState _state.onNext(newState) }
  37. Architecture • Android Library was initially built as MVI •

    subscribe to an Observable<State> • call methods on the library to make changes
  38. interface BookingStore { fun onBookingOfferAccepted(bookingId: Long) fun bookingOfferExpired(bookingId: Long) fun

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

  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 }) ) }
  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 }) ) }
  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 }) ) }
  43. Architecture • Android Library was initially built as MVI •

    iOS devs wanted calls with results instead
  44. func accept(offer: Offer) -> Booking

  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
  46. public func accept(offer: Offer) -> Single<Booking> { 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() }
  47. public func accept(offer: Offer) -> Single<Booking> { 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() }
  48. public func accept(offer: Offer) -> Single<Booking> { 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() }
  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
  50. Platform Differences

  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.
  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.
  53. Code Organization

  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
  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.
  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
  57. iosArm64("iosArm64").binaries { framework { baseName = "base" export project(":local") export

    "com.careem.captain:dep:1.0.0" freeCompilerArgs += "-Xobjc-generics" transitiveExport = true } }
  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.
  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.
  60. Building for iOS

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

    Swift • default parameter values • copy methods from data classes • generics
  62. Things are getting Better!

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

  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;
  65. let node = Node<NSString>(value: "hello!", next: nil)

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

    S")) }
  67. func test() { let currentCarNode = CarNode.init().getCarNode() let currentCar =

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

    currentCarNode.value as! Car print(currentCar.model) }
  69. kotlin { iosX64 { binaries { framework { freeCompilerArgs +=

    "-Xobjc-generics" } } } }
  70. __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("Node"))) @interface EverythingNode<T> : KotlinBase - (instancetype)initWithValue:(T _Nullable)value next:(EverythingNode<T>

    * _Nullable)next __attribute__((swift_name("init(value:next:)"))) __attribute__((objc_designated_initializer)); // more declarations here @end;
  71. let node = Node<NSString>(value: "hello!", next: nil) let length =

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

    node.value?.length
  73. data class Node<T : Any>(val value: T, val next: Node<T>?

    = null)
  74. __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("Node"))) @interface EverythingNode<T> : KotlinBase - (instancetype)initWithValue:(T)value next:(EverythingNode<T> *

    _Nullable)next __attribute__((swift_name("init(value:next:)"))) __attribute__((objc_designated_initializer)); // more declarations here @end;
  75. func test() { let node = Node<NSString>(value: "hello!", next: nil)

    let length = node.value.length print(length) let currentCarNode = CarNode.init().getCarNode() let currentCar = currentCarNode.value print(currentCar.model) }
  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
  77. Multithreading • Coroutines are single threaded on iOS - but

    not for long. • Other options exist (Workers, etc) • Opted to multithread with delegation and interfaces
  78. interface BookingInteractor { fun handleBooking(bookingId: Long, callback: (Error?) -> Unit)

    }
  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) } } }
  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) } } }
  81. Multithreading • Access to all mutable objects needs to be

    single threaded • object creation • using the object • releasing the object
  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
  83. Summary

  84. Summary • Avoid Android Driven Development • Communication is everything

    • Give a sense of ownership • Encourage iOS participation • Treat developers as customers
  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
  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
  87. Final Summary (oh, and btw, we’re hiring!)

  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.
  89. #KotlinConf THANK YOU AND REMEMBER TO VOTE Ahmed El-Helw @ahmedre