Blog Infos
Author
Published
Topics
Published
Illustrated with Feature Examples
Introduction

I recently wrote (and updated) a blog post about our product feature that was completely rewritten with Jetpack Compose.

 

After the grinding but also exciting development phase, we have come to a good checkpoint to reflect on what we have been doing and why. I can summarize into 3 reasons why we adopted Compose and think it is the future for Android UI development. I also would like to incorporate the theory into our feature work and compare the resulting code of Compose vs. the old View system.

Thinking in Compose

Many Android engineers started the journey of learning Compose from the article Thinking in Compose from the official developer site. Indeed, the introduction captured 2 essential reasons why Google’s Android team designed the new UI toolkit from the platform perspective. In addition, as an app developer, I also want to call out the 3rd reason why I really enjoy using Compose, from the API users’ perspective.

Let us start with the excerpt from Thinking in ComposeI will offer my interpretation and illustrate with code comparison.

Historically, an Android view hierarchy has been representable as a tree of UI widgets. As the state of the app changes because of things like user interactions, the UI hierarchy needs to be updated to display the current data. The most common way of updating the UI is to walk the tree using functions like findViewById(), and change nodes by calling methods like button.setText(String)container.addChild(View), or img.setImageBitmap(Bitmap). These methods change the internal state of the widget.

Over the last several years, the entire industry has started shifting to a declarative UI model, which greatly simplifies the engineering associated with building and updating user interfaces. The technique works by conceptually regenerating the entire screen from scratch, then applying only the necessary changes. This approach avoids the complexity of manually updating a stateful view hierarchy. Compose is a declarative UI framework.

Reason #1 Declarative DSL vs. Imperative API

Declarative is a buzzword and it may mean different things in different context. But it would make more sense when we compare declarative with imperative programming styles in examples. Then we can see their difference relatively.

In old Android View system, we write UI via inflating view widgets then mutate their internal states by calling getters & setters. Let us called that imperative paradigm because app developer needs to manually control internal states of view widgets.

However, in Jetpack Compose, app developers don’t have direct access to widget objects any more. The underlying UI hierarchy is hidden behind the Compose declarative API. The reason they are called declarative is because the way we call those function APIs reads like describing what we want UI looks and behaves like.

So imperative coding is more about how and declarative is more about what. Because the Compose library exposes only DSL API and encapsulate a lot of underlying heavy-lifting work.

Let us use a feature example to compare the results: we want to build a list of rooms vertically, as shown below:

In View system, we would typically define recycler_view.xml, row_item.xml, RecyclerViewAdapter and binding & config adapter.

<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

recycler_view.xml

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
...>
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/element_text"/>
</FrameLayout>
view raw row_item.xml hosted with ❤ by GitHub

row_item.xml

class CustomAdapter(private val dataSet: Array<String>) : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView
init {
textView = view.findViewById(R.id.textView)
}
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.text_row_item, viewGroup, false)
return ViewHolder(view)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
viewHolder.textView.text = dataSet[position]
}
override fun getItemCount() = dataSet.size
}

RecyclerView.Adapter

val rooms: List<Room> = rooms
val recyclerViewAdapter = CustomAdapter(rooms)
val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recyclerView.adapter = recyclerViewAdapter

Binding Data & Config LayoutManager

 

