Android Developer Interview Questions · 2026

Android Developer Interview Questions (2026): Most Asked Q&A (Kotlin)

Google's own developer documentation says it plainly: "Jetpack Compose is Android's recommended modern toolkit for building native UI." That's not a slide from a keynote, it's the current guidance every serious Android team reads, and it has quietly rewritten what a 2026 interview loop actually tests.

I think most "Android interview questions" lists still circulating online are stuck around 2019, XML layouts, AsyncTask, maybe an RxJava chain nobody ships anymore. That's a real gap. A senior Android loop today expects you to reason about coroutines, StateFlow, and recomposition, on top of everything that hasn't changed in a decade: lifecycle behavior, memory pressure, background work constraints. Get the newer half wrong and you look out of date. Get the older half wrong and you look junior, no matter how polished your Compose demo is.

This page covers the Android interview questions that actually show up in loops right now, Kotlin fundamentals through Compose, architecture, and the background-work plumbing that never makes it into a portfolio README. Every question below includes what the interviewer is actually testing for, not just the textbook answer.

KotlinCore language
Jetpack ComposeUI toolkit
3-5 roundsTypical loop
Easy-MediumCoding level

Kotlin fundamentals interviewers actually probe

Kotlin isn't new anymore, Google made it the preferred language for Android back in 2019, so interviewers assume fluency, not familiarity. Where they actually spend time is null safety and coroutines, since that's where sloppy real-world code shows up.

Easy questions

15

Kotlin distinguishes nullable types (String?) from non-null types (String) at compile time. The safe call operator ?. returns null instead of crashing if the receiver is null, and the Elvis operator ?: supplies a fallback value. The double-bang !! operator forces a null check and throws a NullPointerException if the value actually is null, it exists mainly for interop with Java code that doesn't carry nullability information.

Interviewers push on this because !! shows up constantly in rushed code, and it defeats the entire point of Kotlin's null safety. A good answer names a real alternative, ?.let { }, ?:, or restructuring the function to take a non-null parameter instead of asserting one exists.

Both are scope functions, but let passes the receiver in as it and returns the lambda's result, while apply passes the receiver as this and always returns the receiver itself. Use let when you want to transform a value or run null-safe logic on it (user?.let { sendEmail(it) }). Use apply when you're configuring an object's properties and want the object back at the end, like builder-style setup.

Candidates who've only memorized definitions usually can't explain why you'd pick one over the other in a real snippet. Interviewers ask for the return-value difference specifically because that's what actually breaks code when you swap them.

onCreate() runs once when the Activity is first created, followed by onStart() (visible but not interactive) and onResume() (in the foreground, receiving input). Going backward: onPause() fires when another Activity partially covers this one, onStop() when it's no longer visible, and onDestroy() when the system reclaims it or the user finishes it. Coming back from the background re-triggers onRestart() then onStart() again.

One detail trips people up, onSaveInstanceState() isn't guaranteed to fire before onStop() on every API level. As of Android 9 (API 28), the platform explicitly calls it after onStop(), on older versions the order was less strict. I've seen sample code online that assumes the old ordering and breaks in subtle ways on newer devices, so don't just memorize the diagram, check which API level the guarantee actually applies to.

ViewModel holds UI-related state and survives configuration changes, so a rotation doesn't force you to re-fetch data or lose in-progress state. It should never hold a reference to a View, Fragment, or Activity Context, doing so leaks the entire view hierarchy for as long as the ViewModel lives, which can be longer than the Activity itself.

An @Entity maps a class to a table, a @Dao interface declares your queries, and the abstract @Database class ties entities and DAOs together. Room throws an IllegalStateException if you run a query on the main thread by default, DAO methods are expected to be suspend functions or return a Flow or LiveData so the query naturally happens off the UI thread. You can disable this check with allowMainThreadQueries(), but doing so in real code is close to always the wrong call.

A repository gives you one place that decides where data comes from, cache, database, or network, and coordinates between them: serve from Room, refresh from network in the background, update Room, let the UI observe the single source of truth. Without it, that decision logic ends up duplicated across every ViewModel that needs the same data, and testing means mocking Retrofit and Room instead of one clean interface.

ViewHolder caches the result of findViewById() for each row so scrolling doesn't repeatedly walk the view hierarchy looking up the same references. Skip it, or implement it wrong, and every scroll event triggers unnecessary view lookups and inflation, which shows up as visible jank on anything but a flagship device.

An explicit Intent names the exact component to start, Intent(this, DetailActivity::class.java), used for navigation within your own app. An implicit Intent describes an action to perform, Intent(Intent.ACTION_VIEW, uri), and lets the system pick whichever installed app can handle it, sharing a link, opening a map location, sending an email.

