Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 080 / 100

Common Android mistakes: real-world lessons and examples

Bugs that ship too often.

Expona Admin9 min read

Introduction

After years of shipping production Android apps across e-commerce, ride-hailing, and SaaS platforms, we've seen the same bugs surface repeatedly—bugs that slip past code review and land in production with alarming consistency. Common Android mistakes: real-world lessons and examples exist not because developers lack skill, but because the Android framework creates hidden traps that even experienced teams trip over. This article walks through four categories of mistakes we've debugged in live apps, why they happen, and how to prevent them.

Network on the Main Thread

The most visible symptom is the NetworkOnMainThreadException, but the real problem runs deeper. Many developers understand they shouldn't block the main thread, yet we still find network calls happening there because the exception only fires on Android 5.0+, and developers test on newer devices. Worse, some codebases suppress the exception instead of fixing it.

Here's a pattern we've seen repeatedly in real apps:

kotlin
// ❌ Real code from production (discovered during an audit)
class LoginViewModel : ViewModel() {
    fun login(email: String, password: String) {
        val client = OkHttpClient()
        val request = Request.Builder()
            .url("https://api.example.com/login")
            .post(RequestBody.create(
                MediaType.get("application/json"),
                """{"email":"$email","password":"$password"}"""
            ))
            .build()

        val response = client.newCall(request).execute() // Blocks main thread
        val result = response.body?.string()
        // Update UI with result
    }
}

This code works—until it doesn't. Network latency spikes, the main thread hangs for 3+ seconds, Android kills the app, and users see the dreaded ANR (Application Not Responding) dialog. We've traced production crashes on ride-hailing apps back to exactly this pattern during rush hour when servers were slow.

The solution sounds obvious but requires discipline:

kotlin
// ✅ Correct approach
class LoginViewModel(private val repository: AuthRepository) : ViewModel() {
    private val _loginState = MutableLiveData<LoginState>()
    val loginState: LiveData<LoginState> = _loginState

    fun login(email: String, password: String) {
        viewModelScope.launch {
            _loginState.value = LoginState.Loading
            try {
                val result = repository.login(email, password) // Suspending function
                _loginState.value = LoginState.Success(result)
            } catch (e: Exception) {
                _loginState.value = LoginState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// Repository uses Retrofit (or similar) with Coroutines
class AuthRepository(private val api: AuthApi) {
    suspend fun login(email: String, password: String): AuthToken {
        return api.login(LoginRequest(email, password))
    }
}

Use coroutines with suspend functions, Retrofit, or RxJava. These frameworks handle thread dispatch automatically. Never catch yourself thinking "I'll just make it quick"—network calls are unpredictable.

Leaking Activities

Memory leaks in Android are insidious because they don't always crash immediately. An Activity leak means the Activity instance remains in memory long after the user navigates away, draining heap space and eventually causing an out-of-memory crash hours or days later.

A common source: inner classes holding references to the Activity. We found this in a travel app's analytics library:

kotlin
// ❌ Activity leak
class BookingActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_booking)

        // Anonymous listener keeps a reference to 'this' Activity
        Analytics.trackEvent("booking_started", object : AnalyticsListener {
            override fun onSuccess() {
                // This closure captures the outer Activity instance
                Toast.makeText(this@BookingActivity, "Tracked", Toast.LENGTH_SHORT).show()
            }
        })
    }
}

If the Analytics singleton holds a strong reference to that listener, the Activity is never garbage collected. The user closes the Activity, rotates the device, navigates to another screen—and the old Activity instance lives on.

The fix depends on the root cause:

kotlin
// ✅ Use weak references for listeners
class BookingActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_booking)

        val listener = object : AnalyticsListener {
            override fun onSuccess() {
                // Use a method that checks if Activity is still alive
                if (!isDestroyed) {
                    Toast.makeText(this@BookingActivity, "Tracked", Toast.LENGTH_SHORT).show()
                }
            }
        }

        // Better: pass a lifecycle-aware callback
        Analytics.trackEvent("booking_started", listener, lifecycle)
    }
}

// Or use ViewModel + LiveData to avoid Activity references
class BookingViewModel : ViewModel() {
    private val _analyticsEvent = MutableLiveData<String>()
    val analyticsEvent: LiveData<String> = _analyticsEvent

    fun trackBooking() {
        // Track without holding Activity reference
        _analyticsEvent.value = "booking_started"
    }
}

Use Lifecycle-aware components. Modern Android libraries (Room, LiveData, WorkManager) respect the Activity lifecycle and clean up automatically. If you must hold a reference, use WeakReference or check isDestroyed before accessing the Activity.

Ignoring Process Death

