The Android ecosystem is constantly evolving, but some development pitfalls have stood the test of time. Even with years of experience, it's easy to fall back on old habits or overlook subtle issues that can impact performance, stability, and maintainability. Here are 10 mistakes we still see in the wild, even from seasoned developers.
Table of Contents
- Ignoring Memory Leaks in Views and Coroutines
- Misusing Coroutine Scopes
- Passing Large Data Between Activities/Fragments
- Neglecting UI State Restoration
- Building Inefficient or Deeply Nested Layouts
- Blocking the Main Thread
- Not Providing Proper Accessibility Support
- Overusing God Objects (Massive ViewModels/Activities)
- Failing to Handle Network Errors Gracefully
- Ignoring the Power of Jetpack DataStore
1. Ignoring Memory Leaks in Views and Coroutines
This is the classic mistake that never dies. A memory leak occurs when an object is no longer needed but can't be garbage collected because something else holds a reference to it. In Android, this often happens with Context
, Views, or long-running background tasks.
// In a Fragment
fun fetchData() {
// This GlobalScope will keep running even if the Fragment is destroyed
GlobalScope.launch {
val data = api.fetchSomeData() // Network call
view.showData(data) // Leaks 'view' if Fragment is gone
}
}
Good: Using Lifecycle-Aware Scope
// In a Fragment
fun fetchData() {
// This scope is automatically cancelled when the Fragment's view is destroyed
viewLifecycleOwner.lifecycleScope.launch {
val data = api.fetchSomeData()
view.showData(data)
}
}
2. Misusing Coroutine Scopes
Related to the first point, not all scopes are created equal. Using GlobalScope
is almost always a red flag in application code. It creates a top-level coroutine that is not tied to any component's lifecycle, making it a prime source for leaks and unmanaged work.
Always use a structured, lifecycle-aware scope like viewModelScope
in your ViewModel or lifecycleScope
in your Fragment/Activity. This ensures that when the component is destroyed, all its associated coroutines are automatically cancelled.
class MyViewModel : ViewModel() {
fun doWork() {
// This work is automatically cancelled if the ViewModel is cleared.
viewModelScope.launch {
// ... perform long-running work
}
}
}
3. Passing Large Data Between Activities/Fragments
Putting large objects (like Bitmaps, complex data models, or long lists) into an Intent
or Bundle
is a recipe for disaster. This data is serialized to the binder transaction buffer, which has a strict limit (around 1MB). Exceeding it will cause a TransactionTooLargeException
.
Instead of passing the data directly, pass only an ID or key. The receiving component can then use this ID to fetch the data from a shared ViewModel
, a database (like Room), or a memory cache.
val intent = Intent(this, DetailActivity::class.java)
val largeBitmap: Bitmap = // ...
intent.putExtra("image", largeBitmap) // Risk of TransactionTooLargeException
startActivity(intent)
Good: Passing an ID and using a Shared ViewModel
// In a SharedViewModel accessible by both activities
val selectedItem = MutableLiveData<Item>()
// In Activity A
sharedViewModel.selectedItem.value = itemWithLargeBitmap
val intent = Intent(this, DetailActivity::class.java)
startActivity(intent)
// In Activity B
sharedViewModel.selectedItem.observe(this) { item ->
// Use the item safely
}
4. Neglecting UI State Restoration
Users expect an app to remember its state, especially after a configuration change (like screen rotation) or process death (when the OS kills your app in the background). Many developers test the "happy path" but forget to check what happens when the app is backgrounded and restored.
Use a ViewModel
with SavedStateHandle
to store simple state. For more complex data, persist it to a local database. This ensures a seamless user experience, no matter what the OS does to your process.
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
// This will be saved and restored automatically on process death
val searchQuery: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
fun setSearchQuery(query: String) {
savedStateHandle["query"] = query
}
}
5. Building Inefficient or Deeply Nested Layouts
Whether you're using XML or Jetpack Compose, a deep and complex view hierarchy is expensive. Each view requires measurement, layout, and drawing passes. With XML, deeply nested LinearLayout
s were a common performance bottleneck. With Compose, excessive recomposition of large, monolithic composables can cause jank.
For XML: Favor ConstraintLayout
to create flat, efficient hierarchies.
For Compose: Keep your composables small, focused, and stateless. Pass only the data they need to avoid unnecessary recompositions.
6. Blocking the Main Thread
This should be obvious, but it still happens. Any long-running operation—network requests, database queries, complex calculations, or file I/O—must not be done on the main (UI) thread. Doing so will freeze the UI and trigger an "Application Not Responding" (ANR) dialog.
Use coroutines with an appropriate dispatcher (like Dispatchers.IO
for I/O or Dispatchers.Default
for CPU-intensive work) to move work off the main thread.
viewModelScope.launch {
// Switch to a background thread for the network call
val result = withContext(Dispatchers.IO) {
api.fetchData()
}
// The result is now available back on the main thread to update the UI
_uiState.value = result
}
7. Not Providing Proper Accessibility Support
Accessibility isn't an optional feature; it's a core part of building an inclusive app. Forgetting to add contentDescription
to ImageViews
or custom controls is a common oversight. This makes your app unusable for people relying on screen readers like TalkBack.
<ImageView
android:id="@+id/close_button"
android:src="@drawable/ic_close" />
Good: Accessible ImageView
<ImageView
android:id="@+id/close_button"
android:src="@drawable/ic_close"
android:contentDescription="@string/description_close_screen" />
8. Overusing God Objects (Massive ViewModels/Activities)
It's tempting to put all your logic into a single Activity, Fragment, or ViewModel. This quickly leads to a "God Object"—a massive, unmaintainable class that does everything. It's hard to test, difficult to debug, and nearly impossible for new team members to understand.
Break down your logic into smaller, more focused components. Use helper classes, repositories for data handling, and specific use cases (interactors) for business logic. A clean architecture pays for itself in the long run.
9. Failing to Handle Network Errors Gracefully
Your app will not always have a perfect internet connection. Many apps show a loading spinner and then... nothing. They crash or show a blank screen if the network request fails. A robust app must handle all scenarios: no internet, slow connection, server errors (5xx), or client errors (4xx).
Wrap your network calls in a try-catch
block. Use a generic Resource
or Result
wrapper class to represent success, loading, and error states in your UI. Always provide clear feedback to the user.
// Sealed class for UI state
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
// In ViewModel
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val data = repository.getData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error("Failed to load data.")
}
}
10. Ignoring the Power of Jetpack DataStore
For years, SharedPreferences
was the go-to for storing simple key-value data. However, it has major drawbacks: it's synchronous (can block the UI thread), not fully transactional, and offers no way to signal errors. Jetpack DataStore is the modern replacement that solves these problems.
Use Preferences DataStore for simple key-value pairs (like user settings) and Proto DataStore for typed objects. It's asynchronous, safer, and uses Kotlin Flow to notify you of data changes.
Good: Writing to Preferences DataStore// Define a key
val THEME_KEY = stringPreferencesKey("app_theme")
// Suspending function to update the theme
suspend fun setAppTheme(context: Context, theme: String) {
context.dataStore.edit { settings ->
settings[THEME_KEY] = theme
}
}