CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Android 开发入门/课时/综合练习
RawBlame
课时目录12 节

Android 开发入门

  1. 01Android 开发概览
  2. 02四大组件
  3. 03传统 View UI
  4. 04Jetpack Compose
  5. 05架构模式
  6. 06依赖注入
  7. 07本地存储
  8. 08网络层
  9. 09异步与响应式
  10. 10Firebase 生态
  11. 11测试与质量
  12. 12综合练习

综合练习

Compose 列表 App + 签名 APK。

40 分钟

综合练习

这一课你将能做到

  1. 独立搭建一个完整的 Compose 应用:列表 → 详情 → 收藏 → 离线缓存
  2. 整合 Retrofit、Room、Hilt、Navigation 等前面课程的所有技术栈
  3. 构建 Signed APK 并理解发布流程的关键步骤

为什么要学

前面 11 课是「点」,这一课是「面」。只有把知识点串成项目,才能真正掌握。综合练习模拟真实开发流程:需求分析 → 架构设计 → 编码实现 → 测试调试 → 构建发布。这是从「学语法」到「做产品」的跨越。

人话:前面学的是「炒菜、炖汤、蒸饭」,这一课是「做一桌满汉全席」。食材(知识)都有了,现在练的是「火候掌握」(技术整合)和「摆盘上桌」(发布流程)。

面试中,「项目架构思路」、「遇到的最大挑战」、「如何优化性能」是必考题。

先建立图像

项目架构总览:

┌─────────────────────────────────────────┐
│              UI 层 (Compose)             │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐  │
│  │TrackList│ │TrackDetail│ │Settings │  │
│  │ Screen  │ │  Screen  │ │ Screen  │  │
│  └────┬────┘ └────┬────┘ └────┬────┘  │
│       └─────────────┴─────────────┘      │
│                  ↓ StateFlow             │
│  ┌─────────────────────────────────┐    │
│  │         ViewModel 层            │    │
│  │  TrackListViewModel 等           │    │
│  └────────────────┬────────────────┘    │
│                   ↓                      │
│  ┌─────────────────────────────────┐    │
│  │        Repository 层            │    │
│  │   TrackRepository               │    │
│  │   (网络 + 数据库 + 缓存策略)       │    │
│  └────┬────────────────────┬───────┘    │
│       ↓                    ↓            │
│  ┌─────────┐        ┌──────────┐       │
│  │ Retrofit│        │   Room   │       │
│  │  网络   │        │  数据库   │       │
│  └─────────┘        └──────────┘       │
│                                         │
│  Hilt 贯穿所有层,提供依赖注入              │
└─────────────────────────────────────────┘

跟着做

步骤 1:项目初始化与依赖配置

创建新项目,选择 Empty Activity(Compose),包名 com.coderoadmap.tracks。

build.gradle.kts(app)关键依赖:

dependencies {
    // Compose BOM
    implementation(platform("androidx.compose:compose-bom:2024.06.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.9.0")
    
    // Navigation
    implementation("androidx.navigation:navigation-compose:2.7.7")
    
    // Hilt
    implementation("com.google.dagger:hilt-android:2.51.1")
    kapt("com.google.dagger:hilt-android-compiler:2.51.1")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    
    // Room
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")
    
    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    
    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    
    // DataStore
    implementation("androidx.datastore:datastore-preferences:1.1.1")
    
    // Coil(图片加载)
    implementation("io.coil-kt:coil-compose:2.6.0")
    
    // 测试
    testImplementation("junit:junit:4.13.2")
    testImplementation("io.mockk:mockk:1.13.11")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
}

步骤 2:数据层完整实现

// 1. Entity
@Entity(tableName = "tracks")
data class TrackEntity(
    @PrimaryKey val slug: String,
    val title: String,
    val description: String,
    val lessonCount: Int,
    val coverUrl: String?,
    val difficulty: String,
    val updatedAt: Long = System.currentTimeMillis()
)

@Entity(
    tableName = "lessons",
    foreignKeys = [ForeignKey(
        entity = TrackEntity::class,
        parentColumns = ["slug"],
        childColumns = ["trackSlug"],
        onDelete = ForeignKey.CASCADE
    )],
    indices = [Index("trackSlug")]
)
data class LessonEntity(
    @PrimaryKey val slug: String,
    val trackSlug: String,
    val title: String,
    val content: String,
    val duration: Int,
    val order: Int
)

@Entity(tableName = "user_progress")
data class ProgressEntity(
    @PrimaryKey val id: String, // "$userId-$trackSlug"
    val userId: String,
    val trackSlug: String,
    val lessonSlug: String?,
    val progress: Int, // 0-100
    val completedAt: Long?
)

// 2. DAO
@Dao
interface TrackDao {
    @Query("SELECT * FROM tracks ORDER BY updatedAt DESC")
    fun getAllTracks(): Flow<List<TrackEntity>>
    
    @Query("SELECT * FROM tracks WHERE slug = :slug")
    suspend fun getTrackBySlug(slug: String): TrackEntity?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTracks(tracks: List<TrackEntity>)
    
    @Transaction
    @Query("SELECT * FROM tracks WHERE slug = :slug")
    fun getTrackWithLessons(slug: String): Flow<TrackWithLessons?>
}

@Dao
interface LessonDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertLessons(lessons: List<LessonEntity>)
    
    @Query("SELECT * FROM lessons WHERE trackSlug = :trackSlug ORDER BY `order`")
    fun getLessonsByTrack(trackSlug: String): Flow<List<LessonEntity>>
}

