目录
  1. 1. 一、Google 官方推荐架构
    1. 1.1. 1.1 架构层次
    2. 1.2. 1.2 架构核心原则
  2. 2. 二、项目模块化架构
    1. 2.1. 2.1 模块划分
    2. 2.2. 2.2 模块依赖关系
    3. 2.3. 2.3 settings.gradle.kts
  3. 3. 三、Domain 层实现
    1. 3.1. 3.1 Domain Model
    2. 3.2. 3.2 Repository 接口(依赖反转关键)
    3. 3.3. 3.3 UseCases
  4. 4. 四、Data 层实现
    1. 4.1. 4.1 Room Entity
    2. 4.2. 4.2 DAO
    3. 4.3. 4.3 AppDatabase
    4. 4.4. 4.4 API Service
    5. 4.5. 4.5 Data Mapper
    6. 4.6. 4.6 Repository 实现
    7. 4.7. 4.7 AppDispatchers(可注入的调度器)
  5. 5. 五、Hilt 依赖注入
    1. 5.1. 5.1 Application 配置
    2. 5.2. 5.2 Data 层 DI Module
    3. 5.3. 5.3 Domain 层 DI Module
  6. 6. 六、UI 层实现
    1. 6.1. 6.1 MainActivity(Single Activity)
    2. 6.2. 6.2 TaskListViewModel
    3. 6.3. 6.3 TaskListFragment
    4. 6.4. 6.4 AddTaskViewModel
  7. 7. 七、DataStore 实现(用户偏好)
    1. 7.1. 7.1 Preferences DataStore
    2. 7.2. 7.2 Proto DataStore 对比
  8. 8. 八、后台同步:WorkManager
  9. 9. 九、测试策略
    1. 9.1. 9.1 Unit Test: UseCases
    2. 9.2. 9.2 Unit Test: ViewModel with Fake Repository
    3. 9.3. 9.3 Integration Test: Room DAO
    4. 9.4. 9.4 FakeTaskRepository(测试工具)
  10. 10. 十、CI Pipeline 配置
    1. 10.1. 10.1 GitHub Actions
    2. 10.2. 10.2 Gradle Properties for CI
  11. 11. 面试常考问题
JetPack全家桶(十)之从0到1设计JetPack架构

本文以设计一个”Tasks”任务管理应用为例,展示如何用 Jetpack 全套组件从 0 到 1 搭建符合 Google 官方推荐的 MVVM + Clean Architecture 应用架构。

参考:Android Architecture Guide

一、Google 官方推荐架构

1.1 架构层次

┌──────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ Fragment/ │ │ Compose Screen │ │ DataBinding │ │
│ │ Activity │ │ │ │ (Legacy) │ │
│ └───────┬───────┘ └────────┬─────────┘ └───────┬───────┘ │
│ │ │ │ │
│ └─────────────────────┼───────────────────────┘ │
│ │ │
│ StateFlow / LiveData │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ ViewModel │ │
│ │ (State Holders) │ │
│ └───────────┬───────────┘ │
├────────────────────────────────┼──────────────────────────────────┤
│ (Optional) Domain Layer │
│ ┌───────────┴───────────┐ │
│ │ UseCases │ │
│ │ (Business Logic) │ │
│ └───────────┬───────────┘ │
├────────────────────────────────┼──────────────────────────────────┤
│ Data Layer │
│ ┌───────────┴───────────┐ │
│ │ Repository │ │
│ │ (Single Source of │ │
│ │ Truth) │ │
│ └─────┬───────────┬─────┘ │
│ │ │ │
│ ┌──────────┴───┐ ┌───┴──────────┐ │
│ │ Room (Local) │ │ Retrofit │ │
│ │ DataStore │ │ (Remote) │ │
│ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────────┘

1.2 架构核心原则

1. 单向数据流(UDF:Unidirectional Data Flow)

User Action → ViewModel → Repository → DataSource → Result

UI ← StateFlow ← ViewModel ← Repository ←────────┘

数据只向一个方向流动:状态从数据层向上暴露给 UI 层,用户操作从 UI 层向下传回数据层。这避免了双向绑定带来的循环更新问题。

2. 持久化数据作为唯一真相源(Single Source of Truth)

┌────────┐      ┌──────────────┐      ┌──────────┐
│ 网络 │ ──→ │ Room Database │ ──→ │ UI │
│ (API) │ │ (SSOT) │ │ │
└────────┘ └──────────────┘ └──────────┘

网络数据不直接交给 UI,而是先缓存到 Room 数据库。UI 只从 Room 读取数据。这保证了:

  • 离线可用:网络断开时 UI 仍能展示最新缓存
  • 数据一致性:所有消费者看到同一份数据
  • 自动更新:Room 的 Flow 在数据变更时自动推送

3. 依赖反转(Dependency Inversion)

