[CleanArchitecture] Google官方的Nowinandroid是如何抽出抽象层(Domain Layer)的

Google官方的安卓应用Nowinandroid使用了目前很主流的技术,其中在架构分层方面使用到了干净架构即CleanArchitecture,该架构配合MVVM模式可以大大提升可读性、拓展性以及可移植性,本文主要学习Google是如何抽出抽象层的。

补充:干净架构的分层如下

随着业务的不断迭代,工程会变得越来越庞大,这时候引入抽象层是很有必要的,抽象层负责管理app中的业务逻辑,除了提升可读性外,还可以将多个地方共用的逻辑抽取并封装成usecases,便于在多个ViewModel中使用。

什么是Use case?

Use case意思为用例,一般来说就是函数(也可以是一个class,内部只有一个简单的public method),一个用例代表一个逻辑或者操作,用例执行后会组合或者拉取Data Layer、其它用例的数据,比如读取用户数据即可作为一个用例。

用例的命名一般按照逻辑名_UseCase的格式,便于阅读理解,google使用的是动词+名词+UseCase格式,如:

class GetUserNewsResourcesUseCase @Inject constructor(
    private val newsRepository: NewsRepository,
    private val userDataRepository: UserDataRepository
) {
    /**
     * Returns a list of UserNewsResources which match the supplied set of topic ids.
     *
     * @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
     * this is empty the list of news resources will not be filtered.
     */
    operator fun invoke(
        filterTopicIds: Set<String> = emptySet()
    ): Flow<List<UserNewsResource>> =
        if (filterTopicIds.isEmpty()) {
            newsRepository.getNewsResources()
        } else {
            newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
        }.mapToUserNewsResources(userDataRepository.userData)
}

可以看到该GetUserNewsResourcesUseCase是一个class,重写了其invoke方法,invoke方法会返回用例执行后的返回值,除此之外没有其它方法。

抽取出抽象层的流程

总体可以分享为以下几步:

  1. 找出ViewModel中复杂的和重复的业务逻辑
  2. 创建对应逻辑的use cases
  3. 将逻辑移动到use cases
  4. 重构ViewModel,构建的时候传入的是use cases而不是repositories
  5. 编写use cases的单元测试
  • 找出ViewModel中复杂的和重复的业务逻辑
    Nowinandroid的app分为三个大模块:For you、Saved(Bookmarks)、Interests,对应三个tab页面,它们的ViewModel中的业务逻辑如下图所示:

可以看到在observes栏有很多相同颜色的逻辑,为了逻辑复用,直接将相同的逻辑抽出来封装成use case(Candidate UseCase栏),可以使得ViewModel更简洁明了。

  • 将逻辑移动到use cases,重构ViewModel
    BookmarksVM、ForYouVM、TopicVM都用到了相同的UseCase-GetSavableNewsResourcesUseCase,来看下这个UseCase怎么封装。
    class GetUserNewsResourcesUseCase @Inject constructor(
        private val newsRepository: NewsRepository,
        private val userDataRepository: UserDataRepository
    ) {
        /**
         * Returns a list of UserNewsResources which match the supplied set of topic ids.
         *
         * @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
         * this is empty the list of news resources will not be filtered.
         */
        operator fun invoke(
            filterTopicIds: Set<String> = emptySet()
        ): Flow<List<UserNewsResource>> =
            if (filterTopicIds.isEmpty()) {
                newsRepository.getNewsResources()
            } else {
                newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
            }.mapToUserNewsResources(userDataRepository.userData)
    }

    private fun Flow<List<NewsResource>>.mapToUserNewsResources(
        userDataStream: Flow<UserData>
    ): Flow<List<UserNewsResource>> =
        filterNot { it.isEmpty() }
            .combine(userDataStream) { newsResources, userData ->
                //组合数据源
                newsResources.mapToUserNewsResources(userData)
            }

很简单,构造的时候传入了多个数据源Repository,用例执行的时候将多个数据源的数据组合起来并返回,现在BookmarksVM中的逻辑就很简单明了了,执行用例拿到数据做筛选并转换成热流给UI层使用,其它使用到相同逻辑的ViewModel也是如此。

  @HiltViewModel
  class BookmarksViewModel @Inject constructor(
      private val userDataRepository: UserDataRepository,
      getSaveableNewsResources: GetUserNewsResourcesUseCase
  ) : ViewModel() {

      val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources() //执行用例
          .filterNot { it.isEmpty() }
          .map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
          .map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
          .onStart { emit(Loading) }
          .stateIn(
              //转换成StateFlow
              scope = viewModelScope,
              started = SharingStarted.WhileSubscribed(5_000),
              initialValue = Loading
          )

      fun removeFromSavedResources(newsResourceId: String) {
          viewModelScope.launch {
              userDataRepository.updateNewsResourceBookmark(newsResourceId, false)
          }
      }
  }
  • 抽象层代码单独存放在一个module

可以看到module里面主要是包含usecases和model,这里的model类代表用例执行后的结果,如果你的用例返回结果结合了多个数据源,那么model类即是做一个结果封装,反之如果用例的数据来源是单一的,那么它使用到的model结构其实跟data层的结构是一样的,但为了保持接结构分层,一般也会单独建一个model。

  • 编写UseCase的单元测试
    一个工程有了好的代码分层还不够,单元测试也是很重要的,UseCase作为一个逻辑用例,编写代码的时候要关注是否是可测试的,其中有一点比较重要的原则是:单一职责原则,即所测试单元的职责是单一的(如只进行数据的拉取结合操作),并不负责创建数据源repository,数据源由构建函数传入,这样保证了数据源是可以mock的。

    class GetUserNewsResourcesUseCaseTest {
    
        @get:Rule
        val mainDispatcherRule = MainDispatcherRule()
    
        private val newsRepository = TestNewsRepository()
        private val userDataRepository = TestUserDataRepository()
    
        val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository)
    
        @Test
        fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
    
            // Obtain the user news resources stream.
            val userNewsResources = useCase()
    
            // Send some news resources and user data into the data repositories.
            newsRepository.sendNewsResources(sampleNewsResources)
    
            // Construct the test user data with bookmarks and followed topics.
            val userData = emptyUserData.copy(
                bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
                followedTopics = setOf(sampleTopic1.id)
            )
    
            userDataRepository.setUserData(userData)
    
            // Check that the correct news resources are returned with their bookmarked state.
            assertEquals(
                sampleNewsResources.mapToUserNewsResources(userData),
                userNewsResources.first()
            )
        }
    }
    
    

总结

  • 抽象层单独放到一个module,里面存放用例(usecase)以及model
  • usecase代表一个业务逻辑,一般是一个函数或者只有invoke方法的类,执行后返回结果
  • 抽取抽象层的步骤一般为:找出ViewModel中复杂的和重复的业务逻辑,将逻辑移到usecase里,ViewModel构建的时候传入usecase

作者:linversion
链接:https://juejin.cn/post/7189535902375346234

猜你喜欢

转载自blog.csdn.net/datian1234/article/details/128767107