Choosing a Reactive Programming Framework for Modern Android Development

Elijah Verdoorn
Algorithm and Blues
14 min readSep 6, 2019

--

Credit: MR.Cole_Photographer

When embarking on the journey of developing a new application, a team must establish the foundational technologies upon which their application will be built. In modern mobile application development, one of these technology areas is the choice of asynchronous programming aids. While a team could implement their own system to handle the challenges of performing work across threads, most developers prefer to rely on an existing, tested library to do the heavy lifting; they are then freed to work on the problems that their domain presents.

In modern Android development, a number of solutions exist for handling this work; the most prevalent are RxJava and Kotlin’s native coroutines library. These two libraries have frequently been presented as competing, such that a team must select one and commit to it. This is a false dichotomy; applications can take advantage of the best of both systems within the same codebase. Still, the development of a new greenfield application presents an opportunity to create a system on which that team can standardize as their default solution for most problems. Exceptional cases may exist that encourage (or necessitate) the usage of a solution that deviates from the team’s default choice, but having a go-to library enables the development of the team’s skills with that library.

Choosing between defaulting to RxJava and Kotlin’s coroutines requires research and understanding of each. Here I present a summary of the features provided by Kotlin’s Coroutines as well as RxJava for consideration by teams currently weighing these two options. Note that other options are certainly available: Reactor and Reactive Streams both come to mind as alternative, viable possibilities.

Kotlin

Background

It is critical to understand the conceptual difference between cold and hot streams before examining Kotlin’s various asynchronous language features.

Cold Streams can be thought of as blueprints for computation that will emit a stream of values — the structure is defined and prepared, but will not be executed and emissions on the stream will not be made until downstream consumers signal to the producer that they are ready to receive data. Familiar examples of cold streams for many Android developers are RxJava’s Flowable and Observable stream types. Kotlin provides support (in an upcoming release) for cold streams via the Flow system.

Hot Streams refer to streams of values that will emit regardless of the state of their subscribers. No consideration is given to how consumers of the emitted data handle the emissions; the stream will emit values even if there are no downstream processors of those emissions. Note that since the hot stream will emit values regardless of the state of a consumer, the values are therefore only processed once. The Kotlin language supports hot streams through the Channel API.

Additionally important to the understanding of asynchronous, reactive programming in Kotlin is the concept of Coroutines. Coroutines represent the basis upon which both the Channel and Flow APIs are implemented, as well as providing a simple solution for developers to execute one-time operations. Android developers familiar with the usage of AsyncTask or RxJava’s Single, Completable, and Maybe stream types will find themselves with a good basis for understanding the common use cases of coroutines.

Stability

The Kotlin programming language is evolving, and many of the asynchronous programming features and APIs are beginning to enter the stable and released states. This page of documentation provides an overview of the state of various language features. Specifically note that the coroutines API entered status FS (fully stable) as of the 1.3 release. The release of the companion library kotlinx.coroutines version 1.3.0 sees the flow API also enter stable state; this breeds confidence in adopting the library.

Coroutines