┌────────────┐       ┌──────────────────┐
│ Domain 层 │ ←──→ │ 接口 (Interface) │
│ (UseCases) │ 依赖 │ │
└────────────┘ └────────┬─────────┘
│ 实现
┌────────┴─────────┐
│ Data 层 │
│ (RepositoryImpl) │
└──────────────────┘

Domain 层不依赖任何具体的数据库或网络实现,只依赖接口。Data 层实现接口。这使得 Domain 层可以完全脱离 Android 框架进行单元测试。

二、项目模块化架构

2.1 模块划分

TasksApp/
├── app/ # 主模块:Application、DI 装配、Navigation
│ ├── src/main/
│ │ ├── TasksApplication.kt # @HiltAndroidApp
│ │ └── MainActivity.kt # Single Activity
│ └── build.gradle.kts

├── feature/tasklist/ # 任务列表 Feature
│ ├── src/main/
│ │ ├── TaskListFragment.kt
│ │ ├── TaskListViewModel.kt
│ │ ├── TaskListAdapter.kt
│ │ └── res/layout/fragment_task_list.xml
│ └── build.gradle.kts

├── feature/taskdetail/ # 任务详情 Feature
│ ├── src/main/
│ │ ├── TaskDetailFragment.kt
│ │ ├── TaskDetailViewModel.kt
│ │ └── res/layout/fragment_task_detail.xml
│ └── build.gradle.kts

├── feature/addtask/ # 添加/编辑任务 Feature
│ ├── src/main/
│ │ ├── AddTaskFragment.kt
│ │ ├── AddTaskViewModel.kt
│ │ └── res/layout/fragment_add_task.xml
│ └── build.gradle.kts

├── feature/settings/ # 设置 Feature
│ └── ...

├── core/data/ # 数据层实现
│ ├── src/main/
│ │ ├── repository/
│ │ │ ├── TaskRepositoryImpl.kt
│ │ │ └── UserPreferencesRepositoryImpl.kt
│ │ ├── local/
│ │ │ ├── AppDatabase.kt
│ │ │ ├── dao/TaskDao.kt
│ │ │ └── entity/TaskEntity.kt
│ │ ├── remote/
│ │ │ ├── TaskApiService.kt
│ │ │ └── dto/TaskDto.kt
│ │ ├── mapper/
│ │ │ └── TaskMapper.kt # Entity ↔ Domain Model 映射
│ │ └── di/
│ │ └── DataModule.kt
│ └── build.gradle.kts

├── core/domain/ # 领域层(纯 Kotlin 模块,无 Android 依赖)
│ ├── src/main/
│ │ ├── model/
│ │ │ ├── Task.kt
│ │ │ ├── Priority.kt
│ │ │ └── TaskStatus.kt
│ │ ├── repository/
│ │ │ ├── TaskRepository.kt # 接口
│ │ │ └── UserPreferencesRepository.kt
│ │ └── usecase/
│ │ ├── GetTasksUseCase.kt
│ │ ├── AddTaskUseCase.kt
│ │ ├── UpdateTaskUseCase.kt
│ │ ├── DeleteTaskUseCase.kt
│ │ ├── GetTaskDetailUseCase.kt
│ │ ├── SearchTasksUseCase.kt
│ │ └── ObserveTasksUseCase.kt
│ └── build.gradle.kts

├── core/model/ # 共享数据模型(纯 Kotlin)
│ ├── src/main/
│ │ └── Result.kt # sealed class Result<T>
│ └── build.gradle.kts

├── core/ui/ # UI 公共组件
│ ├── src/main/
│ │ ├── theme/
│ │ ├── component/
│ │ └── util/
│ └── build.gradle.kts

├── core/network/ # 网络层配置
│ └── build.gradle.kts

├── core/database/ # 数据库层配置
│ └── build.gradle.kts

└── core/testing/ # 共享测试工具
├── src/main/
│ ├── FakeTaskRepository.kt
│ ├── TestDispatchers.kt
│ └── MainCoroutineRule.kt
└── build.gradle.kts

2.2 模块依赖关系

:app
├── :feature:tasklist ──────┐
├── :feature:taskdetail ────┤
├── :feature:addtask ───────┤
└── :feature:settings ──────┤

┌──────────────────┘

:core:domain ←───── :core:model
△ (接口)
│ (实现)
:core:data ←─────── :core:database, :core:network


:core:ui (theme, components)

关键约束:

  • feature:* 模块只依赖 :core:domain:core:ui。它们看不到 Room Entity 或 Retrofit API。
  • :core:domain 是纯 Kotlin 模块,不依赖任何 Android SDK。依赖项只有 kotlin-stdlibkotlinx-coroutines-core
  • :core:data 依赖 :core:domain(实现其定义的接口)和 :core:database:core:network

2.3 settings.gradle.kts

// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "TasksApp"

include(":app")

include(":feature:tasklist")
include(":feature:taskdetail")
include(":feature:addtask")
include(":feature:settings")

