综合练习
Compose 列表 App + 签名 APK。
综合练习
这一课你将能做到
- 独立搭建一个完整的 Compose 应用:列表 → 详情 → 收藏 → 离线缓存
- 整合 Retrofit、Room、Hilt、Navigation 等前面课程的所有技术栈
- 构建 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>;
}
讲清楚要点
-
项目结构决定可维护性:data/(Entity、DAO、API)、domain/(UseCase、Repository 接口)、ui/(Screen、ViewModel、组件)、di/(Module)。每层独立,可单独测试。
-
离线优先是移动 App 的标配:网络数据写入 Room,UI 只读 Room。用户看到的内容永远来自本地,网络只负责更新。这保证了弱网体验。
-
签名是发布的最后一步,也是最容易出错的一步:密钥丢失 = 无法更新应用。必须备份密钥,或使用 Play App Signing 让 Google 托管。
-
ProGuard/R8 是双刃剑:压缩代码、混淆命名,但可能误删必要类。发布后必须测试核心功能,特别是反射、序列化相关的代码。
-
状态管理贯穿始终: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 自动跑测试。
-
下一步学什么?
- 模块化开发(Dynamic Feature);2. 性能优化(Baseline Profile、Startup);3. 发布流程(Play Console、App Bundle、分阶段发布)。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 在 Composable 里直接网络请求 | 重组时重复请求,无法取消 | ViewModel + Repository |
| 所有状态放 ViewModel 单例 | 内存泄漏,多页面状态混乱 | 按页面/功能拆分 ViewModel |
| 忽略数据库迁移 | 升级崩溃 | 版本号 +1,提供 Migration |
| 签名密钥硬编码在 build.gradle | 安全风险 | 环境变量或 local.properties |
| 不测试 Release 包 | ProGuard 误删类导致崩溃 | 构建后安装测试核心功能 |
卡点急救
-
Room 编译报错「Cannot figure out how to save this field」:检查 Entity 字段类型是否支持。Date 用 TypeConverter,List 用 @TypeConverters 或拆表。
-
Navigation 传参类型不匹配:检查 NavType 与实际参数类型。复杂对象用 JSON 字符串或自定义 NavType。
-
Release 包崩溃但 Debug 正常:开启 minifyEnabled 后类名被混淆。检查 proguard-rules.pro 是否保留了必要类,特别是序列化和反射相关的。
面试常问
-
这个项目的架构亮点?
- MVVM + Repository,单向数据流;2. 离线优先,Room 单一数据源;3. Hilt DI,层间解耦;4. Compose 声明式 UI,状态驱动;5. Flow 响应式,数据变化自动通知。
-
如何优化 App 启动速度?
- Baseline Profile 预编译;2. App Startup 库延迟初始化;3. 减少 Application.onCreate 工作;4. 启动页用 SplashScreen API;5. 懒加载非必要模块。
-
如果用户量到 100 万,架构需要怎么调整?
- 模块化:按功能拆分 module,减少编译时间;2. 分页加载:Paging 3 处理大数据列表;3. 缓存策略:多级缓存(内存 → Room → 网络);4. 性能监控:Firebase Performance 或自建 APM。
自测三问
- 画出一个「用户打开 App → 浏览课程 → 进入详情 → 完成一节课」的完整数据流。
- 如果服务器 API 返回格式变化,需要修改哪些层?为什么?
- 设计一个 A/B 测试方案:比较「列表页显示封面图」vs「不显示」对点击率的影响。
小结一句话
综合练习是「从零件到整机」的组装:Compose 做界面,Room 存数据,Retrofit 连网络,Hilt 管依赖,Flow 通状态,ProGuard 保发布;能独立完成,才算真正入门。
延伸