Android Clean架构项目保举

打印 上一主题 下一主题

主题 851|帖子 851|积分 2553


Android Clean架构项目保举

本文将分两个部门演示怎样利用不同架构来构建一个简单的记事本应用。


  • 第一部门将展示作者之前常用的实现方法。
  • 第二部门将严格遵照 Clean Architecture 原则举行实现。
为了让大家更专注于 Clean Architecture,本文章不会过多涉及设计方面的内容。
在构建过程中利用了以下技术栈:Kotlin、Jetpack Compose、协程、Room 和 Hilt。利用的开辟环境是 Android Studio Iguana 和 Kotlin 1.9.22 版本。
第一版,非Clean架构


在该项目下只有一个名为「app」的模块。所有文件夹都位于主包之下。Clean Architecture 的主要目的之一是仅在不同层利用所需的内容。例如,假如您在某个地方需要 DAO,则可能不需要数据库设置。
撇开构建的层级结构,让我们细致看看「features」包内的每个部门。我们可以看到,在每个功能模块中,领域层好像只包罗 UseCases(用例)。理想情况下,此层应该包罗任何与业务逻辑相关的代码,而不但仅是 UseCases。

假如只关注我提到的此中一个功能(姑且称为「main」 - 但「main」并不是一个理想的命名,因为它没有传达任何关于该功能的具体含义),就会发现一个严重违反 Clean Architecture 原则的题目:我只有一个模型 NoteEntity。在整个项目中,我利用这个模型来表现数据库条目。更紧张的是,我在所有层都利用这个唯一的模型。而每个层都应该有本身的模型。展示给用户的模型应该不同于用于数据库的模型。这样一来,我冲破了关注点分离 (Separation of Concerns) 原则。
别的,抽象类 NoteDataSource 和它的具体实现 NoteDataSourceImpl 都位于同一层,这违反了依靠倒置原则 (Dependency Inversion Principle)。既然说到这里,我们还应该为数据源创建两个不同的包:本地和长途。虽然假如您只利用本地数据,这不是强制要求,但是拥有单独的包将有利于将来添加长途数据源而不破坏架构。这绝对是一个好的实践。
  1. class GetAllNotesUseCase(private val noteDataSource: NoteDataSource) {
  2.   operate fun invoke(): Flow<List<NoteEntity>> = noteDataSource.getNotes()
  3. }
复制代码
回顾过去的错误:没有利用数据仓库
很早之前,我对 Clean Architecture 的很多概念还不是很理解,这导致了一些错误,例如没有利用数据仓库。当时我直接注入数据源,因为只有一个本地数据源。然而,利用数据仓库来处理不同的数据源(本地和长途)始终是更好的做法,这有助于遵照单一数据源原则。
Liskov 更换原则的违背
不过,这个案例更适合用来解释 Liskov 更换原则的违背。Liskov 更换原则 意味着什么?它指出你不能将用例(或任何类)直接链接到另一个类的特定实现。请记取,领域层是完全独立的,它不应该知道数据层和展示层的细节。这意味着你需要通过左券(接口) 举行交互,数据源应该继续自这个接口。这样一来,纵然你改变数据源获取数据的方式,用例也不会受到影响,因为它只依靠于接口。因此,你的领域层独立于数据层。
避免利用 Flow 返回类型
别的,我们还需要指出这个用例返回了一个 Flow 对象。这种做法忽略了数据仓库可能碰到的任何异常情况。更健壮的做法是显式地处理异常,例如利用 Try/Catch 语句或返回包罗错误信息的自定义类型。
总之,通过遵照 Clean Architecture 原则,我们可以构建更健壮、更可维护的应用程序。利用数据仓库可以管理不同的数据源,同时遵照单一数据源原则。Liskov 更换原则确保了领域层与数据层的独立性,并提高了代码的可测试性。最后,通过显式地处理异常,我们可以编写更可靠的代码。
  1. @HiltViewModel
  2. class NoteViewModel @Inject constructor(
  3.     getNotesUseCase: GetAllNotesUseCase
  4. ) : ViewModel() {
  5.     val noteState: StateFlow<NotesFeedUiState> = getNotesUseCase()
  6.         .map { list ->
  7.             if (list.isEmpty()) {
  8.                 NotesFeedUiState.Empty
  9.             } else {
  10.                 NotesFeedUiState.Success(list)
  11.             }
  12.         }
  13.         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NotesFeedUiState.Loading)
  14. }
