Introduction
By day 83 of your Android journey, you're likely past hello-world and wrestling with real architectural choices. This Android developer roadmap: practical tips and trade-offs distills what we've learned shipping production apps across e-commerce, SaaS, and on-demand platforms—and when each choice actually matters. We'll walk through the essentials in order, then call out the pitfalls we've hit.
Kotlin, null safety and coroutines
Start here. Kotlin's null safety isn't academic; it eliminates entire classes of crashes your users hit. By enforcing nullable vs non-nullable types at compile time, you catch NullPointerException before release. This alone justifies the language switch from Java.
Once you're comfortable with basic syntax, move to coroutines immediately. They're not optional—they're how you avoid callback hell and keep your main thread responsive. The mental model is straightforward: a coroutine is a function that can suspend and resume without blocking.
Here's a real scenario from a ride-hailing platform. You need to fetch user location, then load nearby drivers, then calculate ETA. Without coroutines, you'd nest callbacks. With them:
suspend fun loadRideData(userId: String) {
try {
val location = locationRepository.getUserLocation(userId)
val drivers = driverRepository.getNearbyDrivers(location)
val eta = routeRepository.calculateETA(location, drivers.first().location)
updateUI(location, drivers, eta)
} catch (e: Exception) {
handleError(e)
}
}The flow reads top-to-bottom, error handling is natural, and the thread doesn't block. Spend time understanding launch, async, and withContext. These three cover 95% of real work.
Compose, state and navigation
Compose is the modern UI layer, and the mental shift matters more than the syntax. Forget layouts and view binding—think of your UI as a pure function of state. When state changes, Compose re-renders the affected parts.
State management is where most developers struggle. Use remember for local, short-lived state. Use ViewModel with state holders for screen-level state. Use a repository or data layer for shared state across screens.
A concrete example from a travel booking app: you're building a flight search screen with filters. Users can toggle class, date range, and price limits. Each toggle should update the results instantly.
@Composable
fun FlightSearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
Column {
ClassToggle(
selected = state.selectedClass,
onSelect = { viewModel.setClass(it) }
)
DateRangeSlider(
range = state.dateRange,
onRangeChange = { viewModel.setDateRange(it) }
)
PriceSlider(
price = state.maxPrice,
onPriceChange = { viewModel.setMaxPrice(it) }
)
FlightList(state.flights)
}
}The ViewModel holds the state; Compose renders based on it. When the user interacts, viewModel methods update state, and the UI recomposes automatically.
For navigation, use Navigation Compose with type-safe arguments via the Kotlin DSL. Avoid passing data through bundle strings—use serializable or dataclass arguments.
MVVM, Hilt, Retrofit and Room
These four tools form the spine of a scalable Android app. MVVM separates UI logic from business logic, Hilt handles dependency injection, Retrofit manages API calls, and Room handles local persistence.
MVVM: The ViewModel sits between the UI (Compose) and the data layer. It holds state, responds to events, and is lifecycle-aware. Never put business logic in Composables; never put UI logic in ViewModels.
Hilt: Dependency injection reduces coupling and makes testing straightforward. Annotate your ViewModel, repositories, and network clients with @HiltViewModel and @Singleton. Let Hilt construct them:
@HiltViewModel
class SearchViewModel @Inject constructor(
private val flightRepository: FlightRepository,
private val analyticsLogger: AnalyticsLogger
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow = _state.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_state.update { it.copy(loading = true) }
try {
val results = flightRepository.search(query)
_state.update { it.copy(flights = results, loading = false) }
analyticsLogger.logSearch(query)
} catch (e: Exception) {
_state.update { it.copy(error = e.message, loading = false) }
}
}
}
}Retrofit: Use it to define REST endpoints cleanly. Return suspend functions or Flow for streaming:
interface FlightApi {
@GET("/flights/search")
suspend fun searchFlights(
@Query("origin") origin: String,
@Query("destination") destination: String,
@Query("date") date: String
): SearchResponse
}Room: Your local database layer. Use it to cache API responses, enabling offline support and reducing network calls:
@Entity
data class FlightCache(
@PrimaryKey val id: String,
val origin: String,
val destination: String,
val price: Double,
val timestamp: Long
)
@Dao
interface FlightDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFlights(flights: List)
@Query("SELECT * FROM FlightCache WHERE timestamp > :since")
fun getRecentFlights(since: Long): Flow>
}Together, these tools enforce a clean architecture: API calls flow through repositories, are cached in Room, and state updates trigger Compose re-renders via ViewModel StateFlow.
Testing, performance and Play releases
Testing: Don't skip this. Unit test your ViewModels and repositories. Mock external dependencies (Retrofit, Room, analytics). Use runTest for coroutine-based tests:
@Test
fun testSearch_success() = runTest {
val mockRepository = mockk()
coEvery { mockRepository.search("NYC-LAX") } returns listOf(flight1, flight2)
val viewModel = SearchViewModel(mockRepository, mockk())
viewModel.search("NYC-LAX")
val state = viewModel.state.first()
assertEquals(2, state.flights.size)
assertFalse(state.loading)
}For UI tests, use ComposeTestRule to interact with Composables and assert state changes.
Performance: Profile early. Use Android Studio's Profiler to detect jank, memory leaks, and excessive allocations. Common culprits: recomposition of large lists (use key()), unscoped coroutines (use viewModelScope), and missing @Stable annotations on data classes passed to Compose.
Play releases: Use Play Console's staged rollout. Release to 1% of users first, monitor crash rates and ANR metrics, then expand. Implement remote configuration to toggle features or adjust timeouts without releasing new code. Always version your Room database; migrations are painful but manageable with Migration objects.
Common mistakes
- Launching coroutines without scope: Use
viewModelScope,lifecycleScope, or a custom scope tied to an object's lifecycle. Launching withGlobalScopecauses leaks. - Putting business logic in Composables: Composables should be pure functions of state. Move logic to ViewModels or repositories.
- Not caching API responses: Hitting the API on every navigation back is wasteful. Cache intelligently in Room with a TTL.
- Ignoring null safety: Using
!!defeats Kotlin's null safety. Use?.let,?:, or handle the nullable case explicitly. - Over-complicating state: One ViewModel per screen is usually right. Keep state flat; derive computed values in Compose if needed.
- Releasing without testing on real devices: Emulator performance is misleading. Test on a low-end device; you'll find frame drops and memory issues the emulator hides.
Key takeaways
- Master Kotlin's null safety and coroutines first; they're the foundation for everything else.
- Learn Compose and state management alongside each other—they're a package deal.
- Use MVVM + Hilt + Retrofit + Room as your architectural spine; it scales from side project to production.
- Write unit tests for ViewModels and repositories; integration tests are slower but catch real issues.
- Profile on real devices, not emulators. Performance surprises hit late otherwise.
- Use Play Console's staged rollout and remote config to de-risk releases and iterate quickly.
- The Android developer roadmap works best when you move through it sequentially, not jumping to advanced tools before basics are solid.






Comments
Loading comments…