November 22, 2025
20 min read

Senior Android Developer Interview Questions: Kotlin, Compose, Architecture

interview
career-advice
job-search
Senior Android Developer Interview Questions: Kotlin, Compose, Architecture
Milad Bonakdar

Milad Bonakdar

Author

Prepare for senior Android interviews with practical questions on Kotlin coroutines, Jetpack Compose state, app architecture, performance, testing, offline-first data, and security.


Introduction

For a senior Android developer interview, prepare to explain architecture trade-offs, lifecycle-safe concurrency, Compose state ownership, offline-first data flow, performance diagnosis, testing strategy, and security. A strong answer should show not only what API you would use, but why it fits the product constraint and how you would debug it in production.

Use this guide as a practical checklist. Focus first on Kotlin coroutines and Flow, ViewModel and state management, Room-backed repositories, Compose versus legacy UI decisions, startup and memory performance, and the examples you can explain from your own apps.

How to use these questions

  • Practice each answer as a short decision: context, trade-off, implementation, failure mode.
  • Connect technical choices to user impact, such as faster startup, reliable offline reads, safer token storage, or fewer lifecycle bugs.
  • Prepare one real project story for architecture, performance, testing, and security.

Advanced Kotlin & Language Features (5 Questions)

1. Explain Kotlin Coroutines and their advantages over threads.

Answer: Kotlin coroutines let Android apps run asynchronous work without blocking the main thread. In a senior interview, do not describe them only as "lightweight threads". Explain lifecycle ownership, cancellation, dispatcher choice, error handling, and how the result reaches UI state.

  • Why they matter: Coroutines make network, database, and CPU work easier to compose while keeping UI code readable.
  • Structured concurrency: Work launched in a parent scope is cancelled with that scope, which prevents many leaks and orphaned tasks.
  • Android lifecycle fit: viewModelScope is usually right for screen state because its work is cancelled when the ViewModel is cleared. Use lifecycle-aware collection from the UI so Flow collection stops when the screen is not active.
  • Dispatcher choice: Use Dispatchers.IO for blocking I/O, Dispatchers.Default for CPU-heavy work, and return to Main for UI state.
  • Senior trade-off: Coroutines do not remove the need for timeouts, retries, idempotency, clear ownership, and observable error states.
class UserRepository(private val api: ApiService) {
    suspend fun getUser(id: Int): Result<User> = withContext(Dispatchers.IO) {
        runCatching { api.fetchUser(id) }
    }
}

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow<UiState<User>>(UiState.Loading)
    val uiState: StateFlow<UiState<User>> = _uiState.asStateFlow()

    fun loadUser(id: Int) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            repository.getUser(id)
                .onSuccess { user -> _uiState.value = UiState.Success(user) }
                .onFailure { error ->
                    _uiState.value = UiState.Error(error.message ?: "Could not load user")
                }
        }
    }
}

Rarity: Very Common Difficulty: Hard


2. What are Sealed Classes and when should you use them?

Answer: Sealed classes represent restricted class hierarchies where all subclasses are known at compile time.

  • Benefits:
    • Exhaustive when expressions
    • Type-safe state management
    • Better than enums for complex data
  • Use Cases: Representing states, results, navigation events
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState<User>>(UiState.Loading)
    val uiState: StateFlow<UiState<User>> = _uiState.asStateFlow()
    
    fun loadUser() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = repository.getUser()
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// In UI
when (val state = uiState.value) {
    is UiState.Loading -> showLoading()
    is UiState.Success -> showData(state.data)
    is UiState.Error -> showError(state.message)
    // Compiler ensures all cases are handled
}

Rarity: Common Difficulty: Medium


3. Explain Kotlin Flow and how it differs from LiveData.

Answer: Flow is Kotlin's asynchronous stream API. It is a strong fit for Android data and UI state because repositories can expose changing data, ViewModels can transform it, and the UI can collect it with lifecycle awareness.

  • Flow: Cold by default. The upstream work starts when a collector subscribes. Good for streams from repositories and use cases.
  • StateFlow: Hot state holder with a current value. Good for screen state exposed by a ViewModel.
  • SharedFlow: Hot stream for events that do not naturally have one current value. Use it carefully for one-off UI events.
  • LiveData: Android-specific and lifecycle-aware by default, but less flexible for operators, structured concurrency, and non-Android layers.
  • Senior trade-off: Avoid collecting flows directly forever in an Activity or Fragment. Use lifecycle-aware collection, keep business streams outside UI classes, and choose stateIn or shareIn only when the sharing behavior is intentional.
