androidinterview.com

Android Interview Questions

126 questions

Tier
Difficulty
Level

Showing all 126 questions

App Components

Name all the Android application components.

Tier: EssentialDifficulty: Easy

Android has four application components, and every app is built out of some combination of them.

  • Activity, a single screen with a UI that the user interacts with.
  • Service, a component that runs work in the background without a UI, like playing music or syncing data.
  • BroadcastReceiver, a component that waits for an event and runs when it happens. Think publish and subscribe. The system or an app publishes an event, the device finished booting, the battery is low, a download completed, and every receiver that subscribed to that event gets a call.
  • ContentProvider, a component that lets other apps read or write your data safely without touching your database. The contacts app is one, so a messaging app can look up a name and number without knowing how contacts are stored. Your photo gallery reads images the same way, through the media provider.

All four are declared in AndroidManifest.xml, and that's what makes them components rather than ordinary classes. The system knows about them ahead of time and can create and destroy them on its own schedule, which is different from a plain Kotlin class you instantiate yourself. That's also why each one has its own lifecycle that you don't fully control. The OS decides when to call onCreate(), onStartCommand(), onReceive(), or a query method, not you.

What is Context? How is it used?

Tier: EssentialDifficulty: Medium

Context is how your code talks to the Android system. Through it you read strings and other resources, get system services like the notification manager, open files and databases, and start activities, services or broadcasts. If a line of code touches the app or the device rather than just your own variables, it needs a Context.

There are two flavors you actually deal with day to day.

  • Application context, from applicationContext. It lives as long as the app process does. Use it for anything that should outlive a single screen, like a database instance, a singleton, or a repository.
  • Activity context, the Activity itself. It's tied to that Activity's lifecycle and carries theme and window information the Application context doesn't have. Use it for anything UI related, like inflating a themed view or showing a dialog.

The rule that actually matters in interviews is not mixing them up. Holding an Activity context in a long lived object, like a singleton or a static field, leaks the entire Activity and everything it references, because that object outlives the Activity but still points back to it. The garbage collector can never reclaim it. This is the classic Android memory leak, and the fix is always the same, pass applicationContext into anything that isn't scoped to the UI.

What is AndroidManifest.xml?

Tier: CommonDifficulty: Easy

AndroidManifest.xml is the file that describes an app to the Android system before any of its code runs, its components, its permissions, and the metadata the OS needs to install and launch it correctly.

It's where you declare a handful of essential things.

  • Application components, every Activity, Service, BroadcastReceiver, and ContentProvider your app defines has to be listed here, or the system doesn't know it exists.
  • Permissions, both the ones your app requests, like camera or location, and the protection level of any permission your own components expose to other apps.
  • Intent filters, which actions, categories, and data types a component can respond to, this is what lets an implicit Intent from another app find your Activity.
  • Hardware and software requirements, minimum SDK version, required features like a camera, which the Play Store uses to decide which devices can even install the app.
  • App level metadata, the application's icon, label, theme, and its Application subclass if it has one.

Since the switch to a modular Gradle build, most large apps don't hand write one final manifest. Each module contributes its own manifest, and the Android Gradle Plugin merges them into a single manifest at build time, resolving conflicts between them using merge rules you can override with tools: attributes when two modules disagree.

What is the Application class?

Tier: CommonDifficulty: Easy

Application is the base class Android instantiates once per process, before any Activity, Service, or other component, and it stays alive for as long as that process does. You subclass it when you need setup that has to happen exactly once and be available everywhere, and you register your subclass in the manifest with android:name.

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        initLogging()
        initDependencyGraph()
    }
}

Typical uses are initializing a dependency injection graph, setting up crash reporting, or creating singletons that need an applicationContext and should live for the whole process, like a database instance shared across the app.

The thing worth knowing for the follow up question is what Application is not for. It's not a place to hold mutable app state as public fields, that state has no lifecycle tied to it, survives configuration changes in a way that's easy to get wrong, and just moves the same bugs a leaked static field would cause into a class that looks more official. A ViewModel or a repository is almost always the better home for state, Application is for one time setup, not for storing data.

Activities & Fragments

What is the difference between a Fragment and an Activity? Explain the relationship between the two.

Tier: EssentialDifficulty: Easy

An Activity is a system entry point with its own window and its own place in the task back stack. A Fragment has no window of its own and can't exist on screen without being hosted inside an Activity.

That's the core relationship. A Fragment is attached to a host Activity through a FragmentManager, and it borrows the Activity's window to draw itself. This is why a Fragment lifecycle has extra states like onAttach() and onCreateView() that an Activity doesn't need, the Fragment has to plug itself into something that already exists rather than being launched directly by the system.

In practice the split is about modularity inside a single screen. One Activity can host several Fragments, swap them in and out, or show two of them side by side on a tablet, all without recreating the Activity or its window. That's cheaper than juggling multiple Activities, and it's why most apps today use a single Activity with Fragments, or Compose destinations, handling navigation underneath it.

The tradeoff is that Fragments come with real complexity. The FragmentManager back stack, the separate view lifecycle through viewLifecycleOwner, and the way a Fragment can survive with its view torn down and rebuilt, none of that exists for an Activity. You take on that complexity in exchange for cheaper navigation and more flexible layouts.

What are launch modes in Android (including singleTask)?

Tier: EssentialDifficulty: Medium

A launch mode is an instruction in the manifest that tells Android how to place a new Activity instance into a task, and there are four of them.

  • standard, the default. Every startActivity() call creates a brand new instance, even if one is already on top.
  • singleTop. Same as standard, except if the target Activity is already at the top of the stack, Android reuses it and delivers the new data through onNewIntent() instead of creating another instance.
  • singleTask. Android looks for an existing instance anywhere in a task. If it finds one, it clears everything above it and brings it forward, again calling onNewIntent(). If it doesn't find one, it starts a new task with this Activity at the root. Only one instance of the Activity can exist at a time.
  • singleInstance. Like singleTask, but the Activity gets its own task all to itself, and nothing else is ever launched into that task.
standard launch modeBefore, a task holds A at the bottom, then B, then C on top. The app calls startActivity for B. After, a new B instance sits on top of C, so the task holds A, B, C and a second B, and two B instances exist at once.
the standard launch mode, before and after
standard, the default. Launching B again pushes a second B instance on top of the task, so two B instances now exist and back will visit each one in turn.
singleTop launch modeTwo cases, both starting from a task holding A, B and C with C on top. In the first case the app calls startActivity for C. C is already at the top, so the system reuses the instance and calls onNewIntent on it, and no new instance is created. In the second case the app calls startActivity for B. B is not at the top, so a new B instance is pushed on top of C, exactly as in standard mode.
the singleTop launch mode, both cases
singleTop. Launching C while it is already on top reuses the instance and delivers the intent through onNewIntent(). Launching B, which is not on top, still creates a new instance, the mode only guards the top of the stack.
singleTask launch modeBefore, a task holds A at the bottom, then B, then C on top. The app calls startActivity for A. The system routes the intent to the existing A instance through onNewIntent and destroys B and C, everything that was above A. After, the task holds only A, and the destroyed B and C are shown popped off above the stack.
the singleTask launch mode, before and after
singleTask. Launching A routes the intent to the existing instance through onNewIntent() and destroys every activity that was above it, so back from here leaves the task instead of revisiting B and C.
singleInstance launch modeBefore, Task 1 holds A at the bottom, then B, then C on top. The app calls startActivity for D, and D declares singleInstance. After, there are two tasks. Task 1 still holds A, B and C, and Task 2 holds only D, because a singleInstance activity is always the single and only member of its task.
the singleInstance launch mode, two tasks
singleInstance. D is the single and only activity in its task, the old task keeps A, B and C untouched, and anything D starts opens in a different task.

The one that actually comes up in interviews is singleTask, because it's what you use for an entry point you want to return to cleanly, like a main screen after a deep link or a notification tap. Say a notification opens a detail screen that's singleTask. If the user already has that screen open three levels deep in the back stack, tapping the notification doesn't stack a new copy on top. Android pops everything above it back to that instance and calls onNewIntent() on it with the fresh intent.

Back press pops the back stackA task holds activities A, B and C with C on top. Pressing back pops C off the stack and destroys it, so the task holds A and B and B resumes. Pressing back again destroys B and resumes A. When the last activity is popped the task is finished.
the back stack after each back press
Each back press pops the top activity off the task and destroys it, and the activity beneath resumes with its state restored. This is why the launch mode matters, it decides what the stack holds before back starts popping.

Worth remembering, the manifest launch mode is a static default. You can override some of this behavior at call time with intent flags like FLAG_ACTIVITY_CLEAR_TOP or FLAG_ACTIVITY_SINGLE_TOP, which is often the more flexible tool since it doesn't lock the Activity into that behavior everywhere it's launched from.

One detail that catches people out. When onNewIntent() runs, getIntent() still returns the Intent the Activity was originally created with. The new one is only handed to you as the parameter. If you want the rest of the Activity to see it, you have to call setIntent(intent) inside onNewIntent() yourself.

The scenario questions interviewers actually ask

Most launch mode interviews are a run of small puzzles. Here is the stack before, the action, and the stack after, for the ones that keep coming up.

  • Everything standard, start B again. The stack is A, B, C. C calls startActivity() for B. You get A, B, C, B, because standard always builds a new instance. Back from the new B goes to C.
  • singleTop when the target is not on top. The stack is A, B, C, B is singleTop, and C starts B. You still get A, B, C, B. singleTop only reuses an instance that is already at the top of the task.
  • singleTop when it is on top. The stack is A, B, C, C is singleTop, and C starts C. Nothing new is created. The existing C receives onNewIntent() and the stack stays A, B, C.
  • singleTask. The stack is A, B, C, B is singleTask, and C starts B. Android finds the existing B, destroys C, and delivers onNewIntent() to B. The stack is now A, B, and back goes to A.
  • singleInstance. The stack is A, B, and B starts D, which is singleInstance. D gets a task of its own with nothing else in it. Now D starts E, an ordinary activity. E cannot join D's task, so it lands in the original task on top of B. The stack reads A, B, E, and back from E goes to B, not D.
  • Home, then relaunch from the launcher. The stack is A, B, C. Home puts the whole task in the background with C still on top, and tapping the icon brings that same task back, C first. It only resets if the root activity sets clearTaskOnLaunch, or a launch mode or flag says otherwise.
  • A notification while the app is running. The app is on A, B, C, D and the notification opens C, which is singleTask. D is popped, C gets onNewIntent(), and the stack is A, B, C.
  • The same notification with the app not running. You get a fresh task with C at its root, so back leaves the app immediately. Build the parent chain with TaskStackBuilder and back walks up to the parent screen instead.
  • Back versus home. Back pops the top activity and finishes it. Home moves the entire task to the background, and every activity in it stays alive until the system needs the memory.
  • taskAffinity picks the task, the launch mode picks the instance. People assume singleTask always makes a new task. It does not. If a task with a matching affinity already exists, the activity joins that one, and affinity defaults to the package name for every activity in the app.
  • singleInstancePerTask, added in Android 12, API 31. Read it as singleTask scoped to a task. The activity is always the root of its task and still clears whatever sits above it, but it can exist once per task instead of once per device. Extra copies come from FLAG_ACTIVITY_MULTIPLE_TASK or FLAG_ACTIVITY_NEW_DOCUMENT, which is what multi window and multi instance launchers need.

Flags versus manifest

Every mode has a call time equivalent, and the flags are usually the better tool because they apply to one launch instead of every launch.

  • FLAG_ACTIVITY_SINGLE_TOP gives you singleTop for this one call.
  • FLAG_ACTIVITY_CLEAR_TOP finds the target lower in the task and destroys everything above it. There is no manifest equivalent for this one.
  • FLAG_ACTIVITY_NEW_TASK starts the activity in a task matching its affinity, creating one if none exists.
  • FLAG_ACTIVITY_CLEAR_TASK empties that task first, and it only works when paired with NEW_TASK.
  • FLAG_ACTIVITY_REORDER_TO_FRONT moves an existing instance to the top without destroying anything above it.
// A screen only ever reached from the notification. Land on a clean stack.
val direct = Intent(this, DetailActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val directPending = PendingIntent.getActivity(
    this, 0, direct,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

// A screen inside the normal flow. Build the parents so Back walks up
// the hierarchy from android:parentActivityName instead of leaving the app.
val withParents = TaskStackBuilder.create(this).run {
    addNextIntentWithParentStack(Intent(this@MainActivity, DetailActivity::class.java))
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}

Read more Tasks and the back stack (opens in a new tab)The activity element, launchMode and clearTaskOnLaunch (opens in a new tab)Start an activity from a notification (opens in a new tab)

Why do we need to call setContentView() in onCreate() of an Activity? What does setContentView do?

Tier: EssentialDifficulty: Medium

An Activity has a window but no UI in it until you tell it what to show, and setContentView() is that step. It inflates your layout, either an XML resource or a View you build in code, and attaches the result as the Activity's window content, so the framework has something to measure, lay out, and draw.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main) // inflates the layout and attaches it
    val button = findViewById<Button>(R.id.submitButton)
}

It has to run in onCreate(), and specifically before you touch anything in the layout, because findViewById() and anything else that looks up a view only works once that view tree actually exists. Call setContentView() too late, or skip it entirely, and any findViewById() call before it either throws or returns null depending on what you're looking up.

setContentView() can also take a View instance directly instead of a resource ID, which is how you'd attach a view built entirely in code. Jetpack Compose skips this altogether, a Compose Activity calls setContent { } instead, which does the equivalent job of giving the Activity's window something to render, just with a composable instead of an inflated layout.

Why is it recommended to use only the default constructor to create a Fragment?

Tier: EssentialDifficulty: Medium

Because Android recreates Fragments for you, and when it does, it can only call the no-argument constructor. If you rely on a custom constructor to pass in data, the system has no way to call it with the right arguments during that recreation, so your data is gone and the app usually crashes trying to instantiate the Fragment through reflection.

This recreation happens more than people expect. A configuration change, a process death while the app is backgrounded, or the fragment manager restoring state after the host Activity comes back all recreate Fragments using the default constructor internally.

The correct pattern is a static factory method paired with setArguments().

class DetailFragment : Fragment() {
    companion object {
        fun newInstance(itemId: String) = DetailFragment().apply {
            arguments = bundleOf("itemId" to itemId)
        }
    }
}

arguments is a Bundle, and the fragment manager knows how to save and restore a Bundle across recreation on its own. So when the system rebuilds the Fragment with the empty constructor, it reattaches the same Bundle you originally set, and your data survives. This is the same reason you read arguments back out in onCreate() rather than caching values passed straight through a constructor parameter.

How would you communicate between two Fragments?

Tier: CommonDifficulty: Easy

Never directly, and there are three ways to do it properly, in this order of preference. The Fragment Result API for a one off value, a shared ViewModel for state both Fragments care about over time, and an interface on the host Activity, which is the old pattern you should recognise but not reach for.

The Fragment Result API is the right default when one Fragment hands a value back to another and then goes away. The sender calls setFragmentResult() with a request key and a Bundle. The receiver registers setFragmentResultListener() against the same key, and both sides have to be talking to the same FragmentManager. For siblings that is parentFragmentManager, which is what the Fragment level extensions use for you. When a parent listens to a Fragment it hosts, the parent registers on childFragmentManager instead. Register early, in onCreate() or onViewCreated(), because the result is only delivered once the listening Fragment reaches STARTED. Until then the FragmentManager holds it, so a Fragment sitting on the back stack still gets the value when it comes forward. Each result is delivered once and then cleared, and there is one listener and one pending result per key.

// Sender, a bottom sheet handing back the item the user picked
class PickerFragment : DialogFragment() {
    private fun onPicked(id: String) {
        // The Fragment extension posts to parentFragmentManager
        setFragmentResult("pickRequest", bundleOf("itemId" to id))
        dismiss()
    }
}

// Receiver, registered early so it is listening before the result lands
class ListFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setFragmentResultListener("pickRequest") { _, bundle ->
            val id = bundle.getString("itemId")
        }
    }
}

A shared ViewModel wins when both Fragments read and write the same state over time, rather than passing one value once. Scope it to the Activity with activityViewModels(), or to a navigation graph so it dies with the flow instead of living as long as the Activity does. Neither Fragment knows the other exists, and the state survives a configuration change.

class SharedViewModel : ViewModel() {
    val selected = MutableStateFlow<Item?>(null)
}

class ListFragment : Fragment() {
    private val vm: SharedViewModel by activityViewModels()
}

class DetailFragment : Fragment() {
    // Scoped to the checkout graph instead, so it is gone when the flow ends
    private val vm: SharedViewModel by hiltNavGraphViewModels(R.id.checkout_graph)
}

An interface on the host Activity is the pattern you will still find in older code. The Fragment declares a small callback interface, the Activity implements it, the Fragment casts its context in onAttach(), and calls through it.

class ListFragment : Fragment() {
    interface Host { fun onItemPicked(id: String) }

    private lateinit var host: Host

    override fun onAttach(context: Context) {
        super.onAttach(context)
        host = context as Host // blows up if this Activity forgot to implement it
    }

    private fun onPicked(id: String) = host.onItemPicked(id)
}

It is fragile for three reasons. The Fragment is now coupled to one kind of host, so you cannot reuse it in another Activity or nest it inside another Fragment. The cast is unchecked, so a mistake becomes a crash at runtime instead of a compile error. And nothing in it survives recreation, so after a rotation or a process death the Activity has to rebuild the state by hand.

In the room, say Fragment Result API for a one shot value, shared ViewModel for ongoing state, and interfaces only when you are reading a legacy codebase. Then name the two anti patterns, because that is what the interviewer is listening for. Never hold a direct reference to the other Fragment, since it breaks the moment either one is reused and it leaks when the other is detached. And never route it through a static field or an event bus, since you lose all lifecycle safety and nobody reading the code can tell where a value came from.

Read more Communicate between fragments (opens in a new tab)

What is a Bundle in Android?

Tier: CommonDifficulty: Easy

A Bundle is a key value container Android uses to pass data between components and to save and restore state, backed by a Parcel so it can be written to and read from an Android specific binary format efficiently.

You run into it in three places mostly.

  • Intent extras, intent.putExtra("id", value) under the hood stores values in the Intent's Bundle.
  • Fragment arguments, set with arguments = bundleOf(...), which is how you pass data into a Fragment safely across recreation.
  • onSaveInstanceState(), where you stash small bits of transient UI state, like a scroll position, before the system might destroy and recreate an Activity.
val bundle = bundleOf("itemId" to id, "isEditable" to true)

The reason a Bundle exists instead of Android just using a Map is that it only accepts types it knows how to write to a Parcel, primitives, String, Parcelable, and arrays of those. That restriction is what lets it survive being flattened to bytes and sent across process boundaries or reconstructed after process death, something a generic Map holding arbitrary objects has no way to guarantee.

What is the purpose of addToBackStack() when committing a fragment transaction?

Tier: CommonDifficulty: Easy

addToBackStack() makes a fragment transaction reversible by the system back button. Without it, a replace() or remove() is final, the outgoing Fragment is gone and pressing back does nothing to bring it back. With it, the transaction is pushed onto the fragment manager's own back stack, and back navigates through those transactions before falling through to the Activity level back behavior.

supportFragmentManager.commit {
    replace(R.id.container, DetailFragment())
    addToBackStack("detail")
}

The string argument is an optional name for that back stack entry, which you can use later with popBackStack("detail", ...) to pop directly back to it rather than one step at a time.

It's worth being precise about what "reversible" means here. Popping the back stack doesn't recreate the previous Fragment from scratch the way navigating forward did, it restores the state of the Fragment that was already there before the transaction ran. This only applies to fragment transactions run through the FragmentManager. If you're using Jetpack Navigation, the NavController manages its own back stack and you don't call addToBackStack() directly at all, the same reversibility is what a navigate() call gives you by default.

When should you use a Fragment rather than an Activity?

Tier: CommonDifficulty: Easy

Use a Fragment when you need a piece of UI that has to be reused across screens or combined with other pieces on the same screen, since an Activity can't be nested inside another Activity but a Fragment can be nested inside one.

  • Reusable UI, a piece of screen you want to drop into more than one place, like a filter panel used on both a search screen and a browse screen.
  • Multi pane layouts, showing a list and a detail view side by side on a tablet, which needs two independent pieces of UI living in one Activity at once.
  • Any screen using Jetpack Navigation, since the Navigation library is built around a single Activity hosting a graph of Fragment or Compose destinations, not a separate Activity per screen.

An Activity still makes sense as the single entry point per app, or per feature module, that hosts everything and is what the OS actually manages as a task, back stack, and launch target. But for most day to day screen building, the answer today leans further than it used to. A lot of what Fragments used to solve is now handled by Compose destinations inside a single Activity, so the practical question is often Fragment versus Composable rather than Fragment versus Activity.

What is the difference between adding and replacing a fragment in the back stack?

Tier: CommonDifficulty: Medium

add() stacks a new Fragment on top of whatever is already in the container, so both are alive at once, the old one's view still exists underneath, just no longer visible if the new one covers it. replace() tears the existing Fragment's view down entirely with remove() first, then adds the new one in its place, so only one Fragment's view exists in that container at a time.

supportFragmentManager.commit {
    replace(R.id.container, DetailFragment())
    addToBackStack(null)
}

The practical difference shows up in lifecycle cost and memory. add() keeps every stacked Fragment's view alive, which is cheap to return to but adds up fast if you keep stacking. replace() runs the outgoing Fragment through onDestroyView() and rebuilds the next one from onCreateView() when the user navigates back, more lifecycle churn per transaction, but only one Fragment's view is ever inflated at a time.

The callbacks, case by case

This is what interviewers actually ask, so know the exact order. In every case A is already on screen and resumed, and B is committed with addToBackStack.

  • A added, then B added. A gets no callback at all. It stays resumed with its view alive underneath B, so a video in A keeps playing and its observers keep firing. B runs onAttach, onCreate, onCreateView, onViewCreated, onStart, onResume. Press Back and B runs onPause, onStop, onDestroyView, onDestroy, onDetach, and A again gets nothing, because it never left the resumed state. If you add but want A to stop doing work, cap it with setMaxLifecycle(fragmentA, Lifecycle.State.STARTED) in the same transaction.
  • A added, then B replaces A. A runs onPause, onStop, onDestroyView and stops there. No onDestroy, no onDetach, the instance is parked on the back stack with its view gone. B runs onAttach through onResume. With setReorderingAllowed(true) the manager may create B's view before it tears A's down so a shared element transition can run, but A always ends at onDestroyView. Press Back and B goes the whole way to onDetach, while the same A instance runs onCreateView, onViewCreated, onStart, onResume. No onCreate, because A was never destroyed, which is why view references must be dropped in onDestroyView and why viewLifecycleOwner exists.
  • A replaced by B without addToBackStack. A runs onPause, onStop, onDestroyView, onDestroy, onDetach. It is gone for good and Back leaves the Activity.
ABonAttach1onCreate2onCreateView3onStart4onResume5
A added, then B added
Nothing happens to A. Both fragments are resumed at once, which is the memory cost and the surprise of add.
ABABonPause1onStop2onDestroyView3onCreateView4onResume5onDestroyView6onDestroy7onCreateView8onStart9onResume10
A added, then B replaces A, then Back
A stops at onDestroyView and comes back with a new view on the same instance. B, which was not on the back stack when Back popped it, goes all the way to onDestroy.

replace() is the far more common choice for standard screen to screen navigation, since you rarely want two full screen Fragments both alive simultaneously. add() shows up for layering something like a dialog-style Fragment or an overlay on top of existing content without disturbing what's underneath.

Intents & Broadcasts

What is a BroadcastReceiver?

Tier: EssentialDifficulty: Easy

A BroadcastReceiver is an app component that listens for system wide or app wide events and reacts to them, without needing anything else in your app to be running. You register it for one or more intent actions, and when a matching broadcast fires, Android calls its onReceive() method.

Typical uses are reacting to things your app doesn't control, like the device finishing boot, connectivity changing, or the battery getting low. You can also broadcast your own custom events to loosely couple parts of your own app, though a SharedFlow or LiveData is usually the better tool for that today.

There are two ways to register one.

  • Statically, in AndroidManifest.xml. This used to let your app be woken up for a broadcast even while not running, but since Android 8, most implicit broadcasts registered this way are no longer delivered, for battery reasons.
  • Dynamically, with registerReceiver() in code, usually tied to an Activity or a LifecycleOwner. This only fires while that component is alive, and it's the recommended approach for most cases now.

onReceive() has a hard limit of a few seconds to finish. It runs on the main thread, so it can't block, and it can't start a long running background task directly. If you need to do real work in response to a broadcast, the receiver should hand off to WorkManager or a foreground service rather than doing it inline.

What is an Intent?

Tier: EssentialDifficulty: Easy

An Intent is a messaging object that asks another component, an Activity, a Service, or a broadcast receiver, to do something on your behalf. It's how Android components talk to each other without holding direct references to one another.

There are two flavors.

  • Explicit intent, you name the target component directly, usually with its class. Intent(this, DetailActivity::class.java). Use this for navigating within your own app, because you know exactly what you want to start.
  • Implicit intent, you don't name a component, you describe an action and let the system find something that can handle it. Intent(Intent.ACTION_SEND) with a text payload is how you hand off to whatever share targets are installed on the device.

An intent carries more than just a target. It can hold an action, a data URI, a category, and a bundle of extras for passing values along. For implicit intents to work, the receiving component has to declare an intent filter in the manifest saying which actions, categories, and data types it can handle, and Android matches your intent against those filters at runtime.

One thing worth knowing for the follow up question, implicit intents to Services are blocked from API 21 onward for security reasons, so starting a Service always has to be explicit.

Describe how broadcasts and intents work to pass messages around your app.

Tier: CommonDifficulty: Easy

Broadcasts are a publish and subscribe messaging system built on top of Intents, and together they let components that have no reference to each other still communicate.

The flow has three parts.

  • A sender packages information into an Intent, an action string plus optional extras, and calls sendBroadcast().
  • Android looks at every BroadcastReceiver currently registered for that action, both the ones declared statically in the manifest and the ones registered dynamically in code.
  • Each matching receiver gets a call to onReceive() with that Intent, independently, without ever holding a direct reference to whoever sent it.

This is the same pattern used for one off navigation with startActivity(), just fanned out to potentially many listeners instead of one target, and without a response coming back.

The main reason this comes up in interviews is knowing where the pattern is still appropriate today. System events like connectivity changes or the device booting still arrive as broadcasts, because the OS is the sender and nothing else can play that role. But for messaging inside your own app between your own components, a SharedFlow, a ViewModel shared between fragments, or an event bus built on Kotlin Flow is the modern replacement. It avoids the overhead of the intent system, the process wide visibility risk, and the deprecated LocalBroadcastManager that used to be the standard answer here.

What are the different types of Broadcasts?

Tier: CommonDifficulty: Easy

Android broadcasts split along two independent axes, how they're delivered and who can receive them, and it's worth knowing both.

By delivery order.

  • Normal broadcasts, sent with sendBroadcast(). All receivers get them asynchronously, in no guaranteed order, and none of them can stop the broadcast from reaching the others.
  • Ordered broadcasts, sent with sendOrderedBroadcast(). Receivers run one at a time in priority order, and each one can modify the result or call abortBroadcast() to stop it from propagating further.

By who can see them.

  • System broadcasts, things like ACTION_BOOT_COMPLETED or ACTION_BATTERY_LOW, fired by the OS itself and visible to any app that registers for them, subject to the API 26 restrictions on manifest declared receivers.
  • Custom broadcasts, your own app defines the action string and sends it, typically to talk to itself internally.
  • Local broadcasts, sent through LocalBroadcastManager in the past, scoped to your own process only, faster and safer since they can't leak to or be spoofed by other apps. This class is now deprecated, and a SharedFlow or LiveData event bus inside your own app does the same job without the broadcast machinery at all.

Sticky broadcasts, which stayed around after sending so a receiver registered later could still read them, are also deprecated and no longer usable from API 21 for anything other than a small set of system actions.

One more thing that's gotten stricter over time. Since API 33, registering a receiver in code with registerReceiver() requires you to explicitly pass RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED, so you have to state on purpose whether other apps are allowed to send that receiver broadcasts.

What is an explicit Intent?

Tier: CommonDifficulty: Easy

An explicit Intent names the exact component you want to start, usually by class, instead of describing an action and letting the system pick a target.

val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("itemId", id)
startActivity(intent)

This is what you use for navigation inside your own app, because you already know exactly which Activity or Service should handle the request. There's no resolution step, no chooser, and no ambiguity about what runs.

Explicit intents are also the only option in a couple of places. Starting a Service has to be explicit, implicit intents to services have been blocked since API 21 for security reasons. And targeting a specific component in another app you don't control, say a debug build's test harness, works the same way, by fully qualified class name, as long as that component is exported.

What is an implicit Intent?

Tier: CommonDifficulty: Easy

An implicit Intent describes an action to perform without naming which component should handle it, and lets Android find something on the device that can. Instead of pointing at a class, you describe what you want done.

val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "Check this out")
}
startActivity(Intent.createChooser(intent, "Share via"))

Android matches this against the intent filters declared in other apps' manifests, and any app that registered a filter for ACTION_SEND with a text/plain type becomes a candidate. If more than one app matches, the user gets a chooser.

Implicit intents are how apps hand work off to each other without knowing about one another ahead of time, sharing text, opening a URL, picking a photo, dialing a number. The trade off is you can't be sure anything is actually installed to handle it. It's good practice to check with resolveActivity() or handle the case where no app responds, so the app doesn't crash trying to start an intent nothing can fulfill.

Explain deep links: understanding and architecture.

Tier: CommonDifficulty: Medium

A deep link is a URL that opens your app directly to a specific screen instead of just launching it to its default entry point, and Android matches it to your app through intent filters declared in the manifest.

There are two kinds, and the difference matters.

  • Custom scheme links, like myapp://product/42. Simple to set up, but the OS can't verify you actually own that scheme, so any app can register the same one and a chooser dialog can pop up asking the user which app to use.
  • App Links, https:// URLs on a domain you actually control, like https://example.com/product/42. These can open your app directly with no chooser, but only after Android verifies you own the domain.

Verification is what makes App Links trustworthy. You host a assetlinks.json file at https://example.com/.well-known/assetlinks.json declaring which app package and signing certificate are allowed to handle that domain's links, and the intent filter sets android:autoVerify="true". Android checks that file against your app at install time, and only then treats the link as fully yours, otherwise it falls back to a chooser like a custom scheme would.

<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="example.com" android:pathPrefix="/product" />
</intent-filter>

On the architecture side, the Activity that receives the link reads the data URI, either directly from intent.data in onCreate() and onNewIntent(), or by wiring the intent filter into a Jetpack Navigation graph, which can map a URI pattern straight to a destination and its arguments without you parsing the URI by hand. The main design decision is where that resolution happens, letting one entry point Activity parse the link and route internally keeps the logic centralized, rather than spreading URI parsing across every screen that might be a deep link target.

What is a PendingIntent?

Tier: CommonDifficulty: Medium

A PendingIntent is a token that wraps an Intent and hands another party, usually a different app or the system itself, the permission to fire it later on your behalf, using your app's identity and permissions rather than its own.

You hand these out to things that aren't your own code. A notification needs to launch an Activity when tapped, but the notification itself is drawn and handled by the system UI process, not yours. AlarmManager needs to start something at a future time, long after your app's process might have died. In both cases the system holds onto the PendingIntent and executes it at the right moment, as your app, without ever needing your app to be running in the meantime.

val intent = Intent(this, DetailActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
    this, 0, intent,
    PendingIntent.FLAG_IMMUTABLE
)

The FLAG_IMMUTABLE and FLAG_MUTABLE flags became mandatory from API 31 onward. Getting this wrong is a common crash source, most PendingIntent uses, like notification taps, should be immutable so nothing else can tamper with the wrapped Intent before it fires.

Services & Background Work

Explain WorkManager and its use cases.

Tier: EssentialDifficulty: Medium

WorkManager is Jetpack's API for scheduling background work that has to run even if the app is closed or the device reboots. It's the tool for deferrable, guaranteed work, not for anything that needs to happen right now.

You describe a unit of work as a WorkRequest wrapping a Worker, hand it constraints like "only on WiFi" or "only while charging," and WorkManager takes it from there. Internally it picks the best available execution mechanism for the OS version and device state, that might be JobScheduler, or AlarmManager plus a BroadcastReceiver on older devices, but you never touch that layer yourself. It persists the request to an internal database, so even if the app process dies or the phone restarts, the work is still queued and runs once the constraints are met.

val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(
        Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
    )
    .build()

WorkManager.getInstance(context).enqueue(uploadRequest)

Typical use cases are things like uploading logs, syncing a local database with a server, compressing images after a shoot, or any periodic cleanup job, anything where "runs eventually, exactly once, even across app restarts" is the actual requirement. It's not the right tool for work that needs to run immediately and visibly, like playing music or an ongoing call, that's still a foreground Service. WorkManager is for the case where the user doesn't need to watch it happen.

On which thread does a Service run in Android?

Tier: CommonDifficulty: Easy

The main thread, by default. A Service gets no background thread of its own just for existing. It's a component with a lifecycle the system manages, not a thread, and its callbacks like onStartCommand() run on the same main thread as your Activities and their UI.

This is the single most common mistake people make with Services. Doing network calls or heavy work directly inside onStartCommand() blocks the main thread exactly like doing it inside onClick() would, and if it runs long enough, the system shows an ANR.

The fix is to move the actual work off the main thread yourself, the same way you would anywhere else.

class SyncService : Service() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        scope.launch {
            doSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }
}

The old IntentService used to do this for you automatically, by running each request on a single background worker thread. It's deprecated now, and WorkManager or a coroutine backed Service like the one above is the current way to get the same guarantee.

What is a Service?

Tier: CommonDifficulty: Easy

A Service is an app component that runs in the background to perform work without a UI, and it keeps running even after the user leaves the Activity that started it. It's for things like playing music, syncing data, or handling a long lived connection.

There are two ways to use one.

  • Started, launched with startService() or startForegroundService(). It runs until it calls stopSelf() or something else stops it, independent of any component that started it.
  • Bound, launched with bindService(). It runs as long as at least one client is bound to it, and it's for when a component wants to actively call methods on the Service through an interface, like a client server relationship inside your own app.

The lifecycle, started versus bound

The two paths share onCreate() at the top and onDestroy() at the bottom, and differ in the middle.

  • Started. startService() or startForegroundService() runs onCreate() the first time only, then onStartCommand() once per call. A second startService() on a running service skips onCreate() and goes straight to onStartCommand() again. It runs until stopSelf() or stopService(), then onDestroy(). A foreground service must call startForeground() with its notification within a few seconds of onStartCommand() or the system kills it.
  • Bound. bindService() runs onCreate() if the service is not already alive, then onBind() returns the IBinder the client talks through. Every further client reuses that binder without another onBind(). When the last client calls unbindService(), onUnbind() runs, then onDestroy(). Bound only, the service lives exactly as long as someone is bound.
  • Both at once. A music player is started so it outlives the screen and bound so the screen can call play and pause. Then neither stopSelf() nor the last unbind alone ends it. onDestroy() runs only when it has been stopped and every client has unbound.
  • Restart after a kill. The value returned from onStartCommand() decides it. START_STICKY recreates the service with a null intent, START_REDELIVER_INTENT recreates it with the last intent, START_NOT_STICKY leaves it dead.
Service lifecycle, started and boundA service is created and onCreate runs once. If it was started with startService, onStartCommand runs and the service keeps running until stopSelf or stopService. If it was bound with bindService, onBind runs and clients stay bound until the last client unbinds and onUnbind runs. A service can be both started and bound at the same time, and onDestroy runs only when it has been stopped and every client has unbound, after which the service is destroyed.
the Service lifecycle
Two tracks, one onCreate and one onDestroy. A service that is both started and bound is not gone until both tracks have ended.

The callback by callback walk, with the rules for each return value, is in the Service lifecycle.

A Service can be both at once. The common trap is assuming a Service runs on a background thread automatically, it doesn't. A plain Service runs on the main thread by default, so any real work inside it still has to be moved off to a coroutine or another thread yourself, exactly like in an Activity.

In practice, a plain Service is rarely the right starting point today. For deferrable background work you reach for WorkManager, and for work the user needs to see happening right now, like a running timer or an active download, you reach for a foreground service.

Explain background task options in Android: AsyncTask, Service, IntentService, etc.

Tier: CommonDifficulty: MediumAsked at: booking-com

Android has gone through a few generations of background task APIs, and most of the old ones are now deprecated in favor of coroutines and WorkManager. It's worth knowing the history because legacy codebases still use them.

  • AsyncTask. The original way to do background work with a UI callback, doInBackground() off the main thread and onPostExecute() back on it. Deprecated since API 30, it leaked Activities easily and its threading model didn't handle configuration changes well. Coroutines replace it directly.
  • Thread and Handler. Raw threading with a Handler to post results back to the main thread. Still works, but you own all the lifecycle management and cancellation yourself, which coroutines now do for you.
  • Service. Runs in the background with no UI, but on the main thread by default, so it still needs its own thread or coroutine for actual work.
  • IntentService. A Service subclass that queued work onto a single background thread automatically. Deprecated since API 30 for the same reason JobIntentService and AsyncTask are, it predates the structured, cancellable model coroutines and WorkManager provide.
  • JobScheduler. System level constraint based scheduling, added in API 21. Still functions, but is now mostly used indirectly, as the engine underneath WorkManager on newer API levels.

The throughline across all of these deprecations is the same. Android moved from ad hoc threading APIs each with their own lifecycle quirks toward two clear defaults, coroutines for work tied to a screen, and WorkManager for anything that needs to survive the app closing. That's what you'd reach for in new code today, not any of the above.

Explain the Android Service lifecycle.

Tier: CommonDifficulty: Medium

A Service's lifecycle depends on whether it's started, bound, or both, and the callbacks differ for each path.

Service lifecycle, started and boundA service is created and onCreate runs once. If it was started with startService, onStartCommand runs and the service keeps running until stopSelf or stopService. If it was bound with bindService, onBind runs and clients stay bound until the last client unbinds and onUnbind runs. A service can be both started and bound at the same time, and onDestroy runs only when it has been stopped and every client has unbound, after which the service is destroyed.
the Service lifecycle
The two Service lifecycles. startService() takes the left track and onStartCommand() runs again for every further call. bindService() takes the right track. A service can be on both tracks at once, and onDestroy() only runs once it has been stopped and the last client has unbound.

For a started Service.

  • onCreate() runs once, when the Service is first created.
  • onStartCommand() runs every time something calls startService(), even if the Service is already running. This is also where you return one of START_STICKY, START_NOT_STICKY, or START_REDELIVER_INTENT, which tells the system what to do if it kills the Service to reclaim memory.
  • onDestroy() runs when the Service stops, either because it called stopSelf() or because something called stopService().

For a bound Service.

  • onCreate() runs once, the same as above.
  • onBind() runs when the first client binds, and returns the IBinder the client uses to call into the Service.
  • onUnbind() runs when the last client unbinds.
  • onDestroy() runs once there are no bound clients left and nothing else is keeping it alive.

A Service that's both started and bound stays alive until it's explicitly stopped and has no bound clients, whichever condition is met last. The detail worth remembering for the follow up question is that none of these callbacks run on a background thread by default. onStartCommand() runs on the main thread just like everything else, so real work still has to be dispatched off it yourself.

Read more Services overview (opens in a new tab)

How does WorkManager guarantee task execution?

Tier: CommonDifficulty: Medium

WorkManager guarantees a task runs by persisting it to an on device database as soon as you enqueue it, not by holding it in memory. If the process dies, the phone reboots, or the task fails midway, the record survives, and WorkManager reschedules it once its constraints are met again.

A few mechanisms work together to make that hold up.

  • Persistence. Every WorkRequest is written to a Room backed database on enqueue, so it doesn't depend on your app process staying alive.
  • Constraints, like requiring network or charging, are rechecked continuously. Work only runs once the real conditions match what you asked for, and pauses again if they stop matching.
  • Retry with backoff. A worker that returns Result.retry() gets rescheduled automatically, using linear or exponential backoff depending on what you configured.
  • Boot handling. WorkManager reschedules any pending work itself after a reboot, without you writing a BOOT_COMPLETED receiver.

Under the hood it picks the best available executor for the OS version, JobScheduler on API 23 and up, or AlarmManager plus a BroadcastReceiver on older versions, so you get one API and one guarantee regardless of which one is actually doing the scheduling. That's the real value, you write the constraints once and don't have to reason about API level branching or process death yourself.

What can you use for background processing in Android?

Tier: CommonDifficulty: Medium

Which tool you reach for depends on how urgent the work is and whether it needs to survive your app closing, and Android gives you a different answer for each case.

  • Kotlin coroutines with Dispatchers.IO or Dispatchers.Default, scoped to viewModelScope or lifecycleScope. Use this for work tied to a screen being open, a network call or a database query the UI is waiting on.
  • WorkManager, for deferrable work that has to survive the app or the process dying, like uploading a log file whenever the network comes back. It persists the request and reschedules it across restarts.
  • A foreground service, for work the user is actively aware of right now and that has to keep running even if they leave the app, like music playback or an in progress download with a visible notification.
  • AlarmManager, for something that has to fire at a specific wall clock time, like a reminder, rather than merely "eventually."

The trap interviewers are checking for is reaching for a Thread or the old AsyncTask directly. Both exist, but neither survives process death, neither has any relationship with the Activity or ViewModel lifecycle, and both are easy to leak if you don't manage cancellation by hand. For almost everything above, a coroutine scoped to a lifecycle aware CoroutineScope replaces manual thread management entirely, and it's the one that should come up first in an answer.

What is a Foreground Service?

Tier: CommonDifficulty: Medium

A foreground service is a Service that tells the system it's doing something the user actively cares about, and in exchange for showing a persistent notification, the system gives it much stronger guarantees against being killed than a regular background Service gets.

class DownloadService : Service() {
    override fun onCreate() {
        super.onCreate()
        val notification = buildNotification()
        startForeground(NOTIFICATION_ID, notification)
    }
}

You use one for work that has to keep running while visibly happening, an active download, music playback, an ongoing navigation route, a fitness tracker recording a run. The notification is not optional, it's the whole trade. It's what tells the user something is running and lets them get back to it or stop it.

Since Android 14, you also have to declare a foregroundServiceType in the manifest, like dataSync or location, and the system enforces that the work you're doing actually matches the type you declared. Starting a foreground service also needs the POST_NOTIFICATIONS runtime permission on API 33 and up, plus the matching FOREGROUND_SERVICE_* permission for whichever type you're using, so this is one of the areas that has gotten noticeably stricter release over release rather than staying static.

IPC & Content Providers

What is a ContentProvider and what is it typically used for?

Tier: CommonDifficulty: Medium

A ContentProvider is an app component that manages a shared set of structured data and exposes it to other apps through a standard interface, without either side needing to know how the other one is implemented internally.

It wraps its data behind query(), insert(), update(), and delete() methods, addressed by content URIs like content://com.example.app/notes, rather than exposing a raw database or file. The system's own contacts app, the media store, and the calendar all work this way, which is how a gallery app can read photos and a contacts picker can read names without either one linking against the app that owns that data.

You reach for one when you actually need to share data across app boundaries, or when you want to plug into a framework feature that expects one, like a search suggestions provider or a sync adapter. If the data only needs to move around inside your own app, a ContentProvider is the wrong tool, that's what Room and a repository layer are for. Building one purely for internal use adds a layer of URI matching and IPC overhead for no benefit, since nothing outside your own process is ever going to query it.

Common providers you already use

You will use these before you ever write your own.

  • ContactsContract. Authority com.android.contacts, you read names, phone numbers, and emails through it.
  • MediaStore. Authority media, you read and write photos, video, and audio. On Android 10 and later, scoped storage limits you to your own app's media unless you hold the right permission, and each app mostly sees what it created.
  • CalendarContract. Authority com.android.calendar, you read and write events, calendars, and attendees.
  • Telephony. Authority sms, apps that hold the SMS permission read and write text messages through it.
  • Settings. Authority settings, you read system, secure, and global settings values.
  • UserDictionary. Authority user_dictionary, you read the words a user has added to their personal dictionary.
// Reading images through MediaStore.
// Needs READ_MEDIA_IMAGES on Android 13 (API 33) and later, READ_EXTERNAL_STORAGE below that.
val projection = arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

contentResolver.query(collection, projection, null, null, sortOrder)?.use { cursor ->
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
    while (cursor.moveToNext()) {
        val imageUri = ContentUris.withAppendedId(collection, cursor.getLong(idColumn))
    }
}

How to build one

You subclass ContentProvider, match URIs to operations, and notify listeners when data changes.

class NotesProvider : ContentProvider() {
    companion object {
        const val AUTHORITY = "com.example.app.notes"
        private const val NOTES = 1
        private const val NOTE_ID = 2
        private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
            addURI(AUTHORITY, "notes", NOTES)
            addURI(AUTHORITY, "notes/#", NOTE_ID)
        }
    }

    override fun onCreate(): Boolean = true

    override fun query(
        uri: Uri, projection: Array<String>?, selection: String?,
        selectionArgs: Array<String>?, sortOrder: String?
    ): Cursor = when (matcher.match(uri)) {
        NOTES -> noteDao.queryAllCursor()
        NOTE_ID -> noteDao.queryByIdCursor(ContentUris.parseId(uri))
        else -> throw IllegalArgumentException("Unknown URI: $uri")
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri {
        val id = noteDao.insert(values)
        context?.contentResolver?.notifyChange(uri, null)
        return ContentUris.withAppendedId(uri, id)
    }

    override fun getType(uri: Uri): String = when (matcher.match(uri)) {
        NOTES -> "vnd.android.cursor.dir/vnd.$AUTHORITY.notes"
        NOTE_ID -> "vnd.android.cursor.item/vnd.$AUTHORITY.notes"
        else -> throw IllegalArgumentException("Unknown URI: $uri")
    }

    override fun update(uri: Uri, values: ContentValues?, selection: String?, args: Array<String>?) = 0
    override fun delete(uri: Uri, selection: String?, args: Array<String>?) = 0
}
<provider
    android:name=".NotesProvider"
    android:authorities="com.example.app.notes"
    android:exported="false"
    android:permission="com.example.app.permission.NOTES" />

What interviewers probe

  • Why the URI matcher. UriMatcher turns a content URI into a stable integer so query(), insert(), update(), and delete() can route in one when instead of parsing strings everywhere.
  • Why notifyChange() matters. It tells registered observers, including CursorLoader and ContentObserver, that the data behind a URI changed, so they refresh instead of showing stale rows.
  • Exported and permissions. Set android:exported="false" unless you actually want other apps to reach the provider, and add a custom android:permission when you do, so a random app can't read or write your data.
  • Thread safety. A provider's methods run on the caller's binder thread, not a thread you control, so query() and friends have to be safe to call concurrently.
  • Startup timing. A ContentProvider's onCreate() runs before your Application.onCreate(), which is why some libraries used a provider purely to auto initialize themselves. The App Startup library replaced that trick because chaining several providers just for init slowed down app launch.
  • FileProvider. Most apps never ship a general purpose provider, they ship a FileProvider, the standard subclass for handing a single file to another app through a content URI instead of a raw file path.

Read more Content provider basics (opens in a new tab)Creating a content provider (opens in a new tab)

Platform Internals & Runtime

What is the Android Runtime?

Tier: CommonDifficulty: Easy

The Android Runtime, ART, is the runtime environment that executes an app's compiled code on the device. It's the thing that actually takes the DEX bytecode your Kotlin or Java compiles down to and turns it into machine instructions the device's CPU can run.

It's been the default runtime since Android 5.0, replacing the older Dalvik runtime. Alongside executing your code, ART also owns garbage collection, and it manages compiling that DEX bytecode using a mix of strategies depending on the situation.

  • Ahead of time compilation, at install time or during idle maintenance windows, for code the profiler has learned gets used often.
  • Just in time compilation, for code encountered during a run that hasn't been AOT compiled yet.
  • Interpretation, for the rest, executed directly without a separate compilation step first.

That mix is what lets ART balance install time against runtime performance, rather than paying either cost entirely up front. Knowing that ART exists as the successor to Dalvik, and that it's the reason Android app startup and battery behavior improved significantly around Android 5.0, is usually the depth an interviewer is actually looking for here.

Explain Dalvik, ART, JIT and AOT in Android.

Tier: CommonDifficulty: Medium

Dalvik and ART are the two runtimes Android has used to execute app code, and JIT and AOT are the two strategies either one can use to turn bytecode into machine code the CPU actually runs.

  • Dalvik was Android's original runtime, used through Android 4.4. It compiled DEX bytecode with a JIT compiler, translating hot methods to machine code as the app ran, which meant every app paid a repeated interpretation and compilation cost every time it launched.
  • ART, Android RunTime, replaced Dalvik starting with Android 5.0. It originally compiled apps fully to native machine code at install time, ahead of actually running them.
  • JIT, just in time compilation, compiles code while the app is running, right before it's needed. It adapts to real usage but adds runtime overhead, and that cost repeats on every run.
  • AOT, ahead of time compilation, compiles code in advance, before the app ever runs. It removes runtime compilation overhead entirely, at the cost of a longer install and larger storage footprint for the compiled output.

Modern ART actually blends both. Since Android 7, it profiles the app as it runs like a JIT would, then uses those profiles to AOT compile just the hot paths in the background when the device is idle and charging, rather than compiling the whole app up front. Android 9 added Cloud Profiles on top, aggregated anonymous usage data from the Play Store, so ART can pre-optimize an app's common code paths at install time using what other users' devices already learned, before you've even opened it once. That hybrid is why app startup on current Android is both fast on first install and gets faster the more the app is actually used, it isn't purely one strategy or the other anymore.

How does Zygote make Android apps start faster?

Tier: CommonDifficulty: Medium

Zygote is a process the system starts once at boot, preloads the core Android framework classes and resources into, and then keeps running as a template. Every new app process is created by forking Zygote rather than starting from a completely blank process, so it inherits all of that already loaded and initialized state for free.

Without this, launching an app would mean starting a fresh process, loading the entire framework's classes, and initializing the runtime from scratch, every single time. Forking instead uses the OS's copy on write behavior. The new app process shares Zygote's memory pages until it actually writes to one, at which point only that page gets copied. That makes the fork itself extremely cheap compared to a true cold process start, and it's a big part of why launching a new app feels near instant rather than taking as long as booting a small standalone program would.

This is also why a device's first app launch after a full reboot tends to feel slower than normal, Zygote itself has to spin up and preload everything before it can start forking for anyone. Every subsequent app launch benefits from work Zygote already did once.

What are the differences between Dalvik and ART?

Tier: CommonDifficulty: Medium

Dalvik used just in time compilation, translating bytecode to machine code while the app ran. ART, which replaced it starting with Android 5.0, originally compiled the whole app ahead of time at install, and now does a hybrid of both.

  • Compilation strategy. Dalvik JIT compiled hot methods as the app executed, paying that cost on every run. Early ART AOT compiled everything at install time, once, so there was no runtime compilation tax at all.
  • Performance and battery. Dalvik's repeated runtime translation cost CPU and battery on every launch. ART's precompiled machine code ran faster and drained less battery, at the cost of a slower install.
  • Install time and storage. Dalvik installed quickly since it deferred compilation. ART's full AOT compile made installs noticeably slower and used more disk space for the compiled output.
  • Debugging and profiling. ART added better crash diagnostics and garbage collection, including a move to a more efficient concurrent, compacting collector that reduced the jank Dalvik's GC pauses used to cause.

The practical difference stopped being "pick one" a while ago. Modern ART doesn't purely AOT compile everything either, it profiles the app at runtime and AOT compiles only the hot paths in the background, closer to a hybrid of what Dalvik and early ART each did separately. Dalvik itself hasn't shipped on new Android versions since 4.4, so this mostly comes up as a history question rather than something you'd choose between today.

What is the 16 KB page size requirement for Android apps?

Tier: CommonDifficulty: Medium

Newer Android devices manage memory in 16 KB pages instead of the traditional 4 KB pages, and any app that ships native code has to be built to support that or it can crash on those devices.

The OS allocates and maps memory in fixed size chunks called pages, and for most of Android's history that size was 4 KB, matching what most ARM hardware used. Newer chips run measurably better with larger 16 KB pages, fewer page faults, less overhead managing the page tables, faster app launch and reduced power use, so Google moved the platform to support 16 KB as the default page size on qualifying devices starting with Android 15.

This mostly matters for native code. An app's .so libraries, and any dependency that ships prebuilt native binaries, have to be compiled with their ELF segments aligned to 16 KB boundaries. An app built only against the old 4 KB alignment can fail to load its native libraries or crash outright on a 16 KB page size device. An app with no native code at all, pure Kotlin or Java, is unaffected, since it never touches page aligned native binaries directly.

Google Play has been requiring 16 KB compatibility for new app submissions and updates targeting recent API levels, so this is an active compliance concern, not a theoretical one. The fix is rebuilding with a current Android Gradle Plugin and NDK version, which handle the alignment automatically, and checking any prebuilt third party native libraries in your dependency tree were built the same way.

Permissions & Security

How do you avoid checking API keys into version control?

Tier: CommonDifficulty: Easy

You keep the key out of any file that gets committed, and inject it into the build instead of hardcoding it in source.

  • local.properties, a file Gradle already ignores by default, is the standard place for a key you only need locally. Read it in build.gradle.kts and expose it as a BuildConfig field.
  • Environment variables in CI, for keys the build server needs. Your CI provider's secret store injects them at build time, so the key exists only in the CI environment, never in a file in the repo.
  • BuildConfig fields, generated at build time from either of the above, so the key ends up compiled into the binary rather than typed directly into a .kt file that gets committed.
