Jetpack Compose First Impression

Recently, in my latest consulting project, I finally got the opportunity to use Jetpack Compose in a professional setting. In this article, I’ll describe my experience with this framework and discuss its benefits and drawbacks that I’ve discovered so far.

More Complex Mode of Operation

The main difference between Jetpack Compose and Views is their mode of operation.

With Views, you have a hierarchy of View objects organized in a tree. You mutate either these objects or the tree itself to modify the UI. It’s a simple and intuitive model because each View object corresponds to an element on the screen, which you control by acting on that object.

With Jetpack Compose, you have a hierarchy of functions organized in a tree. You can’t mutate functions, so you mutate the inputs into these functions to modify the UI.

While the above descriptions might sound similar, I find Jetpack Compose’s mode of operation much more complex. Furthermore, it introduces new, unique challenges. Allow me to demonstrate the additional complexity arising from Compose’s mode of operation with two examples.

State Management Challenges

The idea of having a hierarchy of functions that draw the UI based on their input arguments sounds simple, but there is a catch. If these functions would be fully re-evaluated for each frame, then the implementation would be relatively straightforward. Unfortunately, this would lead to performance issues and battery drain because the entire UI would be re-created from scratch, even if the inputs didn’t change. Therefore, Jetpack Compose has to track changes in the inputs and actively manage so-called recompositions of the UI.

Unfortunately, the framework can only do so much by itself, so at least part of this heavy burden falls on developers’ shoulders. It starts with the “simple” remember keyword, and I’m still not sure where it ends. Fundamentally, when you use Jetpack Compose, in addition to thinking about the state of your UI at a specific instant in time, you also have to think about the temporal evolution of that state. Furthermore, you also have to understand and account for the specifics of Compose’s state tracking mechanics. This caught me off-guard more than once and led to tricky bugs that took me quite some time to resolve (example).

In general, with Compose, which is advertised as “immutable, stateless UI”, I found myself spending much time on state management and obscure state-related issues.

Proliferation of Function Arguments

The second example of the complexity associated with Compose’s mode of operation can be demonstrated with inter-dependent UI elements.

For example, imagine that you have a button in the UI, and when that button is clicked another element should become active. Since you can reference Views directly, the implementation of this feature using Views could be as simple as this:

someButton.setOnClickListener{
    someOtherElement.activate()
}

The nice aspect of the above code is that it’s independent of the locations of the involved elements in the hierarchy. They could reside right next to each other, or be separated by many layers of ViewGroups, this code wouldn’t change.

With Compose, you can’t reference functions, only change their inputs. Therefore, you’d use MutableState object or a listener lambda to implement this communication:

Button(
    onClick = isOtherElementActivated.value = true, 
) {
    Text(text = "Activate")
}

The problem arises when you work with complex UIs that need to be decomposed (i.e. broken down) into multiple Composables, which, in turn, can be composites by themselves. That isOtherElementActivated MutableState (or its value) would need to be passed down the hierarchies that lead to both the button and the target element. If you’d use a lambda listener function instead of MutableState, the effect would be the same.

The end result is that, instead of direct communication between two Views, you now have function arguments propagated down Composable hierarchies, cluttering the code and making it very challenging to understand what’s going on. I found myself dragging function parameters up to five levels deep into Composable functions just to implement a simple feature like in this example. If I wanted to remind myself what’s the end result of clicking on a button, or what’s the source of some callback lambda, I had to traverse these hierarchies while struggling to keep the context in mind.

I tried to mitigate this issue by bundling all the state and lambdas for a screen into a single class, and then passing an instance of that class down the Composable hierarchies. This resolved the multiplicity of function arguments issue, but created readability problem because now all the Composable have a similar signature and I couldn’t know which parts of the state they use without reading through their implementing code.

With Compose, you pay a high price for not being able to store a direct reference to UI elements, like you could do with Views.

Order-Sensitivity of Modifiers

I needed to implement a custom progress animation with bouncing dots. So, I learned how animations work in Compose, wrote the code, but the animation didn’t work. I went over the implementation multiple times, then connected the debugger to make sure that the state evolved as expected. Everything seemed fine, but the damn dots just sat there and didn’t move.

Long story short, this implementation didn’t work:

@Composable
fun Dot(offsetY: Dp, size: Dp, color: Color) {
    Box(
        modifier = Modifier
            .background(color, shape = CircleShape)
            .offset(y = offsetY)
            .size(size)
    )
}

Changing the order of offset and background did the trick:

@Composable
fun Dot(offsetY: Dp, size: Dp, color: Color) {
    Box(
        modifier = Modifier
            .offset(y = offsetY)
            .background(color, shape = CircleShape)
            .size(size)
    )
}

Even now, that I know what the problem is, I don’t understand why the behavior is so different.

The above example was my biggest time-waster in the context of Modifiers’ order-sensitivity, but it was not the only one. In my opinion, making Modifiers order-sensitive was very unfortunate idea.

Slow Preview

The biggest promise of Jetpack Compose for me was the preview feature because, when I played with Flutter, I found the hot reload to be a game changer. Sure, Compose’s preview is still far behind Flutter’s hot reload in terms of functionality, but I expected to see at least the same speed. In fact, since Compose’s preview evaluates just the UI, I thought it will be blazingly fast.

In practice, preview turned out to be surprisingly slow. For example, it takes about eight seconds to preview changes in this simple element on my machine:

@Preview
@Composable
fun MyTextPreview() {
    MyTheme {
        MyText(text = "Hello")
    }
}

@Composable
fun MyText(
    modifier: Modifier = Modifier,
    text: String,
    style: TextStyle = MyTheme.typography.defaultTextStyle,
) {
    Text(
        modifier = modifier,
        text = text,
        style = style,
    )
}

More complex UIs take even longer. Preview’s performance is so bad that I simply gave up on it because, in that relatively small project, it was faster to rebuild and reinstall the entire app.

On the positive side, I like that the preview shows the boundaries of Composables, akin to Layout Inspector. I leveraged this feature several times to debug unexpected UI behaviors (usually related to Modifiers’ order-sensitiviy).

Theming

Another expectation that I had developed after reading many flattering remarks about Jetpack Compose was simple and versatile theming. I think that themes and styles are among the worst thought out features in the classical Views framework, so I really looked forward to using a better approach.

Well, to tell you the truth, I still haven’t seen the light in this context. It took me considerable time to understand how theming works in Compose, but I just can’t think of any aspect of theming that I’ve utilized so far which turned out to be much more efficient than with Views. That said, I’m open to the possibility that I missed something, or used a wrong approach, so I’ll keep exploring this topic.

Animations

One of debates that I had with Jetpack Compose fans concerned animations. They claimed that Compose makes implementing animations a breeze, while I claimed that, at best, creating animations with Compose will be as difficult as with Views. Now I realize that we compared apples to oranges.

After working with custom animations in Compose for a bit, I can confidently state that they aren’t simpler to implement. In fact, due to Compose’s “unnatural” mode of operation (you don’t have objects with properties), I think the mental overhead is higher with Compose. For example, in my open-sourced TechYourChance app, I now have two comparisons: relatively simple dots “progress” animation, and extremely complex “stacked cards”. In the simpler case, the implementations are largely identical. In the complex case, even though Compose’s version is much shorter (it is less capable than the version that uses Views, though), I can’t say that it’s simpler. Furthermore, Compose version has a considerable performance overhead and it isn’t clear whether and how this can be improved. That would surely require hell of an expertise with Compose.

Said all that, I think that what Compose fans meant by “simpler animations” are not custom animations, but features like AnimatedVisibility and animateFloatAsState. These are basically a higher-level animation APIs that let you handle many common animation use cases very efficiently. I liked them very much, and they indeed spared me some time.

It’s interesting to note that the reason these higher-level APIs exist in Compose is that there were one or more googlers assigned to implement these APIs, full time, probably. I don’t see a reason why a similar approach wouldn’t work with Views. So, we could probably have a similar set of APIs for many years, if only that would be a priority. To test this hypothesis, I’ll try to implement a basic AnimatedVisibilityView in the TechYourChance application and see how it works out.

Undocumented Niche Use Cases

While the standard development practices with Compose are fairly documented and have lots of community-generated content around them, if you have a niche use case in your app, you might be up for a world of pain. One example of a niche use case would be SubcomposeLayout Composable. Another example is using Jetpack Compose to implement a UI shown from a Service.

I encountered a handful of these challenging niche use cases in my project, but I’m pretty sure that there are more out there and that many developers encountered them. This is probably related to the immaturity of Compose ecosystem, so, hopefully, we’ll get better educational resources and/or features as the time goes on.

Speed of UI Development

It’s pretty obvious that it’s quicker to build user interfaces using Compose. In part, it’s because you don’t have to split the implementation between Kotlin and XML. In other part, it’s because there are many handy APIs for common tasks, like creating backgrounds with rounded corners. The third reason is that it simply takes less lines of code.

Since this was my first project with Compose, it dragged my productivity down considerably. I estimate that I spent approximately 30 extra hours of effort learning and debugging Compose. Luckily for my client, I charged a flat fee, so I paid for this extra effort with my evenings and weekends. However, I can see how a developer experienced in Compose would be more productive building UIs than a developer experienced in Views. I’d estimate the productivity boost to be around 20%, but that’s a very subjective number. Furthermore, if they ever fix the preview’s performance and get rid of obscure “render issues”, the productivity difference can become even larger.

Reusable UI Components

Another aspect where Jetpack Compose excels is reusable UI components. Now, I think that custom and compound Views have always been underused and undertaught. Therefore, the actual delta in reusability between Compose and Views is nowhere as large as many developers believe. But there is a delta.

Since creating a reusable UI component in Compose amounts to just writing a function, the barrier to entry and the friction are considerably lower than creating a new View class. And that’s even before we factor in the pain of declaring custom XML attributes for your custom Views.

So, Jetpack Compose is a big winner when it comes to reusable UI components.

Maintainability

Unfortunately, even though Compose has an edge in the speed of UI development and makes it simpler to create reusable components, I think it still loses in the expected long-term maintainability. The reason is that, when looking at my Compose code and other Compose-based UIs online, I see a big red flag.

