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

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. • Directly impact captains livelihood • Captains that can’t work

    are unhappy • Mistakes could negatively affect captain earnings Captain Apps
  2. • 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
  3. Origins • Android only • Code was not well written

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

    unknowns, especially on iOS • Risk mitigation
  7. 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
  8. 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
  9. 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
  10. Communicating Changes • Require an RFC for larger features •

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

    • be mindful of goals of engineers • empower engineers to learn and grow
  12. 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
  13. 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
  14. package com.careem.captain expect class Date() { val current: Long }

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

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

    • implementation may not be “pure” Kotlin (ex alloc, etc) • Dependency Injection
  18. 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 } // ... }
  19. 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 } }
  20. class SampleCommand(private val bookingConfigurations: BookingConfigurations) { fun execute() { if

    (bookingConfigurations.isPoolingEnabled()) { // do something } } }
  21. 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
  22. private var state: S by Delegates.observable(initialState) { _, old, new

    -> bookingStateListener.onStateChanged(StateChange(old, new)) }
  23. @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) } }
  24. @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) } }
  25. Architecture • Android Library was initially built as MVI •

    subscribe to an Observable<State> • call methods on the library to make changes
  26. 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 }) ) }
  27. 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 }) ) }
  28. 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 }) ) }
  29. Architecture • Android Library was initially built as MVI •

    iOS devs wanted calls with results instead
  30. 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
  31. 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() }
  32. 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() }
  33. 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() }
  34. 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
  35. 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.
  36. 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.
  37. 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
  38. 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.
  39. 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
  40. iosArm64("iosArm64").binaries { framework { baseName = "base" export project(":local") export

    "com.careem.captain:dep:1.0.0" freeCompilerArgs += "-Xobjc-generics" transitiveExport = true } }
  41. 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.
  42. 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.
  43. Lost in Translation • inter-op is with Objective-C instead of

    Swift • default parameter values • copy methods from data classes • generics
  44. __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;
  45. func test() { let currentCarNode = CarNode.init().getCarNode() let currentCar =

    currentCarNode.value as! Car print(currentCar.model) }
  46. __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;
  47. __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;
  48. 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) }
  49. 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
  50. Multithreading • Coroutines are single threaded on iOS - but

    not for long. • Other options exist (Workers, etc) • Opted to multithread with delegation and interfaces
  51. 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) } } }
  52. 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) } } }
  53. Multithreading • Access to all mutable objects needs to be

    single threaded • object creation • using the object • releasing the object
  54. 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
  55. Summary • Avoid Android Driven Development • Communication is everything

    • Give a sense of ownership • Encourage iOS participation • Treat developers as customers
  56. 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
  57. 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
  58. 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.