// build.gradle.kts
val apiKey = project.findProperty("MAPS_API_KEY") as String? ?: ""
buildConfigField("String", "MAPS_API_KEY", "\"$apiKey\"")

Make sure local.properties and any .env file are actually listed in .gitignore, that's the step people skip. And if a key does slip into a commit, changing it afterward isn't optional, git history keeps it forever even if you delete it in a later commit, so the only real fix is rotating the key on the provider's side.

Worth saying clearly, none of this makes the key secure once it ships. It only keeps it out of your source history and off the screens of anyone browsing the repo, the key is still present inside the compiled APK and extractable by anyone who decompiles it.

How do you encrypt data in Android?

Tier: CommonDifficulty: Medium

Which approach you use depends on what you're encrypting, and Android gives you a purpose built tool for each common case rather than one general answer.

  • Small key value data, like a token or a flag. EncryptedSharedPreferences from Jetpack Security wraps regular SharedPreferences and encrypts both keys and values transparently, so reads and writes look identical to plain SharedPreferences code.
  • Files, like a downloaded document or a cached credential blob. EncryptedFile, also from Jetpack Security, gives you a FileInputStream and FileOutputStream backed by encryption, again without you managing the cipher yourself.
  • Anything backing those two, keys are generated and stored in the Android Keystore, a hardware backed secure store on supported devices. The actual key material never enters your app's process as plain bytes, you only ever get a handle to use it through the Cipher API.
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val prefs = EncryptedSharedPreferences.create(
    context, "secure_prefs", masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

The rule worth stating out loud is that you should never hand roll your own cipher usage for this, generating your own IVs, picking your own mode, managing key storage by hand. Jetpack Security exists specifically because those details are easy to get subtly wrong, and getting them wrong quietly weakens the encryption without throwing any error.

How do you secure the API keys used in an Android app?

Tier: CommonDifficulty: Medium

You can't make a key inside a shipped app fully secure, anything on the client can eventually be extracted, so the real goal is raising the cost of extraction and limiting the blast radius if someone gets it anyway.

  • Move the key server side. The strongest option. Your app calls your own backend, and the backend holds the real key and calls the third party service. The key never ships in the APK at all.
  • Restrict the key at the provider. Most services, Google Maps and Firebase included, let you lock a key to your app's package name and signing certificate fingerprint. A stolen key then only works from a rebuilt, resigned copy of your app, not from a random script.
  • Keep it out of BuildConfig string constants when it matters. A plain string in BuildConfig is trivial to pull out of a decompiled APK. Storing it in native code via the NDK, or fetching it from Play Integrity backed remote config at runtime, raises the bar, though neither is unbreakable.
  • Never trust client side checks for anything valuable. If the key gates something that actually matters, payments, privileged data, treat the app as a hostile environment and enforce the real check on your server, not in the client.

The honest framing for an interview is that "secure the key" really means "assume it leaks eventually, and make sure that's not catastrophic when it does." Restricting by package and certificate plus server side proxying for anything sensitive covers most real cases, a determined attacker with a rooted device can still get past client side obfuscation alone.

WebView

How do you interact with or make connections to JavaScript from a WebView?

Tier: CommonDifficulty: Medium

There are two directions to this, Kotlin calling into the page's JavaScript, and the page's JavaScript calling back into Kotlin, and they use different mechanisms.

Kotlin calling JavaScript, with evaluateJavascript().

webView.evaluateJavascript("document.title") { result ->
    Log.d("WebView", "Title is $result")
}

This runs a script in the page's context and hands the result back through a callback, without navigating away from the current page the way the older loadUrl("javascript:...") trick did.

JavaScript calling Kotlin, with addJavascriptInterface().

class WebAppInterface(private val context: Context) {
    @JavascriptInterface
    fun showToast(message: String) {
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
    }
}

webView.addJavascriptInterface(WebAppInterface(context), "Android")

The page then calls Android.showToast("hello") directly from its own JavaScript. Only methods annotated @JavascriptInterface are actually reachable this way, and JavaScript itself has to be enabled on the WebView with settings.javaScriptEnabled = true for any of this to work at all.

The security angle matters here too. addJavascriptInterface() exposes whatever object you pass to any page the WebView loads, so it should only ever be bound when you fully trust the page's origin, and the bridge object should expose the narrowest possible surface, not a general purpose object with unrelated capabilities on it.

What are the security problems when dealing with WebView?

Tier: CommonDifficulty: Medium

A WebView renders arbitrary web content inside your app's own process and permission sandbox, so anything wrong with the page it loads becomes a problem for your app too, not just for a browser tab that's isolated from everything else on the device.

  • addJavascriptInterface() exposing too much. Any Kotlin object you bridge into JavaScript this way is callable from whatever page happens to be loaded. Before API 17, this could be used to run arbitrary code through Java reflection, and even today, exposing a broad interface to an untrusted page hands it real capabilities inside your app.
  • Loading untrusted or unvalidated URLs. A WebView that follows any link it's given can be redirected somewhere malicious, phishing pages designed to look like your app, or pages that try to exploit the WebView engine itself.
  • Cleartext traffic and mixed content. A page loaded over HTTP, or an HTTPS page that pulls in HTTP subresources, can be intercepted or tampered with the same way any other cleartext request can.
  • File access and file:// URLs. Broad WebView file access settings can let a malicious page read files out of your app's storage if not locked down.
  • Stale WebView versions. The rendering engine itself, updated separately through the WebView system app on most devices, can carry unpatched vulnerabilities if a device hasn't updated it.

The general defense is treating a WebView like it's rendering hostile content until proven otherwise. Restrict which domains it can navigate to, keep JavaScript bridges minimal and only exposed to pages you fully trust, disable file access you don't need, and enforce HTTPS. Increasingly, the simpler answer for a lot of use cases is avoiding a full WebView entirely and using Chrome Custom Tabs instead, which renders in the browser's own sandboxed process rather than yours.

Notifications

How do you make sure push notifications are delivered, and how do you optimise delivery?

Tier: EssentialDifficulty: Medium

Delivery is never guaranteed, no push system on any platform promises it. The job is raising the odds at every hop, and measuring the gap between what you sent and what actually landed. For the flow itself and the message shapes, see how the FCM flow works and the push notification system.

Start with the message you send.

  • Use high priority sparingly. Reserve it for time sensitive user facing things, a chat message or an incoming call.
  • Avoid getting demoted. FCM lowers your priority when high priority messages keep arriving without producing a visible notification. The delivery report exposes this as priorityLowered.
  • Know the two message types. A notification message is drawn by the system while the app is backgrounded, so your code never runs. A data message goes to onMessageReceived() either way.
  • Set a collapse key. A burst of updates then folds into the latest one instead of stacking up.
  • Set a short TTL. A stale message expires quietly rather than landing a day late.
  • Carry your own message id. The client can dedup on it if a message is redelivered.

Then the device, which is where most real losses happen.

  • Doze and standby buckets. Normal priority messages wait for the next maintenance window. Only high priority wakes the app immediately.
  • A force stopped app gets nothing. Messages resume only once the user opens the app again, and some OEM builds get close to that on a swipe from recents.
  • OEM battery managers. Xiaomi, Oppo, Vivo and Samsung kill background work harder than stock Android, so point users at the vendor's own autostart setting, once and in context.
  • Do not beg for a battery exemption. Play policy blocks requesting exemption from Doze unless the core function needs it, so high priority messages are the supported route.
  • No Play services means no FCM. Newer Huawei devices ship HMS instead, so those need Huawei Push Kit and their own token. A small abstraction picks the provider at runtime and the server sends to whichever token that device registered.

Then the OS rules, which change by version.

  • Android 12. No notification trampolines. Build a PendingIntent that opens the target activity directly, not a service or receiver that starts one.
  • Android 13. Request POST_NOTIFICATIONS in context rather than on first launch, and check NotificationManagerCompat.areNotificationsEnabled() plus the channel importance before assuming anything will show.
  • Android 14. USE_FULL_SCREEN_INTENT is limited to calling and alarm apps. Everything else should check NotificationManager.canUseFullScreenIntent() and fall back to an ordinary notification.

Then your own code.

  • Do nothing heavy in onMessageReceived(). The window is short, treat ten seconds as the budget. Post the notification, then hand any network or database work to WorkManager.
  • Implement onNewToken(). Upload the token every time it fires. A stale token is the biggest silent loss in most push setups.
  • Create channels at startup. A message must never arrive for a channel that does not exist yet.

Then the server.

  • One token per device, not per user. Somebody with three phones has three tokens.
  • Prune on UNREGISTERED. Delete that token immediately, and retry only genuinely transient errors with backoff.
  • Send at a sensible local hour. Blasting everyone at once hurts opens and gives you nothing back.

Then prove it worked.

  • Log a received event. Send the message id back from the client and compare that count against what you sent.
  • Cross check Google's own numbers. The Firebase delivery report, and the BigQuery export if it is wired up, show what Google thinks it delivered.
  • Track opens as well as receives. Delivered but never opened is a content or timing problem, not a pipe problem.

And plan for the ones that never arrive.

  • Sync on app open. A missed push should never leave stale data sitting on screen.
  • Run a periodic pull. A WorkManager job catches anything critical that never came through.
  • Keep an in app inbox. A notification the user swiped away is still there to find.
class MyFcmService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        // Post first, it is the only thing that has to happen right now.
        postNotification(message.data["title"], message.data["body"])
        val work = OneTimeWorkRequestBuilder<SyncPayloadWorker>()
            .setInputData(workDataOf("messageId" to message.messageId))
            .build()
        WorkManager.getInstance(this).enqueue(work)
    }

    override fun onNewToken(token: String) {
        WorkManager.getInstance(this).enqueue(
            OneTimeWorkRequestBuilder<UploadTokenWorker>()
                .setInputData(workDataOf("token" to token))
                .build()
        )
    }
}

Open by saying delivery is not guaranteed, and the work is raising the odds and measuring the gap. Then go straight to the two losses that actually happen, a stale token nobody refreshed and an OEM killing the app in the background.

Read more Doze and App Standby (opens in a new tab)Notification permission (opens in a new tab)

Explain the Android push notification flow using FCM.

Tier: CommonDifficulty: Medium

The FCM flow has a registration half and a delivery half, and it's worth walking through both in order.

Registration, which happens once per install.

  • The app asks the Firebase SDK for a registration token, a string unique to this app install on this device.
  • The app sends that token to your own backend and stores it against the signed in user.
  • The token can change, if the app is reinstalled or its data is cleared, so the app also implements onNewToken() to catch rotations and re-send the new one.

Delivery, which happens per message.

  • Your backend calls the FCM API with the target token, or a topic, plus a payload.
  • FCM routes the message to the right device over the persistent connection Google Play services maintains.
  • If the payload is a notification message, the OS displays it directly. If it's a data message, or the app is in the foreground, it arrives in your app's FirebaseMessagingService.onMessageReceived() instead.
class MyFcmService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        message.data["itemId"]?.let { showNotification(it) }
    }

    override fun onNewToken(token: String) {
        sendTokenToServer(token)
    }
}

One detail that trips people up. onMessageReceived() only fires for notification messages when the app is in the foreground, when it's backgrounded or killed, the OS shows the system notification directly and your code doesn't run until the user taps it. Data messages always go through your service regardless of app state, which is why apps that need custom notification handling lean on data messages rather than the default notification payload.

Explain the Android push notification system.

Tier: CommonDifficulty: Medium

Push notifications rely on a persistent connection the OS keeps open, not one your app maintains, so a message can reach the device even while your app isn't running. On Android that channel is Firebase Cloud Messaging, sitting on top of a long lived connection between Google Play services and Google's servers.

The pieces involved.

  • Your app server, which decides when to send a notification and to whom.
  • FCM, Google's delivery service, which routes the message to the right device using a registration token.
  • Google Play services, which keeps one connection open per device for every app that uses FCM, so the OS isn't juggling a separate socket per app.
  • Your app's FirebaseMessagingService, which receives the message once it arrives on device.

FCM messages come in two shapes. A notification message has a title and body the OS can display on its own, even if your app is fully killed, no code of yours has to run. A data message is a plain key value payload with no default UI, your FirebaseMessagingService receives it and decides what to do, which is what you need if you want to update local data or build a custom notification instead of the default one.

The trade off worth knowing is that data messages don't wake a killed app the way notification messages do on all OEM configurations, some manufacturers' battery optimization stops background delivery to apps the user hasn't opened recently, which is a real world constraint you can only partially work around, not eliminate.

Resources & Configuration

How do you implement Dark Mode / Dark Theme in an application?

Tier: CommonDifficulty: Easy

You define two versions of your color values, one for light and one for dark, and let the system switch between them based on the device setting, rather than manually swapping themes in code.

In the classic View system, this means resource qualifiers.

res/values/colors.xml        (light theme colors)
res/values-night/colors.xml  (dark theme colors)

Both files define the same color names, colorBackground, colorOnBackground, and so on, with different values. The system picks whichever file matches the current setting automatically, your layouts and code never need to know which one is active. Setting android:theme to a DayNight based theme, like a Theme.Material3.DayNight variant, is what makes an Activity actually respect this switch.

In Jetpack Compose, the same idea moves into your theme composable.

@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val colors = if (darkTheme) DarkColorScheme else LightColorScheme
    MaterialTheme(colorScheme = colors, content = content)
}

isSystemInDarkTheme() reads the current system setting and recomposes automatically if the user changes it while the app is open.

Since Android 10, the system also offers a "force dark" mode that can algorithmically invert light layouts that never defined their own dark colors. It's meant as a fallback for apps that haven't done the work above, not a substitute for it, the results are visibly worse than an actual designed dark palette, so treating your own light and dark color sets as the real implementation is still the right approach.

How do you support different screen sizes and resolutions?

Tier: CommonDifficulty: Easy

A few techniques work together, and none of them is optional if you want the app to look right from a small phone to a foldable or tablet.

  • Density independent pixels, dp, not px. Every measurement in layouts should scale with the device's screen density automatically, a 48dp button stays visually the same physical size on a low density and a high density screen.
  • Density buckets for images, mdpi, hdpi, xhdpi, xxhdpi, and so on. You provide the same drawable at multiple resolutions, and the system picks the right one for the current screen, or better, use a vector drawable so one asset scales cleanly to any density.
  • Alternative resource qualifiers for layout, like layout-sw600dp for a smallest width of 600dp and up, so a tablet can get a genuinely different layout, a two pane view instead of a single column, rather than just a stretched version of the phone layout.
  • Flexible layouts over fixed ones. ConstraintLayout, or weight based LinearLayout, adapt to the space available instead of assuming a specific screen width.

The current recommendation on top of all this is window size classes, compact, medium, and expanded, based on the space actually available to your app rather than the physical device type. That distinction matters more now than it used to, a phone in split screen multitasking or a foldable's inner display can hand your app a "tablet sized" window even though the device itself is a phone, so branching on the real available width beats assuming based on device category.

Kotlin Multiplatform

How does Kotlin Multiplatform work?

Tier: EssentialDifficulty: Medium

Kotlin Multiplatform lets you write business logic once, in ordinary Kotlin, and compile that same code to each target platform's native format, JVM bytecode for Android, a native binary for iOS through Kotlin/Native, and JS or Wasm for the web, instead of maintaining a separate implementation of the same logic per platform.

The mechanism that makes platform specific code possible inside otherwise shared code is expect and actual. You declare an expect signature in shared code with no body, and provide an actual implementation of it for each target.

// commonMain
expect fun currentTimeMillis(): Long

// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()

// iosMain
actual fun currentTimeMillis(): Long = NSDate().timeIntervalSince1970.toLong() * 1000

Shared code calls currentTimeMillis() like any normal function, and the compiler wires up whichever actual matches the target it's building for. This is the escape hatch for the handful of things that genuinely differ per platform, like date APIs or secure storage, while everything else, your repositories, use cases, networking, and serialization, lives untouched in commonMain and just compiles everywhere.

What's realistic to share by default is the whole data and domain layer, networking with Ktor, serialization with kotlinx.serialization, and increasingly persistence too, since Room and DataStore both ship KMP compatible versions now, alongside ViewModel and Navigation from Jetpack. The UI has traditionally stayed native, Compose on Android, SwiftUI on iOS, and for a lot of production teams that's still the right split, native UI, shared everything underneath it.

Compose Multiplatform, JetBrains' UI toolkit built on the same Compose compiler and runtime Android uses, changes that calculus for teams willing to share UI too. It's stable and used in production apps on Android, iOS, desktop, and web, rendering through Skia on non-Android targets, and on iOS it embeds into a genuine UIKit view rather than emulating one. Google's own Jetpack libraries treating KMP as a first class target is the signal worth remembering for 2026, this isn't a JetBrains side project anymore, it's a direction Android's own tooling is moving in.

Read more Get started with Kotlin Multiplatform (opens in a new tab)

Other Cross-Platform

Compare React Native, Flutter, native Android and Kotlin Multiplatform.

Tier: EssentialDifficulty: Medium

These four differ on one question, what actually gets shared across platforms. The answer is nothing, the UI and the logic, or just the logic under a native UI, and everything else follows from that.

React NativeFlutterNative AndroidKotlin Multiplatform
LanguageJavaScript or TypeScriptDartKotlinKotlin
How UI is drawnReal platform views, driven from JavaScript through JSIIts own widgets, painted by its own engine, ImpellerCompose or the View system, fully nativeNative UI per platform, or Compose Multiplatform if the team opts in
What is sharedUI and logicUI and logicNothing, this is what the others are compared againstBusiness logic, and UI too on Compose Multiplatform
PerformanceGood, and better since the old bridge was removedStrong for animation heavy UIBest possible, no abstraction layer at allNative, it compiles to each platform's own format
Platform APIsThrough Turbo Modules and community packagesThrough platform channels and pluginsDirect and first partyDirect from shared Kotlin with expect and actual, or straight from the native UI layer
Team fitWeb teams already writing ReactTeams happy to pick up Dart for one codebaseTeams building one platform properlyAndroid teams who want to stop duplicating logic without giving up native UI

Pick by what the team already has and where the pain actually is.

  • Choose React Native when the team knows React, the app is mostly screens and forms, and shipping JavaScript updates outside the store review cycle matters.
  • Choose Flutter when near identical UI on both platforms is the goal, the UI is animation heavy, and Dart is not a blocker.
  • Choose native Android when the app is Android only, or the hard parts are deep OS integration, widgets and Wear, which a shared layer never reaches as cleanly.
  • Choose Kotlin Multiplatform when native Android and iOS apps already exist, and the real pain is business logic drifting between two codebases.

A few things are true whichever way you go.

  • A native layer always survives. Push handling, permissions, widgets and background work stay platform code no matter what you share.
  • The cost is tooling, not just code. Every cross platform stack adds its own build, debugging and upgrade burden on top of two native ones.
  • Size and startup pay a tax. A shared runtime or engine ships in the binary, which native code does not have to carry.

The honest 2026 answer for an Android team is Kotlin Multiplatform. Google supports it for shared business logic, and Jetpack libraries like Room, DataStore, Lifecycle, ViewModel and Navigation already run on iOS. Compose Multiplatform is built by JetBrains rather than Google, and it is stable on Android, iOS and desktop, with web still behind it. The two way React Native versus Flutter question sits on this page as well.

Read more Kotlin Multiplatform (opens in a new tab)Get started with Kotlin Multiplatform (opens in a new tab)

Compare React Native vs Flutter.

Tier: CommonDifficulty: Easy

React Native renders through each platform's actual native UI components, controlled from JavaScript, Flutter draws every pixel itself with its own rendering engine and a single codebase in Dart, and that one difference explains most of what separates them.

  • Language and rendering. React Native uses JavaScript or TypeScript, and bridges calls over to real native Views and UIViews. Flutter uses Dart, compiled ahead of time, and paints its own widgets directly onto a canvas through Skia, so the same pixels render identically on Android and iOS.
  • Performance. Flutter tends to be smoother for heavy animation, since it isn't bridging every UI update across a JavaScript to native boundary the way React Native historically has, though React Native's newer architecture narrows this with direct native calls instead of the old async bridge.
  • Look and feel. React Native inherits each platform's native widgets by default, which can mean less custom styling work to feel at home on both platforms. Flutter's widgets are consistent across platforms out of the box, which is a strength for a uniform brand and a weakness if you want an app that feels distinctly native on each one.
  • Ecosystem and hiring. React Native draws on the huge JavaScript and React ecosystem and is an easier lift for web teams already using React. Flutter's package ecosystem is smaller but has grown fast, and Dart is a shallower learning curve for a team with no JavaScript background at all.

Neither one is the clear winner, and that's an honest answer to give in an interview, not a dodge. Choose React Native when the team already knows React and code push style updates outside the app store review cycle matter. Choose Flutter when animation heavy, pixel consistent UI across platforms is the priority and the team is fine picking up Dart. Where this question usually goes next in an Android specific interview is Kotlin Multiplatform, which takes a third approach entirely, share the business logic, keep the UI genuinely native, or increasingly, share that too through Compose Multiplatform.

Project Experience

Describe the architecture of your last app.

Tier: EssentialDifficulty: Easy

There's no universal right answer here, the interviewer wants to hear you reason about your own project, but the answer that lands well has the same shape regardless of the app, name the layers, say why you split them that way, and be ready to defend one real decision.

A solid structure to describe, if it matches what you actually built, is a Clean Architecture style split into three layers.

  • UI layer. Activities, Fragments, or Compose screens, each backed by a ViewModel that exposes state through StateFlow and never touches Android framework classes directly.
  • Domain layer. Use case classes that hold business logic and coordinate one or more repositories, kept as plain Kotlin with no Android dependencies so it's trivially testable.
  • Data layer. Repository implementations that decide whether to serve data from a local Room database or a Retrofit network call, with the repository interface itself living in the domain layer so the domain never depends on the data layer's concrete details.

What actually gets you credit isn't reciting that list, it's the follow up. Be ready to say why you chose MVVM over MVP, whether you split the app into feature modules and what problem that solved, and how you handled something concrete, like offline support, a shared ViewModel between two screens, or how DI was wired with Hilt. If your app was small enough that some of these layers were overkill, say that too. An interviewer trusts "I kept it to two layers because the app was three screens" a lot more than a textbook diagram that doesn't match anything you actually shipped.

Best Practices

What are Android development best practices?

Tier: CommonDifficulty: Easy

This is a deliberately broad question, and the trap is reciting a list. An interviewer is listening for the handful you actually apply and why, so pick a few areas and say what you do in each.

  • Architecture. Keep logic out of Activities and Fragments. State lives in a ViewModel, data access lives behind a repository, and the UI just renders what it is given. The real payoff is that logic you can reach without an emulator is logic you can test.
  • Lifecycle safety. Collect flows with repeatOnLifecycle or collectAsStateWithLifecycle, clear view references in onDestroyView, and never hold a Context for longer than the thing that owns it. A large share of Android crashes and leaks come from getting exactly this wrong.
  • Threading. Nothing that blocks goes on the main thread. Coroutines with the right dispatcher for work tied to a screen, and WorkManager for work that has to survive the process dying.
  • Resources and configuration. Text in strings.xml, styles instead of the same attributes repeated everywhere, and support for different screen sizes and dark mode from the start. Retrofitting any of these later is far more painful than doing it up front.
  • Release hygiene. R8 enabled on release builds, crash reporting wired up before you need it, an App Bundle rather than a single APK, and dependencies kept current so a security fix is a version bump instead of a migration.
  • Tests where they pay. Unit tests on the logic you pulled out of the UI, and a small number of UI tests over the flows that would actually cost you money if they broke.

The strongest version of this answer is specific. Naming one practice you adopted after it bit you, and what it prevented since, lands much better than a longer list.

Read more Guide to app architecture (opens in a new tab)

AI Assisted Development

How do you use AI in your Android development workflow?

Tier: EssentialDifficulty: Medium

I use it every day, and what an interviewer is listening for is where I stop trusting it, not how much I like it. The short version is that AI writes the code I could have written and would rather not, and I keep every decision that carries risk.

Start by naming the tools, because that shows you actually work this way.

  • Gemini in Android Studio. Code completion and chat inside the IDE, plus Agent Mode, which takes a goal, plans it, edits across files, builds, and can deploy to a connected device, take a screenshot, and read Logcat. It also generates Compose previews with mock data, transforms a UI from a plain description in the preview panel, and explains a crash for you inside App Quality Insights.
  • Claude Code and other terminal agents. These sit in the repo rather than in the editor, which suits work that spans many files, a migration, a refactor, or a first pass at a test suite.
  • Cursor style editors and GitHub Copilot. The same three shapes in a different wrapper, completion, chat, and agent. Knowing the shapes matters more in an interview than being able to name every product.

Then say what you hand over, because that is the part that sounds like judgement.

  • Boilerplate. Data classes, mappers between network models and domain models, Room entities, adapters, the code that is obvious once somebody has decided the shape.
  • Test scaffolding. Fakes, fixtures, the twelve near identical cases around one function. I still write the test that describes the actual behaviour I care about.
  • Migrations. View system to Compose, RxJava to coroutines and Flow, Groovy Gradle files to the Kotlin DSL. Repetitive, mechanical, and safe to delegate precisely because there are already tests to check it against.
  • Understanding. Explaining a module I have never opened, reading a stack trace, working out why a Gradle build broke. This is the biggest time saver and nobody talks about it.
  • Config. Gradle setup, CI workflows, R8 keep rules. Fiddly, well documented, and easy to verify by running it.

Then say what you keep, in the same breath.

  • Architecture. Module boundaries, what state lives where, what we own versus what we buy. A model will happily give you a confident answer to a question that needs a conversation with two other teams.
  • Anything touching payments, auth, or user data. I read every line of that myself, and I would rather write it myself.
  • Performance work. A guess about jank is worthless. That work starts in the profiler or in a Macrobenchmark run, and the fix follows the trace.
  • The final review of every diff. Every single one, before it goes anywhere near a pull request.

How you keep it safe is the part junior candidates skip.

  • A rules file that carries the project's conventions. A CLAUDE.md for Claude Code, an AGENTS.md for Android Studio's agent, which reads that file too. Build commands, architecture, naming, the mistakes we keep having to correct. Without it you get generic Android code from three years ago.
  • Small scoped tasks. One clear change with a stated acceptance criterion. A vague request over a large codebase produces a large diff nobody wants to review.
  • Tests as the gate. The task is not done when the code compiles, it is done when the tests pass and I have read the diff.
  • Nothing sensitive in a prompt. No secrets, no keys, no customer data, no raw production logs. Android Studio supports .aiexclude files so you can keep specific files out of the context that gets shared at all.
  • Review it the way you review a junior's pull request. Plausible and wrong is the failure mode, not obviously broken, so you read for invented APIs, swallowed exceptions, and lifecycle mistakes.

