Android interviews focus on the platform behaviours that catch developers out. Expect questions on the Activity lifecycle and configuration changes, why ViewModel exists and what it does not survive, background work and why WorkManager replaced Services, coroutines and dispatchers, StateFlow versus LiveData, Jetpack Compose and recomposition, runtime permissions, and Room. Performance, memory leaks and device fragmentation are recurring themes. The questions below cover the framework and production concerns.
Behavioural Questions
1. Tell me about an Android app you built or worked on. What was your role?
Note: Mobile interviews reward specifics about users and constraints. Play Store link, download numbers, or crash-free rate all make an answer concrete.
Cover:
- What the app did and its scale. Downloads, active users, and the range of devices and Android versions you supported. Supporting API 24 upwards is a very different job from targeting only recent devices.
- Your scope. Whether you owned the architecture, a feature, the network layer, or the release process. Be precise.
- The stack. Java or Kotlin, Views or Jetpack Compose, and the architecture — MVVM, MVI, or something older. Say honestly if it was a legacy codebase.
- The hard part. Strong candidates: background work surviving Doze and manufacturer battery restrictions, offline support and sync conflicts, memory or ANR problems on low-end devices, or fragmentation across OEM behaviour.
- The outcome — crash-free rate, startup time, app size, or rating before and after.
2. How do you handle testing on the huge variety of Android devices and versions?
Show that you approach fragmentation systematically rather than hoping.
- Decide coverage from your own data, not from general statistics. Play Console shows the actual distribution of devices, Android versions, and screen sizes among your users. That tells you what to support and, importantly, what to drop.
- Test the extremes deliberately. The oldest supported API level, the newest, a low-RAM device, a very small screen, and a tablet or foldable. Bugs cluster at the boundaries.
- Test on real devices for the things emulators cannot reproduce — camera, sensors, performance on weak hardware, and manufacturer battery management. Firebase Test Lab gives access to a device matrix in CI.
- Automate what you can. Unit tests for logic, Espresso or Compose tests for UI, and instrumented tests run across a small device matrix on every merge.
- Use staged rollouts. Releasing to 5% first, watching crash-free rate and ANR rate, then expanding, catches device-specific problems before they reach everyone.
Note: OEM-specific behaviour — particularly aggressive background process killing on some manufacturers — is a genuine Android problem that catches out candidates who have only tested on Pixels. Mentioning it signals real production experience.
3. Describe a difficult bug you fixed in an Android app.
Choose a bug that needed investigation, and describe the method.
Strong scenarios:
- A crash affecting only some devices or one manufacturer, found through Crashlytics stack traces and device breakdown.
- A memory leak — an Activity retained by a static reference, a listener never unregistered, or an inner class holding an implicit reference to its outer Activity. LeakCanary and the Android Studio Memory Profiler are the tools.
- An ANR caused by work on the main thread — a database query, a large JSON parse, or disk I/O.
- A configuration change bug where state was lost on rotation or after process death.
- A race condition in asynchronous code that only appeared on slow networks.
How to tell it: how you reproduced it (usually the hardest part), how you narrowed it down, the tools you used, the root cause, and the regression test or structural change that prevents recurrence.
Note: Process death is an excellent thing to mention. Testing with "Don't keep activities" enabled in developer options simulates it, and a large share of Android state bugs only appear that way — most candidates have never tried it.
4. How do you work with designers and product managers on mobile features?
Show that you engage before implementation, since mobile has constraints designers may not think about.
- Review designs early and raise platform realities. Material Design conventions, back navigation behaviour, system bars and gesture insets, keyboard behaviour, and how the layout survives a 320dp-wide screen or a 200% font scale.
- Ask about the states designs usually omit — empty, loading, error, offline, and very long content. On mobile, offline is not an edge case.
- Quantify cost rather than refusing. "That animation is possible but it needs a custom view and about three days; this alternative gets the same effect in half a day" is a conversation.
- Push back on anything that hurts accessibility — touch targets below 48dp, poor contrast, or text that does not scale.
- Get something running early. A rough build on a real device surfaces problems no mockup reveals, because a design that looks good on a laptop can feel wrong in the hand.
Note: Mentioning that you flag when a design fights platform conventions — for example an iOS-style pattern that confuses Android users — shows you advocate for the user rather than just implementing tickets.
5. Android changes significantly with each release. How do you keep current and manage those changes?
How you keep up: the Android developer release notes and behaviour change documentation for each API level, the Android Developers Blog, and Jetpack library release notes. Google I/O sessions give the reasoning behind changes rather than just the rules.
How you manage them — the practical part:
- Read the behaviour changes for the new API level before raising
targetSdkVersion. Google separates changes affecting all apps from those affecting only apps that target the new level, and that distinction determines urgency. - Play Store deadlines force the schedule. Google requires apps to target a recent API level to remain updatable, so this is not optional work and should be planned, not discovered.
- Test against the new version early, during the developer preview if the app is significant.
- Watch the recurring themes — background execution limits, storage access, and permissions have tightened with almost every release, and each has broken apps that were not paying attention.
Note: Distinguish keeping up with the platform from keeping up with libraries. Compose and architecture libraries change fast, but platform behaviour changes are the ones that break shipped apps for real users, so they take priority.
6. Tell me about a time you reduced an Android app's crash rate or ANR rate. How did you decide what to fix first?
This question checks whether you treat stability as a measurable engineering problem. Interviewers want to hear about data, prioritisation and verified results, not just “I fixed some crashes”.
Structure the answer with STAR:
- Situation — start with the metric. For example: “Our user-perceived ANR rate in Play Console Android vitals was 0.8%, above Google's bad-behaviour threshold of 0.47%, which risked reduced visibility on the Play Store.”
- Task — your ownership: leading a stability sprint, or owning a module.
- Action — the substance:
- Grouping issues in Crashlytics and Play vitals by stack signature, then ranking by users affected, not raw event count.
- Slicing by device, manufacturer and OS version to spot patterns, such as crashes only on Android 8 or on low-memory devices.
- Fixing root causes rather than wrapping code in try-catch: moving disk reads and
SharedPreferences.commit()off the main thread, fixing fragment transactions committed after state was saved, downsampling large bitmaps. - Adding StrictMode in debug builds and a crash-free-users alert so regressions are caught early.
- Shipping fixes through a staged rollout and watching vitals before going to 100%.
- Result — the numbers: ANR rate from 0.8% to 0.2%, crash-free users from 98.9% to 99.7%, fewer one-star reviews mentioning freezes.
Show judgement: mention a crash you deliberately deprioritised because it affected a handful of users on an obscure custom ROM, and explain why.
Note: Interviewers often follow up with “what was the hardest one?”. Keep one specific issue ready — the stack trace, how you reproduced it, and the fix — because that detail proves you did the work yourself.
7. Describe how you migrated part of an Android app to Kotlin or Jetpack Compose without disrupting regular releases.
Migrations are a common reality in Android teams. The interviewer wants to see that you can modernise incrementally, keep shipping features, and bring the team along.
Points a strong answer covers:
- The reason. Tie the migration to business value — faster feature development, fewer UI bugs, easier hiring — not just “Compose is newer”.
- An incremental plan.
- New screens written in Compose or Kotlin from day one; existing screens migrated when they needed significant changes anyway.
- Starting with leaf components and the design system — buttons, text styles, theme — so every later screen looked consistent.
- Using interop:
ComposeViewinside existing fragments,AndroidViewfor views that had no Compose equivalent yet, and the same ViewModels driving both UIs. - For Kotlin, converting a file at a time with tests in place, and cleaning up platform-type nullability rather than scattering
!!operators.
- Safety nets. Screenshot tests to catch visual regressions, UI tests on migrated flows, and feature flags so a migrated screen could be switched back.
- Measuring the impact. APK size, startup time and scroll performance before and after — Compose adds some size and needs Baseline Profiles for good first-run performance.
- The team. Brown-bag sessions, a short internal guide on state hoisting and side effects, and reviewing each other's first Compose PRs.
Finish with a result: for example, “Within two quarters 60% of screens were in Compose, the settings rewrite took half the estimated time, and we never delayed a release”.
Note: Mention a mistake honestly, such as a screen that recomposed excessively because of an unstable list parameter. Explaining how you found it with the Layout Inspector shows real hands-on experience.
8. A new release on the Play Store is crashing on launch for some users. Walk me through what you would do.
This is an incident-response question. Interviewers look for calm prioritisation: stop the damage, understand it, fix it safely, then prevent it happening again.
Structure your answer in phases:
- Contain. If the release is in a staged rollout, halt the rollout in Play Console immediately so no more users receive it. If a remote config or feature-flag kill switch covers the new feature, turn it off — that can fix affected users without a new build.
- Assess. Check Crashlytics and Android vitals: how many users, which devices and Android versions, and the top stack trace. A crash limited to one manufacturer or OS version points to very different causes than one hitting everyone.
- Communicate. Tell the product manager, support team and stakeholders what is known, what you are doing and when you will update them. Support needs a message for users.
- Fix. Reproduce on a matching device or emulator, fix the root cause, and ship a hotfix. Remember that Play does not let you roll back to an older build: you ship a new build with a higher versionCode, containing either the fix or the previous good code.
- Verify. Release the hotfix through a quick internal test and a fast staged rollout, watching the crash-free rate before expanding.
Then the post-mortem — blameless, focused on the gap: Why did testing not catch it? Was the device missing from the test matrix? Would the pre-launch report or a longer 1% rollout have caught it? Turn the answers into concrete actions.
Note: If you have a real story, quantify it — “halted at 5% rollout, hotfix live in four hours, crash-free users back above 99.5% the next day”. Numbers make incident stories credible.
9. Tell me about a time platform restrictions or Play Store policies forced you to change how a feature worked.
Android's privacy and battery rules tighten every year, so interviewers want engineers who see these changes coming and turn them into good product decisions rather than last-minute scrambles.
Good real-world examples to draw on:
- Scoped storage (Android 10 and 11) removing broad file access.
- Background location needing a separate permission and Play policy justification.
- Notification permission becoming a runtime permission in Android 13.
- Exact alarms requiring special access from Android 12 onwards.
- Foreground service types becoming mandatory in Android 14.
- Photo and video access policy pushing apps towards the system Photo Picker.
Structure the story:
- How you found out early — reading behaviour-change docs for the new API level, testing on developer previews, or a Play Console policy email.
- Impact analysis — which features broke, how many users were affected, and the deadline set by Play's target API requirement.
- The alternative you proposed — for example, replacing continuous background location with geofencing plus WorkManager, or using the Photo Picker so the app needed no media permission at all.
- Working with others — product for the UX change, legal or privacy for the Data safety form, QA for testing on new OS versions.
- The result — shipped before the deadline, permission-grant rates improved, and fewer users uninstalled after a scary permission prompt.
Note: Frame restrictions positively. Saying the change made the app more trustworthy, or that asking for fewer permissions improved conversion, shows product thinking rather than frustration with the platform.
10. Describe a time you worked with a backend team to design or change an API for a mobile client.
Mobile clients have constraints that backend engineers do not always see. This question tests whether you can represent those constraints clearly and reach a design that works for both sides.
Mobile-specific concerns worth raising in your story:
- Old app versions live for years. Users do not all update, so API changes must be backward compatible, or versioned, and fields should never change meaning.
- Unreliable networks. Requests fail midway and get retried, so write endpoints should be idempotent — for example with a client-generated request ID.
- Round trips are expensive. A screen that needs five calls on a slow 3G connection feels broken; an aggregated endpoint or a well-designed payload helps.
- Pagination. Cursor-based rather than page-number pagination avoids duplicates when new items arrive while the user scrolls.
- Offline sync. Fields such as
updatedAtand soft-delete flags let the client fetch only changes. - Consistent errors. A predictable error format with machine-readable codes, so the app can show the right message.
Structure the story with STAR: the feature and the original proposal, the problem you spotted (such as a breaking change to a field older app versions depended on), how you collaborated — a shared API contract in OpenAPI, a mock server so the app could build in parallel, a review meeting — and the outcome.
Example result: “We added a cursor-based feed endpoint, which cut the home screen from four calls to one and reduced load time on slow networks by about 40%.”
Note: Emphasise that you listened too. Backend teams have their own constraints — caching, database load, security — and interviewers value a candidate who reached a compromise rather than simply demanding a mobile-friendly API.
Technical Questions
11. Explain the Android Activity lifecycle and why it matters.
An Activity moves through callbacks as the system creates, shows, hides, and destroys it:
onCreate()— once, when created. Inflate the layout, initialise the ViewModel, restore saved state.onStart()— becoming visible.onResume()— in the foreground and interactive. Start camera, sensors, animations, and location updates here.onPause()— losing focus but possibly still partly visible. Keep this fast; anything slow delays the next Activity appearing.onStop()— no longer visible. Release heavier resources and unregister listeners.onDestroy()— being destroyed, either by finishing or by a configuration change.
Why it matters so much:
- Configuration changes destroy and recreate the Activity by default. Rotating the screen runs the whole cycle again, so anything held only in Activity fields is lost. This is why ViewModel exists — it survives configuration changes.
- Process death is different and harsher. Android can kill a backgrounded app entirely to reclaim memory. ViewModel does not survive that; only
onSaveInstanceStateorSavedStateHandledoes. - Leaks come from lifecycle mismatches — a listener registered in
onCreateand never unregistered keeps the Activity alive.
Note: Lifecycle-aware components and repeatOnLifecycle for collecting flows are the modern answer, because they tie observation to the lifecycle automatically rather than relying on you remembering to unregister.
12. What is the difference between Activity, Fragment and Service?
- Activity — a single screen with a user interface, and an entry point into the app. It has its own lifecycle and appears in the back stack. Modern apps typically use few Activities — often a single one hosting many destinations.
- Fragment — a reusable portion of UI hosted inside an Activity, with its own lifecycle tied to the host's. Fragments exist so that one Activity can present different content — essential for tablets and foldables where two panes appear side by side, and the basis of Navigation Component destinations. The critical subtlety is that a Fragment has two lifecycles: the Fragment itself, and its view, which is destroyed and recreated when the Fragment goes onto the back stack. Observing LiveData with the Fragment's lifecycle rather than
viewLifecycleOwneris a classic bug that causes duplicate observers. - Service — a component for work with no user interface, running in the background. Foreground services show a persistent notification and are for user-visible ongoing work such as music playback or navigation. Bound services provide an interface for other components to call.
Note: The important modern point about Services: background execution limits since Android 8 mean a plain background Service is no longer a reliable way to do deferred work. WorkManager is the correct API for guaranteed background work, and a foreground service is required for anything long-running and user-visible. Saying you would use a Service for periodic sync is a dated answer.
13. What is an Intent, and what is the difference between explicit and implicit intents?
An Intent is a messaging object describing an operation to be performed. It is how Android components communicate, including across app boundaries.
- An explicit Intent names the exact component to start, by class. Used for navigation within your own app — starting your own Activity or Service. It is unambiguous and secure, because you know exactly what will handle it.
- An implicit Intent declares an action and lets the system find a component that can handle it. Sharing text, opening a URL, dialling a number, or picking an image. The system matches the action, data, and category against the intent filters declared by installed apps, and shows a chooser if several qualify.
// Explicit
startActivity(Intent(this, DetailActivity::class.java))
// Implicit
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))Practical points that matter:
- Always handle the case where nothing can handle an implicit Intent, or the app crashes with
ActivityNotFoundException. - Package visibility since Android 11 means you must declare
<queries>in the manifest to see which apps can handle certain intents. - Never send sensitive data in an implicit Intent — any app with a matching filter can receive it.
- PendingIntents must specify mutability (
FLAG_IMMUTABLEorFLAG_MUTABLE) from Android 12, and immutable should be the default.
14. What is MVVM, and how do ViewModel, LiveData and StateFlow fit together?
MVVM separates concerns into three layers:
- Model — data and business logic, usually behind a Repository that decides between network and local cache.
- View — the Activity, Fragment, or Composable. It observes state and forwards user events. It should contain no business logic.
- ViewModel — holds and exposes UI state, and survives configuration changes. It must never reference a View or Context that could leak an Activity.
The observable holders:
- LiveData — lifecycle-aware by design. It only emits to active observers and cleans up automatically, which is why it was safe before coroutines matured. It always holds a current value.
- StateFlow — the Kotlin coroutines equivalent, holding a current value and emitting updates. It is not lifecycle-aware by itself, so it must be collected inside
repeatOnLifecycle(STARTED), or withcollectAsStateWithLifecycle()in Compose. Otherwise collection continues while the app is backgrounded, wasting work. - SharedFlow — for one-off events such as showing a snackbar or navigating, where a state holder would replay the event after rotation.
Note: StateFlow is the modern recommendation because it composes with the rest of the coroutines API — operators, combining streams, and structured concurrency — while LiveData does not. The single-event problem is the detail interviewers probe: using StateFlow for navigation events causes the navigation to repeat on rotation.
15. What is Jetpack Compose and how does it differ from the View system?
Jetpack Compose is Android's declarative UI toolkit. You describe what the UI should look like for a given state, and the framework updates it when the state changes.
The differences from Views:
- Declarative versus imperative. With Views you find a widget and mutate it —
textView.text = name. With Compose you write a function of state, and recomposition handles updating. This removes a whole class of bug where the UI and the underlying state drift apart. - No XML. Layouts are Kotlin, so you get type safety, loops, conditionals, and normal refactoring tools.
- Composition over inheritance. Reuse comes from small composable functions rather than subclassing View.
- State is explicit.
rememberholds state across recompositions;rememberSaveablealso survives configuration changes and process death.
Recomposition is the key concept: when state a composable reads changes, Compose re-runs that function. It is intelligent — only affected composables re-run — but composables must therefore be side-effect free and cheap, since they may run often and in any order. Side effects belong in LaunchedEffect, DisposableEffect, or similar.
Note: Two practical points. Compose and Views interoperate, so migration can be incremental via ComposeView and AndroidView. And state hoisting — moving state up so a composable takes state and a callback rather than owning it — is the pattern that makes composables reusable and testable.
16. How do you handle background work and threading in Android?
The main thread draws the UI. Blocking it for more than a few hundred milliseconds causes jank; blocking it for five seconds causes an ANR. Network calls, database queries, and file I/O must never run there.
Coroutines are the modern answer:
viewModelScopeties work to the ViewModel and cancels it automatically when the ViewModel clears.lifecycleScopeties work to an Activity or Fragment lifecycle.- Dispatchers choose the thread pool —
Dispatchers.IOfor network and disk,Dispatchers.Defaultfor CPU-bound work,Dispatchers.Mainfor UI.withContextswitches between them cleanly. - Structured concurrency means cancelling a scope cancels its children, which prevents leaked work.
For deferrable, guaranteed work — WorkManager. This is the important distinction. Work that must complete even if the app is closed or the device restarts — uploading a file, periodic sync, sending analytics — belongs in WorkManager, which handles constraints (network available, charging), retries with backoff, and chaining. It respects Doze and battery optimisation rather than fighting them.
What not to use: AsyncTask is deprecated and leaks Activities; raw Thread gives no lifecycle awareness; and a plain background Service is unreliable under background execution limits since Android 8.
Note: Manufacturer battery optimisation aggressively kills background work on some devices. Mentioning it — and that you would use WorkManager and expedited work rather than assuming a Service stays alive — signals real experience.
17. How do you store data in an Android app, and what is Room?
The options, matched to the use case:
- DataStore — key-value or typed proto storage for small data such as user preferences and flags. It replaces
SharedPreferences, which had a synchronous API that could block the main thread and no error signalling. DataStore is asynchronous, coroutine and Flow based, and transactional. - Room — an abstraction over SQLite for structured relational data. It is the standard answer for anything queryable.
- Internal storage — files private to the app, deleted on uninstall.
- MediaStore and the Storage Access Framework — for shared media and user-selected documents, since scoped storage restricted direct filesystem access from Android 10.
- EncryptedSharedPreferences or the Keystore — for tokens and sensitive values. Credentials should never sit in plain preferences.
Room specifically has three parts: Entity classes annotated to define tables, a DAO interface declaring queries, and a Database class tying them together. Its main advantage is that SQL is verified at compile time — a typo in a query is a build error rather than a runtime crash, which raw SQLite could not offer. It also returns Flow, so the UI updates automatically when the underlying data changes, and integrates with coroutines so queries run off the main thread.
Note: Migrations are the practical difficulty. Changing a schema requires a Migration, and falling back to destructive migration wipes user data — acceptable in development, never in production.
18. How do Android permissions work, and what changed with runtime permissions?
Permissions are declared in the manifest, but how they are granted depends on their protection level.
- Normal permissions — low risk, such as internet access or vibration. Granted automatically at install; the user is never asked.
- Dangerous permissions — access to private data or sensitive hardware: camera, location, contacts, microphone, and certain storage access. Since Android 6 (API 23) these must be requested at runtime, and the user can deny or later revoke them.
- Signature permissions — granted only to apps signed with the same certificate.
The runtime flow: check with ContextCompat.checkSelfPermission, request through the Activity Result API, and handle the result. Critically, you must handle all three outcomes — granted, denied, and permanently denied — and the app must remain usable when a permission is refused. Requesting everything at launch is the pattern most likely to get an app uninstalled.
What tightened since:
- Android 10 — background location became a separate, harder-to-obtain permission, and scoped storage restricted filesystem access.
- Android 11 — one-time permissions, and auto-revocation for unused apps.
- Android 13 — granular media permissions replacing broad storage access, and a runtime permission for notifications.
- Android 14 — partial photo and video access, letting users share only selected items.
Note: Explain why you need a permission in context, immediately before requesting it. Requesting without context is the main cause of denial.
19. How do you improve the performance of an Android app?
Measure first with Android Studio Profiler for CPU, memory, and network; Macrobenchmark for startup and scrolling; and Play Console vitals for real-world ANR and crash rates on real devices, which is what actually matters.
Startup time:
- Do as little as possible in
Application.onCreate. Third-party SDK initialisation is the usual culprit — use App Startup or lazy initialisation. - Avoid disk and network on the startup path.
- Baseline Profiles ship ahead-of-time compilation hints and give a substantial, low-effort improvement to startup and scrolling.
Rendering and jank:
- Keep the main thread free — no I/O, no heavy parsing.
- Flatten deep view hierarchies; use ConstraintLayout, or Compose with stable parameters to limit recomposition.
- Use paging for long lists rather than loading everything.
Memory:
- Fix leaks with LeakCanary — retained Activities are the most common.
- Load images at display size with Coil or Glide rather than full resolution bitmaps, which is the single largest source of out-of-memory crashes.
App size: enable R8 shrinking and obfuscation, ship an Android App Bundle so users download only what their device needs, use WebP or vector drawables, and audit dependencies.
Note: Test on a low-end device. An app that feels fine on a flagship can be unusable on the hardware much of the market actually uses.
20. What is dependency injection in Android, and why use Hilt or Dagger?
Dependency injection means a class receives its dependencies from outside rather than constructing them. Instead of a ViewModel creating its own Repository, which creates its own API client, the dependencies are supplied.
Why it matters on Android specifically:
- Testability. A ViewModel that constructs a real network client cannot be unit tested. One that receives a Repository interface can be given a fake. This is the main argument.
- Lifecycle-correct scoping. Some objects should be singletons for the app's life — a database, an OkHttp client — while others should live only as long as a screen. A DI framework enforces that rather than leaving it to static fields, which leak.
- Less boilerplate. Without it, every object graph must be wired by hand, and changing a constructor means editing every call site.
Hilt is built on Dagger and is the recommended option for Android. It generates the code at compile time, so errors surface as build failures rather than runtime crashes, and it has no reflection overhead. It provides predefined components matching Android lifecycles — SingletonComponent, ViewModelComponent, ActivityComponent — with annotations such as @HiltAndroidApp, @AndroidEntryPoint, and @Inject.
Note: Koin is the common alternative — simpler to learn and Kotlin-idiomatic, but it resolves at runtime, so a missing binding is a crash rather than a compile error. That trade-off is the honest comparison to draw.
21. How do Fragment transactions and the back stack work, and what is the difference between add and replace?
Fragments are managed by a FragmentManager. Every change — adding, removing or replacing fragments in a container — happens inside a transaction.
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.container, DetailFragment::class.java, bundleOf("id" to itemId))
addToBackStack(null)
}addplaces a new fragment in the container on top of whatever is already there. The previous fragment stays attached, keeps its view and remains in the started state — both views can overlap, and both keep consuming resources.replaceremoves every fragment currently in that container and then adds the new one. It is what you want for normal screen-to-screen navigation.
The back stack stores transactions, not fragments. addToBackStack records the transaction so that pressing back reverses it. Without it, the replaced fragment is destroyed; with it, the old fragment goes to the back stack, where its view is destroyed (onDestroyView) but the fragment instance survives. When the user returns, onCreateView runs again. This is why view references must be cleared in onDestroyView and why observers should use viewLifecycleOwner.
Committing:
commit()is asynchronous — scheduled on the main thread's queue.commitNow()runs synchronously but cannot be added to the back stack.- Committing after
onSaveInstanceStatethrowsIllegalStateException, because the change could not be restored after process death.commitAllowingStateLoss()suppresses it, but only use it when losing that change is genuinely acceptable.
setReorderingAllowed(true) lets the manager optimise and run transitions correctly, and should always be set.
Note: In modern apps the Navigation component performs these transactions for you, but interviewers still ask this, because understanding the back stack and the separate view lifecycle explains many real Fragment bugs.
22. Which events cause a configuration change on Android, and why is declaring android:configChanges usually the wrong fix?
A configuration change happens when something that can affect resource selection changes while the app is running. Common triggers:
- Screen rotation, resizing in multi-window or free-form mode, and folding or unfolding a foldable.
- Switching dark mode (
uiMode), changing the system or per-app language, or font size. - Keyboard availability, and moving to an external display with a different density.
By default the system destroys and recreates the Activity, so it reloads layouts, strings and dimensions for the new configuration. That is why state held only in Activity fields disappears on rotation.
The correct way to handle it:
- ViewModel for screen data and in-flight work — it survives recreation.
- Saved instance state —
SavedStateHandlein the ViewModel, orrememberSaveablein Compose — for small UI state such as scroll position, selected tab or typed text. Views with IDs save their own state automatically. - Persistent storage for anything the user would expect to survive the app closing.
Why android:configChanges is usually wrong: declaring it tells the system not to recreate the Activity, so you receive onConfigurationChanged instead and must update every affected resource yourself. Teams often add it to “fix” lost state on rotation, but:
- It only covers the changes you listed; others still recreate the Activity.
- Process death still destroys everything, so the underlying state-saving bug remains and resurfaces when the app returns from the background.
- Layouts meant for landscape or tablets are no longer loaded automatically.
There are legitimate uses — a full-screen video player or a game — and some Compose-only apps opt out of recreation deliberately because Compose reads the new configuration during recomposition. But it must be a conscious design choice, not a way to hide state bugs.
Note: A good test is to rotate, change the language and toggle dark mode on every screen, then repeat with “Don't keep activities” enabled. Screens that survive all of those handle state correctly.
23. What is process death on Android, and how do you save and restore UI state so users do not lose their place?
Process death happens when Android kills a backgrounded app's process to reclaim memory. The user never sees it happen: when they return from the recents screen, the system restarts the process and recreates the task's Activities, handing each one the state it saved earlier.
Everything held only in memory is gone — ViewModels, singletons, static fields and in-flight coroutines. Only what was saved into the instance-state Bundle, or persisted to disk, comes back.
Tools for surviving it:
SavedStateHandlein the ViewModel — a key-value map backed by the saved Bundle, which can also expose values as aStateFlow.rememberSaveablein Compose for UI-element state such as a text field or expanded card.onSaveInstanceStatein Activities and Fragments; Views with IDs save their own state, such as EditText content and scroll position.- Room or DataStore for real data the user expects to persist.
@HiltViewModel
class SearchViewModel @Inject constructor(
private val savedState: SavedStateHandle,
private val repo: SearchRepository
) : ViewModel() {
val query: StateFlow<String> = savedState.getStateFlow("query", "")
fun onQueryChange(q: String) {
savedState["query"] = q // restored after process death
}
}What to save: small things that let you rebuild the screen — IDs, the search query, selected filters, the current step of a flow. Do not save whole lists or bitmaps: the Bundle travels through a Binder transaction with a limit of about 1 MB shared across the process, and exceeding it throws TransactionTooLargeException. Save the ID and reload the data.
How to test it: put the app in the background and run adb shell am kill with the package name, then reopen it from recents. The “Don't keep activities” developer option tests Activity recreation, but does not kill the process, so it misses bugs in singletons and static state.
Note: Process death is the most commonly untested scenario in Android apps. Interviewers are impressed when you explain that ViewModel survives rotation but not process death, and name the adb command to test the difference.
26. Why should you collect Flows with repeatOnLifecycle or collectAsStateWithLifecycle rather than launching directly in lifecycleScope?
Collecting a Flow is a coroutine that keeps running until it is cancelled. The question is when it should be cancelled.
The problem with a plain launch:
// Keeps collecting in the background until onDestroy
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}- While the app is in the background, the collector stays active. Upstream work — location updates, database queries, network polling — continues and wastes battery.
- The UI may be updated while it is not visible, and some operations, such as fragment transactions, crash when performed after state is saved.
- The older
launchWhenStartedonly suspended the collector when stopped; the upstream kept producing, so it has been deprecated.
The lifecycle-aware approach: repeatOnLifecycle starts the block each time the lifecycle reaches the target state and cancels it when it falls below it.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.uiState.collect(::render) }
launch { viewModel.events.collect(::handleEvent) }
}
}
// Jetpack Compose
val state by viewModel.uiState.collectAsStateWithLifecycle()- In fragments, use
viewLifecycleOwner, not the fragment itself, because the fragment outlives its view on the back stack. - Collect several flows with separate
launchcalls inside the block —collectsuspends forever, so a second collect written after the first would never run. - For a single flow,
flowWithLifecycleis a shorter alternative. - In Compose,
collectAsState()is not lifecycle-aware;collectAsStateWithLifecycle()from lifecycle-runtime-compose is the recommended API on Android.
Combined with SharingStarted.WhileSubscribed(5_000) in the ViewModel, this means the entire pipeline stops shortly after the app goes to the background and restarts when it returns.
Note: Choosing STARTED rather than RESUMED is deliberate: a screen can be visible but not resumed, for example behind a transparent dialog or in multi-window mode, and it should still display fresh data.
27. What is structured concurrency in Kotlin coroutines, and how does cancellation propagate between parent and child coroutines?
Structured concurrency means every coroutine is launched inside a CoroutineScope, and coroutines form a parent-child tree that follows the structure of the code. It guarantees three things:
- No leaks — a parent does not complete until all its children have completed.
- Cancellation flows down — cancelling a scope, such as
viewModelScopewhen the ViewModel is cleared, cancels every coroutine inside it. - Failure flows up — with a regular
Job, a child that fails with an exception cancels its parent, which then cancels all the other children.
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() }
val feed = async { api.feed() }
Dashboard(profile.await(), feed.await())
// if feed() throws, profile() is cancelled and the exception is rethrown
}coroutineScope creates a child scope for parallel work inside a suspend function and waits for it all — so the caller never gets back control while work it started is still running.
Cancellation is cooperative. Cancelling sets a flag; the coroutine stops only at a suspension point that checks it. All suspending functions in kotlinx.coroutines do, but a long CPU loop does not:
withContext(Dispatchers.Default) {
for (item in hugeList) {
ensureActive() // throws CancellationException if cancelled
process(item)
}
}Rules that follow from this:
- Do not swallow
CancellationException. A broadcatch (e: Exception)that does not rethrow it keeps a cancelled coroutine running.runCatchinghas the same trap. - Clean up in
finally, and wrap suspending cleanup inwithContext(NonCancellable). - Avoid
GlobalScope— it breaks the tree, so nothing cancels the work. Inject an application-level scope for work that must outlive a screen.
Note: Blocking calls such as Thread.sleep or a blocking socket read are not suspension points, so cancelling does not interrupt them. Use their suspending equivalents or runInterruptible.
28. How are exceptions handled in Kotlin coroutines, and when should you use SupervisorJob or supervisorScope?
How an exception behaves depends on the builder and the job that owns it.
launch— an uncaught exception propagates to the parent immediately. At the top of the hierarchy it goes to aCoroutineExceptionHandlerif one is installed; otherwise it reaches the thread's uncaught-exception handler, which crashes an Android app.async— the exception is stored and rethrown when you callawait(). But inside a regular scope the failure also cancels the parent, even if you never callawait(), which surprises many developers.- A regular
Job— one failing child cancels the parent and every sibling. SupervisorJoborsupervisorScope— a failing child does not affect its siblings or its parent. Each child is responsible for its own errors.
viewModelScope and lifecycleScope use a SupervisorJob, so one failed request does not cancel unrelated work on the screen.
// Simplest and clearest: handle errors where they happen
viewModelScope.launch {
try {
_state.value = UiState.Success(repo.load())
} catch (e: IOException) {
_state.value = UiState.Error(e.message)
}
}
// Independent tasks: one failing must not cancel the others
suspend fun syncAll() = supervisorScope {
launch { try { syncContacts() } catch (e: IOException) { log(e) } }
launch { try { syncPhotos() } catch (e: IOException) { log(e) } }
}Guidance:
- Prefer try-catch around the suspending call in the coroutine that can meaningfully handle it — typically turning it into UI state.
- Use
CoroutineExceptionHandleras a last-resort logger on a root scope, not as normal error handling. It only works on root coroutines or direct children of a supervisor, and it cannot recover the coroutine. - Inside
supervisorScope, an uncaught exception in a launched child still crashes the app unless it is caught or a handler is installed. - Catch specific exceptions, and never swallow
CancellationException.
Note: Wrapping a coroutine builder in try-catch does not work — launch returns immediately, and the exception happens later inside the coroutine. The try-catch has to be inside the coroutine body.
29. How would you implement search-as-you-type with Kotlin Flow while avoiding stale results and wasted network requests?
Search-as-you-type has three problems to solve: do not fire a request on every keystroke, do not repeat identical queries, and never show results for an old query that arrive after results for a newer one. Flow operators handle all three declaratively.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<SearchState> = query
.debounce(300)
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.length < 2) flowOf(SearchState.Idle)
else flow<SearchState> {
emit(SearchState.Loading)
emit(SearchState.Results(repo.search(q)))
}.catch { emit(SearchState.Error) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)
fun onQueryChange(text: String) { query.value = text }
}What each step does:
MutableStateFlowholds the latest text. Fast typing simply overwrites the value.debounce(300)waits until the user pauses for 300 ms before passing a value on.distinctUntilChanged()drops a query identical to the previous one, such as after typing and deleting a character.flatMapLatestis the key operator: when a new query arrives, it cancels the inner flow for the previous one, cancelling the in-flight request too. Stale results can never overwrite fresh ones.catchon the inner flow turns a failed request into an error state without terminating the outer flow — placed on the outer flow, a single network error would end the search pipeline for good.stateInexposes it as UI state that survives rotation.
Refinements: a minimum query length, caching recent queries in the repository, and making sure the network client honours coroutine cancellation — Retrofit's suspend functions do, cancelling the underlying OkHttp call.
Note: Interviewers often ask “why not flatMapConcat or flatMapMerge?”. Concat would queue stale requests behind each other, and merge would let old responses arrive after new ones. Only flatMapLatest gives “latest query wins” semantics.
30. How does recomposition work in Jetpack Compose, and what makes a composable function skippable?
Compose builds the UI by running composable functions. During that run, it records every State object each composable reads. When one of those states changes, Compose schedules recomposition of only the scopes that read it, not the whole screen.
During recomposition, Compose can skip a composable call entirely when its inputs have not changed. A composable is skippable when its parameters are stable and equal to their previous values.
- Stable types: primitives,
String, function types, and classes whose public properties are allvaland themselves stable, or classes annotated@Immutableor@Stable. - Unstable types: classes with
varproperties, standardListandMap(interfaces that might be mutable underneath), and classes from modules not compiled with the Compose compiler. - Strong skipping mode, enabled by default since Kotlin 2.0.20, relaxes this: composables with unstable parameters can still skip, compared by instance identity, and lambdas are remembered automatically.
Practical techniques to limit recomposition:
// Recompose only when the boolean flips, not on every scroll pixel
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// Read fast-changing state in the layout phase, not in composition
Box(Modifier.offset { IntOffset(0, scrollOffset.value) })
// Stable keys let LazyColumn track items when the list changes
LazyColumn {
items(orders, key = { it.id }) { order -> OrderRow(order) }
}- Pass the narrowest data a composable needs, not a whole screen state object.
- Use immutable collections, or wrap lists in an
@Immutableclass, when skipping matters. - Never write to state that was already read in the same composition — that causes a recomposition loop.
Composables must be side-effect free and idempotent, because Compose may run them often, skip them, or abandon a composition partway through.
Note: Measure before optimising. The Layout Inspector shows recomposition and skip counts per composable, and Compose compiler reports list which classes the compiler considers stable.
31. What are LaunchedEffect, DisposableEffect, SideEffect and rememberCoroutineScope in Compose, and when do you use each one?
Composables can run many times, in any order, and can be abandoned, so work that touches the outside world must be wrapped in effect APIs that tie it to the composable's lifecycle in composition.
LaunchedEffect(key)— launches a coroutine when the composable enters composition, cancels it when it leaves, and cancels and restarts it whenever a key changes. Use it for suspending work driven by state: showing a snackbar, starting an animation, collecting a flow of events.DisposableEffect(key)— for non-suspending setup that needs cleanup: registering a listener, a lifecycle observer or a broadcast receiver. It must end withonDispose { }.SideEffect— runs after every successful recomposition. Use it to publish Compose state to non-Compose code, such as an analytics object or a system UI controller.rememberCoroutineScope()— returns a scope tied to the composition, for launching coroutines from event callbacks such asonClick, whereLaunchedEffectcannot be used.rememberUpdatedState(value)— lets a long-running effect use the latest value of a parameter without restarting.
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // run once, never restart
delay(2_000)
currentOnTimeout() // always the latest lambda
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> log(event) }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { drawerState.open() } }) { Text("Menu") }
}Choosing keys is where bugs hide. If an effect uses a value that can change, that value should normally be a key; otherwise the effect keeps using a stale one. Using Unit or true as the key means “run once for the lifetime of this composable”.
Note: Launching a coroutine or making a network call directly in a composable body is a classic mistake — it re-runs on every recomposition. Interviewers look for candidates who reach for LaunchedEffect, or better, move the work into the ViewModel.
32. How do you handle Room schema migrations and model relationships between entities such as one-to-many?
Migrations. Every schema change — a new column, table or index — needs the database version incremented and a path from each old version to the new one. Without it, Room throws IllegalStateException on open.
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN notes TEXT")
}
}
@Database(
entities = [Customer::class, Order::class],
version = 4,
autoMigrations = [AutoMigration(from = 1, to = 2)],
exportSchema = true
)
abstract class AppDatabase : RoomDatabase()- Auto-migrations handle simple additions automatically; renames and deletions need a spec class so Room knows your intent.
- Export the schema JSON for every version and commit it to version control — auto-migrations and migration tests depend on it.
- Test every migration with
MigrationTestHelper: create the database at the old version, insert data, migrate, and verify both schema and data. fallbackToDestructiveMigration()wipes the database — acceptable only for a pure cache that can be re-downloaded.
Relationships. Room deliberately does not support object references between entities, because lazy loading would silently run queries on the main thread. Instead you query relationships explicitly:
data class CustomerWithOrders(
@Embedded val customer: Customer,
@Relation(parentColumn = "id", entityColumn = "customerId")
val orders: List<Order>
)
@Transaction
@Query("SELECT * FROM customers")
fun observeCustomersWithOrders(): Flow<List<CustomerWithOrders>>@Transactionis important: Room runs the parent and child queries separately, and the transaction keeps them consistent.- Foreign keys with
onDelete = CASCADEenforce integrity; index the foreign-key column, or Room warns and deletes become slow. - Many-to-many uses a junction (cross-reference) entity and
@RelationwithassociateBy = Junction(...).
Note: A failed migration on a user's device means a crash on every launch until they clear data. That is why interviewers value candidates who test migrations from every previously shipped version, not just the last one.
33. What does an offline-first architecture look like on Android, and why should the local database be the single source of truth?
In an offline-first app, the UI never reads directly from the network. It observes the local database, and the network's job is to keep that database up to date. The database is the single source of truth.
class ArticleRepository @Inject constructor(
private val dao: ArticleDao,
private val api: ArticleApi
) {
// UI observes this; it re-emits whenever the table changes
fun observeArticles(): Flow<List<Article>> =
dao.observeAll().map { rows -> rows.map { it.toModel() } }
// Network refresh writes to the database, never to the UI
suspend fun refresh() {
val remote = api.fetchArticles()
dao.upsertAll(remote.map { it.toEntity() })
}
}How the flow works:
- Reads: the screen shows cached data instantly, even with no connection, while a refresh runs in the background. When new data is written, Room's observable query emits and the UI updates automatically.
- Writes: a user action is saved locally first, marked as pending sync, and the UI updates immediately. A WorkManager job with a network constraint pushes pending changes when connectivity returns, retrying with backoff.
- Conflicts: decide a policy — last write wins, server wins, or merging by field — often using
updatedAttimestamps or version numbers from the server.
Why a single source of truth matters:
- Consistency — every screen shows the same data, because they all observe the same table. There is no chance of the list screen and detail screen disagreeing.
- Simpler UI code — the UI just renders what the database emits; it does not merge cached and fresh data itself.
- Resilience — flaky networks, process death and airplane mode are all handled by the same code path.
For long lists, Paging 3's RemoteMediator applies the same pattern page by page: pages load from Room, and the mediator fetches from the network into Room when the user nears the end.
Note: Offline-first adds real complexity — sync queues, conflict resolution, schema migrations. Strong candidates mention that it is worth it for apps used on the move, such as field sales or messaging, but may be overkill for content that must always be live, like stock prices.
34. How does WorkManager guarantee that background work runs, and how do constraints, unique work and chaining work?
WorkManager is the Jetpack API for deferrable work that must run even if the user leaves the app, the process is killed or the device reboots — syncing data, uploading logs, processing images.
How it guarantees execution: each request is persisted in WorkManager's own internal database. It schedules the work through JobScheduler, re-schedules after reboot, and respects Doze and App Standby, so the work runs at a battery-friendly time rather than necessarily immediately.
val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"article-sync", ExistingPeriodicWorkPolicy.KEEP, request
)
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result =
try { repo.sync(); Result.success() } catch (e: IOException) { Result.retry() }
}- Constraints — network type, charging, battery not low, storage not low, device idle. The work waits until they are met.
- One-time or periodic — periodic work has a minimum interval of 15 minutes.
- Retry — returning
Result.retry()reschedules with linear or exponential backoff. - Unique work —
enqueueUniqueWorkwith a name and a policy (KEEP,REPLACE,APPEND_OR_REPLACE) prevents duplicates when the enqueue code runs on every app launch. - Chaining —
beginWith(a).then(b).then(c).enqueue()runs steps in order, passing smallDataoutputs (limited to about 10 KB) between them. - Expedited work —
setExpeditedfor short, important tasks the user started, such as sending a message. - Observable —
getWorkInfoByIdFlowlets the UI show progress and status.
When not to use it: for work that should stop when the user leaves the screen, use a coroutine in viewModelScope. For work that must fire at an exact time, such as an alarm clock, use AlarmManager.
Note: Some manufacturers aggressively kill background apps, which can delay WorkManager jobs. Mentioning that you monitor job completion and test on devices from popular Indian-market brands shows real production experience.
35. What are the current rules for foreground services on Android, including background start restrictions and foreground service types?
A foreground service performs ongoing work the user is aware of — music playback, navigation, a workout, an active call — and must show a persistent notification. The rules have tightened with almost every release:
- Android 8 — after
startForegroundService(), the service must callstartForeground()within a few seconds, or the app gets an ANR and a crash. - Android 9 — the normal
FOREGROUND_SERVICEpermission is required. - Android 12 — apps targeting API 31 or higher cannot start a foreground service while in the background, except in exempted cases such as a high-priority FCM message, the user tapping a notification or widget, or an exact alarm firing. Violations throw
ForegroundServiceStartNotAllowedException. - Android 13 — the notification needs the
POST_NOTIFICATIONSpermission. Without it the service still runs, but the notification is hidden and appears only in the Task Manager. - Android 14 — every foreground service must declare a
foregroundServiceTypeand hold the matching permission, and Play Console requires a declaration explaining the use.
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<service
android:name=".TrackingService"
android:foregroundServiceType="location"
android:exported="false" />ServiceCompat.startForeground(
this, NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)Types include location, mediaPlayback, camera, microphone, phoneCall, connectedDevice, health, dataSync, shortService and specialUse. Some also require the related runtime permission, such as location, to be granted already. Android 15 further limits dataSync services to about six hours a day.
The design lesson: a foreground service is for user-visible, ongoing work only. For deferrable sync or uploads, use WorkManager — including expedited work for short urgent tasks — which handles these restrictions for you.
Note: Interviewers often ask how to start work from a background push. The modern answer is a high-priority FCM message that enqueues expedited WorkManager work, rather than starting a foreground service directly.
36. How do deep links and Android App Links work, and how is App Links verification set up?
A deep link is a URI that opens a specific screen in your app. You declare which URIs an Activity handles with an intent filter using the VIEW action and the BROWSABLE and DEFAULT categories.
- Custom-scheme links such as
myapp://orders/42are easy, but any app can claim the same scheme, they do not work in a browser, and the user may see a disambiguation chooser. - Android App Links are verified
httpslinks to a domain you own. Once verified, the link opens your app directly, with no chooser. Since Android 12, unverified web links open in the browser by default.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="www.example.com"
android:pathPrefix="/jobs" />
</intent-filter>Verification steps:
- Set
android:autoVerify="true"on the intent filter. - Host a Digital Asset Links file at
https://www.example.com/.well-known/assetlinks.json, listing your package name and the SHA-256 fingerprint of the signing certificate. - Serve it over HTTPS without redirects, with a JSON content type, for every host in your filters.
- Check status with
adb shell pm get-app-linksfollowed by your package name, and re-trigger withpm verify-app-links.
The most common failure: using the debug or upload key fingerprint. With Play App Signing, Google re-signs the app, so the fingerprint shown in Play Console under app signing must be in the file.
Handling the link: read intent.data in onCreate, and in onNewIntent for a singleTop Activity. With the Navigation component, declare a deepLink on the destination and it builds the correct back stack. Always validate the data — a deep link is untrusted input that any app or website can send.
Note: A good answer mentions testing: adb shell am start with the VIEW action and the URL, plus checking behaviour when the user is logged out, when the app is already open, and when the ID in the link no longer exists.
37. How do notifications work on modern Android, including notification channels and the POST_NOTIFICATIONS permission?
Notification channels (Android 8+). Every notification must belong to a channel, and users control each channel's importance, sound and vibration — or block it — in system settings. Design channels around what users would want to control separately: “Order updates”, “Chat messages”, “Promotions”.
val channel = NotificationChannel(
"orders", "Order updates", NotificationManager.IMPORTANCE_DEFAULT
).apply { description = "Shipping and delivery status" }
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel) // safe to call repeatedly
val notification = NotificationCompat.Builder(context, "orders")
.setSmallIcon(R.drawable.ic_order)
.setContentTitle("Order shipped")
.setContentText("Arriving tomorrow")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(orderId, notification)- Once created, a channel's importance and sound cannot be changed by the app — only by the user. To change behaviour you must create a new channel ID, so get the design right early.
- Importance decides whether a notification makes sound or pops up as a heads-up.
Runtime permission (Android 13+). Apps targeting API 33 or higher must request POST_NOTIFICATIONS at runtime. Without it, notifications are silently not shown.
- Ask in context — after the user places an order, explain “Get notified when it ships” — not on first launch.
- Check
areNotificationsEnabled()before relying on them, and offer a path to settings if denied.
Other modern rules:
- PendingIntents must declare
FLAG_IMMUTABLEorFLAG_MUTABLEsince Android 12; prefer immutable. - Notification trampolines are blocked since Android 12: a notification tap cannot start a service or receiver that then launches an Activity. The tap must open the Activity directly.
- Push: FCM notification messages are displayed by the system when the app is in the background; data messages always go to your code, giving full control.
Note: Notification spam is a top reason for uninstalls. Interviewers appreciate candidates who mention separate channels for marketing, so users can mute promotions without losing transactional alerts.
38. What are Activity launch modes and tasks, and when would you use singleTop or singleTask?
A task is a stack of Activities the user interacts with as one unit — what appears as a card in the recents screen. Launch modes and intent flags control whether starting an Activity creates a new instance and which task it goes into.
standard(default) — a new instance every time, pushed on top of the current task. Starting the same Activity twice gives two copies on the stack.singleTop— if an instance is already at the top of the stack, it is reused and receives the Intent inonNewIntent(). Otherwise a new instance is created.singleTask— at most one instance in its task. If it exists, the system brings that task forward, clears every Activity above it, and callsonNewIntent().singleInstance— likesingleTask, but the Activity is the only one in its task.singleInstancePerTask(Android 12) allows one instance per task.
The same effects can be requested per launch with flags such as FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_CLEAR_TOP and FLAG_ACTIVITY_SINGLE_TOP, which is often better because the caller knows the context.
Typical uses:
singleTop— a search results screen that receives a new query, or an Activity opened from notifications, so repeated taps do not stack duplicates.singleTask— the app's main entry Activity, so returning to it from a deep link or a notification clears intermediate screens rather than piling them up.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // otherwise getIntent() returns the old one
handleDeepLink(intent)
}Pitfalls: forgetting to handle onNewIntent, so the second link is ignored; singleTask unexpectedly destroying screens the user was on; and task hijacking, where a malicious app abuses taskAffinity to insert its Activity into your task. Setting an empty task affinity on sensitive apps mitigates it.
Note: Modern single-Activity apps built with the Navigation component rarely need exotic launch modes; navigation options like popUpTo and launchSingleTop handle the same needs inside the app. Mentioning that shows you know current practice.
39. What are the most common causes of memory leaks in Android apps, and how do you find and fix them?
A memory leak on Android usually means an object that should be garbage-collected is still reachable from a long-lived reference. The most damaging case is a leaked Activity or Fragment view, because it holds the entire view hierarchy, its bitmaps and its Context — often several megabytes each time the screen is opened.
Common causes:
- Singletons or static fields holding an Activity Context or a View. Use the application context for long-lived objects.
- Listeners and callbacks not unregistered — location updates, sensors, broadcast receivers, or a callback registered with a singleton manager.
- Delayed work referencing the Activity — a
Runnableposted withpostDelayed, or an anonymous inner class, holds an implicit reference to its outer Activity. - Fragment view references kept after
onDestroyView. The fragment survives on the back stack but its view should not. - Coroutines in the wrong scope —
GlobalScopeor a custom scope never cancelled, capturing the Activity in its lambda. - ViewModels holding Views or Activity Context, which outlive the screen across configuration changes.
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentProfileBinding.bind(view)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null // the fragment outlives its view on the back stack
}
}Finding leaks:
- LeakCanary in debug builds watches destroyed Activities, Fragments, Views and ViewModels. If one is not collected within a few seconds, it dumps the heap and shows the leak trace — the chain of references from a GC root to the leaked object.
- Android Studio Memory Profiler — capture a heap dump, filter for Activity instances, and check for more instances than expected after rotating a few times.
Fix by breaking the reference at the right lifecycle point, not by adding WeakReference everywhere, which hides the design problem.
Note: Leaks often surface as OutOfMemoryError crashes far from the real cause — typically while decoding a bitmap. When a crash report shows an OOM, a strong answer is to look for leaks first rather than simply increasing the image downsampling.
40. How do you diagnose an ANR in an Android app, and what changes prevent ANRs from happening?
An ANR (Application Not Responding) is reported when the main thread is blocked for too long: an input event not handled within about 5 seconds, a BroadcastReceiver that has not finished onReceive in time, or a service that does not start or call startForeground quickly enough. The user sees a “wait or close app” dialog, and Play Console counts it against your vitals.
Typical root causes:
- Disk I/O on the main thread — database queries, file reads,
SharedPreferences.commit(), or first-time reads of large preferences files. - Synchronous IPC or binder calls to slow system services or other apps.
- Lock contention — the main thread waits on a lock held by a background thread doing slow work.
- Heavy computation, JSON parsing or bitmap decoding in UI code.
- Slow
onReceivein broadcast receivers. - Deadlocks between the main thread and a worker.
Diagnosing:
- Play Console Android vitals groups ANRs by the main thread's stack trace — the frame at the top usually shows what it was blocked on.
ApplicationExitInfo(Android 11+) lets the app read the ANR trace from its previous run and upload it to your crash tool.- StrictMode in debug builds flags main-thread disk and network access as soon as it happens.
- Perfetto traces or the Android Studio CPU profiler show exactly what the main thread was doing frame by frame.
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}Prevention: move I/O and computation to background dispatchers with coroutines, use suspend DAOs and DataStore instead of blocking APIs, never hold a lock the main thread needs during slow work, and hand long receiver work to WorkManager (or use goAsync() for short async work).
Note: ANRs frequently spike on low-end devices, where the same code is several times slower. Testing on an inexpensive phone, common in the Indian market, often reproduces ANRs that never appear on a flagship development device.
41. What are cold, warm and hot app starts on Android, and how do you measure and reduce cold start time?
- Cold start — the process does not exist. The system creates it, runs
Application.onCreate, creates the first Activity, inflates its layout and draws the first frame. This is the slowest and most important to optimise. - Warm start — the process exists but the Activity must be recreated, for example after the user backed out of it.
- Hot start — the Activity is still in memory and is simply brought to the foreground.
Play Console flags excessive start-up when cold starts take 5 seconds or more, warm starts 2 seconds or more, or hot starts 1.5 seconds or more.
Measuring:
- Time to initial display — logcat's
Displayedline for each Activity. - Time to full display — call
reportFullyDrawn()once real content has loaded, not just a spinner. - Macrobenchmark for repeatable, CI-friendly numbers on a real device:
@Test
fun coldStartup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}Reducing cold start time:
- Slim down
Application.onCreate. Initialise analytics, crash reporting and other SDKs lazily or in the background; audit libraries that auto-initialise through content providers, which run even beforeonCreate. The App Startup library consolidates and controls them. - Add a Baseline Profile so the start-up code path is compiled ahead of time instead of being interpreted or JIT-compiled on first runs.
- No disk or network I/O on the main thread before the first frame.
- Simplify the first screen — flatter layouts, and show cached content immediately rather than waiting for the network.
- Avoid heavy dependency graphs at launch — use
LazyorProviderinjection for objects not needed immediately. - Enable R8 in release builds, which also optimises start-up code.
Note: The SplashScreen API (Android 12) improves perceived start-up and branding, but it does not make the app start faster. Interviewers like candidates who separate real start-up time from what the user perceives.
42. How do Hilt components and scopes work, and when do you use Binds versus Provides in a module?
Hilt generates a hierarchy of Dagger components tied to Android lifecycles. Each component is a container that can create and hold dependencies for as long as its owner lives:
| Component | Scope annotation | Lives as long as |
|---|---|---|
| SingletonComponent | @Singleton | The application process |
| ActivityRetainedComponent | @ActivityRetainedScoped | An Activity, surviving configuration changes |
| ViewModelComponent | @ViewModelScoped | A ViewModel |
| ActivityComponent | @ActivityScoped | An Activity instance |
| FragmentComponent | @FragmentScoped | A Fragment instance |
Bindings are unscoped by default — every injection gets a new instance. Adding a scope annotation makes the component cache one instance. Scope only when an object holds shared state or is expensive to create, such as Retrofit, OkHttp or a Room database; scoping everything wastes memory and adds synchronisation overhead.
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindOrdersRepository(impl: OfflineFirstOrdersRepository): OrdersRepository
companion object {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
}
}@Binds— an abstract function that tells Hilt “when someone asks for this interface, supply this implementation”. The implementation must be constructible by Hilt, usually through an@Injectconstructor. It generates less code and is the preferred choice for interface-to-implementation mappings.@Provides— a function with a body that builds the object. Use it for types you do not own and cannot annotate (Retrofit, Room, OkHttp), for builder patterns, or when construction needs logic.
Qualifiers such as @IoDispatcher or @AuthClient distinguish multiple bindings of the same type. In tests, @TestInstallIn replaces a production module with fakes across the whole test suite.
Note: A dependency can only be injected into components at or below the one it is installed in. Trying to inject an ActivityScoped object into a Singleton produces a compile-time error — one of the main advantages of Dagger-based DI over runtime service locators.
43. What is MVI, and how does it differ from MVVM in an Android app?
MVI (Model-View-Intent) is an architecture built on strict unidirectional data flow:
- The View renders a single immutable state object.
- User actions are sent to the ViewModel as intents — sealed classes describing what happened.
- A reducer combines the current state with the intent or a result to produce a new state.
- One-off effects — navigation, toasts — travel on a separate channel.
data class CartState(
val items: List<CartItem> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
sealed interface CartIntent {
data class Remove(val id: String) : CartIntent
data object Checkout : CartIntent
}
class CartViewModel : ViewModel() {
private val _state = MutableStateFlow(CartState())
val state: StateFlow<CartState> = _state.asStateFlow()
fun onIntent(intent: CartIntent) = when (intent) {
is CartIntent.Remove -> _state.update { s ->
s.copy(items = s.items.filterNot { it.id == intent.id })
}
CartIntent.Checkout -> checkout()
}
}How it differs from classic MVVM:
- State shape — MVVM often exposes several independent observables (
isLoading,items,error), which can combine into impossible states such as loading and error at once. MVI has one state object, so every screen state is explicit. - Inputs — MVVM Views call many ViewModel methods; MVI funnels everything through one intent entry point, which is easy to log and replay.
- Predictability — pure reducer functions make state transitions trivial to unit-test.
Costs of MVI: more boilerplate, copying state on every change, one-off effects that feel awkward in a state-only model, and a single large state object that can cause extra recomposition if not split carefully.
In practice the line has blurred. Google's recommended architecture — a ViewModel exposing a single UiState via StateFlow, with events flowing up from the UI — is essentially MVVM with MVI's unidirectional data flow, and it pairs naturally with Compose.
Note: Avoid presenting one pattern as universally better. A strong answer says which you would choose for a given screen — MVI for complex forms and multi-step flows, simpler MVVM for a static detail screen — and why.
44. How would you modularise a large Android codebase, and what benefits and costs does modularisation bring?
Modularisation splits one large app module into many Gradle modules with clear responsibilities and controlled dependencies. A common structure, similar to Google's Now in Android sample:
:app— thin: the Application class, top-level navigation and DI wiring.:feature:*— one module per feature or screen group, such as:feature:searchor:feature:checkout, containing UI and ViewModels.:core:*— shared foundations::core:model,:core:data,:core:database,:core:network,:core:designsystem,:core:testing.
// settings.gradle.kts
include(":app")
include(":core:model", ":core:data", ":core:network", ":core:designsystem")
include(":feature:search", ":feature:checkout", ":feature:profile")Dependency rules keep it healthy:
- Feature modules never depend on each other; they navigate through routes or interfaces defined in a shared module.
- Dependencies point one way: features depend on core, core never depends on features.
- Use
implementationrather thanapiso changes in a module's internals do not trigger recompilation of everything downstream. - Kotlin's
internalvisibility hides implementation details inside each module. - Share build configuration through convention plugins in a
build-logicincluded build, and pin versions in a version catalog (libs.versions.toml).
Benefits:
- Faster builds — Gradle builds independent modules in parallel, caches unchanged ones, and recompiles only what changed.
- Ownership — teams own modules, with fewer merge conflicts.
- Enforced architecture — the build fails if someone creates a forbidden dependency.
- Reuse and isolation — test a feature alone, build a demo app for the design system, or deliver a feature on demand with Play Feature Delivery.
Costs: more configuration, more Gradle sync time if over-split, and navigation and DI that span modules. Modularise by feature and layer where there is a real boundary, not into dozens of tiny modules on day one.
Note: Measure the benefit. Gradle build scans show where build time goes, and a before-and-after comparison of incremental build times is a convincing way to justify a modularisation effort to management.
45. What are build variants in Android Gradle builds, and how do build types and product flavors combine?
A build variant is one concrete version of your app that Gradle can build. It is the combination of a build type with one product flavor from each flavor dimension.
- Build types describe how the app is built:
debug(debuggable, debug signing, no shrinking) andrelease(R8 shrinking, release signing). You can add others, such asbenchmark. - Product flavors describe what is built: different environments, editions or brands —
stagingandprod, orfreeandpaid. - Flavor dimensions group flavors. With dimensions
env(staging, prod) andtier(free, paid) plus two build types, you get eight variants, such asstagingFreeDebug.
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug { applicationIdSuffix = ".debug" }
}
flavorDimensions += "env"
productFlavors {
create("staging") {
dimension = "env"
applicationIdSuffix = ".staging"
}
create("prod") { dimension = "env" }
}
buildFeatures { buildConfig = true }
}What can differ per variant:
- Source sets —
src/staging/,src/release/orsrc/stagingDebug/can hold variant-specific code, resources and manifest entries, merged withsrc/main/. - Configuration —
buildConfigFieldfor values such as the API base URL,resValue,applicationIdSuffixso staging and production install side by side, and version name suffixes. - Dependencies —
stagingImplementationordebugImplementation, for example to include LeakCanary only in debug builds.
Good practice: keep the variant count small, because each one multiplies build and test time; filter out meaningless combinations with the variant API; and never put real secrets in BuildConfig, which is trivially readable from the APK. Note that AGP 8 disables BuildConfig generation by default, hence the buildFeatures line.
Note: Interviewers often ask how you point different builds at different servers. Flavors for environments, with the base URL in buildConfigField, is the standard answer — and a debug-only settings screen to switch servers is a useful extra for QA.
46. How do you configure R8 for a release build, and how do you debug a crash that only happens in the minified release build?
R8 is the compiler that turns Java bytecode into DEX for release builds. When isMinifyEnabled = true, it also:
- Shrinks — removes classes, methods and fields that are not reachable from entry points.
- Obfuscates — renames what remains to short names such as
a.b.c. - Optimises — inlines methods, merges classes and removes dead branches.
- With
isShrinkResources = true, removes unused resources as well.
Since AGP 8, R8 runs in full mode by default, which optimises more aggressively and assumes less about reflection.
Why release-only crashes happen: R8 analyses code statically, so anything reached only through reflection looks unused or is renamed. Typical symptoms:
ClassNotFoundExceptionorNoSuchMethodExceptionfrom reflection-based libraries.- JSON fields silently parsing as null because Gson matched on field names that were obfuscated.
- Missing generic type information in Retrofit interfaces, or JNI methods called from native code that were removed.
# Keep DTO classes in this package, parsed by a reflection-based JSON library
-keep class com.example.api.dto.* { *; }
-keepattributes Signature, *Annotation*
# Ask R8 why a class survives shrinking
-whyareyoukeeping class com.example.LegacyHelperDebugging steps:
- De-obfuscate the stack trace with the
mapping.txtproduced for that exact build, using theretracetool. Upload mapping files to Play Console and Crashlytics automatically in CI so production traces are readable. - Reproduce locally with a build type that has minification enabled but is debuggable, so you can attach a debugger.
- Add the narrowest keep rule that fixes it, or annotate the class with
@Keep. Avoid blanket rules such as keeping the whole package, which throw away most of R8's benefit. - Inspect the result with
-printconfigurationand-printusage, or open the APK in the APK Analyzer.
Prevention: prefer code-generating libraries such as kotlinx.serialization or Moshi with codegen, which avoid reflection and ship their own consumer rules, and run UI tests against the minified build in CI.
Note: Keep the mapping file for every release you ship. Without the exact mapping for a given versionCode, an obfuscated production crash is almost impossible to read.
47. Walk through releasing an Android app on Google Play, from app signing to testing tracks and staged rollout.
1. Prepare the build.
- Increase
versionCode— an integer that must be higher than any build previously uploaded — and set a human-readableversionName. - Meet Play's target API level requirement, which rises every year; new apps and updates must target a recent Android version.
- Build a signed Android App Bundle (
.aab). Play generates optimised APKs per device configuration from it.
2. Signing with Play App Signing. You sign the bundle with an upload key; Google verifies it, then re-signs the APKs with the app signing key that it stores securely. If the upload key is lost or leaked, it can be reset through Play support — whereas losing a self-managed signing key used to mean you could never update the app again.
3. Testing tracks.
- Internal testing — up to 100 testers, available within minutes; ideal for the team and QA.
- Closed testing — invited groups or email lists, for beta users or a client. Newer personal developer accounts must complete a closed test with a minimum number of testers over 14 days before they can publish to production.
- Open testing — anyone can join from the Play listing.
- The pre-launch report automatically installs each upload on real devices, crawls the app and reports crashes, accessibility and security issues.
4. Store listing and policy. Complete the Data safety form, content rating, target audience, and declarations for sensitive permissions or foreground service types. Missing declarations are a common cause of rejection.
5. Production with a staged rollout. Release to a small percentage first — for example 1%, then 5%, 20%, 50% and 100% — watching crash-free users, ANR rate and reviews at each step. If something goes wrong, halt the rollout; you cannot roll back, so the fix is a new build with a higher versionCode.
6. Automate it. Teams usually drive this from CI with fastlane or the Gradle Play Publisher plugin, and use the in-app updates API to prompt users onto critical fixes.
Note: Review times vary from hours to several days, especially for new apps or policy-sensitive changes. Build that buffer into release plans rather than promising stakeholders a same-day launch.
48. How do you unit-test an Android ViewModel that uses Kotlin coroutines and StateFlow?
ViewModels are ideal for fast JVM unit tests, but coroutines introduce two problems: viewModelScope uses Dispatchers.Main, which does not exist in a plain JVM test, and real delays would make tests slow and flaky. kotlinx-coroutines-test solves both.
Replace the Main dispatcher with a test dispatcher through a JUnit rule:
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
class OrdersViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun showsOrdersAfterLoading() = runTest {
val repo = FakeOrdersRepository(orders = listOf(sampleOrder))
val viewModel = OrdersViewModel(repo)
viewModel.load()
advanceUntilIdle()
assertEquals(UiState.Success(listOf(sampleOrder)), viewModel.uiState.value)
}
}Key tools and ideas:
runTestruns the test in a coroutine with virtual time:delay(10_000)completes instantly, so timeouts, debounce and retry logic can be tested quickly. When Main is a test dispatcher,runTestshares its scheduler.StandardTestDispatcherqueues coroutines until you advance time withadvanceUntilIdle()orrunCurrent(), letting you assert intermediate states such as Loading.UnconfinedTestDispatcherruns them eagerly, which is simpler when you only care about the final state.- Inject dispatchers into repositories instead of hard-coding
Dispatchers.IO, so tests can substitute the test dispatcher everywhere. - Prefer fakes over mocks — a small in-memory
FakeOrdersRepositoryis easier to read and less brittle than mocking every call. - Testing flows — the Turbine library collects a flow and lets you assert each emission in order with
awaitItem(). For aStateFlowbuilt withstateIn(WhileSubscribed), remember that the upstream only runs while something is collecting, so start a collector, for example inbackgroundScope, before asserting.
Note: Interviewers often probe why a test passes locally but hangs in CI. The usual answers are a hard-coded Dispatchers.IO escaping the test scheduler, or a flow that never completes being collected in the test body rather than in backgroundScope.
49. What is the difference between the Application Context and an Activity Context, and when should you use each?
A Context is the handle to the app's environment: resources, system services, file storage, and the ability to start components. The two you use most have very different lifetimes and capabilities.
- Application Context — one instance for the whole process, alive as long as the app runs. It knows nothing about any screen, window or Activity theme.
- Activity Context — the Activity itself, which is a
ContextThemeWrapper. It carries the Activity's theme and window, and reflects the current configuration of that screen. It lives only as long as the Activity instance.
| Task | Use |
|---|---|
| Inflating views, building Compose UI, anything themed | Activity |
| Showing a dialog | Activity (needs a window token) |
| Starting an Activity normally | Activity |
| Singletons, repositories, Room, WorkManager, DataStore | Application |
| Anything stored beyond the screen's lifetime | Application |
What goes wrong with the wrong choice:
- Holding an Activity Context in a singleton or a long-lived object leaks the whole Activity and its view hierarchy after every rotation — one of the most common leaks on Android.
- Inflating layouts with the Application Context ignores the Activity's theme, so colours and styles come out wrong.
- Showing a dialog with the Application Context throws
WindowManager.BadTokenException. - Starting an Activity from a non-Activity context requires
FLAG_ACTIVITY_NEW_TASK, which changes task behaviour.
class ImageCache @Inject constructor(
@ApplicationContext private val context: Context // safe to keep
)
@Composable
fun ShareButton(url: String) {
val context = LocalContext.current // Activity context, use now
Button(onClick = { context.startActivity(shareIntent(url)) }) { Text("Share") }
}Hilt makes the choice explicit with the @ApplicationContext and @ActivityContext qualifiers.
Note: A simple rule for interviews: use the narrowest context that can do the job right now, but never store anything shorter-lived than the object that holds it.
50. How do Looper, Handler and MessageQueue power the Android main thread, and why does that matter for smooth UI?
The Android main thread runs an event loop. When the process starts, the framework prepares a Looper for the main thread and calls Looper.loop(), which takes messages from a MessageQueue one at a time and runs them — forever.
Everything on the UI thread is a message in that queue: lifecycle callbacks, input events, broadcast deliveries, and every frame drawn by the Choreographer. A Handler is the API for putting work into a specific Looper's queue.
val mainHandler = Handler(Looper.getMainLooper())
private val tick = object : Runnable {
override fun run() {
updateClock()
mainHandler.postDelayed(this, 1_000) // schedule the next tick
}
}
override fun onStart() {
super.onStart()
mainHandler.post(tick)
}
override fun onStop() {
super.onStop()
mainHandler.removeCallbacks(tick) // avoid leaking the Activity
}Why it matters:
- One slow message delays everything behind it. At 60 Hz the app has about 16 ms per frame, less on 90 or 120 Hz screens. A 200 ms database read on the main thread means dropped frames — visible jank.
- Blocking long enough causes an ANR, because input events waiting in the queue are not handled within the timeout.
- Handlers can leak. A delayed message holds a reference to its
Runnable, which often holds the Activity. Remove callbacks when the screen stops. - Other threads can have loopers too.
HandlerThreadcreates a background thread with its own Looper, used for serialised background work such as camera callbacks.
How it relates to modern code: Dispatchers.Main in Kotlin coroutines is implemented on top of a main-thread Handler, and View.post uses the same queue. Dispatchers.Main.immediate skips posting when you are already on the main thread, avoiding a frame of delay. The no-argument Handler() constructor is deprecated because it silently used whichever Looper the current thread had — always pass one explicitly.
Note: Understanding the queue explains many puzzles: why View.post runs after layout, why updating UI from a background thread crashes, and why a tight main-thread loop freezes the whole app rather than just one screen.