Android development has gone through a complete modernization over the past several years. Kotlin replaced Java as the recommended language in 2019. Jetpack Compose replaced the XML-based view system as the recommended UI toolkit in 2021. Together, they represent a fundamentally different way of building Android applications -- one that is more concise, more type-safe, and more aligned with how modern UI frameworks work across every platform.
The old approach -- defining layouts in XML, finding views by ID, manually updating text fields and visibility flags in response to data changes -- is still supported and still powers millions of apps. But it carries a constant overhead of boilerplate and a persistent risk of state inconsistencies between your data and your UI. Jetpack Compose eliminates both. You describe your UI as a function of state, and the framework handles the rest.
This guide covers what you need to build production-quality Android applications with Kotlin and Jetpack Compose: composable functions, state and recomposition, architecture with ViewModel and StateFlow, navigation, Material 3 theming, Room database integration, and testing.
Jetpack Compose Fundamentals: Composables, State, and Recomposition
In Jetpack Compose, your UI is built from composable functions. A composable is a Kotlin function annotated with @Composable that describes a piece of UI. Composables do not return a view object. They emit UI by calling other composable functions. The Compose runtime tracks these calls and builds a tree that it renders to the screen.
@Composable
fun UserCard(user: User, onEditClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = user.avatarUrl,
contentDescription = "Avatar for ${user.name}",
modifier = Modifier
.size(56.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onEditClick) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit profile"
)
}
}
}
}
This function takes data (user) and a callback (onEditClick) as parameters and describes the UI that should appear. There is no XML layout file, no findViewById, no manual binding. When user changes, Compose re-invokes this function with the new data and updates only the parts of the UI that actually changed.
This update process is called recomposition. Compose is intelligent about it -- it tracks which state each composable reads and only recomposes composables whose inputs have actually changed. This makes the declarative model performant even for complex UIs.
State in Compose
State drives recomposition. When state changes, any composable that reads that state recomposes. Compose provides remember and mutableStateOf for managing local UI state:
@Composable
fun SearchBar(onSearch: (String) -> Unit) {
var query by remember { mutableStateOf("") }
OutlinedTextField(
value = query,
onValueChange = { newValue ->
query = newValue
onSearch(newValue)
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search...") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = {
query = ""
onSearch("")
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
},
singleLine = true
)
}
remember tells Compose to preserve this value across recompositions. Without it, the state would reset every time the composable recomposes. mutableStateOf creates a state holder that Compose can observe -- when its value changes, Compose knows to recompose any composable that reads it.
For state that should survive configuration changes (screen rotation, theme changes), use rememberSaveable:
var selectedTab by rememberSaveable { mutableStateOf(0) }
The key principle: state should be hoisted to the lowest common ancestor that needs it. A composable that manages its own state is harder to test and reuse than one that receives state and callbacks from its parent. This pattern is called state hoisting, and it is fundamental to writing good Compose code.
Architecture: MVVM with ViewModel and StateFlow
Production Android applications separate UI logic from business logic using the MVVM pattern. The ViewModel holds state and logic. The composable observes that state and renders it. Kotlin's StateFlow is the standard mechanism for exposing observable state from a ViewModel.
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
val searchQuery: String = "",
) {
val filteredTasks: List<Task>
get() = if (searchQuery.isBlank()) {
tasks
} else {
tasks.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
}
class TaskListViewModel(
private val taskRepository: TaskRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
init {
loadTasks()
}
fun loadTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val tasks = taskRepository.getAllTasks()
_uiState.update { it.copy(tasks = tasks, isLoading = false) }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load tasks. Pull to refresh."
)
}
}
}
}
fun onSearchQueryChanged(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun deleteTask(taskId: String) {
viewModelScope.launch {
try {
taskRepository.deleteTask(taskId)
_uiState.update { state ->
state.copy(tasks = state.tasks.filter { it.id != taskId })
}
} catch (e: Exception) {
_uiState.update { it.copy(errorMessage = "Failed to delete task.") }
}
}
}
fun toggleTaskCompletion(taskId: String) {
viewModelScope.launch {
val task = _uiState.value.tasks.find { it.id == taskId } ?: return@launch
val updated = task.copy(isCompleted = !task.isCompleted)
taskRepository.updateTask(updated)
_uiState.update { state ->
state.copy(tasks = state.tasks.map { if (it.id == taskId) updated else it })
}
}
}
}
The composable collects the state flow and renders it:
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = viewModel(),
onTaskClick: (Task) -> Unit = {},
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
SearchBar(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChanged
)
}
) { paddingValues ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
uiState.errorMessage != null -> {
ErrorContent(
message = uiState.errorMessage!!,
onRetry = viewModel::loadTasks,
modifier = Modifier.padding(paddingValues)
)
}
uiState.filteredTasks.isEmpty() -> {
EmptyContent(
message = if (uiState.searchQuery.isNotEmpty()) {
"No tasks match your search."
} else {
"No tasks yet. Create one to get started."
},
modifier = Modifier.padding(paddingValues)
)
}
else -> {
TaskList(
tasks = uiState.filteredTasks,
onTaskClick = onTaskClick,
onToggleCompletion = viewModel::toggleTaskCompletion,
onDelete = viewModel::deleteTask,
modifier = Modifier.padding(paddingValues)
)
}
}
}
}
collectAsStateWithLifecycle() is the correct way to collect flows in Compose. It is lifecycle-aware -- it stops collecting when the composable is not visible, preventing unnecessary work and potential crashes from updating UI that is off-screen.
The UI state is modeled as a single data class. This makes the state easy to reason about, easy to serialize for process death restoration, and easy to test. The ViewModel exposes a single StateFlow rather than multiple individual state properties, which prevents partial state updates and inconsistencies.
Navigation with Jetpack Compose
Navigation in Compose is handled by the Navigation component, which provides type-safe routing between screens. Define your routes as a sealed hierarchy:
@Serializable
sealed class Screen {
@Serializable
data object TaskList : Screen()
@Serializable
data class TaskDetail(val taskId: String) : Screen()
@Serializable
data object CreateTask : Screen()
@Serializable
data object Settings : Screen()
}
Set up the navigation graph:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.TaskList
) {
composable<Screen.TaskList> {
TaskListScreen(
onTaskClick = { task ->
navController.navigate(Screen.TaskDetail(taskId = task.id))
},
onCreateClick = {
navController.navigate(Screen.CreateTask)
}
)
}
composable<Screen.TaskDetail> { backStackEntry ->
val route = backStackEntry.toRoute<Screen.TaskDetail>()
TaskDetailScreen(
taskId = route.taskId,
onBackClick = { navController.popBackStack() }
)
}
composable<Screen.CreateTask> {
CreateTaskScreen(
onTaskCreated = { navController.popBackStack() },
onCancel = { navController.popBackStack() }
)
}
composable<Screen.Settings> {
SettingsScreen()
}
}
}
For bottom navigation, combine NavHost with a NavigationBar:
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentBackStackEntry?.destination?.route == Screen.TaskList::class.qualifiedName,
onClick = {
navController.navigate(Screen.TaskList) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(Icons.Default.List, contentDescription = null) },
label = { Text("Tasks") }
)
NavigationBarItem(
selected = currentBackStackEntry?.destination?.route == Screen.Settings::class.qualifiedName,
onClick = {
navController.navigate(Screen.Settings) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Settings") }
)
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = Screen.TaskList,
modifier = Modifier.padding(paddingValues)
) {
// ... composable destinations
}
}
}
The popUpTo and launchSingleTop flags prevent duplicate destinations from stacking up in the back stack when the user taps the same tab repeatedly.
Theming with Material 3
Material 3 (Material You) is the current design system for Android. Compose provides a full implementation through the Material 3 library. Theming is defined centrally and accessed throughout the app via MaterialTheme.
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1A5276),
onPrimary = Color.White,
primaryContainer = Color(0xFFD4E6F1),
onPrimaryContainer = Color(0xFF0A2A3F),
secondary = Color(0xFF2E86C1),
onSecondary = Color.White,
background = Color(0xFFFCFCFC),
onBackground = Color(0xFF1A1A1A),
surface = Color.White,
onSurface = Color(0xFF1A1A1A),
error = Color(0xFFBA1A1A),
onError = Color.White,
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF85C1E9),
onPrimary = Color(0xFF0A2A3F),
primaryContainer = Color(0xFF1A5276),
onPrimaryContainer = Color(0xFFD4E6F1),
secondary = Color(0xFF5DADE2),
onSecondary = Color(0xFF0A2A3F),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE0E0E0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Dynamic color (available on Android 12+) extracts colors from the user's wallpaper to create a personalized color scheme. The fallback to your defined color schemes ensures consistency on older devices.
Access theme values anywhere in your composables:
Text(
text = "Hello",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
Working with Room Database
Room is Android's persistence library, built on top of SQLite. It provides compile-time verification of SQL queries, reactive data access with Kotlin Flow, and seamless integration with the rest of the Jetpack ecosystem.
Define your entity, DAO, and database:
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "description") val description: String?,
@ColumnInfo(name = "is_completed") val isCompleted: Boolean = false,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "priority") val priority: Int = 0,
)
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY created_at DESC")
fun getAllTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: String): TaskEntity?
@Query("SELECT * FROM tasks WHERE is_completed = :completed ORDER BY priority DESC, created_at DESC")
fun getTasksByCompletion(completed: Boolean): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity)
@Update
suspend fun updateTask(task: TaskEntity)
@Delete
suspend fun deleteTask(task: TaskEntity)
@Query("DELETE FROM tasks WHERE is_completed = 1")
suspend fun deleteCompletedTasks()
}
@Database(
entities = [TaskEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
Build the database instance (typically in your dependency injection setup):
val database = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app-database"
)
.fallbackToDestructiveMigration()
.build()
The DAO methods that return Flow are reactive. When data in the database changes, the flow emits updated values automatically. This integrates naturally with the ViewModel pattern:
class TaskListViewModel(
private val taskDao: TaskDao,
) : ViewModel() {
val tasks: StateFlow<List<TaskEntity>> = taskDao.getAllTasks()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
}
SharingStarted.WhileSubscribed(5000) keeps the flow active for five seconds after the last subscriber disconnects. This handles configuration changes gracefully -- the flow stays hot during a screen rotation rather than requerying the database.
For production applications, use a repository layer between the ViewModel and the DAO to abstract the data source and handle mapping between entity types and domain types:
class TaskRepositoryImpl(
private val taskDao: TaskDao,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : TaskRepository {
override fun getAllTasks(): Flow<List<Task>> {
return taskDao.getAllTasks().map { entities ->
entities.map { it.toDomain() }
}.flowOn(dispatcher)
}
override suspend fun createTask(task: Task) {
withContext(dispatcher) {
taskDao.insertTask(task.toEntity())
}
}
override suspend fun deleteTask(taskId: String) {
withContext(dispatcher) {
val entity = taskDao.getTaskById(taskId) ?: return@withContext
taskDao.deleteTask(entity)
}
}
}
Testing Composables
Compose provides a testing library that lets you render composables in isolation and assert against their state without running a full Android emulator.
class TaskListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysTasksWhenLoaded() {
val tasks = listOf(
Task(id = "1", title = "Buy groceries", isCompleted = false),
Task(id = "2", title = "Write report", isCompleted = true),
)
composeTestRule.setContent {
AppTheme {
TaskList(
tasks = tasks,
onTaskClick = {},
onToggleCompletion = {},
onDelete = {},
)
}
}
composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
composeTestRule.onNodeWithText("Write report").assertIsDisplayed()
}
@Test
fun showsEmptyStateWhenNoTasks() {
composeTestRule.setContent {
AppTheme {
EmptyContent(message = "No tasks yet. Create one to get started.")
}
}
composeTestRule
.onNodeWithText("No tasks yet. Create one to get started.")
.assertIsDisplayed()
}
@Test
fun callsOnToggleWhenCheckboxClicked() {
var toggledTaskId: String? = null
val task = Task(id = "1", title = "Test task", isCompleted = false)
composeTestRule.setContent {
AppTheme {
TaskRow(
task = task,
onToggleCompletion = { toggledTaskId = it },
onClick = {},
)
}
}
composeTestRule
.onNodeWithContentDescription("Toggle completion for Test task")
.performClick()
assert(toggledTaskId == "1")
}
}
For ViewModel testing, use kotlinx-coroutines-test to control coroutine execution:
class TaskListViewModelTest {
@Test
fun `loadTasks updates state with tasks from repository`() = runTest {
val testTasks = listOf(
Task(id = "1", title = "Task 1", isCompleted = false),
)
val repository = FakeTaskRepository(testTasks)
val viewModel = TaskListViewModel(repository)
// Collect the first emission after initialization
val state = viewModel.uiState.first { it.tasks.isNotEmpty() }
assertEquals(1, state.tasks.size)
assertEquals("Task 1", state.tasks.first().title)
assertFalse(state.isLoading)
assertNull(state.errorMessage)
}
@Test
fun `deleteTask removes task from state`() = runTest {
val testTasks = listOf(
Task(id = "1", title = "Task 1", isCompleted = false),
Task(id = "2", title = "Task 2", isCompleted = false),
)
val repository = FakeTaskRepository(testTasks.toMutableList())
val viewModel = TaskListViewModel(repository)
// Wait for initial load
viewModel.uiState.first { it.tasks.size == 2 }
viewModel.deleteTask("1")
val state = viewModel.uiState.first { it.tasks.size == 1 }
assertEquals("Task 2", state.tasks.first().title)
}
}
Use fake implementations of your repository interfaces rather than mocking libraries. Fakes are more readable, more maintainable, and catch more integration issues than mock-based tests.
Building Modern Android Applications
Kotlin and Jetpack Compose together represent the current standard for Android development. Kotlin provides the language features -- null safety, coroutines, extension functions, data classes -- that make code concise and correct. Compose provides the UI framework that eliminates the impedance mismatch between your data and your interface. The Jetpack libraries (ViewModel, Room, Navigation, WorkManager) provide the architectural infrastructure that handles the platform complexities of process death, configuration changes, and background work.
The investment in learning this stack pays off immediately in developer productivity and long-term in maintainability. Applications built with these tools have fewer state synchronization bugs, less boilerplate, and a clearer separation between UI and logic.
If you are building an Android application and want a team that understands the modern Android stack deeply, Maranatha Technologies delivers production-quality Android applications built with Kotlin, Jetpack Compose, and best-practice architecture. Contact us to discuss your project and how we can bring it to life on Android.