On whether it helps, be honest that it is hard to measure. I look at cycle time from first commit to merge, how much review churn a change causes, and whether escaped defects went up, because faster code that breaks more often is not a win. Android Studio's business tier reports acceptance rate for its suggestions, which is a usage number rather than a quality one. The signal I trust most is whether the boring work shrank while the interesting work stayed mine.

Close on the line that actually answers the question. Whoever or whatever typed it, my name is on the commit, and I am accountable for every line in it.

Read more About Gemini in Android Studio (opens in a new tab)Agent Mode (opens in a new tab)

What are skills in Claude, and how do they differ from prompts, CLAUDE.md and MCP?

Tier: CommonDifficulty: Medium

A skill is a folder with a SKILL.md in it, instructions plus any scripts and reference files it needs, that the assistant loads on demand when a task matches its description. The point is that a piece of expertise gets written once and reused, instead of being pasted into chat again every time. It is an open standard now, so the same folder works in Claude Code and in Android Studio's Agent Mode.

The anatomy is small, which is why this is worth doing.

  • The frontmatter. Two fields carry it, name and description. The name is lowercase and hyphenated, and the description says both what the skill does and when to use it, because that sentence is the entire trigger.
  • The body. Plain markdown, the procedure you would give a new teammate. Keep it short and move detail out to other files.
  • Supporting files. A references/ folder for the long material and a scripts/ folder for anything deterministic. A script's output comes back into the conversation, its source code never has to.
  • Where they live. In Claude Code, ~/.claude/skills/ for yourself and .claude/skills/ in the repo for the team, so a skill ships with the project through git. Plugins can bundle them too. Android Studio reads them from .agents/skills/ or .android-studio/skills/, and Google publishes ready made Android skills for jobs like migrating XML to Compose or upgrading to AGP 9.
  • Progressive disclosure. Only the name and description sit in context at startup, roughly a hundred tokens each. The body loads when the skill fires, and the reference files only when they are actually read. That is why you can have thirty skills installed and pay almost nothing for the twenty nine you did not use.

How one gets chosen is worth being precise about.

  • The description is the trigger. The model matches your request against it, so a vague description means the skill never fires and a specific one means it fires at the right moment. Put the use case first.
  • You can also just ask for it. Type /skill-name in Claude Code to invoke one directly, or @ in Android Studio's Agent Mode. That is how you pin down anything with side effects, a release or a deploy, where you want the timing to be yours.

The comparison is the part interviewers actually probe.

MechanismWhat it isWhen it loads
PromptInstructions for this one conversationYou type it, every time
CLAUDE.md or AGENTS.mdStanding facts about the projectAlways, at the start of every session
SkillA packaged procedure with its own filesOnly when the task matches its description
MCP serverA live connection to a real toolConnected for the session, called when needed
SubagentA separate agent with its own contextWhen work is delegated to it
  • Versus a rules file. CLAUDE.md is for facts that are true in every session, the build command, the module layout, the conventions. A skill is for a procedure that only matters sometimes. The rule of thumb in the documentation is blunt, once an entry in your rules file has grown into a multi step procedure, it belongs in a skill, because the rules file is paid for on every single turn.
  • Versus MCP. A skill is instructions and files, it teaches the agent how to do something well with the tools it already has. MCP is plumbing, it gives the agent a new tool to call. Know how versus reach. They pair naturally, a skill that says how your team cuts a release, an MCP server that talks to the release system.
  • Versus a subagent. A skill changes how the current agent works, in the current conversation. A subagent is a separate context with its own budget that goes away and reports back. Reach for a subagent when the work would flood your context, and a skill when the work needs your standards applied.

The Android skills a team actually writes are unglamorous and that is the point.

  • A release checklist skill. Version bump, changelog, R8 mapping upload, the tag, the staged rollout percentage, in the order your team does it.
  • A Compose migration skill. How this codebase converts a Fragment and its XML layout, which patterns are banned, where the previews go.
  • A Room migration skill. Write the migration, write the schema test, never let anyone ship a destructive fallback.
  • A screenshot test skill. The exact harness, the naming convention, where the golden images live and how they get updated.

A minimal one is genuinely this small.

---
name: room-migration
description: Adds a Room schema migration and its test. Use when a
  @Entity changes or the database version is bumped.
---

1. Bump the version in the @Database annotation.
2. Add a Migration object in `AppDatabase.migrations`, one ALTER per column.
3. Add a MigrationTestHelper test that runs the old schema forward.
4. Run `./gradlew :core:database:test` and do not proceed until it is green.

The honest rule for when to write one is that you write it the third time you paste the same instructions into a chat window. Once is a prompt, twice is a coincidence, three times means your team has a procedure, and a procedure is worth putting in the repo where everyone and every agent reads the same copy.

Read more Overview of Android skills (opens in a new tab)Extend Agent Mode with skills (opens in a new tab)

What is MCP (Model Context Protocol) and how would you use it as an Android developer?

Tier: CommonDifficulty: Medium

MCP is an open protocol that lets an AI assistant call tools and read data through one standard client and server contract, so a tool you build once works in every assistant that speaks it. It came out of Anthropic, it is now governed as an open project under the Linux Foundation, and that is why Claude Code, Android Studio, VS Code, Cursor and the rest all support the same servers.

The pieces are worth naming, because the whole design is four nouns.

  • Host and client. The host is the AI application, Android Studio or Claude Code or your editor. It creates one client per server, and each client holds one connection.
  • Server. A small program that exposes a capability. It can run locally on your machine or remotely behind a URL, and the word server says nothing about where it lives.
  • Tools, resources, and prompts. Tools are functions the model can call and are the ones that matter in practice. Resources are read only data the client can pull in, like a schema or a file. Prompts are reusable templates. The server describes all of them, and the client discovers them at connect time rather than having them hardcoded.
  • Transports. Two of them. Stdio, meaning the server is a local process talking over standard input and output, and streamable HTTP for anything remote. Messages are JSON-RPC either way.

For Android work the interesting part is what you can plug in.

  • Inside Android Studio. Agent Mode supports MCP servers directly. You enable them under settings, Tools, AI, MCP Servers, and the configuration is saved to an mcp.json file. Worth knowing for the interview that it currently connects to remote HTTP servers rather than local stdio ones, and that typing /mcp in the chat lists the tools it can see.
  • Design. Figma publishes a remote MCP server, which is one of the examples in Google's own documentation. The agent reads the real spacing, colours and text from a frame instead of you retyping them into a Compose file.
  • Tickets and code review. GitHub, GitLab, Linear and Notion all publish servers, so the agent can read the ticket it is implementing and open the pull request when it is done.
  • Device and build. Community servers wrap adb and the emulator so an assistant can install a build, tap through a screen, and pull Logcat. Android Studio's own agent already has device tools built in, so this matters most for a terminal agent working outside the IDE.

Building one for your own team is less work than it sounds.

  • Wrap the tooling you already have. The feature flag service, the release dashboard, the internal crash backend. The server is a thin adapter over an API that already exists.
  • A few well described tools beat one that does everything. The description is the only thing the model reads when it decides what to call, so name the tool for the job and say when to use it.
  • Read only wherever you can. A server that reads build status is safe to hand out. A server that can cut a release is not, and that one gets a confirmation step.
  • Same auth as the underlying tool. The server runs as the developer and inherits their permissions. It never carries a shared admin token that quietly gives everyone root.

The risks are the part a senior candidate is expected to raise unprompted.

  • Prompt injection through tool output. A crash report, a ticket description, or a web page fetched by a tool is untrusted text. If the model treats it as instructions, a comment in a bug report can talk it into calling another tool. So anything with a side effect asks first, and you do not connect servers you have not read.
  • Over broad permissions. A filesystem server pointed at your home directory gives the model your keystore and your local.properties. Scope it to the repo.
  • Too many tools. Every connected server spends context and widens the surface. Connect what the task needs.

Registering a local server is a few lines. This is the stdio shape a Claude Code style client expects.

{
  "mcpServers": {
    "release-tools": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@acme/release-mcp"],
      "env": {
        "ACME_API_URL": "https://tools.internal.acme.com"
      }
    }
  }
}

Finish on how this differs from a plugin or a plain API, which is where the question usually lands. An API is something your code calls at a moment you chose in advance. A plugin is something the IDE runs when you click it. An MCP server is a menu you hand to the model, and the model decides which tool to call and with what arguments. That shift in who is driving is the whole point, and it is also exactly why you scope the tools tightly.

Read more Add an MCP server (opens in a new tab)Agent Mode (opens in a new tab)

Staying Current

How do you stay updated with Android development?

Tier: EssentialDifficulty: Easy

The honest answer is that I follow a small number of primary sources closely, and I let everything else come to me. Android moves fast enough that chasing every post is a waste of an evening, so I read the official channels when something ships and rely on one weekly newsletter to catch what I missed.

Start with the official sources, because they are the ones that are never wrong.

  • Android Developers Blog. Every platform, Play policy, and tooling announcement lands here first. If a thing matters, it shows up on this blog before it shows up anywhere else.
  • Platform release notes. One page per Android version, and each one splits behavior changes into what hits every app and what hits apps that target the new release. Android 17 is the current one, and that split is exactly what you need before a targetSdk bump.
  • Android Studio release notes. The IDE and the Android Gradle plugin move together, so this is where a build that broke overnight usually gets explained.
  • AndroidX release notes. Every Jetpack library with its stable, beta, and alpha versions and a link to its changelog. Skim it before a dependency bump instead of after one.
  • Compose release notes. The Compose group ships on its own cadence through the BOM, and this page is how you find out what changed in a version you just adopted.
  • Android Developers on YouTube. Conference talks and short explainers from the engineers who wrote the API. Watch a talk when the docs leave you unsure why an API is shaped the way it is.
  • Now in Android. A recurring roundup published as an article, a video, and a podcast episode. Ten minutes of it replaces an hour of scrolling.
  • The Now in Android app. Google's own reference app, fully Compose and fully modularised. Reading its source is the fastest way to see how the team that owns the libraries actually wires them together.
  • The Kotlin blog and KotlinConf. Kotlin ships language and Multiplatform news on JetBrains time, not Google time, so it needs its own source.

Newsletters do the filtering for you, which is the whole point.

  • Android Weekly. The one newsletter I would keep if I could only keep one. It has run for over seven hundred issues and it lands every week.
  • Kotlin Weekly. The same idea for the language side, libraries, coroutines, and Multiplatform.
  • ProAndroidDev. A community publication rather than a newsletter, several posts a day, higher volume and more variable quality. Good for depth on one topic once you know what you are looking for.

Podcasts and video are for the background knowledge you cannot get from release notes.

  • Android Developers Backstage. Long interviews with the platform engineers themselves. This is where you learn why a decision was made, which is the part interviewers actually probe.
  • Talking Kotlin. JetBrains' own podcast, useful for Multiplatform and for how teams are really shipping Kotlin outside Android.
  • Fragmented. Worth knowing about, though be aware it has moved toward AI and general software engineering. The Android back catalogue still holds up.
  • Philipp Lackner. The most consistently current Android channel on YouTube, architecture, testing, Compose, and CI, all in production shaped examples.
  • Stevdza-San. Compose and Multiplatform tutorials, short and practical, good when you want to see an API working in ten minutes.
  • Kotlin by JetBrains. Where the KotlinConf talks are published, and there are a lot of them.
  • next.app devCon. The channel that publishes droidcon and flutterCon talks. Conference talks are free senior level teaching if you pick well.

Community is where you find out what other teams are hitting before it reaches the docs.

  • r/androiddev. Busy every day, and the release day threads on a new Android version are often more useful than the release notes.
  • The Kotlin Slack. Library authors and JetBrains engineers answer questions in the open. You need an invite, and the form is linked from that page at surveys.jetbrains.com.
  • The r/AndroidDev Discord. Around twenty three thousand members and a couple of thousand online at any time. Better than Reddit for a quick question.
  • droidcon. The big independent Android conference series, now running under the next.app banner, with events across Berlin, London, India, Kenya, and more.
  • Google I/O. The main event of the year for Android developers, and the Android specific news now arrives through The Android Show a week before it. The old Android Dev Summit has not run since 2022, so do not go looking for it.

A few people are worth following directly, and I only name the ones I have actually read this year.

  • Jake Wharton, at jakewharton.com. Deep, occasionally contrarian posts on Gradle, Compose, and the Kotlin toolchain. If he says a common practice is wrong, read the argument.
  • Chris Banes, at chrisbanes.me. Compose UI and Multiplatform from someone who worked on the toolkit. His library posts are a good model for how to design an API.
  • Philipp Lackner and Stevdza-San, on YouTube, linked above. Both publish weekly and both stay on current APIs rather than recycling old tutorials.

None of that helps unless you turn it into a habit, so here is what actually works.

  • Read one release notes page a month. Pick the library you depend on most and read its changelog properly. You will find a deprecation before it becomes a migration.
  • Build one small sample per new API. A throwaway project that exercises one thing teaches more than three articles about it.
  • Follow the Jetpack cadence, not the hype. Watch the AndroidX versions page for a library moving from alpha to beta. Beta is the point where an API is worth learning, because it has stopped changing.
  • Read the behavior changes before every targetSdk bump. That single page prevents most of the surprises a new Android version can cause.
  • Say it concretely in the room. Do not recite a list of sources. Name one thing you learned recently, where you learned it, and what you did with it.

What the interviewer is really asking is whether you learn on your own, without a manager assigning you a course. The second half of it, and the part most candidates miss, is whether you can tell what actually matters from what is just noise, so pick your examples accordingly.

Read more Now in Android (opens in a new tab)AndroidX releases (opens in a new tab)Android 17 (opens in a new tab)

Lifecycle

The questions asked most often in this area.

How would you preserve Activity state during a screen rotation?

Tier: EssentialDifficulty: Easy

The right tool depends on what kind of state it is, and most real screens end up using two of these together.

  • UI state that is expensive to rebuild, a list you fetched from the network, form data the user typed, filters the user applied, belongs in a ViewModel. Rotation destroys and recreates the Activity, but the ViewModel instance survives that cycle untouched, so the new Activity just reconnects to the same object and the screen redraws itself from whatever is already there.
  • Small, simple values the system itself needs to restore, like scroll position or a selected tab index, belong in onSaveInstanceState(outState: Bundle). The system hands that Bundle back to you in onCreate(), or via onRestoreInstanceState(), after recreation.
  • Anything that needs to survive process death, not just rotation, needs to actually be persisted, either through SavedStateHandle inside the ViewModel, which is backed by that same Bundle mechanism, or written to disk or a database.

The distinction interviewers are listening for is that a ViewModel alone is not enough, it protects you from rotation but not from the system killing your process in the background, which is what onSaveInstanceState() and SavedStateHandle are actually for.

What is a Fragment and what is its lifecycle?

Tier: EssentialDifficulty: Easy

A Fragment is a reusable piece of UI and behavior that lives inside an Activity, and its lifecycle is similar to an Activity's but with a few extra states because a Fragment has to attach to a host and manage its own view separately.

The order runs like this.

  • onAttach(), the Fragment gets a reference to its host Activity.
  • onCreate(), the Fragment itself is created, but there's no view yet.
  • onCreateView(), you inflate and return the layout.
  • onViewCreated(), the view now exists, so this is where you set up RecyclerView adapters or observe LiveData.

Then onStart() and onResume() mirror the Activity's, followed on the way out by onPause(), onStop(), onDestroyView(), onDestroy(), and onDetach().

Fragment lifecycle flowThe fragment is added and the system calls onAttach and onCreate. The view lifecycle then begins with onCreateView and onViewCreated, followed by onStart and onResume. On the way down the system calls onPause, onStop and onDestroyView, which ends the view lifecycle, then onDestroy and onDetach. When the fragment goes on the back stack only the view is destroyed, and onCreateView runs again when the user returns, while the fragment instance survives.
the Fragment lifecycle
The fragment lifecycle. The highlighted region is the view lifecycle, which runs from onCreateView to onDestroyView and is what getViewLifecycleOwner tracks. On the back stack only the view is destroyed, the fragment instance survives and the view is recreated on return.

The one thing that actually gets asked in interviews is why there are two separate "destroy" points, onDestroyView() and onDestroy(). A Fragment can be kept alive in the back stack while its view is torn down, for example when you navigate away. So the Fragment object survives but its view doesn't. That's why Fragments expose a separate view lifecycle through viewLifecycleOwner, and why you should always observe LiveData or collect Flows using viewLifecycleOwner, not the Fragment itself. If you use the Fragment's own lifecycle for that, you can leak observers that outlive the view and even get called after the view is gone, which crashes or silently updates a view that no longer exists.

One more thing worth saying out loud. Any view references you grab in onCreateView() should be cleared in onDestroyView() (this is the whole reason for the "clear the binding" pattern with view binding), otherwise you're holding a reference to a destroyed view.

The scenario questions interviewers ask

The rule underneath all of these is that a Fragment can never be in a higher state than its host. The host moves first on the way up and last on the way down.

  • Adding F in the Activity's onCreate(). The transaction is committed there, so F runs onAttach, onCreate, onCreateView and onViewCreated while the Activity is still creating. Then the Activity's onStart is followed by F's onStart, and the Activity's onResume by F's onResume. On the way out the order flips, F pauses and stops before its host does.
  • Replacing F with G, with addToBackStack. F runs onPause, onStop, onDestroyView, and stops there. No onDestroy, no onDetach. The instance stays alive in the back stack with only its view torn down. Press Back and the same instance gets onCreateView and onViewCreated again with a brand new view, then onStart and onResume. That is why view references must be cleared in onDestroyView() and why viewLifecycleOwner exists at all.
  • Replacing F without addToBackStack. F goes the whole way, onPause, onStop, onDestroyView, onDestroy, onDetach. The instance is gone and nothing about it comes back.
  • Rotation with a Fragment on screen. It is destroyed and recreated along with the Activity. The arguments Bundle survives, and so does anything you wrote in the Fragment's onSaveInstanceState. Plain fields do not. A ViewModel scoped to the Fragment survives. The FragmentManager restores the Fragment for you, so committing it again in the Activity's onCreate() without checking savedInstanceState == null gives you two copies stacked on each other. That is one of the most common bugs on this topic.
  • ViewPager or tabs. Off screen pages get created but should not be resumed, otherwise every tab starts its own analytics ping or video player. FragmentStateAdapter on ViewPager2 uses setMaxLifecycle() to cap off screen pages at the started state, so only the visible one reaches resumed. On the old ViewPager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT is the setting that does the same job.
  • A DialogFragment shown and dismissed. Showing it runs the normal creation path, onAttach through onResume, and the host Activity gets no callback, because the dialog is a window inside it. Dismissing runs onPause, onStop, onDestroyView, onDestroy, onDetach, unless you added it to the back stack, in which case it stops at onDestroyView like any other Fragment.
  • Nested child fragments. A child can never be in a higher state than its parent, so the parent starts first and the child stops first. Use childFragmentManager for fragments hosted inside this Fragment, so they are torn down with it, and parentFragmentManager for the container the Fragment itself lives in. Using the wrong one is how children leak or get restored into the wrong host.
  • Two lifecycles, one classic bug. Collecting a Flow in lifecycleScope inside onViewCreated() looks fine until you navigate away and come back. The Fragment instance was never destroyed, so the old collector is still alive, onViewCreated() runs again, and now you have two. The updates arrive twice and can hit a view that no longer exists. Use viewLifecycleOwner.lifecycleScope with repeatOnLifecycle, which dies with the view.

The back stack case, drawn out, because it is the one people get wrong.

FGFGonPause1onStop2onDestroyView3onCreateView4onResume5onDestroy6onCreateView7onViewCreated8onStart9onResume10
Replace F with G, then Back
F never reaches onDestroy. The same instance gets a brand new view on the way back, so anything holding the old view in onDestroyView has to let go of it.

What the interviewer is really checking is that you know there are two lifecycles here, the Fragment's and its view's, and that the FragmentManager restores fragments for you. Candidates who miss the first one leak observers. Candidates who miss the second one add duplicate fragments on every rotation.

Read more The fragment lifecycle (opens in a new tab)

What is a ViewModel and how is it useful?

Tier: EssentialDifficulty: Easy

A ViewModel is a class that holds and manages UI-related data so that it survives configuration changes, like a screen rotation, without you having to manually save and restore it.

Without a ViewModel, if you load a list of results in onCreate() and the user rotates the phone, the Activity gets destroyed and recreated, and you'd have to fetch that data all over again, or stash it in a Bundle, which only works for small amounts of data. A ViewModel is scoped to the Activity or Fragment's lifecycle, but the framework keeps the same ViewModel instance alive across a configuration change and only actually clears it when the screen is truly finishing, calling onCleared() at that point so you can cancel jobs or release resources.

The other big benefit is separation of concerns. Your ViewModel holds the state and the logic for producing it, usually exposed as LiveData or StateFlow, and your Activity or Fragment just observes that state and renders it. That keeps business logic out of the Activity, which makes it testable without needing an actual Android device or emulator, since ViewModel itself doesn't hold a reference to a View or Context.

class UserViewModel : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user

    fun loadUser(id: String) {
        viewModelScope.launch {
            _user.value = repository.getUser(id)
        }
    }
}

One thing worth flagging. A ViewModel is not for long-term persistence. It survives rotation, but it does not survive process death or the user leaving the app entirely. That's what SavedStateHandle or actual storage is for.

What is an Activity and what is its lifecycle?

Tier: EssentialDifficulty: Easy

An Activity is a single screen with a UI, and its lifecycle is the set of callback methods the system calls as that screen moves between being created, visible, focused, backgrounded, and destroyed.

There are seven you should know cold.

  • onCreate() runs once, when the activity is first created. That's where you inflate the layout, set up your ViewModel, and restore any saved state.
  • onStart() fires when the activity becomes visible.
  • onResume() fires when it gains focus and the user can actually interact with it. This is the foreground state.
  • onPause() fires the moment the user starts leaving. It has to be fast, because the next screen is waiting on it.
  • onStop() fires once the activity is no longer visible at all. That's where you do heavier cleanup.
  • onRestart() fires when a stopped activity is about to become visible again, right before onStart(). It's the one people forget, because it only runs on the way back and never on first launch.
  • onDestroy() fires when the activity is finishing or being recreated, for final cleanup.
Activity lifecycle flowThe activity is launched and the system calls onCreate, onStart and onResume, after which the activity is running in the foreground. When the user leaves, the system calls onPause, then onStop when the activity is no longer visible, then onDestroy. If the activity regains focus from the paused state, onResume runs again. If the user navigates back to a stopped activity, onRestart runs and then onStart. A configuration change destroys the activity and immediately recreates it starting from onCreate.
the Activity lifecycle
The activity lifecycle. onPause runs when focus is lost, onStop when the activity is no longer visible, and onDestroy when it is finishing. A configuration change destroys the instance and recreates it from onCreate, which is why state that must survive rotation lives in a ViewModel.

The rule of thumb interviewers are checking for is symmetry. Whatever you start in onResume(), like a camera preview or a sensor listener, you release in onPause(). Whatever you start in onStart(), you release in onStop().

One trap worth mentioning. A configuration change, like a screen rotation, destroys and recreates the activity by default, running the whole onPause through onDestroy then onCreate through onResume sequence again. There is no onRecreate() callback, which people sometimes expect. recreate() is a method you call to trigger that cycle yourself, not something the system calls on you. That's exactly why ViewModel exists, to hold UI state across that destroy and recreate cycle without you having to manually save and restore everything in onSaveInstanceState().

The other callback worth knowing is onNewIntent(), which sits outside that sequence. If an Activity is launched again while an instance already exists, and its launch mode is singleTop, singleTask or singleInstance, Android does not build a second instance and does not call onCreate() again. It calls onNewIntent() on the existing instance, then onRestart(), onStart() and onResume() if it had been stopped.

The gotcha interviewers like here is that getIntent() still returns the original Intent after onNewIntent() runs. You have to call setIntent(intent) yourself inside onNewIntent(), otherwise you keep reading stale data from whenever the Activity was first launched. This is the classic bug behind a notification tap that opens the wrong screen.

The scenario questions interviewers ask

Most of this topic gets asked as small puzzles. Here is the trigger and the exact callback order for the ones that keep coming up.

  • A starts B. A onPause, then B onCreate, onStart and onResume, and only after that A onStop. A stops last on purpose, because the system waits until B is actually on screen before it tears down what A was holding. That is also why onPause has to be tiny. B is waiting on it, so a disk write or a network call there turns into visible jank on every navigation.
  • Back from B to A. B onPause, then A onRestart, onStart and onResume, then B onStop and onDestroy. Same rule, the leaving screen finishes last. A is the same instance, so there is no onCreate.
  • Home on A. onPause and onStop, with onSaveInstanceState next to them. On API 28 and later it runs after onStop, and before API 28 it ran before onStop. Coming back from the launcher gives you onRestart, onStart and onResume, with no onCreate, because the instance was never destroyed.
  • A dialog themed or transparent activity on top of A. A gets onPause and nothing more. It is still partly visible, so it never reaches the stopped state. A plain Dialog, the kind you show from the activity itself, is only a window inside that same activity, so it fires no lifecycle callback at all. People get that second half wrong constantly.
  • Rotation on A. onPause, onStop, onSaveInstanceState, onDestroy, then onCreate with the saved Bundle, onStart, onRestoreInstanceState, onResume. A ViewModel survives, because the system keeps it as the retained non configuration instance and hands it to the new activity. Fields on the activity do not survive. android:configChanges is the opt out, and it is discouraged, because you then owe correct handling of every change you claimed and recreation can still happen for other reasons.
  • Process death in the background, then return. You come back on a brand new process, so onCreate runs with a non null Bundle. The ViewModel is gone, since it only ever lived in memory. What comes back is whatever you wrote in onSaveInstanceState or in a SavedStateHandle, which is the whole reason SavedStateHandle exists.
  • Multi window or split screen. Before Android 10 only one activity is resumed at a time and the rest sit in the started state. On Android 10 and later, multi resume keeps both resumed, and onTopResumedActivityChanged() is the callback that tells you which one currently has focus. That is where you grab or release a singleton resource like the camera or the microphone.
  • An incoming call or the lock screen on A. The full screen case is onPause then onStop, and onRestart, onStart, onResume when the user comes back. A call shown as a heads up overlay is the other case. A stays partly visible, so it only pauses, which is exactly the dialog themed activity rule again.
  • finish() called inside onCreate. onDestroy runs and onStart and onResume never do. The activity is finishing before it was ever visible, so nothing you set up for the visible states gets torn down for you. Clean up whatever you already allocated.
  • A notification opens A while B is on top. With standard, you get a second A instance stacked above B. With singleTop, singleTask or singleInstance, the existing A gets onNewIntent(), then onRestart, onStart and onResume if it was stopped, and no onCreate. The full set of stack puzzles lives in launch modes.

Three of those, drawn out. Time runs left to right and the arrow follows the order the system actually calls things.

ABonPause1onCreate2onStart3onResume4onStop5
A starts B
A stops only after B is on screen, which is why onPause has to be tiny. B is waiting on it.
BAonPause1onRestart2onStart3onResume4onStop5onDestroy6
Back from B to A
Same rule in reverse. The leaving screen finishes last, and A is the same instance, so there is no onCreate.
A (old)A (new)A (old)A (new)onPause1onStop2onSaveInstance…3onDestroy4onCreate5onStart6onRestoreInst…7onResume8
Rotation on A
Two different objects. The dashed boxes are the system destroying one and creating the next, and the ViewModel is what crosses the gap between them.

What all of these are really checking is three habits. That you treat onPause as a place for tiny work only, that onStop is where you release what the screen was holding, and that onDestroy is not guaranteed at all when the system kills your process. If cleanup has to happen, it belongs in onStop, not in onDestroy.

Read more The activity lifecycle (opens in a new tab)

How does ViewModel work internally?

Tier: EssentialDifficulty: Medium

A ViewModel survives configuration changes because it's not actually owned by the Activity. It's held in a separate ViewModelStore object that the Android framework keeps alive across the destroy and recreate cycle, and only throws away when the Activity is finishing for real.

Your Activity or Fragment implements ViewModelStoreOwner, which just means it holds a ViewModelStore, essentially a map of ViewModel instances keyed by class name (or a key you provide). When you call by viewModels() or ViewModelProvider(this).get(MyViewModel::class.java), the ViewModelProvider first checks that store. If a ViewModel already exists there, it hands you back the same instance instead of creating a new one.

On a configuration change, the framework retains that ViewModelStore in a special non-configuration instance and reattaches it to the new Activity instance that gets created right after. So the Activity object itself is brand new, but the ViewModelStore, and every ViewModel inside it, is literally the same object as before. That's the whole trick. Nothing is being serialized or restored. It's just not thrown away in the first place.

The store only gets cleared, and onCleared() called on each ViewModel, when the Activity is actually finishing, meaning isFinishing() is true and it's not just a rotation. That's also when viewModelScope, which is backed by a Job tied to onCleared(), gets cancelled, which is why any coroutines you launch in viewModelScope stop automatically instead of leaking.

What is process death?

Tier: EssentialDifficulty: Medium

Process death is the system killing your app's entire process while it is in the background, usually to reclaim memory for whatever the user is actively using. It is different from the destroy and recreate cycle a rotation triggers, that keeps the process alive and just rebuilds the Activity, process death ends the process itself, taking every in memory object with it.

That distinction matters because of what survives each one.

  • A ViewModel survives rotation, since its retained store lives inside the process, but it does not survive process death, the whole process including that store is gone.
  • onSaveInstanceState() state does survive process death, because the system writes that Bundle out to disk before killing the process, and hands it back to onCreate() if the user returns to that same task later.
  • SavedStateHandle inside a ViewModel is the bridge between the two, it is backed by that same saved Bundle mechanism, so values you put there come back even after a real process death, not just a rotation.

You can trigger process death on purpose while debugging by running adb shell am kill <package name> after backgrounding the app, then reopening it from Recents, which is the honest way to check whether your screen actually restores state instead of just surviving rotation.

Go deeper: every Lifecycle question

Android UI (Views)

The questions asked most often in this area.

Explain the role of RecyclerView.Adapter and RecyclerView.ViewHolder.

Tier: EssentialDifficulty: Easy

Adapter and ViewHolder split the two jobs a list needs done, deciding what data goes where, and holding onto the actual view references so the layout does not get inflated over and over.

  • RecyclerView.Adapter owns the data set. It creates view holders in onCreateViewHolder(), one per item type it needs on screen, and fills them with data in onBindViewHolder() whenever a row needs to show a new item.
  • RecyclerView.ViewHolder wraps one row's views and caches the findViewById() lookups for them, so those lookups happen once when the holder is created, not every single time that row gets bound to new data.
class ItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val title: TextView = view.findViewById(R.id.title)
}

class ItemAdapter(private val items: List<Item>) : RecyclerView.Adapter<ItemViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_row, parent, false)
        return ItemViewHolder(view)
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        holder.title.text = items[position].title
    }

    override fun getItemCount() = items.size
}

The LayoutManager is what actually decides which positions need a view holder at all, but the Adapter and ViewHolder are what make reuse possible, a small number of ViewHolders get created once and then rebound with onBindViewHolder() as the user scrolls, instead of RecyclerView inflating a fresh layout for every row.

What is a View in Android?

Tier: EssentialDifficulty: Easy

A View is the base class for every UI element on screen, a Button, a TextView, an ImageView, anything the user can see or touch is a View or a subclass of it. It's responsible for its own measuring, drawing, and handling of input events.

Every View goes through the same core lifecycle to get on screen, it is measured through onMeasure(), which figures out how much space it needs given the constraints its parent hands it, positioned through onLayout(), which places it at its final coordinates, and painted through onDraw(), which is where its actual pixels get rendered onto a Canvas.

val button = Button(context).apply {
    text = "Submit"
    setOnClickListener { submitForm() }
}

ViewGroup is the other half of this system, a View subclass that acts as an invisible container holding other Views and ViewGroups, LinearLayout and ConstraintLayout are both ViewGroups. A screen is ultimately a tree built out of these two kinds of nodes, ViewGroups arranging their children, and Views at the leaves actually rendering content. In Jetpack Compose there is no View class at all for the UI you write yourself, a composable function describes what to draw and Compose's own renderer paints it directly, though a Compose screen can still host a legacy View through AndroidView when it needs to.

What is the difference between View.GONE and View.INVISIBLE?

Tier: EssentialDifficulty: Easy

View.GONE removes the view from layout entirely, it takes up no space and neighboring views collapse into where it used to be. View.INVISIBLE hides the view but keeps its space reserved, everything around it stays exactly where it was.

errorText.visibility = View.GONE      // layout reflows around it
loadingSpinner.visibility = View.INVISIBLE  // space stays reserved

The practical rule is whether you want the layout to shift. Use GONE when a view is conditionally not part of the screen at all, an optional error message that only sometimes shows, so nothing should reserve space for it when it's absent. Use INVISIBLE when you want the view to stop being seen or interacted with but everything else on screen should stay put, a common example being a placeholder that toggles with a loading spinner in the exact same spot, where you don't want surrounding content jumping around every time it toggles.

Both skip drawing and skip touch handling, the difference is entirely about whether onMeasure() and onLayout() still account for that view's space, GONE is excluded from measurement, INVISIBLE still gets measured and laid out as if it were showing.

How does RecyclerView work internally?

Tier: EssentialDifficulty: Medium

RecyclerView works by reusing a small pool of item views instead of creating a new one for every row in the data set, which is the whole reason it replaced ListView.

Three pieces make this happen.

  • The Adapter knows how to create a view holder and bind data into it.
  • The ViewHolder wraps one item's views and caches the findViewById() lookups so they only happen once per view, not once per bind.
  • The LayoutManager decides where each visible item goes on screen, whether that's a linear list, a grid, or something staggered, and it also decides which items are currently visible and need a view at all.

As you scroll, only enough view holders exist to cover the visible screen plus a small buffer. When a row scrolls off screen, its view holder isn't destroyed. It goes back into a recycle pool, and when a new row is about to scroll into view, the LayoutManager pulls a view holder out of that pool and the adapter calls onBindViewHolder() to stuff the new data into the existing views. So you get one onCreateViewHolder() call per view holder ever created for the visible window, and many onBindViewHolder() calls as it gets reused, which is far cheaper than inflating a new layout on every scroll tick.

That recycling is also why you have to be careful about state inside a row. If a view holder gets reused for a different item, anything you didn't explicitly reset in onBindViewHolder(), like a checkbox's checked state, can bleed over from the previous item unless you set it every single time.

What is DiffUtil and how does it improve RecyclerView performance?

Tier: EssentialDifficulty: Medium

DiffUtil is a utility that compares an old list and a new list, works out the minimal set of insertions, removals, and moves between them, and tells the adapter to animate and rebind only those changes, instead of redrawing the whole RecyclerView.

Before DiffUtil, the common pattern was notifyDataSetChanged(), which throws away all view holder state and rebinds every visible row, whether it actually changed or not. That's wasteful and it kills animations, since RecyclerView has no idea what actually moved.

DiffUtil fixes both problems. You implement a Callback that tells it how to check if two items are the same entity, usually by id, and whether their contents are equal. DiffUtil runs a diffing algorithm over the two lists and produces a list of granular update operations, which you dispatch to the adapter.

class ItemDiffCallback(
    private val old: List<Item>,
    private val new: List<Item>
) : DiffUtil.Callback() {
    override fun getOldListSize() = old.size
    override fun getNewListSize() = new.size
    override fun areItemsTheSame(oldPos: Int, newPos: Int) =
        old[oldPos].id == new[newPos].id
    override fun areContentsTheSame(oldPos: Int, newPos: Int) =
        old[oldPos] == new[newPos]
}

In practice most people reach for ListAdapter, which wraps this whole flow for you, you just call submitList() with the new data and it runs the diff on a background thread and dispatches the updates automatically. The performance win is real, only the rows that actually changed get rebound, and you get correct move and fade animations for free instead of a full list flash.

Go deeper: every Android UI (Views) question

Jetpack Compose

The questions asked most often in this area.

Jetpack Compose vs the Android View system: compare them.

Tier: EssentialDifficulty: Easy

The View system is imperative, you build a tree of View objects, usually from XML, then mutate them by hand as your data changes, calling things like findViewById() and setText(). Compose is declarative, you write a function that describes what the UI should look like for the current state, and Compose figures out how to update the screen when that state changes.

// View system
textView.text = "Hello, $name"

// Compose
Text(text = "Hello, $name")

With Views, keeping the UI in sync with your data is your job, you have to remember to call the right setter every time something changes, and it's easy to miss a spot or update a view that's already gone. With Compose, you never touch the UI directly at all, you just update the state, and recomposition handles the rest.

Compose also cuts out a lot of the ceremony that comes with Views, no XML layout files, no findViewById(), no view binding boilerplate, and it's all plain Kotlin, so you get real language features like loops and conditionals directly in your UI code. The two aren't mutually exclusive either, Compose has AndroidView for embedding a legacy View inside a Compose screen, and ComposeView for embedding Compose inside a View based screen, which is how most real apps migrate incrementally instead of rewriting everything at once.

What are Composable functions?

Tier: EssentialDifficulty: Easy

A composable function is a regular Kotlin function marked with the @Composable annotation, and it describes a piece of UI declaratively. Instead of writing code that builds a TextView and sets its text step by step, you just describe what the UI should look like for the current data, and Compose takes care of turning that into actual UI on screen.

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

The @Composable annotation isn't just documentation, it tells the Compose compiler plugin to treat this function specially, tracking what state it reads so Compose knows when to call it again. A composable function can only be called from inside another composable function, you can't call one from a regular function or a click listener directly.

Composable functions are also expected to be side effect free and fast, since Compose might call them many times as state changes, skip some of them entirely when their inputs haven't changed, or run them in an order you don't control. You describe the UI, Compose handles rebuilding it.

What is remember in Compose, and why and when should you use it?

Tier: EssentialDifficulty: Easy

remember stores a value in the Composition and hands you back that same cached value on the next recomposition, instead of recreating it from scratch every time the function runs. You use it any time a composable needs to hold onto a value across recompositions.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) } // survives recomposition
    Button(onClick = { count++ }) { Text("Count: $count") }
}

Without remember, mutableStateOf(0) would run again on every recomposition and reset count back to zero every time, so clicking the button would never actually appear to do anything. remember is what lets that state persist between one recomposition and the next.

What remember doesn't do is survive a configuration change like a screen rotation, since the whole Composition is thrown away and rebuilt then. For that you'd reach for rememberSaveable, which saves the value into the instance state bundle, or better, put the state in a ViewModel if it needs to survive longer than that.

What is State in Compose?

Tier: EssentialDifficulty: Easy

State in Compose is any value that can change over time and that the UI needs to reflect, like a counter, a text field's contents, or a loading flag. Compose watches state through State<T> and its mutable form MutableState<T>, and when the value changes, Compose automatically recomposes whatever composables read it.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

mutableStateOf(0) creates an observable holder around the value 0. Wrapping it in remember is what makes it survive recomposition, without remember a new MutableState would be created from scratch every time the function reran, and your count would keep resetting to zero. Reading count inside Text is what registers that composable as a subscriber, so only the parts of the UI that actually read count recompose when it changes.

State doesn't have to live inside the composable that displays it. A common pattern is state hoisting, where the state is lifted up to a parent or a ViewModel and passed down as a plain parameter, with an event callback to request changes, which keeps the composable that displays it simple and reusable.

What is the difference between a stateful and a stateless composable?

Tier: EssentialDifficulty: Easy

A stateful composable owns and manages its own state internally, usually with remember. A stateless composable owns no state at all, it just takes everything it needs as parameters and reports changes back through callback lambdas.

// stateful, owns count itself
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Count: $count") }
}

// stateless, caller owns count
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Count: $count") }
}

Stateless composables are generally preferred, because they're easier to reuse, easier to test, and easier to preview, since you control their state entirely from outside. This pattern is called state hoisting, you move the state up to the nearest common caller, often a ViewModel, and pass it down as a value plus an event lambda, following what Compose calls unidirectional data flow, state flows down, events flow up.

That said, some state genuinely belongs inside the composable and never needs to leave it, like whether a dropdown is currently expanded. For anything a parent, a sibling, or a ViewModel needs to know about or control, hoist it, for anything purely local to that one piece of UI, keeping it stateful is fine.

Explain the lifecycle of a Composable in Jetpack Compose.

Tier: EssentialDifficulty: Medium

A composable's lifecycle has three stages, and that's the whole thing.

  • Entering the Composition. The composable is called for the first time and Compose adds it to the tree.
  • Recomposing. Zero or more times, whenever the state it reads changes, Compose calls it again to update the tree.
  • Leaving the Composition. The composable is no longer called, usually because a condition changed or the caller stopped including it, and Compose removes it from the tree.

That's it, there's no onPause or onStop to override, it's a much simpler lifecycle than an Activity or a Fragment. What decides identity across these stages is the call site, the specific place in the source where a composable is invoked. Two calls to the same composable from two different places in the code are treated as two separate instances, and Compose reuses an instance across recomposition as long as it's called from the same site with the same position.

That positional matching breaks down inside a loop. If you render a list of items by index and the list gets reordered or an item is inserted in the middle, Compose can't tell which instance is which anymore, so it may tear down and recreate instances that should have just moved, restarting any side effects they were running.

Column {
    for (movie in movies) {
        key(movie.id) { // gives the instance a stable identity
            MovieRow(movie)
        }
    }
}

Wrapping each item in key(movie.id) fixes that, it tells Compose to track instances by that key instead of position, so reordering or inserting items moves and reuses composables correctly instead of restarting them. LazyColumn and LazyRow expose the same idea through their own key parameter on items().

What are side effects in Jetpack Compose?

Tier: EssentialDifficulty: Medium

A side effect is a change to app state that happens outside a composable's own scope, like starting a network call, writing to a database, showing a snackbar, or navigating to another screen. Composable functions are supposed to be side effect free, since Compose might call them multiple times, skip them, or run them out of order, so Compose gives you a separate set of Effect APIs to run this kind of work in a controlled, lifecycle aware way.

The main ones cover different shapes of work.

  • LaunchedEffect runs a suspend block tied to the composable's lifetime, canceled automatically when it leaves the Composition.
  • DisposableEffect is for effects that need explicit cleanup, it requires an onDispose block, useful for registering and unregistering listeners.
  • SideEffect runs after every successful recomposition, for publishing Compose state out to non-Compose code, like an analytics library.
  • rememberCoroutineScope gives you a CoroutineScope you can launch from inside a callback, like a button click, rather than from the composable body directly.
  • produceState converts an external source like a Flow or a callback based API into Compose State.
  • derivedStateOf recomputes a value from other state, but only notifies the Composition when the computed result actually changes.
LaunchedEffect(userId) {
    val user = repository.fetchUser(userId) // suspend call, safe here
    onUserLoaded(user)
}

The thing all of these share is a key parameter, whatever value you pass in determines when Compose cancels the old effect and starts a new one. Get the key wrong, like leaving it out or passing something that changes too often, and you either miss updates or restart expensive work needlessly. Picking the right Effect API for the job, rather than reaching for LaunchedEffect for everything, is most of what makes side effects in Compose predictable.

What is recomposition?

Tier: EssentialDifficulty: Medium

Recomposition is Compose calling your composable functions again to update the UI, whenever the state they read changes. There's no setText() or notifyDataSetChanged() to call yourself, you just change the state, and Compose figures out what needs to be redrawn.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times") // recomposes whenever count changes
    }
}

Compose is smart about how much it redoes. It only recomposes the composables that actually read the piece of state that changed, and skips the ones whose inputs are the same as last time. So in a screen with a header and a list, changing the list doesn't force the header to recompose too, as long as the header doesn't depend on that state.

Because recomposition can happen often, on every frame during an animation, and Compose doesn't guarantee the order functions run in or even that a composable only runs once per update, your composables need to be fast, side effect free, and idempotent. That means no writing to a shared variable or mutating something outside the function from inside a composable body, since Compose might call it more times than you expect.

What is the difference between LaunchedEffect and DisposableEffect?

Tier: EssentialDifficulty: Medium

LaunchedEffect runs a suspend function, DisposableEffect runs a regular block that must register something and explicitly clean it up. Reach for LaunchedEffect when the work is a coroutine, reach for DisposableEffect when the work is a subscription or a listener that has to be torn down.

// LaunchedEffect, suspend work, cancels automatically on key change or leaving composition
LaunchedEffect(userId) {
    val user = repository.fetchUser(userId)
    onUserLoaded(user)
}

// DisposableEffect, requires an explicit onDispose
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event -> /* ... */ }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

LaunchedEffect launches a coroutine scoped to the composable, when the key changes or the composable leaves the Composition, that coroutine is canceled, and Compose starts a fresh one if the composable is still around. Cleanup is implicit, cancellation is the cleanup.

DisposableEffect doesn't run suspend code at all, it's for setup that has a distinct undo step, registering a BroadcastReceiver, adding a LifecycleEventObserver, subscribing to a sensor. Because that undo step isn't automatic the way coroutine cancellation is, DisposableEffect forces you to write an onDispose block yourself, and the compiler won't let you skip it. Miss that block with LaunchedEffect and there's nothing to miss, cancellation handles it, miss it with DisposableEffect and you'd leak the listener, so the API makes it mandatory.

The rule of thumb is simple. If what you're doing is naturally a coroutine, a network call, a delay, collecting a flow, use LaunchedEffect. If what you're doing is register something now, unregister it later, use DisposableEffect.

What is the difference between remember and rememberSaveable?

Tier: EssentialDifficulty: Medium

remember survives recomposition, rememberSaveable survives recomposition and configuration changes. That's the whole difference, and it comes down to where each one stores the value.

var scrollPosition by remember { mutableStateOf(0) } // lost on rotation

var count by rememberSaveable { mutableStateOf(0) } // survives rotation

remember keeps its value in the Composition itself, in memory. That's enough to survive a recomposition, since the Composition isn't rebuilt for that, but a configuration change like a rotation throws the whole Composition away and builds a new one, so anything held only in remember resets to its initial value.

rememberSaveable writes its value into the same saved instance state Bundle an Activity uses for onSaveInstanceState(), so it comes back after the Composition is rebuilt. The catch is that a Bundle can only hold what Parcelable and a small set of built in types support, primitives, String, and anything you mark @Parcelize. For a custom type that isn't naturally Bundle friendly, you give it a mapSaver or a listSaver to tell rememberSaveable how to serialize and restore it.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

var selectedCity by rememberSaveable { mutableStateOf(City("Madrid", "Spain")) }

Neither one survives the process actually being killed and the task removed from recents, for that you need real persistence, like a ViewModel backed by SavedStateHandle or storage on disk. As a rule, default to remember for anything transient, like a dropdown's open state, and reach for rememberSaveable specifically for state the user would be annoyed to lose on a rotation, like form input or scroll position.

Go deeper: every Jetpack Compose question

Architecture

The questions asked most often in this area.

Describe MVVM.

Tier: EssentialDifficulty: Easy

MVVM splits an app into three layers, Model, View, and ViewModel, so the UI and the business logic don't end up tangled together in the same class.

The Model handles data and business logic, repositories, local and remote data sources, and plain data classes. The View is the UI layer, an Activity, Fragment, or composable, and its only job is to render whatever state it's given and forward user actions onward. The ViewModel sits between the two, it pulls data from the Model, turns it into UI ready state, and exposes that state through something observable like StateFlow or LiveData.

class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user

    fun loadUser(id: String) {
        viewModelScope.launch { _user.value = repo.getUser(id) }
    }
}

The key detail that makes MVVM work is that the ViewModel has no reference to the View at all, it doesn't hold an Activity, a Fragment, or a Context. The View observes the ViewModel and updates itself, but the ViewModel never reaches back to touch the View directly. That one way relationship is what makes the ViewModel testable on its own and what lets it survive configuration changes without dragging a dead View reference along with it.

What is LiveData in Android?

Tier: EssentialDifficulty: Easy

LiveData is a lifecycle aware observable data holder, it wraps a value and notifies observers when that value changes, but only while the observer is in an active lifecycle state.

class UserViewModel : ViewModel() {
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user

    fun loadUser(id: String) {
        viewModelScope.launch { _user.value = repository.getUser(id) }
    }
}

// in the Fragment
viewModel.user.observe(viewLifecycleOwner) { user -> renderUser(user) }

The lifecycle awareness is the whole point. A plain observable would keep calling back into a Fragment or Activity even after it's been destroyed, which is a common source of crashes and memory leaks. LiveData checks the state of the LifecycleOwner you pass to observe(), STARTED or RESUMED counts as active, and it automatically stops delivering updates once that owner drops below that, and unsubscribes entirely once it's destroyed.

It's typically exposed from a ViewModel as read only LiveData, backed by a private MutableLiveData the ViewModel updates internally, which is the same pattern you'd use with StateFlow. Google's own guidance has shifted toward Kotlin Flow for new code, but LiveData is still common in existing codebases and interviewers still expect you to know how it behaves.

What is the difference between setValue and postValue in LiveData?

Tier: EssentialDifficulty: Easy

setValue must be called from the main thread, and it updates the value, and notifies observers, immediately, synchronously. postValue can be called from any thread, background work included, it posts the update to the main thread and the actual write happens slightly later, asynchronously.

liveData.value = user           // setValue, main thread only
liveData.postValue(user)        // postValue, safe from a background thread

The gotcha worth knowing, if you call postValue multiple times before the main thread gets a chance to process any of them, only the last value survives, the earlier ones are simply overwritten and never delivered. That's fine for something like a progress percentage where only the latest matters, but it's a real bug if you're relying on postValue to deliver every intermediate value in order.

The rule of thumb, use setValue when you're already on the main thread, which is the common case inside a ViewModel using viewModelScope, since Dispatchers.Main is the default there. Reach for postValue only when you're genuinely updating from a background thread and don't want to hop back to the main thread yourself.

Read more LiveData overview (opens in a new tab)

Compare MVC vs MVP vs MVVM architecture.

Tier: EssentialDifficulty: Medium

All three split an app into a data layer and a UI layer, the difference is how much the UI layer knows about the other two, and how that plays out on Android specifically.

MVC is the oldest and the weakest fit for Android. In theory the Controller sits between Model and View, but on Android the Activity or Fragment usually ends up playing both Controller and View at once, since it both handles input and directly manipulates its own views. That makes it hard to test and easy to end up with a bloated Activity that does everything.

MVP fixes the testing problem by pulling the logic out into a Presenter, which talks to the Model and updates the View through a plain interface. The View, usually the Activity or Fragment, becomes a thin layer that just implements that interface and forwards user actions to the Presenter. The Presenter itself is unit testable since it only depends on an interface, not a real Activity, but it does hold a direct reference to the View, so you have to manage that reference carefully or you leak the View when it's destroyed while the Presenter is still doing work.

MVVM removes that reference entirely. The ViewModel exposes state through something observable, LiveData or StateFlow, and the View just subscribes to it, so the ViewModel never needs to know the View exists at all. That avoids the leak problem MVP has by construction, and on Android it pairs naturally with the ViewModel class, which already survives configuration changes for you. That combination is why MVVM is the default choice on Android today, MVP still works but needs more manual lifecycle discipline, and MVC mostly shows up as an anti pattern people are warned away from.

Go deeper: every Architecture question

Dependency Injection

The questions asked most often in this area.

What is Dependency Injection?

Tier: EssentialDifficulty: Easy

Dependency Injection is a pattern where a class receives the objects it depends on from the outside, instead of creating them itself.

// Without DI, the ViewModel builds its own dependency, and is stuck with it
class UserViewModel {
    private val repository = UserRepository(RetrofitClient.api)
}

// With DI, the dependency is handed in from the outside
class UserViewModel(private val repository: UserRepository) : ViewModel()

That one change buys you a few things. The class no longer needs to know how to construct its dependency, only what shape it needs, usually expressed as an interface. Testing gets much easier because you can hand the class a fake or mock repository instead of a real network client. And the dependency can be shared or scoped, the same UserRepository instance can be reused across every ViewModel that needs it instead of each one making its own.

You can do DI by hand, just pass constructor arguments yourself, and plenty of small apps do. The reason frameworks like Dagger, Hilt, or Koin exist is that manual DI gets painful once you have a real dependency graph, a repository needing an API client and a database, both needing a config object, all needing to be wired up consistently across dozens of classes. The framework generates or manages that wiring for you so you don't hand-assemble it at every call site.

Explain the @Inject, @Module, @Provides and @Component annotations in Dagger 2.

Tier: EssentialDifficulty: Medium

These four annotations are the core vocabulary of Dagger, each one plays a different role in building the dependency graph.

  • @Inject on a constructor tells Dagger how to build that class itself, and marks the fields or constructor parameters that need dependencies supplied.
  • @Module marks a class that groups together methods for building objects Dagger can't construct directly, usually because they come from a third party library or need custom setup.
  • @Provides marks a method inside a @Module that returns a fully built object, Dagger calls it whenever something needs that type.
  • @Component marks an interface that Dagger uses to generate the actual implementation, it's the bridge between the modules and the classes that need injecting.
class UserRepository @Inject constructor(private val api: ApiService)

@Module
class NetworkModule {
    @Provides
    fun provideApi(): ApiService = Retrofit.Builder().build().create(ApiService::class.java)
}