Android kills background processes without warning. A user switches to another app for 10 minutes, the system reclaims memory, and your app's process dies—but the OS keeps the Activity stack intact. When the user returns, Android recreates the Activities, but your in-memory state is gone.

We debugged this extensively in a CRM app where sales reps would lose form data:

kotlin
// ❌ State lost on process death
class SalesFormActivity : AppCompatActivity() {
    private var customer: Customer? = null
    private var notes = ""

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sales_form)

        // If process died, these are null/empty even if user filled them in
        customer = intent.getParcelableExtra("customer")
        notes = intent.getStringExtra("notes") ?: ""

        updateUI()
    }

    fun onSaveNotes(text: String) {
        notes = text
        // ❌ No persistence; if process dies, notes are lost
    }
}

The correct pattern: save state to disk immediately, not just to memory.

kotlin
// ✅ Persist state with SavedStateHandle
class SalesFormViewModel(
    private val repository: CustomerRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    private val _notes = savedStateHandle.getLiveData<String>("notes", "")
    val notes: LiveData<String> = _notes

    fun updateNotes(text: String) {
        // Automatically persisted across process death
        _notes.value = text
    }

    fun saveForm(customerId: Long) {
        viewModelScope.launch {
            val currentNotes = _notes.value ?: ""
            repository.saveCustomerNotes(customerId, currentNotes)
        }
    }
}

// In Activity
class SalesFormActivity : AppCompatActivity() {
    private val viewModel: SalesFormViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sales_form)

        viewModel.notes.observe(this) { notes ->
            notesEditText.setText(notes)
        }

        notesEditText.addTextChangedListener {
            viewModel.updateNotes(it.toString())
        }
    }
}

SavedStateHandle persists primitive and Parcelable data across process death. For complex data, use a local database (Room) or file-based persistence. Never assume your Activity will survive the background state.

Not Testing Release Builds

Debug builds and release builds are different. Debug includes symbols, enables assertions, and disables ProGuard/R8 obfuscation. We've seen bugs that never happen in debug but crash consistently in release—and developers ship them because they didn't test the actual build type users receive.

A real case from a trading app:

kotlin
// ❌ Works in debug, crashes in release
class PriceCalculator {
    fun calculateCommission(price: Double): Double {
        assert(price > 0) { "Price must be positive" } // Stripped in release
        return price * 0.02
    }
}

// Another subtle issue: reflection with minification
class ConfigLoader {
    fun loadSettings() {
        val clazz = Class.forName("com.example.trade.ConfigImpl")
        val method = clazz.getMethod("getSettings") // ❌ Class names obfuscated in release
        return method.invoke(null) as Settings
    }
}

In release, the obfuscated class name won't match, and Class.forName throws an exception. The app crashes silently for end users.

The solution requires discipline in the build pipeline:

kotlin
// ✅ Test both build types
// In your test suite or CI/CD:
// 1. Build release variant
// 2. Sign the APK
// 3. Deploy to device or emulator
// 4. Run critical user flows

// In code, avoid reflection or keep a ProGuard/R8 rules file
// proguard-rules.pro
-keep class com.example.trade.ConfigImpl { *; }
-keep class com.example.trade.ConfigImpl { 
    public *** getSettings(...); 
}

// Or use proper dependency injection
class ConfigLoader(private val configFactory: ConfigFactory) {
    fun loadSettings(): Settings = configFactory.createSettings()
}

Always test release builds before shipping. Use ./gradlew assembleRelease locally and on a physical device. Enable ProGuard/R8 shrinking and obfuscation in your build, then verify the app still works. We now require release build testing as a gate in CI/CD.

Common Mistakes Summary

  • Blocking the main thread with network calls: Causes ANRs and crashes. Use coroutines or async frameworks.
  • Holding Activity references in singletons or listeners: Prevents garbage collection and leaks memory. Use lifecycle-aware components or weak references.
  • Assuming in-memory state survives process death: It doesn't. Persist to disk or use SavedStateHandle.
  • Shipping without testing the release build: Obfuscation and optimization reveal bugs hidden in debug. Test release builds before shipping.
  • Ignoring crashes in crash reporting: Set up Crashlytics or similar. Bugs you don't see still hurt users.

Key Takeaways

  • Move network and I/O off the main thread without exception. Use coroutines or Retrofit.
  • Design for process death. Persist state to disk, not just memory, and use SavedStateHandle.
  • Prefer lifecycle-aware components (LiveData, ViewModel, WorkManager) over manual reference management.
  • Always test release builds locally and in CI/CD before merging to main.
  • Set up crash reporting and monitor production. Real bugs surface under real load, not in your emulator.
  • Use static analysis tools (Lint, Detekt) and code review to catch these patterns early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles