Android Developer Interview Questions
Core Overview
Practice Kotlin syntax, Jetpack Compose UI architecture, Coroutines/Flow async processing, and Android SDK lifecycles.
Ready to test your knowledge?
Launch a focused practice session to review questions without distraction.
How does Kotlin enforce null safety, and what are platform types in Android development?
Direct Answer
Kotlin enforces null safety at compile time using nullable vs non-nullable types. Platform types are types from Java code where nullability is unspecified, requiring careful developer checks.
Detailed Explanation
Kotlin prevents NullPointerExceptions (NPEs) by distinguishing between nullable types (e.g. String?) and non-nullable types (e.g. String) at compile time. Non-nullable variables cannot hold null values.
Platform Types: When calling Java code from Kotlin (common in the Android SDK), nullability annotations (like @Nullable or @NonNull) might be missing. Kotlin treats these as "platform types" (written as Type!). Compiler null checks are bypassed for platform types, meaning any direct access can trigger an NPE at runtime if the Java object is null. Developers must explicitly define the nullability or use safe calls (?.) when wrapping Java SDK returns.
Code Example
// Java signature: public String getName() { return null; }
// Kotlin caller:
val name: String? = api.name // Explicitly declaring as nullable is safe
val uppercase = name?.uppercase() // Safe call operator prevents NPE
Common Interview Pitfalls
- Using the double-bang operator (`!!`) on platform types without verifying that they cannot be null.
- Assuming that Kotlin null checks protect against null values returned by JSON parsing libraries reflection instantiations.
Explain the role of CoroutineDispatchers and structured concurrency in Kotlin.
Direct Answer
CoroutineDispatchers specify the thread pool for coroutine execution (Main, IO, Default). Structured concurrency ensures coroutines are launched in scoped contexts to prevent memory leaks.
Detailed Explanation
Kotlin Coroutines use Dispatchers to determine which thread pool executes the code:
Dispatchers.Main: Runs on the Android main thread for UI operations.Dispatchers.IO: Optimized for disk/network I/O tasks (shares thread pool).Dispatchers.Default: Optimized for CPU-intensive work (e.g., parsing JSON, sorting lists).Dispatchers.Unconfined: Executes on the current call-frame thread.Structured Concurrency: Coroutines must be launched within a CoroutineScope (like lifecycleScope or viewModelScope). Structured concurrency guarantees that when a scope is cancelled (e.g. user leaves an Activity), all child coroutines running in that scope are cancelled automatically, preventing background thread memory leaks.
Code Example
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch { // Structured: cancelled on ViewModel clear
val data = withContext(Dispatchers.IO) {
api.downloadData() // Offloaded to background thread
}
uiState.value = data // Main thread
}
}
}
Common Interview Pitfalls
- Launching long-running tasks in GlobalScope (bypasses structured concurrency, leading to memory leaks when components destroy).
- Performing blocking I/O calls directly on Dispatchers.Main, freezing the Android UI thread.
Compare Flow, StateFlow, and SharedFlow in Kotlin Coroutines.
Direct Answer
Flow is cold and active only during collection. StateFlow is hot, retains one state, and triggers updates on change. SharedFlow is hot, broadcasts to multiple subscribers, and lacks state memory.
Detailed Explanation
Kotlin provides reactive stream options for state propagation:
Code Example
// Hot state emission
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
// Hot event emission
private val _event = MutableSharedFlow<String>()
val event: SharedFlow<String> = _event.asSharedFlow()
Common Interview Pitfalls
- Collecting flows in Compose or lifecycle environments using plain `.collect` instead of `.collectAsStateWithLifecycle` or `repeatOnLifecycle` (causes resources leak in background).
- Using StateFlow for one-time events, leading to event re-delivery when the device is rotated.
What are inline classes and reified type parameters in Kotlin, and how do they optimize runtime performance?
Direct Answer
Inline functions substitute bytecode at the call site. Reified type parameters preserve generic types at runtime, avoiding Java type erasure.
Detailed Explanation
Kotlin generic type parameters are erased at runtime due to JVM specifications. However, by using inline functions with reified type parameters, we can preserve the generic class type:
T as a class directly inside the inline function (e.g., T::class.java), avoiding the need to pass Class<T> manually.value class Password(val value: String)) to create type-safe code. The compiler inlines the wrapped value at compilation, avoiding heap allocation overhead.Code Example
// Reified generic navigation helper
inline fun <reified T : Activity> Context.startActivity() {
val intent = Intent(this, T::class.java)
startActivity(intent)
}
// Usage: startActivity<SettingsActivity>()
Common Interview Pitfalls
- Inlining massive functions with large blocks of code (increases the generated APK bytecode size unnecessarily).
- Attempting to use reified type parameters on non-inline functions (will not compile because type preservation requires compiler code generation).
How do extension functions and delegates work in Kotlin?
Direct Answer
Extension functions add methods to existing classes without inheritance. Delegates offload property reads/writes to helper objects.
Detailed Explanation
Kotlin provides syntactic options to extend classes and decouple concerns:
.show() to Android View classes.by keyword:by lazy: Thread-safe, deferred initialization. Evaluates only on the first call.by Delegates.observable: Triggers a listener callback whenever the property value changes.Code Example
// Extension function
fun View.hide() { this.visibility = View.GONE }
// Lazy delegation
val database: Database by lazy { Database.build(context) }
Common Interview Pitfalls
- Assuming extension functions override member methods with identical signatures (member methods always win).
- Declaring lazy properties that reference lifecycle-bound contexts, causing memory leaks if retained.
How does exception propagation work in Kotlin Coroutines, and how do you handle errors?
Direct Answer
Exceptions propagate up the job hierarchy, cancelling parents and siblings. Use SupervisorJob or supervisorScope to isolate child failures.
Detailed Explanation
In structured concurrency, an unhandled exception in a child coroutine propagates up to its parent, cancelling the parent job and all its other children.
supervisorScope throws an exception, only that child fails. Sibling coroutines and the parent context remain active.launch blocks directly in the scope) and is ignored in nested child coroutines.Code Example
// If api1 fails, api2 continues running
supervisorScope {
val first = launch { api1.fetch() }
val second = launch { api2.fetch() }
}
Common Interview Pitfalls
- Wrapping async blocks with try-catch blocks expecting to catch exceptions thrown inside child launch blocks (exceptions are propagated through the Coroutine context instead).
- Using a plain Job inside a CoroutineScope expecting supervisor-like failure isolation.
Describe the three phases of the Jetpack Compose rendering lifecycle and how Recomposition works.
Direct Answer
Compose renders UI in three phases: Composition (what to show), Layout (where to place), and Drawing (how to render). Recomposition runs when State changes.
Detailed Explanation
Jetpack Compose converts state into UI through three distinct phases:
1. Composition: Runs the composable functions. This determines what UI elements are required and builds the UI tree representation.
2. Layout: Places UI elements in the 2D plane. This consists of measuring child composables and placing them in coordinate bounds.
3. Drawing: Renders the elements to the canvas.
Recomposition: When a State read occurs within a composable, Compose registers it as a dependency. If that state changes, Compose schedules the composable function to execute again (Recomposition) with the new data. Compose optimizes this by skipping recomposition for any nested functions whose inputs (parameters) have not changed (smart recomposition).
Code Example
@Composable
fun ProfileCard(name: String) { // Smart skip if name is unchanged
Text(text = name) // Reads name state, draws during composition
}
Common Interview Pitfalls
- Performing database queries or network operations directly inside a `@Composable` block (runs on every single recomposition, freezing the app).
- Reading frequently changing state (like scroll offsets) in the Composition phase instead of using lambda-modifiers for Layout/Draw optimization.
What is state hoisting, and how does it support unidirectional data flow in Compose?
Direct Answer
State hoisting is the pattern of moving state up to a component's caller to make it stateless, promoting unidirectional data flow (state flows down, events flow up).
Detailed Explanation
State hoisting is the practice of moving state to the caller of a composable to make the composable stateless. Instead of managing state internally, a hoisted composable receives its current state via parameters and notifies changes via event lambdas.
This pattern enforces Unidirectional Data Flow (UDF):
Code Example
@Composable
fun SearchField(query: String, onQueryChange: (String) -> void) {
TextField(value = query, onValueChange = onQueryChange) // Stateless child
}
Common Interview Pitfalls
- Hoisting state unnecessarily high up the UI tree, causing unrelated parent components to recompose constantly.
- Modifying hoisted state directly from nested children without triggering the callback event parameter.
What is the difference between `remember` and `rememberSaveable` in Jetpack Compose?
Direct Answer
`remember` preserves state across recompositions but loses it on configuration changes. `rememberSaveable` preserves state across both using Bundle mechanisms.
Detailed Explanation
State in Jetpack Compose must be cached to survive updates:
Code Example
@Composable
fun InputForm() {
// Survives recomposition, lost on screen rotation
var text1 by remember { mutableStateOf("") }
// Survives both recomposition and screen rotation
var text2 by rememberSaveable { mutableStateOf("") }
}
Common Interview Pitfalls
- Using `rememberSaveable` for complex objects that cannot be serialized into a Bundle without writing a custom Saver.
- Expecting `remember` to persist data indefinitely like a local database storage.
Compare LaunchedEffect, DisposableEffect, and SideEffect in Compose.
Direct Answer
LaunchedEffect runs suspend blocks on keys changes. DisposableEffect executes cleanups on leaving composition. SideEffect runs on every successful recomposition.
Detailed Explanation
Side effects are operations that escape the scope of a composable function. Compose provides structured APIs to manage them:
key parameter changes. Useful for network requests, navigation, or animations.onDispose block whenever the keys change or the composable leaves the composition.Code Example
@Composable
fun Timer(timer: CustomTimer) {
DisposableEffect(timer) {
timer.start()
onDispose { timer.stop() } // Cleanup
}
}
Common Interview Pitfalls
- Using a frequently changing value as a key in `LaunchedEffect`, causing the coroutine to constantly cancel and restart.
- Forgetting to call `onDispose` at the end of a `DisposableEffect` block.
Explain measurement rules in Compose and how custom layouts are created.
Direct Answer
Compose enforces a single-pass measurement rule (children can only be measured once). Custom layouts are built by measuring children and defining their coordinates.
Detailed Explanation
In traditional Android views, multi-pass measurement (calling measure multiple times) was common but led to exponential rendering times. Compose resolves this by enforcing Single-Pass Measurement: a parent node can only measure each child once. If a child needs to fit parent coordinates, Compose uses SubcomposeLayout or Intrinsic measurements instead.
To build a custom layout, use the Layout composable. It accepts a list of children composables and a lambda where you measure each child (obtaining a list of Placeable objects) and then place them on the screen by specifying exact x and y coordinate offsets inside the layout(width, height) method block.
Code Example
@Composable
fun CustomColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
var yPosition = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
}
}
}
}
Common Interview Pitfalls
- Measuring a child twice inside a custom layout, causing a runtime crash due to single-pass enforcement.
- Using SubcomposeLayout for standard static structures (SubcomposeLayout has a high performance overhead because it defers composition until layout phase).
How do LazyColumn and LazyRow optimize memory usage, and how do you define item keys?
Direct Answer
Lazy components compose and layout only the currently visible items on the screen. Explicit keys ensure item identities are preserved during updates.
Detailed Explanation
Unlike standard columns that compose all child elements at once, LazyColumn and LazyRow optimize memory usage by only composing and rendering items currently visible on the screen. As the user scrolls, off-screen composables are recycled.
Item Keys: By default, an item's identity is mapped by its index position in the list. If you add, remove, or reorder items, the indices change, forcing Compose to recompose the entire list and lose scroll state. To prevent this, always define explicit, unique keys for items (e.g. key = { item.id }). This preserves the item identity, allowing Compose to animate list shifts and skip recomposing unchanged cards.
Code Example
@Composable
fun ItemList(items: List<Product>) {
LazyColumn {
items(items, key = { it.id }) { product ->
ProductRow(product) // Skip recomposing if identity is unchanged
}
}
}
Common Interview Pitfalls
- Using changing indices or class hashCode values as keys, leading to duplicate key errors or memory leaks.
- Placing another scrollable component (like a nested scrollable Column) inside a LazyColumn without specifying height bounds.
Describe Activity and Fragment lifecycle state transitions and where to release resources.
Direct Answer
Activity and Fragment cycles transition from created to resumed. Release heavy UI resources in onDestroy, and listeners in onPause or onStop depending on state.
Detailed Explanation
Activities and Fragments cycle through matching lifecycle states:
onDestroyView to prevent memory leaks because the Fragment instance outlives its view lifecycle).Code Example
class DetailFragment : Fragment() {
private var _binding: FragmentDetailBinding? = null
private val binding get() = _binding!!
override fun onDestroyView() {
super.onDestroyView()
_binding = null // Prevent View memory leaks
}
}
Common Interview Pitfalls
- Failing to clear view binding references in `onDestroyView` for Fragments, holding the entire view hierarchy in memory when the fragment is in the backstack.
- Registering heavy event listeners in `onResume` but failing to remove them in `onPause`.
What is the difference between Foreground Services and WorkManager in Android?
Direct Answer
Foreground Services execute immediate, user-perceptible background tasks with a persistent notification. WorkManager schedules persistent, deferrable background tasks.
Detailed Explanation
Android enforces strict background execution limits. To run code when the app is in the background, you must choose the correct component:
only run when connected to Wi-Fi).Code Example
val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Only Wi-Fi
.setRequiresCharging(true)
.build())
.build()
WorkManager.getInstance(context).enqueue(uploadWorkRequest)
Common Interview Pitfalls
- Using Foreground Services for background sync actions that do not interest the user directly (spams notification drawers unnecessarily).
- Using standard threads or coroutines inside an Activity for long-running synchronization (these terminate instantly if the OS kills the process).
How do you secure Intents and PendingIntents in Android to prevent security vulnerabilities?
Direct Answer
Secure Intents by using explicit declarations for internal components, and secure PendingIntents by setting FLAG_IMMUTABLE flag, preventing parameter hijacking.
Detailed Explanation
Intents pass data between application components, but exposed intent interfaces are vulnerable to injection or hijacking:
Intent(context, Target::class.java)). This prevents intercept attacks.android:exported="false" in the Manifest unless external apps explicitly need to trigger it.PendingIntent wraps an intent, delegating the authority of your app to another application (like the System Notification Manager). If a PendingIntent is mutable (FLAG_MUTABLE), the target app can inspect the intent and modify its internal parameters. Always use PendingIntent.FLAG_IMMUTABLE by default, specifying mutability only if required for notifications inputs.Code Example
val intent = Intent(context, SecureActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
Common Interview Pitfalls
- Creating mutable PendingIntents without defining a target package, allowing malicious third-party apps to hijack the intent contents.
- Forgetting to declare the `android:exported` property explicitly for manifest components in apps targetting Android 12+.
What are the different Context types in Android, and how can they cause memory leaks?
Direct Answer
Application Context lives for the app runtime. Activity Context is short-lived. Storing an Activity Context reference in long-lived singletons causes memory leaks.
Detailed Explanation
A Context is a handle to Android system resources. However, mixing up Context scopes is the primary cause of memory leaks:
Memory Leaks: If you store a reference to an Activity Context inside a singleton, static variable, or long-running background thread, that Activity cannot be garbage collected on destroy because a reference still exists. This leaks the entire View tree, causing high heap consumption and OutOfMemory (OOM) errors.
Code Example
// Memory Leak Example:
object LeakManager {
private var context: Context? = null
fun init(ctx: Context) {
this.context = ctx // Leaks if ctx is an Activity Context!
}
}
// Fix: Use ctx.applicationContext instead
Common Interview Pitfalls
- Passing an Activity Context directly to database initialize helpers or API clients that live for the duration of the application.
- Holding static references to Views (which implicitly hold references to their parent Activity Context).
What is Scoped Storage in Android, and how does it affect file access?
Direct Answer
Scoped Storage isolates app storage. Apps have read/write access to their private folder and MediaStore without requiring storage permissions.
Detailed Explanation
In Android 10+, Google enforced Scoped Storage to protect user privacy and avoid cluttered directories:
context.filesDir and context.cacheDir). These files are deleted when the app is uninstalled.MediaStore API. Writing to MediaStore does *not* require any runtime permissions. Reading files created by *other* apps in shared folders requires permission (READ_MEDIA_IMAGES, etc.).Code Example
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "photo.jpg")
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
}
val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
// Write bytes to imageUri using resolver.openOutputStream(imageUri)
Common Interview Pitfalls
- Requesting broad `READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE` permissions on Android 13+ (these are ignored; you must use specific media permissions instead).
- Using raw file paths (e.g. `/sdcard/`) to write to shared folders, which throws a permission crash in scoped storage.
How do you secure dynamic Broadcast Receivers in Android?
Direct Answer
Secure dynamic Broadcast Receivers by registering them with explicit export flags (RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED) in Android 13+.
Detailed Explanation
Broadcast Receivers capture system events or intents broadcast by other applications. However, unsecured receivers can be triggered by malicious apps sending fake intents:
RECEIVER_NOT_EXPORTED: Restricts broadcasts to the current application or system only. Use this for internal communications.RECEIVER_EXPORTED: Allows external applications to trigger the receiver. Use this only if you need third-party communication.android:exported="false" or specifying custom permissions.Code Example
val receiver = MyBroadcastReceiver()
val filter = IntentFilter("com.example.ACTION_UPDATE")
context.registerReceiver(
receiver, filter,
Context.RECEIVER_NOT_EXPORTED // Secure: internal app broadcasts only
)
Common Interview Pitfalls
- Registering dynamic broadcast receivers without specifying export flags on Android 13+ devices, causing runtime exceptions.
- Forgetting to unregister dynamically registered receivers in matching lifecycle methods (`onStop` / `onDestroy`), causing context leaks.
Explain Clean Architecture and MVVM patterns in Android development.
Direct Answer
Clean Architecture divides code into layers (Presentation, Domain, Data) with strict dependency rules. MVVM separates UI (View) from state/logic (ViewModel).
Detailed Explanation
Modern Android architecture is structured to separate concerns and support testing:
1. Clean Architecture Layers:
2. MVVM (Model-View-ViewModel):
Code Example
// Domain Use Case (Pure Kotlin, no Android dependencies)
class GetUserUseCase(private val repository: UserRepository) {
suspend operator fun invoke(id: String): UserResult = repository.getUser(id)
}
Common Interview Pitfalls
- Importing Android framework classes (like `android.view.View` or context) into the Domain Layer, breaking clean architecture isolation.
- Writing database transactions or network requests directly inside ViewModels instead of delegating to repositories.
How do ViewModels survive configuration changes, and how does SavedStateHandle help with process death?
Direct Answer
ViewModels are cached in the ViewModelStoreOwner across configuration changes. SavedStateHandle persists data during OS background process termination.
Detailed Explanation
Android ViewModels survive screen rotations (configuration changes) through caching:
ViewModelStore is retained in memory by the system. The new Activity instance retrieves the existing ViewModel from this cache, preventing data reload.SavedStateHandle into the ViewModel. The OS automatically saves state key-value pairs in a system Bundle, restoring them when the user returns to the app.Code Example
class UserViewModel(private val savedState: SavedStateHandle) : ViewModel() {
// SavedStateHandle automatically persists and restores this query value
val searchQuery = savedState.getStateFlow("query", "")
fun setQuery(q: String) {
savedState["query"] = q
}
}
Common Interview Pitfalls
- Passing Activity references, Contexts, or views into ViewModels (causes severe memory leaks on rotation because the ViewModel outlives the Activity).
- Storing massive payloads or images in SavedStateHandle (Bundle size is limited to 1MB; excessive size causes TransactionTooLargeExceptions).
Compare Hilt and Dagger2 for dependency injection in Android applications.
Direct Answer
Dagger2 is a compile-time dependency injection framework requiring custom setups. Hilt builds on top of Dagger2, simplifying integration with predefined scopes and Android components.
Detailed Explanation
Both frameworks validate dependencies at compile time, eliminating runtime reflection overhead, but they differ in setup configuration:
SingletonComponent, ActivityComponent, ViewModelComponent) that map to standard Android lifecycles automatically.@HiltAndroidApp and @AndroidEntryPoint to automatically bootstrap injection points.Code Example
@HiltAndroidApp // Bootstraps Hilt in Application class
class MyApplication : Application()
@AndroidEntryPoint // Enables injection in Activity
class MainActivity : ComponentActivity() {
@Inject lateinit var analytics: AnalyticsTracker
}
Common Interview Pitfalls
- Declaring dependencies inside `@InstallIn(ActivityComponent::class)` and attempting to inject them into ViewModels (ViewModels outlive Activities; dependencies must be scoped to ViewModelComponent or SingletonComponent instead).
- Forgetting to add `@Inject constructor()` on dependency classes, preventing Hilt from resolving class instantiation.
How do you design an offline-first architecture in Android using Room and Retrofit?
Direct Answer
Design a single source of truth repository. The UI observes database updates via Room (Flows). Retrofit updates the database in the background, updating the UI.
Detailed Explanation
An offline-first architecture guarantees a functional UI even without an active internet connection:
1. Single Source of Truth: The UI should never observe network calls directly. It observes data stored in the local SQLite database via Room (using Kotlin Flows).
2. Synchronization: When user actions trigger data updates, the repository saves data locally first, and schedules a background network sync (using WorkManager or Coroutines).
3. Network Updates: The repository fetches data from Retrofit in the background, writes it to the Room database, and Room automatically notifies active UI flow collectors of the data change.
Code Example
class UserRepository(private val userDao: UserDao, private val api: UserApi) {
// Flow emission from SQLite Room database acts as single source
val userProfile: Flow<User> = userDao.observeUser()
suspend fun refreshUser() {
val networkUser = api.fetchUser()
userDao.insert(networkUser) // Triggers automatic Flow updates to UI
}
}
Common Interview Pitfalls
- Updating the local database without checking for write conflicts, leading to data synchronization inconsistencies.
- Performing database writes or reads on the Main Thread (Room checks this and throws an IllegalStateException; always execute queries on dispatcher threads).
How does Jetpack Navigation Component handle backstack management and deep links?
Direct Answer
Jetpack Navigation manages screen transactions using nav graphs. It handles deep links by automatically parsing intent data and rebuilding the backstack.
Detailed Explanation
The Jetpack Navigation Component coordinates screen transactions:
NavHostController to manage a stack of destinations. Custom transactions (like clearing backstack up to a home screen) are defined using popup options (popUpTo / inclusive).Code Example
composable(
route = "details/{id}",
deepLinks = listOf(navDeepLink { uriPattern = "https://example.com/details/{id}" })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailScreen(id)
}
Common Interview Pitfalls
- Failing to specify argument types in deep link configurations, causing route parsing validation crashes.
- Re-creating the NavHostController on every recomposition instead of hoisting it to the top-level parent wrapper.
What are the benefits and patterns of a multi-module project structure in Android?
Direct Answer
Multi-module structures partition applications into Gradle subprojects (feature, core, app). This decreases build times, improves encapsulation, and isolates teams.
Detailed Explanation
In large Android codebases, separating code into multiple Gradle modules is essential:
internal keyword in Kotlin limits class visibility to the containing module, preventing tight coupling.:feature:login, :feature:profile) is self-contained.:core:database, :core:network, :core:designsystem).Code Example
// Gradle feature module build.gradle.kts
dependencies {
implementation(project(":core:network"))
implementation(project(":core:designsystem"))
}
Common Interview Pitfalls
- Creating circular dependencies between feature modules (e.g. `:feature:login` depending on `:feature:profile` and vice versa; solve by creating shared modules or interfaces).
- Declaring API dependency versions independently across modules, causing version conflict errors during runtime aggregation.
Compare Espresso UI testing with Robolectric tests in Android.
Direct Answer
Espresso tests are instrumented tests running on a real device/emulator. Robolectric tests are local unit tests that simulate the Android sandbox on the JVM.
Detailed Explanation
Testing in Android is divided into instrumented and local JVM environments:
/androidTest folder and require a connected Android emulator or physical device./test on a standard development machine JVM.Code Example
// Robolectric test running on local JVM
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class MyActivityTest {
@Test
fun testClick() {
val controller = Robolectric.buildActivity(MyActivity::class.java).setup()
val activity = controller.get()
activity.findViewById<Button>(R.id.btn_submit).performClick()
}
}
Common Interview Pitfalls
- Using Espresso tests for simple presenter or ViewModel unit tests, slowing down the build CI/CD pipeline.
- Assuming Robolectric shadow behavior matches physical GPU layout sizing checks (cannot test layout overlaps or pixel measurements).
How do you unit test a ViewModel that exposes StateFlow data streams using JUnit?
Direct Answer
Mock dependencies, set up a custom Main Coroutine Dispatcher in JUnit, and collect/assert emitted StateFlow values.
Detailed Explanation
Unit testing ViewModels requires configuring coroutines correctly:
1. Main Dispatcher Override: JUnit tests run on a plain JVM where Dispatchers.Main is undefined. You must override the Main dispatcher using a custom test rule containing StandardTestDispatcher().
2. Mocking: Mock dependencies (like repositories) using Mockito or Mockk.
3. Flow Collection: Since StateFlow is a hot stream that does not complete, you cannot call blocking collection methods directly. Use kotlinx.coroutines.test utilities to collect emissions inside a runTest scope.
Code Example
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher) // Set Main dispatcher to test runner
}
@Test
fun testLoadingState() = runTest {
val viewModel = MainViewModel(mockRepo)
val state = viewModel.uiState.value
Assert.assertEquals(UiState.Loading, state)
}
}
Common Interview Pitfalls
- Failing to call `Dispatchers.setMain` in the test setup, causing a "Module with the Main dispatcher had failed to initialize" crash.
- Calling `viewModel.uiState.collect { ... }` directly in the test without scoping it inside a coroutine launch, freezing the test runner.
How does LeakCanary detect memory leaks in Android applications?
Direct Answer
LeakCanary observes object lifecycles. It uses weak references to verify objects are garbage collected after destruction, generating a heap dump trace on failure.
Detailed Explanation
LeakCanary is an automated memory leak detection library:
1. Lifecycle Monitoring: LeakCanary automatically hooks into Activity and Fragment lifecycle callbacks.
2. WeakReference Tracking: When an Activity is destroyed, LeakCanary wraps it inside a WeakReference associated with a ReferenceQueue.
3. GC Verification: It triggers a garbage collection pass after a short delay. If the reference is not cleared (meaning the destroyed object is still held in memory by something else), LeakCanary identifies it as a potential leak.
4. Heap Dump & Trace: If the leak persists, it dumps the JVM heap (.hprof file), parses it in the background, and generates a visual reference chain (leak trace) showing which object reference paths are preventing garbage collection.
Code Example
// Add dependency to build.gradle (no code initialization needed, Hilt automatically loads it on debug builds)
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
Common Interview Pitfalls
- Shipping LeakCanary implementation in release production builds (it consumes high memory and causes performance lag during heap dumps; always use `debugImplementation`).
- Ignoring LeakCanary notifications in debug builds, allowing leaks to propagate to production environments.
How do you profile app startup times and layout performance in Android using Systrace and Android Profiler?
Direct Answer
Use Android Profiler to monitor CPU/Memory allocation in real-time. Use Systrace/Macrobenchmark to capture detailed frame drops and trace system-level layout costs.
Detailed Explanation
Profiling isolates performance bottlenecks (like frame drops or slow app startup):
Code Example
// Trace custom execution block programmatically
import androidx.tracing.trace
fun loadAssets() {
trace("AssetLoadTrace") {
// Business logic monitored in Systrace
processHeavyAssets()
}
}
Common Interview Pitfalls
- Profiling applications in debug builds (debug builds add runtime log wrappers and disable R8 optimization, yielding inaccurate performance metrics; always profile in release-like builds with proguard enabled).
- Analyzing average frame rates instead of focusing on 99th percentile frame drop spikes (jank is perceived as spikes, not averages).
What techniques reduce APK download sizes in Android applications?
Direct Answer
Use Android App Bundles (AAB), enable resource and code shrinking (R8), convert images to WebP format, and remove unused resources.
Detailed Explanation
Reducing APK size is critical to prevent app store install drop-offs:
1. Android App Bundle (AAB): Publish in AAB format instead of APK. Google Play uses the bundle to generate optimized APKs tailored to each user's device density, ABI architecture, and language, saving up to 50% download size.
2. R8 Code & Resource Shrinking: Configure Gradle to strip unused code and resources during release packaging:
isMinifyEnabled = true: Enables R8 to remove unreachable code.isShrinkResources = true: Strips resources that are not referenced in code.3. Image Optimization: Convert PNG/JPG files to WebP format, which has superior compression ratios without quality loss. Use VectorDrawables instead of multiple raster image files where possible.
Code Example
// build.gradle.kts release build configuration:
buildTypes {
getByName("release") {
isMinifyEnabled = true // Strip dead code
isShrinkResources = true // Strip dead resources
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
Common Interview Pitfalls
- Manually compressing image files while keeping duplicate assets for different screen density buckets (e.g. hdpi, xhdpi) instead of using vectors.
- Failing to verify R8 reflections rules, causing runtime crashes on dynamic class loading.
How do Proguard and R8 optimize and obfuscate code, and how do you write keep rules?
Direct Answer
R8 performs compile-time code shrinking and optimization. Obfuscation renames classes and methods to single letters. Keep rules preserve reflection targets.
Detailed Explanation
R8 is Google's default replacement for Proguard, performing three operations during release compilation:
1. Shrinking: Traces dependencies starting from entry points (Activities, Services) and removes unreachable classes, fields, and methods.
2. Optimization: Optimizes instructions, inlines functions, and flattens class hierarchies.
3. Obfuscation: Renames remaining classes, methods, and variables to short, unreadable letters (e.g., UserRepository to a.b.c), making reverse engineering difficult.
Keep Rules (`proguard-rules.pro`): Since R8 static analysis cannot trace reflection calls or runtime string lookups (like Gson parsing a JSON key to a Java class field), it might strip or rename these classes, causing runtime crashes. You must define -keep rules to instruct R8 to bypass optimization/obfuscation for those specific models.
Code Example
# Keep rule: preserve all fields in serialize model package
-keepclassmembers class com.example.models.** {
@com.google.gson.annotations.SerializedName <fields>;
}
Common Interview Pitfalls
- Writing over-broad keep rules (like `-keep class com.example.** { *; }`), which disables optimization for the entire package and increases APK size.
- Forgetting to upload the mapping file (`mapping.txt`) to Google Play Console, preventing the de-obfuscation of production crash stack traces.
Want to tailer your resume for Android Developer roles?
Import your resume, scan it for critical Android Developer keywords, and compare it against ATS standards instantly.