@Component(modules = [NetworkModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}

The way to think about the split, @Inject is for classes you own and can annotate directly, @Provides inside a @Module is for everything else, an interface, a third party class, or anything that needs a builder instead of a plain constructor call. @Component ties both together and is what actually generates code at compile time, an AppComponent implementation with all the wiring already written for you.

How would you choose between Dagger 2 and Dagger-Hilt?

Tier: EssentialDifficulty: Medium

For any new Android app, choose Hilt. It's built on top of Dagger, so you get the same compile time verified dependency graph and the same performance, but with most of the boilerplate stripped away.

The difference is what you have to write by hand.

  • Dagger 2 makes you define your own components, tie each one to an Android lifecycle owner yourself, and write the modules that wire it all together. That's a lot of ceremony to get right, and it's easy for a new team member to wire a scope incorrectly.
  • Hilt gives you standard components already scoped to Application, Activity, Fragment, ViewModel, and so on, generated for you. You annotate a class with @AndroidEntryPoint or @HiltViewModel and the scoping is handled correctly by convention, so there's a lot less room to get it wrong.

The one case where you'd still reach for plain Dagger is a non-Android module, like a shared Kotlin Multiplatform or pure JVM library, where Hilt's Android-specific generated components don't apply and you want the graph without any Android dependency at all.

So in practice the decision isn't really Dagger versus Hilt as competitors, Hilt is opinionated Dagger for Android. You pick raw Dagger only when you're outside the Android application layer or maintaining a large legacy codebase where migrating off Dagger isn't worth the churn.

Go deeper: every Dependency Injection question

Concurrency & Threading

The questions asked most often in this area.

What is the difference between the UI thread and a background thread?

Tier: EssentialDifficulty: Easy

The UI thread, also called the main thread, is the one thread the system creates that's allowed to touch View objects, and it's the only one running a Looper from the moment your app starts, pumping the MessageQueue that drives every click, animation, and lifecycle callback. A background thread is any other thread, whether one you create directly or one pulled from a pool, and it can do heavy work but can never touch a View directly.

Thread {
    val result = doExpensiveWork()          // fine, off the UI thread
    // textView.text = result               // would crash, wrong thread
    runOnUiThread { textView.text = result } // hop back to the UI thread to touch the view
}.start()

Anything that blocks the UI thread for more than a moment, network calls, disk reads, heavy computation, shows up as dropped frames or, past about five seconds, an ANR. That's the entire reason background threads exist, to keep the UI thread free to keep pumping that queue at 60 or more frames a second. The corresponding rule going the other way is that a background thread can never touch a View directly, since the view system isn't thread safe, results have to be handed back to the UI thread through a Handler, runOnUiThread, or withContext(Dispatchers.Main) in coroutines.

Explain Thread, Handler, Looper, MessageQueue and HandlerThread.

Tier: EssentialDifficulty: Medium

These five pieces are how Android moves work onto and off of a given thread, and they only make sense together.

  • Thread. A basic unit of execution. Android's main thread is just a Thread with one extra thing attached to it, a Looper.
  • MessageQueue. A queue of Message and Runnable objects waiting to be processed, owned by one thread.
  • Looper. Runs on a thread and continuously pulls the next item off that thread's MessageQueue, dispatching it for execution. Without a Looper, a thread has nowhere to receive posted work, it just runs its run() method and exits.
  • Handler. Your entry point for posting work into a specific thread's queue. You create a Handler tied to a Looper, and calling handler.post { ... } from any thread queues that block to run on the Handler's thread.
  • HandlerThread. A Thread subclass that sets up its own Looper for you, since a plain background Thread doesn't have one by default. You start it, grab its Looper, and hand that to a Handler to get a dedicated background thread you can keep posting work to.

The classic use is posting a result from a background thread back to the main thread. Handler(Looper.getMainLooper()).post { textView.text = result } works because the main thread already has a Looper running from the moment the app starts, pumping the MessageQueue that drives every UI update, click, and lifecycle callback. In modern code coroutines usually replace this, withContext(Dispatchers.Main) is doing the same handoff under the hood, but the interview question is really checking that you understand what's happening beneath that abstraction.

What is an ANR? How can an ANR be prevented?

Tier: EssentialDifficulty: Medium

An ANR, Application Not Responding, is what the system throws up when your app's main thread is blocked for too long to respond to input, and it offers the user a dialog to force quit you.

Android is watching for a few specific triggers, not just "the app feels slow."

  • No response to a key press or touch within 5 seconds.
  • A BroadcastReceiver's onReceive() not finishing within 5 seconds while the app is in the foreground.
  • A Service not calling startForeground() within 5 seconds of Context.startForegroundService().
  • Service.onCreate() or onStartCommand() taking too long to return.

Prevention comes down to one rule, never block the main thread, and it plays out as several concrete habits.

  • Move network calls, disk reads and writes, and database queries off the main thread, typically with Dispatchers.IO in a coroutine.
  • Keep BroadcastReceiver.onReceive() short, if the work takes real time hand it off to a coroutine, WorkManager, or a Service instead of doing it inline.
  • Avoid locks on the main thread that a background thread might hold, since a blocked main thread waiting on a mutex is exactly what an ANR looks like.
  • Use StrictMode during development to catch accidental disk or network access on the main thread before it ships.

If you do ship an ANR, Android vitals in Play Console and ApplicationExitInfo on API 30 and up will tell you which one hit users and how often, which is usually where a real investigation starts.

Go deeper: every Concurrency & Threading question

Networking

The questions asked most often in this area.

What is the difference between Retrofit and OkHttp, and what is the role of each?

Tier: EssentialDifficulty: Easy

OkHttp is the HTTP client that actually talks to the network, and Retrofit is a layer on top of it that turns your API into a Kotlin interface instead of hand-built requests.

  • OkHttp owns the connection. It opens sockets, handles TLS, follows redirects, retries on connection failure, and applies interceptors and caching. If you dropped Retrofit entirely, you could still make every network call OkHttp gives you directly with Request and Call objects, it's just verbose to do for a large API surface.
  • Retrofit owns the mapping. You declare an interface with annotated methods, @GET, @POST, @Body, and Retrofit generates the code that builds the right OkHttp request from your method call and converts the response body into the Kotlin type you asked for.
interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): User
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(MoshiConverterFactory.create())
    .build()

Retrofit needs an OkHttpClient to actually run, you can see that in the builder above, it's a required dependency, not an alternative. So the practical way to think about it, OkHttp is the transport layer, Retrofit is the API layer built on it. Anything about how the request goes over the wire, timeouts, headers, caching, interceptors, is configured on the OkHttpClient. Anything about what the request looks like as a Kotlin function call, endpoints, parameters, response types, is configured on the Retrofit interface.

Compare HTTP Request vs HTTP Long-Polling vs WebSocket vs Server-Sent Events (REST vs WebSockets).

Tier: EssentialDifficulty: Medium

These are four ways a client and server exchange data, and the difference is who initiates the exchange and whether the connection stays open.

  • Plain HTTP request. Client opens a connection, sends a request, server responds, connection closes. Good for one-off data like loading a profile. No way to push new data without the client asking again.
  • Polling. The client just repeats a plain HTTP request on a timer, every few seconds, to check for updates. Simple to build, but it wastes requests when nothing has changed, delays updates by up to the polling interval, and drains battery from the constant wakeups.
  • Long-polling. The client sends a request and the server holds it open, not responding until it actually has new data or a timeout hits. The client immediately reopens the request once it gets a response. This cuts down on empty responses compared to polling and delivers data close to real time, but you still pay reconnection overhead on every cycle.
  • WebSocket. After one handshake, the connection stays open and both sides can send data whenever they want, in either direction. This is the only one of the four that's truly bidirectional, which makes it the right fit for chat apps or anything with continuous two-way traffic.
  • Server-Sent Events. Like WebSocket in that the connection stays open, but one-way, server to client only. Simpler to implement than a WebSocket when the client never needs to push data back, good for something like a live stock ticker or streaming tokens from an LLM.

The interview framing of "REST vs WebSockets" comes down to the same tradeoff. REST is simple, cacheable, and fine for anything request-driven. WebSocket costs more to set up and to keep alive but is the only option when the server needs to push data the moment it changes, without the client asking first.

Go deeper: every Networking question

Persistence & Data

The questions asked most often in this area.

Have you used Room? Explain it.

Tier: EssentialDifficulty: Easy

Room is Jetpack's persistence library, an ORM layer over SQLite that gives you compile-time-checked SQL and generated boilerplate instead of hand-written Cursor code.

It's built from three pieces.

  • Entity. A data class annotated @Entity that maps to a table, one property per column.
  • DAO. An interface annotated @Dao where you declare queries as methods, Room generates the implementation at compile time.
  • Database. An abstract class annotated @Database that ties the entities and DAOs together and is your handle to the actual SQLite file.
@Entity
data class User(@PrimaryKey val id: String, val name: String)

@Dao
interface UserDao {
    @Query("SELECT * FROM User WHERE id = :id")
    suspend fun getUser(id: String): User

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)
}

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

What Room actually buys you over plain SQLite. It checks every @Query against your schema at compile time, so a typo in a column name fails the build instead of crashing at runtime. It maps Cursor rows to your data classes automatically. It supports suspend functions and Flow return types natively, so a DAO method can just emit a new list every time the underlying table changes, no manual observer wiring. And it comes with a migration system for versioning the schema across app updates, which is the part people underestimate until they ship a schema change without one and crash on every upgrading user's device.

Read more Save data in a local database using Room (opens in a new tab)

What is the difference between commit() and apply() in SharedPreferences?

Tier: EssentialDifficulty: Easy

commit() writes to disk synchronously and blocks until it's done, apply() updates the in-memory value immediately and writes to disk asynchronously in the background.

sharedPreferences.edit()
    .putString("token", token)
    .apply() // returns immediately, safe to call from the main thread

The practical differences.

  • Return value. commit() returns a Boolean telling you whether the write succeeded. apply() returns nothing, you get no confirmation the disk write finished.
  • Thread safety for the caller. commit() blocks the calling thread until the write completes, which can jank the UI if called on the main thread. apply() returns immediately since the disk write happens off-thread.
  • Ordering guarantee. apply() still guarantees that if you apply() and immediately commit() on the same file, the commit waits for any pending apply() writes to finish first, so reads stay consistent even though the write itself is async.

The rule of thumb is to default to apply(), since almost nothing needs to block on the write finishing, and reach for commit() only in the rare case where you genuinely need the return value or need to guarantee the write landed before the very next line of code runs, like right before the process might be killed.

Read more SharedPreferences.Editor (opens in a new tab)

What is the difference between Serializable and Parcelable? What are the disadvantages of Serializable and which is best in Android?

Tier: EssentialDifficulty: Medium

Parcelable is Android's own interface for passing objects between components, and it's the one you should use, Serializable is the general Java interface and it's noticeably slower and heavier on Android.

// Serializable, simplest to write
data class Developer(val name: String, val age: Int) : Serializable

// Parcelable, the recommended approach
@Parcelize
data class Developer(val name: String, val age: Int) : Parcelable

The disadvantages of Serializable come down to how it works internally.

  • It uses reflection to figure out an object's fields at runtime, which is slow compared to code that already knows the shape of the object at compile time.
  • It creates a lot of temporary objects during the serialization process, which adds memory churn and garbage collection pressure right when you're trying to pass data between an Activity and a Fragment quickly.
  • It has no control over the format, since it's a generic Java mechanism not built with Android's Bundle transport in mind.

Parcelable avoids all of that. It doesn't use reflection, since with @Parcelize the compiler generates the exact read and write code for your fields ahead of time. It creates far fewer temporary objects, and it's built specifically for Android's IPC path through Bundle and Parcel, which is the mechanism Activities and Fragments actually use to pass data.

So the answer to "which is best" is Parcelable, and with the @Parcelize compiler plugin the boilerplate that used to be the main argument for reaching for Serializable instead is gone, you get the annotation and nothing else to write by hand.

Read more Parcelable implementation generator (opens in a new tab)

Go deeper: every Persistence & Data question

Performance & Memory

The questions asked most often in this area.

Explain hot, warm and cold app starts in Android.

Tier: EssentialDifficulty: Medium

The three start types are really just how much work the system has to redo, and that's set by whether your process already exists and whether your activity already exists inside it.

  • A cold start is the slowest and most expensive. Your process isn't running at all, so the system has to create it, initialize Application, then create and start the launching activity from scratch, running the full onCreate() through onResume() sequence.
  • A warm start skips process creation, your process is still alive, but the activity itself was destroyed, maybe by a configuration change or low memory reclaiming it, so the system still has to recreate and reinflate it, cheaper than cold but not free.
  • A hot start is the fastest by far. Both the process and the activity are still alive in memory, the system just has to bring it back to the foreground, so you get onRestart(), onStart(), and onResume() with no recreation at all.

Cold start is the one that matters most for interviews and for real product metrics, because it's what a new user experiences on first install, and what Android Vitals in the Play Console specifically measures and can flag your app for if it's too slow. The direct fix for it is a Baseline Profile, which gets ART to compile your app's critical startup path ahead of time instead of interpreting it cold, plus keeping Application.onCreate() lean, anything that isn't strictly required before the first frame renders shouldn't be running there.

Read more App startup time (opens in a new tab)

How do you find memory leaks in Android applications?

Tier: EssentialDifficulty: Medium

You find a memory leak by watching whether an object you expect to be destroyed sticks around, and the tool that makes this automatic in practice is LeakCanary. Drop it into a debug build, and it hooks into Activity.onDestroy() and Fragment.onDestroyView(), waits for garbage collection, and if the object is still in memory when it shouldn't be, it walks the reference chain and shows you exactly what's holding it.

debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")

That reference chain is the actual diagnostic value, it doesn't just say something leaked, it prints the path from a garbage collection root down to the leaked object, which usually points straight at the bug, a static field, a listener never unregistered, an anonymous inner class holding an implicit outer reference.

  • Rotate the screen or navigate away from a screen repeatedly, then watch whether memory climbs and never comes back down, that's the manual version of what LeakCanary automates.
  • The Android Studio Memory Profiler lets you force a garbage collection and capture a heap dump, then search for instances of a specific Activity or Fragment class, if you find more than one alive when only one should exist, you have a leak.
  • Trigger the leak deliberately in a test, some teams write an instrumented test that navigates through a screen and asserts the Activity was actually garbage collected afterward, catching a regression before it ships.

The manual heap dump route works, but it's slow and easy to miss, which is exactly why LeakCanary is the answer most working Android teams actually give, automated detection during normal QA and development, not a special investigation you have to remember to run.

How do you improve app startup performance?

Tier: EssentialDifficulty: Medium

Startup work is measure first, then cut work out of the path between process creation and the first frame. Doing it the other way round is how you optimize the thing that was never slow.

First, know what number you are talking about.

  • Cold, warm and hot. Cold is a fresh process, warm reuses a live process, hot just brings an existing activity forward. Cold start is the one interviewers mean.
  • TTID and TTFD. Time to initial display is the first frame. Time to full display is when the screen is genuinely usable, and you report it by calling reportFullyDrawn().
  • The thresholds. Android vitals treats a cold start of 5 seconds or more as excessive, warm at 2 seconds, hot at 1.5 seconds.
  • Measure repeatably. A Macrobenchmark test with StartupTimingMetric gives a number you can compare across builds.
  • Then look at a trace. A Perfetto system trace shows which method or which thread actually ate the time.

Next, get work out of Application.onCreate().

  • Do less, eagerly. Anything not needed before the first frame moves out of eager init and runs later.
  • Use the App Startup library. It runs initializers in dependency order behind one content provider, instead of every library adding one of its own.
  • Drop automatic init you do not need. A library that installs its own content provider is doing work before your code runs at all.
  • Never touch disk or network on the main thread here. Turn StrictMode on in debug builds so a violation shows up long before it ships.

Then let ART do less work at launch.

  • Ship a Baseline Profile. ART compiles your hot startup path ahead of time instead of interpreting it cold on first launch. This is usually the single biggest win.
  • Add a Startup Profile. It reorders the dex layout so startup classes sit together and load faster.
  • Keep the dex lean. R8 shrinking and fewer classes on the startup path both cut class loading time.

Then keep the first screen cheap.

  • Keep the launch activity light. A deep view hierarchy or heavy Compose work before the first frame is the usual cause of a bad number.
  • Use the splash screen API. androidx.core.splashscreen shows something immediately, where a dedicated splash activity adds a whole extra transition.
  • Defer the rest. Push non critical work to after the first frame with a lifecycle observer or a post on the main looper.

Finally, watch the dependency graph.

  • Make injection lazy. Inject Lazy or Provider in Hilt for anything the first screen does not need.
  • Watch reflection heavy libraries. Some do real setup at process start, and a trace will name them.
  • Hand the rest to WorkManager. Work that does not need to finish before the user sees a screen should not run inline.
class AnalyticsInitializer : Initializer<Analytics> {
    override fun create(context: Context): Analytics {
        // Runs in dependency order alongside every other startup initializer,
        // behind one content provider instead of one provider per library.
        return Analytics.init(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

Open with the Baseline Profile and a lean Application.onCreate(), those two are what most interviewers want to hear first. Then bring in Macrobenchmark and the splash screen API, so it is clear you would measure the fix rather than guess at it.

Read more App startup time (opens in a new tab)Baseline Profiles overview (opens in a new tab)

How do you reduce battery usage in an Android application?

Tier: EssentialDifficulty: Medium

Battery work comes down to two things, waking the device less often, and doing the unavoidable work while the CPU, the radio or the GPS is already up for something else. Everything below is a way of doing one of those, and the order matters, because you measure before you change anything.

Measure first, or you will optimise the wrong thing.

  • The Power Profiler in Android Studio. Record a system trace and read the Power Rails track, which shows the on device power monitor broken out per subsystem, cellular, display, GPS, WLAN, CPU and GPU. It is device level rather than app level, so use it as an A and B test between two builds of your own.
  • Battery Historian. The classic tool for turning a batterystats dump into a timeline of what woke the device. Google no longer actively maintains it and now points at system tracing, the Power Profiler and the Macrobenchmark power metric instead, so name it as the thing you know and say what replaced it.
  • Android vitals in the Play Console. It flags excessive wakeups, stuck partial wake locks and excessive background Wi-Fi scans across real installs. That is field data from users, which no local profiling run gives you.
  • The battery screen in system settings. Per app usage over the last day, the same number the user sees before they uninstall you. It is crude and it is the one that gets you a one star review.

Background work is where most of the damage is.

  • WorkManager with constraints. Deferrable work should say what it needs, an unmetered network, charging, battery not low, device idle, and let the system pick the moment. Constrained work batches with everyone else's instead of waking the device on your own schedule.
  • Expedited work for the rare urgent case. setExpedited() is for short, user initiated things like sending a chat message or completing a payment. It runs against a quota tied to your standby bucket, so it is an exception, not a default.
  • Batch and defer. Collect analytics events and flush them in one go. Prefetch on Wi-Fi and charging. Five requests sent together cost far less than five spread across an hour, because each one pays to bring the radio out of idle.
  • Exact alarms are restricted now. From Android 12 an exact alarm needs SCHEDULE_EXACT_ALARM, and from Android 14 that permission is not pre granted to new installs, so you have to check canScheduleExactAlarms() and fall back. USE_EXACT_ALARM is auto granted but reserved for alarm clock and calendar style apps. If your work is not clock accurate by nature, it belongs in WorkManager.
  • Doze. Once the device is unplugged, still and screen off, network access is suspended, wake locks are ignored, jobs and syncs are frozen, and alarms are deferred to a maintenance window. Fighting it does not work, setExactAndAllowWhileIdle() still cannot fire more than once every nine minutes.
  • App Standby buckets. The system sorts your app into active, working set, frequent, rare or restricted based on how the user actually uses it, and each step down throttles jobs and alarms harder. In the restricted bucket you get roughly one alarm and one batched job window a day. The fix is not a trick, it is being an app the user opens, and never spamming notifications to farm promotion.
  • Battery optimisation exemptions are a last resort. Asking the user to exempt you is allowed only when Doze genuinely breaks a core function, a safety app or a companion device connection. If FCM can do the job, Play policy says use FCM.

Location is the single most expensive API most apps touch.

  • Use the fused location provider, not the raw framework API. It blends GPS, Wi-Fi, cell and the motion sensors, which is both more accurate and cheaper than driving GPS yourself.
  • Pick the lowest priority that works. PRIORITY_BALANCED_POWER_ACCURACY is the right default and rarely touches GPS. PRIORITY_HIGH_ACCURACY is for a map on screen with the user watching it. PRIORITY_LOW_POWER gives city level accuracy for almost nothing.
  • Ask for the longest interval you can live with. Pass the largest value into setIntervalMillis(), and set setMaxUpdateDelayMillis() several times larger so the system batches updates and delivers them together instead of waking you each time.
  • Geofences instead of polling. If you only care about arriving somewhere, register a geofence and let the platform tell you. Set the notification responsiveness to five minutes or more, which is a large power win for a small latency cost.
  • Passive location. PRIORITY_PASSIVE piggybacks on locations other apps already requested, so it costs you nothing extra.
  • Foreground service only while it is genuinely needed. A tracking session runs in a foreground service with the location type and its FOREGROUND_SERVICE_LOCATION permission, and stops the moment the trip ends. Continuous background tracking also needs ACCESS_BACKGROUND_LOCATION, which on Android 11 and up the user has to grant from settings, so most apps should not want it.

Network is the other big radio consumer.

  • Batch and compress. Fewer, larger, gzip compressed requests beat many small ones. Keep payloads small so the radio spends less time at full power.
  • Never poll on a keep alive timer. A background poll every few minutes is the classic drain. Let the server tell you instead.
  • Prefer FCM to wake the app. A high priority message gets you temporary network access and a wake lock even in Doze, and it costs you nothing while nothing is happening, because the OS owns the one connection for every app on the device.
  • Defer big transfers to unmetered and charging. Video prefetch, model downloads and backups are exactly what NetworkType.UNMETERED plus setRequiresCharging(true) exist for.
  • Back off exponentially on failure. A tight retry loop against a dead endpoint will flatten a battery in an afternoon. Add jitter so your whole install base does not retry in lockstep.

Wake locks and sensors are the classic interview follow up.

  • Avoid a partial wake lock if you possibly can. A wake lock held past the operation that needed it is the most direct battery bug there is, and Android vitals reports it by name. WorkManager and a foreground service both keep the CPU up for you without you owning a lock.
  • If you must hold one, bound it. Acquire it around the exact operation, release it in a finally, and use a timeout so a crashed code path cannot leave it held.
  • Batch sensor delivery. Pass a large maxReportLatencyUs to registerListener() so the sensor hub buffers samples and wakes the application processor once instead of continuously.
  • Unregister on the way out. Sensors, location updates, camera and Bluetooth scans all get released in onPause() or onStop(), not in onDestroy(), because a backgrounded activity may never see onDestroy().

Rendering costs real power too, and candidates usually forget it.

  • Dark theme on OLED. Black pixels are unlit pixels, so a dark surface measurably reduces display draw on OLED panels, and the display is often the largest single consumer on the device.
  • Cut overdraw. Every pixel painted more than once is GPU work with no visual result. Remove redundant backgrounds and flatten the hierarchy.
  • Stop animations nothing can see. Pause looping animations, video and Lottie when the view scrolls off screen or the app goes to the background.
  • Drop the frame rate for static content. A page of text does not need 120Hz, and asking for a lower rate on a mostly still screen saves both GPU and display power.

Finally, react to the device getting hot.

  • The thermal API. PowerManager.getCurrentThermalStatus() and addThermalStatusListener() report THERMAL_STATUS_LIGHT through THERMAL_STATUS_SEVERE and beyond. At moderate and above, back off, lower video quality, reduce frame rate, pause background sync, because the system is already throttling the CPU and burning power for no throughput.
// Deferrable work states its conditions and lets the platform choose the time.
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)   // Wi-Fi only
    .setRequiresCharging(true)
    .setRequiresBatteryNotLow(true)
    .build()

val prefetch = OneTimeWorkRequestBuilder<PrefetchWorker>()
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

WorkManager.getInstance(context).enqueue(prefetch)

In the room, lead with the process rather than the list. Say you measure first with a system trace and Android vitals, find the biggest wake up source, and fix that one, because battery is dominated by a handful of causes and the rest is noise. Then name the three levers that cover most real apps, WorkManager with constraints instead of your own alarms, the cheapest location priority with the longest interval you can tolerate, and FCM instead of any background polling. That is the answer, and the depth follows from whichever one the interviewer pulls on.

Read more Optimize for Doze and App Standby (opens in a new tab)App Standby Buckets (opens in a new tab)About background location and battery life (opens in a new tab)

How does garbage collection work?

Tier: EssentialDifficulty: Medium

Garbage collection is ART automatically reclaiming heap memory held by objects nothing can reach anymore, so you never call free() yourself. It works by tracing reachability from a set of roots, static fields, local variables on the stack, active threads, and anything still connected to one of those roots is live. Anything not connected is garbage, and its memory gets reclaimed.

ART's collector is generational and mostly concurrent, which is what makes it practical on a phone.

  • Most objects die young, a temporary object created inside a function and discarded almost immediately, so new allocations go into a smaller, frequently collected young generation, which is fast to sweep because most of it is already garbage by the time it runs.
  • An object that survives several young collections gets promoted to the old generation, collected less often since long lived objects are less likely to have become garbage.
  • The collector runs concurrently with your app on a separate thread for most of its work, so it doesn't have to fully stop the app the way an old style stop the world collector would. It still needs brief pauses to keep the heap consistent while your threads run, but they're short compared to older Dalvik era collection.

The reason this comes up in performance interviews isn't the algorithm, it's the consequence. A collection that runs during a scroll or an animation is a visible stutter, because collection work is competing with your app for CPU time even when it's mostly concurrent. That's why reducing allocation churn in a hot path, like inside onBindViewHolder() or an animation frame, matters more than knowing GC internals, fewer objects created means fewer and shorter collections.

What is the difference between a memory leak and an Out of Memory (OOM) error? Elaborate on memory leaks.

Tier: EssentialDifficulty: Medium

A memory leak is an object that should have been garbage collected but is still reachable, quietly holding memory hostage. An Out of Memory error is the crash that happens when the app tries to allocate more memory than the system will give it. A leak is a slow cause, an OOM is the eventual effect, though plenty of OOMs happen with no leak involved at all, just a genuinely large allocation like a full resolution bitmap.

The mechanism behind a leak is always the same shape. Something with a long lifetime, a static field, a singleton, a running thread, holds a reference to something with a short lifetime, usually an Activity or a View, past the point where that short lived object should have died. The garbage collector can't reclaim an object as long as something still references it, so it just sits in memory.

The usual suspects in Android are specific enough to be worth naming directly.

  • A static field or singleton holding a Context, especially an Activity Context, past its onDestroy().
  • A listener or callback registered on a long lived object, like a location or sensor manager, and never unregistered.
  • An inner class or anonymous class, including a Handler with pending messages, that implicitly holds a reference to its outer Activity or Fragment.
  • A running coroutine or RxJava subscription tied to a scope that outlives the screen, instead of one tied to viewModelScope or viewLifecycleOwner.

The fix in each case is the same idea, don't let something short lived get referenced by something long lived without an explicit cleanup path, unregister listeners in onDestroy() or onDestroyView(), use applicationContext for anything that truly needs to outlive a screen, and let a lifecycle aware scope cancel your coroutines for you. In practice, LeakCanary is how most teams actually catch these before they ship.

Go deeper: every Performance & Memory question

Testing

The questions asked most often in this area.

How do you unit test a ViewModel with Kotlin Coroutines and LiveData?

Tier: EssentialDifficulty: Medium

Testing a ViewModel that mixes coroutines and LiveData needs two pieces of test infrastructure, one to make coroutines run synchronously and predictably, one to make LiveData work without the real main looper.

  • InstantTaskExecutorRule from androidx.arch.core:core-testing makes LiveData post its updates immediately instead of through the real Android main thread scheduler, which doesn't exist on a JVM test.
  • A TestDispatcher from kotlinx-coroutines-test, injected wherever the ViewModel launches coroutines, replaces Dispatchers.Main so viewModelScope.launch executes on the test thread instead of failing with no main looper available.
  • runTest replaces runBlocking as the coroutine builder for the test body itself, and it fast forwards any virtual time your code delays on.
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()
    private val testDispatcher = StandardTestDispatcher()

    @Before
    fun setUp() = Dispatchers.setMain(testDispatcher)

    @After
    fun tearDown() = Dispatchers.resetMain()

    @Test
    fun `fetch success updates state to Success`() = runTest {
        val repository = mock<UserRepository>()
        whenever(repository.getUsers()).thenReturn(emptyList())
        val viewModel = UserViewModel(repository)

        viewModel.fetchUsers()
        advanceUntilIdle()

        assertEquals(UiState.Success(emptyList()), viewModel.state.value)
    }
}

Because LiveData.observeForever() doesn't suspend, the test has to advance the dispatcher itself, advanceUntilIdle() runs every pending coroutine to completion before the assertion checks the final state. Skip that call and you're asserting on a state the coroutine hasn't reached yet, which is the single most common way this style of test flakes.

How do you unit test a ViewModel with Kotlin Flow and StateFlow?

Tier: EssentialDifficulty: Medium

A ViewModel built on StateFlow tests more cleanly than one built on LiveData, because StateFlow is just a coroutine primitive, no InstantTaskExecutorRule or main looper workaround needed, only a TestDispatcher for Dispatchers.Main and a way to collect emissions.

Turbine is that collection tool. It turns a flow into a small sequential API inside a test, awaitItem() for the next emission, awaitComplete() when it finishes, cancelAndIgnoreRemainingEvents() when you're done asserting and want to stop collecting a flow that never completes on its own, like a StateFlow.

@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
    private val testDispatcher = StandardTestDispatcher()

    @Before
    fun setUp() = Dispatchers.setMain(testDispatcher)

    @After
    fun tearDown() = Dispatchers.resetMain()

    @Test
    fun `fetch success emits Success state`() = runTest {
        val repository = mock<UserRepository>()
        whenever(repository.getUsers()).thenReturn(flowOf(emptyList()))
        val viewModel = UserViewModel(repository)

        viewModel.state.test {
            assertEquals(UiState.Loading, awaitItem())
            viewModel.fetchUsers()
            assertEquals(UiState.Success(emptyList()), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
}

The detail that trips people up is StateFlow conflation, it only ever holds the latest value, and it drops intermediate emissions a slow collector missed, so if your ViewModel flips through several states quickly, Turbine's awaitItem() calls need to match what actually reaches a collector, not necessarily every state the ViewModel briefly passed through. Reach for UnconfinedTestDispatcher instead of StandardTestDispatcher when you want coroutines to run eagerly without explicit advanceUntilIdle() calls, useful when a test only cares about the final emitted state.

How do you write Compose UI tests?

Tier: EssentialDifficulty: Medium

You add a compose test rule, set the composable as content, then find a node, act on it, and assert on the result. There is no view hierarchy to query, so every test goes through the semantics tree instead, which is the parallel tree Compose builds to describe what the UI means rather than how it looks.

That semantics tree is the whole mental model. Accessibility services read it, and the test framework reads the same thing, so a screen that is easy to test is usually a screen that is easy to use with TalkBack.

The two rules

  • createComposeRule() hosts your composable in a bare activity that the test framework supplies. Use it whenever you are testing a composable in isolation, which should be most of the time, because there is no real activity to launch and no navigation graph in the way.
  • createAndroidComposeRule<MyActivity>() launches a real activity, so you get the activity, its intent extras, and anything the activity itself sets up. Reach for it only when the test genuinely needs the activity, for example a flow that crosses into a fragment or reads a deep link.
  • setContent is where you install the UI, and it is called once per test. Wrap the composable in your theme, because a missing theme changes colours and sizes and makes screenshot comparisons meaningless.

Finders, assertions and actions

  • Finders. onNodeWithText, onNodeWithTag, onNodeWithContentDescription, and onAllNodesWithTag when you expect several. onNode(matcher) composes matchers with and and or when a single attribute is not specific enough.
  • Assertions. assertIsDisplayed, assertIsEnabled, assertIsSelected, assertTextEquals, assertCountEquals on a collection, and assertDoesNotExist for the negative case, which is the one people forget.
  • Actions. performClick, performTextInput, performTextClearance, performScrollTo, performScrollToIndex on a lazy list, and performSemanticsAction for anything custom.

testTag versus content description

  • Content description first, when the element needs one anyway. An icon button, an image that carries meaning. Finding it by content description tests the accessibility label at the same time, so one line does two jobs.
  • Text next, for anything a user actually reads. Finding a button by its label is the closest a test gets to describing what a person sees, and it fails honestly when the copy changes in a way that matters.
  • Modifier.testTag("...") last, as an escape hatch. Use it for a container, a list, a chart, anything with no natural label, or when the visible text is dynamic and the test would be asserting on data rather than structure. A tag is invisible to users, so it proves nothing about accessibility, and a screen tagged everywhere is a screen nobody checked with TalkBack.

Synchronisation

  • Compose tests wait for idle automatically. Before every assertion and action, the rule drains the compose clock and the recomposition queue, so you do not need a sleep. This is why a well written Compose test is far less flaky than the equivalent view test.
  • The waiting only covers work Compose knows about. A coroutine on a background dispatcher, a real network call, an animation driven by something outside the composition, none of that is in the idling contract, which is exactly the same trap Espresso has with idling resources, described in what is Espresso.
  • waitUntil is the tool for asynchronous state. It polls a condition with a timeout, so you can wait for a node to appear after a fake repository resolves. Give it a real condition to check, never a bare timeout, and keep the timeout tight so a genuine failure fails fast.
  • mainClock lets you pause and advance the compose clock by hand, which is how you assert on a specific frame of an animation instead of waiting for it to settle.
class LoginScreenTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun `an invalid email shows the error and keeps submit disabled`() {
        val viewModel = LoginViewModel(FakeAuthRepository())
        rule.setContent { AppTheme { LoginScreen(viewModel) } }

        rule.onNodeWithText("Email").performTextInput("not-an-email")
        rule.onNodeWithTag("submit").performClick()

        // Waits for the fake to resolve rather than sleeping for a fixed time.
        rule.waitUntil(timeoutMillis = 2_000) {
            rule.onAllNodesWithText("Enter a valid email")
                .fetchSemanticsNodes().isNotEmpty()
        }
        rule.onNodeWithTag("submit").assertIsNotEnabled()

        // When a finder returns nothing, print the tree and look at it.
        rule.onRoot(useUnmergedTree = true).printToLog("LoginScreenTest")
    }
}

Wiring up the dependencies

  • A fake ViewModel or plain state is the default. Construct the real ViewModel with fake repositories, or better, make the composable take a state object and a lambda so the test can pass state directly and assert on what the lambda received. That version needs no ViewModel at all.
  • A Hilt test when the graph really is the point. Annotate with @HiltAndroidTest, add HiltAndroidRule at order = 0 and the compose rule at order = 1, run through a custom runner that installs HiltTestApplication, and swap bindings with @BindValue or a @TestInstallIn module. Rule ordering is the part people get wrong, Hilt has to inject before the activity starts.

The unmerged tree trap

By default the test framework reads the merged semantics tree, where a Button containing an icon and a label collapses into one node carrying the merged properties. That is usually what you want, onNodeWithText("Like").performClick() finds the button rather than the text inside it. It is also why a finder aimed at a child fails with a node not found error even though you can see the thing on screen. Passing useUnmergedTree = true keeps every node separate so you can reach the child, and the honest first move whenever a finder misses is onRoot(useUnmergedTree = true).printToLog(tag), which dumps the whole tree to logcat with every property on every node.

In the room, say semantics tree in the first sentence, because that is the word that separates someone who has written these tests from someone who has read about them. Then name the rule you use and why, describe find, act, assert in one breath, and finish on synchronisation, that Compose waits for idle for you but only for work inside the composition, so anything asynchronous outside it needs waitUntil.

Read more Test your Compose layout (opens in a new tab)Testing cheatsheet (opens in a new tab)Semantics (opens in a new tab)

What is your testing strategy for an Android app?

Tier: EssentialDifficulty: Medium

A pyramid, wide at the bottom and narrow at the top. Most of my tests are plain JVM tests that run in seconds on every commit, a thinner band of Robolectric and Compose tests sits above them, and a small set of real device tests at the top proves the critical journeys still work on an actual phone.

Google's own testing guidance now splits that pyramid into five layers, unit, component, feature, application and release candidate, and the one idea worth carrying out of it is that you always pick the lowest layer that still gives honest feedback. A bug caught by a unit test costs minutes. The same bug caught by an end to end test costs a day, and in production it costs weeks.

What goes in each layer

  • Local JVM unit tests, the base. ViewModels, use cases, mappers, validators, and repositories wired to fakes. These live in src/test/, need no emulator, and are where the bulk of my logic coverage sits. If a class needs Android to be tested, that is usually a design smell rather than a reason to move up a layer.
  • Robolectric, the fast middle. Code that genuinely touches the framework but does not need a real device, resource lookups, SharedPreferences round trips, a Parcelable, simple view inflation. It buys you Android APIs at JVM speed, at the cost of a simulation that can drift from the real OS. The caveats are in what are the disadvantages of using Robolectric.
  • Compose UI tests. One per screen, driving the real composable with a fake ViewModel or a hand built state object, asserting the states that matter, loading, empty, error, content. Written once, they run either on a device or under Robolectric, which is why they are cheap enough to have a lot of. The detail is in how do you write Compose UI tests.
  • Screenshot tests, the cheap UI regression layer. They catch what assertions never do, a broken margin, a colour that lost contrast, text clipped at a large font scale. One test asserts a hundred things about a screen at once. See what is screenshot testing.
  • Device backed integration tests. Room DAOs against a real in memory database, WorkManager workers, anything talking to a real system service. SQLite behaves differently enough that I want a real one under my queries and migrations.
  • End to end journeys, the tip. Three or four flows, sign in, the main task the app exists for, checkout or submit. They run on a device against fakes or a staging server, they are slow, and they are worth it only for the paths where a failure is a company problem.

The network layer

  • Fake the boundary, not the client. My repository takes an interface, and tests inject a fake that returns canned domain objects. That is where nine tests out of ten belong.
  • Keep a small contract test against the real parser. A MockWebServer style test that feeds a recorded JSON payload through the actual Retrofit and serialization setup, so a renamed field on the server side fails a test rather than a screen. This is the one place I want the real client in the loop.
  • Never hit the live API from a test. A test that depends on someone else's uptime is not a test, it is a monitor.

Fakes over mocks

  • A fake is a working implementation. An in memory repository backed by a map, an interface you wrote and can call for real. Google's own guidance names fakes as the preferred double, because they need no framework, they are lighter, and they read like the production object.
  • A mock asserts on interactions. It tells you a method was called, not that the behaviour is correct, so a suite full of verify calls locks in the shape of the code and breaks on every refactor even when nothing is broken.
  • The practical rule. Reach for a mock when the interaction really is the thing under test, like proving an analytics event fires exactly once. Everything else gets a fake.
// A fake is production shaped, so a refactor of the interface breaks it honestly.
class FakeUserRepository : UserRepository {
    var users = listOf(User("alice"))
    var failNext = false

    override suspend fun load(): Result<List<User>> =
        if (failNext) Result.failure(IOException()) else Result.success(users)
}

What I do not test

  • Framework code and libraries. Room's own SQL generation, Retrofit's own HTTP handling, the Compose runtime. Somebody already tests those.
  • Trivial getters, generated code, and data class equality. Nothing to break, so the test is pure maintenance cost.
  • Every visual permutation. Screenshot tests are chosen cases, not every combination of theme, font scale and screen size, or you drown in reference images.
  • The number itself. Coverage is a smoke alarm, not a target, which is the point made in describe code coverage.

The ratio, and the signal each layer gives

I aim for roughly seventy percent local unit tests, twenty percent Robolectric and Compose tests, and ten percent on a real device, and I care far more about the shape than the exact split. The base tells me a function is wrong. The middle tells me a screen renders the wrong state. The top tells me the app is broken. When the pyramid inverts and the device tests carry the coverage, the suite gets slow, flaky, and people start ignoring it, which is worse than having no suite at all.

How this becomes a gate

  • Every commit. ./gradlew test for the unit and Robolectric layers, plus lint and the screenshot verification task. Minutes, not tens of minutes.
  • Every pull request. The same, plus the Compose and instrumented tests for the modules the change actually touched, on a build managed emulator. Required to merge.
  • Nightly and pre release. The full instrumented suite across a device matrix, and the end to end journeys against a release build with R8 on, because that is the build users get.
  • No green, no merge. The gate only works if it is enforced by the branch rule rather than by good intentions, and if a flake gets quarantined the same day rather than retried forever.

In the room, lead with the pyramid and the reason for it, speed of feedback, then name what you actually put in each layer on your app. The thing an interviewer is listening for is whether you have a rule for deciding where a test goes, and whether the suite is wired into CI as a gate. Say fakes over mocks and say why, and if there is time, say what you deliberately do not test, because knowing where to stop is the part most candidates leave out.

Read more Testing strategies (opens in a new tab)Use test doubles in Android (opens in a new tab)

Go deeper: every Testing question

Build, Tooling & Release

The questions asked most often in this area.

How do you reduce APK size?

Tier: EssentialDifficulty: Medium

App size is four separate jobs, what Play delivers, what your code compiles down to, what resources ship, and how you stop it creeping back up. A strong answer names something from each.

Start with delivery, because that is where the biggest single win sits.

  • Ship an app bundle. Upload an .aab instead of a universal APK. Play builds an optimized APK per device from it.
  • Let the splits do the work. A bundle splits by density, ABI and language by default, so a user downloads only their own slice.
  • Play Feature Delivery. Put rarely used modules behind on demand delivery. A debug screen or an export flow does not belong in every install.
  • Play Asset Delivery. Large media and game assets ship as asset packs instead of riding inside the base install.

Then the code.

  • Turn R8 on. isMinifyEnabled = true shrinks unreachable code, obfuscates what is left and optimizes bytecode in one pass.
  • Full mode is already the default. It has been on by default since AGP 8.0, so the question is whether your keep rules are too broad, not whether it is enabled.
  • Cut dependencies. Drop a heavy library pulled in for one helper, and prefer the focused one over the general purpose one.
  • Watch enums and generated code. Every enum costs real dex weight, so @IntDef is cheaper in code that ships everywhere. Generated bindings add up the same way.
  • Know what desugaring costs. Core library desugaring backports classes into your dex, so wider API reach is paid for in size.

Then resources, usually the fattest part of a real app.

  • Shrink resources. isShrinkResources = true drops resources nothing references, and lint finds the ones it cannot see.
  • Prefer WebP. WebP beats PNG and JPEG at the same quality. AVIF is smaller again but needs Android 12 or higher, so check your minSdk.
  • Vectors for icons. One VectorDrawable replaces the same PNG sitting in every density bucket.
  • Limit the locales. Use localeFilters in the androidResources block so untranslated languages do not ride along. It replaced resourceConfigurations, which AGP deprecated in 8.8.
  • Do not fill every density bucket. Ship xxhdpi and let the platform scale, unless a specific bucket genuinely looks wrong.

Native code is its own axis.

  • One ABI per download. The bundle splits .so files by ABI, so an arm64 device never carries armeabi-v7a as well.
  • Strip release binaries. Debug symbols in a shipped .so are pure weight, so strip them in the release build.
  • Leave useLegacyPackaging false. Native libraries then map straight out of the APK instead of being extracted into a second copy on disk.

Assets and fonts are the easy thing to forget.

  • Subset custom fonts. Ship the glyphs the app actually draws, not the whole family.
  • Download the big stuff. Video, large illustrations and sample content come down on first use.

Finally, make it stick.

  • APK Analyzer. Open the built artifact in Android Studio and see what is really taking the space.
  • Play Console size report. It shows download and install size per device configuration, which is the number users feel.
  • A budget in CI. Fail the build when the release artifact grows past a threshold, so a regression gets caught in review.
android {
    buildTypes {
        release {
            // AGP 9.3 and newer also expose optimization { enable = true }.
            isMinifyEnabled = true
            isShrinkResources = true
        }
    }
    androidResources {
        // Replaces the deprecated resourceConfigurations.
        localeFilters += listOf("en", "es", "fr")
    }
}

Open with the app bundle plus R8, those two do most of the work on a modern release. Then layer in feature delivery, resource cleanup and a CI size budget, which is the depth the interviewer is actually probing for.

Read more Reduce your app size (opens in a new tab)Shrink, obfuscate, and optimize your app (opens in a new tab)

How do you set up a CI/CD pipeline for Android?

Tier: EssentialDifficulty: Medium

A CI/CD pipeline for Android is a fixed set of stages that runs the same way for every change, so nothing ever ships from somebody's laptop. It lints, tests, builds, signs and uploads to a Play track, and each stage runs at the frequency its cost justifies.

The stages, cheapest first.

  • Lint and static analysis. Android Lint plus whatever the team runs on top, ktlint or detekt, wired as a Gradle task so it fails the build instead of printing a warning nobody reads.
  • Unit tests. testDebugUnitTest, pure JVM, no device, and usually most of the suite. If this takes more than a few minutes, that is the thing to fix before anything else.
  • Build. assembleDebug to prove the code compiles, and bundleRelease on the branch you ship from, because an App Bundle is what Play actually takes.
  • Instrumented tests. These need a device, so they are slow and flaky in a way unit tests are not. Run them with Gradle managed devices or Firebase Test Lab, sharded across a few configurations, on a schedule rather than on every push.
  • Sign and upload. A release bundle signed with the upload key, then pushed to an internal track through the Google Play Developer API.

Not every stage runs every time, and deciding what runs when is most of the design.

  • Every pull request. Lint, unit tests, a debug build. Keep it under about ten minutes, because a check slower than a reviewer's patience gets ignored or skipped.
  • On merge to the main branch. All of the above plus a signed release bundle uploaded to the internal track, so a tester always has today's build without anyone doing anything.
  • Nightly. Instrumented tests across a device matrix, dependency and vulnerability checks, and anything else too expensive to justify per commit.

Caching is what separates a pipeline people tolerate from one they route around.

  • Gradle caches. Use the official gradle/actions/setup-gradle action, which restores and saves the Gradle user home so dependencies and the local build cache survive between runs. Without it every run downloads the world.
  • The configuration cache. It lives inside the project directory and is encrypted with a machine local key, so it does not carry across runs by itself. Set a GRADLE_ENCRYPTION_KEY secret and setup-gradle will save and restore it, which skips the entire configuration phase on a hit.
  • A remote build cache. This is the big win on a large team. CI populates it from clean builds, developers only read from it, and any task whose inputs have not changed is downloaded instead of rerun.

Secrets are the part people get wrong, and the rule is that nothing sensitive lives in the repository.

  • The keystore. Base64 encode the .jks file, store the string as an encrypted secret, and decode it to a temporary file inside the job. It never exists in git and it is gone when the runner is torn down.
  • Passwords and the service account. Store password, key password, alias and the Play service account JSON as separate secrets, exposed as environment variables that the signing config reads.

Publishing is the delivery half, and it should be boring.

  • Upload with a wrapper. Gradle Play Publisher gives you publishBundle as a Gradle task, fastlane supply does the same job from a Fastfile. Both wrap the same Play Developer API, so pick whichever the team will maintain.
  • Internal first, then promote. CI always uploads to internal. Moving that same artifact on to closed, open or production is a separate, deliberate step, usually behind a manual approval.
  • Version from the build number. Derive versionCode from the CI run number or a monotonic counter so it always increases, and take versionName from the git tag. Play rejects a version code it has already seen, and a human incrementing it by hand will eventually forget.

Then keep what the run produced, the R8 mapping file for every release build, the test and lint reports, and the bundle itself, all attached to the run so a failure is diagnosable without rerunning anything. Post the result to the team channel so a red main branch is noticed in minutes rather than at standup.

name: android-ci
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '17'
      - uses: gradle/actions/setup-gradle@v6
        with:
          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
      - run: ./gradlew lintDebug testDebugUnitTest assembleDebug
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports
          path: '**/build/reports/'

GitHub Actions, GitLab CI, Bitrise and CircleCI are all common hosts for this, and the stages look the same on every one of them, so the choice is usually about what the company already runs. In the room, lead with the shape, lint and unit tests on every pull request, a signed bundle to the internal track on merge, instrumented tests on a schedule, secrets injected rather than committed. Then name the two things that make it survivable at scale, a remote build cache so CI is not rebuilding from zero, and version codes derived from the build number so nobody hand edits them. If they push further, speeding up the Gradle build covers the settings underneath, and the release checklist covers what a human still verifies before the rollout starts.

Read more Build your app from the command line (opens in a new tab)Scale your tests with build-managed devices (opens in a new tab)Google Play Developer APIs (opens in a new tab)

What is the difference between annotationProcessor, kapt and ksp in Gradle?

Tier: EssentialDifficulty: Medium

All three run code generation at build time, for something like Room, Dagger, or Moshi, and the difference is what kind of source they run against and how fast that makes them.

  • annotationProcessor is the original Java mechanism, it processes Java source directly through javax.annotation.processing. It has no idea what Kotlin syntax means at all.
  • kapt makes annotation processing work on a Kotlin codebase by first generating Java stub files that approximate your Kotlin classes, then running the same Java annotation processors against those stubs. That stub generation pass is real, measurable build time, and it's the main reason kapt has a reputation for being slow.
  • KSP, Kotlin Symbol Processing, skips the stub step entirely. It's a processor API built directly against the Kotlin compiler's own symbol representation, so it reads your actual Kotlin code, not a Java approximation of it.
dependencies {
    implementation("androidx.room:room-runtime:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
}

KSP is faster, commonly cited around two times faster than kapt on real projects, and it's what Google recommends for any processor that supports it, Room and Moshi both ship KSP compatible processors now. Kapt is effectively legacy at this point, kept around mainly for older libraries that haven't shipped a KSP variant, and Dagger's move to KSP support closed most of the remaining gap. The migration itself is usually mechanical, swap the kapt(...) dependency line for ksp(...), the annotations and generated code don't change.

Read more KSP overview (opens in a new tab)

What is the difference between implementation and api in Gradle?

Tier: EssentialDifficulty: Medium

implementation and api both add a dependency to a module, the difference is whether that dependency leaks into the compile classpath of whatever depends on your module.

  • implementation keeps the dependency internal. Module B depends on module A using implementation("some:library"), and a module C that depends on B cannot see that library on its own compile classpath, only at runtime.
  • api re-exposes the dependency. If B declares it with api("some:library"), module C automatically gets that library on its compile classpath too, as if it declared the dependency itself.
dependencies {
    api(project(":core:model"))
    implementation(project(":core:network"))
}

The practical reason to default to implementation almost everywhere is build performance in a multi module project, not just encapsulation. An api dependency means Gradle has to assume any change to that library could affect every module downstream of yours, transitively, so it has to reconsider recompiling all of them. implementation tells Gradle the dependency is fully contained, a change inside it only forces your own module to recompile, not the entire chain of modules above it. Reach for api only when a type from that dependency genuinely appears in your module's own public API, a return type or parameter type another module needs to compile against directly.

Read more Configure a build variant's dependencies (opens in a new tab)

What profilers are available in Android Studio, and when do you use each?

Tier: EssentialDifficulty: Medium

Android Studio no longer has one profiler with four tabs along the top. Since the Koala and Ladybug releases it is task based, so you pick the question first from the Home tab of the Profiler pane, and it records only the data that answers that question. Around it sit the App Inspection tools, which show live state a trace cannot show.

You start a task either from startup, which is what you want for launch problems, or by attaching to a process that is already running.

Profiler tasks

  • View Live Telemetry. The exploratory one. It draws CPU usage, thread states and a stacked memory graph in real time, plus an Interactions track of touches and lifecycle events on a debuggable app running API 26 or higher. Start here when you have no theory yet, then switch to a specific task once you see something odd.
  • Capture System Activities (System Trace). The most valuable task for a senior candidate to name, because it is a Perfetto system trace. It shows every process and thread scheduled across the CPU cores, frame timing on the main thread and RenderThread, process memory, and power rails on a physical device. This is the jank and startup task, and you can export the trace and open it in the Perfetto UI for a deeper look.
  • Find CPU Hotspots (Callstack Sample). Samples the callstack at an interval, so it costs little and includes native frames through simpleperf. Use it when something is burning CPU and you do not yet know which method. Needs API 26 or higher.
  • Find CPU Hotspots (Java/Kotlin Method Recording). Instruments method entry and exit, so every call is captured with exact timings. It is heavier, and the instrumentation itself skews the numbers, so keep recordings to a few seconds and use it only when sampling missed a short call you care about.
  • Analyze Memory Usage (Heap Dump). A point in time snapshot of the Java heap, with allocation counts, shallow size, retained size and native size per class. The class dropdown has a Show activity/fragment leaks filter that surfaces destroyed activities and fragments still being retained.
  • Track Memory Consumption (Java/Kotlin Allocations). Records what is allocated over a window, the stack trace of each allocation, and when it was freed. This is the churn task, for a hot path creating far more short lived objects than it needs to. It requires a debuggable build.
  • Track Memory Consumption (Native Allocations). The same idea for malloc and new in native code, sampling every 2048 bytes by default. Only relevant when you ship NDK code.

The split that matters is that heap dumps and Java or Kotlin allocation tracking need a debuggable build, while the trace and sampling tasks run happily against a profileable release build, which is the build whose numbers actually mean something.

Inspectors

  • Layout Inspector. A live view hierarchy with attributes, and for Compose it shows how many times each composable recomposed and how many times it was skipped. Excessive recomposition counts on a scrolling screen is a direct explanation for jank.
  • Network Inspector. A timeline of requests with headers, bodies, timing and the call stack that made each one. It supports HttpsURLConnection and OkHttp, which covers Retrofit. The Rules view lets you fake a status code or a response body to test your error handling.
  • Database Inspector. Queries and edits your live Room database on the device, and updates the results as the app writes. It ends most arguments about whether the bug is in the query or in the UI.
  • Background Task Inspector. Shows your WorkManager graph, the state of each worker, its constraints, its retry count and why it is not running yet.

Beyond Studio

  • Macrobenchmark. A test that launches your real app and measures startup and frame timing over repeated iterations, capturing a Perfetto trace per iteration. Use it in CI, because a profiler session is one run on one device and a benchmark is a number you can regress against.
  • Baseline Profile generation. Built on the same library. You record the critical user journeys, and ART compiles those paths ahead of time instead of interpreting them on first run.
  • StrictMode. Free and permanently on in debug builds. It catches disk and network work on the main thread, and leaked closable objects, at the moment you write the bug rather than months later.
  • Android vitals in the Play Console. Real startup times, ANR rate and crash rate from real devices in the field. Your Pixel is not the phone your slowest user has.
  • The profileable flag. Add it to the release manifest so you can profile the build users actually get, with R8 on and debugging off.
<!-- In the release manifest. Lets the profiler attach with low overhead. -->
<profileable android:shell="true" />

Which one for which symptom

  • Slow startup. Start a System Trace from startup, look at where the time goes before the first frame, then confirm the fix with a Macrobenchmark and a Baseline Profile.
  • Jank while scrolling. System Trace for the dropped frames, then Layout Inspector recomposition counts if it is Compose.
  • Memory that keeps growing. Watch the memory graph in Live Telemetry, then take a heap dump. For leaks specifically, see how to find memory leaks and using the Memory Profiler.
  • Battery drain. System Trace with power rails on a physical device, plus wake lock and job counts, and Android vitals for the field picture.
  • Slow network. Network Inspector for the timing breakdown, and check whether the delay is the request, the response, or your own parsing on the main thread.
  • A WorkManager job that never runs. Background Task Inspector, which usually shows an unmet constraint or a worker stuck in a retry backoff.

In the room, say the profiler is task based now and name two or three tasks by their real names, because that is what proves you have opened it recently. Then say the thing interviewers are actually listening for, that you profile a profileable release build rather than a debug build, and that a Macrobenchmark in CI is what stops the regression coming back.

Read more Profile your app performance (opens in a new tab)

Go deeper: every Build, Tooling & Release question

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

App Components

What is the project structure of an Android application?

Tier: Less commonDifficulty: Easy

A standard Android project is organized as a Gradle multi module project, with a top level build configuration and one or more app or library modules underneath it, each following the same internal layout.

At the top level.

  • settings.gradle.kts, lists which modules belong to the project.
  • build.gradle.kts, the root build file, usually just plugin versions shared across modules.
  • gradle/libs.versions.toml, the version catalog most modern projects use to centralize dependency versions.

Inside each module, typically app/.

  • src/main/java or src/main/kotlin, your actual source code, organized by package.
  • src/main/res, resources, layouts, drawables, strings, colors, split across density and configuration specific folders like values-night or drawable-xxhdpi.
  • src/main/AndroidManifest.xml, the module's manifest, declaring its components and permissions.
  • src/test, JVM unit tests that run without a device or emulator.
  • src/androidTest, instrumented tests that run on a device or emulator, since they need real Android framework classes.
  • build.gradle.kts, the module's own build configuration, dependencies, SDK versions, build types.

Most production apps split further into multiple modules beyond the single app module, a core module for shared utilities, feature modules per major flow, a data module for repositories and networking. That split is mainly about build speed and enforcing boundaries between features, Gradle can build and cache unrelated modules in parallel, and a module can't accidentally reach into another feature's internals if there's no dependency edge between them.

Activities & Fragments

What is a retained Fragment?

Tier: Less commonDifficulty: Medium

A retained Fragment is one created with setRetainInstance(true), which told the fragment manager not to destroy and recreate that Fragment's instance across a configuration change, only its view. The Fragment object itself, and any object it was holding in memory, survived a rotation intact.

class WorkerFragment : Fragment() {
    init {
        retainInstance = true
    }
    val cachedData: List<Item> = emptyList()
}

People used this to hold onto expensive in memory state, like data loaded over the network, without having to reload it every time the screen rotated, back before there was a better tool for that specific job.

setRetainInstance() is deprecated now. ViewModel does exactly what a retained Fragment was being used for, surviving configuration changes, but with a real lifecycle API, scoping rules, and none of the surprising edge cases retained Fragments had around nested Fragments and the back stack. Any code still using setRetainInstance(true) today is holding onto a pattern ViewModel replaced outright, not a still current alternative to it.

What is the difference between FragmentPagerAdapter and FragmentStatePagerAdapter?

Tier: Less commonDifficulty: Medium

Both were adapters for the old ViewPager, and they differed in how aggressively they kept offscreen Fragments alive.

  • FragmentPagerAdapter kept every Fragment instance in memory for the life of the adapter. Only its view was destroyed when a page scrolled offscreen, the Fragment object itself never went away. That made it cheap to switch back to a page, but expensive to hold a lot of pages, since nothing was ever fully released.
  • FragmentStatePagerAdapter destroyed the whole Fragment instance once a page was far enough offscreen, keeping just its saved state in a Bundle. Revisiting that page recreated the Fragment from that saved state. This scaled to far more pages, at the cost of paying Fragment recreation each time.

So the choice came down to page count, FragmentPagerAdapter for a handful of static tabs, FragmentStatePagerAdapter for a longer or dynamic list where holding every Fragment in memory wasn't realistic.

Both classes are deprecated now. ViewPager2 replaced ViewPager, and it uses FragmentStateAdapter, which does what FragmentStatePagerAdapter did but is built on RecyclerView underneath, giving it proper view recycling instead of the older adapter's more limited offscreen page handling.

What is the difference between FragmentStateAdapter and FragmentStatePagerAdapter?

Tier: Less commonDifficulty: Medium

FragmentStateAdapter is the current adapter, built for ViewPager2. FragmentStatePagerAdapter is its predecessor, built for the older ViewPager, and it's deprecated.

The behavior they're aiming for is similar, both destroy offscreen Fragments and keep just their saved state, recreating them when the page comes back into view. What changed is the foundation underneath.

  • FragmentStatePagerAdapter sits on top of the original PagerAdapter and ViewPager, which predates RecyclerView and manages its own view recycling logic.
  • FragmentStateAdapter sits on top of RecyclerView.Adapter, since ViewPager2 is a RecyclerView under the hood. That gets it RecyclerView's view recycling, right to left layout support, and vertical paging for free, none of which the old ViewPager had a clean way to do.

In practice this is a "know it's deprecated" question more than a "compare the internals" question. Any new code uses ViewPager2 with FragmentStateAdapter, FragmentStatePagerAdapter only comes up maintaining an app that hasn't migrated off the old ViewPager yet.

Why is the Bundle class used for passing data instead of a simple Map?

Tier: Less commonDifficulty: Medium

Because a Bundle can be serialized to Android's Parcel format and a plain Map can't be, and that's exactly the property Android needs when data has to survive crossing a process boundary or the app being killed and restarted.

  • It's restricted on purpose. A Bundle only accepts primitives, String, Parcelable, and arrays of those. Android knows in advance how to write every value it can hold into a Parcel, byte by byte. A Map<String, Any> could hold literally anything, including something with no defined way to serialize it, so there's no way to guarantee the whole thing can be reconstructed later.
  • It's optimized for Android's IPC layer. Parcel is a lightweight, Android specific format built for speed across Binder calls, unlike Java serialization, which reflects over an object graph and is comparatively slow. Passing an Intent to another process, or saving state before an OS initiated process death, both go through this path.
  • It's what the platform APIs actually expect. onSaveInstanceState(), Intent extras, and Fragment arguments are all typed to take a Bundle, not a Map, because the whole state restoration system is built around Parcelable data.

The practical upshot is that a Bundle isn't Android being needlessly different from a Map, it's a Map shaped API with the serialization guarantee that this specific job requires baked in.

Services & Background Work

What is the difference between Service and IntentService?

Tier: Less commonDifficulty: Easy

A Service runs on the main thread by default and gives you no threading help at all, you're responsible for moving any real work off the main thread yourself. An IntentService was a subclass that did that for you, it queued incoming Intents and handled them one at a time on a single dedicated background worker thread, calling onHandleIntent() for each, then stopped itself automatically once the queue was empty.

That automatic threading and automatic shutdown were the entire selling point. You didn't need to spin up your own thread or remember to call stopSelf().

IntentService is deprecated as of API 30. Its single sequential worker thread doesn't play well with modern background execution limits, and Android now expects you to use WorkManager for deferrable background work, or a coroutine backed Service with your own CoroutineScope when you specifically need Service semantics, like a foreground service for an active download. Both give you the same "don't block the main thread" outcome IntentService did, without the API being frozen against how the platform's background restrictions have evolved since.

What is the minimum repeat interval allowed when scheduling a PeriodicWorkRequest with WorkManager?

Tier: Less commonDifficulty: Easy

Fifteen minutes. PeriodicWorkRequest.Builder enforces MIN_PERIODIC_INTERVAL_MILLIS, and passing anything shorter gets silently clamped up to fifteen minutes rather than throwing an error, which is worth knowing because it means a bug here doesn't fail loudly.

val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
    .build()

This isn't a WorkManager specific restriction, it reflects a platform wide limit the OS puts on background execution to protect battery life, so no periodic scheduling API gets you tighter than this for deferrable background work. If a task genuinely needs to run more often than every fifteen minutes, periodic WorkManager is the wrong tool. A foreground service with its own internal timer, or push driven work triggered from a server, are the usual ways around the limit, depending on why the frequency is actually needed.

What is JobScheduler?

Tier: Less commonDifficulty: Medium

JobScheduler is a system API, added in API 21, for deferring background work until conditions you specify are met, like the device being on Wi-Fi, charging, or idle. You describe the job and its constraints, and the OS batches it with other apps' jobs to run efficiently instead of waking the device up separately for each one.

val job = JobInfo.Builder(JOB_ID, ComponentName(this, SyncJobService::class.java))
    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
    .setPersisted(true)
    .build()
scheduler.schedule(job)

It's still there and it still works, but you'd rarely reach for it directly today. WorkManager is built on top of JobScheduler on API 23 and up, and falls back to AlarmManager and a BroadcastReceiver on older versions, so it gives you the same constraint based scheduling with one consistent API across every Android version instead of you having to branch on API level yourself. The only reason to touch JobScheduler directly now is maintaining older code that predates WorkManager.

IPC & Content Providers

How can two distinct Android apps interact?

Tier: Less commonDifficulty: Medium

Two separate Android apps, each in their own process with their own sandbox, can only interact through mechanisms the OS explicitly exposes, since neither one can reach into the other's memory directly.

  • Intents, explicit or implicit, to start an Activity or Service the other app exports, or to broadcast something it listens for. This is the most common path, sharing a link or handing off to another app's camera flow both work this way.
  • ContentProvider, when one app needs to read or write structured data another app owns, like reading contacts or the media store.
  • AIDL over Binder, when one app needs to call methods on a bound Service in another app directly, rather than just firing an Intent at it.
  • A shared backend, both apps talk to the same server, which is often the simplest answer when the interaction is more than trivial and both apps are yours.

Whichever mechanism you use, the receiving app has to opt in. A component only accepts calls from outside if it's declared exported="true" in the manifest, or has an intent filter that matches, otherwise the sandbox keeps it fully isolated by default.

Is it possible to run an Android app in multiple processes? How?

Tier: Less commonDifficulty: Medium

Yes. By default every app runs in one process, but you can put any component in a separate one by adding android:process to its manifest entry.

<service
    android:name=".SyncService"
    android:process=":sync" />

A name starting with a colon, like :sync, creates a private process that still belongs only to your app. Leaving off the colon and giving it a fully qualified name instead lets other apps signed with the same key share that process with you, which is rarer and mostly a legacy pattern.

The main reason to do this is isolation. A crash in one process doesn't take down the other, which matters for something like a content provider or a background component you want to keep resilient even if the main UI process misbehaves. It's also genuinely necessary for a few Android features, like an app widget's remote views host, or a WebView you want to sandbox separately since it runs so much third party code.

The cost is real, though. Each process gets its own Application instance and its own memory space, so static state, singletons, and even a database connection held in memory aren't automatically shared across processes. Anything that needs to cross that boundary has to go through IPC, like a ContentProvider or a bound Service, the same as talking to a different app entirely.

What is AIDL? Enumerate the steps in creating a bounded service through AIDL.

Tier: Less commonDifficulty: Hard

AIDL, Android Interface Definition Language, is how you define a method interface that can be called across process boundaries, so a bound Service running in one process can be called from a client in another process. Regular Kotlin interfaces don't work across processes because the client and the Service don't share memory, AIDL generates the marshalling code that serializes a call, sends it over Binder, and returns a result.

You reach for it specifically for cross process IPC, a Service in a separate app or a separate process of your own app that another process needs to call directly. For a bound Service within your own process, a plain Binder subclass is simpler and AIDL is unnecessary overhead.

Creating one follows a fixed set of steps.

  • Define the interface in an .aidl file, listing the methods the client can call.
  • Let the Android build tools generate the Stub class from that file.
  • In the Service, implement the generated Stub with your actual logic and return it from onBind().
  • In the client, connect with bindService() and a ServiceConnection, then cast the returned IBinder with YourInterface.Stub.asInterface() to get a callable proxy.
  • Call methods on that proxy like normal function calls, they're transparently sent over Binder under the hood.

This is legacy machinery that most apps never touch today. It shows up in system level services and a few IPC heavy libraries, but for cross process communication in an app, Messenger for simple message passing or a dedicated IPC library covers most real needs with far less boilerplate.

Platform Internals & Runtime

Can you manually force Garbage Collection in Android?

Tier: Less commonDifficulty: Easy

You can call System.gc(), but it's only a request. It suggests to ART that now would be a good time to run a collection, it doesn't force one to happen immediately, and the runtime is free to ignore or delay it based on its own heuristics.

System.gc()

In practice you almost never want to call this. ART's garbage collector already runs automatically and is tuned for the device it's on, and calling System.gc() yourself tends to trigger a full collection at a moment you don't control, which can cause a visible frame drop or jank right when you least want it, like mid scroll. It doesn't fix a memory leak either, if objects are still reachable through a live reference, forcing a collection changes nothing, the memory still won't be reclaimed.

The right response to a memory problem is finding and removing the leaked reference, using a tool like Android Studio's Memory Profiler or LeakCanary, not requesting garbage collection more often. System.gc() shows up almost exclusively in test code, where you sometimes want to nudge collection before measuring memory usage, not in application logic.

What is DEX?

Tier: Less commonDifficulty: Easy

DEX, Dalvik Executable, is the bytecode format Android runs your compiled code in, and it's what your .kt and .java files ultimately become inside an APK.

The build process compiles Kotlin and Java source to standard JVM .class files first, then a tool called d8 converts those into one or more .dex files. DEX isn't just a repackaging of class files, it's a different format designed specifically for mobile constraints. Where a JVM app ships many separate .class files each with their own constant pool, DEX merges everything into a single shared constant pool across the whole app, so a string or method reference used in ten places is stored once instead of ten times. That was designed to keep binaries smaller and more memory efficient on constrained hardware than plain JVM bytecode would be.

A single classic DEX file has a hard limit of 65,536 referenced methods, since method references are indexed with a 16 bit number internally. That limit is the entire reason Multidex exists, once an app's combined code and dependencies cross it, the build has to split output across multiple .dex files instead of one.

You rarely touch DEX directly today, d8 and r8 handle the compilation and shrinking automatically as part of the Gradle build. It mostly comes up in interviews as the explanation behind the 65k method limit and Multidex, rather than something you configure by hand.

What is Multidex in Android?

Tier: Less commonDifficulty: Easy

Multidex is the mechanism that lets an app ship more than one .dex file, which becomes necessary once its total method count crosses the 65,536 method reference limit a single DEX file can hold.

That limit is easy to hit once an app pulls in enough libraries, since every method referenced anywhere in the app, its own code plus every dependency, counts toward the same shared limit. Once you cross it, the build has to split compiled code across multiple .dex files, a primary one and one or more secondary ones, and something has to make sure all of them get loaded at app startup.

On modern Android, API 21 and above, this is handled natively by ART, which supports loading multiple DEX files out of the box. Enabling it is just a manifest flag now.

<application android:name="androidx.multidex.MultiDexApplication">

The support library version, MultidexApplication, mattered a lot more on API 20 and below, where the old Dalvik runtime could only load one DEX file and needed a library to manually load the rest at startup, which also added a real cost to cold start time. Since minimum supported API levels have moved well past 21 for most apps, this is mostly a legacy concern today, and the Android Gradle Plugin enables Multidex automatically once your app actually needs it, no explicit opt in required for a typical modern project.

What is RenderScript?

Tier: Less commonDifficulty: Easy

RenderScript was a framework for running computationally heavy, data parallel work, mainly image processing, across whichever processor on the device could do it fastest, the CPU, GPU, or a dedicated DSP, without you writing separate code for each one. You wrote a single kernel once, and the runtime picked where to execute it.

It was genuinely useful for its era, things like blurring a bitmap or doing per pixel image transforms ran meaningfully faster than a hand rolled Kotlin loop, especially on lower end CPUs where offloading to the GPU actually mattered.

RenderScript is deprecated as of API 31 and Google has told developers to migrate off it. For the specific case of common image operations, like blurring, the RenderScript Intrinsics Replacement Toolkit reimplements the same operations without the RenderScript runtime. For everything else, the current recommendation is to reach for the NDK with Vulkan or OpenGL compute shaders when you genuinely need GPU parallelism, or plain Kotlin with coroutines running on Dispatchers.Default when the work fits on the CPU. If this comes up in an interview, the useful answer is knowing it's deprecated and naming what replaced it, not describing its API in detail.

What is the NDK and why is it useful?

Tier: Less commonDifficulty: Easy

The NDK, Native Development Kit, is a toolset for writing parts of an Android app in C or C++ and calling into that code from Kotlin through JNI, instead of writing everything against the Android SDK in a JVM language.

It's useful for a narrow set of real cases.

  • CPU intensive work, like signal processing, physics, or a game engine's core loop, where native code genuinely outperforms JVM bytecode.
  • Reusing existing C or C++ code, a codec, an image processing library, or a cross platform engine you don't want to reimplement in Kotlin.
  • Sharing logic across platforms, since C++ can be compiled for Android and iOS from largely the same source, which matters for teams maintaining both.

It's not useful for ordinary app logic. JNI calls have real overhead crossing the Kotlin to native boundary, native crashes are harder to debug than a Kotlin exception, and you take on manual memory management again. Most apps never touch the NDK directly, and today Kotlin Multiplatform is usually the better answer for the "share logic across platforms" case specifically, since it avoids the JNI boundary and native memory management entirely while still compiling to native code under the hood. The NDK remains the right tool when the requirement is genuinely native, high performance, C or C++ specific work, not general cross platform code sharing.

Permissions & Security

What are the different protection levels in Android permissions?

Tier: Less commonDifficulty: Easy

Android permissions are grouped into protection levels, and the level determines whether the system grants a permission automatically or has to ask the user.

  • Normal. Low risk permissions, like checking network state, that the system grants automatically at install time. The user never sees a prompt.
  • Dangerous. Permissions that touch sensitive data or hardware, like camera, location, or contacts. These need an explicit runtime prompt, the user has to tap allow, and they can revoke it later from settings at any time.
  • Signature. Granted automatically, but only to an app signed with the same certificate as the app that declared the permission. Used for letting your own related apps talk to each other without exposing that access to anyone else.
  • SignatureOrSystem, now largely folded into signature level permissions. Reserved for apps signed with the platform key or shipped as part of the system image, not something a normal app ever declares or requests.

The one that actually shows up in day to day work is dangerous. Since API 23, declaring a dangerous permission in the manifest is only half the story, you also have to request it at runtime with ActivityCompat.requestPermissions() and handle the user saying no, since the app has to keep working, or degrade gracefully, even when a dangerous permission is denied.

What is cleartext traffic?

Tier: Less commonDifficulty: Easy

Cleartext traffic is any network communication sent unencrypted, plain HTTP instead of HTTPS, where anyone positioned between the device and the server, on the same Wi-Fi, a compromised router, can read or tamper with it.

Since API 28, Android blocks cleartext traffic by default for every app. An HTTP request that used to just work now fails outright unless you explicitly opt into allowing it. That opt in goes in a network security config file, not the manifest directly.

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">api.example.com</domain>
    </domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application android:networkSecurityConfig="@xml/network_security_config">

The right response to hitting this almost never involves permitting cleartext at all, the actual server endpoint should be moved to HTTPS. Scoping the exception to one specific domain, like a local development server, rather than enabling it app wide, is the acceptable use of this flag. Enabling cleartextTrafficPermitted="true" for the whole app is a real security regression and a common finding in app security reviews, so it's worth knowing this default exists and why loosening it should be narrow and deliberate.

Notifications

How do you show a local notification at an exact time?

Tier: Less commonDifficulty: Medium

You use AlarmManager with one of its exact scheduling methods, not WorkManager. WorkManager is built for deferrable work with a minimum quantum of fifteen minutes, it deliberately doesn't guarantee an exact moment, so for something like a reminder that has to fire at precisely 9am, AlarmManager is the right tool.

val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pendingIntent = PendingIntent.getBroadcast(
    this, requestCode, intent, PendingIntent.FLAG_IMMUTABLE
)
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.RTC_WAKEUP,
    triggerAtMillis,
    pendingIntent
)

The PendingIntent typically points at a BroadcastReceiver that builds and posts the actual notification with NotificationManager when the alarm fires. setExactAndAllowWhileIdle() is the version that still fires close to on time even during Doze, plain setExact() can get deferred until the device exits Doze.

Since API 31, scheduling an exact alarm needs the SCHEDULE_EXACT_ALARM permission, and the system treats this as sensitive, the user can revoke it from settings at any time. It's worth checking canScheduleExactAlarms() before relying on exact timing, and falling back to an inexact alarm or a WorkManager based approach when it isn't granted.

Resources & Configuration

What is the Android Support Library and why was it introduced?

Tier: Less commonDifficulty: Easy

The Support Library was a set of libraries Google shipped to backport newer Android APIs and add compatibility widgets, like RecyclerView and Fragment in their early years, to devices running older OS versions than the API they targeted.

It existed because of fragmentation. New Android APIs only worked on devices that had actually updated to that OS version, and years ago plenty of active devices hadn't. The Support Library let you write against a consistent API and have it fall back to compatible behavior on older devices, instead of writing version branches by hand throughout your app, AppCompatActivity and its Material styling working the same way whether the device was on a recent release or several versions behind is a good example of what it bought you.

It's fully superseded now. In 2018, Google repackaged the entire Support Library as AndroidX, with a new androidx.* namespace, and every future Jetpack library ships under that namespace instead. Same underlying idea, backward compatible APIs and widgets, but with saner versioning per library instead of one giant library moving in lockstep, and semantic package names, androidx.fragment, androidx.recyclerview, instead of the old catch all android.support.*.

If this comes up in an interview, the useful answer is knowing it's why androidx imports look the way they do and that android.support.* imports are a signal of a genuinely old, unmigrated codebase, not a still current choice.

What is AAPT?

Tier: Less commonDifficulty: Medium

AAPT, the Android Asset Packaging Tool, is the build tool that compiles and packages an app's resources, XML layouts, drawables, strings, into the binary format the platform actually reads, and generates the R.java class your code references those resources through.

The tool you actually interact with today is its successor, AAPT2, which is enabled by default since Android Gradle Plugin 3.0. It splits resource processing into two phases.

  • Compile, which converts individual resource files into intermediate .flat binary files, one at a time.
  • Link, which merges all of those compiled files into the final APK, generating R.java and the resource table alongside it.

The reason that split matters is incremental builds. Because AAPT2 compiles resources one file at a time, changing a single string in one XML file only requires recompiling that file, then relinking, instead of reprocessing every resource in the module the way the original single pass AAPT had to. That's a meaningful chunk of why incremental builds got faster.

You rarely invoke AAPT2 directly, Gradle and the Android build pipeline call it automatically as part of the standard build. It's worth knowing it exists and what phase it owns, resource compilation and packaging, before DEX compilation and signing happen, but not something you configure by hand in normal day to day work.

Complete index

All 624 Android interview questions by topic

Every question on the site. Each one goes straight to its answer.

Android327 questions

Android Core

66 questions

The whole of Android in one page. Core platform questions in full, plus the questions asked most often in every other area.

Lifecycle

31 questions

Activity and Fragment lifecycle interview questions, plus ViewModel, configuration changes and process death, answered in plain language.

Android UI (Views)

33 questions

View system interview questions on Views, layouts, RecyclerView, dialogs and drawing, answered in plain language you can say out loud.

Jetpack Compose

46 questions

Jetpack Compose interview questions on recomposition, state, side effects and performance, answered in plain language you can say out loud.

Architecture

19 questions

MVVM, MVP, Clean Architecture and modularization interview questions for Android, answered in plain language you can say out loud.

Dependency injection interview questions for Android, covering the concepts plus Dagger, Hilt and Koin, answered in plain language.

Android threading interview questions on Handler, Looper, thread pools and the Java concurrency primitives, answered in plain language.

Networking

12 questions

Networking interview questions for Android, covering Retrofit and OkHttp, caching and interceptors, and the protocols behind real time features.

Persistence & Data

12 questions

Storage interview questions for Android, covering SharedPreferences and DataStore, SQLite and Room, and how to choose between them.

Android performance interview questions on memory leaks, jank, app startup and battery use, answered in plain language you can say out loud.

Testing

15 questions

Android testing interview questions covering unit tests, instrumentation and UI tests, and the tooling a Kotlin codebase actually uses.

Gradle, R8, CI and release interview questions for Android, covering the modern build setup rather than the one from five years ago.

Kotlin105 questions

Kotlin

47 questions

The Kotlin questions Android interviewers actually ask, from val versus var to variance, answered in plain language you can say out loud.

Kotlin Coroutines

38 questions

Every Kotlin Coroutines question an Android interviewer asks, answered in plain language you can say out loud.

Kotlin Flow

20 questions

Kotlin Flow interview questions, from builders and operators to StateFlow and SharedFlow, answered in plain language you can say out loud.

System design34 questions

System Design

34 questions

Android system design interview questions, from designing a library to designing a feature, structured the way you would actually run the round.

DSA55 questions

Coding and data structure questions asked in Android interviews, answered as reasoning you can talk through rather than memorised solutions.

Everything else85 questions

Design Patterns

26 questions

Design pattern and SOLID interview questions for Android developers, each anchored in a library you have actually used rather than a textbook example.

Java & JVM

35 questions

The Java and JVM questions Android interviewers still ask, from OOP and collections to equals and hashCode, answered in plain language.

RxJava interview questions answered as legacy knowledge, with the Kotlin Flow equivalent named for each, plus image loading library internals.

Cross-Platform

3 questions

The whole of Android in one page. Core platform questions in full, plus the questions asked most often in every other area.

Behavioral & Experience

6 questions

The whole of Android in one page. Core platform questions in full, plus the questions asked most often in every other area.