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 Team9 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

  1. Architecture: Use clear layers with defined responsibilities
  2. Reactive: Leverage Kotlin Flow for reactive data streams
  3. Offline-First: Build resilient apps with local caching
  4. Testing: Comprehensive testing at all layers
  5. Performance: Monitor and optimize continuously
  6. 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