Android架构: 掌握 MVVM 和简洁架构
我请 ChatGPT 为一个典型的 Jetpack Compose MVVM 应用提出项目结构建议
维护一个简洁, 可扩展和可测试的代码库是一项挑战. 在众多架构模式中, MVVM(Model-View-ViewModel)与简洁架构(Clean Architecture)原则相结合, 已成为一个强大的框架, 可用于创建高效, 有组织和可维护的应用. 为依赖注入添加 Dagger Hilt 可以进一步简化开发, 确保松散耦合的组件和模块化的代码库. 但是, 应该如何利用这些方法来组织应用呢?让我们来探讨优化的目录结构, 并讨论每个决定背后的理由.
为什么采用这种结构?
建议的结构并非随意而为, 它旨在隔离责任, 使代码更易于浏览, 理解和测试. 它遵循了“简洁架构”和MVVM的原则, 将关注点分成不同的层, 每个层都有不同的角色:
- UI层(用于展示)
- 用于业务规则的domain层
- 数据层用于数据处理
- DI 模块用于依赖注入
这种分离确保一个层的修改对其他层的影响最小, 从而便于更新和维护.
简要说明
有经验的开发人员应该长期从事类似结构的项目. 让我们来看看建议的结构, 如果它足够简单易懂, 无需进一步阐述.
app/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── com/
│ │ │ │ ├── yourapp/
│ │ │ │ │ ├── Application.kt
│ │ │ │ │ ├── MainActivity.kt
│ │ │ │ │ ├── di/
│ │ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ │ ├── ViewModelModule.kt
│ │ │ │ │ │ ├── RepositoryModule.kt
│ │ │ │ │ │ └── UseCaseModule.kt
│ │ │ │ │ ├── ui/
│ │ │ │ │ │ ├── navigation/
│ │ │ │ │ │ │ ├── NavigationHost.kt
│ │ │ │ │ │ │ ├── NavigationItems.kt
│ │ │ │ │ │ │ └── NavigationActions.kt
│ │ │ │ │ │ ├── compose/
│ │ │ │ │ │ │ ├── components/
│ │ │ │ │ │ │ │ ├── ButtonComponent.kt
│ │ │ │ │ │ │ │ ├── CardComponent.kt
│ │ │ │ │ │ │ │ └── ... (other reusable components)
│ │ │ │ │ │ │ ├── screens/
│ │ │ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ │ │ ├── DetailScreen.kt
│ │ │ │ │ │ │ │ └── ... (UI screens)
│ │ │ │ │ │ ├── viewmodel/
│ │ │ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ │ │ ├── DetailViewModel.kt
│ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ ├── UiModelToDomainModelMapper.kt
│ │ │ │ │ │ │ │ └── DomainModelToUiModelMapper.kt
│ │ │ │ │ │ │ └── ... (other view models)
│ │ │ │ │ │ ├── theme/
│ │ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ │ ├── Shape.kt
│ │ │ │ │ │ │ ├── Type.kt
│ │ │ │ │ │ │ └── ... (theme and styling related)
│ │ │ │ │ │ ├── preview/
│ │ │ │ │ │ │ └── PreviewParameterProviders.kt
│ │ │ │ │ │ └── util/
│ │ │ │ │ │ └── Extensions.kt
│ │ │ │ │ ├── domain/
│ │ │ │ │ │ ├── model/
│ │ │ │ │ │ │ └── ... (domain models)
│ │ │ │ │ │ ├── usecase/
│ │ │ │ │ │ │ ├── interfaces/
│ │ │ │ │ │ │ │ └── ... (use case interfaces, if used)
│ │ │ │ │ │ │ ├── FetchUserProfileUseCase.kt
│ │ │ │ │ │ │ ├── UpdateUserProfileUseCase.kt
│ │ │ │ │ │ │ └── ... (other use cases)
│ │ │ │ │ │ └── repository/
│ │ │ │ │ │ └── interfaces/
│ │ │ │ │ │ ├── UserRepositoryInterface.kt
│ │ │ │ │ │ └── ... (other repository interfaces)
│ │ │ │ │ ├── repository/
│ │ │ │ │ │ ├── UserRepository.kt
│ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ ├── DomainModelToDtoMapper.kt
│ │ │ │ │ │ │ └── DtoToDomainModelMapper.kt
│ │ │ │ │ │ └── ... (other repositories)
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── datasource/
│ │ │ │ │ │ │ ├── remote/
│ │ │ │ │ │ │ │ ├── RemoteDataSource.kt
│ │ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ │ └── DtoToDomainMapper.kt
│ │ │ │ │ │ │ ├── local/
│ │ │ │ │ │ │ │ ├── LocalDataSource.kt
│ │ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ │ └── DomainToPersistenceMapper.kt
│ │ │ │ │ │ ├── dto/
│ │ │ │ │ │ │ └── ... (data transfer objects)
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ └── ... (persistence models)
│ │ ├── res/
│ │ │ └── ...
│ └── ...
└── ...
💡 没有放之四海而皆准的规则适用于所有代码项目. 鉴于 ChatGPT 提出了这种结构, 我要求它解释为什么一定要这样. 只有理解了其中的道理, 我们才能判断是遵循建议, 还是对其进行定制以满足我们的要求.
Application.kt和MainActivity.kt.
- 位置:
app/src/main/java/com/yourapp/ - 目的:
Application.kt是全局初始化任务的支柱, 对于 Dagger, Hilt 等设置至关重要.MainActivity.kt是应用的启动点. - 为什么要放在这里? 把它们放在软件包结构的根部, 是为了强调它们在整个应用中的范围和重要性.
│ │ │ │ │ ├── di/
│ │ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ │ ├── ViewModelModule.kt
│ │ │ │ │ │ ├── RepositoryModule.kt
│ │ │ │ │ │ └── UseCaseModule.kt
- 位置:
app/src/main/java/com/yourapp/di. - 目的*: 集中配置依赖关系, 确保每个组件都能获得所需的实例, 而无需关心这些实例是如何创建的.
- 为什么选择这里: 一个专用的 DI 包可以直接管理依赖注入, 对于维护简洁架构至关重要.
DI模块解释:
- AppModule.kt: 提供应用范围内的依赖关系, 如数据库, 网络客户端(如 Retrofit)或整个应用可能需要的任何单例对象.
- ViewModelModule.kt: 包含 ViewModel 实例的绑定. Dagger Hilt 允许直接注入 ViewModel, 利用 Hilt 的
@ViewModelInject注解(或@HiltViewModel, 如果使用较新版本).
💡 使用
ViewModelModule已不再常见, 因为我们现在通常可以使用@HiltViewModel.
- RepositoryModule.kt: 提供用例所需的repository. 该模块确保repository可以访问必要的数据源实例, 无论它们是本地(Room 数据库)还是远程(API 服务).
- UseCaseModule.kt: 为应用提供用例. 由于用例依赖于一个或多个资源库, 因此该模块可以注入这些依赖关系.
重塑UI层
│ │ │ │ │ ├── ui/
│ │ │ │ │ │ ├── navigation/
│ │ │ │ │ │ │ ├── NavigationHost.kt
│ │ │ │ │ │ │ ├── NavigationItems.kt
│ │ │ │ │ │ │ └── NavigationActions.kt
│ │ │ │ │ │ ├── compose/
│ │ │ │ │ │ │ ├── components/
│ │ │ │ │ │ │ │ ├── ButtonComponent.kt
│ │ │ │ │ │ │ │ ├── CardComponent.kt
│ │ │ │ │ │ │ │ └── ... (other reusable components)
│ │ │ │ │ │ │ ├── screens/
│ │ │ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ │ │ ├── DetailScreen.kt
│ │ │ │ │ │ │ │ └── ... (UI screens)
│ │ │ │ │ │ ├── viewmodel/
│ │ │ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ │ │ ├── DetailViewModel.kt
│ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ ├── UiModelToDomainModelMapper.kt
│ │ │ │ │ │ │ │ └── DomainModelToUiModelMapper.kt
│ │ │ │ │ │ │ └── ... (other view models)
│ │ │ │ │ │ ├── theme/
│ │ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ │ ├── Shape.kt
│ │ │ │ │ │ │ ├── Type.kt
│ │ │ │ │ │ │ └── ... (theme and styling related)
│ │ │ │ │ │ ├── preview/
│ │ │ │ │ │ │ └── PreviewParameterProviders.kt
│ │ │ │ │ │ └── util/
│ │ │ │ │ │ └── Extensions.kt
精心组织这一层可确保我们的应用用户友好, 易于管理和更新.
Compose 和组件
app/src/main/java/com/yourapp/ui/compose 目录使用 Jetpack Compose 来定义应用的UI. 该目录包含组件和屏幕子目录. 组件是可重复使用的UI元素, 如按钮和卡片, 可促进UI的一致性并减少代码重复. 屏幕是应用中的单个视图, 由这些组件构建而成, 便于更新.
ViewModel 和 Mapper:
该部分位于 app/src/main/java/com/yourapp/ui/viewmodel, 连接UI与业务逻辑, 并管理与UI相关的数据. 它还包含Mapper, 对于UI和domain层之间的数据转换至关重要, 强调了清晰的关注点分离.
Compose 中的导航
假设我们使用 Compose 导航. 导航逻辑集中在 app/src/main/java/com/yourapp/ui/navigation 中. 其中包括概述应用导航图谱的 NavigationHost 文件和以类型安全方式列出应用中所有可导航项目的 NavigationItems 文件. 这种设置简化了整个应用的导航流管理.
主题和样式
颜色, 形状和排版等设计组件归类于 app/src/main/java/com/yourapp/ui/theme. 这种组织方式可确保整个应用的外观和感觉保持一致, 并允许从单一位置进行全局样式调整.
这确实是 Android Studio 为我们生成新项目时放置主题的默认位置.
预览和实用工具
app/src/main/java/com/yourapp/ui/preview 目录专门用于 Jetpack Compose UI 组件预览. 此外, 还在 app/src/main/java/com/yourapp/ui/util 目录中存储了构建更高效UI的实用功能.
domain层: 业务核心
│ │ │ │ │ ├── domain/
│ │ │ │ │ │ ├── model/
│ │ │ │ │ │ │ └── ... (domain models)
│ │ │ │ │ │ ├── usecase/
│ │ │ │ │ │ │ ├── interfaces/
│ │ │ │ │ │ │ │ └── ... (use case interfaces, if used)
│ │ │ │ │ │ │ ├── FetchUserProfileUseCase.kt
│ │ │ │ │ │ │ ├── UpdateUserProfileUseCase.kt
│ │ │ │ │ │ │ └── ... (other use cases)
│ │ │ │ │ │ └── repository/
│ │ │ │ │ │ └── interfaces/
│ │ │ │ │ │ ├── UserRepositoryInterface.kt
│ │ │ │ │ │ └── ... (other repository interfaces)
domain层是简洁架构方法的核心, 它封装了业务逻辑和规则的精髓. 它是应用的真正内容, 剔除了任何基础架构, UI或外部数据问题. 该层通常位于 app/src/main/java/com/yourapp/domain, 是应用的核心, 可确保业务逻辑纯粹, 可测试且独立于其他层.
模型
domain层中的模型子目录至关重要. 它承载着整个应用中使用的数据结构的定义, 但有一个转折: 这些模型纯粹用于业务逻辑. 它们不关心数据库模式或来自网络响应的 JSON 结构. 例如, 一个 User domain模型将包括对业务流程很重要的属性(如 userId, name, email), 而不包括其他任何属性.
UseCase/Interactor
本部分包含应用中可执行的操作, 直接代表业务操作. 每个用例都是一段独立的业务逻辑.
例如, FetchUserProfile 可以是检索用户数据的用例. 这些用例根据模型执行, 并由UI层中的 ViewModels 协调, 将用户操作与业务操作连接起来. 这种清晰的分离允许进行集中的单元测试, 确保业务规则按预期运行, 而无需依赖UI或数据源.
repository接口
domain层为与之交互的repository定义了接口, 尽管实现在其他地方进行. 这种设置执行了“依赖反转原则”, 将domain层与数据层解耦. 一个repository接口, 如UserRepository, 概述了getUserDetails(userId: String): User等方法, 抽象出实际的数据检索机制.
Mapper
💡 通常不会出现在domain层中, 因为其目的是不受外部关注的影响
不过, 从domain层过渡到用例或repository时, 可能需要进行数据转换. **这些转换在domain层之外处理, 以保持其纯洁性.
例如, 将Userdomain模型转换为UserProfile视图模型以便显示, 这属于 ViewModel 层或接口适配器层中特定Mapper组件的职责, 从而确保domain层的隔离.
repository层: 数据集中管理
│ │ │ │ │ ├── repository/
│ │ │ │ │ │ ├── UserRepository.kt
│ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ ├── DomainModelToDtoMapper.kt
│ │ │ │ │ │ │ └── DtoToDomainModelMapper.kt
│ │ │ │ │ │ └── ... (other repositories)
在我们的架构中, 资源库层沿用了domain层的做法, 成为所有数据相关操作的中心点, 在domain层的抽象与数据源的实际情况之间发挥中介作用. 该层位于app/src/main/java/com/yourapp/repository, 通过将数据源从应用的其他部分抽象出来, 确保数据逻辑被整齐封装并易于管理, 从而体现了简洁架构的原则.
repository接口(domain层)
- 位置:
app/src/main/java/com/yourapp/domain/repository/interfaces
如上所述, 版本库接口应置于domain层之下.
实现和Mapper
- 位置:
app/src/main/java/com/yourapp/repository
版本库接口的具体实现位于版本库软件包内, 但不在domain的权限范围内. 这是与数据源(应用接口, 数据库)交互的逻辑实现, 履行接口定义的契约.
-
Mapper: Mapper是repository实施的重要组成部分. 它们负责在domain模型(domain层使用并由业务逻辑定义)和数据传输对象(DTO)或持久化模型(外部数据源或数据库使用)之间转换数据.
-
理由: 通过在repository层中使用Mapper, 应用可确保数据源结构的更改(如 API 响应更改)不会直接影响封装在domain模型中的核心业务逻辑. 这样就可以建立一个灵活, 可维护的代码库, 使外部变化对应用核心功能的影响降到最低.
实用数据管理
在实践中, 资源库层可能会与多个数据源交互. 例如, UserRepository 可能首先尝试从本地缓存中获取用户详细信息; 如果不可用, 则查询远程 API. 该层负责管理这些复杂问题, 决定何时以及如何缓存数据, 刷新数据或从持久化存储中获取数据, 同时为domain层提供统一的接口.
- 缓存策略: 在repository内, 实施人员决定缓存策略, 确保高效的数据检索, 最大限度地减少网络调用, 从而提高应用的性能和用户体验.
- 数据同步: 它还处理本地和远程数据源之间的数据同步, 确保用户与最新的数据交互, 并确保在离线状态下所做的任何更改都能在恢复连接后得到充分同步.
数据层: 与外部世界的接口
│ │ │ │ │ ├── data/
│ │ │ │ │ │ ├── datasource/
│ │ │ │ │ │ │ ├── remote/
│ │ │ │ │ │ │ │ ├── RemoteDataSource.kt
│ │ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ │ └── DtoToDomainMapper.kt
│ │ │ │ │ │ │ ├── local/
│ │ │ │ │ │ │ │ ├── LocalDataSource.kt
│ │ │ │ │ │ │ │ ├── mappers/
│ │ │ │ │ │ │ │ │ └── DomainToPersistenceMapper.kt
│ │ │ │ │ │ ├── dto/
│ │ │ │ │ │ │ └── ... (data transfer objects)
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ └── ... (persistence models)
数据层, app/src/main/java/com/yourapp/data, 处理与外部数据源的所有交互. 这包括从远程 API 获取数据, 从本地数据库读取或向本地数据库写入数据, 以及管理任何其他形式的外部数据交互.
数据源及其作用
- 远程和本地: 数据层通常分为“远程”和“本地”子目录, 以管理不同的数据源. "远程"数据源处理网络操作, 从应用接口获取数据, 而”本地"数据源处理持久性, 将数据存储在本地数据库中, 以便离线访问或缓存.
- 数据传输对象(DTO): DTO 在远程数据源中发挥着关键作用. 它们是为匹配从应用接口接收或发送到应用接口的数据结构而定制的, 并充当网络通信中使用的直接数据格式.
- 持久化模型: 在本地数据管理中, 持久化模型定义了本地存储的数据结构. 这些模型旨在优化 Room 等数据库的存储和检索, 确保数据的高效存储和随时访问.
Mapper的关键作用
- 转换数据: 数据层中的Mapper对于在 DTO, 持久化模型和domain模型之间进行转换是不可或缺的. 他们确保外部来源的数据可以转换成domain层可用的形式, 反之亦然. 这包括将网络响应转换为domain模型供业务逻辑使用, 以及将domain模型映射为持久化模型供本地存储使用.
- 确保关注点分离: 通过使用Mapper, 数据层与domain层保持了明确的分离, 遵守了简洁架构的原则. **这种设置可使应用在外部数据结构发生变化时保持灵活性和弹性, 而不会影响核心业务逻辑.
管理复杂性
- 缓存和同步: 缓存和数据同步逻辑位于数据层. 它决定以最有效的方式缓存数据以便快速检索, 以及如何将本地数据与远程数据同步, 以确保一致性和最新性.
- 错误处理和数据转换: 该层管理网络响应中的错误, 管理重试, 并将原始数据转换为适合应用需要的格式. 封装数据处理逻辑可确保应用的其他部分专注于各自的具体职责, 从而提高应用的整体可维护性和可扩展性.
总结一下: 可持续的安卓开发蓝图
在剖析现代 Android 应用的各个层面(UI, domain, repository和数据)时, 我要求 ChatGPT 勾勒出一个强调清晰分离, 模块化和适应性的架构.
这种架构便于开发和维护, 并确保应用能够不断发展以满足未来的需求. 整合 Jetpack Compose, 坚持简洁架构原则, 以及通过Mapper进行战略性数据处理, 这些都表明了我们致力于使用最先进的实践和原则进行稳健的应用开发.
事实上, 每个项目都有自己的定制结构, 尤其是在考虑多个模块或 Kotlin Multiplatform 时. 了解事情为什么会以这种方式存在, 有助于我们更好地判断是否要遵循这些建议.