Introduction
Interview questions about Android development often separate solid mid-level engineers from those ready for senior roles. The difference isn't memorising answers—it's demonstrating a real mental model of how the platform works, what problems you've solved, and how you'd approach unfamiliar challenges.
This article covers Android interview questions: what developers should know, focusing on topics that come up repeatedly in technical conversations with teams building production apps. We'll work through activity lifecycle nuances, state management philosophy, concurrency patterns, and modern Compose rendering. Each section includes practical examples and the reasoning behind best practices, so you can explain not just what to do, but why it matters in real codebases.
Activity lifecycle: The full picture from onCreate to onDestroy
Most developers know the activity lifecycle states, but interviews often dig deeper. The question "Walk me through activity lifecycle" can expose whether you understand process death, configuration changes, and edge cases that bite production apps.
The basic sequence is well-documented: onCreate → onStart → onResume → onPause → onStop → onDestroy. But the critical part is understanding when the system calls each callback and, crucially, what happens when the process is killed by the OS.
Consider this scenario: a user opens your app, navigates to a detail screen, and then rotates the device. The system destroys the current activity and recreates it. If you're holding a reference to a database cursor or network request in an instance variable, you'll lose it. This is where savedInstanceState comes in:
class DetailActivity : AppCompatActivity() {
private var userId: String? = null
private var userData: User? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_detail)
// Restore from savedInstanceState if available
if (savedInstanceState != null) {
userId = savedInstanceState.getString("USER_ID")
userData = savedInstanceState.getParcelable("USER_DATA")
} else {
// First launch or process death recovery
userId = intent.getStringExtra("USER_ID")
loadUserData(userId)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("USER_ID", userId)
outState.putParcelable("USER_DATA", userData)
}
}However, savedInstanceState has a crucial limitation: it's not guaranteed to persist across process death. If the OS kills your process to reclaim memory, savedInstanceState is lost. This is why we separate UI state (needed for configuration changes) from application state (needed for process death recovery).
A follow-up question often explores this: "What's the difference between savedInstanceState and ViewModel?" The answer reveals whether you've grappled with state management in real apps.
Why ViewModel: Surviving configuration changes and holding UI state
ViewModels exist to solve a specific problem: holding UI-related state that should survive configuration changes but can be cleared when the user genuinely leaves the screen.
Before ViewModels, developers often retained fragments or held state in singleton references, both fragile approaches that led to memory leaks and orphaned data. ViewModels are scoped to the activity or fragment lifecycle, not the activity instance lifecycle. This distinction is critical.
Here's a practical example: a search screen where users type a query, the results load asynchronously, and then the device rotates.
class SearchViewModel : ViewModel() {
private val _searchResults = MutableLiveData>()
val searchResults: LiveData> = _searchResults
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData = _isLoading
private var currentQuery: String = ""
fun search(query: String) {
currentQuery = query
_isLoading.value = true
viewModelScope.launch {
try {
val results = repository.search(query)
_searchResults.value = results
} finally {
_isLoading.value = false
}
}
}
override fun onCleared() {
// Clean up resources when the ViewModel is destroyed
super.onCleared()
}
}
class SearchActivity : AppCompatActivity() {
private val viewModel: SearchViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
viewModel.searchResults.observe(this) { results ->
updateUI(results)
}
// If the ViewModel already has results from a previous search,
// they're available immediately after configuration change
}
}When the device rotates, the SearchActivity is destroyed and recreated, but the SearchViewModel instance persists. If a network request was in progress, it continues. If results were already loaded, they're immediately available without reloading.
The key insight: ViewModel is not a database. It's a container for transient UI state that needs to outlive activity recreation but should be cleared when the user leaves the screen or the app is force-stopped. For persistence across app restarts, use a database or SavedStateHandle.
Coroutines vs threads: Why we switched
This question filters out developers who've only shipped small projects. Production systems at scale reveal why threads become a problem and why coroutines changed the game.
The traditional approach: spawn threads for background work. The problems emerge fast:
- Each thread consumes ~1 MB of memory. Creating hundreds of threads kills device RAM.
- Thread state is hard to reason about. Who owns cleanup? When does it complete?
- Cancellation requires manual flags or interrupt() calls, which are fragile.
- Switching between threads requires callbacks or RxJava, adding cognitive overhead.
Coroutines solve this by running lightweight suspend functions that yield control without blocking threads:
// Old approach: Thread-per-request
fun fetchUserOld() {
thread {
try {
val user = api.getUser(userId) // Blocks the thread
runOnUiThread {
updateUI(user)
}
} catch (e: Exception) {
// Error handling scattered across thread boundaries
}
}
}
// Coroutine approach: Structured concurrency
fun fetchUserNew() {
viewModelScope.launch {
try {
val user = withContext(Dispatchers.IO) {
api.getUser(userId) // Suspends, doesn't block
}
// Automatically back on Main dispatcher
updateUI(user)
} catch (e: Exception) {
showError(e)
}
}
}
The coroutine approach is lighter, cleaner, and safer. Multiple coroutines share a small thread pool. Cancellation is automatic when the scope (viewModelScope, lifecycleScope) is cancelled. Exception handling is straightforward because you're writing synchronous-looking code in a try-catch block, even though it's async.
Interviewers dig into this to see if you understand: Why structured concurrency? The answer is that tying coroutine lifetime to a component lifecycle (Activity, ViewModel, Fragment) prevents leaks and dangling work. If the user leaves the screen, all work in that scope is cancelled. You don't have to manage it manually.
Recomposition in Compose: Understanding when and why
Jetpack Compose is now the default UI toolkit for Android. Interview questions here test whether you grasp the declarative paradigm and performance implications.
Recomposition is the process of re-running composable functions when their inputs change. The key misunderstanding: "Does my entire screen re-render?" No. Compose only re-runs composables that read changed state.
Here's an example that reveals understanding:
@Composable
fun UserDetailScreen(userId: String, viewModel: UserViewModel) {
val user by viewModel.user.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
Column {
// This recomposes only when isLoading changes
if (isLoading) {
CircularProgressIndicator()
} else {
// This recomposes only when user changes
UserCard(user = user)
UserActions(userId = userId)
}
}
}
@Composable
fun UserCard(user: User) {
// Recomposes only when this user instance changes
Card {
Text(user.name)
Text(user.email)
}
}
@Composable
fun UserActions(userId: String) {
// This does NOT recompose when user data changes
// because it doesn't read that state
Row {
Button(onClick = { /* ... */ }) {
Text("Message")
}
}
}
Notice: UserActions doesn't recompose when the user's email changes because it doesn't read that state. Only composables that directly or indirectly read changed state trigger a new composition.
A deeper follow-up: "What's the difference between recomposition and invalidation?" Invalidation is the marking phase—Compose determines which composables depend on changed state. Recomposition is the execution phase—it re-runs those functions and builds a new composition tree. If the tree hasn't changed, the UI doesn't update.
This is why remember blocks and keys matter. Without proper scoping, you can trigger unnecessary recompositions:
@Composable
fun ItemList(items: List) {
LazyColumn {
items(items, key = { item -> item }) { item ->
// With a key, Compose preserves state even if list order changes
ItemRow(item)
}
}
}Common mistakes in Android interviews
- Conflating Activity and Activity instance: Saying "the activity is destroyed" when you mean "the activity instance is destroyed but the scope persists." ViewModel is proof they're different.
- Over-explaining basics: Interviewers want depth. If asked about lifecycle, jump to savedInstanceState and process death rather than reciting onCreate, onResume, onDestroy.
- Treating coroutines as just async: The real benefit is structured concurrency and automatic cancellation, not syntax sugar. Explain why that matters.
- Ignoring recomposition costs: Even with smart skipping, poorly structured state can force recompositions. Know how to use remember and derivedStateOf to control it.
- Writing synchronous examples for async problems: When discussing coroutines or async work, use async/await or withContext. Don't pretend blocking code is a solution.
Key takeaways
- Activity lifecycle has two parts: instance lifecycle (destroyed on rotation) and logical lifecycle (destroyed when user leaves). savedInstanceState handles one, ViewModel handles both.
- ViewModel survives configuration changes and process death recovery if paired with persistence mechanisms like SavedStateHandle or Room.
- Coroutines replace threads in production because they're lightweight, composable, and integrate with structured concurrency scopes that prevent leaks.
- Recomposition in Compose is smart: only composables reading changed state re-run. Understanding this prevents performance bugs.
- These questions reveal how you think about state, lifecycle, and architecture. In production, getting these wrong causes crashes, memory leaks, and data loss. Interviewers care because they do too.






Comments
Loading comments…