setRequiredNetworkType(NetworkType.UNMETERED) tells WorkManager not to run the work until the device is on Wi-Fi, and requiresCharging(true) delays it until the device is plugged in. These aren't checked once at scheduling time, WorkManager re-evaluates constraints continuously and only actually runs the work once all of them are satisfied, pausing it again if a constraint stops holding mid-execution.

Most 2026 loops lead with Compose since it's Google's recommended toolkit, but the View system isn't gone. Plenty of production codebases are mid-migration, so expect at least a light question about how the two interoperate, ComposeView, AndroidView, if the company's app predates Compose.

Kotlin is the default expectation for almost every Android role advertised in 2026. A handful of large legacy codebases still interview in Java for maintenance-heavy roles, but if the job description doesn't say otherwise, prepare in Kotlin.

Yes, typically at Easy to Medium difficulty, arrays, hash maps, basic graph or tree problems. It's usually one round out of several, not the entire loop, unlike a pure backend SWE interview where DSA rounds can dominate.

Not required for most roles in 2026, but it's worth mentioning if you've used it. Some teams are actively evaluating or migrating shared business logic to KMP, and a candidate who understands the trade-offs stands out. Don't over-invest prep time in it unless the job description specifically mentions cross-platform work.

An APK is the single installable package, and it has to carry every resource density and native library ABI your app supports because you don't know ahead of time which device it lands on, which is why a universal APK ends up so much bigger than it needs to be for any one phone. An AAB isn't installable on its own, it's a publishing format you upload to Play Console, and Google's servers split it into device-specific APKs at install time through dynamic delivery, so a phone with an arm64 chip and an xxhdpi screen only downloads the slice it actually needs instead of every density and architecture combination bundled together.

Google made AAB mandatory for new apps mainly because it cuts average install size meaningfully and unlocks Play Feature Delivery for on-demand modules and Play Asset Delivery for large game assets. Day to day, you still build and run APKs locally in Android Studio, and you can produce a universal APK from an AAB with bundletool for sideloading or testing, but the artifact you actually ship to the Store is the AAB.

Activities, Services, BroadcastReceivers, and ContentProviders are the four component types the OS treats as first-class citizens. Each one has to be declared (in the manifest, or registered dynamically for a receiver) and each gets its own lifecycle the system manages, rather than your code just instantiating a plain object whenever it wants one.

An Activity is a single screen with a UI the user interacts with directly. A Service does work without a UI, either bound, where a client connects and calls methods on it directly like controlling a music player from a notification, or started, where it runs fire-and-forget until it stops itself. A BroadcastReceiver reacts to system-wide or app-wide events such as connectivity changes, boot completion, or a custom broadcast your own app sends. A ContentProvider exposes structured, queryable data to other apps through a standard interface, the way the system Contacts provider does, backed by Binder IPC under the hood.

Medium questions

25

launch starts a coroutine and returns a Job, used when you don't need a result back, fire it and move on. async returns a Deferred, and you call .await() to get the result, used when you need a value from concurrent work, like two parallel network calls you'll combine later.

The exception-handling nuance is where this gets interesting. Inside a regular coroutineScope, an unhandled exception from either builder still cancels the whole scope immediately, structured concurrency doesn't wait around. The practical difference is that async's exception won't surface to your code until you call await(), which means a forgotten await() can silently swallow a crash. I've seen this exact bug in production code more than once.

Dispatchers.Main runs on the UI thread, for anything touching views. Dispatchers.IO is a larger thread pool tuned for blocking work like disk access or network calls. Dispatchers.Default is tuned for CPU-heavy work like sorting a big list or parsing JSON. viewModelScope, by the way, defaults to Dispatchers.Main.immediate, not IO, which trips people up when they assume ViewModel coroutines are automatically off the main thread.

kotlin
class ArticleRepository(private val api: ArticleApi) {

    suspend fun fetchArticle(id: String): Article = withContext(Dispatchers.IO) {
        val response = api.getArticle(id)
        response.toDomainModel()
    }
}

The withContext(Dispatchers.IO) pattern is what interviewers want to see in the repository layer, not sprinkled through the ViewModel. If your answer puts dispatcher-switching logic inside the UI layer, that's usually a sign you haven't separated concerns cleanly.

By default, a configuration change destroys and recreates the Activity, which is why fields on the Activity itself get wiped. ViewModel survives this because it lives in a ViewModelStore that the framework keeps alive across the recreation, but it does not survive process death, when Android kills your backgrounded app under memory pressure.