Kotlin’s Coroutines are the foundation upon which both Channels and Flow are implemented. Coroutines are famously referred to as light-weight threads, meaning that they are executed like threads, but with a significant reduction in the overhead cost of instantiation and additional savings in the scheduling of their execution. Coroutines provide support for interfacing with traditional function structure through the suspend keyword, indicating that the contents of said function will allow for the suspension of execution — during this suspension, other coroutines can be executed, making more optimal use of all the computational resources available. This suspension is a critical component that allows for the idea of structured concurrency, in which developers can define scopes under which coroutines are executed. Such scopes bind the execution of their coroutines, thus mitigating the risk of memory leakage from not handling the cancelation and completion of a coroutine.

  • All coroutines execute under a CoroutineContext.
  • All contexts contain a CoroutineDispatcher that determines the thread under which the coroutine executes.
  • Kotlin provides a few dispatchers by default, and allows for developers to implement their own dispatchers if necessary.
  • Coroutines launched from within another coroutine inherit the scope of the parent coroutine, and the Job property of the new coroutine is referred to as a child of the parent.
  • Cancelation of a parent coroutine triggers cancelation of all said coroutine’s descendents.
  • This is designed to prevent the leakage of coroutines.
  • Child coroutines must still cooperate in order to be canceled in a reasonable amount of time following a call to cancel their parent. For this reason, checking the isActive extension property that is exposed by the CoroutineScope object within a running coroutine (especially long-running or looping coroutines) is important for developers to remember.
  • Coroutines launched in under the GlobalScope do not have a parent, and operate independently. To prevent memory leakage and allow for task management, usage of the GlobalScope should be avoided where possible.
  • Parent coroutines suspend upon completion, waiting for the completion of their children, without the need for developers to manually manage the waiting mechanism. A parent need not track its children nor explicitly call Job.join() to achieve this behavior. This is advantageous in preventing leaking ongoing work.
  • Coroutines may be named, providing developers with enhanced readability in logging and debugging.
  • The lifecycle of a coroutine is managed by its CoroutineScope. This scope is an abstraction over the aforementioned Context and Job systems.
  • Android developers will commonly find themselves implementing CoroutineScope within the framework provided by the Android activity lifecycle, enabling the execution of coroutines in the implementing Activity without the need to explicitly manage the context, cancelation, or cleanup of said coroutines.
  • Coroutines launched in any CoroutineScope are referred to as children of the launching scope.
  • The parent-child relationship is responsible for establishing patterns of structured concurrency, differentiating the structure of code written under a coroutine model from older parallel implementations.
  • The Structured Concurrency model is the foundation for the establishment of the requirement that coroutines are launched in a specific scope.
  • Coroutines are an ideal solution for the execution of fire-and-forget background work. Android developers familiar with delegation of work to AsyncTasks will find the shift to coroutines saves lines of code and increases both usability and readability without sacrificing performance. Logic that was once implemented by overriding the doInBackground() method can simply be written into a suspend function, then executed on one of the provided background dispatchers.
  • By convention, functions that start a coroutine are defined as extensions on the CoroutineScope interface. This convention makes the coroutine function somewhat self-documenting, as it becomes immediately clear to future developers who may read the code that the contents of that function is meant to be run as a coroutine.

Channels

Channels in Kotlin represent hot streams of data. When applied, they are an effective means of allowing coroutines to communicate multiple values (as opposed to the deferred system, which can communicate only a single value). Creation of a channel is accomplished by implementing the Channel interface, values are emitted to the channel via the send() function and handled via a receive() function. Note that both send() and receive() are suspending functions, making the channel API similar to Java’s familiar BlockingQueue interface while being more idiomatic to the Kotlin language. Additional deviation from BlockingQueue can be seen when observing that channels can be closed, which guarantees that all previous calls to send() have been completed and then prevents future emissions on the channel.

  • One common pattern in channels is the pipeline pattern, where one coroutine is tasked with producing values while other coroutines are processing those values and producing other results. This operates similarly to the RxJava pattern of defining functions that take an Observable as a parameter and returning a modified Observable (or other stream primitive)
  • Channels can have multiple coroutine receivers, as well as multiple coroutine producers. The developers of the API refer to these patterns as Fan-Out and Fan-In.
  • The Fan-Out pattern enables the handling of high-frequency emissions from a single channel across many consumer coroutines, each of which can perform processing in parallel. This handles some of the backpressure-related concerns that are common with hot channels.
  • The Fan-In pattern enables the consolidation of the emissions of multiple coroutines on a single channel. Android developers may be familiar with this pattern from using event-bus solutions like Otto, or perhaps implementations created with RxJava.
  • Channels allow for buffering of events. Developers specify the size of the buffer at the time of Channel creation. This buffer allows the producing coroutine to continue execution until it fills, then suspends the coroutine.
  • Channels are described as fair, in that the first consuming coroutine to execute the receive() function on the channel gets the emission.
  • Channels, like RxJava streams, must be closed when you are finished using them. Attempts to emit on a closed channel will throw ClosedSendChannelException.

Flow

Kotlin Flow is the native reactive programming solution for cold streams provided by the Kotlin language. Built atop the existing coroutines language feature, Flows represent a variation on familiar ideas introduced in the original ReactiveX standard. Currently, the flow API is stable (as of version 1.3.0, currently on RC2) but is not fully released.

A key difference between the Flow system and the more familiar RxJava implementation of reactive programming is the scope of operators provided to the user out-of-the-box. By taking advantage of Kotlin’s extension function API, Flows expose a small number of operators when compared to RxJava. This reduction in API scope means that developers do not have to spend as much time getting familiar with the intricacies of a myriad of operators, rather they are left to implement only the ones that are relevant to their business use case.

  • Operators
  • Given that Kotlin’s Flow system is built in to the language rather than implemented as an external library, many of the provided operators will operate identically to the Sequence operators that developers will be familiar with using.
  • Intermediate Operators
  • Applied to the upstream flow, they return a downstream (cold) flow and are quick to execute.
  • Set up a chain of operations for future execution
  • Examples: map, filter, flatMap
  • Independent Operators
  • Suspending functions
  • Examples: collect, single, reduce, toList
  • launchIn → starts collection of flow in current scope
  • By default, all flows are sequential and bound to a single coroutine, exceptions for structured concurrency exist.
  • Transitions between hot and cold streams are supported via channels and the corresponding API: channelFlow, produceIn, broadcastIn.
  • Creating a Flow
  • flowOf(…) functions to create a flow from a fixed set of values.
  • asFlow() extension functions on various types to convert them into flows.
  • flow { … } builder function to construct arbitrary flows from sequential calls to emit function.
  • channelFlow { … } builder function to construct arbitrary flows from potentially concurrent calls to the send function.
  • Context Preservation
  • There is only one way to change the context of a flow: the flowOn operator that changes the upstream context (“everything above the flowOn operator”).
  • All flow implementations should only emit from the same coroutine
  • Exception Transparency
  • Flow implementations never catch or handle exceptions that occur in downstream flows.
  • Exception handling in flows shall be performed with catch operator and it is designed to only catch exceptions coming from upstream flows while passing all downstream exceptions.
  • Cannot leak a flow subscription, since the concept of subscription doesn’t exist.
  • Closest thing to a subscription is the collect function.
  • Care must still be taken to avoid leaking a coroutine when running them outside of the context of a flow.
  • The usage of GlobalScope is one common indicator that coroutines may be prone to leaking.
  • Coroutines properly scoped to a component of the Android application lifecycle are more leak-proof than their RxJava counterparts, since the framework will handle cancelation rather than developers needing to remember to call dispose() on an instance of Disposable during the destruction of the Android component (usually in onDestroy()).
  • Unification of onError and onComplete
  • Flow is Reactive Streams compliant, you can safely interop it with reactive streams using Flow.asPublisher and Publisher.asFlow from kotlinx-coroutines-reactive module.

RxJava

Background

The popular RxJava library emerged shortly after the concept of Reactive Streams began gaining momentum. It became heralded as a new standard for the way that applications were constructed on the JVM. Now preparing for the release of version 3 of the API, it has gained wide adoption within the Android developer community. Libraries relied upon by major players in the industry commonly expose interfaces that conform to the RxJava specification; some of Google’s internally-developed Android Jetpack suite of libraries have taken up the RxJava banner. This significant adoption has made the usage of the library familiar to most professional Android developers; communication about reactive concepts are frequently written in the context of RxJava.

Unlike Kotlin’s coroutines, RxJava’s streams all conform to the same basic pattern: a stream is declared, then subscribed to following a fluent-API pattern (similar to the builder pattern).

Threading under RxJava’s model is managed by Schedulers. These are an abstraction over concurrency that simplifies the delegation of work and response to various pools of threads. The library exposes a number of pools via a Schedulers utility class; additional pools are added on a per-platform basis. Pools exposed to all platforms include Schedulers.computation(), Schedulers.io(), Schedulers.single(), and Schedulers.trampoline(); Android developers will be interested in looking into the additional pools provided by the AndroidSchedulers utility class.

Stability

The RxJava library has two major versions in a public, released state. Version 1 of the library is currently deprecated, and should not be considered usable. Version 2 of the library is slated to be supported through bug fixes and documentation updates through December 31, 2020. Version 3 of the library is currently the recommend version, with the Release Candidate 2 being the most up-to-date. In the development of new applications, adopting an outdated version of a library that becomes core to the architecture of the application (as RxJava is intended to be) is a mistake, for this reason new applications should make every effort to standardize on version 3 of RxJava, if at all possible.

Flowable

Flowable is the default cold stream type provided by RxJava, allowing for 0 to many emissions on a single stream. It supports the Reactive-Streams standard, and has the capability to handle event emission backpressure.

Observable

Observable, like Flowable, is a cold stream that supports 0 to many emissions, but differs in that it does not support explicit backpressure handling.

Subjects

RxJava exposes a number of Subjects, which are analogous to hot streams — events posted to these subjects will be propagated to any downstream observers, or if there are no consumers the events will simply be lost. A variety of subjects are available to developers, each offering slightly different handling of events — some allow for buffering, replaying, multi-casting.

Single

Single is RxJava’s solution for a stream that emits exactly one item, or an error. Its most direct counterpart in the world of Kotlin would be a suspend function that returns a value (or throws an exception).

Maybe

A significant difference between version 1 and 2 of RxJava was in the handling of nullability. Version 1 of the library allowed for null emissions on all streams, meaning that developers would have to implement null checking (or filtering) manually. To simplify, version 2 of the library does not support null emissions, throwing an exception in cases where a stream attempts to emit null. To address the gap introduced by this change, the Maybe stream type was created, which allows for 0 or 1 emissions, or error. Streams of type Maybe are commonly used for API requests where there exists a chance of request failure.

Completable

The final stream type that developers have access to under RxJava is Completable, which represents a flow with no emissions, rather only a signal that it has completed or has encountered an error. Flows of this type are commonly used to delegate work to background threads for processing, complete long-running computation, or manage fire-and-forget tasks.

Additional Considerations

Integration

Integration with other key libraries is a key factor that must be considered when choosing a reactive framework — making the wrong decision in this regard could potentially preclude a team from using a technology that later becomes industry-standard. To that end, in considering Kotlin and RxJava we observe that the majority of the top libraries and technologies that Android teams are interested in using provide full support for both systems, either natively or via a companion library. It is likely not necessary to base the decision of which reactive framework to adopt on the support provided by third-party libraries.

Many, if not all, Android app architectures going forward have some interest in the establishment of a clean module structure. The structure established by the formation of a module graph informs many aspects of how a modern Android app is created, and even extends to the ability for a development team to embrace new features — dynamic feature modules, for example. Given the focus on modularity and the consequential separation of concerns that follows, developers are increasingly interested in preserving reusability and clean interfaces between modules. One of the longtime best-practices around interface development is to avoid requiring module consumers adopt specific libraries or technologies as prerequisites to using an interface — this gives a distinct advantage to the Kotlin system for asynchrony since it is shipped as a part of the language rather than a third-party library. Standardizing on the Kotlin system for interfaces between distinct modules ensures that consumers (internal or external) will be compatible without forcing additional dependencies downstream.

Human Factors

The relative familiarity of these APIs to the development team working on the project in question is not a trivial consideration. Each of Coroutines and ReactiveX has a significant learning curve; the onboarding overhead of teams to each of the systems will cost productivity. The goal in either case is to maximize the productivity of the team during this time while minimizing frustration. With that in mind, the ecosystem that has developed around each API should be considered as a team works to make a decision.

The RxJava ecosystem has matured over time, with a robust community and numerous books, articles, videos, and other resources in existence. These resources are developed and have gone through many iterations over time to build a stable of commonly-referenced knowledge, accessible to many levels of experience and skill. This significant ecosystem is a formidable point in the corner of RxJava, although teams should consider the lifecycle that exists for community-maintained documentation: with multiple versions of the RxJava library in use across the industry, there exist dangers stemming from following documentation applicable only to specific versions of the library. Taking care to follow only the latest documentation and best practices is critical to success. Since the RxJava library is community-developed and not directly backed by any established force in the Android ecosystem, there exists the possibility that it falls out of favor and ceases to be updated. Such a situation could leave reliant developers in a challenging position of needing to maintain not only their own code, but also individually address concerns that arise with the library itself.

The Kotlin language, and its widespread usage as a programming language for Android are young relative to Java and ReactiveX; this means that there are simply fewer resources available for educating developers on the best practices. As the Android world moves ever more rapidly towards Kotlin-centric development the rate at which documentation is written for Kotlin increases, bolstered by Google’s establishment of Kotlin as a first-class language for Android at Google I/O 2017, then furthered by the announcement of Kotlin-first APIs and systems at the same event in 2019. These developments provide a momentum for Kotlin’s asynchronous APIs that cements their place going forward in the Android ecosystem — developers can be confident that these APIs will continue to be supported going forward; the popular Android Jetpack suite of tools has already published robust interfaces using Kotlin’s systems. All told, both the official documentation and community-created resources that exist around Kotlin’s APIs and tools improve daily, with no sign of slowing.

References

Articles

API Documentation

Videos

--

--