Everything asked in this round
This was the second round for an Android developer position — a technical discussion that ran a little over an hour, with one coding question at the end. The interviewer moved quickly between coroutines, architecture and Compose, and almost every question had a follow-up. Below is everything that was asked, with the explanation each answer needs.
1. Coroutines and Flow — what are they, and when do you use which?
A coroutine is work that can pause and resume without holding on to a thread while it waits. A thread costs about a megabyte of stack and an OS context switch; a coroutine is an object, so one thread can run thousands of them. Whenever a coroutine hits a suspend point it hands the thread back.
A coroutine produces one result. A Flow produces many values over time — that is the whole distinction, and it is the answer they were after.
- One network call, one result →
suspend fun. - A Room query that must keep the screen fresh, a search box, a socket stream →
Flow.
suspend fun user(id: String): User = api.user(id) // one value
fun observeCart(): Flow<List<Item>> = dao.observeCart() // many values
Follow-up asked: "is Flow cold or hot?" A plain Flow is cold — nothing runs until someone collects it, and each collector gets its own run. StateFlow and SharedFlow are hot.
2. Hilt — what does it give you over creating objects yourself?
Hilt is a compile-time dependency injection framework built on Dagger. You declare how a thing is built, and Hilt supplies it wherever it is needed.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun api(client: OkHttpClient): ApiService = Retrofit.Builder()
.baseUrl(BASE_URL).client(client).build().create(ApiService::class.java)
}
@HiltViewModel
class FeedViewModel @Inject constructor(private val repo: FeedRepository) : ViewModel()
What it actually buys you:
- Testability — production injects the real Retrofit service, a test injects a fake. The class under test does not change.
- Compile-time safety — a missing binding is a build error, not a crash on the user's phone.
- Lifetimes handled for you —
@InstallIn(SingletonComponent::class)is app-wide,ViewModelComponentis per ViewModel. You stop writing singletons by hand.
Follow-up asked: "@Provides or @Binds?" @Binds when you are simply telling Hilt which implementation satisfies an interface — it generates less code. @Provides when you have to build the object yourself.
3. Modularization — why split an app into modules?
Two honest reasons, and they liked that both were named:
- Build time. Gradle rebuilds only the modules that changed and can compile independent ones in parallel. On a large app this is the difference between a 30-second and a four-minute cycle.
- Boundaries. A module cannot reach into another module's internals unless that module exposes them, so architecture stops depending on everyone's discipline.
The structure used in practice: :app at the top, then :feature:* modules that do not know about each other, and shared :core:network, :core:database, :core:designsystem, :core:common. Features talk to each other only through an interface in a core module or through navigation routes.
Follow-up asked: "when is it not worth it?" On a small app with two developers it is pure overhead — the ceremony costs more than the build time it saves. Say that; it reads as judgement rather than fashion.
4. Performance optimization — how do you approach it?
The answer they want starts with measurement, not tricks: you cannot optimise what you have not measured, and the thing you assume is slow usually is not.
Tools first — Android Studio Profiler for CPU, memory and network, Layout Inspector for recomposition counts, Macrobenchmark for startup and scroll numbers you can compare, Play Console vitals for the field.
Then the fixes that actually move the needle:
- Lists — stable keys, no per-row work (format dates and build strings in the ViewModel), images decoded at display size.
- Main thread — nothing blocking on it; StrictMode in debug shouts when you slip.
- Compose — stable parameters so composables can be skipped, and read state in the smallest scope that needs it.
- Startup — Baseline Profiles, and lazy SDK initialisation.
- Memory — bitmaps sized correctly, released on
onTrimMemory; LeakCanary in debug.
Closing line that landed well: measure, change one thing, measure again — otherwise you are guessing with extra steps.
5. MVVM — explain the layers you actually use
MVVM in an Android app, with each layer's job in one line:
- View (Compose or XML) — renders state and sends events. No business logic, no formatting rules.
- ViewModel — holds screen state as a
StateFlow, handles events, survives configuration change. - Repository — decides where data comes from: cache first, then network. Maps DTOs to domain models and HTTP errors to typed results.
- Data sources — Retrofit and Room.
private val _state = MutableStateFlow(FeedUiState())
val state: StateFlow<FeedUiState> = _state.asStateFlow()
The detail worth adding unprompted: the ViewModel must not know about Android UI classes — no Context, no View, no Activity. That is what keeps it unit-testable on the JVM.
Follow-up asked: "MVVM or MVI?" With one immutable state object per screen you already have most of MVI's benefit. Full MVI earns its keep when a screen has many interacting flags and you keep hitting impossible combinations.
6. Difference between LiveData and StateFlow
| LiveData | StateFlow | |
|---|---|---|
| Comes from | Android (lifecycle library) | Kotlin coroutines — pure Kotlin |
| Initial value | Optional; can be empty | Required, so state is never undefined |
| Lifecycle | Lifecycle-aware by itself | Needs collectAsStateWithLifecycle() / repeatOnLifecycle |
| Threading | Main thread only | Any dispatcher |
| Operators | Very limited (map, switchMap) | The whole Flow operator set |
| Testing | Needs Android test rules | Plain JVM test with runTest |
So StateFlow is preferred in new code, with one caveat to mention: it is not lifecycle-aware on its own. Collect it with collectAsStateWithLifecycle(), or you keep collecting while the app is in the background.
7. StateFlow vs SharedFlow
Both are hot. The difference is whether a current value exists.
- StateFlow always holds exactly one value, deduplicates equal values, and a new collector immediately receives the current one. This is state: the list, the loading flag.
- SharedFlow holds no value by default, emits to whoever is collecting at that moment, and can be configured with a replay cache. This is events: show a snackbar, navigate, play a sound.
// state
private val _state = MutableStateFlow(UiState())
// one-shot events
private val _events = MutableSharedFlow<UiEvent>()
The follow-up that catches people: "why not keep a navigation flag in StateFlow?" Because it is sticky — after rotation the screen reads the same state and navigates a second time. Events belong in SharedFlow or a Channel.
8. launch vs async
launchreturns aJoband no result. "Do this."asyncreturns aDeferred<T>and you callawait(). "Give me this value."
suspend fun loadScreen() = coroutineScope {
val user = async { api.user() } // both requests start here
val feed = async { api.feed() }
Screen(user.await(), feed.await()) // takes max(t1, t2), not t1 + t2
}
Two points worth adding: async is for genuine parallelism — writing async { }.await() immediately is just a slower launch. And exceptions differ: a failure inside launch propagates up at once, while a failure inside async is held until you await() it.
9. supervisorScope — what problem does it solve?
In a normal coroutineScope, if one child fails, the parent cancels every sibling. In a supervisorScope, a child's failure stays with that child and the siblings keep running.
// a dashboard where one failed widget must not blank the screen
supervisorScope {
launch { loadProfile() } // if this fails...
launch { loadOrders() } // ...this still finishes
launch { loadOffers() }
}
The same idea is behind SupervisorJob(), which is what viewModelScope uses — one failed coroutine does not kill the whole ViewModel's scope.
The catch to mention: in a supervisor scope each child must handle its own exception. An uncaught failure inside one of those launch blocks goes to the uncaught handler rather than being thrown at the parent, so wrap each in try/catch or runCatching.
10. withContext — what is it for?
withContext switches the dispatcher for a block and returns its result. It suspends until the block finishes, so it is sequential, not parallel.
suspend fun loadUser(id: String): User = withContext(Dispatchers.IO) {
api.getUser(id)
}
The rule it enforces: a suspend function should be safe to call from any dispatcher. The dispatcher choice belongs inside the repository function, never at the call site in the ViewModel.
Follow-up asked: "withContext or async?" withContext for one piece of work on a different dispatcher; async when two pieces of work should run at the same time. Also worth saying: Retrofit and Room already move off the main thread, so wrapping them again is harmless but unnecessary.
11. zip — what does it do?
zip pairs values from two flows one to one: it waits until both flows have produced their next value, emits the pair, and waits again. The result ends when the shorter flow ends.
val names = flowOf("A", "B", "C")
val ages = flowOf(1, 2)
names.zip(ages) { name, age -> "$name:$age" }
// A:1, B:2 — "C" is never paired, so it is dropped
Use it when values genuinely belong together in order — two paginated sources read in step. In app code it is far less common than combine, and saying that is part of the right answer.
12. combine — and how is it different from zip?
combine emits whenever either flow produces a new value, using the latest value from the other. Nothing waits for a partner, so nothing is dropped.
combine(products, searchQuery, selectedCategory) { items, query, category ->
items.filter { it.matches(query) && it.inCategory(category) }
}
This is the everyday operator for a screen: data from one source, filters from another, one state object out. zip is lock-step and lossy; combine is latest-wins and keeps emitting.
Follow-up asked: "what does combine emit first?" Nothing until every source has emitted at least once — which is why a filter flow should be a StateFlow with a default rather than a bare Flow.
13. flatMapLatest — where do you use it?
flatMapLatest switches to a new inner flow every time the outer flow emits, and cancels the previous inner flow. That cancellation is the entire point.
val results = query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repo.search(q) } // old search is cancelled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
Without it, a slow response for "sho" can arrive after the response for "shoes" and overwrite the newer results — the classic search race condition. With it, only the latest query can ever reach the screen.
The family, in one line each: flatMapConcat keeps order and waits; flatMapMerge runs them all concurrently; flatMapLatest keeps only the newest. For user-driven input you almost always want the last one.
14. SOLID principles — with an Android example each
- S — Single responsibility. A ViewModel holds screen state; it does not also parse JSON or format dates. When a class has two reasons to change, split it.
- O — Open/closed. Adding a new payment method should mean adding a class, not editing a
whenin five files. A sealed interface plus one handler per type gets you there. - L — Liskov substitution. A fake repository used in tests must behave like the real one. If the fake throws where the real one returns an empty list, your tests are lying to you.
- I — Interface segregation. Do not make a screen depend on a 20-method
ApiServicewhen it uses two. Narrow interfaces per feature keep the dependency honest. - D — Dependency inversion. The ViewModel depends on a
UserRepositoryinterface; Hilt supplies the Retrofit-backed implementation. This is why DI and SOLID are usually asked in the same breath.
Giving an Android example for each — rather than reciting the names — is what the question is really checking.
15. Room migration — how do you change a schema safely?
Room verifies the schema at startup. Change an entity without bumping version and the app crashes with "Room cannot verify the data integrity". Bump the version and supply a migration:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE orders ADD COLUMN discount REAL NOT NULL DEFAULT 0")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_2_3)
.build()
Points that matter in the answer:
- A new
NOT NULLcolumn needs aDEFAULT, otherwise existing rows cannot satisfy it. - SQLite cannot drop or rename a column in older versions — you create the new table, copy the data, drop the old one, rename.
fallbackToDestructiveMigration()wipes user data. Debug convenience only; never in a release build.- Export the schema JSON and write a migration test with
MigrationTestHelper— this is the part most candidates have never done, and saying it stands out. - For a purely additive change,
@AutoMigrationcan write it for you.
16. WorkManager — when do you use it instead of a coroutine?
WorkManager is for work that must finish even if the user leaves the app or the device reboots. It persists the request in its own database and runs it when the constraints are met.
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("upload-$id", ExistingWorkPolicy.KEEP, request)
The decision rule, said out loud:
- Matters only while the screen is open →
viewModelScope. - Must happen eventually — photo upload, offline order sync, nightly cleanup → WorkManager.
- The user is watching it happen right now — music, navigation → foreground service.
Follow-up asked: "how do you avoid duplicate work?" enqueueUniqueWork with a stable name and the right ExistingWorkPolicy — KEEP to ignore a second request, REPLACE to cancel and restart.
17. App startup optimization
First split the number: cold start (process created), warm, hot. Cold start is what Play vitals and reviews react to.
Then, in order of impact:
- Baseline Profiles. Ship them — they pre-compile the startup path and routinely take 20-30% off cold start with no code change.
- Application.onCreate. Every SDK initialised there is on the critical path. Move analytics, crash extras and image libraries to lazy or background init with AndroidX App Startup.
- Content providers. Libraries that auto-initialise through a provider run before your first frame; App Startup exists to merge them.
- First screen. Render from cache and fill in later — never block the first frame on a network call.
- SplashScreen API rather than a separate splash Activity, which just adds a screen to create.
Measure with adb shell am start -W, Macrobenchmark's StartupTimingMetric, and Play Console vitals for real devices.
18. What causes a memory leak in Android?
Always the same shape: something long-lived holds a reference to something short-lived — usually an Activity, Fragment or View.
- A singleton or static field holding an Activity
Context. PassapplicationContextwhen the object outlives the screen. - A listener, callback or Handler that is registered and never unregistered.
GlobalScope.launchcapturing the screen — nothing cancels it.- A Fragment's view binding kept after
onDestroyView; null it there. - A non-static inner class or anonymous object that implicitly holds the outer instance.
How you prove it: LeakCanary in debug, and the Memory Profiler — rotate a few times, force GC, dump the heap and look for more than one instance of the same Activity.
19. rememberSaveable — how is it different from remember?
remembersurvives recomposition only. Rotate the phone and the value is gone.rememberSaveablealso survives configuration change and process death, because it is stored in the saved-state Bundle.
var query by rememberSaveable { mutableStateOf("") } // kept across rotation
var isMenuOpen by remember { mutableStateOf(false) } // fine to lose
The rule: if the user produced the value, it belongs in rememberSaveable; if it is pure UI mechanics, remember is right. Because it is a Bundle, only small data belongs there — a custom type needs a Saver or @Parcelize.
Follow-up asked: "then why a ViewModel at all?" A ViewModel survives rotation but not process death, and it is where state that comes from the data layer belongs. Big objects go in the ViewModel; the key to them goes in saved state.
20. Recomposition — what is it, and what makes it skip?
Compose records which composables read which state. When that state changes, it re-runs only those composables — that is recomposition. It is not a full-screen redraw.
A composable is skipped when its parameters are unchanged and stable. Stable means Compose can rely on equals and knows the value will not change under it: primitives, String, a data class of stable types, a lambda it can remember. A plain List or a class with a var is treated as unstable, so the composable recomposes every time.
Because recomposition can run often, in any order, and be cancelled, a composable body must have no side effects.
// recomputed on every recomposition — and nothing can be skipped downstream
val filtered = items.filter { it.isActive }
// recomputed only when items change
val filtered = remember(items) { items.filter { it.isActive } }
You verify it with recomposition counts in Layout Inspector; a number climbing while a static area sits on screen is the bug.
21. LaunchedEffect
LaunchedEffect runs a coroutine when the composable enters the composition, and restarts it when its key changes. It is cancelled automatically when the composable leaves.
LaunchedEffect(userId) { // runs again only when userId changes
viewModel.load(userId)
}
LaunchedEffect(Unit) { // runs exactly once per entry
viewModel.trackScreenView()
}
This is where a side effect belongs — a one-off load, a snackbar, a navigation triggered by an event. Starting that work directly in the composable body would fire it again on every recomposition, which is the mistake the question is looking for.
Follow-up asked: "difference from SideEffect?" SideEffect is not a coroutine — it runs after every successful recomposition, for publishing state to non-Compose code. rememberCoroutineScope is the third one: use it to launch work from a click handler rather than from composition.
22. DisposableEffect
DisposableEffect is for a side effect that must be cleaned up. You register something on enter and release it in onDispose.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Use it for listeners, sensors, a BroadcastReceiver, a map callback — anything with a matching unregister. The difference from LaunchedEffect: that one is for suspending work and needs no cleanup, this one is for registrations that leak if you forget to remove them.
23. Unit testing — what do you test, and how?
Keep most tests on the JVM, where they are fast and need no device:
- ViewModels — state transitions and error handling, with a fake repository.
- Repositories — the cache decision, with in-memory Room and MockWebServer.
- Mappers and validation — pure functions, the cheapest tests you will ever write.
@Test
fun showsErrorWhenLoginFails() = runTest {
val vm = LoginViewModel(FakeAuthRepository(fails = true))
vm.submit("a@b.com", "password")
assertEquals("Wrong email or password", vm.state.value.error)
}
The coroutine details they probed: use runTest, inject the dispatcher instead of hardcoding Dispatchers.IO, and set Dispatchers.setMain(StandardTestDispatcher()) in a JUnit rule so viewModelScope is controllable.
What not to test: that Compose draws a Text, that Retrofit parses JSON, getters and setters. That is testing the framework. Keep UI tests to the two or three journeys that earn money.
24. Coding question: first non-repeating character
Problem: given a string, return the first character that appears exactly once. Return null (or '_') if every character repeats.
"swiss" → 'w'
"aabbcc" → null
"android" → 'a'
Thinking: the brute force compares every character with every other — O(n²). To do better, notice the question needs two things: a count per character, and the original order. Counting first and then scanning the string again in order gives both, in two passes.
fun firstNonRepeating(input: String): Char? {
val counts = LinkedHashMap<Char, Int>()
for (c in input) counts[c] = (counts[c] ?: 0) + 1
return counts.entries.firstOrNull { it.value == 1 }?.key
}
Because LinkedHashMap keeps insertion order, the first entry with a count of 1 is the answer — one pass to count, one pass over the map.
Follow-ups asked:
- Complexity? O(n) time, O(k) space where k is the number of distinct characters — bounded, so effectively O(1) for ASCII.
- Without a LinkedHashMap? Use an
IntArray(256)of counts, then scan the string again and return the first character whose count is 1. - Case sensitivity and Unicode? Ask before coding — lowercase the input if required, and iterate code points rather than chars if emoji must be handled. Asking that was treated as part of the answer.