复制代码
在展示层方面,项目主要利用了 ViewModel 和可组合项 (Composable) 来构建 UI。总的来说,ViewModel 的实现还不错,但仍有一些改进空间。
以下是一些可以考虑的优化方向:


  • 减少 ViewModel 的复杂性: 假如 ViewModel 变得过于复杂,可以考虑将其拆分成更小的 ViewModel 类,每个类负责处理特定的功能。
  • 利用数据类 (Data Class): 对于包罗多个属性的 ViewModel 数据,可以利用数据类来简化代码并提高可读性。
  • 测试驱动开辟 (TDD): 编写单位测试来验证 ViewModel 的逻辑,确保其举动符合预期。
  1. class NoteDataSourceImpl(private val noteDAO: NoteDAO) : NoteDataSource {
  2.     // Other CRUD methods
  3.     override suspend fun upsertNote(note: NoteEntity): Result<Boolean> =
  4.         try {
  5.             noteDAO.upsertNote(note)
  6.             Result.Success(true)
  7.         } catch (e: Exception) {
  8.             e.printStackTrace()
  9.             Result.Error(
  10.                 throwable = e.cause ?: Throwable(message = "Could not upsert note")
  11.             )
  12.         }
  13. }
复制代码
让我们关注一下我CRUD具体实现中的一个方法。这个方法返回一个结果,但在我的用例中(以及缺失的存储库)完全被忽略了。别的,异常处理也可以改进。然而,总体来说,这是一个好的方法,因为它遵照单一职责原则,只实行一个任务。
让我们看看怎样通过更清楚的第二版来改进这个项目。
第二版,Clean架构


首先?我们现在有 3 个主要模块:

  • App
  • Core
  • Feature
App 模块是必需的。在这里,你会找到 MainActivity 以及与应用程序安稳运行相关的所有内容(例如,你的 Application 文件)。
  1. // 来自 build.gradle.kts (app)
  2. // 模块
  3. implementation(project(Modules.DATABASE))
  4. implementation(project(Modules.DAO))
  5. implementation(project(Modules.DI))
  6. implementation(project(Modules.ADD_NOTE_PRESENTATION))
  7. implementation(project(Modules.NOTE_DATA))
  8. implementation(project(Modules.NOTE_DOMAIN))
  9. implementation(project(Modules.NOTE_PRESENTATION))
  10. implementation(project(Modules.NOTE_DETAIL_PRESENTATION))
复制代码
别的,你的 app 的 build.gradle 应该包罗(几乎)你在这个项目中创建的每个模块。

在 Core 模块中,你将放置所有不是功能的内容。我们通常用它来组合提供根本功能和其他模块共享的实用程序的代码(例如,数据库、设计、公共…)。每个文件夹应该是一个模块,以便你可以在需要时注入它们。

在 Feature 模块中,你将放置你的…功能(好主意!)。在每个功能内,你将为 Clean Architecture 的每一层创建一个模块:数据层、领域层和展示层。你的领域模块将被注入到展示模块和数据模块中,但它将保持独立。然而,请记取,假如你的功能不需要显示任何内容,就不需要创建展示层(例如)。

再一次,让我们专注于 Notes 模块——请注意,这次的命名更清楚——并从领域层开始。我们之前关于领域层学到了什么?


  • 它不了解数据和展示
  • 它包罗业务逻辑
  • 领域层和数据层之间的界限是可调整的。有些人更喜欢把所有与数据源相关的内容放在 Data 文件夹中,例如存储库不专门针对领域层。
  1. // 来自 feature.notes.domain.data_sources.local
  2. interface NoteLocalDataSource {
  3.     fun observeNotes(): Flow<List<NoteDomainModel>>
  4.     suspend fun insertNote(note: NoteDomainModel): Result<Boolean>
  5.     suspend fun fetchNoteById(id: Long): Result<NoteDomainModel>
  6. }
复制代码
我们有了第一个业务逻辑文件夹:数据源。如前所述,我只包罗了一个本地文件夹。为了遵守里氏更换原则,我们创建了一个接口,包罗业务逻辑所需的方法。在返回数据时,我们将其封装到一个自定义的 Result 对象中(来自我们的公共模块)。这个密封接口由两个类构成:Success 和 Failure。实现这些方法不是领域层的责任,以是我们就到此为止。
  1. // 来自 feature.notes.domain.repositories
  2. suspend fun fetchNoteById(id: Long): Result<NoteDomainModel> {
  3.     return noteLocalDataSource.fetchNoteById(id)
  4. }
复制代码
我们的存储库专注于利用前面的接口:数据泉源可有可无。我们返回一个 Result,最紧张的是,我们返回一个针对这一层定制的模型 NoteDomainModel。域层中需要模型的每个文件都利用这个自定义模型。
  1. // 来自 feature.notes.domain.use_cases
  2. class InsertNoteUseCase(
  3.     private val noteRepository: NoteRepository
  4. ) {
  5.     suspend operator fun invoke(note: NoteDomainModel): Result<Boolean> {
  6.         return noteRepository.insertNote(note)
  7.     }
  8. }
复制代码
最后,我们有了一些用例。如前所述,它利用自定义模型 NoteDomainModel 并返回一个 Result,这样我们可以在展示层处理异常。

