android - 架构:MVVM + Clean Architecture

我将为您展示一个完整的 MVVM + Clean Architecture 的 Android 项目代码结构。这是一个简单的用户列表应用示例。

## 项目结构

```

app/

├── data/

│  ├── local/

│  │  ├── dao/

│  │  ├── database/

│  │  └── entity/

│  ├── remote/

│  │  ├── api/

│  │  └── dto/

│  ├── repository/

│  └── mapper/

├── domain/

│  ├── model/

│  ├── repository/

│  └── usecase/

├── presentation/

│  ├── ui/

│  │  ├── users/

│  │  └── userdetail/

│  ├── viewmodel/

│  └── utils/

└── di/

```

## 1. 依赖配置 (build.gradle.kts)

```kotlin

// app/build.gradle.kts

dependencies {

    // Core

    implementation("androidx.core:core-ktx:1.12.0")

    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")

    implementation("androidx.activity:activity-compose:1.8.0")


    // Compose

    implementation("androidx.compose.ui:ui:1.5.4")

    implementation("androidx.compose.ui:ui-tooling-preview:1.5.4")

    implementation("androidx.compose.material3:material3:1.1.2")


    // ViewModel

    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")

    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")


    // Hilt

    implementation("com.google.dagger:hilt-android:2.48")

    kapt("com.google.dagger:hilt-compiler:2.48")

    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")


    // Coroutines

    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")


    // Retrofit

    implementation("com.squareup.retrofit2:retrofit:2.9.0")

    implementation("com.squareup.retrofit2:converter-gson:2.9.0")

    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")


    // Room

    implementation("androidx.room:room-runtime:2.6.0")

    implementation("androidx.room:room-ktx:2.6.0")

    kapt("androidx.room:room-compiler:2.6.0")


    // Navigation

    implementation("androidx.navigation:navigation-compose:2.7.5")

}

```

## 2. Domain Layer (核心业务逻辑)

### Domain Model

```kotlin

// domain/model/User.kt

data class User(

    val id: Int,

    val name: String,

    val email: String,

    val avatar: String,

    val createdAt: String

)

```

### Repository Interface

```kotlin

// domain/repository/UserRepository.kt

interface UserRepository {

    suspend fun getUsers(): Result<List<User>>

    suspend fun getUserById(id: Int): Result<User>

    suspend fun refreshUsers(): Result<Unit>

}

```

### Use Cases

```kotlin

// domain/usecase/GetUsersUseCase.kt

class GetUsersUseCase @Inject constructor(

    private val userRepository: UserRepository

) {

    suspend operator fun invoke(): Result<List<User>> {

        return userRepository.getUsers()

    }

}

// domain/usecase/GetUserDetailUseCase.kt

class GetUserDetailUseCase @Inject constructor(

    private val userRepository: UserRepository

) {

    suspend operator fun invoke(userId: Int): Result<User> {

        return userRepository.getUserById(userId)

    }

}

// domain/usecase/RefreshUsersUseCase.kt

class RefreshUsersUseCase @Inject constructor(

    private val userRepository: UserRepository

) {

    suspend operator fun invoke(): Result<Unit> {

        return userRepository.refreshUsers()

    }

}

```

## 3. Data Layer (数据处理)

### Remote Data Source

```kotlin

// data/remote/api/ApiService.kt

interface ApiService {

    @GET("users")

    suspend fun getUsers(): List<UserDto>


    @GET("users/{id}")

    suspend fun getUserById(@Path("id") id: Int): UserDto

}

// data/remote/dto/UserDto.kt

data class UserDto(

    @SerializedName("id")

    val id: Int,

    @SerializedName("name")

    val name: String,

    @SerializedName("email")

    val email: String,

    @SerializedName("avatar")

    val avatar: String,

    @SerializedName("created_at")

    val createdAt: String

)

```

### Local Data Source

```kotlin

// data/local/entity/UserEntity.kt

@Entity(tableName = "users")

data class UserEntity(

    @PrimaryKey

    val id: Int,

    val name: String,

    val email: String,

    val avatar: String,

    val createdAt: String

)

// data/local/dao/UserDao.kt

@Dao

interface UserDao {

    @Query("SELECT * FROM users")

    suspend fun getAllUsers(): List<UserEntity>


    @Query("SELECT * FROM users WHERE id = :id")

    suspend fun getUserById(id: Int): UserEntity?


    @Insert(onConflict = OnConflictStrategy.REPLACE)

    suspend fun insertUsers(users: List<UserEntity>)


    @Query("DELETE FROM users")

    suspend fun deleteAllUsers()

}

// data/local/database/AppDatabase.kt

@Database(

    entities = [UserEntity::class],

    version = 1,

    exportSchema = false

)

@TypeConverters(Converters::class)

abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

}

```

