Skip to content
Muhammed Ali
Kocabey

From RxJava to Kotlin Coroutines: Modern Android Development

Hello, Android developers! I want to share the experiences I had while migrating a project from RxJava to Kotlin Coroutines. Step by step, we'll look at the advantages these two powerful tools offer in asynchronous programming, along with the challenges I ran into.

Table of Contents

  • Why Should We Migrate?
  • Asynchronous Operations with RxJava
  • The Basics of Migrating to Coroutines
  • Managing CoroutineScope and Dispatchers
  • Using Coroutines in the ViewModel
  • Custom Extension Functions: throttleFirst and collectFlow
  • Error Handling
  • Best Practices
  • Resources

Why Should We Migrate?

Asynchronous programming is an indispensable part of modern mobile applications. Many operations — such as network requests, database operations, and user interactions — run in the background, and they must be managed correctly in order to give the user a seamless, smooth experience. RxJava has long been used as a powerful tool in this area. However, its complex structure and learning curve have pushed some developers toward alternative solutions. Kotlin Coroutines, on the other hand, meets this need by offering a simpler and more readable structure.

Advantages of RxJava:

  • Powerful Reactive Programming Capabilities:
  • Offers a flexible structure for managing data streams.
  • A Wide Range of Operators:
  • Provides many operator options for different scenarios.
  • Multiplatform Support:
  • Can be used beyond Android, which increases consistency across projects.

Advantages of Coroutines:

  • Simpler and more readable code
  • Less boilerplate code
  • Native Kotlin support

Considering these advantages, migrating to Kotlin Coroutines can help you build a more maintainable and sustainable codebase.

Asynchronous Operations with RxJava

Managing asynchronous operations with RxJava is quite powerful, but it can sometimes lead to complex and repetitive code structures. In a past project, when I made a network request using RxJava, I used a structure like the following:

abstract class RxUseCase<Response, Params> {    abstract fun execute(params: Params?): Observable<Response>    fun run(observer: DisposableObserver<Response>, params: Params?) {        execute(params)            .subscribeOn(Schedulers.io())            .observeOn(AndroidSchedulers.mainThread())            .subscribeWith(observer)    }}

This class let me make network requests with specific parameters. However, setting up similar dispatcher configurations for every use case caused code duplication and made maintenance harder. This becomes especially noticeable when there are a large number of use cases in the project.

The Basics of Migrating to Coroutines

As a first step, we aim to replace the RxJava-based structure with Kotlin Coroutines. To do this, we'll first create a basic coroutine-based use-case class.

abstract class CoroutineUseCase<Response, Params> {    abstract suspend fun execute(params: Params?): Response    suspend fun run(params: Params?): Response {        return execute(params)    }}

This class contains a function defined with the suspend keyword. Thanks to this, it can be called inside a coroutine and we can manage asynchronous operations more simply.

Key Differences

  • RxJava: Manages asynchronous operations using Observable and DisposableObserver.
  • Coroutines: Offers a simpler and more readable structure using suspend functions and coroutine scope.

With this simple step, we freed ourselves from RxJava's complexity and started to have a cleaner, more understandable code structure.

Managing CoroutineScope and Dispatchers

When working with Kotlin Coroutines, it's important to define the lifecycle of coroutines and which thread they will run on. For this reason, the use of CoroutineScope and Dispatcher plays a critical role.

Using CoroutineScope

CoroutineScope is used to manage the lifecycle of coroutines. In particular, using viewModelScope inside viewModels ensures that coroutines are automatically cancelled according to the ViewModel's lifecycle. This is critically important for preventing memory leaks and using resources efficiently.

abstract class CoroutineRequestUseCase<Response, Params> {    abstract suspend fun execute(params: Params?): Response    fun run(        scope: CoroutineScope,        observer: CoroutineObserver<Response>,        params: Params?    ) {        scope.launch {            try {                val response = withContext(Dispatchers.IO) {                    execute(params)                }                withContext(Dispatchers.Main) {                    observer.onSuccess(response)                }            } catch (e: Exception) {                withContext(Dispatchers.Main) {                    observer.onError(e)                }            }        }    }}

Using Dispatchers

  • Dispatchers.IO: Used for IO-intensive operations such as network calls and disk operations.
  • Dispatchers.Main: Used for UI updates.
  • withContext: Used to specify the pieces of code that need to run on a specific dispatcher.

This structure optimizes performance by ensuring that coroutines run on the correct thread.

Using Coroutines in the ViewModel

ViewModels are important components in Android's MVVM architecture that handle data management and interaction with the UI. Using viewModelScope to manage the lifecycle of coroutines in relation to the ViewModel is one of the best approaches.

Restructuring the UseCase Class

Let's restructure our UseCase class so that it takes a CoroutineScope parameter. This way, we can manage the lifecycle of coroutines in relation to the ViewModel.

