Android (Kotlin) Interview Questions and Answers: 60 Questions from Easy to Hard
This guide is for Android developers in India preparing for interviews, from freshers and interns who have built their first app to engineers with several years of experience moving into senior roles. It has 60 questions with model answers in three levels: Kotlin and Android basics, then the coroutines, Flow, Jetpack Compose, Room, Retrofit and Hilt questions asked for 1-4 years of experience, and finally the design, performance and internals questions of senior rounds. Most answers come with a short Kotlin snippet. Try to answer each question aloud before reading the model answer, build the snippets in a small practice app, then take the mock test to check your speed.
- 60 questions, 3 levels
- Updated
- CoreHRx Jobs
- 155 open Android (Kotlin) jobs
What Android (Kotlin) interviews cover
- Kotlin itself: null safety, data and sealed classes, extension and scope functions, collections and higher-order functions. Freshers are often asked to write small functions on a whiteboard or shared editor.
- Android fundamentals: the activity and fragment lifecycle, configuration changes, intents, the four app components and runtime permissions.
- Modern Jetpack: ViewModel with StateFlow, Jetpack Compose state and side effects, Room, Retrofit with OkHttp, Hilt and WorkManager.
- Coroutines and Flow at every level, from launch versus async for freshers to cancellation, exception propagation and backpressure for senior roles.
- For experienced roles: architecture and modularization, offline-first design, startup and rendering performance, app size, and a testing strategy.
How to prepare (freshers and experienced developers)
- Freshers: build one small app end to end, such as a job list that loads from an API, caches in Room and survives rotation. Interviewers ask about what you built, so know every line of it.
- Learn the lifecycle by experiment: log every callback, rotate the phone, press Home and come back, and turn on Don't keep activities in developer options to watch recreation.
- Experienced developers: prepare two or three stories from your own apps, such as a crash you fixed, a slow screen you sped up or a migration you shipped, with numbers where you have them.
- Read the official Android developer guides on app architecture, Compose performance and coroutines best practices; many senior questions come straight from those trade-offs.
- Practise explaining code aloud: state what the code does, why you chose it and what you would change at ten times the scale.
Common mistakes and interview-day tips
- Do not answer "it depends" without saying on what. Name the trade-off and give a reason, for example why you chose StateFlow over LiveData for screen state.
- Avoid old patterns unless asked: AsyncTask, GlobalScope, static Activity references and Handler-based threading are red flags in a modern codebase.
- When you write code, handle the null and error cases out loud. Interviewers notice when !! or an empty catch block appears.
- If you have not used something, such as Hilt or Compose, say so and explain how you would learn it or what you used instead. Guessing is easy to spot.
- Ask about their app: minimum SDK, Compose or Views, and team size. It shows interest and helps you pitch your answers at the right level.
Android (Kotlin) interview questions: Easy questions (freshers)
20 questions1. How does Kotlin null safety work, and what do ?., ?: and !! do?
In Kotlin a type is non-null by default, so a String variable can never hold null; only String? can. The compiler then forces you to handle the null case before using a nullable value. The safe call a?.length returns null instead of crashing when a is null, the Elvis operator ?: supplies a fallback, and !! tells the compiler to trust you, throwing a NullPointerException if the value is null. Prefer ?. with ?: or an early return; code full of !! is a common review comment, because it brings back the crashes Kotlin was designed to remove.
fun cityLabel(user: User?): String {
val city: String? = user?.address?.city
return city ?: "City not set"
}
val length = name!!.length // crashes if name is null2. What is the difference between lateinit and by lazy?
Both delay initialisation, but they suit different cases. lateinit var works only with a mutable, non-null property of a non-primitive type that is set later, such as a view binding or an injected field; reading it before assignment throws UninitializedPropertyAccessException. by lazy works with a val: the block runs once, on first access, and the result is cached, and by default that is synchronised, so it is thread-safe. Use lazy for expensive values you may never need, and lateinit when a framework hands you the value. Do not use lateinit to hide a value that can really be absent.
class ProfileActivity : AppCompatActivity() {
private lateinit var binding: ActivityProfileBinding
private val dateFormatter by lazy {
DateTimeFormatter.ofPattern("dd MMM yyyy")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}3. What does a data class give you, and what should you watch out for?
Marking a class as data makes the compiler generate equals, hashCode, toString, copy and componentN functions from the properties in the primary constructor. That suits models such as API responses, Room rows and UI state: two objects with the same values compare equal, and copy changes one field while keeping the rest. Watch two things. Properties declared in the class body are left out of equals, hashCode and toString, and copy is shallow, so a MutableList inside is shared by both copies. Keep data class properties as val with read-only types.
data class Employee(val id: Int, val name: String, val city: String)
val asha = Employee(101, "Asha", "Pune")
val moved = asha.copy(city = "Bengaluru")
val (id, name) = moved // destructuring via component1, component24. What is a sealed class or sealed interface, and why is it popular for UI state?
A sealed type has a fixed set of direct subclasses, all known at compile time because they must be declared in the same module and package. The benefit shows in when: if a when expression covers every subclass, the compiler knows it is exhaustive and needs no else branch, and adding a subclass later turns every incomplete when into a compile error. That is why screens model state as Loading, Success and Error. An enum also gives a fixed set, but each entry is a single instance; a sealed type lets each case carry its own data, such as the list in Success or the message in Error.
sealed interface UiState {
data object Loading : UiState
data class Success(val jobs: List<Job>) : UiState
data class Error(val message: String) : UiState
}
fun render(state: UiState) = when (state) {
UiState.Loading -> showSpinner()
is UiState.Success -> showJobs(state.jobs)
is UiState.Error -> showError(state.message)
}5. What are extension functions, and how are they resolved?
An extension function adds a function to an existing type without inheriting from it or editing its source, such as isEven on Int. Inside it, this is the receiver, but private and protected members stay out of reach. Extensions are resolved statically, from the declared type at compile time, not the runtime type, so they never override anything: in the example s.name() picks the Shape version even though s holds a Circle. If the class already has a member with the same signature, the member always wins. They suit small helpers, but dozens of them on String become hard to discover.
fun Int.isEven(): Boolean = this % 2 == 0
open class Shape
class Circle : Shape()
fun Shape.name() = "shape"
fun Circle.name() = "circle"
val s: Shape = Circle()
s.name() // "shape": chosen from the declared type6. How do you choose between let, run, with, apply and also?
All five run a block with an object; they differ in how the block sees the object and what they return. let and also pass it as it; run, with and apply pass it as this. let, run and with return the result of the block, while apply and also return the object itself. So use apply to configure an object, also for a side effect such as logging without breaking a chain, let with ?. to run code only when a value is not null, and run or with to compute a result from an object. The common mistake is nesting them three deep, which makes this and it ambiguous; a plain if or a local val is often clearer.
val intent = Intent(this, JobDetailActivity::class.java).apply {
putExtra("job_id", jobId)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
user?.email?.let { email -> sendOffer(email) }
val saved = repository.save(job).also { Log.d("Jobs", "saved $it") }7. What is the difference between List and MutableList in Kotlin, and how do filter and map work?
List is a read-only interface: it has get, size and iteration but no add or remove, and MutableList extends it with those functions. Read-only is not the same as immutable, because a List reference may point to an ArrayList that other code can still change. Functions such as filter and map never change the original; each returns a new list, so the chain in the example builds two lists, gives [1, 9, 25] and leaves numbers untouched. Expose List from a class and keep the MutableList private, so callers cannot edit your data behind your back.
val numbers = listOf(1, 2, 3, 4, 5)
val oddSquares = numbers.filter { it % 2 == 1 }.map { it * it }
// oddSquares is [1, 9, 25]; numbers is unchanged
class Cart {
private val _items = mutableListOf<String>()
val items: List<String> get() = _items
}8. Explain the Activity lifecycle callbacks and when each one runs.
onCreate runs once per instance: set the content view, connect the ViewModel and restore saved state. onStart makes the activity visible, and onResume brings it to the foreground, where it receives input. When another window partly covers it, onPause runs; when it is no longer visible, onStop; and onDestroy runs when it finishes or is destroyed for a configuration change. Coming back from the background goes through onRestart, onStart and onResume. Release the camera and sensors in onPause or onStop, not onDestroy, because the system may kill a stopped process without calling onDestroy.
9. What happens to an Activity when the user rotates the phone?
By default a configuration change such as rotation, a language change or switching to dark mode destroys the activity and creates a new instance, so the right resources are loaded. Data held in plain fields of the activity is lost. Views with an id save some of their own state, such as text typed in an EditText, through the saved instance state. The proper fix is to keep screen data in a ViewModel, which survives the change, and small UI state in saved state. Adding android:configChanges in the manifest to stop recreation means you must handle every resource change by hand.
10. Why should data be passed to a Fragment through arguments instead of a constructor?
The system recreates fragments on its own after a configuration change or process death, and by default it uses the no-argument constructor to do so. Any value you passed to a custom constructor would be missing in the recreated fragment, often causing a crash or a blank screen. The arguments Bundle is saved and restored with the fragment, so values stored there survive. Put only small values in it, such as an id, and load the full object from a repository. With the Navigation component, Safe Args generates type-safe code for the same Bundle.
11. What are the four main Android app components?
Activities are the screens the user interacts with. Services do work without a UI, such as a foreground service playing music with a visible notification. Broadcast receivers react to system or app events, for example the device finishing boot or the time zone changing. Content providers share structured data with other apps through a content URI, as the Contacts app does. Activities, services and providers are declared in AndroidManifest.xml; receivers can be declared there or registered in code. The system creates components, so you start an Activity with an Intent, never its constructor.
12. What is the difference between an explicit and an implicit Intent?
An explicit Intent names the exact component to start, usually a class in your own app, such as opening JobDetailActivity. An implicit Intent describes an action and data, such as viewing a web page or sharing text, and the system finds apps whose intent filters match, showing a chooser if several can handle it. Use explicit intents inside your app and implicit ones to hand work to other apps. Since Android 11, an app targeting API 30 or higher sees only some other apps unless it declares queries in the manifest, so resolveActivity can return null even when a matching app is installed.
13. What is a coroutine, and what are Dispatchers.Main, IO and Default for?
A coroutine is a lightweight, suspendable unit of work. When it calls a suspend function such as a network request, it suspends and frees the thread instead of blocking it, then resumes when the result is ready, so thousands of coroutines can share a few threads. A dispatcher decides which threads run it. Main is the UI thread, for updating views and state; IO is a pool for blocking calls such as file or database access; Default is sized to the CPU cores, for heavy computation like parsing a large JSON file or sorting. Switch with withContext, and never block the main thread.
viewModelScope.launch { // runs on the main thread
_state.value = UiState.Loading
val raw = withContext(Dispatchers.IO) {
reportFile.readText() // blocking file read
}
val report = withContext(Dispatchers.Default) {
parseReport(raw) // CPU-heavy work
}
_state.value = UiState.Success(report)
}14. What is the difference between launch and async?
Both start a coroutine in a scope. launch is fire-and-forget: it returns a Job that you can cancel or join, but no value. async returns a Deferred, which is a Job that also carries a result, and you get the result by calling await, which also rethrows any exception. Use async when you need values from several calls in parallel, such as loading the profile and the job list together, then await both. A common mistake is calling async and then await on the very next line for each call, which runs them one after another and adds nothing over a plain suspend call.
suspend fun loadHome(): Home = coroutineScope {
val profile = async { api.getProfile() }
val jobs = async { api.getRecommendedJobs() }
Home(profile.await(), jobs.await()) // both run in parallel
}15. How do runtime permissions work on modern Android?
Since Android 6.0, dangerous permissions such as camera, location and contacts must be granted by the user at runtime, not just declared in the manifest. Declare the permission, check it with ContextCompat.checkSelfPermission and, if it is missing, request it through the Activity Result API with RequestPermission. Ask in context, when the user taps the feature that needs it, and explain why first if shouldShowRequestPermissionRationale returns true. On Android 11 and higher, once the user denies a permission twice the dialog no longer appears, so offer a link to app settings instead.
16. What is Jetpack Compose, and how is it different from XML layouts?
Compose is Android's declarative UI toolkit. Instead of inflating XML and then changing views by hand with findViewById and setText, you write Kotlin functions annotated with @Composable that describe what the screen should look like for the current state. When the state changes, Compose calls the affected functions again, which is called recomposition, and updates only what changed. That removes a whole class of bugs where the views and the data drift apart. Composable functions should be fast and free of side effects, because they can run often and in any order.
@Composable
fun JobCard(title: String, city: String, onApply: () -> Unit) {
Card {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(city)
Button(onClick = onApply) { Text("Apply") }
}
}
}17. What do remember and mutableStateOf do in Compose?
mutableStateOf creates an observable value: when a composable reads it and the value later changes, Compose schedules a recomposition of the code that read it. remember keeps an object across recompositions, so the value is not reset each time the function runs. You usually need both: without remember the counter goes back to its starting value on every recomposition, and with a plain var instead of state the screen never updates. remember is lost on rotation, so use rememberSaveable for small UI state the user expects to keep, and a ViewModel for screen data.
@Composable
fun QuantityPicker() {
var quantity by remember { mutableStateOf(1) }
Row(verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { if (quantity > 1) quantity-- }) { Text("-") }
Text("$quantity", Modifier.padding(horizontal = 16.dp))
Button(onClick = { quantity++ }) { Text("+") }
}
}18. Why do we use a ViewModel?
A ViewModel holds and prepares the data for a screen and survives configuration changes such as rotation, because it lives in a ViewModelStore that outlives the activity instance. So a network call is not repeated and the list does not flash empty after rotation. It also moves logic out of the activity, where it can be unit tested without a device. It is cleared, with onCleared called, only when the screen is finished for good. Never keep an Activity, Fragment or View in a ViewModel, as it outlives them and would leak them, and remember that it does not survive process death.
19. What are the main parts of Room?
Room is the Jetpack persistence library on top of SQLite. A class annotated with @Entity maps to a table, a @Dao interface declares queries with @Query, @Insert, @Update and @Delete, and an abstract class extending RoomDatabase ties the entities and DAOs together with a version number. Room checks your SQL at compile time, so a typo in a column name fails the build instead of crashing on a user's phone. DAO functions can be suspend for one-shot work or return Flow to observe a table. By default Room refuses to run queries on the main thread, which stops you from freezing the UI.
@Entity(tableName = "jobs")
data class JobEntity(
@PrimaryKey val id: Long,
val title: String,
val city: String
)
@Dao
interface JobDao {
@Query("SELECT * FROM jobs WHERE city = :city")
fun observeByCity(city: String): Flow<List<JobEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(jobs: List<JobEntity>)
}
@Database(entities = [JobEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun jobDao(): JobDao
}20. How do you make a network call with Retrofit?
You describe the API as a Kotlin interface: annotations such as @GET and @POST give the path, and @Path, @Query and @Body fill in the parts. Retrofit creates the implementation at runtime from a base URL, an OkHttp client and a converter, such as Moshi, Gson or kotlinx.serialization, that turns JSON into data classes. Mark the functions suspend and Retrofit runs the call without blocking the calling thread, so you can call it from viewModelScope. A suspend function that returns a body type throws HttpException for a non-2xx code; return a Response type when you need the status code or headers.
interface JobsApi {
@GET("jobs")
suspend fun search(
@Query("city") city: String,
@Query("page") page: Int
): JobsResponse
@POST("jobs/{id}/apply")
suspend fun apply(
@Path("id") id: Long,
@Body form: ApplyForm
): Response<Unit>
}
val api = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(JobsApi::class.java)Ready to check yourself? Take the free Android (Kotlin) mock test
Android (Kotlin) interview questions: Medium questions (1-3 years)
20 questions21. Why can Kotlin code still crash with a NullPointerException when it calls Java code?
Java does not tell the Kotlin compiler whether a value can be null, so a Java type appears in Kotlin as a platform type, shown as String! in errors. Kotlin lets you treat it as nullable or non-null, and if you choose non-null and Java returns null, a NullPointerException is thrown where the value is assigned or used. AndroidX and many Android APIs carry nullability annotations that Kotlin understands, but older Java libraries often lack them, so declare the type as nullable at that boundary when unsure. Other usual crashes are !!, which throws an NPE, and reading a lateinit too early.
22. What do inline, reified and crossinline mean in Kotlin?
An inline function has its body, and the lambdas passed to it, copied into each call site by the compiler. That avoids creating a lambda object for small higher-order functions such as filter, and lets a return inside the lambda return from the enclosing function. reified works only in inline functions and keeps the type argument at runtime, so you can write T::class or check is T, which normal generics lose to erasure. crossinline forbids those non-local returns when the lambda is called from another context, such as a Runnable. Keep inline functions small, as the code is copied everywhere.
inline fun <reified T : Activity> Context.start(
vararg extras: Pair<String, Any?>
) {
val intent = Intent(this, T::class.java)
intent.putExtras(bundleOf(*extras))
startActivity(intent)
}
// Call site: no Class argument needed
start<JobDetailActivity>("job_id" to 42L)23. When should you use a Sequence instead of a chain of List operations?
List operations are eager: each step processes the whole collection and builds a new intermediate list before the next step starts. A Sequence is lazy: each element goes through the whole chain one at a time, and nothing runs until a terminal operation such as first, toList or count. In the example both give 30, but the list maps all five numbers before first looks at them, while the sequence stops after the third. Use sequences for long chains over large data or when you stop early; for small lists, plain list operations are usually as fast or faster, as sequences add overhead per element.
val ids = listOf(1, 2, 3, 4, 5)
val fromList = ids.map { println("map $it"); it * 10 }
.first { it > 20 } // prints map 1 to map 5
val fromSeq = ids.asSequence()
.map { println("map $it"); it * 10 }
.first { it > 20 } // prints map 1, map 2, map 324. Why should you use viewModelScope or lifecycleScope instead of GlobalScope?
Structured concurrency means every coroutine belongs to a scope, and the scope's lifetime limits the coroutine's lifetime. viewModelScope is cancelled when the ViewModel is cleared, and lifecycleScope when its owner is destroyed, so their work stops by itself when the user leaves the screen. GlobalScope has no parent, so a coroutine started there keeps running, holds whatever it captured and may update a screen that is gone. Work that must outlive the screen, such as uploading a resume, belongs in WorkManager or an injected application-level scope, never GlobalScope.
25. How do exceptions work in coroutines, and where does a try/catch actually help?
An uncaught exception in a child coroutine cancels its parent and, through it, the siblings; with launch it then reaches the CoroutineExceptionHandler of the root coroutine, or crashes the app if there is none. A try/catch around launch does not catch it, because launch returns at once; put the try/catch inside the coroutine, around the suspend call. With async the exception is rethrown by await, but a failing child async still cancels its parent. Use supervisorScope or a SupervisorJob when one failed child should not cancel the others, and never swallow CancellationException.
// Does NOT catch the network error
try {
viewModelScope.launch { api.getProfile() }
} catch (e: IOException) { showError() }
// Catches it
viewModelScope.launch {
try {
_profile.value = api.getProfile()
} catch (e: IOException) {
_error.value = "Check your internet connection"
}
}26. Why is coroutine cancellation called cooperative, and how do you write code that respects it?
Cancelling a coroutine only marks its Job as cancelling; the code stops at the next suspension point that checks for cancellation, such as delay, withContext or a suspending Room or Retrofit call, which throws CancellationException. A CPU loop with no suspend calls keeps running after cancel, so call ensureActive or yield inside it. Cleanup in finally still runs, but suspend calls there fail unless wrapped in withContext(NonCancellable). The classic bug is catching Exception around suspend code, which swallows CancellationException and lets a cancelled coroutine carry on; rethrow it.
suspend fun compressPhotos(files: List<File>) =
withContext(Dispatchers.Default) {
for (file in files) {
ensureActive() // stop promptly if cancelled
compress(file) // CPU work, no suspension inside
}
}
try {
repo.sync()
} catch (e: Exception) {
if (e is CancellationException) throw e
log(e)
}27. What is the difference between StateFlow, SharedFlow and LiveData?
StateFlow always has a current value, gives only that latest value to a new collector and skips updates equal to the current one, which suits screen state. SharedFlow needs no initial value and can be configured with replay and a buffer, so it can broadcast a stream of values to several collectors. LiveData is the older lifecycle-aware holder: it delivers only to active observers, but it is tied to Android and has few operators. StateFlow is plain Kotlin and works in pure Kotlin modules and tests, but it is not lifecycle-aware; collect it with repeatOnLifecycle or collectAsStateWithLifecycle.
28. How should you collect a Flow in a Fragment or a composable?
Collect so that collection stops when the UI is not visible. In a Fragment, launch in viewLifecycleOwner.lifecycleScope and wrap the collect in repeatOnLifecycle(Lifecycle.State.STARTED): collection starts in onStart, is cancelled in onStop and restarts when the user returns. In Compose, use collectAsStateWithLifecycle. A plain lifecycleScope.launch with collect keeps collecting in the background, wasting work and possibly showing dialogs on a hidden screen. In a Fragment use the view lifecycle, not the fragment's own, because the view can be destroyed while the fragment lives on.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
}
@Composable
fun JobsRoute(viewModel: JobsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
JobsScreen(state)
}29. How would you build a search box that calls the API only after the user stops typing?
Keep the query in a MutableStateFlow and derive the results with Flow operators. debounce waits until the text has been stable for a short time, such as 300 ms, so fast typing does not fire one request per letter. distinctUntilChanged drops repeats, and flatMapLatest cancels the previous request when a new query arrives, so a slow old response can never overwrite newer results. Put catch inside the inner flow, so one failed request does not end the stream, and use stateIn to share the result with the UI. A common bug is flatMapMerge or flatMapConcat, which let stale results through.
private val query = MutableStateFlow("")
val results: StateFlow<SearchUi> = query
.debounce(300)
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.length < 2) flowOf(SearchUi.Idle)
else flow { emit(SearchUi.Results(api.search(q))) }
.onStart { emit(SearchUi.Loading) }
.catch { emit(SearchUi.Error) }
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
SearchUi.Idle
)
fun onQueryChanged(text: String) { query.value = text }30. What is state hoisting in Compose, and why does it matter?
State hoisting means moving state out of a composable to its caller and passing in the value plus an event callback, such as value and onValueChange. The child becomes stateless: it shows what it is given and reports what the user did. That makes it reusable, easy to preview and easy to test, and it gives one source of truth, so two parts of the screen cannot disagree. Hoist state to the lowest common parent that needs to read or change it; screen-level state usually ends up in the ViewModel. This is unidirectional data flow: state goes down, events go up.
@Composable
fun NoticePeriodField(value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit)) },
label = { Text("Notice period in days") }
)
}
@Composable
fun ApplyForm(viewModel: ApplyViewModel) {
val form by viewModel.form.collectAsStateWithLifecycle()
NoticePeriodField(form.noticeDays, viewModel::onNoticeChanged)
}31. What triggers recomposition, and why do LazyColumn items need keys?
Compose recomposes a composable when a State it read during composition changes, or when its caller recomposes and passes new parameters; a composable whose parameters are stable and unchanged can be skipped. In a LazyColumn, item identity is based on position by default, so inserting a job at the top makes every row look changed, and remembered state inside a row moves to the wrong item. Passing key = { it.id } ties each row to a stable id, which keeps remembered state with the right item, enables item animations and avoids extra work. Keys must be unique, or the list throws an exception.
LazyColumn {
items(items = jobs, key = { job -> job.id }) { job ->
var expanded by rememberSaveable { mutableStateOf(false) }
JobRow(
job = job,
expanded = expanded,
onToggle = { expanded = !expanded },
modifier = Modifier.animateItem()
)
}
}32. When do you use LaunchedEffect, DisposableEffect and rememberUpdatedState?
Composables must not start work directly in their body, because they can run many times. LaunchedEffect(key) runs a suspend block in a coroutine when it enters the composition, cancels it when it leaves and restarts it when the key changes, which suits a snackbar or a timer. DisposableEffect is for things that need cleanup, such as adding a lifecycle observer, with the removal in onDispose. rememberUpdatedState lets a long-running effect call the latest lambda without restarting. Using Unit as the key when the effect really depends on a value is a common bug.
@Composable
fun OtpTimer(seconds: Int, onFinished: () -> Unit) {
val latestOnFinished by rememberUpdatedState(onFinished)
LaunchedEffect(seconds) {
delay(seconds * 1000L)
latestOnFinished()
}
}
@Composable
fun LifecycleLogger(owner: LifecycleOwner) {
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, e -> Log.d("UI", "$e") }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}33. Which state survives rotation, and which survives process death: remember, rememberSaveable, ViewModel or SavedStateHandle?
remember survives recomposition only, so it is lost on rotation. rememberSaveable stores its value in the saved instance state, so it survives both rotation and process death, but only for small values a Bundle can hold, such as a selected tab or typed text. A ViewModel survives rotation but not process death, because its memory goes with the process. SavedStateHandle inside the ViewModel does survive process death, so use it for the ids and filters you need to rebuild the screen. Keep large data such as lists in Room or on the server, and save only the keys needed to load it again.
34. How do you change a Room schema in an app that is already live?
Increase the database version and give Room a way to move existing data: a Migration object with the ALTER TABLE or CREATE TABLE statements, or an auto-migration that Room generates from exported schemas for simple changes such as a new column. Turn on exportSchema with a schema directory, commit the JSON files and test migrations with MigrationTestHelper. If the version changes and Room has no migration path, it throws an IllegalStateException. fallbackToDestructiveMigration avoids the crash by deleting all data, fine for a cache but wrong for drafts or offline records the user typed.
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE jobs ADD COLUMN is_remote " +
"INTEGER NOT NULL DEFAULT 0"
)
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()35. How do you handle network errors and add common headers with Retrofit and OkHttp?
Separate the failure kinds. An IOException means no connection or a timeout, an HttpException carries a non-2xx status such as 401 or 500, and a serialization exception means the JSON did not match your model. Catch them in the repository and map them to a small error type the UI understands, instead of letting raw exceptions reach the screen. Headers every request needs, such as the auth token or app version, belong in an OkHttp Interceptor, so no API method repeats them. Add HttpLoggingInterceptor only in debug builds, because logging bodies in release puts personal data into logcat.
class AuthInterceptor(private val tokens: TokenStore) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokens.current()}")
.build()
return chain.proceed(request)
}
}
suspend fun loadJobs(): Result<List<Job>> = try {
Result.success(api.jobs().map { it.toJob() })
} catch (e: IOException) {
Result.failure(NoInternetError())
} catch (e: HttpException) {
Result.failure(ServerError(e.code()))
}36. How do you structure an Android app with MVVM and a repository layer?
The UI layer, activities, fragments or composables, only renders state and forwards user actions. The ViewModel turns those actions into calls on the data layer and exposes one immutable UI state, usually a data class in a StateFlow. Repositories own the data: they decide whether to read from Room, the network or both, so the ViewModel never sees Retrofit or SQL. An optional domain layer holds use cases shared by several ViewModels. Dependencies point one way, so each layer can be tested with fakes. A common smell is a ViewModel that calls Retrofit and exposes ten LiveData fields.
data class JobsUiState(
val isLoading: Boolean = false,
val jobs: List<JobItem> = emptyList(),
val error: String? = null
)
class JobsViewModel(private val repo: JobRepository) : ViewModel() {
private val _ui = MutableStateFlow(JobsUiState(isLoading = true))
val ui: StateFlow<JobsUiState> = _ui.asStateFlow()
fun refresh() = viewModelScope.launch {
_ui.update { it.copy(isLoading = true, error = null) }
repo.refreshJobs()
.onSuccess { list ->
_ui.update { it.copy(isLoading = false, jobs = list) }
}
.onFailure {
_ui.update {
it.copy(isLoading = false, error = "Could not load")
}
}
}
}37. How does Hilt work, and when do you use @Provides versus @Binds?
Hilt is the Jetpack dependency injection library built on Dagger. You annotate the Application with @HiltAndroidApp, activities and fragments with @AndroidEntryPoint and ViewModels with @HiltViewModel, and Hilt generates the components at compile time. Classes you own get an @Inject constructor. For interfaces and third-party types you write a module: @Binds maps an interface to its implementation with no body, while @Provides has a function body that builds an object such as a Retrofit API. Scope annotations like @Singleton share one instance; leave a class unscoped unless it must be shared.
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindJobRepo(impl: DefaultJobRepository): JobRepository
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApi(client: OkHttpClient): JobsApi = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(JobsApi::class.java)
}
class DefaultJobRepository @Inject constructor(
private val api: JobsApi
) : JobRepository38. When should you use WorkManager, and how do you set it up for a reliable upload?
WorkManager is for deferrable work that must finish even if the user leaves the app or the phone restarts, such as uploading a resume, syncing attendance or sending logs. It stores the request, honours constraints such as a connected network or battery not low, and retries with backoff when doWork returns Result.retry. It is not for work the user is waiting on right now, which is just a coroutine, and not for alarms at an exact time. Use enqueueUniqueWork with a stable name, so a double tap does not queue two uploads, and pass only small ids in the input Data, which is limited to 10 KB.
class ResumeUploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = inputData.getLong("resume_id", -1L)
return try {
ResumeUploader.upload(id)
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
}
val request = OneTimeWorkRequestBuilder<ResumeUploadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(workDataOf("resume_id" to resumeId))
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"upload_$resumeId", ExistingWorkPolicy.KEEP, request
)39. Why does a Fragment leak memory when it keeps a ViewBinding, and how do you fix it?
A Fragment can outlive its view: when it goes on the back stack, onDestroyView runs and the views are thrown away, but the Fragment object stays. If a field still points to the binding, it keeps the whole old view tree, and through it the context, in memory. Set the binding to null in onDestroyView and use a non-null getter only between onCreateView and onDestroyView. For the same reason, observe LiveData and collect flows with viewLifecycleOwner, not the fragment itself. Add LeakCanary to debug builds; it reports such leaks with the chain of references that keeps the object alive.
class JobsFragment : Fragment() {
private var _binding: FragmentJobsBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentJobsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}40. How do you unit test a ViewModel that uses coroutines and StateFlow?
Use kotlinx-coroutines-test. viewModelScope runs on Dispatchers.Main, which does not exist in a local JVM test, so replace it with Dispatchers.setMain(StandardTestDispatcher()) before each test and call resetMain after. Wrap the test in runTest, which skips delays using virtual time, and give the ViewModel a fake repository. With StandardTestDispatcher, launched coroutines wait until you call advanceUntilIdle, so you can check the loading state first and the final state after. Inject dispatchers rather than hardcoding Dispatchers.IO, or that work escapes the test scheduler.
class JobsViewModelTest {
@Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) }
@After fun tearDown() { Dispatchers.resetMain() }
@Test
fun refresh_showsJobs() = runTest {
val vm = JobsViewModel(FakeJobRepository(listOf(sampleJob)))
vm.refresh()
assertTrue(vm.ui.value.isLoading)
advanceUntilIdle()
assertEquals(listOf(sampleJob), vm.ui.value.jobs)
}
}Ready to check yourself? Take the free Android (Kotlin) mock test
Android (Kotlin) interview questions: Hard questions (experienced)
20 questions41. Explain how cancellation and failure propagate in a coroutine hierarchy, and when you would use SupervisorJob.
Each coroutine's Job is a child of the Job of the scope it was launched in. Cancelling a parent cancels all its children, and a parent completes only after its children finish. Failure travels the other way: with a normal Job, a child that throws cancels its parent, which cancels every sibling, so one failed call aborts the group. A SupervisorJob, or supervisorScope, stops that upward spread, so children fail alone; viewModelScope uses one. A classic trap is launch(SupervisorJob()): the new Job becomes the coroutine's parent, cutting it off from the scope instead of supervising it.
suspend fun loadDashboard() = supervisorScope {
val jobs = async { api.recommendedJobs() }
val alerts = async { api.alerts() } // may fail alone
val alertList = try {
alerts.await()
} catch (e: IOException) {
emptyList()
}
Dashboard(jobs.await(), alertList)
}
// Trap: no longer a child of viewModelScope, not cancelled with it
viewModelScope.launch(SupervisorJob()) { sync() }42. How does the Kotlin compiler implement suspend functions?
The compiler uses continuation-passing style: each suspend function gets a hidden extra parameter, a Continuation, and returns Any?, so it can return either the result or the COROUTINE_SUSPENDED marker. Its body becomes a state machine with a label per suspension point, and local variables are kept in a continuation object. When a call suspends, the function returns the marker and the thread is free; later resumeWith calls it again and it jumps to the saved label. So suspend functions are callable only from suspend code or coroutine builders, and suspending costs an object, not a thread.
// What you write
suspend fun loadProfile(id: Long): Profile {
val user = api.user(id) // suspension point 1
val stats = api.stats(user.id) // suspension point 2
return Profile(user, stats)
}
// Roughly what the compiler generates (simplified)
// fun loadProfile(id: Long, cont: Continuation<Profile>): Any?
// label 0: call api.user; if COROUTINE_SUSPENDED, return it
// label 1: save user, call api.stats; maybe return the marker
// label 2: build Profile and return it43. How do you protect shared mutable state when several coroutines update it?
Coroutines on a multi-threaded dispatcher such as Default can run truly in parallel, so a plain counter++ or list.add from many of them loses updates, just as with threads. Options, simplest first: confine the state to one thread, such as changing UI state only on Main; use atomics or MutableStateFlow.update, which retries with compare-and-set, so its lambda must have no side effects; or guard a larger critical section with a Mutex, which suspends instead of blocking. You cannot suspend inside synchronized, and a Mutex is not reentrant, so locking it twice in one coroutine deadlocks.
private val _state = MutableStateFlow(CartState())
fun addItem(item: Item) {
// Safe: retries if another coroutine changed state meanwhile
_state.update { it.copy(items = it.items + item) }
}
private val mutex = Mutex()
private var cachedToken: String? = null
suspend fun token(): String = mutex.withLock {
cachedToken ?: api.refreshToken().also { cachedToken = it }
}44. Why do many apps expose state with stateIn and SharingStarted.WhileSubscribed(5000)?
stateIn turns a cold Flow, such as a Room query combined with user settings, into a StateFlow that many collectors share. The started policy decides when the upstream runs: Eagerly and Lazily keep it running for the rest of the ViewModel's life once started. WhileSubscribed stops the upstream when the last collector leaves, and the 5000 ms timeout keeps it alive through a short gap such as rotation, when the old UI stops collecting and the new one starts moments later. So rotation does not restart the query, but a screen left in the background stops its work. Pick a sensible initial value.
val ui: StateFlow<HomeUi> = combine(
jobDao.observeSaved(),
settings.preferredCity
) { saved, city -> HomeUi(savedJobs = saved, city = city) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUi()
)45. What happens when a Flow emits faster than the collector can handle, and how do buffer, conflate and collectLatest differ?
By default a Flow is sequential: emit suspends until the collector has finished with the previous value, so a slow collector slows the producer and nothing is lost. buffer runs the producer in a separate coroutine connected by a channel, so both work at once and values queue up to the capacity. conflate keeps only the newest value while the collector is busy, which suits location or progress updates. collectLatest cancels the collector's current work when a new value arrives and starts again with the new one, which suits redrawing a chart. Choose by whether every value matters.
locationUpdates() // emits every 100 ms
.conflate() // keep only the newest while busy
.collect { loc -> renderMap(loc) }
auditEvents
.buffer(capacity = 16) // producer runs ahead, nothing dropped
.collect { saveToDisk(it) }
queryFlow.collectLatest { q -> // previous search is cancelled
showResults(api.search(q))
}46. What makes a composable skippable, and how do you fix a list screen that recomposes too much?
When a parent recomposes, Compose can skip a child whose parameters equal last time's, but only if their types are stable: equals can be trusted and changes are observable. Primitives, String and data classes with only stable val properties are stable. List is unstable, since the interface may hide a mutable implementation, as are classes from modules without the Compose compiler. Strong skipping, on by default since Kotlin 2.0.20, compares unstable parameters by instance, so a new list instance still recomposes. Fixes: immutable collections, @Immutable or @Stable, or a stability config file.
// Unstable parameter type: List may be mutable behind the interface
data class JobsUi(val jobs: List<Job>)
// Stable: kotlinx.collections.immutable
data class StableJobsUi(val jobs: ImmutableList<Job>)
// Or promise immutability yourself (you must keep the promise)
@Immutable
data class JobsUiWrapper(val jobs: List<Job>)47. What are the three phases of a Compose frame, and how does deferring state reads improve performance?
Each frame goes through composition, which runs composables to decide what to show, then layout, which measures and places, then drawing. A state read is tracked in the phase where it happens, and a change restarts work only from that phase. Read a scroll offset in the composable body and pass it to Modifier.offset(x, y), and every scrolled pixel recomposes. Read it inside the lambda form, Modifier.offset { }, or in drawBehind, and only layout or draw reruns. derivedStateOf helps when a fast-changing state feeds a value that changes rarely, such as showing a scroll-to-top button.
@Composable
fun CollapsingHeader(listState: LazyListState) {
// Read in the layout phase: no recomposition while scrolling
Image(
painter = painterResource(R.drawable.banner),
contentDescription = null,
modifier = Modifier.offset {
IntOffset(0, -listState.firstVisibleItemScrollOffset / 2)
}
)
val showTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (showTop) ScrollToTopButton()
}48. How should a ViewModel send one-off events such as navigation or a snackbar message to the UI?
The trade-off is about losing events. A SharedFlow with no replay drops an event emitted while nobody collects, for example during rotation. A Channel exposed with receiveAsFlow buffers events until a collector takes them, which is better, but an event can still be lost in rare cases if collection is cancelled just after it is received. The Android architecture guidance models the event as state instead, such as a userMessage field in the UI state, which the UI shows and then reports as handled so the ViewModel clears it. It survives rotation and is easy to test; the cost is a callback.
data class ApplyUi(
val submitting: Boolean = false,
val userMessage: String? = null
)
fun onApplied() = _ui.update { it.copy(userMessage = "Application sent") }
fun onMessageShown() = _ui.update { it.copy(userMessage = null) }
// In the composable
ui.userMessage?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
viewModel.onMessageShown()
}
}49. How would you design an offline-first job list that works on a patchy mobile network?
Make the Room database the single source of truth. The UI observes a Flow from Room only, so cached jobs show at once and update whenever the table changes. A refresh fetches from the network and writes to Room in a transaction; the screen updates through the same Flow, never from the response directly. User actions, such as saving a job, go to Room first with a pending flag, and a WorkManager job with a network constraint sends them later. Decide conflict rules early, such as server wins by updated_at, and when a refresh fails but a cache exists, show a stale-data hint, not an error.
class OfflineJobRepository @Inject constructor(
private val db: AppDatabase,
private val dao: JobDao,
private val api: JobsApi
) {
fun observeJobs(city: String): Flow<List<Job>> =
dao.observeByCity(city).map { rows -> rows.map { it.toJob() } }
suspend fun refresh(city: String) {
val fresh = api.search(city, page = 1).jobs
db.withTransaction {
dao.deleteByCity(city)
dao.insertAll(fresh.map { it.toEntity() })
}
}
}50. How would you modularize a large Android app, and what do you gain?
Split by feature and by layer. Feature modules such as feature-jobs hold their screens and ViewModels, core modules such as core-network hold shared code, and the app module wires them together. Feature modules should not depend on each other; they connect through navigation routes or interfaces in a core module. The gains are faster incremental builds, since Gradle recompiles only changed modules and their dependents, clear team ownership and boundaries the build enforces. Prefer implementation over api dependencies so changes do not leak upward, and share build logic in convention plugins.
51. How do you make a screen survive process death, and how do you test it?
In the background, Android can kill the app's process to free memory. When the user returns, the system recreates the activity and back stack from saved state, but everything that lived only in memory, including ViewModels and singletons, is gone. So save the minimum needed to rebuild the screen, such as the job id, filter and scroll position, in SavedStateHandle or rememberSaveable, and reload the rest from Room or the network. Keep the saved Bundle small, or you risk TransactionTooLargeException. To test, background the app, run adb shell am kill with the package name, then reopen it.
52. A Room query on a large table is slow and the list screen janks. How do you investigate and fix it?
First prove it is the query: run it with EXPLAIN QUERY PLAN and look for a full table scan. Add an index on the columns in WHERE and ORDER BY, declared in the indices of @Entity so it is part of the schema and its migration. Select only the columns the list shows into a small data class instead of SELECT * with long description text, and page the results with Paging 3 rather than loading thousands of rows. For relations, use @Relation with @Transaction so parent and child queries see the same data. Batch writes in one transaction; separate inserts each pay for a commit.
@Entity(
tableName = "jobs",
indices = [Index(value = ["city", "posted_at"])]
)
data class JobEntity(
@PrimaryKey val id: Long,
val title: String,
val city: String,
@ColumnInfo(name = "posted_at") val postedAt: Long,
val description: String
)
data class JobRow(val id: Long, val title: String, val city: String)
@Query(
"SELECT id, title, city FROM jobs " +
"WHERE city = :city ORDER BY posted_at DESC"
)
fun pagedByCity(city: String): PagingSource<Int, JobRow>53. How do you refresh an expired access token safely when several requests fail with 401 at the same time?
Use an OkHttp Authenticator, which OkHttp calls on a 401 response; it returns a new request with a fresh token, or null to give up. The danger is five parallel requests all refreshing, wasting the refresh token or logging the user out. Synchronise the refresh and, inside the lock, compare the token the failed request used with the current one: if another thread already refreshed it, just retry with the new token. Count earlier attempts through priorResponse to avoid an endless loop, and if the refresh fails, clear the session and show login. It runs on an OkHttp thread, so it may block.
class TokenAuthenticator(private val store: TokenStore) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (attempts(response) >= 2) return null
val failed = response.request.header("Authorization")
?.removePrefix("Bearer ")
val token = synchronized(this) {
val current = store.accessToken()
if (current != null && current != failed) current
else store.refreshBlocking() ?: return null
}
return response.request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
}
private fun attempts(r: Response): Int =
generateSequence(r) { it.priorResponse }.count()
}54. Explain Hilt components and scopes, and how you would pass a runtime value such as a job id into a ViewModel.
Hilt generates a hierarchy of components, each with a scope: SingletonComponent with @Singleton, ActivityRetainedComponent with @ActivityRetainedScoped, which survives rotation, ViewModelComponent with @ViewModelScoped, then ActivityComponent, FragmentComponent and ViewComponent. A child component can use its parents' bindings, never the reverse. A scope means one instance per component instance, so an object holding an activity must never be a singleton, or it leaks. For runtime values, read navigation arguments from SavedStateHandle, or use @AssistedInject with an @AssistedFactory.
@HiltViewModel(assistedFactory = JobDetailViewModel.Factory::class)
class JobDetailViewModel @AssistedInject constructor(
@Assisted private val jobId: Long,
private val repo: JobRepository
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(jobId: Long): JobDetailViewModel
}
}
// Compose call site
val vm = hiltViewModel<JobDetailViewModel, JobDetailViewModel.Factory> {
factory -> factory.create(jobId)
}55. What does WorkManager guarantee and not guarantee, and how do long-running jobs fit in on Android 14?
WorkManager guarantees that enqueued work runs once its constraints are met, even after an app restart or a reboot, but not exactly when: Doze, App Standby buckets and battery saver can delay it, and periodic work runs at most every 15 minutes. Mark urgent short work, such as sending a chat message, as expedited. A worker normally gets about ten minutes; for longer work call setForeground to run as a foreground service with a notification, and when targeting Android 14 declare its foreground service type, such as dataSync. A force stop halts the app's work until it is opened again.
56. What changed for media access and permissions in Android 13 and 14, and how should a photo or resume upload handle it?
Android 13 split storage reads into READ_MEDIA_IMAGES, READ_MEDIA_VIDEO and READ_MEDIA_AUDIO, and made notifications a runtime permission, POST_NOTIFICATIONS. Android 14 added partial access: the user can allow only selected photos, signalled by READ_MEDIA_VISUAL_USER_SELECTED, so the app must cope with seeing a few images and let the user pick more. Better still, avoid the permission: the photo picker returns chosen images with no storage permission, and a PDF resume comes through the system file picker with OpenDocument. Google Play also restricts broad photo and video permissions.
val pickPhoto = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> uri?.let(::uploadProfilePhoto) }
val pickResume = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let(::uploadResume) }
// Neither needs a storage permission
pickPhoto.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
pickResume.launch(arrayOf("application/pdf"))57. How do you measure and improve app startup time?
Measure first, on a release build and a real mid-range phone. Android vitals in Play Console shows real users' cold starts, and Macrobenchmark with StartupTimingMetric measures time to initial and full display in the lab. Then cut the work before the first frame: move SDK setup out of Application.onCreate or make it lazy, use the App Startup library for initialisers that must run, keep disk and network work off the main thread, and keep the first screen light. Add a Baseline Profile so the startup path is compiled ahead of time, and call reportFullyDrawn when real content appears.
58. Users report jank and ANRs on low-end phones. How do you find and fix the cause?
An ANR means the main thread was blocked too long, for example an input event not handled within 5 seconds; jank means frames missing their deadline, about 16 ms at 60 Hz. Start with Android vitals and the ANR traces to see what the main thread was doing, then reproduce with a Perfetto trace or the Android Studio profiler. Usual causes are disk or database reads, JSON parsing or a SharedPreferences commit on the main thread, heavy layouts, work inside composition and lock contention. Use StrictMode in debug builds to flag main-thread disk and network access, and JankStats in production.
class JobsApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}59. How do you reduce the size of an Android app, and what can go wrong?
Publish an Android App Bundle, so Google Play delivers APKs split by screen density, CPU architecture and language instead of one universal APK. In the release build turn on R8 with isMinifyEnabled to remove unused code and shorten names, and isShrinkResources to drop unused resources. Convert large PNGs to WebP or vectors and check heavy libraries with the APK Analyzer. The risk is code reached only by reflection, such as Gson models or classes named in strings: R8 may remove or rename it, so release crashes while debug works. Add keep rules, prefer code generation and test the release build.
// app/build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
# proguard-rules.pro: keep models that Gson fills by reflection
-keep class com.example.jobs.data.remote.model.** { *; }60. What testing strategy would you set up for a medium-sized Android app?
Follow the test pyramid: many fast local tests for ViewModels, repositories, use cases and mappers; fewer integration tests, such as Room DAOs on an in-memory database and Retrofit against MockWebServer; and a few instrumented UI tests for key journeys like login and applying for a job. Prefer fakes to mocks for your own interfaces, since a fake repository behaves like the real one and survives refactors. For Compose, use createComposeRule and find nodes by text, content description or test tag. With Hilt, @TestInstallIn swaps real modules for fakes. Run the unit tests on every pull request.
class ApplyScreenTest {
@get:Rule val compose = createComposeRule()
@Test
fun submitEnabledOnlyAfterNoticePeriod() {
compose.setContent { ApplyScreen(onSubmit = {}) }
compose.onNodeWithText("Submit").assertIsNotEnabled()
compose.onNodeWithTag("notice_field").performTextInput("30")
compose.onNodeWithText("Submit").assertIsEnabled()
}
}Practice questions for November 2026
New every month3 questions from the Android (Kotlin) mock test, the way they appear in it. A new set comes on the 1st of every month. Pick your answer, then open it.
-
How do you write a function that is called on the class name itself, such as User.create(), in Kotlin?
- Mark the function with the static keyword
- Declare the function as open
- Mark the function with @JvmField
- Put it inside a companion object in the class
Show the answer
Answer: Put it inside a companion object in the class. Kotlin has no static keyword. Members of a companion object can be called through the class name, as in User.create(). On the JVM they are members of the companion instance unless you add @JvmStatic, which matters when Java code calls them.
-
Which API should a Fragment use to handle the system Back gesture on Android 13 and later?
- Override onBackPressed() in the Fragment class itself
- Override onKeyDown for KEYCODE_BACK in the Fragment
- An OnBackPressedCallback added to the activity's dispatcher
- Add android:back="false" to the fragment's entry in the manifest file
Show the answer
Answer: An OnBackPressedCallback added to the activity's dispatcher. requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { } handles Back only while the view is alive, and isEnabled = false hands it back. Activity.onBackPressed() was deprecated in API level 33 for predictive back.
-
A DAO has suspend functions debit() and credit(). How do you make a transfer all-or-nothing?
- Call both inside db.withTransaction { }
- Call them inside runBlocking
- Annotate both with @Insert(onConflict = REPLACE)
- Call them one after another from viewModelScope
Show the answer
Answer: Call both inside db.withTransaction { }. withTransaction runs the suspend block in one SQLite transaction, so if credit throws, debit is rolled back. A @Transaction DAO method with a body does the same. Separate calls could leave money debited but never credited.
Ready to apply?
There are 155 open jobs asking for Android (Kotlin) on CoreHRx right now. Create a free profile once and apply with it.