### Mapper

```kotlin

// data/mapper/UserMapper.kt

fun UserDto.toEntity(): UserEntity {

    return UserEntity(

        id = id,

        name = name,

        email = email,

        avatar = avatar,

        createdAt = createdAt

    )

}

fun UserEntity.toDomain(): User {

    return User(

        id = id,

        name = name,

        email = email,

        avatar = avatar,

        createdAt = createdAt

    )

}

fun UserDto.toDomain(): User {

    return User(

        id = id,

        name = name,

        email = email,

        avatar = avatar,

        createdAt = createdAt

    )

}

```

### Repository Implementation

```kotlin

// data/repository/UserRepositoryImpl.kt

class UserRepositoryImpl @Inject constructor(

    private val apiService: ApiService,

    private val userDao: UserDao,

    private val dispatcher: CoroutineDispatcher = Dispatchers.IO

) : UserRepository {


    override suspend fun getUsers(): Result<List<User>> = withContext(dispatcher) {

        try {

            // 首先尝试从本地数据库获取

            val localUsers = userDao.getAllUsers()

            if (localUsers.isNotEmpty()) {

                Result.success(localUsers.map { it.toDomain() })

            } else {

                // 如果本地没有数据,从网络获取

                val remoteUsers = apiService.getUsers()

                userDao.insertUsers(remoteUsers.map { it.toEntity() })

                Result.success(remoteUsers.map { it.toDomain() })

            }

        } catch (e: Exception) {

            Result.failure(e)

        }

    }


    override suspend fun getUserById(id: Int): Result<User> = withContext(dispatcher) {

        try {

            // 先从本地获取

            val localUser = userDao.getUserById(id)

            if (localUser != null) {

                Result.success(localUser.toDomain())

            } else {

                // 本地没有则从网络获取

                val remoteUser = apiService.getUserById(id)

                userDao.insertUsers(listOf(remoteUser.toEntity()))

                Result.success(remoteUser.toDomain())

            }

        } catch (e: Exception) {

            Result.failure(e)

        }

    }


    override suspend fun refreshUsers(): Result<Unit> = withContext(dispatcher) {

        try {

            val remoteUsers = apiService.getUsers()

            userDao.deleteAllUsers()

            userDao.insertUsers(remoteUsers.map { it.toEntity() })

            Result.success(Unit)

        } catch (e: Exception) {

            Result.failure(e)

        }

    }

}

```

## 4. Presentation Layer (UI层)

### ViewModels

```kotlin

// presentation/viewmodel/UsersViewModel.kt

@HiltViewModel

class UsersViewModel @Inject constructor(

    private val getUsersUseCase: GetUsersUseCase,

    private val refreshUsersUseCase: RefreshUsersUseCase

) : ViewModel() {


    private val _uiState = MutableStateFlow(UsersUiState())

    val uiState: StateFlow<UsersUiState> = _uiState.asStateFlow()


    init {

        loadUsers()

    }


    fun loadUsers() {

        viewModelScope.launch {

            _uiState.update { it.copy(isLoading = true) }


            getUsersUseCase().fold(

                onSuccess = { users ->

                    _uiState.update {

                        it.copy(

                            users = users,

                            isLoading = false,

                            error = null

                        )

                    }

                },

                onFailure = { exception ->

                    _uiState.update {

                        it.copy(

                            isLoading = false,

                            error = exception.message

                        )

                    }

                }

            )

        }

    }


    fun refreshUsers() {

        viewModelScope.launch {

            _uiState.update { it.copy(isRefreshing = true) }


            refreshUsersUseCase().fold(

                onSuccess = {

                    loadUsers()

                    _uiState.update { it.copy(isRefreshing = false) }

                },

                onFailure = { exception ->

                    _uiState.update {

                        it.copy(

                            isRefreshing = false,

                            error = exception.message

                        )

                    }

                }

            )

        }

    }

}

data class UsersUiState(

    val users: List<User> = emptyList(),

    val isLoading: Boolean = false,

    val isRefreshing: Boolean = false,

    val error: String? = null

)

// presentation/viewmodel/UserDetailViewModel.kt

@HiltViewModel

class UserDetailViewModel @Inject constructor(

    private val getUserDetailUseCase: GetUserDetailUseCase,

    savedStateHandle: SavedStateHandle

) : ViewModel() {


    private val userId: Int = savedStateHandle.get<Int>("userId") ?: 0


    private val _uiState = MutableStateFlow(UserDetailUiState())

    val uiState: StateFlow<UserDetailUiState> = _uiState.asStateFlow()


    init {

        loadUserDetail()

    }


    private fun loadUserDetail() {

        viewModelScope.launch {

            _uiState.update { it.copy(isLoading = true) }


            getUserDetailUseCase(userId).fold(

                onSuccess = { user ->

                    _uiState.update {

                        it.copy(

                            user = user,

                            isLoading = false,

                            error = null

                        )

                    }

                },

                onFailure = { exception ->

                    _uiState.update {

                        it.copy(

                            isLoading = false,

                            error = exception.message

                        )

                    }

                }

            )

        }

    }

}

data class UserDetailUiState(

    val user: User? = null,

    val isLoading: Boolean = false,

    val error: String? = null

)

```

