20 frequently asked Android interview questions. Android development is an in-demand field focused on building Kotlin-based mobile apps, working with Android SDK and Jetpack, and solving lifecycle, performance, networking, storage, testing, and release tasks. The questions cover different levels, and you can practice answering them aloud in our interview trainer.
1Walk through the core Activity lifecycle callbacks from creation to destruction, and explain the differences between onPause(), onStop(), and onDestroy() regarding resource release.
An Activity transitions through six core lifecycle callbacks: onCreate(), onStart(), onResume(), onPause(), onStop(), and onDestroy(). onCreate() performs one-time initialization such as view inflation. onStart() makes the activity visible, and onResume() brings it into the foreground where it has interactive focus. When navigating away, onPause() indicates the activity lost focus, onStop() indicates it is no longer visible on screen, and onDestroy() indicates final teardown of the activity instance. Regarding resource release: - onPause(): Only pause quick, foreground-specific operations (such as pausing UI animations or lightweight camera previews) because code execution in onPause() directly blocks the next incoming activity from starting. - onStop(): This is the primary place to release heavier resources tied to UI visibility (such as GPS/location listeners, sensor feeds, media player playback, or network polling). Because the activity is fully invisible, keeping these active wastes battery, and the system may terminate the background process without further notice. - onDestroy(): Used for final cleanup of the Activity instance (such as clearing local threads or references). However, critical resources should not wait until onDestroy() because Android can kill a stopped app process directly to reclaim memory without ever executing onDestroy().
class LocationActivity : AppCompatActivity() {
private var locationClient: LocationClient? = null
override fun onStart() {
super.onStart()
// Start listening when visible to the user
locationClient?.startLocationUpdates()
}
override fun onStop() {
super.onStop()
// Release when invisible to save battery and prevent leaks on process kill
locationClient?.stopLocationUpdates()
}
}
2What are the four primary Android application components, and what is the lifecycle and threading model of their default callbacks?
The four primary Android application components are Activity, Service, BroadcastReceiver, and ContentProvider. An Activity provides a user interface for screen interactions. A Service performs background processing without a dedicated UI. A BroadcastReceiver listens for and responds to system-wide or app-level broadcast announcements. A ContentProvider manages and exposes structured data to other applications or internal modules. By default, the primary lifecycle callbacks for Activity (such as onCreate, onStart, onResume), Service (such as onCreate, onStartCommand, onBind), and BroadcastReceiver (onReceive) execute synchronously on the application's main thread (UI thread). ContentProvider methods (such as onCreate) also initialize on the main thread, though query/insert operations invoked across processes run on Binder thread pool threads. Because default callbacks run on the main thread, executing heavy blocking operations like network requests or large database disk I/O directly in them blocks the UI loop and triggers an Application Not Responding (ANR) error.
class DataSyncService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// onStartCommand runs on the Main Thread; heavy work must be offloaded
serviceScope.launch {
performDiskAndNetworkSync()
stopSelf(startId)
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
private fun performDiskAndNetworkSync() {
// Blocking I/O safely executed on Dispatchers.IO
}
}
3In Android's MVVM architecture, what are the primary responsibilities of a ViewModel compared to UI controllers like Activities and Fragments, and why is passing Context to a ViewModel discouraged?
In Android's MVVM architecture, the ViewModel is responsible for holding and managing UI-related state, orchestrating presentation logic, and surviving configuration changes (such as screen rotations). In contrast, UI controllers (Activities and Fragments) are responsible for rendering UI elements on the screen, observing state changes, handling direct user interactions (clicks, gestures), and managing Android lifecycle events. Passing an Activity Context or View reference to a ViewModel is strongly discouraged because ViewModels generally outlive the lifecycle of UI controllers across configuration changes. When an Activity is destroyed and recreated during rotation, a ViewModel holding its Context prevents the old Activity from being garbage collected, causing a memory leak. If an Android Context is strictly necessary for system-level operations, an application-scoped Context (such as via `AndroidViewModel` or injecting ApplicationContext) should be used instead.
// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}
// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Success(repository.getUser(id))
}
}
}
4How do normal and dangerous Android permissions differ, and what happens when an app requests a dangerous permission at runtime?
Normal permissions pose minimal risk to user privacy or device operation and are automatically granted by the system upon app installation when declared in the AndroidManifest.xml (such as ACCESS_NETWORK_STATE). Dangerous permissions (also called runtime permissions) govern access to private user data or restricted device capabilities, such as camera, microphone, location, and contacts. When an app requests a dangerous permission at runtime, the Android system presents a system dialog prompting the user to allow or deny access. If the user grants permission, the app executes the requested feature. If the user denies permission, the app receives a denial callback and must handle it gracefully by disabling or degrading the dependent feature without crashing, and optionally displaying an educational UI rationale.
5What is the difference between debug and release Android builds, and how do build types and product flavors combine into build variants?
In Android development, build types configure packaging and runtime properties for different stages of development. Debug builds enable debugging flags (`isDebuggable = true`), use a default debug keystore, and typically disable code minification and shrinking (R8/ProGuard) for fast build iterations. Release builds disable debugging flags, enable optimizations and obfuscation, and require a secure release signing key for distribution. Product flavors represent different versions or targets of the app sharing the same core codebase, such as different environments (staging vs. production) or product editions (free vs. paid). In Gradle, build variants are formed by the Cartesian product of product flavors and build types (Build Variant = Product Flavor × Build Type). For instance, combining flavors `staging` and `production` with build types `debug` and `release` produces four build variants: `stagingDebug`, `stagingRelease`, `productionDebug`, and `productionRelease`.
6What is a coroutine in Kotlin, and how does suspension differ from blocking a thread at runtime in Android?
A coroutine in Kotlin is a lightweight concurrency abstraction that enables writing asynchronous, non-blocking code in a sequential manner. Multiple coroutines can run concurrently on a single thread or across thread pools without the heavy memory and context-switching overhead of OS-level threads. The essential difference between suspension and blocking is thread utilization: - Blocking (e.g., Thread.sleep() or synchronous disk/network I/O) halts the underlying thread and makes it unavailable for any other work. On Android's main thread, blocking freezes the UI and triggers Application Not Responding (ANR) errors. - Suspension (via suspend functions such as delay()) pauses the coroutine without halting the underlying thread. The coroutine captures its execution state in a Continuation and yields the thread back to the runtime so it can perform other work (like handling UI interactions). When the asynchronous operation finishes, the coroutine is resumed.
suspend fun fetchUserData(): String {
delay(1000) // Suspends execution and yields the thread
return "User Data"
}
fun fetchUserDataBlocking(): String {
Thread.sleep(1000) // Blocks and locks the underlying thread
return "User Data"
}
7In Jetpack Compose, what is the difference between state preserved with remember versus rememberSaveable, and how does mutableStateOf drive UI updates across recompositions?
In Jetpack Compose, `remember` stores an object in memory across recompositions for as long as the composable remains in the composition tree. However, state held only with `remember` is lost during configuration changes (such as screen rotations) and system-initiated process death. In contrast, `rememberSaveable` preserves state across configuration changes and process death by automatically saving and restoring values via the Android Saved Instance State bundle (or through custom `Saver` implementations for unsupported data types). `mutableStateOf` wraps a value in an observable `MutableState` object backed by Compose's Snapshot state system. When a composable reads the value of a `MutableState` during composition, Compose records that read operation. When the value later changes, Compose automatically marks all composables that read that value as invalid and schedules them for recomposition, ensuring the UI stays synchronized with the updated state.
@Composable
fun CounterExample() {
// Resets to 0 on screen rotation
var localCount by remember { mutableStateOf(0) }
// Persists across screen rotation and process death
var persistentCount by rememberSaveable { mutableStateOf(0) }
Column {
Button(onClick = { localCount++; persistentCount++ }) {
Text("Local: $localCount | Saved: $persistentCount")
}
}
}
8How does Kotlin null safety help prevent crashes in Android apps, and when would you use a nullable type versus a non-null type?
Kotlin null safety helps prevent many Android crashes by making nullability part of the type system. A value declared as `String` is treated as non-null, so the compiler allows direct access like `name.length`. A value declared as `String?` may be null, so Kotlin forces you to handle that case before using it, which reduces accidental `NullPointerException`s from optional Intent extras, API responses, Bundle arguments, or Java/platform APIs. Use a non-null type when the value is required for the object or function to be valid. Use a nullable type when absence is a real expected state, such as an optional profile image URL or missing server field. Nullable values are usually handled with safe calls like `user?.name`, the Elvis operator like `user?.name ?: "Guest"`, or explicit null checks. The `!!` operator opts out of safety and throws if the value is null, so it should be rare and only used when the developer can genuinely prove the value cannot be null.
val displayName: String? = intent.getStringExtra("display_name")
val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"
// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
9What is the mechanical difference between passing an Activity Context versus an Application Context to long-lived objects, and how does choosing the wrong one lead to a memory leak?
An Activity Context is tied directly to the lifecycle of a specific screen and UI hierarchy, whereas an Application Context is tied to the lifecycle of the entire app process. When an Activity finishes or is recreated (e.g., during a configuration change like screen rotation), its lifecycle ends and its memory is meant to be reclaimed by the Garbage Collector (GC). If you pass an Activity Context to a long-lived object—such as a singleton, a static field, or a long-running background thread—that long-lived object retains a strong reference to the Activity. Because long-lived objects act as GC roots or are reachable from GC roots, the garbage collector cannot collect the destroyed Activity. This retains not just the Activity instance itself, but also its entire attached View hierarchy, drawables, and resources, creating a substantial memory leak. In contrast, passing applicationContext is safe for long-lived components because its lifecycle is expected to match the process lifetime.
class LocationRepository private constructor(private val context: Context) {
companion object {
@Volatile
private var INSTANCE: LocationRepository? = null
fun getInstance(context: Context): LocationRepository {
return INSTANCE ?: synchronized(this) {
// SAFE: Using context.applicationContext prevents retaining an Activity reference
INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
}
}
}
}
10What is the distinction between an upload key and an app signing key when using Google Play App Signing?
Under Google Play App Signing, the upload key and the app signing key serve two distinct security roles. The upload key is retained by the developer to sign the build artifact (AAB) prior to uploading it to Google Play Console, verifying the developer's identity to Google. The app signing key is stored securely within Google's cloud infrastructure and is used by Google Play to sign the generated APKs actually delivered and installed onto end-user devices, establishing the app's permanent cryptographic identity on Android. A major operational benefit is key recovery: if a developer loses or compromises their upload key, Google Play support can reset the upload key after verifying developer identity without breaking the app's update path. Under the legacy model where developers directly held the app signing key, losing the key meant the app could never be updated again.
11A screen loses user-entered form data after device rotation. How would you systematically diagnose whether the issue is missing saved state, incorrect ViewModel scoping, or view rebinding?
To systematically diagnose why form data is lost after device rotation, isolate three core areas: 1. **ViewModel Scoping & Retention:** Verify that the ViewModel instance is actually retained across the configuration change rather than being re-created. Log the ViewModel's hash code (`viewModel.hashCode()`) across rotations. Ensure it is obtained via delegated properties like `by viewModels()` / `by activityViewModels()` or `ViewModelProvider(this)`, and not via direct constructor instantiation (e.g., `MyViewModel()`). 2. **Saved State & View ID Restoration:** Check if the form relied solely on Android's default view hierarchy restoration. Views must have an `android:id` attribute defined in the layout to participate in automatic view state save/restore. If using custom views or `SavedStateHandle`, verify that `onSaveInstanceState` / `SavedStateHandle` actually saves and restores the relevant fields. 3. **View Rebinding & Observer Logic:** Inspect `onViewCreated` or observer subscriptions (`StateFlow`, `LiveData`). Verify whether newly created views re-subscribe properly, or if view setup code accidentally overwrites restored text with default empty values. Additionally, check for two-way binding or `TextWatcher` loops where an empty restored view immediately emits a blank update back into the ViewModel.
class FormFragment : Fragment(R.layout.fragment_form) {
// 1. Ensure correct lifecycle scoping
private val viewModel: FormViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")
// 2. Observe state without clobbering input during view recreation
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.formText.collect { text ->
if (binding.inputField.text.toString() != text) {
binding.inputField.setText(text)
}
}
}
}
}
}
12How does the modern Jetpack Activity Result API replace legacy startActivityForResult, and how does it safeguard against callback loss during Activity recreation?
The modern Jetpack Activity Result API replaces legacy `startActivityForResult()` and `onActivityResult()` with a type-safe, decoupled contract-based mechanism. Instead of managing arbitrary integer request codes and large `onActivityResult` switch statements, callers define an `ActivityResultContract<I, O>` (which specifies the input intent parameters and the expected parsed output) and register a typed callback using `registerForActivityResult()` to obtain an `ActivityResultLauncher`. The API safeguards against callback loss during Activity recreation or process death through strict lifecycle registration: 1. When an external Activity (such as a camera or document picker) is launched, the calling Activity may be destroyed by the OS due to memory pressure or configuration changes. 2. By requiring `registerForActivityResult()` to be called unconditionally before the Activity or Fragment reaches the `STARTED` state (typically as a property initializer or inside `onCreate`), the result callback is registered into the `ActivityResultRegistry` before state restoration occurs. 3. When the launched activity returns and the host activity is recreated, the `ActivityResultRegistry` matches the pending result from the system with the newly registered callback and dispatches the result safely.
class ProfileActivity : AppCompatActivity() {
// Registered unconditionally during initialization / before onStart()
private val takePictureLauncher = registerForActivityResult(
ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? ->
bitmap?.let {
findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile)
findViewById<Button>(R.id.btnCapture).setOnClickListener {
takePictureLauncher.launch(null)
}
}
}
13Compare MVVM and MVI architectures for a complex screen: how do state reducers and intent dispatching in MVI guarantee atomic state updates compared to MVVM?
In traditional MVVM, a ViewModel often exposes multiple independent reactive streams (such as `isLoading`, `userData`, `errorMessage` as separate `StateFlow` or `LiveData` properties) or multiple public update methods. When multiple asynchronous tasks finish concurrently, these streams can update independently, leading to race conditions, intermediate incomplete states, or visual flickering. In MVI (Model-View-Intent), state management follows strict Unidirectional Data Flow (UDF) built around three elements: 1. A single immutable `UiState` representing the entire screen state. 2. Discrete `UiIntent` (or Action) types representing all user interactions and system events. 3. A pure State Reducer function: `(PreviousState, UiIntent) -> NewState`. MVI guarantees atomic state updates because all events are dispatched as distinct intents and routed sequentially through the state reducer. The reducer takes an immutable snapshot of the existing state and produces an entirely new state snapshot with all related properties updated simultaneously. Because state updates are centralized and transitions are sequential, the UI never observes a partial, out-of-sync, or contradictory state snapshot.
data class ScreenState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null
)
sealed interface ScreenIntent {
data object Refresh : ScreenIntent
data class DataLoaded(val items: List<Item>) : ScreenIntent
data class LoadFailed(val message: String) : ScreenIntent
}
fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
14How would you design a runtime permission flow that handles first request, rationale UI, denial, 'don't ask again', and a settings fallback without coercing the user?
A non-coercive, user-friendly runtime permission flow follows progressive disclosure, clear rationale, and graceful degradation across all denial states: 1. **In-Context Request (First Time)**: Request permissions only at the point of need when the user interacts with a feature (e.g., tapping 'Scan Code' for Camera permission), rather than up-front on app startup. 2. **Rationale UI**: When `shouldShowRequestPermissionRationale()` returns `true`, show a clear in-app explanation (such as a bottom sheet or dialog) explaining why the permission is required and what benefit it provides before triggering the system prompt. 3. **Graceful Degradation on Denial**: If the user denies the request, respect the choice without blocking unrelated features. Provide an alternative workflow where possible (e.g., manual text input if camera access is denied). 4. **Permanent Denial ('Don't Ask Again') & Settings Fallback**: If the permission is denied and `shouldShowRequestPermissionRationale()` returns `false` (the user selected 'Don't ask again' or denied repeatedly), inform the user why the feature is unavailable and offer an optional button directing them to app settings via `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Ensure the user can easily cancel or navigate back without being trapped.
fun onScanButtonClicked() {
when {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
openScanner()
}
activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
}
else -> {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
fun handlePermissionResult(isGranted: Boolean) {
if (isGranted) {
openScanner()
} else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
showSettingsRedirectDialog()
} else {
showManualEntryFallback()
}
}
15How would you design an Android CI/CD pipeline from pull request validation through signed release candidate creation, internal testing, Play track upload, and rollout promotion?
A production Android CI/CD pipeline begins at the pull request validation stage with fast automated checks: static analysis (ktlint, detekt, Android Lint), unit tests, and compiling a debug build. Upon merge to the main or release branch, the release pipeline triggers to generate a signed Release Candidate (RC) Android App Bundle (`.aab`) using CI-managed release keystore credentials, Proguard/R8 shrinking, and version bump automation. The signed AAB and its deobfuscation mapping files are uploaded to Google Play's Internal Testing track (or Firebase App Distribution) using tools like Gradle Play Publisher (GPP) or Fastlane, where automated smoke or instrumentation tests run. Once internal QA and stakeholders approve the build, the exact same binary artifact is promoted downstream (Internal -> Closed Alpha/Beta -> Production Rollout) without rebuilding from source. Finally, staged rollouts (e.g., 5% -> 20% -> 100%) paired with automated crash rate and vitals monitoring ensure safe deployment.
lane :promote_internal_to_beta do
upload_to_play_store(
track: 'internal',
track_promote_to: 'beta',
skip_upload_aab: true, # Promotes existing binary without rebuilding
skip_upload_metadata: false,
skip_upload_changelogs: false
)
end
16Why does catching generic Exception or Throwable without rethrowing CancellationException break coroutine cancellation, and how do you diagnose cancellation issues?
Kotlin Coroutines rely on cooperative cancellation implemented through CancellationException. When a coroutine's Job is cancelled, suspending calls (such as delay or yield) throw a CancellationException to unwind the call stack and terminate the coroutine. When code catches generic Exception or Throwable without rethrowing CancellationException, the cancellation signal is swallowed. The coroutine fails to abort and keeps executing, creating a 'zombie coroutine' that wastes CPU/memory, leaks resources, and may trigger invalid state transitions or crashes against destroyed UI components. To diagnose cancellation issues: 1. Verify exception handling hygiene by ensuring CancellationException is explicitly rethrown or domain-specific exceptions are caught instead of generic Throwable/Exception. 2. Inspect coroutine states in debug builds using the Kotlin Coroutines Debugger (-Dkotlinx.coroutines.debug) or the Android Studio Coroutines Inspector to identify running coroutines that should have terminated. 3. Log coroutine lifecycle states (e.g., job.isActive, job.isCancelled) or use structured logging in cancellation completion handlers (job.invokeOnCompletion).
import kotlinx.coroutines.CancellationException
// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
doSuspendingWork()
} catch (e: Exception) {
logError(e)
}
// CORRECT: Preserves cancellation propagation
try {
doSuspendingWork()
} catch (e: Exception) {
if (e is CancellationException) throw e
logError(e)
}
17How do state hoisting and Unidirectional Data Flow (UDF) work in Jetpack Compose, and how do you decide whether state belongs in a leaf composable, a parent composable, or a ViewModel?
State hoisting is a pattern in Jetpack Compose where state is moved up the composition hierarchy to make a composable stateless, turning it into a pure UI component. It enables Unidirectional Data Flow (UDF), where state flows down (from parent/ViewModel to child composables as arguments) and events flow up (from child composables to parent/ViewModel as callbacks). Deciding where state belongs follows these guidelines: 1. Leaf Composable (Local UI state): If the state is purely transient, visual, and not needed by any parent or sibling (e.g., whether an internal expand/collapse animation is running or a local ripple effect), keep it local in the leaf composable using `remember`. 2. Parent Composable (Hoisted UI state): If sibling composables need to share or react to the state, or if the parent controls the component's visibility/validation, hoist the state to the immediate common parent. The leaf becomes stateless (takes `value` and `onValueChange`). 3. ViewModel (Screen / Business state): If state represents business data, survives configuration changes, drives navigation, or requires interaction with domain/repository layers, it belongs in a ViewModel exposed as observable state (e.g., `StateFlow`). The ViewModel handles business logic and updates the UI state accordingly.
18How would you design telemetry, crash breadcrumbs, and architectural guardrails to detect, diagnose, and prevent production FragmentManager state loss and navigation race conditions?
FragmentManager state loss (`IllegalStateException: Can not perform this action after onSaveInstanceState`) and async navigation races occur when asynchronous operations—such as network callbacks or reactive streams—attempt UI transactions after the host lifecycle has transitioned past `onSaveInstanceState()` or `onStop()`. To detect and diagnose these in production, we implement lifecycle telemetry and breadcrumbs via `Application.ActivityLifecycleCallbacks` and `FragmentManager.FragmentLifecycleCallbacks`, capturing timestamped transitions, pending backstack counts, and execution context prior to crashes. To prevent state loss architecturally, navigation and UI transactions must be driven exclusively by lifecycle-aware state observers (such as `repeatOnLifecycle(Lifecycle.State.RESUMED)` or `StateFlow` collected with lifecycle binding) rather than raw asynchronous callbacks. Navigation events should be modeled as discrete unidirectional data flow (UDF) state transitions or single-shot events consumed only while the lifecycle state is at least `STARTED` or `RESUMED`. Furthermore, architectural guardrails should enforce safety using `commitStateLoss()` only in explicit non-restorable transient contexts, or preferably migrating to Jetpack Navigation component with strict lifecycle bounds. Static analysis via custom Android Lint rules and runtime enforcement in debug builds (e.g., Fragment StrictMode) can catch violations before reaching production.
class NavigationDispatcher @Inject constructor() {
private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
val events: Flow<NavigationCommand> = _events.receiveAsFlow()
fun navigate(command: NavigationCommand) {
_events.trySend(command)
}
}
// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
navigationDispatcher.events.collect { command ->
CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
command.execute(parentFragmentManager)
}
}
}
19How do system-level power management features like Doze Mode, App Standby Buckets, and OEM task killers impact background scheduling, and how would you design a resilient sync engine using WorkManager?
System-level battery optimizations severely restrict background execution. Doze Mode restricts CPU, network access, and background jobs during periods of inactivity, releasing execution only during periodic maintenance windows. App Standby Buckets dynamically throttle background job frequency and network access based on app usage recency (from Active down to Restricted or Never). Furthermore, aggressive OEM custom power managers (such as MIUI or OneUI) frequently kill background processes, ignore standard alarms, and strip autostart permissions regardless of vanilla AOSP behavior. To build a resilient sync engine, WorkManager is the standard foundation because it abstracts JobScheduler, AlarmManager, and BroadcastReceivers while integrating directly with OS constraints. WorkManager allows declaring strict execution preconditions (such as NetworkType.CONNECTED, requiresBatteryNotLow(true)), automatically deferring execution until Doze maintenance windows or until connectivity is restored. To withstand unexpected process termination, transient network drops, and OEM kills, the sync engine must adhere to two core principles: exponential backoff and end-to-end idempotency. WorkManager should be configured with BackoffPolicy.EXPONENTIAL to avoid thundering herd issues on backend servers when recovering from Doze. In addition, workers must treat sync operations as atomic and idempotent—using deterministic transaction IDs, local state flags, and server-side deduplication keys—so that if a worker is abruptly killed midway and re-enqueued, retrying does not duplicate data or corrupt local databases.
20How do you design a scalable multi-team Android architecture using the 'API-Implementation' module separation pattern to optimize Gradle build times and enforce strict contract boundaries?
In an enterprise multi-team Android codebase, the API-Implementation (API-Impl) pattern splits each feature or domain module into two distinct Gradle subprojects: a lightweight `:feature:api` module containing public interfaces, models, and navigation contracts, and a `:feature:impl` module containing internal business logic, UI, and repository implementations. Consuming modules depend exclusively on `:feature:api` using `implementation project(':feature:api')`, while the root `:app` module or dedicated composition roots tie concrete implementations together via Dependency Injection (e.g., Dagger/Hilt). This pattern dramatically optimizes Gradle build performance through Application Binary Interface (ABI) stability and compilation classpath isolation. When engineers modify implementation details in `:feature:impl`, the public ABI of `:feature:api` remains unchanged. Consequently, Gradle skips recompilation of all downstream modules that depend only on `:feature:api`, maximizing the effectiveness of Gradle Remote Build Cache and configuration cache. From an organizational and governance perspective, this pattern establishes clear cross-team ownership boundaries using tools like CODEOWNERS. Teams can safely evolve their internal implementation details without exposing private classes, preventing unwanted tight coupling and circular dependencies across large teams.