Why a Dumb Android UI Leads to a More Performant And Overall Safer Codebase

by | Oct 2, 2024

Whether you use XML or Jetpack Compose for your Android UI, having a clear line between where the responsibilities of your UI end is something you’ll be thankful you did. A common pitfall many developers encounter is the temptation to include UI logic directly within UI components. This approach can lead to complicated code that is difficult to manage and test. Just because we want a dumb UI does not mean we can’t have a complex UI. Those complex screens, in particular, get increasingly more complicated when they have too many responsibilities and would benefit from a clear separation of concerns.

UI logic is any logic that “calculates” how the final UI for a screen should look like at a specific time. This could be logic that updates your UI state, but also logic that brings values that are hard to display into a displayable format, like formatting a Long timestamp into a well readable date time string.

From here on, I’ll focus on Jetpack Compose for the UI, but the core principles I’ll share also apply to UI created using XML.

What Do I Mean By The UI Being Dumb?

Android’s modern toolkit for building declarative UI, Jetpack Compose, is far from dumb. We rely on its efficient recomposition strategy for a responsive, performant UI, regardless of how complex our screen is. This is already a big enough responsibility of our UI layer, wouldn’t you agree?

Therefore, the only job we should give our UI is to let it interpret ready-made UI state. It should specifically not have to decide how this state is created (e.g. by sorting a list or loading some items from a database).

By allowing the UI to focus purely on rendering the UI based on the state given to it, we are following the Single Responsibility Principle. We can help ensure this by not adding logic like data validation or state manipulation to our Composables and instead placing it in a class that already is responsible for state management—your ViewModel.

To summarize, I am claiming three things:

  • Your UI should have as little logic as possible
  • Most state should be contained in your ViewModel
  • State mapping should be handled by a ViewModel

The Problems I Have With A “Smart UI”

Take the following very basic Composable, which just contains two text fields to capture a username and password. We have a set of requirements for each, which, if not met, the text field should indicate visually by moving to the error state with the help of a state variable.

@Composable
fun LoginScreen() {
    // State is managed directly in the Composable
    var userName by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var isNameError by remember { mutableStateOf(false) }
    var isPasswordError by remember { mutableStateOf(false) }

    Column {
        TextField(
            value = userName,
            isError = isNameError,
            onValueChange = {
                userName = it
                // Validate user name
                isNameError = it.length < 3
            },
            label = { Text("User Name") }
        )
        TextField(
            value = password,
            isError = isPasswordError,
            onValueChange = {
                password = it
                // Validate password
                val hasMinLength = password.length >= 8
                val hasDigit = password.any { it.isDigit() }
                val hasUppercaseChar = password.any { it.isUpperCase() } 
                
                isPasswordError = !(hasMinLength && hasDigit && hasUppercaseChar)
            },
            label = { Text("Password") }
        )
    }
}

The first thing that jumps at me when looking at this code is those remember functions. The lifespan of those states is tied to the lifecycle of this Composable. Is that what you’d want with a username and password text field? Consider a simple navigation situation; these states will be reset if the user navigates away from this screen, which is likely not the desired behavior you’d want. Sure, you could mitigate this by using rememberSaveable. This, however, is limited to types that can be stored in a Bundle.

Let’s say you keep the states here and use rememberSaveable; the next issue I’d point out is the validation logic that happens when the text field’s value changes. Input validation like this is naturally tied to the domain layer of the app because the answer to a valid username or password is subjective and it can vary from app to app.

What if you had another place in the app where you needed the user to input their password? Would you duplicate the logic to that screen? Extract the logic to a singleton that you can reference inside each Composable? Create a custom Composable for the password text field that contains the validation logic? Can you see how we have moved away from how to render state already? Why would you want to put yourself in a position where it can constantly feel like you are fighting to fit a square in a round hole? (not even mentioning what you would do if the validation needs to happen server-side or if you want to cancel ongoing network calls…)

Clear separation of concerns. That is the first reason why your UI needs to be dumb. We want to give the UI a state and have it purely concerned about rendering that state on the screen, nothing more.

You may not be convinced just yet, so let’s see how we would test the above Composable!

Testing State And Logic Inside A Composable

We will keep it simple and see how we would test the username validation contained in our ProfileScreen Composable. This won’t be a unit test since we need to launch an Activity to host our Compose code. That is what the composeRule property is responsible for. A concept you might not be aware of is Semantics. In short, this is how Compose describes its components in such a manner that it is understandable for accessibility services (that might need to read the text that’s displayed on the screen, describe the image for the user audibly, or inform them that the button is currently disabled) and, the one we care about, the testing framework.

Next, our UI tests for our username validation logic:

class ProfileScreenTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun nameLessThanThreeCharacters_isError() {
        composeRule.setContent {
            LoginScreen()
        }

        composeRule
            .onNodeWithText("User Name")
            .performTextInput("Jo")

        composeRule
            .onNodeWithText("Jo")
            .assert(
                matcher = SemanticsMatcher.expectValue(
                    key = SemanticsProperties.Error, 
                    expectedValue = "Invalid input"
                 )
             )
    }

    @Test
    fun nameMoreThanTwoCharacters_isValid() {
        composeRule.setContent {
            LoginScreen()
        }

        composeRule
            .onNodeWithText("User Name")
            .performTextInput("John")

        composeRule
            .onNodeWithText("John")
            .assert(
                matcher = SemanticsMatcher.keyNotDefined(
                    key = SemanticsProperties.Error
                )
            )  
    }
}

Ideally, I would have wanted to use some assertions on our states that we defined inside our ProfileScreen, but we don’t have access to it. This fact alone should make you think twice about writing UI tests to validate some business logic, but let’s try to work around this limitation.

UI Test Problem #1: Assertions on mutable states inside Composables are impossible, unless we pass them in from the outside (including a lambda when they change) or heavily work with semantics.

Alright, that was a bit of work, but in the end our test is working, right? It is.

However, here comes another catch: UI tests are the slowest of all tests.

Every time, you want to execute a UI test, this is what has to happen:

  1. An Android device has to be booted up (this has to happen once per test run at least, if it’s not already running)
  2. With every test run, Android Studio needs to build an APK that contains your tests.
  3. This APK has to be installed on your device.
  4. The app needs to launch on your device.
  5. For each UI test, a Composable needs to be composed on the screen and each matcher has to query through the Composable hierarchy to find the UI component that is referred to.

Let’s check what this means in numbers and assume we have 300 of such tests in our codebase. I’ve put this code in a serious project of mine with 36,000 lines of Kotlin code, so we get realistic results. I cleaned the build cache, had no emulator launched and I also calculated the total time based on the second UI test, since the first one always takes longer to launch. These are the results:

  • Booting up the emulator took 8s.
  • Building and installing the APK took 80s.
  • Running the first UI test took 1.774s.
  • Running the second UI test took 0.824s.

If we do the math and assume our codebase would have 300 such UI tests, the total time it would take to run them is

8s + 80s + 1.774s + 300 * 0.824s = 5min 36s.

That means in an equivalent codebase with 300 such UI tests, running them all would take 5min 36s every time. You’d have to spend these extra 5min at least every time you are about to merge some changes into your development branch.

This example makes UI tests look even better than they are. We are just working with a simple UI that involves two text fields after all. In a real app, a more complex UI tree could quickly more than triple the time it takes to run a test.

Slow tests are a bigger problem than you might think because the slower they are the less frequently someone will run them. And the slower they are, the longer your CI build will take. So a slow test equals less overall test runs which results in an overall more error-prone codebase.

UI Test Problem #2: They are the slowest type of tests. Slow tests are ran less frequently. Tests that are ran less frequently are less useful.

I wish I could tell you that’s it, but UI tests have another big problem: They are more error-prone than unit tests.

Above, you’ve already learnt that in order for the composeRule to find a specific Composable, it has to query it in the UI tree based on its text or the semantics we pass. A very common semantic used for UI tests is the content description of a UI component or simply its text (like the text of a button). And as you can guess: In a real-world environment these things can change. Yes, you could make use of string resources in both your UI and UI test code to mitigate that, but in reality, semantic matchers are typically way more complex than the simple ones I’ve shown you here. They often query based on siblings or children of a specific composable with multiple semantic attributes that have to match. And if just one component in that query changes, the whole query will break and not find what you’re looking for.

UI Test Problem #3: They tend to be more vulnerable to changes in your codebase than unit tests.

So, does all that mean UI tests are bad and should be avoided? Hell no! They do have their purpose and can be really helpful, especially when testing multiple screens of your app together. But, if testing certain logic does not have to be tested with a UI test, we should definitely avoid that.