### UI Screens

```kotlin

// presentation/ui/users/UsersScreen.kt

@Composable

fun UsersScreen(

    onNavigateToDetail: (Int) -> Unit,

    viewModel: UsersViewModel = hiltViewModel()

) {

    val uiState by viewModel.uiState.collectAsState()


    Scaffold(

        topBar = {

            TopAppBar(

                title = { Text("Users") }

            )

        }

    ) { paddingValues ->

        Box(

            modifier = Modifier

                .fillMaxSize()

                .padding(paddingValues)

        ) {

            when {

                uiState.isLoading -> {

                    CircularProgressIndicator(

                        modifier = Modifier.align(Alignment.Center)

                    )

                }

                uiState.error != null -> {

                    ErrorMessage(

                        message = uiState.error,

                        onRetry = { viewModel.loadUsers() },

                        modifier = Modifier.align(Alignment.Center)

                    )

                }

                else -> {

                    UsersList(

                        users = uiState.users,

                        onUserClick = onNavigateToDetail,

                        onRefresh = { viewModel.refreshUsers() },

                        isRefreshing = uiState.isRefreshing

                    )

                }

            }

        }

    }

}

@Composable

private fun UsersList(

    users: List<User>,

    onUserClick: (Int) -> Unit,

    onRefresh: () -> Unit,

    isRefreshing: Boolean

) {

    val pullRefreshState = rememberPullRefreshState(

        refreshing = isRefreshing,

        onRefresh = onRefresh

    )


    Box(

        modifier = Modifier.pullRefresh(pullRefreshState)

    ) {

        LazyColumn(

            modifier = Modifier.fillMaxSize(),

            contentPadding = PaddingValues(16.dp),

            verticalArrangement = Arrangement.spacedBy(8.dp)

        ) {

            items(users) { user ->

                UserItem(

                    user = user,

                    onClick = { onUserClick(user.id) }

                )

            }

        }


        PullRefreshIndicator(

            refreshing = isRefreshing,

            state = pullRefreshState,

            modifier = Modifier.align(Alignment.TopCenter)

        )

    }

}

@Composable

private fun UserItem(

    user: User,

    onClick: () -> Unit

) {

    Card(

        onClick = onClick,

        modifier = Modifier.fillMaxWidth()

    ) {

        Row(

            modifier = Modifier

                .fillMaxWidth()

                .padding(16.dp),

            verticalAlignment = Alignment.CenterVertically

        ) {

            AsyncImage(

                model = user.avatar,

                contentDescription = "User avatar",

                modifier = Modifier

                    .size(48.dp)

                    .clip(CircleShape),

                contentScale = ContentScale.Crop

            )


            Spacer(modifier = Modifier.width(16.dp))


            Column {

                Text(

                    text = user.name,

                    style = MaterialTheme.typography.titleMedium

                )

                Text(

                    text = user.email,

                    style = MaterialTheme.typography.bodyMedium,

                    color = MaterialTheme.colorScheme.onSurfaceVariant

                )

            }

        }

    }

}

// presentation/ui/userdetail/UserDetailScreen.kt

@Composable

fun UserDetailScreen(

    onNavigateBack: () -> Unit,

    viewModel: UserDetailViewModel = hiltViewModel()

) {

    val uiState by viewModel.uiState.collectAsState()


    Scaffold(

        topBar = {

            TopAppBar(

                title = { Text("User Detail") },

                navigationIcon = {

                    IconButton(onClick = onNavigateBack) {

                        Icon(Icons.Default.ArrowBack, contentDescription = "Back")

                    }

                }

            )

        }

    ) { paddingValues ->

        Box(

            modifier = Modifier

                .fillMaxSize()

                .padding(paddingValues)

        ) {

            when {

                uiState.isLoading -> {

                    CircularProgressIndicator(

                        modifier = Modifier.align(Alignment.Center)

                    )

                }

                uiState.error != null -> {

                    ErrorMessage(

                        message = uiState.error,

                        onRetry = { /* Retry logic */ },

                        modifier = Modifier.align(Alignment.Center)

                    )

                }

                uiState.user != null -> {

                    UserDetailContent(user = uiState.user)

                }

            }

        }

    }

}

@Composable

private fun UserDetailContent(user: User) {

    Column(

        modifier = Modifier

            .fillMaxSize()

            .padding(16.dp),

        horizontalAlignment = Alignment.CenterHorizontally

    ) {

        AsyncImage(

            model = user.avatar,

            contentDescription = "User avatar",

            modifier = Modifier

                .size(120.dp)

                .clip(CircleShape),

            contentScale = ContentScale.Crop

        )


        Spacer(modifier = Modifier.height(24.dp))


        Text(

            text = user.name,

            style = MaterialTheme.typography.headlineMedium

        )


        Spacer(modifier = Modifier.height(8.dp))


        Text(

            text = user.email,

            style = MaterialTheme.typography.bodyLarge,

            color = MaterialTheme.colorScheme.onSurfaceVariant

        )


        Spacer(modifier = Modifier.height(16.dp))


        Card(

            modifier = Modifier.fillMaxWidth()

        ) {

            Column(

                modifier = Modifier.padding(16.dp)

            ) {

                Text(

                    text = "Account Information",

                    style = MaterialTheme.typography.titleMedium

                )


                Spacer(modifier = Modifier.height(8.dp))


                Row(

                    modifier = Modifier.fillMaxWidth(),

                    horizontalArrangement = Arrangement.SpaceBetween

                ) {

                    Text("User ID")

                    Text(user.id.toString())

                }


                Spacer(modifier = Modifier.height(4.dp))


                Row(

                    modifier = Modifier.fillMaxWidth(),

                    horizontalArrangement = Arrangement.SpaceBetween

                ) {

                    Text("Created At")

                    Text(user.createdAt)

                }

            }

        }

    }

}

@Composable

private fun ErrorMessage(

    message: String,

    onRetry: () -> Unit,

    modifier: Modifier = Modifier

) {

    Column(

        modifier = modifier,

        horizontalAlignment = Alignment.CenterHorizontally

    ) {

        Text(

            text = message,

            style = MaterialTheme.typography.bodyLarge,

            color = MaterialTheme.colorScheme.error

        )

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = onRetry) {

            Text("Retry")

        }

    }

}

```

## 5. Dependency Injection (依赖注入)

```kotlin

// di/NetworkModule.kt

@Module

@InstallIn(SingletonComponent::class)

object NetworkModule {


    @Provides

    @Singleton

    fun provideOkHttpClient(): OkHttpClient {

        return OkHttpClient.Builder()

            .addInterceptor(HttpLoggingInterceptor().apply {

                level = HttpLoggingInterceptor.Level.BODY

            })

            .connectTimeout(30, TimeUnit.SECONDS)

            .readTimeout(30, TimeUnit.SECONDS)

            .build()

    }


    @Provides

    @Singleton

    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {

        return Retrofit.Builder()

            .baseUrl("https://api.example.com/")

            .client(okHttpClient)

            .addConverterFactory(GsonConverterFactory.create())

            .build()

    }


    @Provides

    @Singleton

    fun provideApiService(retrofit: Retrofit): ApiService {

        return retrofit.create(ApiService::class.java)

    }

}

// di/DatabaseModule.kt

@Module

@InstallIn(SingletonComponent::class)

object DatabaseModule {


    @Provides

    @Singleton

    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {

        return Room.databaseBuilder(

            context,

            AppDatabase::class.java,

            "app_database"

        ).build()

    }


    @Provides

    @Singleton

    fun provideUserDao(database: AppDatabase): UserDao {

        return database.userDao()

    }

}

// di/RepositoryModule.kt

@Module

@InstallIn(SingletonComponent::class)

abstract class RepositoryModule {


    @Binds

    abstract fun bindUserRepository(

        userRepositoryImpl: UserRepositoryImpl

    ): UserRepository

}

// di/DispatcherModule.kt

@Module

@InstallIn(SingletonComponent::class)

object DispatcherModule {


    @Provides

    @IoDispatcher

    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO


    @Provides

    @MainDispatcher

    fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main

}

// Qualifier annotations

@Qualifier

@Retention(AnnotationRetention.BINARY)

annotation class IoDispatcher

@Qualifier

@Retention(AnnotationRetention.BINARY)

annotation class MainDispatcher

```

## 6. Navigation

```kotlin

// presentation/navigation/NavGraph.kt

@Composable

fun NavGraph(navController: NavHostController) {

    NavHost(

        navController = navController,

        startDestination = "users"

    ) {

        composable("users") {

            UsersScreen(

                onNavigateToDetail = { userId ->

                    navController.navigate("user_detail/$userId")

                }

            )

        }


        composable(

            route = "user_detail/{userId}",

            arguments = listOf(

                navArgument("userId") {

                    type = NavType.IntType

                }

            )

        ) {

            UserDetailScreen(

                onNavigateBack = {

                    navController.popBackStack()

                }

            )

        }

    }

}

```

## 7. Application Class

```kotlin

// MyApplication.kt

@HiltAndroidApp

class MyApplication : Application() {

    override fun onCreate() {

        super.onCreate()

        // 初始化其他库

    }

}

```

## 8. MainActivity

```kotlin

// MainActivity.kt

@AndroidEntryPoint

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)

        setContent {

            MyAppTheme {

                val navController = rememberNavController()

                NavGraph(navController = navController)

            }

        }

    }

}

```

## 9. 测试示例

```kotlin

// domain/usecase/GetUsersUseCaseTest.kt

class GetUsersUseCaseTest {


    @Mock

    private lateinit var userRepository: UserRepository


    private lateinit var getUsersUseCase: GetUsersUseCase


    @Before

    fun setup() {

        MockitoAnnotations.openMocks(this)

        getUsersUseCase = GetUsersUseCase(userRepository)

    }


    @Test

    fun `invoke should return users from repository`() = runTest {

        // Given

        val expectedUsers = listOf(

            User(1, "John", "john@example.com", "", "2023-01-01"),

            User(2, "Jane", "jane@example.com", "", "2023-01-02")

        )

        whenever(userRepository.getUsers()).thenReturn(Result.success(expectedUsers))


        // When

        val result = getUsersUseCase()


        // Then

        assertTrue(result.isSuccess)

        assertEquals(expectedUsers, result.getOrNull())

    }

}

// presentation/viewmodel/UsersViewModelTest.kt

class UsersViewModelTest {


    @get:Rule

    val mainDispatcherRule = MainDispatcherRule()


    @Mock

    private lateinit var getUsersUseCase: GetUsersUseCase


    @Mock

    private lateinit var refreshUsersUseCase: RefreshUsersUseCase


    private lateinit var viewModel: UsersViewModel


    @Before

    fun setup() {

        MockitoAnnotations.openMocks(this)

    }


    @Test

    fun `loadUsers should update ui state with users`() = runTest {

        // Given

        val users = listOf(

            User(1, "John", "john@example.com", "", "2023-01-01")

        )

        whenever(getUsersUseCase()).thenReturn(Result.success(users))


        // When

        viewModel = UsersViewModel(getUsersUseCase, refreshUsersUseCase)


        // Then

        val uiState = viewModel.uiState.value

        assertFalse(uiState.isLoading)

        assertEquals(users, uiState.users)

        assertNull(uiState.error)

    }

}

```

这个完整的项目展示了:

1. **Clean Architecture 分层**:Domain、Data、Presentation 层分离

2. **MVVM 模式**:ViewModel 管理 UI 状态

3. **依赖注入**:使用 Hilt 进行依赖管理

4. **Repository 模式**:数据源抽象

5. **Use Cases**:业务逻辑封装

6. **Coroutines + Flow**:异步操作和响应式编程

7. **Jetpack Compose**:现代 UI 开发

8. **单一数据源**:结合本地和远程数据

9. **错误处理**:统一的错误处理机制

10. **测试**:单元测试示例

这种架构的优点:

- 高度可测试性

- 关注点分离

- 易于维护和扩展

- 符合 SOLID 原则

- 清晰的依赖关系

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容