include(":core:domain")
include(":core:data")
include(":core:model")
include(":core:ui")
include(":core:network")
include(":core:database")
include(":core:testing")

三、Domain 层实现

3.1 Domain Model

// core/domain/src/main/kotlin/com/example/tasks/domain/model/Task.kt
data class Task(
val id: Long = 0,
val title: String,
val description: String = "",
val priority: Priority = Priority.MEDIUM,
val status: TaskStatus = TaskStatus.TODO,
val dueDate: Long? = null, // Epoch millis
val createdAt: Long = System.currentTimeMillis(),
val modifiedAt: Long = System.currentTimeMillis()
)

enum class Priority { HIGH, MEDIUM, LOW }

enum class TaskStatus { TODO, IN_PROGRESS, DONE, CANCELLED }
// core/model/src/main/kotlin/com/example/tasks/model/Result.kt
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>()
object Loading : Result<Nothing>()
}

3.2 Repository 接口(依赖反转关键)

// core/domain/src/main/kotlin/com/example/tasks/domain/repository/TaskRepository.kt
interface TaskRepository {
fun observeTasks(filter: TaskStatus? = null): Flow<List<Task>>

fun observeTask(taskId: Long): Flow<Task?>

fun searchTasks(query: String): Flow<List<Task>>

suspend fun getTask(taskId: Long): Task?

suspend fun addTask(task: Task): Long // 返回新建 task 的 id

suspend fun updateTask(task: Task)

suspend fun deleteTask(taskId: Long)

suspend fun toggleTaskStatus(taskId: Long)
}

3.3 UseCases

// core/domain/src/main/kotlin/com/example/tasks/domain/usecase/GetTasksUseCase.kt
class GetTasksUseCase(
private val taskRepository: TaskRepository
) {
operator fun invoke(filter: TaskStatus? = null): Flow<List<Task>> {
return taskRepository.observeTasks(filter)
}
}

// core/domain/src/main/kotlin/com/example/tasks/domain/usecase/AddTaskUseCase.kt
class AddTaskUseCase(
private val taskRepository: TaskRepository
) {
suspend operator fun invoke(task: Task): Result<Long> {
return try {
require(task.title.isNotBlank()) { "任务标题不能为空" }
require(task.title.length <= 100) { "任务标题不能超过100个字符" }
val id = taskRepository.addTask(task)
Result.Success(id)
} catch (e: IllegalArgumentException) {
Result.Error(e, e.message)
} catch (e: Exception) {
Result.Error(e, "添加任务失败")
}
}
}

// core/domain/src/main/kotlin/com/example/tasks/domain/usecase/SearchTasksUseCase.kt
class SearchTasksUseCase(
private val taskRepository: TaskRepository
) {
// throttle: 搜索防抖
operator fun invoke(query: String): Flow<List<Task>> {
return taskRepository.searchTasks(query)
.debounce(300)
}
}

四、Data 层实现

4.1 Room Entity

// core/data/src/main/kotlin/com/example/tasks/data/local/entity/TaskEntity.kt
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val description: String = "",
val priority: String = "MEDIUM", // HIGH/MEDIUM/LOW
val status: String = "TODO", // TODO/IN_PROGRESS/DONE/CANCELLED
val dueDate: Long? = null,
val createdAt: Long,
val modifiedAt: Long
)

4.2 DAO

// core/data/src/main/kotlin/com/example/tasks/data/local/dao/TaskDao.kt
@Dao
interface TaskDao {

@Query("SELECT * FROM tasks ORDER BY priority DESC, modifiedAt DESC")
fun observeAllTasks(): Flow<List<TaskEntity>>

@Query("SELECT * FROM tasks WHERE status = :status ORDER BY modifiedAt DESC")
fun observeTasksByStatus(status: String): Flow<List<TaskEntity>>

@Query("SELECT * FROM tasks WHERE id = :taskId")
fun observeTask(taskId: Long): Flow<TaskEntity?>

@Query("SELECT * FROM tasks WHERE id = :taskId")
suspend fun getTask(taskId: Long): TaskEntity?

@Query("SELECT * FROM tasks WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%' ORDER BY modifiedAt DESC")
fun searchTasks(query: String): Flow<List<TaskEntity>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity): Long

@Update
suspend fun updateTask(task: TaskEntity)

@Query("DELETE FROM tasks WHERE id = :taskId")
suspend fun deleteTask(taskId: Long)

@Query("UPDATE tasks SET status = CASE WHEN status = 'DONE' THEN 'TODO' ELSE 'DONE' END WHERE id = :taskId")
suspend fun toggleTaskStatus(taskId: Long)
}

4.3 AppDatabase

// core/database/src/main/kotlin/com/example/tasks/database/AppDatabase.kt
@Database(
entities = [TaskEntity::class],
version = 1,
exportSchema = true // 生产项目导出 schema 用于 migration 测试
)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}

4.4 API Service