There are actually multiple reasons that View system would require more coding in general: The forced separation of xml and logic code(will mention in reason #3) would require manual view inflation and view binding(recycler view & row item view) in logic code. Also, app developers have to manually manage data binding and configure Layout. As result, everything adds up.

With Compose, writing code in declarative API would result in:

val items: List<Room> = rooms
val listState = rememberLazyListState()
LazyColumn(state = listState) {
items(items = items) { item -> RoomItem(item) }
}
@Composable
fun RoomItem(item: Room) {
Text(...)
}
view raw LazyColumn.kt hosted with ❤ by GitHub

We describe UI like:

  • Make a Column with items (as data) and lazyListState (as widget state)
  • Compose each item in RoomItem

The amount of code speaks for itself for the efficiency.

Reason #2 True State-Driven Architectures

In View system, application should hold app state in each screen, view widgets also hold their internal states. But in Jetpack Compose, app developers won’t have references to the view objects and won’t manually mutate internal states of them. Instead, we can only build composable functions like this:

@Composable
fun FunctionName(inputState: T) { …}

Note that we annotate the function with @Composable and the function has no return type. By doing that, we are telling Compose-compiler that this function is to convert the input state into a node that is registered in the composition tree. Composition tree is the in-memory representation of UI views that Compose-runtime manages. Composable functions would emit scheduled changes to UI tree nodes. The mental model can be diagrammed something like this:

Composable Function

Job Offers

Job Offers


    Kotlin Multiplatform Mobile Developer

    Touchlab
    Remote
    • Full Time
    apply now

    Sr. Software Development Engineer, Last Mile Driver Assistance Technology

    Amazon
    Berlin
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

, ,

Painless, Typesafe Jetpack Compose Navigation with Voyager

Jetpack Compose Navigation by Google has so many drawbacks like no typesafety, specifying the whole NavGraph at startup and fuzzing around with ids. It could all be so simple: Why not just define screens by…
Watch Video

Painless, Typesafe Jetpack Compose Navigation with Voyager

Alexander Steenbergen
Android Dev Lead
IBM

Painless, Typesafe Jetpack Compose Navigation with Voyager

Alexander Steenber ...
Android Dev Lead
IBM

Painless, Typesafe Jetpack Compose Navigation with Voyager

Alexander Steenb ...
Android Dev Lead
IBM

Jobs

The underlying magic was done by Compose-compiler which would add an implicit parameter, Composer, to the composable function to perform lot of underlying work such as tracking, caching and optimization. It is in similar fashion as adding the implicit parameter, Continuation, to suspend functions in coroutines.

The nature of the architecture eliminated the need for app developers to manage the internal states of the view widgets. Instead, only the state input dictates how UI is rendered. The new architecture yields the following benefits:

  • By eliminating managing internal state of view widgets, it truly delivered the unidirectional data flow: InputState => UI
  • App developers only need to describe what the current state should be and no longer need to worry about the previous state that UI was in and how to transition from one state to another. The library would take care of that.
  • This allows the vast majority of the performance optimization happen in Compose library level, therefore, it alleviates such daunting task from app developers.

Let us use another example to illustrate how the new architecture plays out in real world. The feature is that assuming we already had a Row of rooms in the Revealed state of scaffold and Column of rooms in the Concealed state, we would like the same scrolled position to be transferred from Row to Column and vice versa. As shown below. IOW, the scrolled position is synchronized between Row & Column.

Scroll Position Synchronization

During the transition, we’d like both Row & Column to render on screen with different gradually increased / decreased transparency:

With View system, a typical code skeleton would looks like this:

// Adapter and data set
class CustomAdapter extends RecyclerView.Adapter<CustomAdapter.ViewHolder> {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { ... }
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { ... }
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { ... }
override fun getItemCount() = dataSet.size
}
val rooms: List<Room> = rooms
val recyclerViewAdapter = CustomAdapter(rooms)
// Horizontle RecyclerView
val horizontalRecyclerView: RecyclerView = findViewById(R.id.recycler_view)
val horizontalLayoutManager: LinearLayoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
horizontalRecyclerView.layoutManager = horizontalLayoutManager
horizontalRecyclerView.adapter = recyclerViewAdapter
// Vertical RecyclerView
val verticalRecyclerView: RecyclerView = findViewById(R.id.recycler_view)
val verticalLayoutManager: LinearLayoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
verticalRecyclerView.layoutManager = verticalLayoutManager
verticalRecyclerView.adapter = recyclerViewAdapter
// ... When vertical list starts to render
// Transfer scroll position from horizontal list to vertical one
val horizontalScrollPosition = horizontalLayoutManager.findFirstVisibleItemPosition()
verticalRecyclerView.scrollToPosition(horizontalScrollPosition)
// ... When horizontal list starts to render
// Transfer scroll postion from vertical list to horizontal one
val verticalScrollPosition = verticalLayoutManager.findFirstVisibleItemPosition()
horizontalRecyclerView.scrollToPosition(verticalScrollPosition)
  • Inflate horizontalRecyclerView
  • Inflate verticalRecyclerView
  • During transition, manually fetch & pass scroll-position from one orientation of list to the other

Because of the design of View system, not only we need to inflate and hold on to the recycler views, but also have to manually manage the internal states of the recycler view – the scrolled positions.

In Compose, we would code something like this:

val items: List<Room> = rooms
val listState = rememberLazyListState()
// Horizontal List
LazyRow(state = listState) {
items(items = items) { item -> RoomItem(item) }
}
// Vertical List
LazyColumn(state = listState) {
items(items = items) { item -> RoomItem(item) }
}

Besides the code brevity(mentioned in reason #1), The state-drive architecture played an important role in the difference of the resulting code. The input state for composable function has 2 things:

  • items: List<Room>
  • listState: LazyListState

We passed them into LazyRow & LazyColumn . Notice that we even pass the exact same instance of LazyListState into both LazyRow & LazyColumn . We are effectively telling Compose-runtime just to render Row & Column based on the same scrolling state. That is how simply we can achieve the synchronization of scroll-positions. Also no need to worry about the previous scroll position and transition, because Compose renders UI based on the current input state for each frame. Thanks to Compose’s state-driven architecture and unidirectional data flow, features like this can be easily achieved.

Reason #3 Single Skill Set

Thinking in Compose may not explicitly point it out. But personally, this was actually the main reason that drew me into learning about Compose in the first place.

Thanks to the design of Android View system, XML-based UI development becomes a separate knowledge base from the core software development. We needed to learn how to use XML to express layout, attribute, style, theme, animations, etc.

But with Compose, we finally unified our skill set and write UI with the same core expertise that Android developer already possessed: Kotlin language features, functional programming, coroutines, software engineering principles of writing readable and reusable code. Composable functions are similar to normal Kotlin functions. We can express conditions and loops with the same coding struts we are used to. The more we know about Compose API and its internal implementation, the more we find it familiar with our core knowledge base:

  • DSL captures many aspects of functional programming like extensionhigh-order functions, lambda with receivers, operator and infix(ex. provides) functions
  • Kotlin features such as immutability, trailing lambda argumentnamed function parameter and default valuesdelegate(ex. by)destructuring(ex. mutableStateOf)inline classes(ex. Color)singleton(ex. Theme), factory(ex. LazyListState)
  • Async programming with Kotlin coroutines for UI animations
  • Patterns like reactive programming like observable State and Flow
Final Thoughts

I have formed the 3 reasons why we(also We 😅)adopted Jetpack Compose. Having said that, there have been inconveniences that we experienced through the journey:

  • Incompatibility & bugs in Compose & the associated tech stack(Kotlin, Gradle, Android Studio & other libraries)
  • Missing features to completely match View system

But those are what I called “growing pain”, inevitable snags and hurdles on the bright path.

There should be plenty of resource to understand Kotlin & DSL already. But regarding the Compose architecture and internals (compiler/runtime/UI), there seemed to be limited insightful information available online. Also, the caveat is that some of them may contain guess work from the authors. Nevertheless, I attached a few resources here as they have been useful for me to understand Compose better. Hope they are helpful.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu