Android powers over 3 billion active devices worldwide, making it the most widely used mobile platform on the planet. This roadmap shows you exactly what to learn, in what order, and with realistic timelines to go from complete beginner to job-ready Android developer in 2025.
At a glance
| Phase | Topics | Time estimate |
|---|---|---|
| 1 | Programming fundamentals (Kotlin) | 4–6 weeks |
| 2 | Android core concepts | 4–6 weeks |
| 3 | Jetpack Compose UI | 4–6 weeks |
| 4 | Architecture patterns | 3–4 weeks |
| 5 | Data & networking | 4–6 weeks |
| 6 | Jetpack libraries | 3–4 weeks |
| 7 | Testing | 2–3 weeks |
| 8 | Deployment & publishing | 1–2 weeks |
| 9 | Advanced topics | ongoing |
| 10 | Portfolio & job search | 4–6 weeks |
Total: 12–18 months to job-ready.
Phase 1 — Kotlin fundamentals
Android development uses Kotlin as the primary language (officially since 2017). Java still works, but all modern Android codebases are Kotlin-first.
Core Kotlin syntax
// Variables
val name: String = "Alice" // immutable
var age: Int = 30 // mutable
// Null safety
val email: String? = null // nullable
val length = email?.length ?: 0 // safe call + Elvis
// Functions
fun greet(name: String): String {
return "Hello, $name!"
}
// Lambdas
val square: (Int) -> Int = { x -> x * x }
// Data classes
data class User(val id: Int, val name: String, val email: String)
Key Kotlin concepts
| Concept | Why it matters |
|---|---|
| Null safety | Eliminates NullPointerException at compile time |
| Extension functions | Add methods to existing classes without inheritance |
| Coroutines | Async code without callbacks |
| Data classes | Auto-generates equals/hashCode/copy/toString |
| Sealed classes | Exhaustive when expressions for state modeling |
| Object/companion | Singletons and static-like members |
| Lambdas & higher-order functions | Functional-style collection operations |
| Scope functions (let/run/with/apply/also) | Idiomatic object configuration and null checks |
Kotlin collections
val numbers = listOf(1, 2, 3, 4, 5)
val evens = numbers.filter { it % 2 == 0 } // [2, 4]
val doubled = numbers.map { it * 2 } // [2, 4, 6, 8, 10]
val sum = numbers.reduce { acc, n -> acc + n } // 15
val grouped = numbers.groupBy { if (it % 2 == 0) "even" else "odd" }
Resources: Kotlin Playground (play.kotlinlang.org), Kotlin Koans, official Kotlin docs.
Phase 2 — Android core concepts
Project structure
app/
├── src/
│ ├── main/
│ │ ├── java/com/example/myapp/
│ │ │ ├── MainActivity.kt
│ │ │ └── ...
│ │ ├── res/
│ │ │ ├── layout/ # XML layouts (legacy)
│ │ │ ├── drawable/ # Images and icons
│ │ │ ├── values/ # strings.xml, colors.xml, themes.xml
│ │ │ └── mipmap/ # App icons
│ │ └── AndroidManifest.xml
│ └── test/ # Unit tests
├── build.gradle.kts
└── proguard-rules.pro
Activity lifecycle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize UI — called once
}
override fun onStart() { } // Visible but not interactive
override fun onResume() { } // Fully visible and interactive
override fun onPause() { } // Partially obscured
override fun onStop() { } // Not visible
override fun onDestroy() { } // About to be destroyed
}
Key Android concepts
| Concept | Description |
|---|---|
| Activity | Single screen with a UI |
| Fragment | Reusable UI portion hosted inside an Activity |
| Intent | Messaging object to start Activities or Services |
| Service | Background work without a UI |
| BroadcastReceiver | Responds to system-wide events |
| ContentProvider | Shares data between apps |
| AndroidManifest.xml | Declares components, permissions, features |
| Gradle | Build system; manages dependencies and build config |
| Context | Interface to global Android information (app resources, files, etc.) |
Permissions
// Declare in AndroidManifest.xml
// <uses-permission android:name="android.permission.CAMERA"/>
// Request at runtime (Android 6+)
val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) startCamera() else showPermissionRationale()
}
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
Phase 3 — Jetpack Compose UI
Jetpack Compose is Android's modern, declarative UI toolkit. All new Android projects should use Compose instead of XML layouts.
Composable functions
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// Preview in Android Studio
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MyAppTheme {
Greeting("World")
}
}
Core Compose concepts
// State management
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
Compose layout building blocks
| Component | Purpose |
|---|---|
Column |
Vertical stack |
Row |
Horizontal stack |
Box |
Layered/overlap |
LazyColumn |
Efficient scrollable list (like RecyclerView) |
LazyRow |
Horizontal scrollable list |
Scaffold |
Material app shell (TopBar, BottomBar, FAB) |
Card |
Material card surface |
Text |
Display text |
Button / IconButton |
Click targets |
TextField |
Text input |
Image |
Display images (use Coil for async loading) |
Navigation with Compose
// Add dependency: androidx.navigation:navigation-compose
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailScreen(id, navController)
}
}
// Navigate
navController.navigate("detail/42")
navController.popBackStack()
Phase 4 — Architecture patterns
MVVM (Model-View-ViewModel) — the standard
// ViewModel — survives configuration changes
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
_users.value = repository.getUsers()
}
}
}
// Composable screen
@Composable
fun UserListScreen(viewModel: UserViewModel = viewModel()) {
val users by viewModel.users.collectAsStateWithLifecycle()
LazyColumn {
items(users) { user ->
Text(user.name)
}
}
}
Architecture layers
┌─────────────────────────────────┐
│ UI Layer (Compose + ViewModels) │
├─────────────────────────────────┤
│ Domain Layer (Use Cases) │ ← optional but recommended
├─────────────────────────────────┤
│ Data Layer (Repositories) │
│ ├── Remote (Retrofit/Ktor) │
│ └── Local (Room) │
└─────────────────────────────────┘
Dependency injection with Hilt
// App-level
@HiltAndroidApp
class MyApp : Application()
// Repository
@Singleton
class UserRepository @Inject constructor(
private val api: UserApi,
private val db: UserDao
)
// ViewModel
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel()
// Activity/Fragment
@AndroidEntryPoint
class MainActivity : AppCompatActivity()
Architecture patterns comparison
| Pattern | Description | When to use |
|---|---|---|
| MVVM | ViewModel separates UI from business logic | Default choice — well-supported by Jetpack |
| MVI | Unidirectional data flow with immutable state | Complex state management, large teams |
| MVP | Presenter handles logic, View is passive | Legacy codebases; less common in Compose era |
| Clean Architecture | Strict layer separation with use cases | Large apps with complex domain logic |
Phase 5 — Data & networking
Room database (local persistence)
// Entity
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val email: String
)
// DAO
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: User)
@Delete
suspend fun delete(user: User)
}
// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Retrofit (network requests)
// API interface
interface UserApi {
@GET("users")
suspend fun getUsers(): List<UserDto>
@POST("users")
suspend fun createUser(@Body user: CreateUserRequest): UserDto
}
// Setup (inject via Hilt)
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val api = retrofit.create(UserApi::class.java)
// Repository usage
suspend fun getUsers(): List<User> {
return try {
api.getUsers().map { it.toUser() }
} catch (e: HttpException) {
emptyList()
}
}
DataStore (replacing SharedPreferences)
// Proto DataStore for typed preferences
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val DARK_MODE_KEY = booleanPreferencesKey("dark_mode")
// Write
suspend fun setDarkMode(context: Context, enabled: Boolean) {
context.dataStore.edit { settings ->
settings[DARK_MODE_KEY] = enabled
}
}
// Read (returns Flow)
val darkModeFlow: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[DARK_MODE_KEY] ?: false }
Phase 6 — Jetpack libraries
The Jetpack suite covers everything you need to build production-quality apps.
| Library | Purpose |
|---|---|
| Compose | Declarative UI |
| Navigation | In-app navigation and deep links |
| ViewModel | UI state that survives rotation |
| Room | SQLite ORM |
| DataStore | Key-value and proto storage |
| WorkManager | Guaranteed background tasks |
| Paging 3 | Paginated list loading |
| CameraX | Camera hardware abstraction |
| Hilt | Dependency injection |
| Lifecycle | Lifecycle-aware components |
| Compose Material 3 | Material You design components |
WorkManager (background tasks)
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
syncData()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Schedule one-time work with constraints
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
Phase 7 — Testing
Unit tests
// app/src/test/ (JVM, no Android)
class UserViewModelTest {
private val repository = mockk<UserRepository>()
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
coEvery { repository.getUsers() } returns listOf(User(1, "Alice", "alice@example.com"))
viewModel = UserViewModel(repository)
}
@Test
fun `users are loaded on init`() = runTest {
val users = viewModel.users.first()
assertThat(users).hasSize(1)
assertThat(users[0].name).isEqualTo("Alice")
}
}
Instrumentation tests (Compose UI)
// app/src/androidTest/ (runs on device/emulator)
@HiltAndroidTest
class UserListScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun userListDisplayed() {
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}
@Test
fun buttonClick_incrementsCount() {
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
}
Testing pyramid for Android
| Level | Tools | Speed | Count |
|---|---|---|---|
| Unit (logic) | JUnit 5, MockK, Turbine | Fast | ~70% |
| Integration (DB, Repository) | Room in-memory, Hilt test | Medium | ~20% |
| UI / E2E | Compose test rules, Espresso | Slow | ~10% |
Phase 8 — Deployment & publishing
Build variants
// build.gradle.kts (app)
android {
buildTypes {
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
flavorDimensions += "env"
productFlavors {
create("staging") { applicationIdSuffix = ".staging" }
create("production")
}
}
Publishing to Google Play
- Generate a signed APK/AAB — Build > Generate Signed Bundle / APK in Android Studio
- Create Play Console account — one-time $25 fee at play.google.com/console
- Prepare store listing — icon (512×512), feature graphic (1024×500), screenshots (min 2 phone), description
- Set up app content — privacy policy, content rating questionnaire, target age group
- Internal testing → Closed testing → Open testing → Production — staged rollout recommended
- AAB preferred — Android App Bundle lets Play optimize the download for each device
CI/CD with GitHub Actions
# .github/workflows/android.yml
name: Android CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Run unit tests
run: ./gradlew test
- name: Build release AAB
run: ./gradlew bundleRelease
- uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/
Phase 9 — Advanced topics
Kotlin coroutines & Flow
// Coroutine scopes
viewModelScope.launch { } // cancelled when ViewModel destroyed
lifecycleScope.launch { } // cancelled with lifecycle
// Flow operators
repository.getUsersFlow()
.filter { it.isActive }
.map { it.toUiModel() }
.catch { e -> emit(emptyList()) }
.collectLatest { users -> _uiState.value = users }
// StateFlow vs SharedFlow
// StateFlow — always has a value, replays last to new collectors
// SharedFlow — for events (navigation, snackbars) — no replay
Performance optimization
| Problem | Solution |
|---|---|
| Slow list scrolling | LazyColumn + stable keys + @Stable data classes |
| Excessive recomposition | remember, derivedStateOf, avoid lambda captures |
| Large APK size | App Bundle + R8 minification + on-demand modules |
| ANR (app not responding) | Move all I/O to coroutines, never block main thread |
| Memory leaks | Don't hold Activity context in ViewModel; use applicationContext |
| Battery drain | Use WorkManager for background work; batch network calls |
Accessibility
Button(
onClick = { performAction() },
modifier = Modifier.semantics {
contentDescription = "Submit order"
role = Role.Button
}
) {
Text("Submit")
}
// Enable TalkBack on a real device to test
// Use Layout Inspector → Semantics tree to verify
Kotlin Multiplatform (KMP)
KMP lets you share business logic (network, database, domain) between Android and iOS while keeping native UIs:
// shared/commonMain
class UserRepository(private val api: UserApi, private val db: Database) {
suspend fun getUsers(): List<User> = api.fetchUsers()
}
// Android — use as normal
// iOS — call from Swift via generated Kotlin/Native bindings
Full technology map
Android Developer Skills
├── Language
│ ├── Kotlin (primary)
│ └── Java (legacy/interop)
├── UI
│ ├── Jetpack Compose (modern)
│ ├── XML + View system (legacy)
│ └── Material 3 design system
├── Architecture
│ ├── MVVM + StateFlow
│ ├── MVI (Orbit, MVI Kotlin)
│ └── Clean Architecture + Use Cases
├── Jetpack
│ ├── Navigation, ViewModel, Lifecycle
│ ├── Room, DataStore, WorkManager
│ └── Paging 3, CameraX, Biometric
├── Dependency Injection
│ ├── Hilt (recommended)
│ └── Koin (alternative)
├── Networking
│ ├── Retrofit + OkHttp
│ └── Ktor client (KMP-friendly)
├── Async
│ ├── Coroutines
│ └── Flow / StateFlow / SharedFlow
├── Testing
│ ├── JUnit 5, MockK, Turbine
│ └── Compose test rules, Espresso
├── CI/CD
│ ├── GitHub Actions, Bitrise, CircleCI
│ └── Firebase App Distribution
└── Advanced
├── KMP / Compose Multiplatform
├── Custom Views & Canvas
└── NDK / C++ interop
Realistic 14-month timeline
| Month | Focus |
|---|---|
| 1–2 | Kotlin fundamentals + Android Studio setup |
| 3–4 | Android core (Activity, Fragment, Intents, Permissions) |
| 5–6 | Jetpack Compose UI + Navigation |
| 7–8 | MVVM architecture + ViewModel + StateFlow |
| 9–10 | Room + Retrofit + DataStore |
| 11 | Testing (unit + UI) |
| 12 | Hilt DI + WorkManager + Performance |
| 13 | First portfolio app (solo) |
| 14 | Second portfolio app + Google Play publishing + job applications |
Portfolio projects
| Project | Skills demonstrated |
|---|---|
| Weather app | Retrofit, ViewModel, Compose UI, location permissions |
| Notes app | Room, DataStore, CRUD, MVVM, search |
| Expense tracker | Room with relations, charts (MPAndroidChart), export to CSV |
| News reader | Retrofit + Paging 3, offline cache with Room, deep links |
| Chat app | Firebase Firestore/Realtime DB, FCM push notifications, auth |
| Fitness tracker | WorkManager (background steps), Health Connect API, charts |
Android developer roles & salary
| Role | Skills | Salary (US) |
|---|---|---|
| Junior Android Developer | Kotlin, Compose, MVVM basics | $65k–$90k |
| Android Developer | Full Jetpack stack, testing, CI/CD | $90k–$130k |
| Senior Android Developer | Architecture, performance, mentoring | $130k–$170k |
| Android Lead / Staff | Technical direction, cross-platform, KMP | $160k–$200k+ |
| KMP / Mobile Architect | Shared logic iOS+Android, KMP, Ktor | $150k–$200k+ |
| Freelance Android Developer | Client projects, full ownership | $80k–$200k+ |
Common mistakes
| Mistake | Fix |
|---|---|
Using GlobalScope instead of viewModelScope |
Always use lifecycle-scoped coroutines |
Holding Activity context in a ViewModel |
Use applicationContext or avoid context in ViewModel |
| Doing network/database work on main thread | All I/O must use suspend functions and coroutines |
| Ignoring backstack and deep links | Design navigation graph from the start |
| Not testing on low-end devices | Use Android Emulator with 1–2 GB RAM profiles |
| Skipping ProGuard/R8 rules | Test release builds, not just debug, before publishing |
Overusing remember { mutableStateOf } everywhere |
Lift state to ViewModel for anything that outlives a composable |
| Never using LazyColumn for lists | Column with many children causes performance issues |
Android vs iOS vs Cross-platform
| Factor | Android (Kotlin) | iOS (Swift) | Cross-platform (Flutter/KMP) |
|---|---|---|---|
| Language | Kotlin | Swift | Dart (Flutter) / Kotlin (KMP) |
| Market share | ~72% globally | ~27% globally | Both |
| IDE | Android Studio | Xcode | Both |
| Job market | High demand | High demand | Growing |
| Hardware diversity | Very high | Controlled (Apple) | Depends on platform |
| Time to market | Single platform | Single platform | Both platforms at once |
| Performance | Native | Native | Near-native (Flutter) / Native (KMP) |
Frequently asked questions
Do I need to learn Java before Kotlin? No. Kotlin is the official Android language and is easier to learn than Java. Start directly with Kotlin. You'll eventually read Java code in older projects, but you can pick that up as you go.
Should I use Jetpack Compose or XML layouts? Use Jetpack Compose for all new projects. Google has made it clear that Compose is the future of Android UI. XML/View system knowledge is only needed for maintaining legacy apps.
How important is knowing the Android lifecycle? Very important. Most Android bugs — crashes on rotation, memory leaks, state loss — come from misunderstanding the lifecycle. Study the Activity and Fragment lifecycle diagrams carefully.
What's the difference between MVVM and MVI? MVVM uses two-way data binding or state flows from ViewModel to UI. MVI enforces strict unidirectional data flow with immutable state objects and explicit user intents. MVVM is simpler to start with; MVI scales better for complex apps.
How long does it take to get an Android job? With 1–2 strong portfolio apps on Google Play and consistent learning, most developers reach hireable level in 12–18 months. Android engineer is one of the most in-demand mobile roles.
Should I learn Kotlin Multiplatform (KMP)? Learn Android thoroughly first. Once you're comfortable, KMP is a strong differentiator that lets you share business logic with iOS. Companies are increasingly adopting it, especially for new projects.