// core/data/src/main/kotlin/com/example/tasks/data/remote/TaskApiService.kt
interface TaskApiService {
@GET("tasks")
suspend fun getTasks(): Response<List<TaskDto>>

@GET("tasks/{id}")
suspend fun getTask(@Path("id") id: Long): Response<TaskDto>

@POST("tasks")
suspend fun createTask(@Body task: TaskDto): Response<TaskDto>

@PUT("tasks/{id}")
suspend fun updateTask(@Path("id") id: Long, @Body task: TaskDto): Response<TaskDto>

@DELETE("tasks/{id}")
suspend fun deleteTask(@Path("id") id: Long): Response<Unit>
}

4.5 Data Mapper

// core/data/src/main/kotlin/com/example/tasks/data/mapper/TaskMapper.kt
fun TaskEntity.toDomain(): Task = Task(
id = id,
title = title,
description = description,
priority = Priority.valueOf(priority),
status = TaskStatus.valueOf(status),
dueDate = dueDate,
createdAt = createdAt,
modifiedAt = modifiedAt
)

fun Task.toEntity(): TaskEntity = TaskEntity(
id = id,
title = title,
description = description,
priority = priority.name,
status = status.name,
dueDate = dueDate,
createdAt = createdAt,
modifiedAt = System.currentTimeMillis()
)

fun TaskDto.toEntity(): TaskEntity = TaskEntity(
id = id,
title = title,
description = description ?: "",
priority = priority ?: "MEDIUM",
status = status ?: "TODO",
dueDate = dueDate,
createdAt = createdAt ?: System.currentTimeMillis(),
modifiedAt = modifiedAt ?: System.currentTimeMillis()
)

4.6 Repository 实现

// core/data/src/main/kotlin/com/example/tasks/data/repository/TaskRepositoryImpl.kt
class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
private val apiService: TaskApiService,
private val dispatchers: AppDispatchers
) : TaskRepository {

override fun observeTasks(filter: TaskStatus?): Flow<List<Task>> {
return if (filter != null) {
taskDao.observeTasksByStatus(filter.name)
} else {
taskDao.observeAllTasks()
}.map { entities -> entities.map { it.toDomain() } }
}

override fun observeTask(taskId: Long): Flow<Task?> {
return taskDao.observeTask(taskId)
.map { entity -> entity?.toDomain() }
}

override fun searchTasks(query: String): Flow<List<Task>> {
return taskDao.searchTasks(query)
.map { entities -> entities.map { it.toDomain() } }
}

override suspend fun getTask(taskId: Long): Task? {
return taskDao.getTask(taskId)?.toDomain()
}

override suspend fun addTask(task: Task): Long {
val entity = task.toEntity()
val localId = taskDao.insertTask(entity)

// 异步同步到远程(fire and forget)
withContext(dispatchers.io) {
try {
apiService.createTask(entity.toDto())
} catch (e: Exception) {
// 记录同步失败,稍后通过 WorkManager 重试
// 本地数据已经写入,离线可用
}
}
return localId
}

override suspend fun updateTask(task: Task) {
taskDao.updateTask(task.toEntity())
// 同上:异步同步到远程
}

override suspend fun deleteTask(taskId: Long) {
taskDao.deleteTask(taskId)
// 同上
}

override suspend fun toggleTaskStatus(taskId: Long) {
taskDao.toggleTaskStatus(taskId)
}
}

4.7 AppDispatchers(可注入的调度器)

// core/data/src/main/kotlin/com/example/tasks/data/util/AppDispatchers.kt
data class AppDispatchers(
val main: CoroutineDispatcher = Dispatchers.Main,
val io: CoroutineDispatcher = Dispatchers.IO,
val default: CoroutineDispatcher = Dispatchers.Default
)

可注入的调度器使得测试中可以替换为 TestDispatcher

五、Hilt 依赖注入

5.1 Application 配置

// app/src/main/kotlin/com/example/tasks/TasksApplication.kt
@HiltAndroidApp
class TasksApplication : Application()

// AndroidManifest.xml
// <application android:name=".TasksApplication" ...>

5.2 Data 层 DI Module

// core/data/src/main/kotlin/com/example/tasks/data/di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DataModule {

@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"tasks.db"
)
.fallbackToDestructiveMigration() // 生产环境应实现 Migration
.build()
}

@Provides
fun provideTaskDao(db: AppDatabase): TaskDao = db.taskDao()

@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG)
HttpLoggingInterceptor.Level.BODY
else
HttpLoggingInterceptor.Level.NONE
}
)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}

@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(
Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
))
.build()
}

@Provides
@Singleton
fun provideTaskApiService(retrofit: Retrofit): TaskApiService {
return retrofit.create(TaskApiService::class.java)
}

@Provides
@Singleton
fun provideAppDispatchers(): AppDispatchers = AppDispatchers()

@Provides
@Singleton
fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository = impl
}

5.3 Domain 层 DI Module

// core/domain/src/main/kotlin/com/example/tasks/domain/di/DomainModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DomainModule {