class UserRepository(
    private val userDao: UserDao,
    private val api: ApiService
) {
    fun users(): Flow<List<User>> = userDao.observeUsers()

    suspend fun refreshUsers() = withContext(Dispatchers.IO) {
        val remoteUsers = api.fetchUsers()
        userDao.replaceUsers(remoteUsers)
    }
}

class UserViewModel(repository: UserRepository) : ViewModel() {
    val users: StateFlow<List<User>> = repository.users()
        .map { users -> users.filter { it.isActive } }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )
}

// Collect in Activity/Fragment
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.users.collect { users ->
            updateUI(users)
        }
    }
}

Rarity: Very Common Difficulty: Hard


4. What are Inline Functions and when should you use them?

Answer: Inline functions copy the function body to the call site, avoiding function call overhead.

  • Benefits:
    • Eliminates lambda allocation overhead
    • Allows non-local returns from lambdas
    • Better performance for higher-order functions
  • Use Cases: Higher-order functions with lambda parameters
  • Trade-off: Increases code size
// Without inline - creates lambda object
fun measureTime(block: () -> Unit) {
    val start = System.currentTimeMillis()
    block()
    val end = System.currentTimeMillis()
    println("Time: ${end - start}ms")
}

// With inline - no lambda object created
inline fun measureTimeInline(block: () -> Unit) {
    val start = System.currentTimeMillis()
    block()
    val end = System.currentTimeMillis()
    println("Time: ${end - start}ms")
}

// noinline - prevents specific parameter from being inlined
inline fun performOperation(
    inline operation: () -> Unit,
    noinline logger: () -> Unit
) {
    operation()
    saveLogger(logger)  // Can store noinline lambda
}

// crossinline - allows inline but prevents non-local returns
inline fun runAsync(crossinline block: () -> Unit) {
    thread {
        block()  // Can't return from outer function
    }
}

Rarity: Medium Difficulty: Hard


5. Explain Delegation in Kotlin.

Answer: Delegation allows an object to delegate some of its responsibilities to another object.

  • Class Delegation: by keyword
  • Property Delegation: Lazy, observable, delegates
  • Benefits: Code reuse, composition over inheritance
// Class delegation
interface Repository {
    fun getData(): String
}

class RemoteRepository : Repository {
    override fun getData() = "Remote data"
}

class CachedRepository(
    private val remote: Repository
) : Repository by remote {
    private var cache: String? = null
    
    override fun getData(): String {
        return cache ?: remote.getData().also { cache = it }
    }
}

// Property delegation
class User {
    // Lazy initialization
    val database by lazy {
        Room.databaseBuilder(context, AppDatabase::class.java, "db").build()
    }
    
    // Observable property
    var name: String by Delegates.observable("Initial") { prop, old, new ->
        println("$old -> $new")
    }
    
    // Custom delegate
    var token: String by PreferenceDelegate("auth_token", "")
}

class PreferenceDelegate(
    private val key: String,
    private val defaultValue: String
) {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return sharedPreferences.getString(key, defaultValue) ?: defaultValue
    }
    
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        sharedPreferences.edit().putString(key, value).apply()
    }
}

Rarity: Medium Difficulty: Medium


Architecture Patterns (6 Questions)

6. Explain MVVM architecture and its benefits.

Answer: MVVM (Model-View-ViewModel) separates UI logic from business logic.

Loading diagram...
  • Model: Data layer (repositories, data sources)
  • View: UI layer (Activities, Fragments, Composables)
  • ViewModel: Presentation logic, survives configuration changes
  • Benefits: Testable, separation of concerns, lifecycle-aware
// Model
data class User(val id: Int, val name: String, val email: String)

// Repository
class UserRepository(
    private val api: ApiService,
    private val dao: UserDao
) {
    suspend fun getUser(id: Int): User {
        return dao.getUser(id) ?: api.fetchUser(id).also {
            dao.insertUser(it)
        }
    }
}

