稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

在现代Android开发中实战Clean Architecture

本文译自「Refining Clean Architecture for Android: A Practical Approach」,原文链接https://medium.com/@siarhei.krupenich/refining-clean-architecture-for-android-a-practical-approach-32ce966f8ba3,由Siarhei Krupenich发布于2025年2月23日。

简介

本文是专注于 Android 开发和设计中实际问题一系列文章中的第一篇,这些文章将会涵盖整洁架构 (Clean Architecture)、ViewModel 输入/输出拆分、Repository 模式等,并基于最佳实践提供解决方案。每篇都将聚焦于一个特定主题,并包含一个 GitHub 分支的链接,用于演示该主题的实践代码。作为开篇,本文将探讨整洁架构、其面临的挑战以及可在生产环境中应用的实用解决方案。

整洁架构(Clean Architecture)简介

整洁架构是一种结构化的方法,通过将应用程序的代码库划分为数据层、领域层和展现层来组织它。

  • 数据层(Data Layer):处理数据检索和存储,无需了解域层或表示层。
  • 领域层(Domain Layer):包含业务逻辑和用例,与数据层交互,但独立于表示层。
  • 展现层(Presentation Layer):专注于 UI,从领域层接收状态,并保持逻辑最小化。

这里关键原则是依赖关系只向内流动——每一层只了解其下一层,从而确保可维护性和关注点分离(Separation of Concerns)。下图展示了 Android 中一个简单的整洁架构 (Clean Architecture) 实现:

图 1:Android 的简单整洁架构实现

让我们开始撸吧!

首先,我们需要按照图 1构建项目结构。虽然功能模块通常是大型项目的一部分,但为了演示方便,我们将尽量简化,仅关注整洁架构。

我们将构建一个小型应用,该应用使用整洁架构 + MVVM + 协程,进行单个 API 调用并显示一个简单的列表。

为了保持独立性,每个层将被拆分为独立的模块:

  • 数据层(Data Layer):包含 Retrofit 依赖项、网络模型和用于数据访问的存储库。(也可以选择添加本地存储实现。)
  • 领域层(Domain Layer):包含用例(或交互器)、领域模型和用于将网络模型转换为领域模型的映射器。它依赖于数据模块,因为它使用其存储库。
  • 表示层(Presentation Layer):包含与领域模块中的用例交互的 ViewModel,用于处理 UI 数据。

项目结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
- Data
  API Interface (Retrofit)
  DAO
  Repository Interface
  Repository Instance
- Domain
  Use-cases and their interfaces
  Domain models
  Mappers from Network to Domain models
- Presentation
  ViewModels
  Views

数据模块(Data Module):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface DataNetworkApi {
  @GET(API_DATA) suspend fun getData(): Result<List<ApiEntity>>

  companion object { 
    private const val API_DATA = "/api/data"
  }
}

@Keep
data class DataApiEntity(
  @SerializedName("id") val id: String,
  @SerializedName("name") val name: String,
  @SerializedName("description") val description: String
)

interface DataRepository {
  suspend fun getData(isRefreshing: Boolean): Result<List<DataApiEntity>>
}

internal class DataRepositoryImpl @Inject constructor(
  private val dataNetworkApi: DataNetworkApi,
  private val dataLocalStorage: DataLocalStorage,
) : DataRepository {
  override suspend fun getData(isRefreshing: Boolean):
    Result<List<DataApiEntity>> {
    // dataLocalStorage and dataLocalStorage usage 
    
    return dataNetworkApi.getData()
  }
}

显然,这不是存储库模式(Repository Pattern)的经典实现——它纯粹是为了演示而设计的,以让我们能专注于整洁架构。

领域模块(Domain Module):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
data class DataEntity(
  val id: String,
  val title: String,
  val description: String
)

internal class DataEntityMapperImpl: DataEntityMapper {
  override fun map(param: DataApiEntity): DataEntity = with(param) {
    DataEntity(
      
    )
  }
}

interface GetDataUseCase {
  suspend operator fun invoke(param: Boolean):  Result<List<DataEntity>>
}

internal class GetDataUseCaseImpl(
  private val mapper: DataEntityMapper,
  private val repository: DataRepository
) : GetDataUseCase {
  override suspend operator fun invoke(
    param: Boolean
  ): Result<List<DataEntity>> =
    repository.getData(param).map { repositoryData ->
      repositoryData.map { data -> mapper.map(data) }
    }
}

展现模块(Presentation Module):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
internal sealed interface UiDataState {
   data class Success(val repos: List<Data>): UiDataState
   data object Empty: UiDataState
   data object Loading: UiDataState
   data class Error(val message: String?): UiDataState
}

interface GetUiDataUseCase {
  suspend operator fun invoke(param: Boolean): UiDataState
}

internal class GetUiDataUseCaseImpl constructor(
  private val getData: GetDataUseCase,
  private val mapUiData: MapDataUiModelUseCase,
  private val mapUiStateData: MapDataUiStateUseCase
): GetUiDataUseCase {
  override suspend fun invoke(param: Boolean): UiDataState =
    mapUiStateData(
      getData(param).mapCatching {
        it.map(mapUiRepos::invoke)
      }
    )
}

关于 GetUiDataUseCaseImpl 有几个关键点需要注意。如你所见,它包含一个上述 GetDataUseCase 实现的实例以及两个额外的映射器。MapDataUiModelUseCase 可以将领域模型转换为 UI 模型(属于表示层)。同时,MapDataUiStateUseCase 确定相应的 UI 状态,可以是以下之一:

  • 正在加载(Loading)
  • 空数据(Empty)
  • 非空(Non-Empty)
  • 错误(Error)

ViewModel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TestViewModel constructor(
  private val getData: GetUiDataUseCase,
) : ViewModel() {

  val state: StateFlow<State> = _state.asStateFlow()

  init {
    refresh(true)
  }

  fun refresh(isRefreshing: Boolean) {
    updateState(isRefreshing)
  }

  private fun updateState(isRefreshing: Boolean) = launch {
     _state.value = RepoState.Loading
     _state.value = getData(isRefreshing)
  }
}

GitHub 项目与提供的代码片段略有不同,因为它使用了 ViewModel 输入/输出模式和 Hilt 依赖注入——这两者都是可扩展开发的基本原则,我们将在后续文章中探讨。此外,所有组件都用接口包装,确保可测试性,同时允许在接口级别进行无缝操作。后续文章将深入探讨这些方面。

我们成功了!现在,让我们深入研究代码。每一层都负责特定的功能,使其既模块化又易于测试。数据层的存储库依赖于 API 和本地存储,而领域用例依赖于存储库。表示层与领域用例交互。

如果我需要替换 HTTP 客户端,我只需将新的依赖项注入存储库,同时保持契约,确保功能无缝衔接。此外,每个组件都可以被单元测试覆盖,从而使架构可扩展且易于维护。

同样需要强调的是,接口应该属于使用它们的层。乍一看,一切都井井有条,似乎没有什么需要改进的地方。但让我们仔细看看。

经典整洁架构实现的缺点

观察结果和潜在问题

我们上面开发的实现允许直接访问底层模块组件,例如 API 实现或数据库存储。一种可能的解决方案是将它们内部化,并确保只能通过存储库访问数据。然而,这种方法仍然存在一个关键的缺点:存储库的接口及其实例都保存在同一个数据模块中。这会导致一些潜在的风险:

  1. 违反封装——直接使用具体实例而不是接口存在风险,这也会影响接口隔离原则。
  2. 违反整洁架构契约——随着项目的发展,可能会出现偷工减料的倾向,例如允许领域用例与数据库实例而不是存储库接口交互。
  3. 存储库委托的复杂性增加——将组件绑定在一起变得更具挑战性,尤其是在依赖关系增加的情况下。
  4. 更高的维护成本——随着时间的推移,违反架构契约会导致维护工作量增加,并降低代码的可扩展性。
  5. 更复杂的单元测试——对低级组件的无限制访问使测试变得复杂,因为依赖项可能会以非预期的方式使用。

缓解上述风险的唯一方法是将职责进一步拆分成更多独立的层和模块。数据模块应继续包含 API 和数据存储的实例,但所有存储库接口都应移出。建议在领域模块内创建子模块,并确保它们保持独立。

此外,API 和数据库实例可以通过专用服务层(例如 API 服务和本地存储服务)访问。这不仅增强了可测试性,还强制执行了适当的抽象。此外,数据实体应放置在数据子模块中,这些子模块仍与域层保持连接。

通过遵循这种方法,所有组件仅通过各自的接口进行交互,而无需直接了解它们的实际实现。这确保了更好的模块化、可维护性,并遵循了整洁架构原则。

下图展示了改进的解决方案:

图 2. 增强型 Android Clean Architecture 实现

以下项目结构说明了增强的架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- Data
  API Interface (Retrofit)
  DAO
  Data Services
  API Services
  Repositories implementations of Domain.Repository
- Domain
  Use-cases
  Domain Entities
  Mappers from Network models to Domain ones
  -- Repository
     Repository Interface
     API Entity
- Presentation
  ViewModels
  Views

重新开始编码

现在,我们将重点关注架构的改进部分。你可以通过以下链接在 GitHub 上找到完整的示例。

Domain.Repository 子模块:

1
2
3
interface DataRepository {
   suspend fun getData(isRefreshing: Boolean): Result<List<DataEntity>>
}

Data Module:

1
2
3
4
5
6
internal class DataRepositoryImpl @Inject constructor(
   private val dataNetworkApi: DataNetworkApi,
) : DataRepository {
   override suspend fun getData(isRefreshing: Boolean) =
       dataNetworkApi.getData()
}

Domain Module:

1
2
3
4
5
6
7
8
9
10
internal class GetDataUseCaseImpl(
   private val mapper: DataEntityMapper,
   private val repository: DataRepository
) : GetDataUseCase {
   override suspend operator fun invoke(param: Boolean): Result<List<DataEntity>> =
       repository.getData(param)
           .map { listOfData ->
               listOfData.map { data -> mapper.map(data) }
           }
}

结论

总而言之,我们实现了增强版的整洁架构。逻辑可以按功能进一步划分,每个功能模块都遵循其自身的整洁架构结构。此外,这种划分可以扩展到展现层,确保领域层和展现层之间的界限清晰区分。这种方法允许 UI用例严格通过接口与领域用例交互,从而保持模块化和可扩展性。

额外注意事项

项目示例演示了这种方法。通过实现了一个简单的应用程序,可以进行 API 调用、接收数据并显示列表。为了使应用程序功能齐全,它还使用了本文未涉及的其他组件:Retrofit、Hilt 以及包含通用组件的核心模块。这些额外组件不会影响你对本文主题的理解。

完整实例代码:https://github.com/sergeykrupenich/TestRepo/tree/clean-architecture

Comments