Lesson 49 of 83 intermediate

Jetpack Compose vs Flutter — How to Answer Without Sounding Biased

A senior engineer's balanced take on declarative UI, state, navigation, and ecosystem fit

Open interactive version (quiz + challenge)

Real-world analogy

Asking 'Compose or Flutter?' is like asking whether a Toyota built in Japan or Germany is better. Both are excellent cars. The smart answer is: it depends on the road, the team, and how far you need to go.

What is it?

Jetpack Compose is Google's native Android declarative UI toolkit built in Kotlin. Flutter is Google's cross-platform UI SDK built in Dart. Both use a reactive, widget/composable-based model, but they differ fundamentally in rendering strategy, ecosystem integration, and target platforms.

Real-world relevance

Google's own apps — Google Pay, Maps, and Messages — use Jetpack Compose. eBay's mobile app, Alibaba's Xianyu, and BMW's companion app use Flutter. Neither choice is 'wrong' at scale; the decision is architectural, not tribal.

Key points

Code example

// ── Jetpack Compose — simple counter screen ──────────────────
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsStateWithLifecycle()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Count: $count",
            style = MaterialTheme.typography.headlineMedium
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = { viewModel.increment() }) {
            Text("Increment")
        }
    }
}

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() { _count.update { it + 1 } }
}

// ── Flutter equivalent ────────────────────────────────────────
// class CounterScreen extends StatefulWidget { ... }
// class _CounterScreenState extends State<CounterScreen> {
//   int _count = 0;
//   void _increment() => setState(() => _count++);
//
//   Widget build(BuildContext context) => Column(children: [
//     Text('Count: $_count'),
//     ElevatedButton(onPressed: _increment, child: Text('Increment')),
//   ]);
// }
//
// With BLoC:
// BlocBuilder<CounterBloc, CounterState>(
//   builder: (context, state) => Text('Count: ${state.count}'),
// )

Line-by-line walkthrough

  1. 1. CounterScreen is a @Composable function — it describes UI as a function of state, not imperative commands
  2. 2. viewModel() retrieves or creates a CounterViewModel scoped to the current NavBackStackEntry, surviving rotation
  3. 3. collectAsStateWithLifecycle() converts StateFlow into Compose State, pausing collection when the app is in the background — battery-safe
  4. 4. Column with fillMaxSize, verticalArrangement, and horizontalAlignment replaces XML LinearLayout + gravity attributes
  5. 5. MaterialTheme.typography.headlineMedium is a Material 3 type role — guaranteed contrast and scale across the design system
  6. 6. Button's onClick lambda calls viewModel.increment(), keeping business logic out of the composable
  7. 7. CounterViewModel extends ViewModel — survives configuration changes automatically via ViewModelStore
  8. 8. MutableStateFlow(0) holds the current count; asStateFlow() exposes a read-only StateFlow to the UI
  9. 9. update{} is atomic and thread-safe — correct even if increment() is called from a background thread
  10. 10. The Flutter equivalent in comments shows setState() for local state and BlocBuilder for BLoC — structurally identical roles but different APIs

Spot the bug

@Composable
fun ProductListScreen() {
    var products by remember { mutableStateOf(emptyList<Product>()) }

    LaunchedEffect(Unit) {
        products = fetchProductsFromNetwork() // suspend fun
    }

    LazyColumn {
        items(products) { product ->
            Text(product.name)
        }
    }
}
Need a hint?
Think about what happens to 'products' when the user rotates the device. Is remember enough here?
Show answer
The bug is that remember{} does not survive configuration changes (screen rotation, locale change). When the device rotates, the composable is recomposed from scratch, remember{} starts with emptyList() again, and LaunchedEffect re-fires, causing a redundant network call and a visible blank flash. Fix: move the state and network call into a ViewModel. Use a MutableStateFlow<List<Product>> in the ViewModel and collect it with collectAsStateWithLifecycle() in the composable. The ViewModel survives rotation; the network call is only made once.

Explain like I'm 5

Compose is like building with Lego made specifically for your bedroom floor — it fits perfectly and works with all your other Lego sets. Flutter is like bringing a special Lego kit that works in any room of any house, but you need an adapter to use your other bedroom Lego with it.

Fun fact

Compose's compiler plugin transforms @Composable functions into a call graph that the Compose runtime can skip (recompose) selectively. Flutter's widget rebuild works similarly but must diff the entire subtree. Both arrived at the same performance insight independently.

Hands-on challenge

Implement a minimal two-screen app in Compose (a list screen and a detail screen) using NavController. Then sketch in comments how the equivalent GoRouter setup would look in Flutter. Identify two architectural differences between the two navigation approaches.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery