本文以设计一个”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-stdlib 和 kotlinx-coroutines-core。
:core:data 依赖 :core:domain(实现其定义的接口)和 :core:database、:core:network。
2.3 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 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 , val createdAt: Long = System.currentTimeMillis(), val modifiedAt: Long = System.currentTimeMillis() ) enum class Priority { HIGH, MEDIUM, LOW }enum class TaskStatus { TODO, IN_PROGRESS, DONE, CANCELLED }
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 接口(依赖反转关键) 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 suspend fun updateTask (task: Task ) suspend fun deleteTask (taskId: Long ) suspend fun toggleTaskStatus (taskId: Long ) }
3.3 UseCases class GetTasksUseCase ( private val taskRepository: TaskRepository ) { operator fun invoke (filter: TaskStatus ? = null ) : Flow<List<Task>> { return taskRepository.observeTasks(filter) } } 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, "添加任务失败" ) } } } class SearchTasksUseCase ( private val taskRepository: TaskRepository ) { operator fun invoke (query: String ) : Flow<List<Task>> { return taskRepository.searchTasks(query) .debounce(300 ) } }
四、Data 层实现 4.1 Room Entity @Entity(tableName = "tasks" ) data class TaskEntity ( @PrimaryKey(autoGenerate = true) val id: Long = 0 , val title: String, val description: String = "" , val priority: String = "MEDIUM" , val status: String = "TODO" , val dueDate: Long ? = null , val createdAt: Long , val modifiedAt: Long )
4.2 DAO @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 @Database( entities = [TaskEntity::class], version = 1, exportSchema = true // 生产项目导出 schema 用于 migration 测试 ) abstract class AppDatabase : RoomDatabase () { abstract fun taskDao () : TaskDao }
4.4 API Service 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 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 实现 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) withContext(dispatchers.io) { try { apiService.createTask(entity.toDto()) } catch (e: Exception) { } } 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(可注入的调度器) data class AppDispatchers ( val main: CoroutineDispatcher = Dispatchers.Main, val io: CoroutineDispatcher = Dispatchers.IO, val default: CoroutineDispatcher = Dispatchers.Default )
可注入的调度器使得测试中可以替换为 TestDispatcher。
五、Hilt 依赖注入 5.1 Application 配置 @HiltAndroidApp class TasksApplication : Application ()
5.2 Data 层 DI Module @Module @InstallIn(SingletonComponent::class) object DataModule { @Provides @Singleton fun provideAppDatabase (@ApplicationContext context: Context ) : AppDatabase { return Room.databaseBuilder( context, AppDatabase::class .java, "tasks.db" ) .fallbackToDestructiveMigration() .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 @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) @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 binding.bottomNav.setupWithNavController(navController) 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 @HiltViewModel class TaskListViewModel @Inject constructor ( private val getTasksUseCase: GetTasksUseCase, private val deleteTaskUseCase: DeleteTaskUseCase, private val searchTasksUseCase: SearchTasksUseCase ) : ViewModel() { private val _filter = MutableStateFlow<TaskStatus?>(null ) val filter: StateFlow<TaskStatus?> = _filter.asStateFlow() private val _searchQuery = MutableStateFlow("" ) 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() ) private val _uiEvent = Channel<UiEvent>(Channel.BUFFERED) val uiEvent: Flow<UiEvent> = _uiEvent.receiveAsFlow() 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 @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 @HiltViewModel class AddTaskViewModel @Inject constructor ( private val addTaskUseCase: AddTaskUseCase, savedStateHandle: SavedStateHandle ) : ViewModel() { 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() 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 object PreferenceKeys { val SORT_ORDER = stringPreferencesKey("sort_order" ) val SHOW_COMPLETED = booleanPreferencesKey("show_completed" ) val THEME_MODE = stringPreferencesKey("theme_mode" ) 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:
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 @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 { syncLocalChangesToRemote() syncRemoteChangesToLocal() Result.success() } catch (e: Exception) { if (runAttemptCount < 3 ) Result.retry() else Result.failure() } } private suspend fun syncLocalChangesToRemote () { } private suspend fun syncRemoteChangesToLocal () { } } @Module @InstallIn(SingletonComponent::class) object SyncModule { @Provides @Singleton fun provideSyncConstraints () = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true ) .build() }
九、测试策略 9.1 Unit Test: UseCases 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 @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 @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(测试工具) 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 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 org.gradle.daemon =true org.gradle.parallel =true org.gradle.caching =true org.gradle.configuration-cache =true org.gradle.jvmargs =-Xmx4g -XX:+HeapDumpOnOutOfMemoryError 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 是推荐模式但不强制。它的价值在于:
统一接口:ViewModel 不关心数据来自本地还是远程
可替换性:测试时可注入 FakeRepository,不依赖网络/数据库
缓存策略集中管理:Repository 决定了”何时读缓存、何时刷新网络”
单一职责: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 不重复插入数据?
DAO 中使用 @Insert(onConflict = OnConflictStrategy.REPLACE) 基于主键去重
在 REFRESH LoadType 时调用 clearAll() + insertAll() 全量覆盖
维护 RemoteKey 表精确跟踪每个 item 的页码,避免重复请求同一页
initialize() 方法中检查缓存新鲜度,避免不必要的网络刷新
使用 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 兼容。