Android Kotlin Interview Questions — 40 with Code and Answers
If you're preparing for an Android developer interview in 2025, you're not going to coast on theory alone. Interviewers at mid-to-senior level want to see code: how you handle lifecycle leaks, why you pick StateFlow over LiveData, what happens when Room migrates a schema. This guide covers 40 real questions with Kotlin code answers, organized by topic so you can study the areas where you're weakest.
Kotlin Language Fundamentals
1. What is a coroutine, and how does it differ from a thread?
A coroutine is a suspendable unit of work. Unlike a thread, it doesn't block the OS thread it runs on — it suspends execution and releases the thread until the result is ready. You can run thousands of coroutines on a handful of threads.
// Thread-blocking approach (avoid this)
fun loadDataBlocking(): String {
Thread.sleep(2000) // blocks the calling thread
return "result"
}
// Coroutine approach
suspend fun loadData(): String {
delay(2000) // suspends, doesn't block
return "result"
}
// Launching a coroutine
viewModelScope.launch {
val data = loadData() // safe on main thread
_uiState.value = data
}What interviewers look for: Understanding that delay doesn't block a thread, and that viewModelScope automatically cancels the coroutine when the ViewModel is destroyed.
2. Explain `launch` vs `async` in coroutines.
launch starts a coroutine and returns a Job — fire-and-forget. async starts a coroutine and returns a Deferred, which you await() to get a result. Use async when you need the return value, especially for parallel execution.
// launch: fire and forget
viewModelScope.launch {
saveToDatabase(user)
}
// async: parallel execution with results
viewModelScope.launch {
val profileDeferred = async { fetchUserProfile(userId) }
val postsDeferred = async { fetchUserPosts(userId) }
val profile = profileDeferred.await()
val posts = postsDeferred.await()
// both requests ran in parallel
_uiState.value = UiState(profile, posts)
}Common mistake: Using async when you don't need the result, or await-ing immediately after async (which eliminates the parallelism benefit).
3. What is `CoroutineScope` and why does it matter?
Scope defines the lifetime of coroutines. If the scope is cancelled, all coroutines within it are cancelled. Android provides structured scopes so you don't leak work:
viewModelScope— cancelled when the ViewModel is clearedlifecycleScope— cancelled when the lifecycle owner (Activity/Fragment) is destroyedGlobalScope— lives for the app's lifetime (avoid unless you have a very specific reason)
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch { // auto-cancelled on ViewModel.onCleared()
val result = repository.getData()
_data.value = result
}
}
}
// In a Fragment
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
// Use viewLifecycleOwner, NOT lifecycleOwner
// viewLifecycleOwner is destroyed when the view is destroyed
viewModel.uiState.collect { state -> render(state) }
}
}
}What interviewers look for: The distinction between lifecycleOwner and viewLifecycleOwner in Fragments. Using the wrong one can cause crashes.
4. What are Kotlin extension functions? Give a practical Android example.
Extension functions let you add methods to existing classes without inheriting from them or modifying their source.
// Extension on View
fun View.visible() { visibility = View.VISIBLE }
fun View.gone() { visibility = View.GONE }
fun View.invisible() { visibility = View.INVISIBLE }
// Extension on Context
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
// Extension on String for validation
fun String.isValidEmail(): Boolean {
return android.util.Patterns.EMAIL_ADDRESS.matcher(this).matches()
}
// Usage
binding.progressBar.gone()
binding.content.visible()
requireContext().showToast("Saved!")
if (emailInput.isValidEmail()) { /* proceed */ }What interviewers look for: That extension functions are resolved statically (not virtual dispatch), and that they can't access private members of the class they extend.
5. What are sealed classes, and when do you use them over enums?
Sealed classes restrict class hierarchies: all subclasses must be declared in the same file. Unlike enums, each subclass can hold different data. They're ideal for representing UI state or result types.
// Result type with sealed class
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable, val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// UI State
sealed class ProfileUiState {
object Idle : ProfileUiState()
object Loading : ProfileUiState()
data class Success(val user: User, val posts: List<Post>) : ProfileUiState()
data class Error(val message: String) : ProfileUiState()
}
// Exhaustive when expression — compiler enforces all branches
fun render(state: ProfileUiState) = when (state) {
is ProfileUiState.Idle -> showEmpty()
is ProfileUiState.Loading -> showSpinner()
is ProfileUiState.Success -> showContent(state.user, state.posts)
is ProfileUiState.Error -> showError(state.message)
}Key difference from enums: Each subclass is its own type and can carry unique data. Enums are single instances with shared properties.
6. Explain `data class` in Kotlin and its generated methods.
A data class auto-generates equals(), hashCode(), toString(), copy(), and componentN() functions based on properties in the primary constructor.
data class User(
val id: Int,
val name: String,
val email: String
)
val user1 = User(1, "Ana", "ana@example.com")
val user2 = User(1, "Ana", "ana@example.com")
println(user1 == user2) // true (structural equality)
// copy() is extremely useful for immutable updates
val updatedUser = user1.copy(email = "new@example.com")
// Destructuring via componentN
val (id, name, email) = user1What interviewers look for: Understanding that equals only considers primary constructor properties, and that copy() is the right way to update immutable state objects (e.g., in StateFlow).
7. What is the difference between `lateinit` and `lazy`?
// lateinit: mutable var, initialized later, throws if accessed before init
class MyFragment : Fragment() {
private lateinit var adapter: RecyclerView.Adapter<*>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MyAdapter()
binding.recycler.adapter = adapter
}
}
// lazy: immutable val, initialized on first access, thread-safe by default
class MyViewModel : ViewModel() {
private val repository: UserRepository by lazy {
UserRepository(database.userDao())
}
}
// Check before access (lateinit only)
if (::adapter.isInitialized) {
adapter.notifyDataSetChanged()
}lateinit works only on var and non-nullable types. lazy works on val and is evaluated once, cached, and thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED).
Activity and Fragment Lifecycle
8. Walk through the Activity lifecycle and when each callback fires.
onCreate → onStart → onResume → [RUNNING]
[user presses Home] → onPause → onStop
[user returns] → onRestart → onStart → onResume
[Activity finished] → onPause → onStop → onDestroyclass MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize views, ViewModel, restore state
setContentView(R.layout.activity_main)
}
override fun onStart() {
super.onStart()
// Activity visible but not interactive
// Register BroadcastReceivers that need to update UI
}
override fun onResume() {
super.onResume()
// Activity is in the foreground and interactive
// Start animations, sensors, cameras
}
override fun onPause() {
super.onPause()
// Another activity is taking focus
// Stop animations, release camera, save lightweight state
// Keep this method fast — the next activity won't show until it completes
}
override fun onStop() {
super.onStop()
// Activity is no longer visible
// Persist data, unregister receivers
}
override fun onDestroy() {
super.onDestroy()
// Final cleanup — but don't rely on this being called
}
}What interviewers look for: Understanding that onPause must be fast, that onDestroy isn't guaranteed, and the difference between finishing vs the system killing the process.
9. What is the Fragment lifecycle, and how does it differ from Activity?
Fragments have both a fragment lifecycle and a separate view lifecycle. This is a notorious source of bugs.
class MyFragment : Fragment(R.layout.fragment_my) {
// Fragment lifecycle
override fun onAttach(context: Context) { /* fragment attached to activity */ }
override fun onCreate(savedInstanceState: Bundle?) { /* non-view initialization */ }
// View lifecycle starts here
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_my, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Bind views, set up observers — use viewLifecycleOwner here
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
}
override fun onDestroyView() {
super.onDestroyView()
// View is destroyed (e.g., fragment on back stack)
// Clear view references to avoid memory leaks with ViewBinding:
_binding = null
}
// Fragment lifecycle continues after onDestroyView
override fun onDestroy() { /* fragment fully destroyed */ }
override fun onDetach() { /* fragment detached from activity */ }
}Critical interview point: When a Fragment is on the back stack, its view is destroyed (onDestroyView) but the Fragment instance remains. If you hold view references after onDestroyView, you leak memory.
10. How do you pass data between fragments safely?
The recommended approach is shared ViewModel scoped to the Activity, or Fragment Result API for one-time results.
// Shared ViewModel
class SharedViewModel : ViewModel() {
private val _selectedItem = MutableStateFlow<Item?>(null)
val selectedItem: StateFlow<Item?> = _selectedItem.asStateFlow()
fun selectItem(item: Item) { _selectedItem.value = item }
}
// Fragment A — sends data
class ListFragment : Fragment() {
private val sharedViewModel: SharedViewModel by activityViewModels()
fun onItemClick(item: Item) {
sharedViewModel.selectItem(item)
}
}
// Fragment B — receives data
class DetailFragment : Fragment() {
private val sharedViewModel: SharedViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
sharedViewModel.selectedItem.filterNotNull().collect { item ->
showDetail(item)
}
}
}
}
// Fragment Result API (for one-time results, e.g., dialog confirmations)
// In the dialog/child fragment:
setFragmentResult("requestKey", bundleOf("result" to true))
// In the parent fragment:
setFragmentResultListener("requestKey") { _, bundle ->
val result = bundle.getBoolean("result")
}ViewModel and SavedStateHandle
11. What is ViewModel and why doesn't it survive process death?
ViewModel survives configuration changes (screen rotation) because it's stored in a ViewModelStore that is retained across recreations. However, it does NOT survive process death — the OS kills the process and there's no memory to retain.
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() { _count.value++ }
override fun onCleared() {
super.onCleared()
// Cancel custom coroutines or release resources here
}
}
// In Activity
class MainActivity : AppCompatActivity() {
private val viewModel: CounterViewModel by viewModels()
// ViewModel persists across rotation — "by viewModels()" is the key
}What interviewers look for: The distinction between configuration change survival (ViewModel handles) vs process death survival (SavedStateHandle or Room/DataStore needed).
12. How does `SavedStateHandle` work and when do you need it?
SavedStateHandle is a key-value map that persists across both configuration changes AND process death (saved to the Bundle). Use it for UI state that must survive death: selected tab, scroll position, form input.
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// The value is automatically persisted and restored
var searchQuery: String
get() = savedStateHandle.get<String>("search_query") ?: ""
set(value) { savedStateHandle["search_query"] = value }
// Or as a StateFlow backed by SavedStateHandle
val searchQuery: StateFlow<String> = savedStateHandle.getStateFlow("search_query", "")
fun updateQuery(query: String) {
savedStateHandle["search_query"] = query
}
}With Hilt, SavedStateHandle is injected automatically when declared in the constructor. Without Hilt, use SavedStateViewModelFactory.
LiveData vs StateFlow
13. Compare LiveData and StateFlow. When do you choose each?
// LiveData — lifecycle-aware, only updates active observers
class OldViewModel : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
fun loadUser() {
viewModelScope.launch {
_user.value = repository.getUser()
// postValue() from background thread
}
}
}
// StateFlow — cold flow, holds current value, Kotlin-first
class NewViewModel : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun loadUser() {
viewModelScope.launch {
_user.value = repository.getUser()
}
}
}
// Collecting StateFlow safely in Fragment
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.user.collect { user ->
user?.let { render(it) }
}
}
}Key differences:
StateFlowrequires an initial value;LiveDatadoes notStateFlowdoesn't automatically stop when the UI goes to background — you must userepeatOnLifecycleStateFlowuses equality-based emission (won't emit the same value twice);LiveDatadoesn't deduplicateSharedFlowfor events (one-time actions like navigation or showing a snackbar)
14. What is `SharedFlow` and how does it differ from `StateFlow`?
class EventViewModel : ViewModel() {
// SharedFlow for one-time events (no initial value, not replayed)
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
// StateFlow for UI state (has initial value, replays last value)
private val _state = MutableStateFlow(UiState.Idle)
val state: StateFlow<UiState> = _state.asStateFlow()
fun onButtonClick() {
viewModelScope.launch {
_events.emit(UiEvent.NavigateToDetail(itemId = 42))
}
}
}
sealed class UiEvent {
data class NavigateToDetail(val itemId: Int) : UiEvent()
data class ShowSnackbar(val message: String) : UiEvent()
}Use SharedFlow for navigation commands, snackbar messages, and dialogs — things that should fire once, not replay when the screen rotates.
Jetpack Compose and Recomposition
15. What triggers recomposition in Compose?
Recomposition occurs when the state a composable reads changes. Compose tracks which state objects are read during composition using snapshot state.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
// Only this composable recomposes when count changes
Column {
Text("Count: $count") // recomposes when count changes
Button(onClick = { count++ }) {
Text("Increment") // does NOT recompose when count changes
// Compose is smart enough to skip unchanged subtrees
}
}
}Common recomposition mistakes:
// BAD: Lambda captures are recreated on every recomposition
@Composable
fun BadList(items: List<Item>, onItemClick: (Item) -> Unit) {
items.forEach { item ->
ItemRow(item = item, onClick = { onItemClick(item) }) // new lambda every time
}
}
// GOOD: Use remember for stable lambdas
@Composable
fun GoodList(items: List<Item>, onItemClick: (Item) -> Unit) {
val stableOnClick = rememberUpdatedState(onItemClick)
items.forEach { item ->
key(item.id) { // key helps Compose identify items
ItemRow(item = item, onClick = { stableOnClick.value(item) })
}
}
}16. What is the difference between `remember` and `rememberSaveable`?
@Composable
fun FormScreen() {
// remember: survives recomposition, lost on configuration change
var tempValue by remember { mutableStateOf("") }
// rememberSaveable: survives recomposition AND configuration changes
var persistedInput by rememberSaveable { mutableStateOf("") }
// For custom objects, implement Saver
var customState by rememberSaveable(stateSaver = CustomStateSaver) {
mutableStateOf(CustomState())
}
TextField(
value = persistedInput,
onValueChange = { persistedInput = it },
label = { Text("This survives rotation") }
)
}
// Custom saver for complex types
val CustomStateSaver = Saver<CustomState, Bundle>(
save = { state -> Bundle().apply { putString("key", state.value) } },
restore = { bundle -> CustomState(bundle.getString("key") ?: "") }
)17. How do you collect a Flow/StateFlow in Compose?
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle: lifecycle-aware, stops when app is backgrounded
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// collectAsState: collects always (even when backgrounded)
// val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is ProfileUiState.Loading -> CircularProgressIndicator()
is ProfileUiState.Success -> ProfileContent((uiState as ProfileUiState.Success).user)
is ProfileUiState.Error -> ErrorMessage((uiState as ProfileUiState.Error).message)
}
}collectAsStateWithLifecycle() (from lifecycle-runtime-compose) is the preferred approach — it respects lifecycle and saves battery/CPU when the app is in the background.
18. What are side effects in Compose and when do you use `LaunchedEffect` vs `SideEffect`?
@Composable
fun SearchScreen(query: String) {
// LaunchedEffect: runs a coroutine when key changes
// Re-runs whenever `query` changes, cancels previous coroutine
LaunchedEffect(query) {
delay(300) // debounce
performSearch(query)
}
// SideEffect: runs on every successful recomposition
// Use to sync Compose state with non-Compose code
SideEffect {
analyticsTracker.setCurrentScreen("SearchScreen")
}
// DisposableEffect: cleanup when composable leaves composition
DisposableEffect(Unit) {
val listener = SomeListener { /* handle event */ }
eventBus.register(listener)
onDispose {
eventBus.unregister(listener)
}
}
}What interviewers look for: Understanding that LaunchedEffect(Unit) runs once, LaunchedEffect(key) runs whenever key changes, and that side effects must be used inside effect handlers — never in the composition body directly.
Room Database and Migrations
19. Set up a Room database with a DAO.
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "full_name") val name: String,
val email: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY created_at DESC")
fun getAllUsers(): Flow<List<User>> // Flow keeps UI in sync
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: Int): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User): Long
@Update
suspend fun updateUser(user: User)
@Delete
suspend fun deleteUser(user: User)
@Query("DELETE FROM users WHERE id = :userId")
suspend fun deleteUserById(userId: Int)
}
@Database(entities = [User::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build().also { INSTANCE = it }
}
}
}
}20. How do you handle Room database migrations?
// Migration from version 1 to version 2: add a column
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE users ADD COLUMN phone_number TEXT DEFAULT '' NOT NULL"
)
}
}
// Migration from version 2 to version 3: create a new table
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE IF NOT EXISTS `posts` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` INTEGER NOT NULL,
`content` TEXT NOT NULL,
FOREIGN KEY(`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
)
""".trimIndent())
}
}
// Register migrations in the builder
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
// Nuclear option: destructive migration (data loss — dev only)
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.fallbackToDestructiveMigration()
.build()What interviewers look for: Always export schema (exportSchema = true in @Database), version control the schema JSON files, and write migration tests using MigrationTestHelper.
21. How do you test Room DAOs?
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries() // only in tests
.build()
userDao = database.userDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun insertAndRetrieveUser() = runTest {
val user = User(name = "Ana García", email = "ana@example.com")
val insertedId = userDao.insertUser(user)
val retrieved = userDao.getUserById(insertedId.toInt())
assertThat(retrieved?.name).isEqualTo("Ana García")
}
@Test
fun getAllUsersFlow_emitsOnInsert() = runTest {
val users = mutableListOf<List<User>>()
val job = launch { userDao.getAllUsers().toList(users) }
userDao.insertUser(User(name = "Test", email = "test@example.com"))
job.cancel()
assertThat(users.last()).hasSize(1)
}
}Retrofit and OkHttp Networking
22. Set up Retrofit with OkHttp interceptors.
// API interface
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: Int): UserResponse
@POST("users")
suspend fun createUser(@Body request: CreateUserRequest): UserResponse
@GET("posts")
suspend fun getPosts(
@Query("page") page: Int,
@Query("limit") limit: Int = 20
): List<PostResponse>
}
// OkHttp setup with interceptors
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider)) // add auth headers
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
// Retrofit instance
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val apiService: ApiService = retrofit.create(ApiService::class.java)
// Auth interceptor implementation
class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer ${tokenProvider.getToken()}")
.addHeader("Accept", "application/json")
.build()
return chain.proceed(request)
}
}23. How do you handle API errors gracefully with Retrofit?
// Wrap responses in a Result type
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
object NetworkException : NetworkResult<Nothing>()
}
// Repository with error handling
class UserRepository(private val apiService: ApiService) {
suspend fun getUser(userId: Int): NetworkResult<User> {
return try {
val response = apiService.getUser(userId)
NetworkResult.Success(response.toDomainModel())
} catch (e: HttpException) {
val errorMessage = e.response()?.errorBody()?.string() ?: "Unknown error"
NetworkResult.Error(e.code(), errorMessage)
} catch (e: IOException) {
NetworkResult.NetworkException
}
}
}
// Using the result in ViewModel
class UserViewModel(private val repository: UserRepository) : ViewModel() {
fun loadUser(userId: Int) {
viewModelScope.launch {
_uiState.value = ProfileUiState.Loading
when (val result = repository.getUser(userId)) {
is NetworkResult.Success -> _uiState.value = ProfileUiState.Success(result.data)
is NetworkResult.Error -> _uiState.value = ProfileUiState.Error(result.message)
is NetworkResult.NetworkException -> _uiState.value = ProfileUiState.Error("No internet connection")
}
}
}
}Hilt Dependency Injection
24. How do you set up Hilt in an Android project?
// 1. Annotate your Application class
@HiltAndroidApp
class MyApplication : Application()
// 2. Annotate entry points (Activities, Fragments, ViewModels)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var analyticsTracker: AnalyticsTracker
private val viewModel: MainViewModel by viewModels()
}
// 3. Define a module for dependencies you don't own
@Module
@InstallIn(SingletonComponent::class) // lives as long as the app
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor())
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
// 4. Inject constructor — Hilt creates this automatically
@Singleton
class UserRepository @Inject constructor(
private val apiService: ApiService,
private val userDao: UserDao
) {
// repository methods
}25. What is the difference between `@Singleton`, `@ActivityScoped`, and `@ViewModelScoped`?
// @Singleton — one instance for the entire app lifetime
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "db").build()
}
}
// @ActivityScoped — one instance per Activity instance
@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
@Provides
@ActivityScoped
fun provideNavigationController(activity: FragmentActivity): NavController {
return activity.findNavController(R.id.nav_host_fragment)
}
}
// @ViewModelScoped — one instance per ViewModel
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: UserRepository, // Singleton injected here
savedStateHandle: SavedStateHandle
) : ViewModel() {
val userId: Int = savedStateHandle["userId"] ?: 0
}What interviewers look for: Understanding that @HiltViewModel + @ViewModelScoped allows ViewModel-scoped dependencies that are shared between a ViewModel and its injected collaborators, all cleaned up when the ViewModel is cleared.
26. How do you provide different implementations for the same interface?
interface ImageLoader {
fun load(url: String, imageView: ImageView)
}
class GlideImageLoader @Inject constructor() : ImageLoader {
override fun load(url: String, imageView: ImageView) {
Glide.with(imageView).load(url).into(imageView)
}
}
class PicassoImageLoader @Inject constructor() : ImageLoader {
override fun load(url: String, imageView: ImageView) {
Picasso.get().load(url).into(imageView)
}
}
// Qualifiers to distinguish implementations
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GlideLoader
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PicassoLoader
@Module
@InstallIn(SingletonComponent::class)
abstract class ImageLoaderModule {
@Binds
@GlideLoader
abstract fun bindGlideLoader(impl: GlideImageLoader): ImageLoader
@Binds
@PicassoLoader
abstract fun bindPicassoLoader(impl: PicassoImageLoader): ImageLoader
}
// Inject with qualifier
class MyFragment : Fragment() {
@Inject @GlideLoader lateinit var imageLoader: ImageLoader
}WorkManager
27. How do you schedule background work with WorkManager?
// Define the work
class SyncWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
val userId = inputData.getString("user_id") ?: return Result.failure()
performSync(userId)
Result.success(
workDataOf("sync_count" to 42)
)
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
private suspend fun performSync(userId: String) {
// actual sync work
}
}
// Schedule the work
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("user_id" to "abc123"))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
.addTag("sync_work")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"user_sync",
ExistingWorkPolicy.KEEP, // don't replace if already scheduled
syncRequest
)
// Periodic work
val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"periodic_sync",
ExistingPeriodicWorkPolicy.UPDATE,
periodicSync
)
// Observe work status
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(syncRequest.id)
.observe(this) { workInfo ->
when (workInfo?.state) {
WorkInfo.State.SUCCEEDED -> showSuccess()
WorkInfo.State.FAILED -> showError()
WorkInfo.State.RUNNING -> showProgress()
else -> Unit
}
}28. How do you chain WorkManager tasks?
val downloadWork = OneTimeWorkRequestBuilder<DownloadWorker>().build()
val processWork = OneTimeWorkRequestBuilder<ProcessWorker>().build()
val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>().build()
// Sequential chain
WorkManager.getInstance(context)
.beginWith(downloadWork)
.then(processWork)
.then(uploadWork)
.enqueue()
// Parallel then merge
val parallel1 = OneTimeWorkRequestBuilder<ParallelWorker1>().build()
val parallel2 = OneTimeWorkRequestBuilder<ParallelWorker2>().build()
val mergeWork = OneTimeWorkRequestBuilder<MergeWorker>().build()
WorkManager.getInstance(context)
.beginWith(listOf(parallel1, parallel2)) // run in parallel
.then(mergeWork) // runs after both complete
.enqueue()Unit and Instrumented Testing
29. How do you unit test a ViewModel with coroutines?
@ExtendWith(InstantExecutorExtension::class) // for LiveData
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // replaces Dispatchers.Main
private val fakeRepository = FakeUserRepository()
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
viewModel = UserViewModel(fakeRepository)
}
@Test
fun `loadUser emits success state with user data`() = runTest {
// Arrange
val expectedUser = User(id = 1, name = "Ana", email = "ana@test.com")
fakeRepository.userToReturn = expectedUser
// Act
viewModel.loadUser(1)
// Assert
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(ProfileUiState.Success::class.java)
assertThat((state as ProfileUiState.Success).user).isEqualTo(expectedUser)
}
@Test
fun `loadUser emits error state on network failure`() = runTest {
fakeRepository.shouldThrowError = true
viewModel.loadUser(1)
assertThat(viewModel.uiState.value).isInstanceOf(ProfileUiState.Error::class.java)
}
}
// Fake repository for tests
class FakeUserRepository : UserRepository {
var userToReturn: User? = null
var shouldThrowError = false
override suspend fun getUser(userId: Int): User {
if (shouldThrowError) throw IOException("Network error")
return userToReturn ?: throw IllegalStateException("Set userToReturn first")
}
}
// MainDispatcherRule replaces Dispatchers.Main for tests
class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}30. How do you test composables with Compose UI testing?
@RunWith(AndroidJUnit4::class)
class ProfileScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun profileScreen_showsUserName_whenLoadedSuccessfully() {
val testUser = User(name = "Ana García", email = "ana@test.com")
composeTestRule.setContent {
ProfileScreen(uiState = ProfileUiState.Success(testUser))
}
composeTestRule.onNodeWithText("Ana García").assertIsDisplayed()
composeTestRule.onNodeWithText("ana@test.com").assertIsDisplayed()
}
@Test
fun profileScreen_showsLoadingIndicator_whenLoading() {
composeTestRule.setContent {
ProfileScreen(uiState = ProfileUiState.Loading)
}
composeTestRule.onNodeWithContentDescription("Loading").assertIsDisplayed()
}
@Test
fun editButton_triggersCallback_whenClicked() {
var editClicked = false
composeTestRule.setContent {
ProfileScreen(
uiState = ProfileUiState.Success(User(name = "Test")),
onEditClick = { editClicked = true }
)
}
composeTestRule.onNodeWithText("Edit").performClick()
assertThat(editClicked).isTrue()
}
}31. What is the difference between unit tests and instrumented tests?
| Aspect | Unit Tests | Instrumented Tests |
|--------|-----------|-------------------|
| Location | src/test/ | src/androidTest/ |
| Runtime | JVM | Android device/emulator |
| Speed | Fast (milliseconds) | Slow (seconds) |
| Access | No Android framework | Full Android framework |
| Use for | ViewModels, repositories, utils | DAOs, Compose UI, Activities |
// Unit test (src/test/) — pure JVM
class StringUtilsTest {
@Test
fun `isValidEmail returns true for valid email`() {
assertThat("user@example.com".isValidEmail()).isTrue()
}
}
// Instrumented test (src/androidTest/) — needs device
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun activity_displaysWelcomeMessage() {
onView(withId(R.id.welcome_text)).check(matches(isDisplayed()))
}
}32. How do you mock dependencies in unit tests using MockK?
class UserRepositoryTest {
private val mockApiService = mockk<ApiService>()
private val mockUserDao = mockk<UserDao>()
private lateinit var repository: UserRepository
@Before
fun setup() {
repository = UserRepository(mockApiService, mockUserDao)
}
@Test
fun `getUser returns success when API call succeeds`() = runTest {
val userResponse = UserResponse(id = 1, name = "Ana", email = "ana@test.com")
coEvery { mockApiService.getUser(1) } returns userResponse
coEvery { mockUserDao.insertUser(any()) } returns 1L
val result = repository.getUser(1)
assertThat(result).isInstanceOf(NetworkResult.Success::class.java)
coVerify { mockUserDao.insertUser(any()) } // verify side effect
}
@Test
fun `getUser returns error on HTTP 404`() = runTest {
coEvery { mockApiService.getUser(1) } throws HttpException(
Response.error<UserResponse>(404, "Not Found".toResponseBody())
)
val result = repository.getUser(1)
assertThat(result).isInstanceOf(NetworkResult.Error::class.java)
assertThat((result as NetworkResult.Error).code).isEqualTo(404)
}
}Advanced Topics
33. What is the difference between `Flow`, `StateFlow`, and `SharedFlow`?
// Flow: cold, starts on each collection, can have multiple values over time
fun getNumbers(): Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
// Each collector gets their own execution — cold
// StateFlow: hot, always has a value, replays last value to new collectors
val stateFlow = MutableStateFlow(0) // must have initial value
// New collectors immediately receive the current value
// SharedFlow: hot, configurable replay and buffer
val sharedFlow = MutableSharedFlow<Event>(
replay = 0, // don't replay old events
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// Flow operators you need to know
fun processData() {
repository.getDataFlow()
.filter { it.isActive }
.map { it.toDisplayModel() }
.distinctUntilChanged()
.debounce(300)
.catch { e -> emit(DisplayModel.error(e.message)) }
.flowOn(Dispatchers.IO) // upstream runs on IO
.launchIn(viewModelScope)
}34. How does `RecyclerView` differ from `LazyColumn` in Compose?
// Traditional RecyclerView
class UserAdapter : ListAdapter<User, UserAdapter.ViewHolder>(UserDiffCallback()) {
class ViewHolder(private val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.nameText.text = user.name
binding.emailText.text = user.email
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
// LazyColumn in Compose — much simpler
@Composable
fun UserList(users: List<User>, onUserClick: (User) -> Unit) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = users,
key = { user -> user.id } // stable keys prevent unnecessary recompositions
) { user ->
UserCard(user = user, onClick = { onUserClick(user) })
}
item {
// Footer item
Text("End of list", modifier = Modifier.fillMaxWidth())
}
}
}What interviewers look for: key parameter in items() is critical for performance — it helps Compose identify which items changed, were inserted, or removed.
35. How do you handle deep links in Android?
// In AndroidManifest.xml
// <activity android:name=".MainActivity">
// <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="app.example.com" android:pathPrefix="/user" />
// </intent-filter>
// </activity>
// Navigation component deep link
// In nav_graph.xml:
// <deepLink app:uri="https://app.example.com/user/{userId}" />
// Handle deep link in Activity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent?) {
intent?.data?.let { uri ->
when {
uri.pathSegments.firstOrNull() == "user" -> {
val userId = uri.lastPathSegment
// navigate to user profile
}
}
}
}
}36. What is Paging 3 and when do you use it?
// PagingSource
class UserPagingSource(private val apiService: ApiService) : PagingSource<Int, User>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
return try {
val page = params.key ?: 1
val response = apiService.getUsers(page = page, size = params.loadSize)
LoadResult.Page(
data = response.users,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.users.isEmpty()) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
// ViewModel
class UserListViewModel : ViewModel() {
val users: Flow<PagingData<User>> = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { UserPagingSource(apiService) }
).flow.cachedIn(viewModelScope) // cachedIn survives config changes
}
// In Composable
@Composable
fun UserListScreen(viewModel: UserListViewModel = hiltViewModel()) {
val users = viewModel.users.collectAsLazyPagingItems()
LazyColumn {
items(count = users.itemCount, key = users.itemKey { it.id }) { index ->
users[index]?.let { user -> UserCard(user) }
}
when (users.loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error -> item { ErrorItem(onRetry = { users.retry() }) }
else -> Unit
}
}
}37. How do you implement a custom Compose modifier?
// Custom modifier using Modifier.composed or extension function
fun Modifier.shimmerEffect(): Modifier = composed {
val transition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by transition.animateFloat(
initialValue = 0.6f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
),
label = "shimmer_alpha"
)
this.then(
Modifier.drawBehind {
drawRect(
color = Color.LightGray.copy(alpha = shimmerAlpha)
)
}
)
}
// Modifier with layout
fun Modifier.aspectRatioFixed(ratio: Float): Modifier = layout { measurable, constraints ->
val width = constraints.maxWidth
val height = (width / ratio).toInt()
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height)
)
layout(width, height) {
placeable.placeRelative(0, 0)
}
}
// Usage
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.shimmerEffect()
)38. What is `rememberCoroutineScope` and when do you use it in Compose?
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// rememberCoroutineScope gives you a scope tied to the composable's lifecycle
val scope = rememberCoroutineScope()
FloatingActionButton(
onClick = {
// You can't use suspend functions directly in non-composable lambdas
// rememberCoroutineScope solves this
scope.launch {
listState.animateScrollToItem(0)
}
}
) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
}
}
// Don't confuse with LaunchedEffect:
// - LaunchedEffect: runs automatically on composition/key change
// - rememberCoroutineScope: gives you manual control to launch when needed (button clicks, etc.)39. How do you implement offline-first architecture?
// Repository with offline-first pattern using Room + Retrofit
class UserRepository @Inject constructor(
private val userDao: UserDao,
private val apiService: ApiService
) {
// Emit from DB first, then refresh from network
fun getUser(userId: Int): Flow<Resource<User>> = flow {
emit(Resource.Loading)
// Emit cached data immediately
val cachedUser = userDao.getUserById(userId)
if (cachedUser != null) {
emit(Resource.Success(cachedUser))
}
// Try to fetch fresh data
try {
val networkUser = apiService.getUser(userId)
userDao.insertUser(networkUser.toEntity())
emit(Resource.Success(networkUser.toDomainModel()))
} catch (e: IOException) {
if (cachedUser == null) {
emit(Resource.Error("No connection and no cached data"))
}
// If we have cached data, silently fail — the user sees stale data
}
}.flowOn(Dispatchers.IO)
}
sealed class Resource<out T> {
object Loading : Resource<Nothing>()
data class Success<T>(val data: T) : Resource<T>()
data class Error(val message: String) : Resource<Nothing>()
}40. What is `rememberUpdatedState` and why does it matter?
@Composable
fun AutoSaveTextField(
value: String,
onSave: (String) -> Unit // this lambda might change across recompositions
) {
// BAD: LaunchedEffect captures the initial onSave lambda and never updates
LaunchedEffect(Unit) {
delay(3000)
onSave(value) // might call stale lambda
}
// GOOD: rememberUpdatedState captures the latest value
val latestOnSave by rememberUpdatedState(onSave)
val latestValue by rememberUpdatedState(value)
LaunchedEffect(Unit) {
delay(3000)
latestOnSave(latestValue) // always calls the current lambda with current value
}
TextField(value = value, onValueChange = {})
}What interviewers look for: This pattern is subtle. The LaunchedEffect(Unit) key means the effect runs once and doesn't restart. But the captured lambdas or values can become stale. rememberUpdatedState gives you a reference that always points to the latest value without restarting the effect.
Common Interview Mistakes to Avoid
Memory leaks:
- Holding a reference to an Activity or View in a background coroutine
- Not clearing ViewBinding in
onDestroyView - Using
GlobalScopeinstead ofviewModelScope
Threading issues:
- Calling
LiveData.valuefrom a background thread (usepostValue) - Not using
flowOn(Dispatchers.IO)for DB/network operations
Coroutine pitfalls:
- Using
try/catcharoundlaunchinstead of inside it - Not handling
CancellationException(don't swallow it) - Starting coroutines in
init {}before the ViewModel's scope is ready
Compose anti-patterns:
- Triggering side effects directly in composition (use
LaunchedEffect) - Reading from a ViewModel inside a composable without
collectAsStateWithLifecycle - Not using stable keys in
LazyColumn
Hilt mistakes:
- Forgetting
@AndroidEntryPointon fragments or activities - Using
@Singletonfor something that depends on Activity context (causes leaks)
What Senior-Level Interviewers Actually Test
Beyond syntax knowledge, senior interviews probe judgment:
- 1"Why would you choose StateFlow over LiveData?" — They want to hear: Kotlin-first, composable with Flow operators, testable without
InstantTaskExecutorRule, works outside of Android context.
- 2"Walk me through what happens when a user rotates the screen." — Configuration change triggers
onDestroy/onCreate, ViewModel survives viaViewModelStore,SavedStateHandlerestores UI state,repeatOnLifecyclere-collects flows.
- 3"How do you prevent a memory leak in a Fragment with ViewBinding?" — Set
_binding = nullinonDestroyView().
- 4"Your Room query is slow on large datasets. What do you investigate?" — Missing indexes,
SELECT *on wide tables, not paginating results, running on main thread.
- 5"How would you test a composable that depends on a ViewModel?" — Inject a fake ViewModel or use
createComposeRulewith a fake state directly.