Bye bye RxJava: Building flexible SDKs with Kotlin and Coroutines

Bye bye RxJava: Building flexible SDKs with Kotlin and Coroutines

We've made some mistakes building our client-facing SDKs over the last two years at Babylon Health.

RxJava has been around in Android development for years, and we've learned a great deal about it. It's a powerful tool, but with great power comes great responsibility. And really - you should always use the right tool for the job, not the most "powerful" one. Now we have a new tool in our toolbox - Coroutines - and they seem better suited to a typical Android business layer. It doesn't mean RxJava should be entirely out of the window, as for us it's still quite useful on the UI layer.

We're here to share why and how we went from a heavily RxJava based SDK architecture to a more client and developer-friendly approach replacing most of our code with Kotlin & Coroutines and compare both solutions.

We will admit to mistakes, share the principles that guided us in our planning for fixing them and show you how we came up with a plan and executed it.

4373b656c719a36afb681c5beaf0ef9b?s=128

Mikolaj Leszczynski

September 09, 2019
Tweet

Transcript

  1. Bye bye RxJava Building flexible SDKs with Kotlin and Coroutines

  2. Our journey with reactive streams

  3. Step 1 - MVP UI Network stream Presenter commands actions

  4. Step 2 - clean architecture UI Network stream Presenter commands

    actions Repository Use case stream stream
  5. Step 3 - SDKs UI Network callbacks Presenter commands actions

    Repository Use case stream stream SDK
  6. SDK Step 4 - fully reactive (experiment*) UI Network request


    stream MVI state stream action stream Repository Use case response
 stream stream response
 stream request
 stream
  7. It worked nicely…

  8. … on a small scale

  9. None
  10. SDK design issues

  11. Streaming is overused

  12. It started with the network

  13. Network ops interface MessageService { @GET(“messages") fun getMessages(): Observable<List<Message >>

    }
  14. Network ops interface MessageService { @GET(“messages") fun getMessages(): Observable<List<Message >>

    }
  15. Network ops interface MessageService { @GET(“messages") fun getMessages(): Single<List<Message >>

    }
  16. HTTP network calls are not streams

  17. Rx mostly provided easy background networking

  18. Rx and Callback APIs

  19. Callback APIs data class GetPatientRequest(val patientId: String) interface GetPatientOutput {

    fun onGetPatientSuccess(patient: Patient) } interface BabylonUserApi { fun getPatient( request: GetPatientRequest, output: GetPatientOutput ): Disposable }
  20. Rx APIs @Experimental interface BabylonRxUserApi { fun getPatient(): ObservableTransformer<GetPatientRequest, GetPatientStatus>

    }
  21. Rx APIs // From RxJava sources public interface ObservableTransformer<Upstream, Downstream>

    { ObservableSource<Downstream> apply(Observable<Upstream> upstream); }
  22. Rx APIs @Experimental interface BabylonRxUserApi { fun getPatient(): ObservableTransformer<GetPatientRequest, GetPatientStatus>

    }
  23. Rx APIs sealed class GetPatientStatus { data class Ready(val patient:

    Patient) : GetPatientStatus() object Loading : GetPatientStatus() data class Error(val throwable: Throwable) : GetPatientStatus() }
  24. Rx APIs internal class GetPatientUseCase ( ... ) : ObservableTransformer<GetPatientRequest,

    GetPatientStatus>() { override fun apply( upstream: Observable<GetPatientRequest> ): Observable<GetPatientStatus> = upstream.switchMap { patientRepository.getPatient(it.patientId) .map { patient -> GetPatientStatus.Ready(patient) } .onErrorReturn { throwable -> GetPatientStatus.Error(throwable) } .startWith(GetPatientStatus.Loading) } }
  25. Rx APIs upstream.switchMap { patientRepository.getPatient(it.patientId) .map { patient -> GetPatientStatus.Ready(patient)

    } .onErrorReturn { throwable -> GetPatientStatus.Error(throwable) } .startWith(GetPatientStatus.Loading) }
  26. Rx APIs upstream.switchMap { patientRepository.getPatient(it.patientId) .map { patient -> GetPatientStatus.Ready(patient)

    } .onErrorReturn { throwable -> GetPatientStatus.Error(throwable) } .startWith(GetPatientStatus.Loading) }
  27. Rx APIs upstream.switchMap { patientRepository.getPatient(it.patientId) .map { patient -> GetPatientStatus.Ready(patient)

    } .onErrorReturn { throwable -> GetPatientStatus.Error(throwable) } .startWith(GetPatientStatus.Loading) }
  28. Overengineering

  29. class GetImportantMessagesUseCase @Inject constructor( private val loadSessionConfigurationUseCase: LoadSessionConfigurationUseCase, private val

    getAppointmentsUseCase: GetAppointmentsUseCase, private val getLoggedInPatientUseCase: GetLoggedInPatientUseCase, private val getNotificationsUseCase: GetNotificationsUseCase, private val isHealthCheckCompletedUseCase: IsHealthCheckCompletedUseCase, private val systemUtil: SystemUtil ) : AuthenticatedUseCase<GetImportantMessagesRequest, GetImportantMessagesStatus>() { override fun applyUseCase(upstream: Observable<GetImportantMessagesRequest>): Observable<GetImportantMessagesStatus> { return upstream.switchMap { Observable.just(GetLoggedInPatientRequest()) .compose(getLoggedInPatientUseCase) .toReadyData() .toObservable() .switchMap { patient -> Observable.combineLatest<LoadSessionConfigurationStatus, GetAppointmentsStatus, GetNotificationsStatus, IsHealthCheckCompletedStatus, GetImportantMessagesStatus>( Observable.just(LoadSessionConfigurationRequest()) .compose(loadSessionConfigurationUseCase), Observable.just(GetAppointmentsRequest(patient.id !!)) .compose(getAppointmentsUseCase), Observable.just(GetNotificationsRequest) .compose(getNotificationsUseCase), Observable.just(IsHealthCheckCompletedRequest) .compose(isHealthCheckCompletedUseCase), Function4 { sessionConfigurationStatus, getAppointmentsStatus, getNotificationsStatus, isHealthCheckCompletedStatus -> when { getAppointmentsStatus is GetAppointmentsStatus.Error -> GetImportantMessagesStatus.Error(getAppointmentsStatus.throwable) sessionConfigurationStatus is LoadSessionConfigurationStatus.Error -> GetImportantMessagesStatus.Error(sessionConfigurationStatus.throwable) getNotificationsStatus is GetNotificationsStatus.Error -> GetImportantMessagesStatus.Error(getNotificationsStatus.throwable) isHealthCheckCompletedStatus is IsHealthCheckCompletedStatus.Error ->
  30. Complexity bubbles upwards

  31. Reactive stream reuse is a double edged sword

  32. “Bad” consistency

  33. Not enough due diligence

  34. value < cost

  35. Reactive streams are powerful and not to be used lightly!

  36. None
  37. None
  38. Lessons learned

  39. Look before you leap

  40. Understand both the value and its cost

  41. Monitor & iterate

  42. Problems snowball with time

  43. Keep It Simple Stupid

  44. Our latest SDK architecture

  45. Guiding principles

  46. KISS

  47. Design for the majority of problems - not the minority

  48. Reserve streams for true streams to avoid ambiguity

  49. Be consistent thoughtfully

  50. Design

  51. SDK Before - Fully reactive SDK UI Network request
 stream

    MVI state stream action stream Repository Use case response
 stream stream response
 stream request
 stream
  52. SDK After - V2 SDK architecture UI Network Call MVI

    State stream action stream Repository Use case
  53. Lower layers as simple as possible

  54. Coroutines

  55. Lower layers as simple as possible internal interface PaymentCardsService {

    @GET(PAYMENT_CARD_ENDPOINT) suspend fun getPaymentCards(): List<PaymentCardModel> } Retrofit 2.6.0+
  56. Lower layers as simple as possible internal interface PaymentCardsRepository {

    suspend fun getPaymentCards(): List<PaymentCard> }
  57. Lower layers as simple as possible class NetworkPaymentCardsRepository( private val

    paymentCardsService: PaymentCardsService ) : PaymentCardsRepository { override suspend fun getPaymentCards(): List<PaymentCard> = paymentCardsService.getPaymentCards() .map { it.toDomainEntity() } }
  58. Topmost layer takes care of high-level decisions

  59. interface PaymentV2Api { fun getPaymentCards(): Call<List<PaymentCard >> }

  60. interface PaymentV2Api { fun getPaymentCards(): Call<List<PaymentCard >> }

  61. interface Call<T> { fun execute(): T fun enqueue(callback: Callback<T>) fun

    cancel() } interface Callback<T> { fun onResult(data: T) fun onException(throwable: Throwable) }
  62. class PaymentV2ApiImpl( private val paymentCardsRepository: PaymentCardsRepository ) : PaymentV2Api {

    override fun getPaymentCards() = wrapCall { paymentCardsRepository.getPaymentCards() } }
  63. class PaymentV2ApiImpl( private val paymentCardsRepository: PaymentCardsRepository ) : PaymentV2Api {

    override fun getPaymentCards() = wrapCall { paymentCardsRepository.getPaymentCards() } }
  64. Before class GetPrescriptionsUseCase ( private val prescriptionsRepository: PrescriptionsRepository, private val

    userAccountsRepository: UserAccountsRepository ) : AuthenticatedUseCase<GetPrescriptionsRequest, GetPrescriptionsStatus>() { override fun applyUseCase(upstream: Observable<GetPrescriptionsRequest>): Observable<GetPrescriptionsStatus> { return upstream.switchMap { request -> getPrescriptions(request) .toObservable() .map<GetPrescriptionsStatus> { GetPrescriptionsStatus.Ready(request.location, it) } .startWith(GetPrescriptionsStatus.Loading) .onErrorReturn { GetPrescriptionsStatus.Error(it) } } } private fun getPrescriptions(request: GetPrescriptionsRequest): Single<List<Prescription >> { return userAccountsRepository.getLoggedInUser() .flatMapObservable { prescriptionsRepository.getPrescriptions(it) } } }
  65. After class GetPrescriptionsUseCase ( private val prescriptionsRepository: PrescriptionsRepository, private val

    userAccountsRepository: UserAccountsRepository ) { suspend operator fun invoke(): List<Prescription> { val userAccount = userAccountsRepository.getLoggedInUser() return prescriptionsRepository.getPrescriptions(userAccount) } }
  66. Extensions provide flexibility

  67. suspend fun <T : Any> Call<T>.asSuspend(): T { ... //

    Coroutine directly returning the result } fun <T : Any> Call<T>.asSingle(): Single<T> { ... // Rx single emitting the result } fun <T : Any> Call<T>.asStatusObservable(): Observable<Status<T >> { ... // Rx stream with updates on the status of the execution }
  68. Hiding implementation details

  69. Notes on rx and coroutine compatibility

  70. fun getSomething(): Single<String> val nonce = getSomething().await()

  71. suspend fun getSomething(): String rxSingle { getSomething() }

  72. suspend fun getSomething(): String rxSingle(Dispatchers.Unconfined) { getSomething() }

  73. Execution

  74. Prototyping

  75. Testing the behaviour

  76. Conventions & howtos

  77. Enforce conventions

  78. Migration

  79. –Dalai Lama “Forget the failures. Keep the lessons.”

  80. Mikolaj Leszczynski @TheAngroid Twitter / Medium Careers: http://tiny.cc/babylon