As much as some developers dislike XMLs, there is a benefit in separating the “static” declaration of the UI from its “dynamic” behavior (action listeners, animations, etc.). With Compose, these aspects are bundled together inside Composables, which leads to the “wall of code” effect. I experienced this effect the most at the bug-fixing stage of the project, when I needed to fix small UI-related issues. Even though I wrote that code, it often took me a considerable time to just find the parts relevant to the bug at hand. Even simple screens, like login, end up containing more than a hundred lines of code. Complex screens easily get to several hundreds, especially if you decompose them into multiple components.

Code reviews can provide another angle on the same issue. When I review pull requests that use Views, I usually ignore XML layouts because it’s close to impossible to find bugs by reading declarative UI code. So, I concentrate on the “dynamic” part of the UIs: the inputs, the outputs, the behaviors, the interconnections, etc. This allows me to focus on the relevant parts and makes code reviews more efficient. Well, you can’t do that with Compose-based UI because it’s just a wall of code. Therefore, even if I just want to verify that the inputs are connected to correct UI elements, I’ll have all those paddings, colors, backgrounds, etc. standing in my way, reducing the quality of code reviews.

Fundamentally, Jetpack Compose’s design is less aligned with Separation of Concerns principle than Views. And it’s always the case that you can get short-term productivity gains by sacrificing Separation of Concerns. That’s why, for example, you’ll so often find God Activities in projects developed by software agencies. However, these short-term gains usually lead to longer-term maintainability issues, and I suspect that UIs implemented with Jetpack Compose will share the same fate.

Conclusion

My first encounter with Jetpack Compose was a mixed bag. On the one hand, I liked some of this framework’s aspects, especially the higher-level APIs for animations, backgrounds and borders, and the ease of creating reusable components. On the other hand, the performance of the preview, which I had been the most enthusiastic about, is horrible. I also spent way too much time debugging state-related issues and the overall maintainability of the code seems to be taking a hit with Compose.

I still hold a strong opinion that Android ecosystem didn’t need Compose, and we’ll never get a positive ROI on all the effort that went into this retooling. In theory, Google could invest a fraction of the effort they put into Compose to address the deficiencies of the Views framework, and we’d get most of the Compose’s benefits without switching to completely new UI framework. I also don’t like that Compose is so complex, with many tricky parts that can catch you off-guard. I like technological challenges, but when it comes to UI frameworks, I just want them to be simple and predictable. I get why many experienced Android devs who like complexity enjoy Compose, but I’m in a different camp.

Said all that, that’s water under the bridge. Jetpack Compose is here and Google pushes it, so it’s only a matter of time before it becomes the prevalent UI framework for Android development.

As always, thanks for reading and don’t forget to subscribe to my mailing list if you’d like to receive notifications about new articles.

P.S. For the past two years, my readers and students have been constantly asking me to create content around Jetpack Compose. Well, now, that I got real professional experience with this framework, your wishes will come true 😉

Check out my premium

Android Development Courses

4 comments on "Jetpack Compose First Impression"

  1. Hi Vasiliy,

    Thank you for your comprehensive insights on Jetpack Compose. As someone deeply involved in Android development, I find your analysis particularly resonant. The challenges of integrating Jetpack Compose into existing systems, especially those based on MVP or MVC architectures, reflect a significant aspect of the adoption barrier. While the potential of Jetpack Compose is undeniable, I share a sense of ambivalence – the excitement for its capabilities is tempered by the absence of a straightforward migration path, reminiscent of the smoother Java-to-Kotlin transition. This balance of potential and challenge is something many of us in the field are navigating.

    Regards,
    Milen

    Reply
  2. Btw, Google already providing the Android Basics for beginners with a Compose-only approach.
    Can’t wait for your Compose Masterclass to cover it properly )

    Reply
  3. Great article, as usual.

    For me the push to use Compose has been to increase the amount of shared code between my Android and Apple versions (via Multiplatform Compose). I’m (slightly) surprised that you hardly ever mention this, not because I think you should also be an iPhone developer (I don’t) but because surely (?) any serious app should have both types and anything to increase common code is vital? I s’pose the big outfits just maintain entirely separate codebases, but that’s much less attractive for small teams (particularly when “small” is “1”!)

    I was very interested in your “Proliferation of Function Arguments” section, since that’s the thing I’m struggling with at the moment. I’ve been reading that adopting a slot-based approach mitigates this difficulty. At least it can keep (a lot of) the processing in one place, but it still requires passing the lambda around.

    Lastly, how does using Compose fit with your previous position of decrying ViewModels? Every single Compose article I’ve found wants to use ViewModels. Does its lifecycle management finally make it a good idea?

    Reply
    • In this project I migrated an app written using MVC to Compose, and it worked great. No need for ViewModel.
      I’m still too new to Compose to have a very strong opinion about the best architectural patter to use with it, but, as far as I’m concerned, Compose is just another UI framework. Therefore, if your project is properly architected, you should be able to just replace your UI code without affecting anything else in the app.

      Reply

Leave a Comment