Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
May 24, 2021 05:07 pm GMT

A historical introduction to the Compose reactive state model

Jetpack Compose offers a completely new way to write, and to think about, UI code. One of its key features is that Compose code is reactive, which is to say it automatically updates in response to state changes. What really makes this feature magic, however, is that there is no explicit reactive API.

This post is part of a series that attempts to explain how Compose does this via its snapshot state system. Stay tuned for the sequel!

Background

Some time in the 10 years before this post was written in 2021, RxJava became the de facto standard way to write reactive UI code. You would design your APIs around streams (Observables) and some infrastructure code would glue streams together and provide other wiring like automatic subscription management. Streams could signal events or hold state and notify listeners about changes to that state. Business logic tended to be written as functional transforms on streams (shoutout to flatMap).

RxJava was a major step up from manually implementing the observer pattern by creating your own Listener interfaces and all the related boilerplate. Observables support sophisticated error handling and handle all the messy thread-safety details for you. But not all the grass was greener on the Rx side of the fence. Large apps with many streams can quickly become hard to reason about. APIs were tightly coupled to the reactive libraries, since the only way to express reactivity was to expose stream types.

  • Does this stream emit immediately or do I need to provide an initial value?
  • How do I combine multiple streams in the right way combineLatest, concat, merge, switchMap, oh my.
  • How do I make a mutable property? I cant use a Kotlin property because the getter needs to return a stream, but the setter needs to take a single, non-stream value.
  • If I need to expose multiple state values, do I combine them into a single stream that emits all values at once or expose multiple streams?
  • Do I need to observeOn or am I already on the right thread?
  • How do I integrate all these nice async streams with this one legacy synchronous API?
  • How do I provide both async and sync, or push-based streams and pull-based getter APIs, without almost-duplicating methods (val currentTime: Date vs val times: Observable<Date>)?

Roughly ten years after introducing RxJava into the codebase I work in, @pyricau is still finding code that leaks because its not handling subscriptions just right.

As the industry adopted Kotlin, a lot of codebases started to migrate from RxJava to Flow a similar stream library built around coroutines. Flows solved some of the problems of RxJava structured concurrency is a much safer way to manage subscription logic but a stream is still a stream. While its possible to get into the habit of thinking of everything in terms of streams, its one more layer of conceptual overhead to learn. Its not intuitive to a lot of new developers, and even experienced developers get tripped up regularly. If only there were a better way.

Example

Consider the following hypothetical implementation of a special button:

class Counter {  var value: Int = 0    private set  fun increment() { value++ }}class CounterButton(val counter: Counter) : Button() {  fun initialize() {    this.text = Counter: ${counter.value}    setOnClickListener {      counter.increment()    }  }}

The initialize() function used in the CounterButton is not from either classic Android Views or Compose for the sake of these examples, it is meant to be called by some glue code elsewhere in the app. If thats unsatisfyingly vague, you can imagine it could be called from an init block or onAttachedToWindow. There is another reason for defining a separate function, which Ill explain once we get to the Compose content later in the post.

Can you tell what the programmers intent was? They wanted to make a button that shows the current value of a counter, and when you click the button, the counter is incremented. But this code is very broken. The text is only set once, when the button is initialized, and is never updated. Lets fix that bug:

class CounterButton(val counter: Counter) : Button() {  fun initialize() {    this.text = Counter: ${counter.value}    setOnClickListener {      counter.increment()      this.text = Counter: ${counter.value}    }  }}

Now the text will be updated when the counter is incremented! But lets say we want to decrement the value when the user long-presses on the button.

class Counter {  //   fun decrement() { value-- }}class CounterButton(val counter: Counter) : Button() {  fun initialize() {    this.text = Counter: ${counter.value}    setOnClickListener {      counter.increment()      this.text = Counter: ${counter.value}    }    setOnLongClickListener {      counter.decrement()      this.text = Counter: ${counter.value}    }  }}

This works, but theres some duplication. Following, the Rule of Three, lets factor the text update out:

class CounterButton(val counter: Counter) : Button() {  fun initialize() {    updateText()    setOnClickListener {      counter.increment()      updateText()    }    setOnLongClickListener {      counter.decrement()      updateText()    }  }  private fun updateText() {    this.text = Counter: ${counter.value}  }}

Unfortunately, any time this button gets another feature, the developer still has to remember to call updateText. Ideally wed like to express that the text should be updated whenever the counter value changes. Lets try using RxJava:

class Counter {  private val _value = BehaviorSubject.createDefault(0)  val value: Observable<Int> = _value  fun increment() { _value.value++ }  fun decrement() { _value.value }}class CounterButton(val counter: Counter) : Button() {  fun initialize() {    counter.value.subscribe { value ->      this.text = Counter: $value    }    setOnClickListener {      counter.increment()    }    setOnLongClickListener {      counter.decrement()    }  }}

This looks like it works in testing, but turns out were leaking that subscription to counter.value (which we might only realize after shipping this code). There are many ways to solve this, but since this blog post is supposed to be about Compose and not RxJava, Ill leave that as an exercise for the reader. Weve managed to keep the intent fairly clear, but the Counter class has gained some boilerplate and leaves some open questions: What if we want to add another state value to the counter? Do we combine all the state values into a single stream, or expose multiple streams? Lets try the latter:

class Counter {  private val _value = BehaviorSubject.createDefault(0)  val value: Observable<Int> = _value  fun increment() { _value.value++ }  fun decrement() { _value.value }  private val _label = BehaviorSubject.createDefault()  val label: Observable<String> = _label  fun setLabel(label: String) { _label.value = label }}class CounterButton(val counter: Counter) : Button() {  fun initialize() {    combineLatest(counter.label, counter.value) { label, value ->        Pair(label, value)      }      .subscribe { (label, value) ->         this.text = $label: $value      }    setOnClickListener {      counter.increment()    }    setOnLongClickListener {      counter.decrement()    }  }}

Now theres more boilerplate in CounterButton we had to start using RxJava APIs to combine streams, but this can get messy if there are more than a few streams. And although Ive been specifically referencing RxJava, this problem isnt unique to that particular library any library that implements reactive programming via a stream or subscription-based API has the same issues (Project Reactor, Kotlin Flows, etc.). It looks like Android developers are doomed to spend the rest of their days tying streams in knots.

A better way

Compose introduces a mechanism for managing state that eliminates the vast majority of boilerplate. Lets update the above sample to take advantage of it:

class Counter {  var value: Int by mutableStateOf(0)    private set  fun increment() { value++ }  fun decrement() { value }  var label: String by mutableStateOf()}class CounterButton(val counter: Counter) : Button() {  fun initialize() {    this.text = ${counter.label}: ${counter.value}    setOnClickListener {      counter.increment()    }    setOnLongClickListener {      counter.decrement()    }  }}

This looks a lot more like the code we started with! The only difference is the introduction of mutableStateOf, which effectively makes the counters properties observable. State values that are managed by things like mutableStateOf are generally referred to as snapshot state, for reasons that I will get into later. There are various types of state that all behave similarly, including mutableStateListOf and friends, so I will use the term snapshot state to refer to this set of concepts.

You may have heard that Compose makes use of a compiler plugin. That is true, however none of the snapshot state infrastructure described here relies on that plugin. Its all done with regular, vanilla Kotlin.

Snapshot state: Observation

Readers familiar with Compose might point out that widgets in Compose arent classes, theyre functions, and none of this looks very Compose-y at all. They would be right, but this highlights a great design feature of Compose: the state management infrastructure is completely decoupled from the rest of the composable concepts. For example, you could, theoretically, use snapshot state with classic Android Views.

Its important to note that this isnt actually magic, and this code change wouldnt actually work automatically: it assumes that whatever glue code calls initialize supports Composes state management. Adding the wiring to make initialize reactive could be as simple as this:

snapshotFlow { initialize() }  .launchIn(scope)

snapshotFlow creates a Flow that executes a lambda, tracks all the snapshot state values that are read inside the lambda, and then re-executes it any time any of those values are changed. The Compose documentation explains in more detail here. It might not be immediately obvious in such a simple example, but this is a huge improvement over the RxJava approach because the code to wire up initialize only needs to be written once (e.g. in a base class or factory function) and it will automatically work for all code using that infrastructure.

The logic for observing changes to state only needs to exist in shared infrastructure code, not everywhere that wants to read observable values.

The UI code (or whatever other business-specific code youre writing) doesnt need to think about how to observe multiple state values, how to manage subscription lifecycles, or any of that other messy stream stuff. We could factor an interface out of Counter that would declare regular properties, and they would still be observable when backed by snapshot state.

Composable functions already have this implicit observation logic wired up, which is why code like this would just work:

@Composable fun CounterButton(counter: Counter) {  Text(${counter.label}: ${counter.value})}

The Compose compiler wraps the body of this CounterButton function with code that effectively observes any and all MutableStates that happen to be read inside the function.

Snapshot state: Thread safety

Another advantage of using snapshot state is that it makes it much easier and safer to reason about mutable state across threads. If seeing mutable state and thread in the same sentence sets off alarm bells, youve got good instincts. Mutating state across threads is so hard to do well, and the cause of so many hard-to-reproduce bugs, that many programming languages forbid it. Swifts new actor library includes thread isolation, following in the footsteps of actor-based languages like Erlang. Dart (the language used by Flutter) uses separate memory spaces for isolates, its version of threads. Functional languages like Haskell often brag that they are safe for writing parallel code because all data is deeply immutable. Even in Kotlin, the initial memory model for Kotlin Native requires all objects shared between threads to be frozen (i.e. made deeply immutable).

Composes snapshot state mechanism is revolutionary for UI programming in a way because it allows you to work with mutable state in a safe way, across multiple threads, without race conditions. It does this by allowing glue code to control when changes made by one thread are seen by other threads. While not as clear a win as implicit observation, this feature will allow Compose to add parallelism to its execution in the future, without affecting the correctness of code (as long as that code follows the documented best practices, at least).

Conclusion

Jetpack Compose is an incredibly ambitious project that changes many of the ways we think about and write UI code in Kotlin. It allows us to write fully reactive apps with less boilerplate and hopefully less cognitive overhead than weve been able to do in the past. Simple, clear code that is easy to read and understand will (usually) just work as intended. In particular, Compose makes mutable state not be scary anymore. I expect this will have a very positive impact on the general quality of Android apps since there are fewer opportunities for hard-to-troubleshoot classes of bugs, and complex behavior is easy to get right.

Please let me know what you thought in the comments! I know there are questions I havent answered.

Digging deeper

This post hopefully demonstrated the practical and ergonomic advantages to Composes state model, and maybe even sparked some new questions: How the heck does all this stuff actually work? The answer to that question deserves its own blog post, so stay tuned for a follow-up!

On the other hand, if youre just trying to figure out how to use these APIs in your UI code, you might find my cheat sheet on remember { mutableStateOf() } useful.

Huge thanks to Mark Murphy and @jossiwolf for helping review and edit this post!


Original Link: https://dev.to/zachklipp/a-historical-introduction-to-the-compose-reactive-state-model-19j8

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To