learn()
{ start }
build++
const tutorial
Modern Android App Architecture - Part 3: Jetpack Compose, Navigation & Testing
Master Jetpack Compose architecture patterns, type-safe navigation, comprehensive testing strategies, and performance monitoring for production-ready Android applications.
•Cuppa Team•9 min read
androidjetpack-composenavigationtestingperformance
In this final part of our Modern Android App Architecture series, we'll explore advanced Jetpack Compose patterns, navigation architecture, testing strategies, and performance optimization techniques.
Jetpack Compose Architecture Patterns
State Hoisting and Unidirectional Data Flow
// Stateless Composable
@Composable
fun ProfileCard(
profile: UserProfile,
onEditClick: () -> Unit,
onShareClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(modifier = modifier) {
Column {
AsyncImage(
model = profile.avatarUrl,
contentDescription = null
)
Text(profile.name, style = MaterialTheme.typography.headlineMedium)
Text(profile.bio, style = MaterialTheme.typography.bodyMedium)
Row {
Button(onClick = onEditClick) {
Text("Edit")
}
Button(onClick = onShareClick) {
Text("Share")
}
}
}
}
}
// Stateful Screen
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val navigator = LocalNavigator.currentOrThrow
ProfileScreenContent(
uiState = uiState,
onEditClick = { navigator.navigate(EditProfileRoute) },
onShareClick = viewModel::shareProfile,
onRetry = viewModel::loadProfile
)
}
@Composable
private fun ProfileScreenContent(
uiState: ProfileUiState,
onEditClick: () -> Unit,
onShareClick: () -> Unit,
onRetry: () -> Unit
) {
when (uiState) {
is ProfileUiState.Loading -> LoadingIndicator()
is ProfileUiState.Success -> ProfileCard(
profile = uiState.profile,
onEditClick = onEditClick,
onShareClick = onShareClick
)
is ProfileUiState.Error -> ErrorView(
message = uiState.message,
onRetry = onRetry
)
}
}
Custom Layouts and Reusable Components
// Custom Layout
@Composable
fun StaggeredGrid(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val columnWidth = constraints.maxWidth / columns
val columnHeights = IntArray(columns) { 0 }
val placeables = measurables.map { measurable ->
val column = columnHeights.withIndex().minBy { it.value }.index
val placeable = measurable.measure(
constraints.copy(maxWidth = columnWidth)
)
columnHeights[column] += placeable.height
column to placeable
}
val height = columnHeights.maxOrNull() ?: 0
layout(constraints.maxWidth, height) {
placeables.forEach { (column, placeable) ->
placeable.placeRelative(
x = column * columnWidth,
y = columnHeights[column] - placeable.height
)
}
}
}
}
// Reusable Form Components
@Composable
fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
error: String? = null,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
isError = error != null,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}
Type-Safe Navigation
Navigation with Compose Destinations or Voyager
// Define routes with type safety
sealed class Screen {
@Serializable
data object Home : Screen()
@Serializable
data class Profile(val userId: String) : Screen()
@Serializable
data class PostDetail(
val postId: String,
val commentId: String? = null
) : Screen()
}
// Navigation setup
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Home
) {
composable<Screen.Home> {
HomeScreen(
onNavigateToProfile = { userId ->
navController.navigate(Screen.Profile(userId))
}
)
}
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
userId = profile.userId,
onBack = { navController.popBackStack() }
)
}
composable<Screen.PostDetail> { backStackEntry ->
val postDetail: Screen.PostDetail = backStackEntry.toRoute()
PostDetailScreen(
postId = postDetail.postId,
highlightCommentId = postDetail.commentId,
onBack = { navController.popBackStack() }
)
}
}
}
// Navigation ViewModel
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _navigationEvents = Channel<NavigationEvent>()
val navigationEvents = _navigationEvents.receiveAsFlow()
fun navigateTo(screen: Screen) {
viewModelScope.launch {
_navigationEvents.send(NavigationEvent.Navigate(screen))
}
}
fun navigateBack() {
viewModelScope.launch {
_navigationEvents.send(NavigationEvent.Back)
}
}
}
sealed interface NavigationEvent {
data class Navigate(val screen: Screen) : NavigationEvent
data object Back : NavigationEvent
}
Deep Linking
// Manifest configuration
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="cuppa"
android:host="app" />
</intent-filter>
</activity>
// Navigation with deep links
NavHost(navController, startDestination = Screen.Home) {
composable<Screen.PostDetail>(
deepLinks = listOf(
navDeepLink<Screen.PostDetail>(
basePath = "cuppa://app/post"
)
)
) { backStackEntry ->
val postDetail: Screen.PostDetail = backStackEntry.toRoute()
PostDetailScreen(postId = postDetail.postId)
}
}
Comprehensive Testing Strategy
Unit Testing ViewModels
@ExperimentalCoroutinesTest
class ProfileViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: ProfileViewModel
private val getUserProfileUseCase: GetUserProfileUseCase = mockk()
private val updateProfileUseCase: UpdateProfileUseCase = mockk()
@Before
fun setup() {
viewModel = ProfileViewModel(
getUserProfileUseCase,
updateProfileUseCase,
SavedStateHandle()
)
}
@Test
fun `initial state is loading`() {
val state = viewModel.uiState.value
assertTrue(state is ProfileUiState.Loading)
}
@Test
fun `loadProfile success updates state correctly`() = runTest {
// Given
val profile = UserProfile(
id = "1",
name = "Test User",
email = "test@example.com",
bio = "Bio",
avatarUrl = null,
createdAt = Instant.now(),
updatedAt = Instant.now()
)
coEvery { getUserProfileUseCase() } returns Result.success(profile)
// When
viewModel.loadProfile()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state is ProfileUiState.Success)
assertEquals(profile, (state as ProfileUiState.Success).profile)
}
@Test
fun `loadProfile failure shows error`() = runTest {
// Given
val error = Exception("Network error")
coEvery { getUserProfileUseCase() } returns Result.failure(error)
// When
viewModel.loadProfile()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state is ProfileUiState.Error)
assertEquals("Network error", (state as ProfileUiState.Error).message)
}
}
Repository Testing
@ExperimentalCoroutinesTest
class UserRepositoryTest {
private val testDispatcher = StandardTestDispatcher()
@get:Rule
val mainDispatcherRule = MainDispatcherRule(testDispatcher)
private lateinit var repository: UserRepositoryImpl
private val remoteDataSource: UserRemoteDataSource = mockk()
private val localDataSource: UserLocalDataSource = mockk()
@Before
fun setup() {
repository = UserRepositoryImpl(
remoteDataSource,
localDataSource,
testDispatcher
)
}
@Test
fun `getUserProfile returns cached data when remote fails`() = runTest {
// Given
val cachedProfile = UserEntity(
id = "1",
name = "Cached",
email = "cached@test.com",
bio = "",
avatarUrl = null,
createdAt = 0,
updatedAt = 0
)
coEvery { remoteDataSource.fetchProfile() } throws IOException()
coEvery { localDataSource.getProfile() } returns cachedProfile
// When
val result = repository.getUserProfile()
// Then
assertEquals("1", result.id)
assertEquals("Cached", result.name)
coVerify { localDataSource.getProfile() }
}
@Test
fun `getUserProfile caches remote data`() = runTest {
// Given
val remoteProfile = UserDto(
id = "1",
name = "Remote",
email = "remote@test.com",
bio = "",
avatarUrl = null,
createdAt = 0,
updatedAt = 0
)
coEvery { remoteDataSource.fetchProfile() } returns remoteProfile
coEvery { localDataSource.saveProfile(any()) } just Runs
// When
val result = repository.getUserProfile()
// Then
assertEquals("Remote", result.name)
coVerify { localDataSource.saveProfile(remoteProfile) }
}
}
UI Testing with Compose
@HiltAndroidTest
@UninstallModules(RepositoryModule::class)
class ProfileScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@BindValue
@JvmField
val userRepository: UserRepository = mockk(relaxed = true)
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun profileScreen_showsLoadingState() {
// Given
coEvery { userRepository.getUserProfile() } coAnswers {
delay(1000)
UserProfile.empty()
}
// When
composeRule.setContent {
ProfileScreen()
}
// Then
composeRule.onNodeWithTag("loading").assertIsDisplayed()
}
@Test
fun profileScreen_displaysProfileData() {
// Given
val profile = UserProfile(
id = "1",
name = "Test User",
email = "test@test.com",
bio = "Test bio",
avatarUrl = null,
createdAt = Instant.now(),
updatedAt = Instant.now()
)
coEvery { userRepository.getUserProfile() } returns profile
// When
composeRule.setContent {
ProfileScreen()
}
// Then
composeRule.onNodeWithText("Test User").assertIsDisplayed()
composeRule.onNodeWithText("Test bio").assertIsDisplayed()
}
@Test
fun profileScreen_editButtonNavigates() {
// Given
val profile = UserProfile.empty()
coEvery { userRepository.getUserProfile() } returns profile
// When
composeRule.setContent {
ProfileScreen()
}
// Then
composeRule.onNodeWithText("Edit").performClick()
// Assert navigation occurred
}
}
Screenshot Testing
class ProfileScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun profileScreen_lightTheme_matchesGolden() {
composeTestRule.setContent {
AppTheme(darkTheme = false) {
ProfileScreenContent(
uiState = ProfileUiState.Success(UserProfile.sample()),
onEditClick = {},
onShareClick = {},
onRetry = {}
)
}
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("profile_screen_light")
}
@Test
fun profileScreen_darkTheme_matchesGolden() {
composeTestRule.setContent {
AppTheme(darkTheme = true) {
ProfileScreenContent(
uiState = ProfileUiState.Success(UserProfile.sample()),
onEditClick = {},
onShareClick = {},
onRetry = {}
)
}
}
composeTestRule.onRoot()
.captureToImage()
.assertAgainstGolden("profile_screen_dark")
}
}
Performance Monitoring
Custom Performance Tracking
class PerformanceMonitor @Inject constructor(
private val firebasePerformance: FirebasePerformance
) {
fun traceOperation(name: String, block: suspend () -> Unit) {
val trace = firebasePerformance.newTrace(name)
trace.start()
try {
block()
} finally {
trace.stop()
}
}
fun recordMetric(name: String, value: Long) {
firebasePerformance.newTrace(name).apply {
putMetric("duration", value)
start()
stop()
}
}
}
// Usage in ViewModel
@HiltViewModel
class FeedViewModel @Inject constructor(
private val feedRepository: FeedRepository,
private val performanceMonitor: PerformanceMonitor
) : ViewModel() {
fun loadFeed() {
viewModelScope.launch {
performanceMonitor.traceOperation("load_feed") {
val startTime = System.currentTimeMillis()
feedRepository.getFeed()
.onSuccess { items ->
val duration = System.currentTimeMillis() - startTime
performanceMonitor.recordMetric("feed_load_success", duration)
}
.onFailure {
performanceMonitor.recordMetric("feed_load_failure", 0)
}
}
}
}
}
Compose Performance Best Practices
// Use remember and derivedStateOf for expensive computations
@Composable
fun ItemList(items: List<Item>) {
val filteredItems by remember(items) {
derivedStateOf {
items.filter { it.isActive }
}
}
LazyColumn {
items(filteredItems, key = { it.id }) { item ->
ItemRow(item = item)
}
}
}
// Use immutable collections
@Immutable
data class FeedState(
val posts: ImmutableList<Post> = persistentListOf(),
val isLoading: Boolean = false
)
// Avoid unnecessary recompositions
@Composable
fun OptimizedList(
items: List<Item>,
onItemClick: (String) -> Unit
) {
// Stable callback reference
val stableOnClick = rememberUpdatedState(onItemClick)
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(
item = item,
onClick = { stableOnClick.value(item.id) }
)
}
}
}
Memory Leak Prevention
// Proper cleanup in ViewModel
@HiltViewModel
class DataViewModel @Inject constructor(
private val repository: DataRepository
) : ViewModel() {
private var job: Job? = null
fun startPolling() {
job = viewModelScope.launch {
while (isActive) {
repository.fetchData()
delay(5000)
}
}
}
fun stopPolling() {
job?.cancel()
job = null
}
override fun onCleared() {
stopPolling()
super.onCleared()
}
}
// LeakCanary integration
class CuppaApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
LeakCanary.config = LeakCanary.config.copy(
retainedVisibleThreshold = 3
)
}
}
}
Production Checklist
Essential Build Configuration
// build.gradle.kts (app)
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Baseline profiles for startup performance
baselineProfile.automaticGenerationDuringBuild = true
}
}
// Enable R8 full mode
optimization {
keepRules {
ignoreAllExternalDependencies(true)
}
}
}
Crash Reporting
class CrashReportingTree : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority == Log.ERROR || priority == Log.WARN) {
FirebaseCrashlytics.getInstance().apply {
if (tag != null) setCustomKey("tag", tag)
setCustomKey("message", message)
t?.let { recordException(it) }
}
}
}
}
// Application class
@HiltAndroidApp
class CuppaApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(CrashReportingTree())
}
}
}
Summary
This three-part series covered:
- Part 1: Layered architecture, MVVM, dependency injection
- Part 2: Reactive patterns, offline-first architecture, state management
- Part 3: Jetpack Compose patterns, navigation, testing, performance
Key Takeaways
- Architecture: Use clear layers with defined responsibilities
- Reactive: Leverage Kotlin Flow for reactive data streams
- Offline-First: Build resilient apps with local caching
- Testing: Comprehensive testing at all layers
- Performance: Monitor and optimize continuously
- Type-Safety: Use sealed classes and type-safe navigation
Resources
This architecture provides a solid foundation for building scalable, maintainable, and performant Android applications that can grow with your team and user base.
Back to Part 1: Foundation & Layers