That's what SavedStateHandle is for. It's backed by the same bundle mechanism as onSaveInstanceState(), restored even after process death, subject to the same size limits (large objects will throw a TransactionTooLargeException if you're not careful). Most teams test rotation constantly and process death almost never, which is a real gap, that bug only shows up in production when a user's phone is under memory pressure and QA never reproduces it.

LiveData is lifecycle-aware automatically and stops delivering updates when the observer's lifecycle isn't active, which made it the safe default for years. StateFlow needs repeatOnLifecycle or collectAsStateWithLifecycle() in Compose to get the same safety, it's not automatic. In exchange, StateFlow is plain Kotlin, testable outside Android, usable in shared or multiplatform code, and it composes naturally with the rest of the coroutines ecosystem.

If I'm starting something today, I don't see a strong reason to reach for LiveData over StateFlow. That said, plenty of production codebases have years of LiveData already working correctly, and rewriting something that already works just to chase a trend is a waste of a sprint. Expect interviewers to probe which side of that trade-off you actually understand, not just which one you'd pick.

Safe Args, a Gradle plugin, generates typed Directions and Args classes from your navigation graph, so arguments are compile-time checked instead of stuffed into a raw Bundle with string keys. The NavController manages a single back stack tied to a NavHost, and popUpTo with inclusive="true" is how you clear screens off that stack, the standard example being clearing the entire login flow once the user reaches the home screen, so back doesn't return them to the login form.

Instead of imperatively mutating a View tree, findViewById then setting .text, you describe what the UI should look like for a given state, and Compose figures out what to redraw. Recomposition happens when a State object being read inside a composable changes value. Compose tracks which composables read which state during the last composition and only re-runs the ones that actually depend on what changed, not the whole tree.

remember keeps a value alive across recompositions, but it's lost when the Activity is recreated, a configuration change, because the whole composition is thrown away and rebuilt. rememberSaveable survives that by saving the value into the same bundle mechanism onSaveInstanceState() uses, with the same size and type constraints. It works out of the box for primitives and Parcelables, anything else needs a custom Saver.

kotlin
@Composable
fun CounterButton() {
    var count by rememberSaveable { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

The View, an Activity, Fragment, or composable, renders state and forwards user actions. The ViewModel holds and transforms UI state, exposed as LiveData or StateFlow, without knowing anything about how it's rendered. The Model is your data layer, repositories, Room, network. The most common mistake: ViewModels that end up holding a Context, a View reference, or navigation logic directly, which breaks testability and re-introduces the exact leak risk MVVM was supposed to prevent.

DiffUtil compares an old list and a new list and computes the minimal set of insert, remove, and move operations needed to turn one into the other, using an algorithm based on Eugene Myers's diff paper. notifyDataSetChanged() throws that away and just rebinds every visible row, which loses scroll position context and kills any row-level animation. ListAdapter wraps DiffUtil in AsyncListDiffer, which runs the diff calculation on a background thread automatically, so you get the smarter updates without blocking the UI thread on a large list.

kotlin
class ArticleDiffCallback : DiffUtil.ItemCallback<Article>() {

    override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem == newItem
    }
}

Conceptually similar, items outside the visible viewport aren't composed, and LazyColumn reuses layout nodes for items that share the same content type as you scroll, the same recycling idea RecyclerView popularized, just handled for you. The part people miss is the key parameter, without a stable key tied to item identity, not just index, reordering a list can scramble per-item state and break animations, the same class of bug DiffUtil's areItemsTheSame exists to prevent.

viewModelScope is for work that only matters while the associated screen or process is alive, it's cancelled automatically when the ViewModel is cleared. WorkManager is for work that has to happen even if the app is killed, the process dies, or the device reboots, a queued photo upload, a periodic sync, anything the user would be upset to silently lose. Under the hood WorkManager picks between JobScheduler, AlarmManager with a BroadcastReceiver, or a foreground service depending on the API level and constraints, so you don't have to.

SharedPreferences reads the entire file into memory on first access and has no fully asynchronous, transactional API. apply() is non-blocking for the caller but still funnels through a single lock, and there's no built-in error signaling if a write silently fails. DataStore, Preferences or Proto, exposes reads as a Flow, handles writes transactionally on Dispatchers.IO, and with Proto DataStore gives you actual type safety instead of loosely-typed key-value pairs.

Less than a backend system design round, usually. Expect app-level design questions instead, offline-first sync strategy, how you'd structure a feature module, how you'd handle a flaky network, not distributed systems questions about sharding a database across regions.

Being able to build features but not explain the reasoning behind lifecycle or memory-related decisions under a follow-up question. Interviewers use the follow-up specifically to separate memorized answers from real understanding, and it's where otherwise strong candidates lose the round.

A Fragment's view hierarchy is destroyed and recreated independently of the Fragment instance itself. When a Fragment gets pushed onto the back stack, or its view otherwise goes away, onDestroyView() fires and the root view plus everything under it gets torn down, but the Fragment object stays alive until onDestroy(), which might not happen for a long time if the user just keeps navigating forward.

If you hold your View Binding in a class-level property and never clear it in onDestroyView, you're keeping the whole destroyed view hierarchy reachable from a Fragment that's still alive on the back stack, which is a genuine memory leak, not just a theoretical one. The standard fix is a nullable backing property that gets set in onCreateView and cleared in onDestroyView, so you only ever touch the binding between those two callbacks and get an obvious null pointer instead of a silent leak if you mess it up.

The standard pattern is a ViewModel scoped to the shared Activity, requested through activityViewModels() instead of the default viewModels(), so both fragments get handed the same instance because they're both keying off the Activity's ViewModelStore rather than getting their own. A StateFlow or LiveData exposed from that shared ViewModel becomes the single source of truth both fragments read from and mutate through its methods, with no direct reference between the fragments at all.

This replaces the older pattern of a Fragment calling a method on its host Activity through an interface the Activity implements, which worked but tightly coupled the Fragment to whatever Activity happened to host it and made it harder to test the Fragment in isolation. One thing to watch for: activityViewModels() means that state outlives any single Fragment and is visible to every fragment in that Activity's stack, not just the two you intended to talk to each other, so it's the wrong choice if what you actually need is fragment-local state.

A sealed class or sealed interface like Result.Loading, Result.Success(data), Result.Error(exception) makes illegal states unrepresentable, and the compiler forces every `when` block over it to handle each branch, refusing to compile if you add a new state later and forget to update a consumer. With a boolean flag plus nullable fields instead, nothing stops combinations that don't make sense, isLoading true while data is also non-null, or both error and data populated at once, and you just get subtly wrong UI at runtime with no compiler warning.

Sealed classes also let each state carry only what's relevant to it, Success carries the payload, Error carries the throwable and maybe a retry count, instead of one flat object with a pile of optional fields that are null most of the time. The tradeoff is a bit more boilerplate up front, and you only get the compiler's exhaustiveness check if you skip adding an `else` branch out of habit, since `else` quietly defeats the whole point.

For every property in the primary constructor, the compiler generates equals(), hashCode(), toString(), copy(), and componentN() functions for destructuring. The generated equals() only looks at primary-constructor properties, so a var declared in the class body instead of the constructor is invisible to it, meaning two instances the code treats as equal can silently differ on that field, and code depending on equality, like DiffUtil or a Set, won't notice the difference.

The other trap is mutable collections inside a data class used as a set element or map key. Mutating that list after the object's been inserted breaks the invariant that hashCode stays stable for an object while it's stored, since the object no longer hashes to the bucket it's actually sitting in, and `contains()` can return false for something clearly in the set.

kotlin
data class Item(val id: String) {
    var isSelected: Boolean = false // not in the primary constructor
}

val a = Item("1").apply { isSelected = true }
val b = Item("1").apply { isSelected = false }
a == b // true, equals() never looks at isSelected

LiveData is lifecycle-aware by construction, it only notifies observers when the LifecycleOwner is at least STARTED and unsubscribes automatically on destroy, and it's tied to Android and the main thread, so it doesn't work in plain Kotlin modules or shared code without extra glue. Flow is a general-purpose coroutines construct, cold by default, meaning the producer block doesn't run until something collects it and each new collector re-runs it from scratch unless you explicitly share it, and it has zero lifecycle awareness baked in.

You get lifecycle awareness back with Flow by collecting inside `repeatOnLifecycle(Lifecycle.State.STARTED)` in a lifecycleScope.launch, which mirrors what LiveData did for free. What Flow actually buys you is the operator set, map, debounce, distinctUntilChanged, flatMapLatest, combine, which composes cleanly for something like search-as-you-type, where the LiveData equivalent means dropping into Transformations.switchMap and losing most of that expressiveness. Most new screens skip LiveData in the ViewModel layer entirely now and only reach for it to bridge into older Java call sites that expect it.

StateFlow always holds a current value, requires an initial value up front, conflates emissions so a slow collector only ever sees the latest state rather than every intermediate one, and always replays that latest value to any new collector. That's exactly the behavior you want for "the current state of this screen." SharedFlow has no required initial value and no built-in conflation, you configure replay count and buffer behavior yourself, and with the default replay of zero, a new collector only sees emissions that happen after it starts collecting.

That makes SharedFlow the right tool for one-shot events, show this snackbar, navigate to this screen, things you don't want replayed to every future subscriber. A recurring bug is using StateFlow for a one-off navigation event, then discovering navigation refires on configuration changes because StateFlow replays its last value to every new collector, exactly the opposite of what a single-shot event needs.

kotlin
private val _uiState = MutableStateFlow(UiState.Loading) // always has a value, replays latest
val uiState: StateFlow<UiState> = _uiState

private val _events = MutableSharedFlow<UiEvent>() // no replay by default
val events: SharedFlow<UiEvent> = _events

The classic causes are all versions of a long-lived object holding a reference to something that should be short-lived: a static field or singleton holding onto an Activity or View context, an inner non-static class or anonymous listener implicitly capturing its outer Activity after that Activity is destroyed, a Handler created without an explicit Looper holding a reference to the enclosing Activity through its posted Runnables, and registered listeners like a BroadcastReceiver or a location callback that never get unregistered in onStop or onDestroy.

LeakCanary works by hooking into onDestroy and onDestroyView, watching those objects with a weak reference, forcing a GC, then checking a short while later whether the reference is still alive. If it is, that object should have been collected and wasn't, so LeakCanary walks the heap dump and computes the shortest path from a GC root to that object, which is exactly the leak trace it shows you, pointing at the field that's holding on. In practice the fix is almost always one of three moves: use a weak reference for anything long-lived pointing at something Android will destroy, unregister listeners in the matching lifecycle callback, and use applicationContext instead of an Activity context for anything meant to outlive the Activity.

Manual dependency injection still works at small scale, you write factory functions or a hand-built container of singletons, but it stops scaling once every class needs to know how to construct everything it depends on, and getting the lifetime right per Activity, Fragment, or ViewModel by hand is easy to get wrong, letting a singleton-scoped object leak into something shorter-lived or the reverse.

Hilt generates that container code at compile time from annotations. You mark a constructor @Inject, scope the class as @Singleton, @ActivityScoped, or @ViewModelScoped depending on how long it should live, and Hilt wires up the graph while enforcing those scope rules, throwing a compile error if you try to inject something Activity-scoped into a class that outlives the Activity. It's built on Dagger, so you get compile-time safety with no runtime reflection, without hand-writing Dagger's components and modules yourself. The real cost is build time, since annotation processing adds real minutes to a clean build on a large app, which is why some teams keep small modules on manual DI or Koin instead.

kotlin
@Singleton
class UserRepository @Inject constructor(
    private val api: UserApi,
    private val db: UserDao
)

An ANR fires when the main thread doesn't respond to an input event within five seconds, or a BroadcastReceiver's onReceive doesn't return within ten seconds, and the system shows the app-isn't-responding dialog while generating a traces file with every thread's stack at that moment. Locally, StrictMode is the first line of defense, flagging disk reads, writes, and network calls happening on the main thread as they occur in a debug build, which catches a large share of ANR causes before they ever reach production.

For an ANR you can't reproduce, Play Console's Android vitals gives you the actual stack collected from real devices, and the first thing to check is what the main thread was doing at the top of that trace, a synchronous DB query, a lock wait, a synchronous binder call into another process. A less obvious cause worth checking is contention on a lock held by a background thread the main thread is also waiting on, which won't look like "the main thread did something slow" in a naive read, so you have to check what every thread is doing, not just the main one, since the main thread can look idle while it's actually blocked on a mutex.

A configuration change destroys and recreates the Activity, but the system deliberately keeps the ViewModelStore alive across that specific cycle because it knows the change is coming, so the new Activity gets handed back the same ViewModel instance. Process death is different, the OS kills the whole process, ViewModel included, to reclaim memory, which can happen to any backgrounded app, and when the user navigates back Android spins up a brand new process and calls onCreate with a saved Bundle, but there's no ViewModel object to hand back since the in-memory instance is gone along with everything else.

SavedStateHandle bridges that gap. It's a small key-value store injected into the ViewModel automatically that gets saved into the same Bundle used before process death and restored into a fresh SavedStateHandle when the process and ViewModel get recreated, so the ViewModel checks that handle for saved values instead of assuming a clean start. The catch is that values have to be small and parcelable since they go through the same Bundle mechanism as onSaveInstanceState, so it's for "which item is selected" or "what's in the search box," not for caching a large list of network results.

Hand-rolled pagination usually means manually tracking a page or offset cursor, deciding when the RecyclerView is close enough to the bottom to trigger the next request, deduplicating requests if fast scrolling triggers two loads before the first one returns, and handling error and retry state per page, and most homegrown implementations get at least one of those wrong under fast scrolling or rotation.

Paging 3 handles all of it through a PagingSource that knows how to load a page given a key, a Pager that turns a stream of those into a PagingData Flow, and a PagingDataAdapter that diffs incoming pages against what's already shown, so loads trigger automatically at a configurable prefetch distance without duplicate in-flight requests. You also get separate loading and error states per load type, refresh, prepend, append, that you can surface directly in the UI, like a retry footer if the next page fails, and if you need local cache plus network, a RemoteMediator plugs into the same Pager so Room stays the actual source of truth while network results populate it in the background.

Hard questions

12

A Fragment has two separate lifecycles: the Fragment instance's own lifecycle, and the View lifecycle, tracked by getViewLifecycleOwner(), which is shorter. It's destroyed and recreated independently when the Fragment is added to the back stack, even though the Fragment instance itself stays alive.

If you observe LiveData using the Fragment's lifecycle instead of the view's, the observer keeps firing after the view is destroyed, either crashing on a null view reference or silently leaking the old view. This is a classic case where the "obviously correct" answer, just use this as the LifecycleOwner, is wrong in a way that only shows up when the Fragment goes through the back stack, not on first load.

LaunchedEffect(key) ties a coroutine to the composition itself, it starts when the composable enters composition and restarts automatically whenever its key changes, good for reacting to state, like fetching data when a screen ID changes. rememberCoroutineScope gives you a CoroutineScope tied to the composable's lifecycle that you control manually, used when you need to launch a coroutine from an event callback, a button's onClick, since you can't call suspend functions directly from a non-suspend lambda.

The follow-up interviewers ask: "what happens if you put LaunchedEffect inside the onClick lambda instead?" You can't, LaunchedEffect is a composable function itself and can only be called during composition, not from an arbitrary callback. That distinction alone weeds out people who've copied Compose code without understanding it.

Intent extras go through Binder, and the whole transaction buffer is shared across everything happening in your process at that moment, commonly cited around 1MB total, not 1MB per Intent. Pass a large Bitmap, a big Parcelable list, or several screens' worth of extras in quick succession, and you can hit TransactionTooLargeException well before any single object looks obviously too big. The fix is usually to hold large data in a shared ViewModel, a repository, or an in-memory cache, and pass a lightweight ID or key through the Intent instead of the object itself.

The standard approach: a LinkedHashMap constructed with accessOrder = true, so the map itself keeps track of recency order for you, then override the size check to evict the oldest entry once you're over a size budget you track manually. Android's own LruCache does this conceptually, tracking a running total in bytes rather than pure entry count, since bitmaps of different sizes shouldn't count as one unit each.

kotlin
class BitmapLruCache(private val maxSizeBytes: Int) {
    private var currentSize = 0
    private val cache = object : LinkedHashMap<String, Bitmap>(16, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Bitmap>): Boolean {
            if (currentSize > maxSizeBytes) {
                currentSize -= sizeOf(eldest.value)
                return true
            }
            return false
        }
    }

    @Synchronized
    fun put(key: String, bitmap: Bitmap) {
        cache[key] = bitmap
        currentSize += sizeOf(bitmap)
    }

    @Synchronized
    fun get(key: String): Bitmap? = cache[key]

    private fun sizeOf(bitmap: Bitmap): Int = bitmap.byteCount
}

Interviewers follow up by asking what happens under concurrent access from multiple threads. The @Synchronized annotation above is the minimum bar, but a real production version usually needs a proper lock strategy if put and get are called from background threads at high frequency, not just the UI thread.

Any thread that wants to process a queue of messages needs a Looper and a MessageQueue. The main thread gets one automatically through Looper.prepareMainLooper() before your app's code even runs, and from then on it's just a loop pulling Message objects off that queue one at a time and dispatching each to whatever Handler posted it. Every "run this on the main thread" mechanism in Android, View.post, Handler.post, Dispatchers.Main, ultimately funnels through posting a Message onto that same queue, which is also why one expensive callback blocks everything else waiting behind it, there's no preemption, whatever's running finishes before the next message gets pulled.

For background work that needs its own serial queue, sequential database writes off the UI thread without spinning up a Thread per write, a HandlerThread gives you a real Thread with Looper.prepare() already called, and after it starts you grab its Looper and build a Handler pointed at it. Every Runnable posted to that Handler executes serially on that one thread in arrival order, with no manual queue and no risk of two writes racing each other. In coroutine terms this maps onto a single-threaded dispatcher, which is exactly why coroutines don't force you to think about Looper and Handler explicitly anymore, even though the mechanism underneath hasn't changed at all.

kotlin
val handlerThread = HandlerThread("db-writer").apply { start() }
val handler = Handler(handlerThread.looper)

handler.post {
    // runs serially on its own thread, one write at a time
    database.insert(record)
}

Touch dispatch starts at the Activity and calls dispatchTouchEvent on the root ViewGroup, which calls its own onInterceptTouchEvent before asking any child; if that returns false, the event gets forwarded to whichever child's bounds contain the touch point, and this repeats recursively down to a leaf View, which finally decides in its own onTouchEvent whether to consume it. Once a View returns true for ACTION_DOWN, it keeps receiving every subsequent event in that gesture, ACTION_MOVE, ACTION_UP, even if the finger moves outside its bounds, because Android tracks gesture ownership from the DOWN event rather than re-hit-testing on every move.

The common bug is a parent ViewGroup's onInterceptTouchEvent returning true partway through a gesture, most often something like a swipeable container deciding "this is actually a horizontal swipe, I'm taking over," which sends the child an ACTION_CANCEL and steals every remaining event for that gesture. The child never sees the ACTION_UP, so any click listener attached to it silently never fires. Debugging usually means overriding dispatchTouchEvent temporarily to log every MotionEvent action and which View is being asked, since onInterceptTouchEvent never shows up in a stack trace and the actual interception can be happening several ancestors above the View you're staring at.

The Compose compiler decides whether it can skip recomposing something by checking if every parameter is stable, meaning its equals() reliably reflects whether it actually changed. Immutable types, String, the primitives, anything the compiler can prove is truly immutable, plus classes annotated @Immutable or @Stable, satisfy that. A plain data class with a var property, or one holding a MutableList, doesn't, because the compiler can't prove an existing instance won't mutate silently without triggering an update.

Lambdas passed into a Composable capture whatever's in scope, and if that capture includes something unstable, an unstable class instance or a var referenced inline, the compiler can't guarantee the lambda instance itself stays stable between recompositions even if functionally it's identical, so it marks that parameter unstable and skips the optimization for anything depending on it. In a LazyColumn this shows up as every row recomposing when you tap one item, because the onClick lambda for each row captures the whole unstable list or an unstable ViewModel reference instead of just that row's id. The fix is almost always narrowing what the lambda captures, pass a primitive id instead of a whole object, and you can confirm the real problem by turning on Compose compiler metrics, which report exactly which Composables and parameters got marked unstable and why.

Under a plain Job, an unhandled exception in any child propagates to the parent, which cancels itself and, in turn, cancels every other child, because a regular Job treats one child's failure as a failure of the whole unit of work, which is structured concurrency doing exactly what it's designed to do. A SupervisorJob breaks that propagation at one level down, an exception in a direct child of the SupervisorJob doesn't cancel the SupervisorJob or its siblings, which is exactly why viewModelScope and lifecycleScope are built on it internally, you don't want one Fragment's failed data load canceling every other coroutine that scope happens to be running.

A CoroutineExceptionHandler only gets invoked for exceptions that would otherwise reach the top of the hierarchy uncaught, and it has to sit on the outermost scope's context, not on an async builder, because a failure inside async is stored in the resulting Deferred and only rethrown when you call await(), so a handler on the scope never sees it, you have to wrap the await() call itself in try and catch. A frequent mistake is installing the handler on a child coroutine's own context expecting it to catch that child's failures, when it only takes effect for exceptions that propagate all the way to a coroutine with nowhere else to hand them off, so nesting it wrong means the exception gets silently absorbed by cancellation instead of ever reaching the handler.

kotlin
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

scope.launch {
    launch { throw RuntimeException("child A failed") } // does not cancel scope or child B
    launch { doWork() } // keeps running
}

The exception's stack trace tells you which View call triggered the check, CalledFromWrongThreadException is thrown from ViewRootImpl.checkThread, called via requestLayout or invalidate, but it almost never tells you which line of your code originally launched the background work that ended up touching that view, so the crash trace by itself is usually a dead end. The real culprit is almost always a callback, a network response handler, a coroutine launched on Dispatchers.IO or Dispatchers.Default that updates UI directly instead of switching back with withContext(Dispatchers.Main), or a listener from a third-party SDK, analytics, Bluetooth, location, that Android doesn't guarantee runs on the main thread even though it usually does.

Because it's intermittent, it's almost always a race, the callback normally returns fast enough that some other main-thread event is already handling it by the time it fires, or the specific device or OS version where it actually crashes has different threading behavior for that one callback, which happens more than people expect with camera and Bluetooth APIs. Without a repro, the systematic path is enabling StrictMode's detectAll() with penaltyLog() in a debug build and stress-testing the exact code paths on the crashing screen that touch views from callbacks, then grepping every listener registration on that screen and checking its documented threading contract instead of assuming, since one wrongly-assumed callback thread is the actual root cause in the large majority of these reports.

The build-speed win from modularization comes from Gradle's parallel and incremental build support, if module A changes and module B depends on it but module C doesn't, Gradle only needs to rebuild A and B. That only works if the module graph is genuinely a graph and not secretly one blob where everything depends on everything, which is what happens when every module exposes dependencies with api instead of implementation. implementation means a dependency stays invisible outside that module and doesn't leak onto the compile classpath of anything depending on it, so if module B uses implementation for a library, and module A depends on B, changing that library only forces a recompile of B, not A. api exposes the dependency transitively, so any change to it forces a recompile of B and everything downstream of B too.

In practice this means feature modules like feature-checkout and feature-profile depending on a small set of core modules, core-network, core-ui, core-database, but never depending directly on each other, mediated instead through a thin navigation or contract module if one feature needs to trigger navigation into another, otherwise you get a circular dependency the build won't even let you express, or a core module everything transitively depends on via api where changing one string resource forces a full rebuild of forty feature modules. Annotation processors like Hilt and Room run per module, so aggressive modularization without care can actually slow a clean build down even while speeding up incremental ones, which is why teams measure with the Gradle profiler before assuming more modules is automatically faster.

kotlin
// feature module build.gradle.kts
dependencies {
    implementation(project(":core-network")) // not exposed downstream
    api(project(":core-ui"))                  // exposed to anything depending on this module
}

A ContentProvider is still the right tool specifically when you need to expose structured, queryable data across a process boundary to apps you don't control, the classic case being contacts, calendar, or media-store-style data where another app runs a query with a WHERE clause and gets a Cursor back, not just a file or a broadcast. That querying interface, backed by Binder IPC, is what a ContentProvider gives you that a shared file never does. FileProvider is itself a ContentProvider subclass, not really an alternative to one, it's a narrower pre-built one Google ships specifically to hand out secure, temporary URIs to files without granting broad filesystem access, which covers "share this PDF to another app" but not "let another app search my locally cached data by keyword."

Where teams get it wrong today is reaching for a full custom ContentProvider for in-app-only data sharing between their own components, when a Room database plus a repository injected via Hilt does the same job with none of the Binder marshaling overhead and none of the manifest declaration and permission ceremony a ContentProvider requires, since IPC is unnecessary within the same process. The other legitimate remaining use is app startup: androidx.startup.Initializer is itself implemented as a ContentProvider under the hood, specifically because ContentProviders are guaranteed to be created before Application.onCreate() finishes, a guarantee that's genuinely hard to get any other way.

A JPEG's file size on disk has almost nothing to do with its decoded size in memory. A decoded Bitmap stores one uncompressed value per pixel position, four bytes per pixel for ARGB_8888, so a 4000x3000 photo from a modern phone camera decodes to roughly 48MB in memory regardless of a 300KB file size, and if your ImageView is only 200dp wide, you're holding 48MB to display something that could have been decoded at a fraction of that size.

Glide and Coil both solve this the same fundamental way. They use BitmapFactory.Options.inJustDecodeBounds to read the image's dimensions without allocating the full bitmap, calculate an inSampleSize based on the actual target ImageView size so the image is decoded already downsampled to close to what's needed, and reuse an existing Bitmap's backing memory via inBitmap, bitmap pooling, instead of allocating a fresh one for every image, which matters a lot in a scrolling grid where thumbnails constantly get recycled.

Diagnosing this means checking bitmap allocations in the Memory Profiler while scrolling the grid; bitmap sizes far larger than the ImageView's actual pixel dimensions are the signature of loading a full-res source without ever specifying a target size or override(). The other thing worth checking is RecyclerView items that don't cancel an in-flight image load when their ViewHolder gets recycled for a different item, which shows up as thumbnails briefly flashing the wrong image and memory climbing from multiple concurrent decodes racing for the same ImageView instead of the old load getting canceled when a new one starts on that holder.

What we see in Android mock interviews on LastRoundAI

Across the Android sessions candidates run through LastRoundAI's mock interview product, the pattern that shows up over and over isn't a knowledge gap, it's a confidence gap on follow-ups. Someone explains remember vs. rememberSaveable correctly, then freezes the moment the interviewer asks what happens if the Activity gets killed by the system instead of just recreated. The first answer was memorized. The second question tests whether you actually understand it.

The candidates who improve fastest aren't the ones who learn more facts, they're the ones who practice narrating their reasoning under a follow-up question, out loud, before the real interview. That's a rehearsal problem, not a knowledge problem, and it's the gap a live mock interview closes that a static question list can't.

Leave a Reply

Your email address will not be published. Required fields are marked *