This page is for anyone interviewing for an Android role, from a first job to a senior engineer. Most Android rounds check that you understand lifecycles and configuration changes, then move to coroutines and Flow, Compose or RecyclerView, and the data layer with Room, Retrofit and Hilt. Stronger rounds add memory leaks, ANRs, background work, testing and shipping to the store, plus stories from real apps and a few judgement calls. Each question shows what the interviewer is checking, the shape of a good answer and a short answer you can say out loud. Replace the stories with your own.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Start: the short story of how you began, one or two lines.
Shipped work: one or two apps, who used them and what you owned.
Now: what you want to go deeper on in this role.
"I started in my second year of college when I wanted a simple app to split bills with friends. I built it in Kotlin with a single activity and a Room database, and about forty friends ended up using it, which taught me more than any course. After graduating I joined a small team building a delivery tracking app. I owned the order history and notifications screens, moved them from XML to Compose, and set up the WorkManager job that syncs orders when the network comes back. Now I want to work on an app with a bigger user base, where performance, crash rates and careful releases matter more, because that's the part of the job I've enjoyed most."
A list of libraries with no app, no users and no clear statement of what you personally built.
What native gives: new platform features on day one, deep system integration, full control of performance.
Where cross-platform wins: two platforms, a small team, mostly standard screens.
Your stance: why native suits you, while respecting the trade-off.
"I like native Android because I get new platform features the day they ship, I can integrate deeply with things like background work, widgets and notifications, and when something is slow I can profile it all the way down. That said, I don't think native is always right. If a small team has to ship the same mostly standard screens on two platforms, a cross-platform framework can be the smarter business choice. A middle path I find interesting is sharing the business logic in Kotlin Multiplatform while keeping native UI on each side. So for me it's about the product. I want to go deep on Android because that's where I think I add the most value, not because the other options are bad."
Dismissing every cross-platform option as bad without being able to name a single case where it makes sense.
The ask: what was requested and why it was a problem on Android.
How you raised it: evidence, a demo or a prototype, not just an opinion.
Outcome: the agreed solution and what the user got.
"On a shopping app, the backend team planned an endpoint that returned the whole product catalogue in one response, so the app could filter locally. On mid-range phones that meant a large download and a parse that froze the screen. Instead of just saying no, I built a quick test with a real sample file and showed the load time and memory use on a cheap test phone during our sync. That made the problem concrete. We agreed on a paged endpoint with server-side filtering and a small summary call for the first screen. It meant a bit more backend work, but the first screen loaded much faster and we stopped seeing memory crashes on low-end devices. I learned to bring numbers from a real device, not arguments."
A story where you simply built what was asked despite knowing it would fail, or where you won by being stubborn rather than showing evidence.
Sources: official release notes, behaviour changes for each version, a few trusted engineers.
Must-do versus nice-to-have: target SDK and policy changes come first.
Trying things: side projects or one small screen before team adoption.
Sharing: short write-ups or demos for the team.
"I split it into what we must do and what's nice to try. The must-do list is the behaviour changes page for each new Android version and the store's target SDK requirements, because those can break us or block updates if we ignore them. I read those every year and turn them into tickets early. For new libraries, I follow the official Android developer blog and release notes and a handful of engineers whose judgement I trust. When something looks useful, I try it in a side project first, then on one small, low-risk screen at work. If it earns its place, I write a short note or give a quick demo so the team can decide together. I'm happy to be a few months behind the newest thing if it means the app stays stable."
Either not knowing what changed in recent Android versions, or wanting to adopt every new library as soon as it is announced.
Opening: onCreate, onStart, onResume.
Home: onPause, then onStop; state is saved for later.
Return: onRestart, onStart, onResume, or a fresh onCreate if the process was killed.
Why it matters: what you start and stop in each pair.
"When the app opens, the activity gets onCreate, where I set up the UI and ViewModel, then onStart when it becomes visible, then onResume when it's in the foreground and taking input. When the user presses Home, it gets onPause and then onStop, and the system also calls onSaveInstanceState so it can restore small UI state later. When they come back, it's onRestart, onStart and onResume. The catch is that while the app sat in the background, the system may have killed the process to free memory. In that case, coming back runs a fresh onCreate with the saved bundle. That's why I tie things like location updates or camera use to start and stop, and never assume the activity simply resumes."
Saying onDestroy runs when the user presses Home, or never mentioning that the process can be killed while in the background.
Default: the activity is destroyed and recreated with the new configuration.
ViewModel: survives rotation, but not process death.
Saved state: SavedStateHandle or rememberSaveable for small UI state that must survive process death.
Disk: real data lives in a database or DataStore.
"By default, a rotation is a configuration change, so the system destroys the activity and creates a new one with the new resources. Anything held only in the activity is gone. I handle it in layers. Screen data like a loaded list lives in a ViewModel, which survives rotation because it's kept outside the activity instance. But a ViewModel dies if the system kills the process in the background, so small things the user typed or selected, like a search query or a tab, go into SavedStateHandle, or rememberSaveable in Compose. Those are written into the saved instance state bundle. Anything that must last, like a draft message, I write to Room or DataStore. I avoid opting out of recreation in the manifest, because it just hides the problem."
Saying the ViewModel survives everything, including the process being killed, or fixing rotation by locking the screen orientation.
Two lifecycles: the fragment itself, and its view from onCreateView to onDestroyView.
Back stack: a replaced fragment on the back stack loses its view but stays alive.
Observers: observe with viewLifecycleOwner so each new view gets exactly one observer.
Binding: clear the view binding in onDestroyView.
"A fragment and its view don't live for the same length of time. When I replace a fragment and add that transaction to the back stack, the old fragment gets onDestroyView but not onDestroy, so the fragment object stays in memory with no view. When the user presses Back, onCreateView runs again and builds a brand new view. If I observe LiveData in onViewCreated with the fragment as the owner, the old observer is never removed, because the fragment never died. So after coming back I have two observers, and the old one may touch views that are gone. Using viewLifecycleOwner ties the observer to the current view, so it's removed in onDestroyView. Flows work the same way: I collect them with the view's lifecycle and repeatOnLifecycle. And I set the binding to null in onDestroyView so the old view tree can be freed."
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.items.observe(viewLifecycleOwner) { adapter.submitList(it) }
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
Observing with the fragment as the owner inside onViewCreated, or not knowing that onDestroyView can run without onDestroy.
LiveData: Android-only, lifecycle-aware, only delivers to active observers.
StateFlow: plain Kotlin, needs an initial value, skips values equal to the current one.
Safe collection: repeatOnLifecycle or collectAsStateWithLifecycle.
Choice: StateFlow for new code, with a reason.
"LiveData is an Android class that knows about the lifecycle, so it only pushes updates to observers that are started or resumed, and it cleans up when the owner is destroyed. StateFlow comes from Kotlin coroutines. It always has a value, so you must give it an initial one, and it skips an update if the new value equals the current one. It isn't lifecycle-aware by itself, so in views I collect it inside repeatOnLifecycle with the started state, and in Compose I use collectAsStateWithLifecycle. For a new ViewModel I'd expose StateFlow, because it works with the rest of the flow operators, it's easy to test without Android, and it can live in modules that don't depend on Android. I'd keep LiveData where an older screen already uses it."
// ViewModel
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
// Fragment
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
Collecting a flow in a fragment with no lifecycle handling, so it keeps working while the screen is in the background.
viewModelScope: work for a screen's data, cancelled when the ViewModel is cleared.
lifecycleScope: UI work tied to an activity or fragment.
GlobalScope: almost never; use an injected app scope or WorkManager instead.
Dispatchers: Main for UI, IO for blocking calls, Default for CPU work.
"A scope decides how long a coroutine lives. viewModelScope is my default for loading screen data, because it's cancelled when the ViewModel is cleared, so a user leaving the screen stops the work. lifecycleScope belongs to an activity or fragment, and I use it for UI things like collecting state. GlobalScope isn't tied to anything, so nothing cancels it and it can leak or run after it's useful. If work really must outlive a screen, I inject an application-level scope, and if it must survive the process dying, it goes to WorkManager. For dispatchers, Main is for touching the UI, IO is for blocking work like file access, and Default is for CPU-heavy work like parsing a big list. Suspend functions from Room and Retrofit already switch threads, so I don't wrap them again."
Reaching for GlobalScope to make a request 'keep going', or saying coroutines are threads.
Cooperative cancellation: suspend points check it; tight loops must check it themselves.
Don't swallow it: CancellationException must be rethrown.
Failure spreads: under a normal Job, a failing child cancels the parent and siblings.
SupervisorJob: children fail on their own; viewModelScope already uses one.
"Cancellation is cooperative. Library suspend functions like delay or withContext check for it, but a loop doing CPU work won't stop unless I call ensureActive or check isActive. Cancelling throws a CancellationException inside the coroutine, so if I write a catch-all for Exception, I have to rethrow that one or I break cancellation. For failures, under a normal Job, a child that throws cancels its parent, and the parent cancels all the other children. That's usually right for related work: if one of two parallel calls for a screen fails, the other is pointless. When the children are independent, I use a SupervisorJob or supervisorScope, so one failing doesn't cancel the rest. viewModelScope already uses a SupervisorJob, which is why one failed launch doesn't kill the others there."
suspend fun loadAll() = supervisorScope {
val news = async { api.news() }
val weather = async { api.weather() }
val items = try { news.await() } catch (e: IOException) { emptyList() }
val forecast = try { weather.await() } catch (e: IOException) { null }
Dashboard(items, forecast)
}
Wrapping everything in runCatching or catch Exception without rethrowing cancellation, then being surprised that work never stops.
Input: a MutableStateFlow holding the query.
Debounce: wait for a pause, trim, drop repeats.
Latest only: mapLatest cancels the previous search when a new query arrives.
Expose: stateIn with viewModelScope; catch errors inside each search.
"I keep the query in a MutableStateFlow that the text field updates. From that I build the results flow. debounce waits until typing pauses for a short time, then I trim the text and use distinctUntilChanged so the same query doesn't search twice. The key operator is mapLatest: when a new query comes in while the previous search is still running, it cancels the old one, so a slow response can't overwrite newer results. I catch network errors inside the mapLatest block, not after it, because a catch at the end of the chain would complete the whole flow after the first failure and search would stop working. Finally stateIn turns it into a StateFlow tied to viewModelScope, and WhileSubscribed stops the work shortly after the screen stops collecting."
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<List<Item>> = query
.debounce(300)
.map { it.trim() }
.distinctUntilChanged()
.mapLatest { q ->
if (q.length < 2) emptyList()
else try { repo.search(q) } catch (e: IOException) { emptyList() }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChanged(text: String) { query.value = text }
}
Launching a new coroutine per keystroke with no cancellation, so older, slower responses overwrite newer results.
Trigger: a state object read during composition changes; readers are re-run.
Skipping: a composable with stable, unchanged inputs can be skipped.
Fixes: read state late, derivedStateOf, remember, stable models, keys in lazy lists.
Measure: recomposition counts in Layout Inspector, then frame timing on a release build.
"Compose tracks which state each composable reads. When that state changes, Compose re-runs the composables that read it, and it can skip children whose inputs are stable and haven't changed. Problems come from reading fast-changing state too high up. For example, if the whole screen reads a scroll offset, every scroll frame recomposes the screen. My fixes are to read state as low and as late as possible, like passing a lambda instead of a value, or using the lambda version of a modifier such as offset so the read happens in layout, not composition. I use derivedStateOf when the UI only cares about a threshold, like whether to show a scroll-to-top button. I remember expensive calculations, give lazy list items stable keys, and keep UI models immutable. Then I check the counts in Layout Inspector."
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
LazyColumn(state = listState) {
items(articles, key = { it.id }) { ArticleRow(it) }
}
Thinking the whole screen redraws on every change, or trying to fix performance before measuring recomposition counts.
remember: keeps a value across recompositions; lost when it leaves the screen or on rotation.
mutableStateOf: an observable value; changing it recomposes whatever reads it.
Hoisting: move state up to the caller; pass the value down and an event lambda up.
Why: one source of truth, easy previews and tests, reusable components.
"Composables can run again at any time, so a normal local variable resets on every recomposition. remember keeps a value across recompositions, and mutableStateOf makes it observable, so changing it recomposes whatever reads it. remember alone doesn't survive rotation, which is what rememberSaveable is for. State hoisting means taking that state out of a composable and moving it up to the caller. The composable receives the current value and a lambda like onQueryChange, and it just displays and reports. So my search field is stateless: it shows the text it's given and calls back when the user types. The caller, often backed by the screen's ViewModel, owns the real state. That gives me one source of truth, and the same component works in previews, tests and other screens. I hoist state to the lowest common parent that needs to read or change it."
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val query by viewModel.query.collectAsStateWithLifecycle()
SearchField(query = query, onQueryChange = viewModel::onQueryChanged)
}
Holding UI state in a plain variable and wondering why it resets, or giving every small component its own copy of the screen's state.
Recycling: only enough view holders for the screen; scrolled-off ones are rebound.
Binding: onBindViewHolder must be cheap and must reset every view.
DiffUtil: computes exact inserts, removes, moves and changes.
ListAdapter: runs the diff off the main thread.
"RecyclerView only creates enough view holders to fill the screen plus a few extra. When a row scrolls off, its view holder goes into a cache or a shared pool, and when a new row scrolls in, RecyclerView reuses one and calls onBindViewHolder to fill it with new data. So binding has to be fast, and it has to set every view, or you see old data flicker in from a recycled row. notifyDataSetChanged tells the adapter that everything might have changed, so it rebinds all visible rows and you lose item animations. DiffUtil compares the old and new lists, using areItemsTheSame on the id and areContentsTheSame on the data, and sends only the real changes. I use ListAdapter, which runs that diff on a background thread and just needs submitList."
Saying RecyclerView creates a view for every item, or forgetting to reset view state in onBindViewHolder.
Explicit: names the exact component; used inside your own app.
Implicit: describes an action; the system finds apps with a matching intent filter.
PendingIntent: a token that lets the system or another app fire your intent later as your app.
Safety: handle no matching app, and mark PendingIntents immutable unless they must change.
"An explicit intent names the exact class to start, like my own DetailsActivity, so I use it inside my app. An implicit intent describes what I want done, like viewing a web page or sharing text, and the system finds an app whose intent filter matches. With implicit intents I have to handle the case where no app can do it, otherwise startActivity throws. A PendingIntent wraps an intent and hands it to someone else, like the notification system or AlarmManager, so they can fire it later with my app's identity and permissions. That's how tapping a notification opens my screen even if my app isn't running. On recent Android versions I have to say whether it's mutable or immutable, and I choose immutable unless something like an inline reply needs to fill it in."
Not knowing what happens when no app matches an implicit intent, or treating PendingIntent as just another kind of intent.
Background services: heavily limited since Android 8; not a safe home for this.
Foreground service: for ongoing work the user is aware of, with a visible notification and a declared type.
WorkManager: persisted, guaranteed work with constraints and retries.
Choice: WorkManager, made long-running with a notification if the upload is big.
"I'd use WorkManager. The work is persisted, so it survives the app being closed and even a reboot, and I can add a network constraint, retry with backoff, and use unique work so the same batch isn't queued twice. A plain background service isn't reliable here, because since Android 8 the system stops background services soon after the app leaves the foreground. A foreground service is meant for ongoing work the user is actively aware of, like navigation or music, and it needs a notification and, on newer versions, a declared service type. For a large upload the user started and is waiting on, I'd still use WorkManager but make the worker long-running with setForeground, which shows a progress notification. I'd also save upload progress so a retry doesn't start from zero."
Starting a plain background service or a thread from an activity and assuming it will keep running after the app closes.
Least access: foreground location covers this feature.
Ask in context: request it when the user taps the feature, with a short reason first.
Denial: degrade gracefully, and send to settings only after repeated denials.
Risk: background location brings extra review and fewer users saying yes.
"I'd push back, because asking for more than we need hurts us twice. Users are much more likely to say no to a scary prompt on first launch, and background location brings a stricter store review where we'd have to justify it. Since the feature only runs while the app is open, foreground location is enough. I'd request it when the user taps the feature, using the activity result permission API, with a short explanation first if the system says a rationale should be shown. If they deny it, the feature falls back to letting them type a city. On recent Android versions, after two denials the system stops showing the prompt, so then I'd offer a button to the app's settings page instead. If we later need tracking during an active trip, a foreground service with a location type is the right route, not background access."
Requesting every permission on first launch, or treating the permission prompt as something to get past rather than a user choice.
Version: bump the database version.
Migration: write a Migration with the SQL change and register it.
Avoid: destructive fallback, which wipes user data.
Test: export the schema and test every upgrade path.
"First I update the entity and bump the database version number. Then I write a Migration from the old version to the new one that runs the SQL, here an ALTER TABLE that adds the column with a default value that matches the entity, and I register it with addMigrations on the database builder. If I bump the version and forget the migration, Room throws when it opens the database, so the app crashes on launch for everyone who updates. The shortcut is fallbackToDestructiveMigration, but that deletes the user's data, so I only accept it for pure caches. For simple changes like adding a column, Room can also generate an auto-migration from the exported schemas. Either way I keep exportSchema on, commit the schema files, and write a migration test that upgrades a real old database."
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN note TEXT NOT NULL DEFAULT ''")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_3_4)
.build()
Reaching for fallbackToDestructiveMigration on user data, or not knowing the app crashes when a migration is missing.
API interface: suspend functions returning data models.
Repository: calls the API, catches network and HTTP errors, maps to a result type.
ViewModel: turns results into one UI state the screen renders.
Cross-cutting: auth headers and logging in OkHttp interceptors.
"I declare the API as a Retrofit interface with suspend functions. The repository is the only thing that calls it. With a suspend function that returns the body directly, a non-success status code throws an HttpException and a network problem throws an IOException, so the repository catches those two and maps them to a small sealed result, like success with data or failure with a reason the UI can explain. If I need the status code or headers, I return Response and check it myself. The ViewModel then turns that into one UI state, loading, content or error with a retry action, and exposes it as a StateFlow. Things every request needs, like the auth token and logging, go into OkHttp interceptors rather than each call."
sealed interface ApiResult<out T> {
data class Ok<T>(val data: T) : ApiResult<T>
data class Err(val reason: Reason) : ApiResult<Nothing>
}
enum class Reason { OFFLINE, NOT_FOUND, SERVER }
suspend fun getUser(id: String): ApiResult<User> = try {
ApiResult.Ok(api.getUser(id))
} catch (e: IOException) {
ApiResult.Err(Reason.OFFLINE)
} catch (e: HttpException) {
ApiResult.Err(if (e.code() == 404) Reason.NOT_FOUND else Reason.SERVER)
}
Calling Retrofit straight from an activity, or catching every exception and showing the same 'something went wrong' for all of them.
The risk: anything in the APK can be pulled out, even with obfuscation.
Better design: the app calls our backend, which holds the secret.
If it must be client-side: a restricted key or short-lived token from our server.
Tone: explain, offer help, agree next steps.
"I'd explain it without making it personal. Anything inside the app, including BuildConfig fields and strings, can be pulled out of the APK with free tools in a few minutes, and obfuscation only slows that down. If it's a secret that can spend money or read other users' data, someone will eventually take it. The safer design is for the app to call our own backend, which holds the secret and calls the service, so we can also add rate limits and logging. If the service is meant to be called from the client, like a maps key, I'd use a key restricted to our package name and signing certificate, or have our server issue a short-lived token. I'd offer to help build the backend piece, so it doesn't feel like I'm just blocking the feature."
Believing obfuscation or hiding the key in a resource file makes it safe to ship.
Problem: classes building their own dependencies are hard to test and share.
Setup: HiltAndroidApp on Application, AndroidEntryPoint on screens.
ViewModel: HiltViewModel with an Inject constructor.
Bindings: a module with Binds or Provides, installed in a component with a scope.
"Without injection, each class creates its own dependencies, so a ViewModel might build its own repository, which builds its own Retrofit client. That's hard to test and wastes objects. Hilt, which is built on Dagger, generates that wiring at compile time. I annotate the Application with HiltAndroidApp and each activity or fragment with AndroidEntryPoint. The ViewModel gets HiltViewModel and an Inject constructor that asks for the repository interface. Then I tell Hilt which implementation to use in a module installed in the singleton component. I use Binds for my own interface-to-class mappings and Provides for things I can't annotate, like the Retrofit instance. In the screen I just use by viewModels, or hiltViewModel in Compose. In tests I can swap the module for fakes."
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repo: ProfileRepository
) : ViewModel()
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds @Singleton
abstract fun bindProfileRepo(impl: DefaultProfileRepository): ProfileRepository
}
Describing Hilt as a library you add 'because everyone does' with no idea what problem it solves for testing.
UI layer: screens render state and send events; the ViewModel holds UI state.
Domain layer: optional use cases for business rules shared across screens.
Data layer: repositories over network and database sources.
Direction: state flows down, events go up, and each layer depends only on the one below it through interfaces.
"I split it into three layers. The UI layer is the activity, fragment or composables plus the ViewModel. The screen only renders a state object and sends user events up. The ViewModel turns events into calls and exposes one UI state as a StateFlow, and it doesn't hold views or contexts. Under that, an optional domain layer holds use cases, like applying a discount rule, when that logic is shared or complex enough to test alone. The data layer has repositories that hide where data comes from, network, Room or DataStore, and decide things like caching. State flows down and events go up. Each layer only knows about the one below it through interfaces, so I can test the ViewModel with a fake repository and swap a data source without touching the UI."
Putting network calls or business rules in the activity, or a ViewModel that holds a reference to a view or activity context.
Source of truth: the UI reads only from Room; the network only writes into Room.
Paging: Paging with a remote mediator that fetches the next page into the database.
Freshness: WorkManager periodic sync with constraints, plus refresh on open if data is stale.
Offline actions: an outbox table synced later, with a clear conflict rule.
"I'd make Room the single source of truth. The feed screen observes a paged query from Room, and the network never talks to the UI directly. It only writes into the database. For paging I'd use the Paging library with a remote mediator, so when the user nears the end of the cached items it fetches the next page and inserts it, and the list updates itself. Each row stores when it was fetched, so on opening the app I refresh only if the data is stale. For background freshness, a periodic WorkManager job runs on unmetered network, and I'd use conditional requests so an unchanged feed costs almost nothing. If the user likes or saves an article offline, I update the local row right away and write the action to an outbox table that a worker sends later, with the server as the final word on conflicts."
Showing network results directly and treating the database as an afterthought, or having no plan for actions taken while offline.
Why: the real problem the old approach caused.
Plan: where you started and how old and new lived side by side.
Risk: how you kept releases safe along the way.
Result: what improved, and what you'd do differently.
"In my last team, most screens used callbacks and a lot of logic lived in fragments, which made bugs hard to test. A full rewrite wasn't an option, so I proposed a rule: any screen we touch for a feature moves to a ViewModel with coroutines, and new screens are written in Compose. I started with one small settings screen to set the pattern, wrote a short guide, and used ComposeView so Compose screens sat inside our existing fragments. For shared code we wrapped the old callback APIs with suspendCancellableCoroutine, so new code didn't have to wait for the old code to change. It took about nine months, and the crash rate on migrated screens dropped noticeably. What I'd change is agreeing on the UI state pattern earlier, because the first few screens did it three different ways."
A story about a rewrite that froze features for months, or a migration done alone without the team agreeing on the pattern.
Pattern: something long-lived holds a reference to something short-lived.
Common cases: static or singleton holding an activity, listeners never removed, fragment view binding kept after the view dies.
Finding: LeakCanary in debug builds, heap dumps in the memory profiler.
Fixing: application context, unregister in the matching callback, clear references.
"A leak on Android is almost always something long-lived holding a reference to something short-lived, usually an activity or a view. The classic cases are a singleton or companion object keeping an activity context, a listener or callback registered with a system service and never unregistered, an inner class or long-running job capturing the activity, and in fragments, keeping the view binding after onDestroyView, because the fragment outlives its view. To find one, I run LeakCanary in debug builds, which dumps the heap after a screen is destroyed and shows the chain of references keeping it alive. For harder cases I take a heap dump in the Android Studio memory profiler. Fixes are usually small: use the application context for long-lived things, unregister in the matching lifecycle callback, and null the binding in onDestroyView."
Saying the garbage collector handles everything, or only ever finding leaks by waiting for out-of-memory crashes.
Cause: the main thread is blocked too long, for example an input event not handled within about five seconds.
Usual suspects: disk or network on main, lock waits, slow broadcast receivers, heavy startup work.
Evidence: Play Console ANR clusters, main thread stacks, exit reasons on the device.
Prevent: StrictMode in debug, move work off main, measure startup.
"An ANR means the main thread was blocked too long, for example an input event wasn't handled within about five seconds, or a broadcast receiver or service didn't finish in time. In production I start with the ANR clusters in the Play Console, which show the main thread's stack when it happened. The top of that stack tells me whether it was doing disk work, waiting on a lock, making a synchronous binder call or just doing too much. On newer Android versions the app can also read the exit reasons the system recorded for it, including the ANR trace. The common roots I've seen are database or SharedPreferences writes on main, a lock held by a background thread, and heavy work at startup. In debug builds I turn on StrictMode so disk and network on the main thread get caught before release."
Treating ANRs like crashes you can reproduce on your own phone, or guessing at fixes without looking at the main thread trace.
Situation: the app, the symptom and how many users it hit.
Evidence: what the traces and device data showed.
Root cause: the real reason, not the first guess.
Fix and guard: the change, how you verified it, what you added to stop a repeat.
"At my last company, after a release our crash reports showed an IllegalStateException in a fragment transaction, mostly on older, low-memory phones. My first guess was a missing null check, but the stacks showed it happened after the activity had saved its state. Grouping the reports by device and by the steps before the crash, I found the pattern: users opened a payment screen, switched to their banking app, and our network callback then tried to show a dialog fragment while we were in the background. I reproduced it by starting a slow payment call and switching apps before it finished. The fix was to move the result into ViewModel state and show the dialog only when the screen was started again. I added a test for that path, and the crash disappeared from the next release."
A story where the fix was wrapping the code in try-catch, or where you never found out why it happened.
Reproduce: a real low-end device, a release build, the same data.
Measure: frame timing, a system trace, recomposition counts or bind times.
Usual causes: heavy binding, oversized images, deep layouts, work on the main thread while scrolling.
Verify: re-measure after each change, and add a benchmark.
"First I'd reproduce it properly: on a low-end phone, with a release build, because debug builds, and Compose in particular, are much slower and can send me after the wrong problem. Then I'd measure instead of guessing. I'd record a system trace while scrolling to see which frames miss their deadline and what the main thread is doing in them. For a RecyclerView list I'd look at time in onBindViewHolder and layout. For Compose, I'd check recomposition counts in Layout Inspector. The usual culprits are loading full-size images into small thumbnails, formatting dates or parsing text during binding, deep nested layouts, and database or disk work landing on the main thread while scrolling. Once I've fixed the top cause, I'd re-measure and add a macrobenchmark for scrolling so it doesn't quietly get worse again."
Jumping straight to rewriting the list or adding caching everywhere without measuring on the device that has the problem.
Fakes: a fake repository you control instead of the real network.
Main dispatcher: replace it with a test dispatcher through a JUnit rule.
runTest: virtual time, so delays don't slow the test.
Assert on state: check the exposed state or collect the flow.
"I test the ViewModel as a plain JVM unit test, with no device. The repository is an interface, so I pass in a fake whose results I control, like returning data or throwing an IOException. viewModelScope runs on the Main dispatcher, which doesn't exist on the JVM, so I use a small JUnit rule that swaps Main for a test dispatcher before each test and resets it after. The test body runs inside runTest, which uses virtual time, so a delay or debounce doesn't make the test slow. Then I call the ViewModel's function and assert on the state it exposes. For flows that emit several values in order, I collect them in the test and check the whole sequence. If a class uses IO or Default, I inject the dispatcher so the test can replace it."
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
class ProfileViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test fun showsErrorWhenLoadFails() = runTest {
val vm = ProfileViewModel(FakeProfileRepository(fail = true))
vm.load()
assertEquals(UiState.Error, vm.state.value)
}
}
Adding Thread.sleep to wait for coroutines, or mocking so much that the test only checks that mocks were called.
Build: App Bundle, R8 shrinking and obfuscation, test the release variant itself.
Signing: upload key locally, app signing key held by Play.
Tracks: internal, then closed testing, then a staged production rollout.
Watch: crash and ANR rates, halt if needed; fixes ship as a new version.
"I build an Android App Bundle for the release variant with R8 turned on, which shrinks and obfuscates the code, and I test that exact build, because R8 can strip classes used through reflection, which only shows up as a crash in release. I keep the mapping file so crash stack traces can be read. The bundle is signed with our upload key, and Play holds the real app signing key, so a lost upload key can be reset. I bump the versionCode, then push to the internal testing track, then a closed group, then production as a staged rollout to a small share of users. I watch the crash and ANR rates in Android vitals for a day or two before widening it. If something's wrong I halt the rollout, but people who already updated keep it, so the fix goes out as a new version."
Testing only debug builds, or thinking you can roll users back to the previous version from the console.
Symptom: what users saw and which devices.
Narrowing: grouping reports by OS version, manufacturer or settings.
Cause: the device or system difference behind it.
Fix: the change and how you tested on the affected devices.
"In a fitness app, some users said their reminder notifications never arrived, but they always worked on my phone. The reports clustered on a couple of manufacturers, so I borrowed a device from a colleague and found its battery saver was stopping our app completely in the background, which also removed our scheduled alarms. On top of that, a few users on newer Android versions had never granted the notification permission, because we only asked for it deep inside settings. We fixed it in two parts: we asked for the notification permission at the moment users turned reminders on, and added a short help screen explaining how to exclude the app from battery saving on those phones. After that, the complaints dropped to almost none, and we added those device models to our test list."
Closing the report as 'cannot reproduce' without looking at which devices or OS versions were affected.
Contain: halt the rollout and switch off the feature flag if one exists.
Tell people: a short note to the team and support.
Investigate: stack traces, device models, OS versions, what changed in this build.
Fix forward: a new build with a higher version, through testing again.
"First I'd halt the rollout in the Play Console so no more users get the build. If the new code sits behind a remote feature flag, I'd switch it off too, since that helps people who already updated. Then I'd post a short note to the team and support: what we're seeing, that the rollout is paused, and when I'll update them next. After that I'd dig in. I'd look at the stack traces, the device models and OS versions, and compare against what changed in this build, maybe a new library version or a camera or media API that behaves differently on that manufacturer. If I can't get the device, a cloud device lab often has it. The fix goes out as a new build with a higher version code, through internal testing first, and I'd add that device to our test list."
Starting to debug while the rollout keeps going, or keeping the team and support in the dark until the fix is ready.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.