// ViewModel
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow<UiState<User>>(UiState.Loading)
    val uiState: StateFlow<UiState<User>> = _uiState.asStateFlow()
    
    fun loadUser(id: Int) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = repository.getUser(id)
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Error")
            }
        }
    }
}

// View (Fragment)
class UserFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                when (state) {
                    is UiState.Loading -> showLoading()
                    is UiState.Success -> showUser(state.data)
                    is UiState.Error -> showError(state.message)
                }
            }
        }
    }
}

Rarity: Very Common Difficulty: Medium


7. What is Clean Architecture and how do you implement it in Android?

Answer: Clean Architecture separates code into layers with clear dependencies.

Loading diagram...
  • Presentation: UI, ViewModels
  • Domain: Use Cases, Business Logic, Entities
  • Data: Repositories, Data Sources (API, Database)
  • Dependency Rule: Inner layers don't know about outer layers
// Domain Layer - Entities
data class User(val id: Int, val name: String, val email: String)

// Domain Layer - Repository Interface
interface UserRepository {
    suspend fun getUser(id: Int): Result<User>
}

// Domain Layer - Use Case
class GetUserUseCase(private val repository: UserRepository) {
    suspend operator fun invoke(id: Int): Result<User> {
        return repository.getUser(id)
    }
}

// Data Layer - Repository Implementation
class UserRepositoryImpl(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource
) : UserRepository {
    override suspend fun getUser(id: Int): Result<User> {
        return try {
            val localUser = localDataSource.getUser(id)
            if (localUser != null) {
                Result.success(localUser)
            } else {
                val remoteUser = remoteDataSource.fetchUser(id)
                localDataSource.saveUser(remoteUser)
                Result.success(remoteUser)
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

// Presentation Layer - ViewModel
class UserViewModel(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    fun loadUser(id: Int) {
        viewModelScope.launch {
            when (val result = getUserUseCase(id)) {
                is Result.Success -> handleSuccess(result.data)
                is Result.Failure -> handleError(result.exception)
            }
        }
    }
}

Rarity: Common Difficulty: Hard


8. Explain Dependency Injection and Dagger/Hilt.

Answer: Dependency Injection provides dependencies to classes instead of creating them internally.

  • Benefits: Testability, loose coupling, reusability
  • Dagger: Compile-time DI framework
  • Hilt: Simplified Dagger for Android
// Define dependencies with Hilt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    
    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(context, AppDatabase::class.java, "app_db")
            .build()
    }
    
    @Provides
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }
}

// Repository with injected dependencies
@Singleton
class UserRepository @Inject constructor(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUser(id: Int): User {
        return userDao.getUser(id) ?: apiService.fetchUser(id).also {
            userDao.insertUser(it)
        }
    }
}

// ViewModel with injected repository
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {
    // ViewModel logic
}

// Activity/Fragment
@AndroidEntryPoint
class UserActivity : AppCompatActivity() {
    private val viewModel: UserViewModel by viewModels()
}

Rarity: Very Common Difficulty: Hard


9. What is the Repository pattern and why use it?

Answer: The Repository pattern gives the rest of the app a stable API for data while hiding whether that data comes from Room, a network API, a cache, or another source. For senior Android roles, the important point is ownership: in an offline-first design, the local database is often the source of truth that the UI observes, while network refreshes update that local source.

  • Benefits: Clear data ownership, easier testing, consistent offline behavior, and less duplication across ViewModels.
  • Good boundary: ViewModels request data and actions; repositories coordinate local and remote sources; DAOs and API clients stay implementation details.
  • Common mistake: Emitting remote data directly to the UI while also writing to Room can create inconsistent states. Prefer one observable source of truth when possible.
  • Interview signal: Explain conflict handling, stale data, retry behavior, pagination, and how the repository reports loading and errors without blocking reads.
interface UserRepository {
    fun observeUsers(): Flow<List<User>>
    suspend fun refreshUsers(): Result<Unit>
}

class OfflineFirstUserRepository @Inject constructor(
    private val api: ApiService,
    private val userDao: UserDao
) : UserRepository {

    override fun observeUsers(): Flow<List<User>> = userDao.observeUsers()

    override suspend fun refreshUsers(): Result<Unit> = withContext(Dispatchers.IO) {
        runCatching {
            val remoteUsers = api.fetchUsers()
            userDao.replaceUsers(remoteUsers)
        }
    }
}

class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    val users = repository.observeUsers()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun refresh() {
        viewModelScope.launch {
            repository.refreshUsers()
                .onFailure { error -> showRefreshError(error) }
        }
    }
}

