Developing Android apps can be a fun and challenging process that includes system design, performance optimisations, and building complex UI. But if you've ever tried it, you'd agree, it can also be hair-pullingly frustrating, especially when facing obscure bugs crashing your app 😅
I've had to face such tricky bugs, on a production app I was working on. Even though they were frustrating to debug, they were pretty interesting and helped me learn more about Kotlin and state management in Android. Let me walk you through them, and what solutions we endeavoured.
Problem
In an Android app, we generally have a state object describing our screen. And our state needs to be updated based on multiple events, such as; user interactions, network requests, error handling, etc… And oftentimes, more than one such event can occur at the same time requiring the state to be updated from different threads and separate pieces of code. This could create strange app behaviours if we're not careful. Some of these behaviours can be quite obvious to spot and fix, and others are more obscure and require more debugging to figure out.
Take a look at the following code:
class Screen(val numbers: List<Int>) |
Even though numbers is declared as a read-only List giving the impression that it's immutable, we're still able to modify its content through the items variable which holds the same instance and is declared as a MutableList.
We can also simply cast screen.numbers to a MutableList, and this will give us access to the underlying mutating API.
Now in this example, the consequences of modifying the list are more or less obvious. However, in real-case scenarios, this could be much less obvious if the mutating code is separated from the one consuming the state, or if multiple threads are accessing the same list at the same time.
This was precisely our case, where we encountered two different serious bugs.
Bug #1: App crashes due to ConcurrentModificationException
The ConcurrentModificationException is thrown when a mutating operation is called on a list while it's being iterated over.
This can happen on the same thread such as in the following example:
val items = mutableListOf(1, 2, 3, 4) |
Or, it can happen if one thread is modifying the list while another thread is iterating over it.
In the loop case, if the mutating code was happening inside a nested function called from the loop, then it can be tricky to understand the issue. However, it's still generally easier to spot and fix than the second case, where multiple threads are operating on the same list.
In our case, our app was crashing because of the latter reason. The issue happened because we passed an instance of MutableList to our state holder and kept a reference to it as a variable in the view model. The main thread was iterating over the state holder's list to render the UI elements, meanwhile, our view model needed to update the list it held as a variable in another thread. This caused the exception to be thrown on the main thread and our app to crash.
Chart illustrating how the ConcurrentModificationException occurred
Now we have an understanding of the issue. However, getting to that point was not straightforward. In fact, what makes this type of error even more tricky, is that it's challenging to debug and reproduce.
The first step to fix an issue is to identify what it is. And to do that, we need to reliably reproduce it, meaning we need to understand the exact conditions in which it occurs.
For that, it’s very helpful to look at our error stack trace which shows us the lines of code leading to that error. However, when it comes to this type of error which happens when two threads conflict with each other, we get the stack trace of only one of the threads in the Logcat tool. That means we’re missing one half of the equation.
Thankfully though, it turns out we can inspect the stack trace of every thread when the error happens. Since we know which line of code crashes the app thanks to the Logcat stack trace, we can put a breakpoint on that line. Then using the debugger, once it stops at the breakpoint, we can inspect the stack trace of other threads and look for code relevant to the issue.
On top of that, our QA team also attached a video documenting the crash and which user interactions led to it. Surprise surprise though, I couldn't replicate the error by following the same steps. And this, actually, is in the very nature of multi-thread errors. They could occur on one device but not on another, even if you follow the same steps.
Basically, threading operations are controlled by the device's CPU. And different devices have different CPUs, which means threads can be scheduled differently. So, you can get lucky on one device, where the two conflicting operations would run sequentially, and unlucky on another where they would run in parallel.
In fact, you can also fall into the case where you cannot replicate the issue on the same device type, because each individual machine would have a different state, depending on how many apps are open, how many operations are happening, and all sorts of different conditions.
Ok, then how do we find the cause of the issue? We have to reason about it. We already have the video evidence and the stack trace. The stack trace reported in Logcat was related to the iteration operation.
So, I had to look into our code and analyse where we modify the list, even in very unrelated pieces of code. Having the video documentation certainly helped narrow down the list of culprits, until I found what I was looking for.
I finally managed to identify the root cause, however it took considerable time. And it really helped to have the video from our QA team. Imagine if the issue happened in production, and I'd only have the error stack trace to work with!
Bug #2: App shows the wrong state
This time the app wasn’t crashing but it was showing an empty state. After setting up multiple breakpoints to debug what was going on, I observed the very strange behaviour where the state was created and passed along with a full list of items, but when it came time to render the items, the list was suddenly empty 😅
The rendering function relied on a class variable holding the list, and this variable was never assigned an empty list after it was assigned the full list of items. So this only meant the list itself got mutated at some point.
Again the culprit turned out to be the variable held in the view model. After passing the state with the full list of items, the main thread didn't render it immediately, but posted the operation. Meanwhile, the view model cleared the list using its class variable. And since this variable held the same instance of the list as the state variable, it explained why the list was suddenly empty when it was time to render it.
Solution
Even though the issues above are tricky to debug, once we figure out where they're stemming from, they're actually simple to resolve.
The fast approach
We can address the issues locally where they're manifesting by simply copying the list into another one. For example, in the crash case, we can create a new list to iterate over using the existing one.
// Main thread |
This way, it's not the same list being operated on simultaneously by different threads, therefore the ConcurrentModificationException is not thrown.
However, even though this would solve the reported bugs, it's more of a bandaid and doesn't prevent this kind of issue from happening again elsewhere and risking the app to crash due to unforeseen cases.
The safe approach
A more methodical approach to this will prevent the issue from ever happening again. The root issue is that we're allowing the state object to hold an instance of a mutable list. This opens the door for:
- Unexpected behaviours due to accidental mutations of the list outside of the state scope.
- No thread safety when consuming the state.
- Obscure bugs that are not easily reproducible and require more time to fix.
Unfortunately, the Kotlin standard library only includes two types of collections:
- Mutable: collections with an interface allowing to change its contents.
- Read-only: collections with an interface allowing just read operations. But these are actually still mutable. Even though their API doesn't allow mutation, under the hood, they're actually instances of mutable Java collections such as ArrayList, HashSet, HashMap and other classes from the java.util package. So we can still cast an instance of List to ArrayList and get away with mutating it. And even if we don't cast them, we can still provide an instance of MutableList to the state which we're able to mutate later if we keep holding the list instance in another variable. So we're not really enforcing immutability in the state.
So how do we enforce the list to be truly immutable ?
The Kotlin immutable collections library
Thankfully, JetBrains has developed a multiplatform library we can import that answers our problems. It has a PersistentList interface that extends List, and we can use it similarly.
val list1 = persistentListOf("a", "b") |
You can read more about it here.
It's worth noting though that the library is currently on alpha with version v0.3.5, and according to JetBrains that means, "use at your own risk, expect migration issues". It probably works, but the API is likely to change.
Before using this library, it would be wise to browse the current Github issues and see if it's compatible with your project or not.
I imagined they’re planning to include this in the Kotlin standard library once the library is stable, but looking at this issue, it seems they're not planning on doing that. So we'll have to keep explicitly importing it. At the time of writing this, it's only 60 kb in size, so it won't significantly affect the app size.
Creating our own ImmutableList class
You might think the Kotlin immutable collections library is not compatible with your project. Maybe there is an existing issue causing you trouble, maybe you're required by the project policy to only use production ready libraries, or maybe you just think it's simply an overkill for your use case.
In that case, creating your own implementation of an immutable list is a viable alternative. It's actually the approach we opted for in our project. Creating such a list is pretty easy and it fits our needs perfectly.
Below is an example of an immutable list implementation and how to use it:
class ImmutableList<T>(list: List<T>) : List<T> by list.toList() // use case example |
As you can see, creating the class is a simple one-liner. It works by:
- Copying the provided list items into a new List by using .toList().
- Declarging this class as a List, but delegating the interface implementation to the newly created List instance which is held as a private field. You can verify this by checking the generated Java code for the class.
Thanks to this, we circumvent the issues we were seeing before:
- Casting the list to a MutableList and gaining access to mutating APIs: This now doesn't work as ImmutableList is a different implementation of List and does not expose its internal List delegate.
- Passing a mutable list as a constructor parameter and mutating that list afterwards: This also won't work anymore as we're copying the items into a new list, and any changes to the original one don't affect the new list.
So as you can see, this solution is pretty simple and addresses all our problems. However, one important downside to it could be the performance overhead of copying the items into a new list. So depending on the size of the original list, it’s worth considering instantiating ImmutableList in a background thread so we don't risk blocking the main thread and stuttering our app.
Summary
- State management can grow into a complex operation and seemingly simple choices such as using a List to describe a collection of items, can incur obscure problems that range from tolerable to severe.
- We should use immutable lists to describe collections in our state objects to protect them against accidental mutations.
- Kotlin doesn't have out-of-the-box support for immutable lists.
- We can instead use the Kotlin immutable collections library or simply create our own implementation.