@Provides
@Singleton
fun provideGetTasksUseCase(repository: TaskRepository): GetTasksUseCase {
return GetTasksUseCase(repository)
}

@Provides
@Singleton
fun provideAddTaskUseCase(repository: TaskRepository): AddTaskUseCase {
return AddTaskUseCase(repository)
}

@Provides
@Singleton
fun provideUpdateTaskUseCase(repository: TaskRepository): UpdateTaskUseCase {
return UpdateTaskUseCase(repository)
}

@Provides
@Singleton
fun provideDeleteTaskUseCase(repository: TaskRepository): DeleteTaskUseCase {
return DeleteTaskUseCase(repository)
}

@Provides
@Singleton
fun provideSearchTasksUseCase(repository: TaskRepository): SearchTasksUseCase {
return SearchTasksUseCase(repository)
}
}

六、UI 层实现

6.1 MainActivity(Single Activity)

// app/src/main/kotlin/com/example/tasks/MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding
private lateinit var navController: NavController

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController

// BottomNavigation 与 Navigation 集成
binding.bottomNav.setupWithNavController(navController)

// AppBarConfiguration:指定哪些是顶层 destination
val appBarConfiguration = AppBarConfiguration(
setOf(
R.id.taskListFragment,
R.id.statisticsFragment,
R.id.settingsFragment
)
)

setSupportActionBar(binding.toolbar)
NavigationUI.setupActionBarWithNavController(
this, navController, appBarConfiguration
)
}

override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
}

6.2 TaskListViewModel

// feature/tasklist/src/main/kotlin/.../TaskListViewModel.kt
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase,
private val searchTasksUseCase: SearchTasksUseCase
) : ViewModel() {

// ===== UI State =====
private val _filter = MutableStateFlow<TaskStatus?>(null)
val filter: StateFlow<TaskStatus?> = _filter.asStateFlow()

private val _searchQuery = MutableStateFlow("")

// 搜索状态组合:filter + searchQuery
val tasks: StateFlow<PagingData<Task>> = combine(
_filter, _searchQuery
) { filter, query ->
Pair(filter, query)
}.flatMapLatest { (filter, query) ->
if (query.isBlank()) {
getTasksUseCase(filter)
} else {
searchTasksUseCase(query)
}
}.cachedIn(viewModelScope).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PagingData.empty()
)

// ===== UI Events =====
private val _uiEvent = Channel<UiEvent>(Channel.BUFFERED)
val uiEvent: Flow<UiEvent> = _uiEvent.receiveAsFlow()

// ===== Actions =====
fun onFilterChanged(status: TaskStatus?) {
_filter.value = status
}

fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}

fun onTaskClicked(taskId: Long) {
viewModelScope.launch {
_uiEvent.send(UiEvent.NavigateToDetail(taskId))
}
}

fun onAddTaskClicked() {
viewModelScope.launch {
_uiEvent.send(UiEvent.NavigateToAddTask)
}
}

fun onDeleteTask(taskId: Long) {
viewModelScope.launch {
val result = deleteTaskUseCase(taskId)
if (result is Result.Error) {
_uiEvent.send(UiEvent.ShowError(result.message ?: "删除失败"))
}
}
}

sealed class UiEvent {
data class NavigateToDetail(val taskId: Long) : UiEvent()
object NavigateToAddTask : UiEvent()
data class ShowError(val message: String) : UiEvent()
}
}

6.3 TaskListFragment

// feature/tasklist/src/main/kotlin/.../TaskListFragment.kt
@AndroidEntryPoint
class TaskListFragment : Fragment() {
private val viewModel: TaskListViewModel by viewModels()
private lateinit var binding: FragmentTaskListBinding
private lateinit var adapter: TaskListAdapter

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentTaskListBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

setupRecyclerView()
observeState()
observeEvents()

binding.fabAddTask.setOnClickListener {
viewModel.onAddTaskClicked()
}

binding.etSearch.doAfterTextChanged { text ->
viewModel.onSearchQueryChanged(text.toString())
}
}

private fun setupRecyclerView() {
adapter = TaskListAdapter(
onItemClick = { taskId -> viewModel.onTaskClicked(taskId) },
onDeleteClick = { taskId -> viewModel.onDeleteTask(taskId) }
)
binding.recyclerView.adapter = adapter
}

private fun observeState() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.tasks.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}

// 空状态
viewLifecycleOwner.lifecycleScope.launch {
adapter.loadStateFlow.collectLatest { loadStates ->
val isEmpty = loadStates.refresh is LoadState.NotLoading
&& adapter.itemCount == 0
binding.tvEmpty.isVisible = isEmpty
}
}
}

