Jetpack Compose First Impression

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 the benefits and the drawbacks that I’ve discovered so far.

More Complex Mode of Operation

The main difference between Jetpack Compose and the classical Views franeworks are their modes 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 quite some time to resolve (example).

In general, even though Compose is advertised as “immutable, stateless UI”, I found myself spending much time on dealing with state management and debugging 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. When that button is clicked, another element should become active. Since you can reference Views directly, the implementation of this feature using Views is simple:

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. Whether they are right next to each other, or 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 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 trying to keep the context in mind.

I attempted 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 all the Composable acquired similar signatures, so I couldn’t know which parts of the state they use without reading through their implementing code.

All in all, with Compose, you pay a high price for not being able to reference UI elements.

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.

In my opinion, making Modifiers order-sensitive was very unfortunate idea.

Slow Preview

When I played with Flutter, I found its hot reload to be a game changer, so the biggest promise of Jetpack Compose for me was the preview feature. Sure, Compose’s preview is still far behind Flutter’s hot reload in terms of functionality, but I still expected to benefit from it. Furthermore, since Compose’s preview evaluates just the UI (as opposed to Flutter’s hot reload), I thought it will be blazingly fast.

In practice, preview turned out to be very 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. Even 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.

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

I had had a long debate with Jetpack Compose fans about 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 say 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 shows worse performance and it isn’t clear whether it can, or how it should be optimized. This requires extermely deep Compose expertise.

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 higher-level animation APIs that let you handle many common animation use cases with ease. I liked them very much too.

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 would be SubcomposeLayout. Another example is using Jetpack Compose to implement a UI inside a Service.

I encountered several such niche use cases in my project and, probably, other Android developers stumble into them as well. Hopefully, it’s just a matter of maturing and we’ll get better educational resources and features with time.

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 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.

Said that, since this was my first project with Compose, it reduced my productivity considerably. However, I can see how a developer experienced in Compose could 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 the obscure “render issues”, the productivity benefit can become even larger.

Reusable UI Components

Another aspect where Jetpack Compose excels is reusable UI components. In Compose, these are just functions which are called from other functions, as opposed to custom or compound Views. Jetpack Compose is a clear winner in this context.

Said that, custom and compound Views have always been underused in my opinion. Therefore, the actual delta in reusability between Compose and Views is nowhere as large as many developers believe. But, still, there is a delta.

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, versatile modifiers, 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 at this point. 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.

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