Rarity: Very Common Difficulty: Medium


10. Explain the Single Activity architecture.

Answer: Single Activity architecture uses one Activity with multiple Fragments, managed by Navigation Component.

  • Benefits:
    • Simplified navigation
    • Shared ViewModels between fragments
    • Better animations
    • Easier deep linking
  • Navigation Component: Handles fragment transactions, back stack, arguments
// Navigation graph (nav_graph.xml)
<navigation>
    <fragment
        android:id="@+id/homeFragment"
        android:name="com.app.HomeFragment">
        <action
            android:id="@+id/action_home_to_detail"
            app:destination="@id/detailFragment" />
    </fragment>
    
    <fragment
        android:id="@+id/detailFragment"
        android:name="com.app.DetailFragment">
        <argument
            android:name="userId"
            app:argType="integer" />
    </fragment>
</navigation>

// MainActivity - single activity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        val navController = findNavController(R.id.nav_host_fragment)
        setupActionBarWithNavController(navController)
    }
}

// HomeFragment - navigate to detail
class HomeFragment : Fragment() {
    fun navigateToDetail(userId: Int) {
        val action = HomeFragmentDirections.actionHomeToDetail(userId)
        findNavController().navigate(action)
    }
}

// DetailFragment - receive arguments
class DetailFragment : Fragment() {
    private val args: DetailFragmentArgs by navArgs()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val userId = args.userId
        viewModel.loadUser(userId)
    }
}

Rarity: Common Difficulty: Medium


11. What is MVI (Model-View-Intent) architecture?

Answer: MVI is a unidirectional data flow architecture inspired by Redux.

  • Components:
    • Model: Represents UI state
    • View: Renders state, emits intents
    • Intent: User actions/events
  • Benefits: Predictable state, easier debugging, time-travel debugging
// State
data class UserScreenState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val error: String? = null
)

// Intent (user actions)
sealed class UserIntent {
    data class LoadUser(val id: Int) : UserIntent()
    object RetryLoading : UserIntent()
}

// ViewModel
class UserViewModel : ViewModel() {
    private val _state = MutableStateFlow(UserScreenState())
    val state: StateFlow<UserScreenState> = _state.asStateFlow()
    
    fun processIntent(intent: UserIntent) {
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.id)
            is UserIntent.RetryLoading -> retryLoading()
        }
    }
    
    private fun loadUser(id: Int) {
        viewModelScope.launch {
            _state.value = _state.value.copy(isLoading = true, error = null)
            try {
                val user = repository.getUser(id)
                _state.value = _state.value.copy(
                    isLoading = false,
                    user = user
                )
            } catch (e: Exception) {
                _state.value = _state.value.copy(
                    isLoading = false,
                    error = e.message
                )
            }
        }
    }
}

// View
class UserFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // Send intent
        viewModel.processIntent(UserIntent.LoadUser(123))
        
        // Observe state
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.state.collect { state ->
                render(state)
            }
        }
    }
    
    private fun render(state: UserScreenState) {
        when {
            state.isLoading -> showLoading()
            state.error != null -> showError(state.error)
            state.user != null -> showUser(state.user)
        }
    }
}

Rarity: Medium Difficulty: Hard


Performance & Optimization (5 Questions)

12. How do you optimize RecyclerView performance?

Answer: Multiple strategies improve RecyclerView scrolling performance:

  1. ViewHolder Pattern: Reuse views (built-in)
  2. DiffUtil: Efficient list updates
  3. Stable IDs: Override getItemId() and setHasStableIds(true)
  4. Prefetching: Increase prefetch distance
  5. Image Loading: Use libraries like Glide/Coil with proper sizing
  6. Avoid Heavy Operations: Don't perform expensive calculations in onBindViewHolder
  7. Nested RecyclerViews: Set setRecycledViewPool() and setHasFixedSize(true)
class UserAdapter : ListAdapter<User, UserViewHolder>(UserDiffCallback()) {
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        val binding = ItemUserBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
        return UserViewHolder(binding)
    }
    
    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
    
    override fun getItemId(position: Int): Long {
        return getItem(position).id.toLong()
    }
}