private fun observeEvents() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiEvent.collectLatest { event ->
when (event) {
is TaskListViewModel.UiEvent.NavigateToDetail -> {
findNavController().navigate(
TaskListFragmentDirections
.actionTaskListToDetail(event.taskId)
)
}
is TaskListViewModel.UiEvent.NavigateToAddTask -> {
findNavController().navigate(
TaskListFragmentDirections.actionTaskListToAddTask()
)
}
is TaskListViewModel.UiEvent.ShowError -> {
Snackbar.make(binding.root, event.message, Snackbar.LENGTH_SHORT)
.show()
}
}
}
}
}
}

6.4 AddTaskViewModel

// feature/addtask/src/main/kotlin/.../AddTaskViewModel.kt
@HiltViewModel
class AddTaskViewModel @Inject constructor(
private val addTaskUseCase: AddTaskUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {

// ===== UI State =====
data class AddTaskUiState(
val title: String = "",
val description: String = "",
val priority: Priority = Priority.MEDIUM,
val dueDate: Long? = null,
val isSubmitting: Boolean = false,
val titleError: String? = null // 表单验证错误
)

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

// ===== Actions =====
fun onTitleChanged(title: String) {
_uiState.update { it.copy(title = title, titleError = null) }
}

fun onDescriptionChanged(description: String) {
_uiState.update { it.copy(description = description) }
}

fun onPriorityChanged(priority: Priority) {
_uiState.update { it.copy(priority = priority) }
}

fun onDueDateChanged(dueDate: Long?) {
_uiState.update { it.copy(dueDate = dueDate) }
}

fun onSubmit() {
val currentState = _uiState.value

// 客户端验证
if (currentState.title.isBlank()) {
_uiState.update { it.copy(titleError = "标题不能为空") }
return
}

viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true) }

val task = Task(
title = currentState.title,
description = currentState.description,
priority = currentState.priority,
dueDate = currentState.dueDate
)

when (val result = addTaskUseCase(task)) {
is Result.Success -> {
_uiEvent.send(UiEvent.TaskAddedSuccessfully)
}
is Result.Error -> {
_uiState.update {
it.copy(
isSubmitting = false,
titleError = result.message
)
}
}
}
}
}

private val _uiEvent = Channel<UiEvent>(Channel.BUFFERED)
val uiEvent: Flow<UiEvent> = _uiEvent.receiveAsFlow()

sealed class UiEvent {
object TaskAddedSuccessfully : UiEvent()
}
}

七、DataStore 实现(用户偏好)

7.1 Preferences DataStore

// core/data/src/main/kotlin/com/example/tasks/data/local/UserPreferencesDataStore.kt

// 定义键
object PreferenceKeys {
val SORT_ORDER = stringPreferencesKey("sort_order") // "priority" / "date" / "alphabetical"
val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
val THEME_MODE = stringPreferencesKey("theme_mode") // "system" / "light" / "dark"
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
}

class UserPreferencesDataStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val Context.dataStore by preferencesDataStore("user_preferences")

// 读取单个偏好
val sortOrder: Flow<String> = context.dataStore.data.map { preferences ->
preferences[PreferenceKeys.SORT_ORDER] ?: "date"
}

val showCompleted: Flow<Boolean> = context.dataStore.data.map { preferences ->
preferences[PreferenceKeys.SHOW_COMPLETED] ?: false
}

val themeMode: Flow<String> = context.dataStore.data.map { preferences ->
preferences[PreferenceKeys.THEME_MODE] ?: "system"
}

// 写入偏好
suspend fun setSortOrder(order: String) {
context.dataStore.edit { preferences ->
preferences[PreferenceKeys.SORT_ORDER] = order
}
}

suspend fun setShowCompleted(show: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferenceKeys.SHOW_COMPLETED] = show
}
}

suspend fun setThemeMode(mode: String) {
context.dataStore.edit { preferences ->
preferences[PreferenceKeys.THEME_MODE] = mode
}
}
}

7.2 Proto DataStore 对比

当偏好数据结构复杂时,使用 Proto DataStore 替代 Preferences DataStore:

// app/src/main/proto/user_preferences.proto
syntax = "proto3";

option java_package = "com.example.tasks";
option java_multiple_files = true;

message UserPreferences {
SortOrder sort_order = 1;
bool show_completed = 2;
ThemeMode theme_mode = 3;
int64 last_sync_time = 4;
}

enum SortOrder {
BY_DATE = 0;
BY_PRIORITY = 1;
BY_ALPHABETICAL = 2;
}

enum ThemeMode {
SYSTEM_DEFAULT = 0;
LIGHT = 1;
DARK = 2;
}

Proto DataStore 的优势:类型安全(编译期)、Schema 演进(protobuf 向后兼容)、无运行时反射(Preferences DataStore 有少量反射)。

八、后台同步:WorkManager