class MyViewModel : ViewModel() {    private val useCase = MyUseCase()    fun performNetworkRequest(params: Params) {        useCase.run(            scope = viewModelScope,            observer = object : CoroutineObserver<Response> {                override fun onSuccess(response: Response) {                    // Handle successful result                }                override fun onError(e: Exception) {                    // Handle error                }            },            params = params        )    }}

With this approach:

  • Dispatcher Usage:
  • Network calls run on Dispatchers.IO, while UI updates are done on Dispatchers.Main.
  • Scope Management:
  • viewModelScope automatically cancels coroutines according to the ViewModel's lifecycle, which prevents memory leaks.
  • Clean Code:
  • Because coroutine management is handled inside the ViewModel, the UseCase class focuses only on business logic.

Custom Extension Functions: throttleFirst and collectFlow

In my projects, I developed custom extension functions to overcome some of the challenges I faced during the migration from RxJava to Coroutines. These functions were created to make using coroutines and Flow even easier and to reduce code duplication.

The throttleFirst Operator

throttleFirst is a Flow operator that, among high-frequency events, forwards only the first one. This operator emits the first value that arrives within a given time window (windowDurationMillis) and ignores subsequent values during that period. This is ideal for managing rapidly repeating events (for example, button clicks).

fun <T> Flow<T>.throttleFirst(windowDurationMillis: Long): Flow<T> = channelFlow {    val mutex = Mutex()    var lastEmitTime = 0L    collect { value ->        val currentTime = System.currentTimeMillis()        mutex.withLock {            if (currentTime - lastEmitTime >= windowDurationMillis) {                lastEmitTime = currentTime                send(value)            }        }    }}

Example Usage

lifecycleScope.launch {    repeatOnLifecycle(Lifecycle.State.STARTED) {        viewModel.eventFlow            .throttleFirst(1000)            .collectLatest { event ->                handleEvent(event)            }    }}

Advantages

  • Prevents Event Flooding: By processing only the first event within a given time window, it prevents rapidly repeating events (e.g., multiple button clicks) from being processed.
  • Improves the User Experience: It prevents unwanted side effects (e.g., a fragment or dialog opening multiple times) when the user quickly taps a button several times.
  • Ease of Use: Instead of managing repetitions manually, UI issues can be prevented by using the throttleFirst operator.

Things to Watch Out For

  • Delays Can Negatively Affect the User Experience: Setting the throttle duration too short or too long may both fail to deliver the desired experience, so it may be a good idea to settle on a standard value such as 300L.
  • Using It with StateFlow: Because of StateFlow's continuously updating nature, problems can arise if you don't pay attention to the throttle duration.

The collectFlow Operator

collectFlow is a custom extension function I developed to collect Flows in a lifecycle-aware manner. This function collects the most recently emitted value, and specific operators (throttle, debounce, etc.) can be applied to the Flow.

fun <T> LifecycleOwner.collectFlow(    flow: Flow<T>,    state: Lifecycle.State = Lifecycle.State.STARTED,    flowOperator: (Flow<T>) -> Flow<T> = { it },    collector: suspend (T) -> Unit) {    val collectedFlow = flowOperator(flow)    lifecycleScope.launch {        repeatOnLifecycle(state) {            collectedFlow.collect { collector(it) }        }    }}

Example Usage

private fun observeFlow() {    viewModel.run {        collectFlow(            flow = formDataFlow,            collector = { formData ->                binding.formListView.submitFormData(formData, listOf(0, formData.size - 1))            }        )        collectFlow(            flow = navigateBackSharedFlow,            flowOperator = { it.throttleFirst(DEFAULT_THROTTLE_TIME) },            collector = { finish() }        )    }}

The collectLatestFlow Operator

collectLatestFlow is another extension function I developed to collect Flows in a lifecycle-aware manner. This function collects the most recently emitted value, and specific operators can be applied to the Flow.

fun <T> LifecycleOwner.collectLatestFlow(    flow: Flow<T>,    state: Lifecycle.State = Lifecycle.State.STARTED,    flowOperator: (Flow<T>) -> Flow<T> = { it },    collector: suspend (T) -> Unit) {    val collectedFlow = flowOperator(flow)    lifecycleScope.launch {        repeatOnLifecycle(state) {            collectedFlow.collectLatest { collector(it) }        }    }}

Example Usage

private fun observeFlow() {    viewModel.run {        collectLatestFlow(            flow = formDataFlow,            collector = { formData ->                binding.formListView.submitFormData(formData, listOf(0, formData.size - 1))            }        )        collectLatestFlow(            flow = navigateBackSharedFlow,            flowOperator = { it.throttleFirst(DEFAULT_THROTTLE_TIME) },            collector = { finish() }        )    }}

Advantages

  • Lifecycle Awareness: It ensures Flows are collected in a lifecycle-aware way, thereby preventing unnecessary resource consumption.
  • Flexibility: Thanks to the flowOperator parameter, additional operators can be applied to the Flow.
  • Reduces Code Duplication: It combines lifecycle management and Flow operations into a single function. This way, you don't have to repeat the same code structure in every Activity or Fragment.

Use Cases

  • Button Clicks: Managing button clicks that are triggered at high frequency/speed.
  • Navigation Events: Preventing the user from triggering navigation structures multiple times with very fast reactions.
  • Form Inputs: Preventing the user from triggering form structures multiple times with very fast reactions.

Error Handling

Correctly handling errors in asynchronous operations is critically important for your application's reliability. Kotlin Coroutines provides centralized error handling through CoroutineExceptionHandler.

Using CoroutineExceptionHandler

CoroutineExceptionHandler lets you centrally manage unexpected errors that may occur inside a coroutine. This way, you don't have to implement separate error handling for each coroutine.

Integrating the Exception Handler into the UseCase Class

abstract class CoroutineRequestUseCase<Response, Params> {    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->        // Centralized error handling        Log.e("CoroutineException", "Unhandled exception: ${throwable.localizedMessage}", throwable)        // If needed, an error reporting system (Timber, etc.) can be integrated    }    abstract suspend fun execute(params: Params?): Response    fun run(        scope: CoroutineScope,        observer: CoroutineObserver<Response>,        params: Params?    ) {        scope.launch(exceptionHandler) {            try {                val response = withContext(Dispatchers.IO) {                    execute(params)                }                withContext(Dispatchers.Main) {                    observer.onSuccess(response)                }            } catch (e: Exception) {                withContext(Dispatchers.Main) {                    observer.onError(e)                }            }        }    }}

The Importance of Error Handling

  • Centralized Management: Handling errors in one central place eliminates the need to implement separate error handling for each coroutine.
  • Code Cleanliness: Gathering error handling in a single place improves the readability of the code.
  • Reliability: You can prevent your application from crashing unexpectedly and relay errors to the user or to the logs.

Best Practices

When working with Kotlin Coroutines, it's important to follow some best practices to keep your code clean, maintainable, and performant.

1. CoroutineScope Management

  • Using viewModelScope:
  • Tie the lifecycle of coroutines to the ViewModel.
  • viewModelScope automatically cancels coroutines when the ViewModel is destroyed.
  • Don't Create Extra Scopes:
  • In particular, using existing scopes instead of creating an additional CoroutineScope simplifies your code and makes resource management easier.

2. Using Dispatchers

  • Dispatchers.IO: Use it for network calls and IO operations.
  • Dispatchers.Main: Use it for UI updates.
  • Using withContext: Use withContext to specify the pieces of code that need to run on a specific dispatcher.

3. Error Handling

  • CoroutineExceptionHandler: Use it to centrally manage uncaught exceptions.
  • try-catch Blocks: Catch expected errors and give the user meaningful feedback.
  • Errors Within a Flow: If you're using Flow, manage errors within the stream using the catch operator.

4. Dependency Injection (DI)

  • Use DI Tools: Inject dependencies such as CoroutineScope and Dispatcher using DI tools (for example, Hilt or Dagger).
  • Testability: By using DI, you can replace coroutines and dispatchers with mocks or stubs in your tests.

5. Clean Architecture Practices

  • Design UseCases as Suspend Functions: Keep your business logic clean and independent.
  • Handle Coroutine Management in the Upper Layers: Clarify the responsibilities of your code by handling coroutine and dispatcher management in upper layers such as the ViewModel.

6. Testability

  • Coroutine Test Libraries: Test your coroutines using the kotlinx-coroutines-test library.
  • Using Mocks and Stubs: Test different scenarios by replacing dependencies with mock objects.

Conclusion

Kotlin Coroutines offers a powerful and flexible asynchronous programming tool for modern Android development. When migrating from RxJava to Coroutines, you can take advantage of the simplicity and readability that coroutines provide. With correct scope management, dispatcher usage, and error handling, you can make your code cleaner, more maintainable, and more testable.

In this article, I will share, step by step, the challenges I faced while migrating from RxJava to Coroutines and how I overcame them. By applying these approaches in your project, you can have a more modern and performant codebase. Remember, every project and team is different; therefore, adapt your approaches to your own needs in order to get the best result.

Happy coding!

Additional Notes and Tips

  • Code Review and Refactoring:
  • Refactor your code step by step during the migration process.
  • Catch bugs at an early stage by doing code reviews after each change.
  • Documentation and Standards:
  • Create documentation about coroutine usage within the project.
  • Establish code standards so that all team members adopt the same approach.
  • Performance Monitoring:
  • Monitor the performance of your coroutines to identify potential bottlenecks.
  • Make optimizations related to Dispatchers usage and scope management.

These recommendations and steps will help you overcome the challenges you may encounter during the migration from RxJava to Kotlin Coroutines and carry out a successful transition. By applying each step carefully, you can take advantage of all the benefits that asynchronous programming offers.

Resources

View on Medium

WORK TOGETHER

Let’s start with a good problem.

Looking for an Android engineer for your team, or someone to think through a technical problem with? Let’s talk.

Book a 30-minute conversationAll contact and podcast links