class UserDiffCallback : DiffUtil.ItemCallback<User>() {
    override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem.id == newItem.id
    }
    
    override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
        return oldItem == newItem
    }
}

// In Fragment/Activity
recyclerView.apply {
    setHasFixedSize(true)
    adapter = userAdapter
    // Increase prefetch
    (layoutManager as? LinearLayoutManager)?.initialPrefetchItemCount = 4
}

// Update list efficiently
userAdapter.submitList(newUsers)

Rarity: Very Common Difficulty: Medium


13. How do you detect and fix memory leaks in Android?

Answer: Memory leaks occur when objects are held in memory longer than needed.

  • Common Causes:
    • Context leaks (Activity/Fragment references)
    • Static references
    • Anonymous inner classes
    • Listeners not unregistered
    • Coroutines not cancelled
  • Detection Tools:
    • LeakCanary library
    • Android Studio Memory Profiler
    • Heap dumps
// BAD - Memory leak
class MyActivity : AppCompatActivity() {
    companion object {
        var instance: MyActivity? = null  // Static reference leaks
    }
    
    private val handler = Handler()  // Implicit reference to Activity
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        instance = this
        
        // Anonymous class holds Activity reference
        handler.postDelayed({
            // Activity might be destroyed
        }, 10000)
    }
}

// GOOD - No memory leak
class MyActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // Use weak reference if needed
        val weakRef = WeakReference(this)
        handler.postDelayed({
            weakRef.get()?.let { activity ->
                // Safe to use activity
            }
        }, 10000)
    }
    
    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacksAndMessages(null)  // Clean up
    }
}

// ViewModel - survives configuration changes
class MyViewModel : ViewModel() {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)
    
    override fun onCleared() {
        super.onCleared()
        job.cancel()  // Cancel coroutines
    }
}

Rarity: Very Common Difficulty: Medium


14. How do you optimize app startup time?

Answer: Faster startup improves user experience:

  1. Lazy Initialization: Initialize objects only when needed
  2. Avoid Heavy Work in Application.onCreate():
    • Move to background thread
    • Defer non-critical initialization
  3. Content Providers: Minimize or lazy-load
  4. Reduce Dependencies: Fewer libraries = faster startup
  5. App Startup Library: Structured initialization
  6. Baseline Profiles: Ahead-of-time compilation hints
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        // Critical initialization only
        initCrashReporting()
        
        // Defer non-critical work
        lifecycleScope.launch {
            delay(100)
            initAnalytics()
            initImageLoader()
        }
        
        // Background initialization
        CoroutineScope(Dispatchers.IO).launch {
            preloadData()
        }
    }
}

// App Startup library
class AnalyticsInitializer : Initializer<Analytics> {
    override fun create(context: Context): Analytics {
        return Analytics.getInstance(context).apply {
            initialize()
        }
    }
    
    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList()
    }
}

// Lazy initialization
class MyActivity : AppCompatActivity() {
    private val database by lazy {
        Room.databaseBuilder(this, AppDatabase::class.java, "db").build()
    }
    
    // Only created when accessed
    private val heavyObject by lazy {
        createHeavyObject()
    }
}

Rarity: Common Difficulty: Medium


15. How do you handle bitmap loading and caching efficiently?

Answer: Efficient image handling is crucial for performance:

  • Libraries: Glide, Coil (handle caching automatically)
  • Manual Optimization:
    • Downsampling (load smaller images)
    • Memory cache (LruCache)
    • Disk cache
    • Bitmap pooling
// Using Glide
Glide.with(context)
    .load(imageUrl)
    .placeholder(R.drawable.placeholder)
    .error(R.drawable.error)
    .diskCacheStrategy(DiskCacheStrategy.ALL)
    .override(width, height)  // Resize
    .into(imageView)

// Manual implementation with LruCache
class ImageCache {
    private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
    private val cacheSize = maxMemory / 8
    
    private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            return bitmap.byteCount / 1024
        }
    }
    
    fun getBitmap(key: String): Bitmap? {
        return memoryCache.get(key)
    }
    
    fun putBitmap(key: String, bitmap: Bitmap) {
        if (getBitmap(key) == null) {
            memoryCache.put(key, bitmap)
        }
    }
}

// Downsample large images
fun decodeSampledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap {
    return BitmapFactory.Options().run {
        inJustDecodeBounds = true
        BitmapFactory.decodeFile(file.path, this)
        
        inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
        inJustDecodeBounds = false
        
        BitmapFactory.decodeFile(file.path, this)
    }
}

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1
    
    if (height > reqHeight || width > reqWidth) {
        val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2
        
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    
    return inSampleSize
}

Rarity: Common Difficulty: Hard


16. What is ANR and how do you prevent it?

Answer: ANR (Application Not Responding) occurs when the main thread is blocked for too long.

  • Causes:
    • Heavy computation on main thread
    • Network calls on main thread
    • Database operations on main thread
    • Deadlocks
  • Prevention:
    • Move heavy work to background threads
    • Use coroutines with proper dispatchers
    • Avoid synchronized blocks on main thread
    • Use WorkManager for background tasks
// BAD - Blocks main thread
class BadActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // ANR risk!
        val data = fetchDataFromNetwork()  // Blocks UI thread
        processLargeFile()  // Blocks UI thread
    }
}

// GOOD - Background processing
class GoodActivity : AppCompatActivity() {
    private val viewModel: MyViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        viewModel.loadData()  // Handled in ViewModel with coroutines
    }
}

class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            // Automatically on Main dispatcher
            showLoading()
            
            // Switch to IO dispatcher for network
            val data = withContext(Dispatchers.IO) {
                fetchDataFromNetwork()
            }
            
            // Switch to Default dispatcher for heavy computation
            val processed = withContext(Dispatchers.Default) {
                processData(data)
            }
            
            // Back to Main for UI update
            updateUI(processed)
        }
    }
}

Rarity: Common Difficulty: Easy


Testing (3 Questions)

17. How do you write unit tests for ViewModels?

Answer: ViewModels should be tested in isolation with mocked dependencies.

class UserViewModelTest {
    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()
    
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    
    private lateinit var viewModel: UserViewModel
    private lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        repository = mockk()
        viewModel = UserViewModel(repository)
    }
    
    @Test
    fun `loadUser success updates state`() = runTest {
        // Given
        val user = User(1, "John", "[email protected]")
        coEvery { repository.getUser(1) } returns Result.success(user)
        
        // When
        viewModel.loadUser(1)
        
        // Then
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Success)
        assertEquals(user, (state as UiState.Success).data)
    }
    
    @Test
    fun `loadUser failure updates error state`() = runTest {
        // Given
        val exception = Exception("Network error")
        coEvery { repository.getUser(1) } returns Result.failure(exception)
        
        // When
        viewModel.loadUser(1)
        
        // Then
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Error)
        assertEquals("Network error", (state as UiState.Error).message)
    }
}

// MainDispatcherRule for testing coroutines
@ExperimentalCoroutinesApi
class MainDispatcherRule : TestWatcher() {
    private val testDispatcher = StandardTestDispatcher()
    
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

Rarity: Very Common Difficulty: Medium


18. What is the difference between Unit, Integration, and UI tests?

Answer:

  • Unit Tests:
    • Test individual components in isolation
    • Fast, run on JVM
    • Mock dependencies
    • Example: ViewModel, Repository, Use Case tests
  • Integration Tests:
    • Test how components work together
    • May require Android framework
    • Example: Repository with real database
  • UI Tests (Instrumented):
    • Test user interactions
    • Run on device/emulator
    • Slower but verify actual behavior
    • Example: Espresso tests
// Unit test - JVM
class UserRepositoryTest {
    @Test
    fun `getUser returns cached user`() {
        val cache = mockk<UserCache>()
        val api = mockk<ApiService>()
        val repository = UserRepository(api, cache)
        
        every { cache.getUser(1) } returns User(1, "John", "[email protected]")
        
        val user = runBlocking { repository.getUser(1) }
        
        assertEquals("John", user.name)
        verify(exactly = 0) { api.fetchUser(any()) }  // API not called
    }
}

// Integration test - Android
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao
    
    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        userDao = database.userDao()
    }
    
    @Test
    fun insertAndGetUser() = runBlocking {
        val user = User(1, "John", "[email protected]")
        userDao.insertUser(user)
        
        val retrieved = userDao.getUser(1)
        assertEquals(user, retrieved)
    }
}