// core/data/src/main/kotlin/com/example/tasks/data/sync/SyncWorker.kt
@HiltWorker
class TaskSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val taskDao: TaskDao,
private val apiService: TaskApiService
) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result {
return try {
// 1. 上传本地未同步的修改
syncLocalChangesToRemote()
// 2. 拉取远程最新的变更
syncRemoteChangesToLocal()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}

private suspend fun syncLocalChangesToRemote() {
// 从本地 pending_sync 表拉取待同步数据
// 逐个尝试上传,成功则标记为已同步
}

private suspend fun syncRemoteChangesToLocal() {
// 从 API 拉取自上次同步时间之后的变化
// 写入本地数据库(Room 的 Flow 自动推送更新到 UI)
}
}

// 配置同步任务
@Module
@InstallIn(SingletonComponent::class)
object SyncModule {
@Provides
@Singleton
fun provideSyncConstraints() = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
}

九、测试策略

9.1 Unit Test: UseCases

// core/domain/src/test/kotlin/.../AddTaskUseCaseTest.kt
class AddTaskUseCaseTest {

private lateinit var fakeRepository: FakeTaskRepository
private lateinit var addTaskUseCase: AddTaskUseCase

@Before
fun setup() {
fakeRepository = FakeTaskRepository()
addTaskUseCase = AddTaskUseCase(fakeRepository)
}

@Test
fun `addTask with valid title returns Success`() = runTest {
val task = Task(title = "Buy groceries", priority = Priority.HIGH)
val result = addTaskUseCase(task)

assertThat(result, instanceOf(Result.Success::class.java))
val localTask = fakeRepository.getTask((result as Result.Success).data)
assertThat(localTask?.title, `is`("Buy groceries"))
}

@Test
fun `addTask with blank title returns Error`() = runTest {
val task = Task(title = " ", priority = Priority.LOW)
val result = addTaskUseCase(task)

assertThat(result, instanceOf(Result.Error::class.java))
}

@Test
fun `addTask with title exceeding 100 chars returns Error`() = runTest {
val task = Task(title = "a".repeat(101))
val result = addTaskUseCase(task)

assertThat(result, instanceOf(Result.Error::class.java))
}
}

9.2 Unit Test: ViewModel with Fake Repository

// feature/tasklist/src/test/kotlin/.../TaskListViewModelTest.kt
@OptIn(ExperimentalCoroutinesApi::class)
class TaskListViewModelTest {

private lateinit var fakeRepository: FakeTaskRepository
private lateinit var viewModel: TaskListViewModel

@Before
fun setup() {
fakeRepository = FakeTaskRepository()
viewModel = TaskListViewModel(
getTasksUseCase = GetTasksUseCase(fakeRepository),
deleteTaskUseCase = DeleteTaskUseCase(fakeRepository),
searchTasksUseCase = SearchTasksUseCase(fakeRepository)
)
}

@Test
fun `initial state shows all tasks`() = runTest {
fakeRepository.addTask(Task(1, "Task 1"))
fakeRepository.addTask(Task(2, "Task 2"))

val tasks = viewModel.tasks.first()

assertThat(tasks.size, `is`(2))
}

@Test
fun `filter changes update task list`() = runTest {
fakeRepository.addTask(Task(1, "Task 1", status = TaskStatus.TODO))
fakeRepository.addTask(Task(2, "Task 2", status = TaskStatus.DONE))

viewModel.onFilterChanged(TaskStatus.DONE)

val tasks = viewModel.tasks.first()
assertThat(tasks.size, `is`(1))
assertThat(tasks[0].status, `is`(TaskStatus.DONE))
}
}

9.3 Integration Test: Room DAO

// core/data/src/androidTest/kotlin/.../TaskDaoTest.kt
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {

private lateinit var database: AppDatabase
private lateinit var taskDao: TaskDao

@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
taskDao = database.taskDao()
}

@After
fun tearDown() {
database.close()
}

@Test
fun insertAndRetrieveTask() = runTest {
val task = TaskEntity(
title = "Test Task",
priority = "HIGH",
status = "TODO",
createdAt = System.currentTimeMillis(),
modifiedAt = System.currentTimeMillis()
)
val id = taskDao.insertTask(task)

val retrieved = taskDao.getTask(id)
assertThat(retrieved?.title, `is`("Test Task"))
}

@Test
fun observeTasksByStatus_returnsFilteredResults() = runTest {
taskDao.insertTask(createTask("Task 1", status = "TODO"))
taskDao.insertTask(createTask("Task 2", status = "DONE"))
taskDao.insertTask(createTask("Task 3", status = "TODO"))

val tasks = taskDao.observeTasksByStatus("TODO").first()

assertThat(tasks.size, `is`(2))
}
}

9.4 FakeTaskRepository(测试工具)

// core/testing/src/main/kotlin/.../FakeTaskRepository.kt
class FakeTaskRepository : TaskRepository {
private val tasks = ConcurrentHashMap<Long, Task>()
private val tasksFlow = MutableStateFlow<List<Task>>(emptyList())

override fun observeTasks(filter: TaskStatus?): Flow<List<Task>> {
return tasksFlow.map { list ->
if (filter != null) list.filter { it.status == filter }
else list
}
}

override suspend fun addTask(task: Task): Long {
val id = (tasks.keys.maxOrNull() ?: 0) + 1
val newTask = task.copy(id = id)
tasks[id] = newTask
tasksFlow.value = tasks.values.toList()
return id
}

override suspend fun deleteTask(taskId: Long) {
tasks.remove(taskId)
tasksFlow.value = tasks.values.toList()
}

// ... 其他方法
}