让我们继续我们的数据模块层。它的目的是获取数据:可以由你的存储设备、数据库、服务器、API、共享偏好提供……在我们的例子中,我们专注于利用本地数据库的简单示例。对于那些有爱好探索 API 功能的人,我建议检察我的 HobbyMatchMaker 项目。
这里的关键文件是 NoteLocalDataSourceImpl,它是我们在领域层定义的 NoteLocalDataSource 接口的具体实现。
  1. // 来自 features.notes.data.data_sources.local
  2. class NoteLocalDataSourceImpl(
  3.   private val noteDAO: NoteDAO
  4. ) : NoteLocalDataSource {
  5.     // 其他方法
  6.     override fun observeNotes(): Flow<List<NoteDomainModel>> {
  7.         return noteDAO.observeNotes()
  8.             .map { list ->
  9.                 list.map { noteEntityModel -> noteEntityModel.toNoteDomainModel() }
  10.             }
  11.             .onEmpty {
  12.                 return@onEmpty
  13.             }
  14.     }
  15. }
复制代码
NoteDAO 通过类构造函数注入。NoteDAO 定义在另一个名为 dao 的模块中,该模块包罗在主模块 core 的父模块数据库中。如前所述,在需要 DAO 时,只需要接口,而不是数据库实现。
  1. // 来自 core.database.main
  2. @Module
  3. @InstallIn(SingletonComponent::class)
  4. object DatabaseModule {
  5.     @Provides
  6.     @Singleton
  7.     fun provideDatabase(
  8.         @ApplicationContext context: Context
  9.     ): NoteAppCleanDatabase =
  10.         Room.databaseBuilder(
  11.             context,
  12.             NoteAppCleanDatabase::class.java,
  13.             "database-clean"
  14.         )
  15.             .build()
  16.     @Provides
  17.     fun providesNoteDAO(database: NoteAppCleanDatabase): NoteDAO = database.noteDAO()
  18. }
复制代码
我们从数据库中检索实体 NoteEntityModel,而且由于我们的领域层只与 NoteDomainModel 交互,我们利用一个映射器。
  1. // 来自 feature.notes.data.data_sources.local.mappers
  2. fun NoteEntityModel.toNoteDomainModel(): NoteDomainModel {
  3.     return NoteDomainModel(
  4.         id = this.id,
  5.         title = this.title,
  6.         description = this.description
  7.     )
  8. }
复制代码
数据层的责任是适应领域层的要求,而绝不是反过来。

最后,我们的最后一层:展示层。其主要功能是向用户展示数据或与他们举行任何交互,处理所有与 UI 或 UX 相关的方面。为此,我们需要一个特定的模型 NoteUiModel。这里的一个好风俗是利用 Immutable 注解它。
我们将找到三个主要元素:映射器、Composable 组件和 ViewModel。映射器遵照与数据层相同的模式:它将业务模型转换为 UI 模型。
  1. // 来自 feature.notes.presentation
  2. @Composable
  3. fun NoteScreen(
  4.     modifier: Modifier = Modifier,
  5.     notes: List<NoteUiModel>,
  6.     openNoteDetail: (id: Long) -> Unit
  7. ) {
  8.     // 内容
  9. }
复制代码
Composable 组件表现我们屏幕的不同部门,例如我们的案例中的条记列表。我们利用我们的 UI 模型 NoteUiModel,并在需要与 ViewModel 交互时利用 lambda 函数。
  1. // 来自 feature.notes.presentation
  2. @OptIn(ExperimentalCoroutinesApi::class)
  3. @HiltViewModel
  4. class NoteViewModel @Inject constructor(
  5.     private val observeNotesUseCase: ObserveNotesUseCase
  6. ) : ViewModel() {
  7.     val noteState: StateFlow<NoteUiStateModel> by lazy {
  8.         observeNotesUseCase()
  9.             .mapLatest { notes ->
  10.                 if (notes.isEmpty()) {
  11.                     NoteUiStateModel.Empty
  12.                 } else {
  13.                     NoteUiStateModel.Fetched(notes.map { it.toNoteUiModel() }.toPersistentList())
  14.                 }
  15.             }
  16.             .flowOn(Dispatchers.Main)
  17.             .stateIn(
  18.                 viewModelScope,
  19.                 SharingStarted.WhileSubscribed(5000),
  20.                 NoteUiStateModel.Loading
  21.             )
  22.     }
  23. }
复制代码
最后,我们的 ViewModel。我们注入我们的用例,因为对于此功能,我们只需要观察我们的条记列表并显示它。
由于 Room 是反应式的,任何对数据库的添加都会触发可观察变量 noteState(因为它是一个 Flow)。根据接收到的数据,我们利用适当的模型调整 UI。这里,我们利用 NoteUiStateModel,它是一个包罗三种可能性的密封接口:Loading(默认状态)、Empty 和 Fetched。
为了提高应用性能,可以将列表设置为长期的。这会提示 Compose 编译器将列表标志为稳定。
结论



  • 在 ViewModel 中利用 SavedStateHandle(你绝对应该探索它,文档可在此处检察)
  • 在项目中实现 Hilt(尽管你可能已经注意到几乎每个模块中都有我们的 DI 模块)。另一方面,假如你想探索 KMP,Koin 绝对是最好的依靠注入工具!
项目地址及参考

https://github.com/morganesoula/NoteApp
https://github.com/morganesoula/NoteAppClean

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

立聪堂德州十三局店

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表