Android Clean架构项目推荐

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)。既然说到这里,我们还应该为数据源创建两个不同的包:本地和远程。虽然如果您只使用本地数据,这不是强制要求,但是拥有单独的包将有利于未来添加远程数据源而不破坏架构。这绝对是一个好的实践。

class GetAllNotesUseCase(private val noteDataSource: NoteDataSource) {
  operate fun invoke(): Flow<List<NoteEntity>> = noteDataSource.getNotes()
}

回顾过去的错误:没有使用数据仓库

很早之前,我对 Clean Architecture 的许多概念还不是很理解,这导致了一些错误,例如没有使用数据仓库。当时我直接注入数据源,因为只有一个本地数据源。然而,使用数据仓库来处理不同的数据源(本地和远程)始终是更好的做法,这有助于遵循单一数据源原则。

Liskov 替换原则的违背

不过,这个案例更适合用来解释 Liskov 替换原则的违背。Liskov 替换原则 意味着什么?它指出你不能将用例(或任何类)直接链接到另一个类的特定实现。请记住,领域层是完全独立的,它不应该知道数据层和展示层的细节。这意味着你需要通过契约(接口) 进行交互,数据源应该继承自这个接口。这样一来,即使你改变数据源获取数据的方式,用例也不会受到影响,因为它只依赖于接口。因此,你的领域层独立于数据层。

避免使用 Flow 返回类型

此外,我们还需要指出这个用例返回了一个 Flow 对象。这种做法忽略了数据仓库可能遇到的任何异常情况。更健壮的做法是显式地处理异常,例如使用 Try/Catch 语句或返回包含错误信息的自定义类型。

总之,通过遵循 Clean Architecture 原则,我们可以构建更健壮、更可维护的应用程序。使用数据仓库可以管理不同的数据源,同时遵循单一数据源原则。Liskov 替换原则确保了领域层与数据层的独立性,并提高了代码的可测试性。最后,通过显式地处理异常,我们可以编写更可靠的代码。

@HiltViewModel
class NoteViewModel @Inject constructor(
    getNotesUseCase: GetAllNotesUseCase
) : ViewModel() {

    val noteState: StateFlow<NotesFeedUiState> = getNotesUseCase()
        .map { list ->
            if (list.isEmpty()) {
                NotesFeedUiState.Empty
            } else {
                NotesFeedUiState.Success(list)
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NotesFeedUiState.Loading)
}

在展示层方面,项目主要使用了 ViewModel 和可组合项 (Composable) 来构建 UI。总的来说,ViewModel 的实现还不错,但仍有一些改进空间。

以下是一些可以考虑的优化方向:

  • 减少 ViewModel 的复杂性: 如果 ViewModel 变得过于复杂,可以考虑将其拆分成更小的 ViewModel 类,每个类负责处理特定的功能。
  • 使用数据类 (Data Class): 对于包含多个属性的 ViewModel 数据,可以使用数据类来简化代码并提高可读性。
  • 测试驱动开发 (TDD): 编写单元测试来验证 ViewModel 的逻辑,确保其行为符合预期。
class NoteDataSourceImpl(private val noteDAO: NoteDAO) : NoteDataSource {

    // Other CRUD methods

    override suspend fun upsertNote(note: NoteEntity): Result<Boolean> =
        try {
            noteDAO.upsertNote(note)
            Result.Success(true)
        } catch (e: Exception) {
            e.printStackTrace()
            Result.Error(
                throwable = e.cause ?: Throwable(message = "Could not upsert note")
            )
        }
}

让我们关注一下我CRUD具体实现中的一个方法。这个方法返回一个结果,但在我的用例中(以及缺失的存储库)完全被忽略了。此外,异常处理也可以改进。然而,总体来说,这是一个好的方法,因为它遵循单一职责原则,只执行一个任务。

让我们看看如何通过更清晰的第二版来改进这个项目。

第二版,Clean架构


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

  1. App
  2. Core
  3. Feature

App 模块是必需的。在这里,你会找到 MainActivity 以及与应用程序平稳运行相关的所有内容(例如,你的 Application 文件)。

// 来自 build.gradle.kts (app)

// 模块
implementation(project(Modules.DATABASE))
implementation(project(Modules.DAO))
implementation(project(Modules.DI))
implementation(project(Modules.ADD_NOTE_PRESENTATION))
implementation(project(Modules.NOTE_DATA))
implementation(project(Modules.NOTE_DOMAIN))
implementation(project(Modules.NOTE_PRESENTATION))
implementation(project(Modules.NOTE_DETAIL_PRESENTATION))

此外,你的 app 的 build.gradle 应该包含(几乎)你在这个项目中创建的每个模块。
Core module
在 Core 模块中,你将放置所有不是功能的内容。我们通常用它来组合提供基本功能和其他模块共享的实用程序的代码(例如,数据库、设计、公共…)。每个文件夹应该是一个模块,以便你可以在需要时注入它们。

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

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

  • 它不了解数据和展示
  • 它包含业务逻辑
  • 领域层和数据层之间的界限是可调整的。有些人更喜欢把所有与数据源相关的内容放在 Data 文件夹中,例如存储库不专门针对领域层。
// 来自 feature.notes.domain.data_sources.local

interface NoteLocalDataSource {
    fun observeNotes(): Flow<List<NoteDomainModel>>
    suspend fun insertNote(note: NoteDomainModel): Result<Boolean>
    suspend fun fetchNoteById(id: Long): Result<NoteDomainModel>
}

我们有了第一个业务逻辑文件夹:数据源。如前所述,我只包含了一个本地文件夹。为了遵守里氏替换原则,我们创建了一个接口,包含业务逻辑所需的方法。在返回数据时,我们将其封装到一个自定义的 Result 对象中(来自我们的公共模块)。这个密封接口由两个类组成:SuccessFailure。实现这些方法不是领域层的责任,所以我们就到此为止。

// 来自 feature.notes.domain.repositories

suspend fun fetchNoteById(id: Long): Result<NoteDomainModel> {
    return noteLocalDataSource.fetchNoteById(id)
}

我们的存储库专注于使用前面的接口:数据来源无关紧要。我们返回一个 Result,最重要的是,我们返回一个针对这一层定制的模型 NoteDomainModel。域层中需要模型的每个文件都使用这个自定义模型。

// 来自 feature.notes.domain.use_cases

class InsertNoteUseCase(
    private val noteRepository: NoteRepository
) {
    suspend operator fun invoke(note: NoteDomainModel): Result<Boolean> {
        return noteRepository.insertNote(note)
    }
}

最后,我们有了一些用例。如前所述,它使用自定义模型 NoteDomainModel 并返回一个 Result,这样我们可以在展示层处理异常。

from feature.notes.data
让我们继续我们的数据模块层。它的目的是获取数据:可以由你的存储设备、数据库、服务器、API、共享偏好提供……在我们的例子中,我们专注于使用本地数据库的简单示例。对于那些有兴趣探索 API 功能的人,我建议查看我的 HobbyMatchMaker 项目。

这里的关键文件是 NoteLocalDataSourceImpl,它是我们在领域层定义的 NoteLocalDataSource 接口的具体实现。

// 来自 features.notes.data.data_sources.local

class NoteLocalDataSourceImpl(
  private val noteDAO: NoteDAO
) : NoteLocalDataSource {

    // 其他方法

    override fun observeNotes(): Flow<List<NoteDomainModel>> {
        return noteDAO.observeNotes()
            .map { list ->
                list.map { noteEntityModel -> noteEntityModel.toNoteDomainModel() }
            }
            .onEmpty {
                return@onEmpty
            }
    }
}

NoteDAO 通过类构造函数注入。NoteDAO 定义在另一个名为 dao 的模块中,该模块包含在主模块 core 的父模块数据库中。如前所述,在需要 DAO 时,只需要接口,而不是数据库实现。

// 来自 core.database.main

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): NoteAppCleanDatabase =
        Room.databaseBuilder(
            context,
            NoteAppCleanDatabase::class.java,
            "database-clean"
        )
            .build()

    @Provides
    fun providesNoteDAO(database: NoteAppCleanDatabase): NoteDAO = database.noteDAO()
}

我们从数据库中检索实体 NoteEntityModel,并且由于我们的领域层只与 NoteDomainModel 交互,我们使用一个映射器。

// 来自 feature.notes.data.data_sources.local.mappers

fun NoteEntityModel.toNoteDomainModel(): NoteDomainModel {
    return NoteDomainModel(
        id = this.id,
        title = this.title,
        description = this.description
    )
}

数据层的责任是适应领域层的要求,而绝不是反过来。


最后,我们的最后一层:展示层。其主要功能是向用户展示数据或与他们进行任何交互,处理所有与 UI 或 UX 相关的方面。为此,我们需要一个特定的模型 NoteUiModel。这里的一个好习惯是使用 Immutable 注解它。

我们将找到三个主要元素:映射器、Composable 组件和 ViewModel。映射器遵循与数据层相同的模式:它将业务模型转换为 UI 模型。

// 来自 feature.notes.presentation

@Composable
fun NoteScreen(
    modifier: Modifier = Modifier,
    notes: List<NoteUiModel>,
    openNoteDetail: (id: Long) -> Unit
) {
    // 内容
}

Composable 组件表示我们屏幕的不同部分,例如我们的案例中的笔记列表。我们使用我们的 UI 模型 NoteUiModel,并在需要与 ViewModel 交互时使用 lambda 函数。

// 来自 feature.notes.presentation

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NoteViewModel @Inject constructor(
    private val observeNotesUseCase: ObserveNotesUseCase
) : ViewModel() {

    val noteState: StateFlow<NoteUiStateModel> by lazy {
        observeNotesUseCase()
            .mapLatest { notes ->
                if (notes.isEmpty()) {
                    NoteUiStateModel.Empty
                } else {
                    NoteUiStateModel.Fetched(notes.map { it.toNoteUiModel() }.toPersistentList())
                }
            }
            .flowOn(Dispatchers.Main)
            .stateIn(
                viewModelScope,
                SharingStarted.WhileSubscribed(5000),
                NoteUiStateModel.Loading
            )
    }
}

最后,我们的 ViewModel。我们注入我们的用例,因为对于此功能,我们只需要观察我们的笔记列表并显示它。

由于 Room 是反应式的,任何对数据库的添加都会触发可观察变量 noteState(因为它是一个 Flow)。根据接收到的数据,我们使用适当的模型调整 UI。这里,我们使用 NoteUiStateModel,它是一个包含三种可能性的密封接口:Loading(默认状态)、EmptyFetched

为了提高应用性能,可以将列表设置为持久的。这会提示 Compose 编译器将列表标记为稳定。

结论

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

项目地址及参考

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

  • 14
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Calvin880828

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值