十、CI Pipeline 配置

10.1 GitHub Actions

# .github/workflows/android-ci.yml
name: Android CI

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3

- name: Run Unit Tests
run: ./gradlew test --parallel

- name: Run Lint
run: ./gradlew lint

- name: Run Detekt (Kotlin static analysis)
run: ./gradlew detekt

- name: Assemble Debug
run: ./gradlew assembleDebug

- name: Run Instrumentation Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
script: ./gradlew connectedCheck

10.2 Gradle Properties for CI

# gradle.properties (for CI)
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError

# 加速构建但不影响 release 质量
android.enableR8.fullMode=false

面试常考问题

Q1:为什么推荐 Single Activity 架构?

  • 减少 Activity 间的 Binder IPC 通信开销(每个 Activity 运行在独立进程中时)
  • Navigation 组件接管 Fragment/Compose 的跳转栈管理,避免多 Activity 的复杂生命周期协调
  • Jetpack Compose 设计上就以 Single Activity 为模型(setContent 调用一次)
  • 一致的 SharedElement Transition 体验(Activity 间的转场动画在 Fragment 间模拟困难)
  • 简化依赖注入:AndroidEntryPoint 只在一个 Activity 上配置

Q2:Repository 是否必须?能否让 ViewModel 直接调用 ApiService?

Repository 是推荐模式但不强制。它的价值在于:

  1. 统一接口:ViewModel 不关心数据来自本地还是远程
  2. 可替换性:测试时可注入 FakeRepository,不依赖网络/数据库
  3. 缓存策略集中管理:Repository 决定了”何时读缓存、何时刷新网络”
  4. 单一职责:ViewModel 只管 UI 状态,Repository 管数据获取

但小项目(如单页面工具类应用)中,ViewModel 直接调用 DAO/API 也是可接受的取舍。

Q3:Domain 层是否必须?哪些场景可以省略?

Google 官方指南将 Domain 层标记为 optional。以下场景建议有 Domain 层:

  • 多个 ViewModel 共享相同的业务逻辑(避免代码重复)
  • 有复杂的业务规则需要单元测试(UseCase 是纯 Kotlin 函数,易测试)
  • 数据模型需要从多个 Repository 聚合

以下场景可以省略:

  • 简单的 CRUD 应用(数据直接传递,无明显业务逻辑)
  • 单一数据源(如纯网络应用或纯本地应用)
  • 团队规模小、迭代快(减少间接层,加速开发)

Q4:DataStore 与 SharedPreferences 选择?

DataStore 是 SharedPreferences 的现代替代品:

  • 异步 API:SharedPreferences 的 apply() 也是异步,但 commit() 是同步阻塞的;DataStore 全面使用 Kotlin Coroutines Flow
  • 类型安全:Proto DataStore 通过 Protobuf 保证类型安全,编译期发现类型错误;SharedPreferences 运行时 ClassCastException
  • 数据迁移:DataStore 支持从 SharedPreferences 自动迁移
  • 错误处理:DataStore 在读取失败时抛出 IOException(而非静默返回默认值),使错误可见
  • 不支持:DataStore 不支持 getAll() 操作(一次读取所有键值对)

Q5:如何保证 Paging 3 RemoteMediator 不重复插入数据?

  1. DAO 中使用 @Insert(onConflict = OnConflictStrategy.REPLACE) 基于主键去重
  2. REFRESH LoadType 时调用 clearAll() + insertAll() 全量覆盖
  3. 维护 RemoteKey 表精确跟踪每个 item 的页码,避免重复请求同一页
  4. initialize() 方法中检查缓存新鲜度,避免不必要的网络刷新
  5. 使用 db.withTransaction {} 保证 delete + insert 原子性

Q6:Hilt 与 Dagger 2 的核心区别?

Hilt 是 Dagger 2 的上层封装,核心改进:

  • 不再需要手写 Component 接口和 @Subcomponent 声明
  • 预定义的组件层次结构(SingletonComponent → ViewModelComponent → FragmentComponent
  • @AndroidEntryPoint 自动注入 Activity/Fragment,无需每个类显式调用 AndroidInjection.inject(this)
  • @HiltViewModel 将 ViewModel 与注入框架集成,无需自定义 ViewModelProvider.Factory
  • @HiltWorker 自动注入 Worker 的依赖
  • 编译期检查和更清晰的错误信息

Hilt 底层仍然是 Dagger 2 的注解处理器,生成的代码与 Dagger 兼容。

打赏
  • 微信
  • 支付宝

评论