Android development in 2026 barely resembles the XML-layout, Java-first world of a few years ago. Jetpack Compose is now the default UI toolkit — Google's own apps run on it, and the library ecosystem has followed. Kotlin 2.1 brings compiler improvements and better multiplatform support. And the architecture story has finally settled into clear, well-documented patterns. Here's a practical guide to building Android apps the right way this year.
📋 Table of Contents
Jetpack Compose in Production
Jetpack Compose has crossed the adoption threshold. Google Maps, Play Store, Gmail — they all run Compose. The library is stable, performant (especially with the compiler improvements in Kotlin 2.1), and the component ecosystem covers almost every UI pattern you'll encounter.
Building a Production Screen
@Composable
fun OrdersScreen(
viewModel: OrdersViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Orders") },
actions = {
FilterChip(
selected = uiState.filter != OrderFilter.ALL,
onClick = { viewModel.toggleFilter() },
label = { Text(uiState.filter.label) }
)
}
)
}
) { padding ->
when (val state = uiState) {
is OrdersUiState.Loading -> LoadingIndicator(Modifier.padding(padding))
is OrdersUiState.Error -> ErrorMessage(
message = state.message,
onRetry = viewModel::retry,
modifier = Modifier.padding(padding)
)
is OrdersUiState.Success -> OrdersList(
orders = state.orders,
onOrderClick = viewModel::onOrderSelected,
modifier = Modifier.padding(padding)
)
}
}
}
@Composable
private fun OrdersList(
orders: List<Order>,
onOrderClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(orders, key = { it.id }) { order ->
OrderCard(
order = order,
onClick = { onOrderClick(order.id) },
modifier = Modifier.animateItem()
)
}
}
}
Compose Best Practices
- Use
collectAsStateWithLifecycle()instead ofcollectAsState()— it respects the lifecycle and stops collecting when the screen is in the background - Hoist state to ViewModels — composables should be stateless receivers of data and emitters of events
- Use
keyinLazyColumnitems — this enables efficient diffing and item animations - Keep composables small — extract reusable components, but don't over-abstract. A component used once in one screen doesn't need to be in a shared components package
- Use
Modifierparameter — every public composable should accept aModifierparameter for external styling
Architecture: MVVM + Clean Architecture
Google's official guidance has converged on a layered architecture: UI layer (Compose + ViewModel), Domain layer (optional, for complex business logic), and Data layer (repositories + data sources). This isn't new, but it's now well-documented and consistently applied across Google's samples and codelabs.
// ViewModel — UI state management
@HiltViewModel
class OrdersViewModel @Inject constructor(
private val getOrdersUseCase: GetOrdersUseCase
) : ViewModel() {
private val filter = MutableStateFlow(OrderFilter.ALL)
val uiState: StateFlow<OrdersUiState> = filter
.flatMapLatest { f -> getOrdersUseCase(f) }
.map { orders -> OrdersUiState.Success(orders) as OrdersUiState }
.catch { e -> emit(OrdersUiState.Error(e.message ?: "Unknown error")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OrdersUiState.Loading)
fun toggleFilter() {
filter.update { current ->
when (current) {
OrderFilter.ALL -> OrderFilter.PENDING
OrderFilter.PENDING -> OrderFilter.COMPLETED
OrderFilter.COMPLETED -> OrderFilter.ALL
}
}
}
}
// Use case — business logic
class GetOrdersUseCase @Inject constructor(
private val orderRepository: OrderRepository
) {
operator fun invoke(filter: OrderFilter): Flow<List<Order>> {
return orderRepository.observeOrders()
.map { orders ->
when (filter) {
OrderFilter.ALL -> orders
OrderFilter.PENDING -> orders.filter { it.status == Status.PENDING }
OrderFilter.COMPLETED -> orders.filter { it.status == Status.COMPLETED }
}
}
}
}
// Repository — data access abstraction
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao
) : OrderRepository {
override fun observeOrders(): Flow<List<Order>> = dao.observeAll()
.onStart { refreshFromNetwork() }
private suspend fun refreshFromNetwork() {
try {
val remote = api.getOrders()
dao.upsertAll(remote.map { it.toEntity() })
} catch (e: IOException) {
// Offline — use cached data
}
}
}
Dependency Injection with Hilt and Koin
Two DI frameworks dominate in 2026:
| Factor | Hilt | Koin |
|---|---|---|
| Type | Compile-time (Dagger under the hood) | Runtime service locator |
| Setup complexity | More setup, annotations | Simpler, Kotlin DSL |
| Error detection | Compile-time errors | Runtime errors |
| KMP support | Android only | Full multiplatform |
| Google recommended | Yes (official) | No (but widely used) |
| Build time impact | Slower (code generation) | Minimal |
Our recommendation: Use Hilt for Android-only projects — compile-time safety catches errors before they reach production. Use Koin if you're doing Kotlin Multiplatform and need DI in shared code.
Kotlin Multiplatform for Shared Logic
Kotlin Multiplatform (KMP) is stable and production-ready in 2026. The key insight: you share business logic (networking, data models, validation, state management) while keeping native UI on each platform. This is fundamentally different from React Native or Flutter, which share the UI layer too.
// Shared KMP module — used by Android (Compose) and iOS (SwiftUI)
// commonMain/src
class OrderRepository(
private val api: OrderApi,
private val database: OrderDatabase
) {
fun getOrders(): Flow<List<Order>> = database.observeOrders()
.onStart { syncFromServer() }
suspend fun syncFromServer() {
val remote = api.fetchOrders()
database.upsert(remote)
}
}
// Shared ViewModel (using KMP-friendly approach)
class OrdersSharedViewModel(
private val repository: OrderRepository
) {
val orders: StateFlow<List<Order>> = repository.getOrders()
.stateIn(CoroutineScope(Dispatchers.Default), SharingStarted.Lazily, emptyList())
}
// Android consumes it with Compose
@Composable
fun OrdersScreen(viewModel: OrdersSharedViewModel = koinViewModel()) {
val orders by viewModel.orders.collectAsStateWithLifecycle()
// ... Compose UI
}
// iOS consumes it with SwiftUI
// struct OrdersView: View {
// @StateObject var viewModel = OrdersSharedViewModel()
// var body: some View { List(viewModel.orders) { ... } }
// }
KMP works well when your team has strong Kotlin skills and wants native UI on both platforms. The shared module handles networking (Ktor), serialization (kotlinx.serialization), database (SQLDelight), and business logic — typically 40-60% of the total codebase.
Networking and Data Layer
The networking stack for Android in 2026 has clear winners:
| Layer | Tool | Why |
|---|---|---|
| HTTP client | Retrofit + OkHttp (Android) / Ktor (KMP) | Retrofit is mature; Ktor for multiplatform |
| Serialization | kotlinx.serialization | Kotlin-native, multiplatform, no reflection |
| Local database | Room (Android) / SQLDelight (KMP) | Room is simpler; SQLDelight for shared code |
| Key-value storage | DataStore (Proto or Preferences) | Type-safe, coroutine-based, replaces SharedPreferences |
| Image loading | Coil 3 | Kotlin-first, Compose-native, multiplatform |
| Caching / offline | Room + offline-first repository pattern | Single source of truth from local DB |
// Offline-first repository pattern with Room
@Dao
interface OrderDao {
@Query("SELECT * FROM orders ORDER BY created_at DESC")
fun observeAll(): Flow<List<OrderEntity>>
@Upsert
suspend fun upsertAll(orders: List<OrderEntity>)
}
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApi,
private val dao: OrderDao,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : OrderRepository {
// Single source of truth: always read from local DB
override fun observeOrders(): Flow<List<Order>> =
dao.observeAll().map { entities -> entities.map { it.toDomain() } }
// Sync from network — update local DB, observers get notified
override suspend fun sync(): Result<Unit> = withContext(dispatcher) {
runCatching {
val response = api.getOrders()
dao.upsertAll(response.map { it.toEntity() })
}
}
}
Performance Optimization
Compose performance has improved significantly with the strong-skipping compiler mode (now default). But there are still patterns to know:
Avoiding Unnecessary Recompositions
// ❌ Bad — lambda recreated every recomposition
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
val items by viewModel.items.collectAsStateWithLifecycle()
LazyColumn {
items(items) { item ->
// This lambda is recreated on every recomposition of ParentScreen
ItemCard(item = item, onClick = { viewModel.onItemClick(item.id) })
}
}
}
// ✅ Better — stable lambda reference
@Composable
fun ParentScreen(viewModel: ParentViewModel) {
val items by viewModel.items.collectAsStateWithLifecycle()
val onItemClick = viewModel::onItemClick // stable reference
LazyColumn {
items(items, key = { it.id }) { item ->
ItemCard(item = item, onClick = { onItemClick(item.id) })
}
}
}
Key Performance Tips
- Use
keyin lazy lists: Without stable keys, Compose can't efficiently diff your list items - Defer reads with
derivedStateOf: For computed state that doesn't need to trigger recomposition on every source change - Use
@Stableor@Immutableannotations: Tell the compiler your data classes won't change, enabling skip optimizations - Profile with Layout Inspector: Android Studio's recomposition counter shows exactly which composables are recomposing and how often
- Baseline Profiles: Pre-compile your app's hot paths for 15-30% faster startup and smoother scrolling on first launch
Testing Strategy
A practical testing pyramid for Android in 2026:
// Unit test — ViewModel logic
class OrdersViewModelTest {
private val fakeRepository = FakeOrderRepository()
private lateinit var viewModel: OrdersViewModel
@Before
fun setup() {
viewModel = OrdersViewModel(GetOrdersUseCase(fakeRepository))
}
@Test
fun `initial state is loading`() = runTest {
val state = viewModel.uiState.first()
assertThat(state).isInstanceOf(OrdersUiState.Loading::class.java)
}
@Test
fun `orders loaded successfully`() = runTest {
fakeRepository.emit(listOf(Order(id = "1", total = 99.0)))
val state = viewModel.uiState.first { it is OrdersUiState.Success }
assertThat((state as OrdersUiState.Success).orders).hasSize(1)
}
}
// Compose UI test
class OrdersScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `displays order list when loaded`() {
val orders = listOf(Order(id = "1", title = "Order #1001", total = 49.99))
composeTestRule.setContent {
OrdersList(orders = orders, onOrderClick = {})
}
composeTestRule.onNodeWithText("Order #1001").assertIsDisplayed()
composeTestRule.onNodeWithText("$49.99").assertIsDisplayed()
}
}
Focus your testing effort on: ViewModel logic (unit tests), repository/data layer (unit tests with fakes), and critical user flows (Compose UI tests). Don't aim for 100% coverage — aim for confidence in the code paths that handle money, user data, and core business logic.
The 2026 Android Toolchain
| Category | Tool | Notes |
|---|---|---|
| IDE | Android Studio Ladybug+ | Compose Preview, Gemini assistance, Layout Inspector |
| Build system | Gradle (Kotlin DSL) + Version Catalogs | Centralized dependency management |
| Navigation | Navigation Compose (type-safe) | Kotlin Serialization for route args |
| Background work | WorkManager | Reliable background tasks with constraints |
| Crash reporting | Firebase Crashlytics | Free, real-time, integrates with Play Console |
| CI/CD | GitHub Actions / Bitrise | Build, test, deploy to Play Console |
| Feature flags | Firebase Remote Config | Toggle features without app updates |
Frequently Asked Questions
Should I still learn XML layouts in 2026?
Only for maintaining existing apps. All new Android development should use Jetpack Compose. Google's official samples, documentation, and new APIs are Compose-first. XML layouts are legacy.
Is Java still used for Android development?
In legacy codebases, yes. For new projects, Kotlin is the only sensible choice. Google made Kotlin the preferred language in 2019, and Jetpack Compose only works with Kotlin. Java interop means you can migrate gradually.
How does Android native compare to Flutter?
Native gives you the best performance, latest APIs, and Google's full Jetpack ecosystem. Flutter offers cross-platform from one codebase. For Android-only apps, native Kotlin is better. For both platforms, evaluate cross-platform options.
What's the minimum Android version to target?
API 26 (Android 8.0) as a minimum covers 95%+ of active devices. For Compose, you need API 21 minimum, but targeting API 26+ lets you use modern APIs without excessive compat layers.
Should I use Kotlin Multiplatform or Flutter for cross-platform?
KMP shares business logic while keeping native UI — ideal when you want Compose on Android and SwiftUI on iOS. Flutter shares everything including UI — faster development but less platform-native feel. Choose based on your team's skills and UI requirements.
Pillai Infotech LLP
We build Android apps with Kotlin and Jetpack Compose — from startup MVPs to enterprise-grade applications. Let's build your Android app.