// UI test - Espresso
@RunWith(AndroidJUnit4::class)
class UserActivityTest {
    @get:Rule
    val activityRule = ActivityScenarioRule(UserActivity::class.java)
    
    @Test
    fun displayUserName() {
        onView(withId(R.id.nameText))
            .check(matches(withText("John Doe")))
        
        onView(withId(R.id.loadButton))
            .perform(click())
        
        onView(withId(R.id.progressBar))
            .check(matches(isDisplayed()))
    }
}

Rarity: Common Difficulty: Easy


19. How do you test Coroutines and Flow?

Answer: Use testing libraries designed for coroutines.

class FlowTest {
    @Test
    fun `flow emits correct values`() = runTest {
        val flow = flow {
            emit(1)
            delay(100)
            emit(2)
            delay(100)
            emit(3)
        }
        
        val values = flow.toList()
        assertEquals(listOf(1, 2, 3), values)
    }
    
    @Test
    fun `stateFlow updates correctly`() = runTest {
        val stateFlow = MutableStateFlow(0)
        
        val job = launch {
            stateFlow.emit(1)
            stateFlow.emit(2)
        }
        
        advanceUntilIdle()  // Process all coroutines
        assertEquals(2, stateFlow.value)
        job.cancel()
    }
    
    @Test
    fun `turbine library for flow testing`() = runTest {
        val flow = flowOf(1, 2, 3)
        
        flow.test {
            assertEquals(1, awaitItem())
            assertEquals(2, awaitItem())
            assertEquals(3, awaitItem())
            awaitComplete()
        }
    }
}

Rarity: Common Difficulty: Medium


Security & Best Practices (2 Questions)

20. How do you secure sensitive data in Android?

Answer: Android security is a layered design problem, not a single library choice. In a senior interview, separate data-at-rest protection, network protection, authentication/session handling, logging, and abuse resistance.

  1. Store less sensitive data: Do not persist tokens or personal data unless the app truly needs it.
  2. Use Android Keystore-backed encryption: Store keys in Android Keystore and use encrypted storage for small secrets.
  3. Protect network traffic: Use HTTPS, a clear Network Security Config, and certificate pinning only when the team can handle rotation and incident response.
  4. Avoid leaking secrets: Keep tokens out of logs, crash reports, analytics events, screenshots, backups, and deep links.
  5. Harden release builds: Use R8/obfuscation, dependency updates, Play Integrity or similar checks where appropriate, and server-side validation for anything security-sensitive.
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

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

encryptedPrefs.edit()
    .putString("auth_token", token)
    .apply()

// Certificate pinning can reduce some MITM risk, but it needs an operational plan.
val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

Rarity: Common Difficulty: Hard


21. What are Android best practices for production apps?

Answer: Production Android best practices are about making the app reliable under real device, network, and team constraints. A senior answer should connect code structure to operability.

  1. Architecture: Keep UI, domain, and data responsibilities clear; avoid architecture that adds ceremony without solving a real coordination problem.
  2. State management: Expose predictable UI state from ViewModels and collect it with lifecycle awareness.
  3. Offline behavior: Decide what must work without network access and make the local source of truth explicit.
  4. Performance: Track startup, memory, jank, battery, and network usage before and after major changes.
  5. Testing: Cover ViewModels, use cases, repositories, database migrations, and critical UI flows with the right level of test.
  6. Security and privacy: Store less data, encrypt sensitive local data, protect network calls, and avoid leaking personal information through logs or analytics.
  7. Accessibility and localization: Design for TalkBack, scalable text, content descriptions, contrast, and markets you actually support.
  8. Release discipline: Use feature flags where helpful, staged rollout, crash monitoring, rollback plans, and clear ownership for incidents.

Rarity: Common Difficulty: Medium


Newsletter subscription

Weekly career tips that actually work

Get the latest insights delivered straight to your inbox

Stand Out to Recruiters & Land Your Dream Job

Join thousands who transformed their careers with AI-powered resumes that pass ATS and impress hiring managers.

Start building now

Share this post

Make Your 6 Seconds Count

Recruiters scan resumes for an average of only 6 to 7 seconds. Our proven templates are designed to capture attention instantly and keep them reading.