InterviewHack.ai
Start free
Blog/Android Kotlin Interview Questions — 40 with Code and Answers

Android Kotlin Interview Questions — 40 with Code and Answers

September 16, 2026

androidkotlin

40 Android/Kotlin interview questions: Kotlin coroutines, Jetpack Compose, ViewModel, LiveData vs Flow, Room, dependency injection with Hilt. With Kotlin code.

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.

kotlin
// 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.

kotlin
// 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 cleared
  • lifecycleScope — cancelled when the lifecycle owner (Activity/Fragment) is destroyed
  • GlobalScope — lives for the app's lifetime (avoid unless you have a very specific reason)
kotlin
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.

kotlin
// 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.

kotlin
// 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.

kotlin
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) = user1

What 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`?

kotlin
// 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 → onDestroy
kotlin
class 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.

kotlin
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.

kotlin
// 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.

kotlin
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.

kotlin
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?

kotlin
// 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:

  • StateFlow requires an initial value; LiveData does not
  • StateFlow doesn't automatically stop when the UI goes to background — you must use repeatOnLifecycle
  • StateFlow uses equality-based emission (won't emit the same value twice); LiveData doesn't deduplicate
  • SharedFlow for events (one-time actions like navigation or showing a snackbar)

14. What is `SharedFlow` and how does it differ from `StateFlow`?

kotlin
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.

kotlin
@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:

kotlin
// 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`?

kotlin
@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?

kotlin
@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`?

kotlin
@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.

kotlin
@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?

kotlin
// 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?

kotlin
@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.

kotlin
// 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?

kotlin
// 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?

kotlin
// 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`?

kotlin
// @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?

kotlin
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?

kotlin
// 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?

kotlin
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?

kotlin
@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?

kotlin
@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 |

kotlin
// 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?

kotlin
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`?

kotlin
// 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?

kotlin
// 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?

kotlin
// 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?

kotlin
// 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?

kotlin
// 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?

kotlin
@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?

kotlin
// 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?

kotlin
@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 GlobalScope instead of viewModelScope

Threading issues:

  • Calling LiveData.value from a background thread (use postValue)
  • Not using flowOn(Dispatchers.IO) for DB/network operations

Coroutine pitfalls:

  • Using try/catch around launch instead 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 @AndroidEntryPoint on fragments or activities
  • Using @Singleton for something that depends on Activity context (causes leaks)

What Senior-Level Interviewers Actually Test

Beyond syntax knowledge, senior interviews probe judgment:

  1. 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.
  1. 2"Walk me through what happens when a user rotates the screen." — Configuration change triggers onDestroy/onCreate, ViewModel survives via ViewModelStore, SavedStateHandle restores UI state, repeatOnLifecycle re-collects flows.
  1. 3"How do you prevent a memory leak in a Fragment with ViewBinding?" — Set _binding = null in onDestroyView().
  1. 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.
  1. 5"How would you test a composable that depends on a ViewModel?" — Inject a fake ViewModel or use createComposeRule with a fake state directly.

FAQ

What Kotlin topics are most commonly asked in Android interviews?+

The most common topics are coroutines (launch vs async, scope, structured concurrency), ViewModel with StateFlow or LiveData, Jetpack Compose recomposition, Hilt dependency injection, and Room database with migrations. Sealed classes for UI state modeling also come up frequently at mid-to-senior level.

What is the difference between StateFlow and LiveData in Android?+

Both hold observable data, but StateFlow is Kotlin-first, requires an initial value, and integrates with coroutine flows using operators like map and filter. LiveData is lifecycle-aware by default but doesn't need repeatOnLifecycle. StateFlow requires repeatOnLifecycle in Fragments to avoid collecting in the background. For new projects, StateFlow with collectAsStateWithLifecycle is the recommended approach.

How do you handle configuration changes in Android without losing data?+

ViewModel survives configuration changes (like screen rotation) because Android retains the ViewModelStore across recreations. However, ViewModel doesn't survive process death. For data that must survive process death (form inputs, selected tab), use SavedStateHandle which persists to the system Bundle.

What is the correct way to collect a Flow in a Fragment?+

Use viewLifecycleOwner.lifecycleScope.launch combined with repeatOnLifecycle(Lifecycle.State.STARTED). The key mistake to avoid is using lifecycleOwner instead of viewLifecycleOwner — when the Fragment is on the back stack, its view is destroyed but the Fragment instance lives on, which can cause crashes or memory leaks if you observe with the wrong lifecycle owner.

How do you write unit tests for ViewModels that use coroutines?+

Use the runTest builder from kotlinx-coroutines-test, set up a MainDispatcherRule to replace Dispatchers.Main with a TestCoroutineDispatcher, and inject fake or mock repositories instead of real ones. Libraries like MockK make it easy to stub suspend functions with coEvery.

What is Hilt and why is it preferred over manual dependency injection in Android?+

Hilt is Google's opinionated dependency injection framework built on Dagger. It reduces boilerplate by generating injection code at compile time, integrates with Android components (ViewModel, Activity, Fragment, WorkManager), provides correct scoping out of the box (Singleton, ActivityScoped, ViewModelScoped), and makes testing easier through replacement modules.

Related articles

iOS Swift Interview Questions — 40 with Code and Answers

40 iOS/Swift interview questions asked at real companies: ARC, protocols, Swift concurrency (async/await, actors), UIKit vs SwiftUI, Core Data, testing. With Swift code.

Prepare for your real interview

Paste your job link: we research who's interviewing you and rehearse you live.

Start free →

Have an interview coming up? Install the live copilot →

InterviewHack.ai

Prepare for the exact interview: who's interviewing you, a tailored CV, and a real coach.

Product

JobsFree ATS checkerInterview-English checkSalary checkLATAM salary reportFree coursesBlogTailored CVSpoken practiceIt's free

Remote jobs

ReactPythonFull-StackLATAMArgentinaMexicoSee all →

Prepare

Spoken practiceFrontendBackendAI EngineerBy companySell with your CV

Company

For employersAboutContactPrivacyTerms

© 2026 InterviewHack.ai · Your CV is yours. Never used to train anything. · A product of IA-PTY