The Solution

Starting off with how we model our state:

data class LoginState(
    val userName: String = "",
    val password: String = "",
    val isNameError: Boolean = false,
    val isPasswordError: Boolean = false
)

Then we have our ViewModel which will hold our state and contain all our username and password validation logic:

class LoginViewModel : ViewModel() {
    
    var state by mutableStateOf(LoginState())
        private set
    
    fun onUserNameChanged(userName: String) {
        state = state.copy(
            userName = userName,
            isNameError = userName.length < 3
        )
    }
    
    fun onPasswordChanged(password: String) {
        val hasMinLength = password.length >= 8
        val hasDigit = password.any { it.isDigit() }
        val hasUppercaseChar = password.any { it.isUpperCase() }
        
        state = state.copy(
            password = password,
            isPasswordError = !(hasMinLength && hasDigit && hasUppercaseChar)
        )
    }
}

We’ll keep the validation logic in the ViewModel here for simplicity, but in an actual project it would make sense to have a function for that which is reusable across ViewModels.

Then, our updated LoginScreen:

@Composable
fun LoginScreen(
    state: LoginState,
    onUserNameChanged: (String) -> Unit,
    onPasswordChanged: (String) -> Unit
) {
    Column {
        TextField(
            value = state.userName,
            isError = state.isNameError,
            onValueChange = onUserNameChanged,
            label = { Text("User Name") }
        )
        TextField(
            value = state.password,
            isError = state.isPasswordError,
            onValueChange = onPasswordChanged,
            label = { Text("Password") }
        )
    }
}

Next, we test this with a local unit test since we aren’t testing the UI anymore, but our ViewModel which is nothing else than a Kotlin class that does not involve specifics from the Android SDK (like an Activity). This advantage is bigger than it sounds: On Android, local unit tests are ran on the JVM directly. This means, they don’t have to run on an Android device and therefore nothing has to boot up and no APK has to be installed!

This is how the unit tests would look like:

class LoginViewModelTest {

    private lateinit var viewModel: LoginVieModel

    @Before
    fun setUp() {
        viewModel = LoginVieModel()
    }

    @Test
    fun `when name is less than three characters then it is invalid`() {

        viewModel.onUserNameChanged("Jo")

        assertEquals("Jo", viewModel.state.userName)
        assertEquals(true, viewModel.state.isNameError)
    }

    @Test
    fun `when name is more than two characters then it is valid`() {

        viewModel.onUserNameChanged("John")

        assertEquals("John", viewModel.state.userName)
        assertEquals(false, viewModel.state.isNameError)
    }
}

This local unit test has 3 major advantages over our previous UI test:

  1. It’s simpler. Each test consists of just three lines in total. We don’t have to simulate our UI for that.
  2. We can assert on our state directly.
  3. These tests can run on the JVM directly making them much faster.

Let’s check the numbers again. I took the same app, again cleaned the build cache and considered the time of the second unit test for the calculation:

  • Build time was 24s.
  • Running the first unit test took 11 ms.
  • Running the second unit test took 0.102 ms (!!!)

Doing our math again:

24s + 0.011s + 300 * 0.000102s = 24.04s.

This makes the local unit tests incredible 5min 12s faster than the equivalent UI tests, while testing the same logic!

If we just take into account the time it takes for a test to run, this means that the local unit test was 8,000x faster than the UI test. And all that just because we clearly separated the responsibilities for creating & displaying UI state.

Final Recap

  1. Separating UI logic from the actual UI makes it easier to navigate through a complex app’s UI layer.
  2. Use local unit tests whenever possible. Avoid using Android SDK dependencies in classes that don’t strictly need them, like in ViewModels, to allow testing that code on the JVM.
  3. Use UI tests only to test your actual UI, rather than how its state is created.
  4. Faster & less error-prone tests lead to an overall safer and more performant codebase.

Mastering Automated Testing of Industry-Level Android Apps

Learn Unit & UI Testing For Android From Scratch

In this course bundle, you will learn the ins and outs of testing on Android. Specifically, you’ll learn about the relevant testing theory and then apply that in practice for real project with more than 50,000 lines of Kotlin code. These courses teach you how testing works in the real world, rather than leaving you with a function that tests adding two numbers.

Read More