Mobile
Building the Pincode Android App on Jetpack Compose: A Journey
Pincode Android Team17 July, 2024
This blog narrates our team’s journey with Compose, detailing its inception at PhonePe, its adoption by the Pincode team, our experiences, and the challenges we faced. The Pincode Android app UI is built using Jetpack Compose. As of July 2024, the app has over 2.5 million downloads on the Play Store, and our journey with Compose continues. We will refer to Jetpack Compose simply as Compose throughout the article unless otherwise specified (read more on naming here: A Jetpack Compose by Any Other Name)
Our initial exploration of Compose
Our exploration of Jetpack Compose at PhonePe began in 2020 when we were developing a design system. Despite Compose being in developer preview, we foresaw its potential as the recommended way to build UI in Android. Our initial PoC aimed to create design system views in the traditional XML/custom view way and check if they can be used with Compose.
The first major adoption of Compose at PhonePe was for the Share.Market Android app. Encouraged by the Share.Market team’s experience, the Pincode team decided to adopt Compose. Compose’s interoperability with traditional Android UI components provided confidence, allowing fallback to traditional methods if needed. Rigorous testing on multiple devices assured us of stability post-release, and our decision proved successful.Simultaneously, the iOS version of the Pincode app adopted SwiftUI, which uses a declarative UI development approach similar to Compose. This allowed us to employ common UI architectural patterns. Read more about our iOS journey here: Embracing SwiftUI
A bit on Jetpack Compose
It might already be obvious in the mobile community what Compose is, but for those who might not be aware, we would like to provide a brief overview. If you are already familiar, feel free to skip this section.
Compose is Android’s recommended modern toolkit to build native UI. It simplifies and accelerates UI development on Android, serving as a complete replacement for the traditional methods of building UI.
Compose consists of Android-specific UI toolkits and platform-independent components like the Compose compiler (a Kotlin compiler plugin), Compose runtime, and UI toolkits. These components were built independently of Android, allowing them to be used in other Kotlin-based solutions like Compose Multiplatform.
The rest of the blog discusses our journey with Compose, our learnings, and what the future holds for Pincode in this journey.
A paradigm shift
From a developer’s perspective, the major shift with Compose is the declarative approach to defining the UI, rather than imperatively mutating frontend views. This paradigm shift has its own learning curve. Developers familiar with other declarative UI frameworks like React, Flutter, Litho, or SwiftUI might find it easier to start with Compose. Read more about this paradigm shift here: Understanding Compose’s mental model.
Viewing UI components as functions that emit different outputs based on state and input parameters is a significant change from traditional UI development. This blog also covers aspects of the learning curve and some mistakes we made along the way.
Learning curve
The team had a diverse mix of developers: some new to Android, some experienced with traditional Android UI development, some familiar with declarative frameworks like React Native, and some with experience in both React Native and native Android. Those with experience in Redux or React Flux found it easier to grasp unidirectional data flow (UDF) design patterns. Examples highlighting these similarities can be found here. However, developers new to declarative UI faced a steeper learning curve.
Understanding side effects and the various Compose APIs for handling them requires time, especially for those unfamiliar with declarative frameworks. Compose state management and the Snapshot system are also new concepts that developers need to learn.
Performance considerations are crucial. While Compose makes it easy to get things working quickly, lacking knowledge of its internal workings and best practices can lead to non-performant UI and frame drops. Learning about recomposition principles and avoiding unnecessary recompositions takes time.
Finally, correctly exposing complex Android views like Map, WebView, and Camera requires careful consideration and time to figure out.
Initial rookie mistakes
The Compose Kotlin compiler plugin does its ‘magic’ to transform the functions annotated with @Composable and make them compose-lifecycle-aware which gives them the ability to emit UI to Compose tree at runtime. It inserts necessary code blocks and params to Compose functions to do so. Most of the time, the code works fine, although it might not be optimal. This can introduce performance issues which can accumulate over time.
When we started, we made a few rookie mistakes, such as:
Not being aware of SideEffects
SideEffects in Compose are mechanisms to handle operations that affect the state or need to interact with the external world, such as database operations, network calls, or logging. When developers are unaware of how to properly manage these side effects, it can lead to unpredictable behavior, such as unintended recompositions or memory leaks. This happens because side effects may be triggered multiple times or not at all if not correctly managed within the Compose lifecycle. For instance, using LaunchedEffect, SideEffect, or rememberUpdatedState incorrectly can result in operations running more frequently than necessary or being missed entirely when the composable is recomposed.
Running business logic in Composables
Composables are primarily meant for UI definition and rendering. Including business logic within them means that every time the UI recomposes, this logic is re-executed, leading to performance inefficiencies and potential bugs. Business logic should reside in ViewModels or other state management components to ensure it runs independently of the UI lifecycle. By separating business logic from UI code, you can minimize redundant computations and make the code more maintainable and testable.
Not following the “Unidirectional data flow pattern” in some cases
Unidirectional data flow (UDF) is a design pattern that promotes a single source of truth and a clear flow of data and events. In Compose, this often involves state hoisting and passing data down the composable tree while events propagate up. Deviating from this pattern by passing ViewModels deep into the Compose tree can lead to tightly coupled components, making the code harder to manage and debug. It also increases the risk of introducing bugs due to state inconsistencies. Instead, ViewModels should be scoped to the relevant part of the UI and only their data and event handlers should be passed down. Also, the mutable state variables in ViewModels need to be private, read-only state needs to be exposed for composables to consume.
Not being aware of the Composition, Layouting, and Drawing phases
Understanding the phases of composition, layouting, and drawing is crucial for optimizing performance and ensuring the correctness of the UI. The composition phase involves constructing the UI tree, layouting determines the size and position of each composable, and drawing renders the UI on the screen. Lack of awareness about these phases can result in inefficient UI updates, excessive recompositions, and incorrect rendering. Developers should understand how to leverage tools like Modifier and Layout to control these phases effectively and minimize unnecessary work. Read more on the same here.
Lack of understanding of skippable composables, as well as the Stable and Immutable annotations
In Compose, skippable composables allow the framework to skip recomposing parts of the UI that haven’t changed, improving performance. The Stable and Immutable annotations help the compiler understand which of the objects can be safely skipped during recomposition. Stable indicates that an object’s properties do not change unexpectedly, while Immutable guarantees that an object’s state cannot change. Misunderstanding or not utilizing these annotations correctly can lead to excessive recompositions, negatively impacting performance. Proper use of these annotations helps Compose optimize rendering, ensuring efficient and responsive UIs.For more detailed information on skippable composables and the Stable and Immutable annotations, you can refer to the official documentation here.
The good part of Jetpack Compose
Compose works well with other existing solutions
Official support is available for other UI-related Jetpack components to work well with Jetpack Compose. Compose navigation library, for example, solves for navigation. ViewModel support with compose navigation solves all things related to ViewModels like scoping ViewModels to a particular navigation backstack entry. All these work well with Hilt. There is first-class support for material design. These things provide a complete UI solution for Android developers and leverage existing knowledge of working with ViewModels and related patterns.
Faster development
The boilerplate code required with XML views, even after data-binding, is sizable compared to the simplicity of defining composable functions. This along with Compose tooling support like Preview makes the development faster.
Also, building reusable components is quite easy as they are just Kotlin functions from the developer’s point of view. It’s very simple compared to XML includes / Custom views.
World of accompanist libraries
Accompanist is a group of libraries that aim to supplement Compose with features that are commonly required by developers but not yet available. Accompanist is a labs-like environment for new Compose APIs. The best part is, these are maintained by Google (https://github.com/google/accompanist). This however is losing its importance as most of the accompanist libraries are now part of the official toolkit.
Easy-to-use animation API
It’s a unanimous opinion of all the developers in the team with prior Android experience that animations are far easier to achieve in Compose.
Actual community support is better than what we expected. We could find some discussions / open issues already raised whenever we were blocked. There were alternate approaches discussed online in some forums.
The Compose team at Google has been super active in improving the official documentation and tooling.
Tooling support
Real-time previews: You can see your UI components in real-time without having to run your app on a device or emulator. The @Preview annotation enables developers to preview Composables directly in Android Studio.
Interactive mode: Interactive previews allow developers to interact with the Composable in the preview pane, which is useful for testing UI behaviour and states without launching the full app.
Breakpoint support: Traditional debugging with breakpoints is supported for Composables, allowing you to step through code and inspect values just like with conventional Android code.
Live edit: As of Android Studio Arctic Fox, Live Edit allows you to see changes to your Composable functions instantly without restarting the app.
Layout inspector: The Layout Inspector in Android Studio supports Jetpack Compose, allowing developers to inspect the composition tree, understand the layout hierarchy, and debug UI issues.
Some of the other helpful features we experienced were:
- Ease of adoption of existing solutions to build support for server-driven UI which supports Pincode landing pages.
- New developers could quickly start contributing to the project after learning the basics and very little guidance from existing members.
- It was quite easy to initially build a design system and place all the building blocks there.
Not-so-good parts
Hurdles in adopting latest compose versions
Every version of Compose is recommended to be used with a specific version of Kotlin. Upgrading Kotlin is challenging due to dependencies on various libraries, causing delays in updates. This issue is common to many libraries, not just Compose.
Performance and best practices
Developers often miss following the right patterns and monitoring performance in the early stages with Compose. The Compose compiler plugin performs complex operations, which are not well-documented. Official documentation lacks detailed information on the internals, though some articles are available.
Third-party SDK integration
Third-party SDKs, such as MapMyIndia in the Pincode app, may not have robust Compose support yet. Developers may need to write extensive boilerplate code to integrate these SDKs with Compose.
Code quality and linting
Composables are regular Kotlin functions, which may require disabling certain linting rules. For instance, rules like limiting the number of parameters in a method may need to be disabled. This practice is common, even within the androidx Compose libraries.
Community support
While community support for Compose is decent, it is not as extensive as for traditional XML-based UI development. However, the community is growing quite rapidly. We found the compose channel in https://kotlinlang.slack.com/ to be very useful.
Future
At PhonePe, we have already made significant bets on Kotlin Multiplatform. Explorations are underway to adopt Compose Multiplatform for cross-platform UI development.