@Dao
interface ProgressDao {
    @Query("SELECT * FROM user_progress WHERE userId = :userId")
    fun getUserProgress(userId: String): Flow<List<ProgressEntity>>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun saveProgress(progress: ProgressEntity)
    
    @Query("SELECT * FROM user_progress WHERE trackSlug = :trackSlug AND userId = :userId")
    suspend fun getTrackProgress(userId: String, trackSlug: String): ProgressEntity?
}

// 3. Database
@Database(
    entities = [TrackEntity::class, LessonEntity::class, ProgressEntity::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun trackDao(): TrackDao
    abstract fun lessonDao(): LessonDao
    abstract fun progressDao(): ProgressDao
}

// 4. API
interface TrackApiService {
    @GET("tracks")
    suspend fun getTracks(): ApiResponse<List<TrackDto>>
    
    @GET("tracks/{slug}")
    suspend fun getTrackDetail(@Path("slug") slug: String): ApiResponse<TrackDetailDto>
    
    @POST("progress")
    suspend fun saveProgress(@Body request: ProgressRequest): ApiResponse<Unit>
}

data class ApiResponse<T>(val code: Int, val message: String, val data: T)
data class TrackDto(val slug: String, val title: String, val description: String, 
    val lessonCount: Int, val coverUrl: String?, val difficulty: String)
data class TrackDetailDto(val slug: String, val title: String, val lessons: List<LessonDto>)
data class LessonDto(val slug: String, val title: String, val content: String, 
    val duration: Int, val order: Int)
data class ProgressRequest(val trackSlug: String, val lessonSlug: String?, val progress: Int)

// 5. Repository
@Singleton
class TrackRepository @Inject constructor(
    private val api: TrackApiService,
    private val trackDao: TrackDao,
    private val lessonDao: LessonDao,
    private val progressDao: ProgressDao
) {
    val tracks: Flow<List<TrackUiModel>> = trackDao.getAllTracks()
        .map { entities -> entities.map { it.toUiModel() } }
    
    fun getTrackDetail(slug: String): Flow<TrackDetailUiModel?> = 
        trackDao.getTrackWithLessons(slug)
            .map { relation -> relation?.toDetailUiModel() }
    
    suspend fun refreshTracks(): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            val response = api.getTracks()
            val entities = response.data.map { it.toEntity() }
            trackDao.insertTracks(entities)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun refreshLessons(trackSlug: String): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            val response = api.getTrackDetail(trackSlug)
            val lessons = response.data.lessons.map { it.toEntity(trackSlug) }
            lessonDao.insertLessons(lessons)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun saveProgress(userId: String, trackSlug: String, lessonSlug: String?, progress: Int) {
        val entity = ProgressEntity(
            id = "$userId-$trackSlug",
            userId = userId,
            trackSlug = trackSlug,
            lessonSlug = lessonSlug,
            progress = progress,
            completedAt = if (progress == 100) System.currentTimeMillis() else null
        )
        progressDao.saveProgress(entity)
        
        // 同步到服务器(可选,WorkManager 批量同步)
        try {
            api.saveProgress(ProgressRequest(trackSlug, lessonSlug, progress))
        } catch (e: Exception) {
            // 静默失败,本地已保存
        }
    }
}

步骤 3:ViewModel 层

@HiltViewModel
class TrackListViewModel @Inject constructor(
    private val repository: TrackRepository
) : ViewModel() {

    data class UiState(
        val tracks: List<TrackUiModel> = emptyList(),
        val isLoading: Boolean = true,
        val isRefreshing: Boolean = false,
        val error: String? = null
    )

    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    init {
        observeTracks()
        refresh()
    }

    private fun observeTracks() {
        viewModelScope.launch {
            repository.tracks.collect { tracks ->
                _uiState.update { it.copy(
                    tracks = tracks,
                    isLoading = false,
                    error = null
                )}
            }
        }
    }

    fun refresh() {
        viewModelScope.launch {
            _uiState.update { it.copy(isRefreshing = true) }
            repository.refreshTracks()
                .onSuccess { _uiState.update { it.copy(isRefreshing = false) } }
                .onFailure { e ->
                    _uiState.update { it.copy(
                        isRefreshing = false,
                        error = e.message
                    )}
                }
        }
    }
}

@HiltViewModel
class TrackDetailViewModel @Inject constructor(
    private val repository: TrackRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val trackSlug: String = checkNotNull(savedStateHandle["trackSlug"])

    val detail: StateFlow<TrackDetailUiModel?> = repository.getTrackDetail(trackSlug)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    init {
        refreshLessons()
    }

    fun refreshLessons() {
        viewModelScope.launch {
            _isLoading.value = true
            repository.refreshLessons(trackSlug)
            _isLoading.value = false
        }
    }

    fun markLessonComplete(userId: String, lessonSlug: String, progress: Int) {
        viewModelScope.launch {
            repository.saveProgress(userId, trackSlug, lessonSlug, progress)
        }
    }
}

步骤 4:Compose UI 层

// Navigation
sealed class Screen(val route: String) {
    object TrackList : Screen("tracks")
    object TrackDetail : Screen("tracks/{trackSlug}") {
        fun createRoute(slug: String) = "tracks/$slug"
    }
    object LessonDetail : Screen("lessons/{lessonSlug}") {
        fun createRoute(slug: String) = "lessons/$slug"
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    
    NavHost(navController = navController, startDestination = Screen.TrackList.route) {
        composable(Screen.TrackList.route) {
            TrackListScreen(
                onTrackClick = { slug ->
                    navController.navigate(Screen.TrackDetail.createRoute(slug))
                }
            )
        }
        composable(
            Screen.TrackDetail.route,
            arguments = listOf(navArgument("trackSlug") { type = NavType.StringType })
        ) { entry ->
            val slug = entry.arguments?.getString("trackSlug") ?: return@composable
            TrackDetailScreen(
                trackSlug = slug,
                onBack = { navController.popBackStack() },
                onLessonClick = { lessonSlug ->
                    navController.navigate(Screen.LessonDetail.createRoute(lessonSlug))
                }
            )
        }
    }
}

// 列表页
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrackListScreen(
    viewModel: TrackListViewModel = hiltViewModel(),
    onTrackClick: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("CodeRoadMap") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            )
        }
    ) { padding ->
        PullToRefreshBox(
            isRefreshing = uiState.isRefreshing,
            onRefresh = viewModel::refresh,
            modifier = Modifier.padding(padding)
        ) {
            when {
                uiState.isLoading -> {
                    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                        CircularProgressIndicator()
                    }
                }
                uiState.error != null -> {
                    Column(
                        modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text("加载失败: ${uiState.error}")
                        Spacer(modifier = Modifier.height(16.dp))
                        Button(onClick = viewModel::refresh) {
                            Text("重试")
                        }
                    }
                }
                else -> {
                    LazyColumn(
                        contentPadding = PaddingValues(16.dp),
                        verticalArrangement = Arrangement.spacedBy(12.dp)
                    ) {
                        items(uiState.tracks, key = { it.slug }) { track ->
                            TrackCard(
                                track = track,
                                onClick = { onTrackClick(track.slug) }
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun TrackCard(track: TrackUiModel, onClick: () -> Unit) {
    Card(
        onClick = onClick,
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(16.dp)
    ) {
        Column {
            // 封面图(Coil 加载)
            AsyncImage(
                model = track.coverUrl,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(180.dp),
                contentScale = ContentScale.Crop
            )
            
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = track.title,
                    style = MaterialTheme.typography.titleLarge,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = track.description,
                    style = MaterialTheme.typography.bodyMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )
                Spacer(modifier = Modifier.height(8.dp))
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text(
                        text = "${track.lessonCount} 节课",
                        style = MaterialTheme.typography.labelMedium
                    )
                    Text(
                        text = track.difficulty,
                        color = when (track.difficulty) {
                            "初级" -> Color.Green
                            "中级" -> Color.Orange
                            else -> Color.Red
                        },
                        style = MaterialTheme.typography.labelMedium
                    )
                }
            }
        }
    }
}

步骤 5:构建 Signed APK

// 1. 生成签名密钥(命令行)
// keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias

// 2. 配置 build.gradle.kts
android {
    signingConfigs {
        create("release") {
            storeFile = file("../my-release-key.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = "my-alias"
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

// 3. 构建命令
// ./gradlew assembleRelease
// 输出:app/build/outputs/apk/release/app-release.apk

步骤 6:ProGuard 规则(proguard-rules.pro)

# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
    @retrofit2.http.* <methods>;
}

# Gson
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.coderoadmap.tracks.data.dto.** { *; }

# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

# Kotlin
-keep class kotlin.Metadata { *; }
-keepclassmembers class **$WhenMappings {
    <fields>;
}

讲清楚要点

  1. 项目结构决定可维护性:data/(Entity、DAO、API)、domain/(UseCase、Repository 接口)、ui/(Screen、ViewModel、组件)、di/(Module)。每层独立,可单独测试。

  2. 离线优先是移动 App 的标配:网络数据写入 Room,UI 只读 Room。用户看到的内容永远来自本地,网络只负责更新。这保证了弱网体验。

  3. 签名是发布的最后一步,也是最容易出错的一步:密钥丢失 = 无法更新应用。必须备份密钥,或使用 Play App Signing 让 Google 托管。

  4. ProGuard/R8 是双刃剑:压缩代码、混淆命名,但可能误删必要类。发布后必须测试核心功能,特别是反射、序列化相关的代码。

  5. 状态管理贯穿始终:Loading → Success/Error 的 UI 状态机;Pull-to-Refresh 的刷新状态;收藏/进度的本地即时更新。状态设计好,UI 代码就简洁。

你可能会懵的地方

  • 为什么 Repository 里网络失败不抛异常? 离线优先架构中,网络是「增强」不是「必需」。网络失败时返回本地缓存,用户无感知。只有首次加载且无缓存时才显示错误。

  • SavedStateHandle 有什么用? ViewModel 销毁时保存状态。Navigation 传参、用户输入、滚动位置都可以放。进程被杀后恢复,用户体验无缝。

  • Coil 和 Glide 选哪个? Coil 是 Kotlin 协程原生,API 更现代,与 Compose 集成更好。Glide 更成熟,功能更丰富。新项目推荐 Coil。

  • 如何测试这个完整项目? 单元测试:ViewModel + Repository;集成测试:Room + Repository;UI 测试:Compose 关键路径。CI 配置 GitHub Actions 自动跑测试。

  • 下一步学什么?

    1. 模块化开发(Dynamic Feature);2. 性能优化(Baseline Profile、Startup);3. 发布流程(Play Console、App Bundle、分阶段发布)。

错法对照

错法问题正解
在 Composable 里直接网络请求重组时重复请求,无法取消ViewModel + Repository
所有状态放 ViewModel 单例内存泄漏,多页面状态混乱按页面/功能拆分 ViewModel
忽略数据库迁移升级崩溃版本号 +1,提供 Migration
签名密钥硬编码在 build.gradle安全风险环境变量或 local.properties
不测试 Release 包ProGuard 误删类导致崩溃构建后安装测试核心功能

卡点急救

  1. Room 编译报错「Cannot figure out how to save this field」:检查 Entity 字段类型是否支持。Date 用 TypeConverter,List 用 @TypeConverters 或拆表。

  2. Navigation 传参类型不匹配:检查 NavType 与实际参数类型。复杂对象用 JSON 字符串或自定义 NavType。

  3. Release 包崩溃但 Debug 正常:开启 minifyEnabled 后类名被混淆。检查 proguard-rules.pro 是否保留了必要类,特别是序列化和反射相关的。

面试常问

  • 这个项目的架构亮点?

    1. MVVM + Repository,单向数据流;2. 离线优先,Room 单一数据源;3. Hilt DI,层间解耦;4. Compose 声明式 UI,状态驱动;5. Flow 响应式,数据变化自动通知。
  • 如何优化 App 启动速度?

    1. Baseline Profile 预编译;2. App Startup 库延迟初始化;3. 减少 Application.onCreate 工作;4. 启动页用 SplashScreen API;5. 懒加载非必要模块。
  • 如果用户量到 100 万,架构需要怎么调整?

    1. 模块化:按功能拆分 module,减少编译时间;2. 分页加载:Paging 3 处理大数据列表;3. 缓存策略:多级缓存(内存 → Room → 网络);4. 性能监控:Firebase Performance 或自建 APM。

自测三问

  1. 画出一个「用户打开 App → 浏览课程 → 进入详情 → 完成一节课」的完整数据流。
  2. 如果服务器 API 返回格式变化,需要修改哪些层?为什么?
  3. 设计一个 A/B 测试方案:比较「列表页显示封面图」vs「不显示」对点击率的影响。

小结一句话

综合练习是「从零件到整机」的组装:Compose 做界面,Room 存数据,Retrofit 连网络,Hilt 管依赖,Flow 通状态,ProGuard 保发布;能独立完成,才算真正入门。

延伸

  • Now in Android 官方示例
  • Android 性能优化指南
  • Google Play 发布流程
  • 下一步方向:探索 Kotlin Multiplatform(KMP),或深入研究 Compose 性能优化与自定义 Layout。
加载本节练习…
← 上一课测试与质量