发现 Android Clean Architecture 的优势

已发表: 2022-03-11

你更喜欢什么:为架构糟糕但运行良好的应用程序添加新功能,或者修复架构良好但有漏洞的 Android 应用程序中的错误? 就个人而言,我肯定会选择第二个选项。 考虑到每个类中所有内容的所有依赖关系,添加一个新功能,即使是一个简单的功能,在应用程序中也会变得非常费力。 我记得我的一个 Android 项目,项目经理让我添加一个小功能——比如下载数据并将其显示在新屏幕上。 这是我的一位找到新工作的同事编写的应用程序。 该功能不应超过半个工作日。 我非常乐观……

在对应用程序如何工作、有哪些模块以及它们如何相互通信进行了 7 个小时的调查后,我对该功能进行了一些试用实现。 那是地狱。 数据模型的微小变化迫使登录屏幕发生重大变化。 添加网络请求需要更改几乎所有屏幕和GodOnlyKnowsWhatThisClassDoes类的实现。 将数据保存到数据库或整个应用程序崩溃时,按钮颜色更改会导致奇怪的行为。 第二天中途,我告诉我的项目经理,“我们有两种方法来实现这个功能。 首先,我可以多花三天时间,最后会以非常肮脏的方式实现它,并且每个下一个功能或错误修复的实现时间都会成倍增长。 或者,我可以重写应用程序。 这将需要我两三周的时间,但我们会为未来的应用程序更改节省时间。”幸运的是,他同意了第二种选择。 如果我曾经怀疑为什么应用程序中的良好软件架构(即使是非常小的一个)很重要,这个应用程序完全消除了它们。 但是我们应该使用哪种 Android 架构模式来避免此类问题呢?

在本文中,我想向您展示一个 Android 应用程序中的干净架构示例。 然而,这种模式的主要思想可以适应每个平台和语言。 好的架构应该独立于平台、语言、数据库系统、输入或输出等细节。

示例应用

我们将创建一个简单的 Android 应用程序来注册我们的位置,并具有以下功能:

  • 用户可以创建一个具有名称的帐户。
  • 用户可以编辑帐户名称。
  • 用户可以删除该帐户。
  • 用户可以选择活动帐户。
  • 用户可以保存位置。
  • 用户可以看到用户的位置列表。
  • 用户可以看到用户列表。

清洁架构

这些层是干净架构的主要核心。 在我们的应用程序中,我们将使用三层:表示层、域和模型。 每一层都应该分开,不需要知道其他层。 它应该存在于自己的世界中,并且最多共享一个小接口进行通信。

层职责:

  • Domain:包含我们应用的业务规则。 它应该提供反映我们应用程序功能的用例。
  • 展示:向用户展示数据,并收集必要的数据,如用户名。 这是一种输入/输出。
  • 模型:为我们的应用程序提供数据。 负责从外部获取数据,并保存到数据库、云服务器等。

哪些层应该知道其他层? 获得答案的最简单方法是考虑变化。 让我们以表示层为例——我们将向用户展示一些东西。 如果我们在表示中改变一些东西,我们是否也应该在模型层中做出改变? 想象一下,我们有一个“用户”屏幕,其中包含用户的姓名和最后的位置。 如果我们想呈现用户的最后两个位置而不是只显示一个,我们的模型应该不会受到影响。 所以,我们有第一个原则:表示层不知道模型层。

而且,相反——模型层应该知道表示层吗? 再说一次——不,因为如果我们将数据源从数据库更改为网络,它不应该改变 UI 中的任何内容(如果您考虑在这里添加一个加载器——是的,但我们也可以有一个 UI 加载器使用数据库时)。 所以这两层是完全分开的。 伟大的!

领域层呢? 它是最重要的,因为它包含所有主要的业务逻辑。 这是我们想要在将数据传递给模型层或将其呈现给用户之前处理数据的地方。 它应该独立于任何其他层——它对数据库、网络或用户界面一无所知。 由于这是核心,其他层将只与这一层通信。 为什么我们要让这个完全独立? 与 UI 设计或数据库或网络存储中的某些内容相比,业务规则的更改可能更少。 我们将通过一些提供的接口与该层进行通信。 它不使用任何具体的模型或 UI 实现。 这些都是细节,记住——细节会改变。 一个好的架构并不局限于细节。

理论到此为止。 让我们开始编码吧! 本文围绕代码展开,因此——为了更好地理解——您应该从 GitHub 下载代码并检查其中的内容。 创建了三个 Git 标签 —architecture_v1、architecture_v2 和 architecture_v3,它们对应于文章的各个部分。

应用技术

在应用程序中,我使用 Kotlin 和 Dagger 2 进行依赖注入。 这里既不需要 Kotlin 也不需要 Dagger 2,但它使事情变得容易得多。 你可能会对我不使用 RxJava(也不是 RxKotlin)感到惊讶,但我发现它在这里没有用,而且我不喜欢使用任何库,只是因为它在顶部并且有人说它是必须的。 正如我所说的——语言和库是细节,所以你可以使用你想要的。 还使用了一些 Android 单元测试库:JUnit、Robolectric 和 Mockito。

领域

在我们的 Android 应用架构设计中,最重要的层是领域层。 让我们从它开始。 这就是我们的业务逻辑和与其他层通信的接口所在的地方。 主要核心是UseCase ,它反映了用户可以使用我们的应用程序做什么。 让我们为它们准备一个抽象:

 abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }

我决定在这里使用 Kotlin 的协程。 每个UseCase都必须实现一个 run 方法来提供数据。 该方法在后台线程调用,收到结果后,在UI线程上传递。 返回的类型是OneOf<F, T> ——我们可以用数据返回错误或成功:

 sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }

领域层需要自己的实体,所以下一步是定义它们。 我们现在有两个实体: UserUserLocation

 data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

现在我们知道要返回什么数据,我们必须声明数据提供者的接口。 这些将是IUsersRepositoryILocationsRepository 。 它们必须在模型层中实现:

 interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }

这组操作应该足以为应用程序提供必要的数据。 在这个阶段,我们不决定如何存储数据——这是一个我们希望独立的细节。 目前,我们的领域层甚至不知道它在 Android 上。 我们将尝试保持这种状态(有点。我稍后会解释)。

最后一步(或几乎最后一步)是为我们的UseCase定义实现,这将由演示数据使用。 它们都非常简单(就像我们的应用程序和数据一样简单)——它们的操作仅限于从存储库中调用适当的方法,例如:

 class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }

Repository抽象使我们的UseCases非常容易测试——我们不必关心网络或数据库。 它可以以任何方式模拟,因此我们的单元测试将测试实际用例,而不是其他不相关的类。 这将使我们的单元测试简单快速:

 @RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }

目前,领域层已经完成。

模型

作为一名 Android 开发人员,您可能会选择 Room,这是用于存储数据的新 Android 库。 但是让我们想象一下,项目经理问你是否可以推迟关于数据库的决定,因为管理层正试图在 Room、Realm 和一些新的、超快速的存储库之间做出决定。 我们需要一些数据来开始使用 UI,所以我们暂时将其保存在内存中:

 class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }

介绍

两年前,我写了一篇关于 MVP 作为 Android 非常好的应用程序结构的文章。 当 Google 发布了伟大的架构组件,这使得 Android 应用程序开发变得更加容易时,MVP 不再需要并且可以被 MVVM 取代; 然而,这种模式的一些想法仍然非常有用——比如关于哑视图的想法。 他们应该只关心显示数据。 为此,我们将使用 ViewModel 和 LiveData。

我们应用程序的设计非常简单——一个带有底部导航的活动,其中两个菜单条目显示locations片段或users片段。 在这些视图中,我们使用 ViewModels,而后者又使用来自领域层的UseCase ,从而保持通信整洁和简单。 例如,这里是LocationsViewModel

 class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }

对那些不熟悉 ViewModel 的人稍微解释一下——我们的数据存储在位置变量中。 当我们从getLocations用例中获取数据时,它们被传递给LiveData值。 此更改将通知观察者,以便他们可以做出反应并更新他们的数据。 我们为片段中的数据添加一个观察者:

 class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }

在每次位置更改时,我们只需将新数据传递给分配给回收器视图的适配器——这就是在回收器视图中显示数据的正常 Android 流程的去向。

因为我们在视图中使用 ViewModel,所以它们的行为也很容易测试——我们可以只模拟 ViewModel,而不关心数据源、网络或其他因素:

 @RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }

您可能会注意到表示层也被分成具有清晰边界的较小层。 像activitiesfragmentsViewHolders等视图只负责显示数据。 他们只知道 ViewModel 层——并且只使用它来获取或发送用户和位置。 它是一个与域通信的 ViewModel。 视图的 ViewModel 实现与域的 UseCases 相同。 套用一句话,干净的架构就像一个洋葱——它有层,层也可以有层。

依赖注入

我们已经为我们的架构创建了所有类,但还有一件事要做——我们需要将所有东西连接在一起的东西。 表示层、域层和模型层保持干净,但我们需要一个模块,它是肮脏的,并且将了解所有事物的一切——通过这种知识,它将能够连接我们的层。 实现它的最好方法是使用一种常见的设计模式(SOLID 中定义的干净代码原则之一)——依赖注入,它为我们创建适当的对象并将它们注入到所需的依赖中。 我在这里使用了 Dagger 2(在项目中间,我将版本更改为 2.16,它的样板更少),但您可以使用任何您喜欢的机制。 最近玩了一下Koin库,觉得也值得一试。 我想在这里使用它,但是在测试时模拟 ViewModel 时遇到了很多问题。 我希望我能找到一种方法来快速解决它们——如果是这样,我可以在使用 Koin 和 Dagger 2 时展示这个应用程序的不同之处。

您可以在 GitHub 上使用标签 architecture_v1 检查此阶段的应用程序。

变化

我们完成了图层,测试了应用程序——一切正常! 除了一件事——我们仍然需要知道我们的 PM 想要使用什么数据库。 假设他们来找你说管理层同意使用 Room,但他们仍然希望将来有可能使用最新的超高速库,所以你需要记住潜在的变化。 此外,其中一位利益相关者询问数据是否可以存储在云中,并想知道这种更改的成本。 因此,现在是检查我们的架构是否良好以及我们是否可以在不更改表示或域层的情况下更改数据存储系统的时候了。

变化 1

使用 Room 的第一件事是为数据库定义实体。 我们已经有了一些: UserUserLocation 。 我们所要做的就是添加像@Entity@PrimaryKey这样的注解,然后我们就可以在我们的模型层和数据库中使用它。 伟大的! 这是打破我们想要保留的所有架构规则的绝佳方式。 实际上,域实体不能通过这种方式转换为数据库实体。 想象一下,我们还想从网络下载数据。 我们可以使用更多的类来处理网络响应——转换我们的简单实体以使它们与数据库和网络一起工作。 这是通向未来灾难的最短路径(并且哭着说:“到底是谁写了这段代码?”)。 对于我们使用的每种数据存储类型,我们都需要单独的实体类。 它不会花费太多,所以让我们正确定义 Room 实体:

 @Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )

如您所见,它们与域实体几乎相同,因此很容易合并它们。 这只是一个意外——数据越复杂,相似度就会越小。

接下来,我们必须实现UserDAOUserLocationsDAO ,我们的AppDatabase ,最后是IUsersRepositoryILocationsRepository的实现。 这里有一个小问题—— ILocationsRepository应该返回一个UserLocation ,但它从数据库接收一个UserLocationEntity 。 与User相关的类也是如此。 相反,当数据库需要UserLocationEntity UserLocation 为了解决这个问题,我们需要域和数据实体之间的Mappers器。 我使用了我最喜欢的 Kotlin 功能之一——扩展。 我创建了一个名为Mapper.kt的文件,并将类之间映射的所有方法都放在那里(当然,它在模型层——域不需要它):

 fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

我之前提到的小谎言是关于域实体的。 我写道,他们对 Android 一无所知,但这并不完全正确。 我将@Parcelize注释添加到User实体并在那里扩展Parcelable ,从而可以将实体传递给片段。 对于更复杂的结构,我们应该提供视图层自己的数据类,并在域和数据模型之间创建映射器。 将Parcelable添加到域实体是我敢于冒的一个小风险——我知道这一点,如果User实体发生任何更改,我将为表示创建单独的数据类并从域层中删除Parcelable

最后要做的是更改我们的依赖注入模块以提供新创建的Repository实现而不是以前的MemoryRepository 。 在我们构建并运行应用程序之后,我们可以去 PM 显示带有 Room 数据库的工作应用程序。 我们还可以通知 PM,添加网络不会花费太多时间,并且我们对管理层想要的任何存储库都是开放的。 您可以检查哪些文件已更改——仅是模型层中的文件。 我们的架构真的很整洁! 每个下一个存储类型都可以以相同的方式构建,只需扩展我们的存储库并提供适当的实现。 当然,我们可能需要多个数据源,例如数据库和网络。 然后怎样呢? 没什么,我们只需要创建三个存储库实现——一个用于网络,一个用于数据库,一个用于选择正确的数据源(例如,如果我们有一个网络,从网络,如果没有,则从数据库加载)。

您可以使用标签 architecture_v2 在 GitHub 上查看此阶段的应用程序。

所以,这一天快结束了——你正坐在电脑前喝杯咖啡,准备将应用程序发送到 Google Play,突然项目经理来问你“你能添加一个功能吗?可以从 GPS 中保存用户的当前位置吗?”

变化 2

一切都在改变……尤其是软件。 这就是为什么我们需要干净的代码和干净的架构。 但是,如果我们不假思索地编码,即使是最干净的东西也可能是肮脏的。 实现从 GPS 获取位置时的第一个想法是在活动中添加所有位置感知代码,在我们的SaveLocationDialogFragment中运行它并使用相应的数据创建一个新的UserLocation 。 这可能是最快的方法。 但是,如果我们疯狂的 PM 来找我们并要求我们将位置从 GPS 转移到其他提供商(例如,蓝牙或网络之类的东西)怎么办? 这些变化很快就会失控。 我们怎样才能以干净的方式做到这一点?

用户位置是数据。 获取位置是一个UseCase ——所以我认为我们的领域和模型层也应该在这里涉及。 因此,我们还有一个UseCase需要实现GetCurrentLocation 。 我们还需要一些可以为我们提供位置的东西——一个ILocationProvider接口,以使UseCase独立于诸如 GPS 传感器之类的细节:

 interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }

你可以看到我们这里还有一种方法——取消。 这是因为我们需要一种方法来取消 GPS 位置更新。 我们在模型层中定义的Provider实现在这里:

 class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }

该提供程序已准备好与 Kotlin 协程一起使用。 如果你还记得的话, UseCase的 run 方法是在后台线程上调用的——所以我们必须确保并正确地标记我们的线程。 正如你所看到的,我们必须在这里传递一个活动——当我们不再需要它们以避免内存泄漏时,取消更新和从监听器注销是至关重要的。 由于它实现了ILocationProvider ,我们可以在未来轻松地将其修改为其他提供者。 我们还可以轻松地测试当前位置的处理(自动或手动),即使没有在我们的手机中启用 GPS——我们所要做的就是替换实现以返回一个随机构建的位置。 为了让它工作,我们必须将新创建的UseCase添加到LocationsViewModel中。 反过来,ViewModel 必须有一个新方法getCurrentLocation ,它将实际调用用例。 只需对 UI 进行一些小的更改即可调用它并在 Dagger 中注册 GPSProvider——瞧,我们的应用程序就完成了!

概括

我试图向您展示我们如何开发一个易于维护、测试和更改的 Android 应用程序。 它也应该很容易理解——如果有人新加入你的工作,他们在理解数据流或结构方面应该没有问题。 如果他们知道架构是干净的,他们可以确定 UI 中的更改不会影响模型中的任何内容,并且添加新功能不会超出预期。 但这并不是旅程的终点​​。 即使我们有一个结构良好的应用程序,也很容易被混乱的代码更改破坏它“只是暂时,只是为了工作”。 请记住——没有“暂时”的代码。 每个违反我们规则的代码都可以保留在代码库中,并且可能成为未来更大的中断的来源。 如果您在一周后看到该代码,看起来有人在该代码中实现了一些强依赖关系,并且要解决它,您必须深入研究应用程序的许多其他部分。 一个好的代码架构不仅在项目开始时是一个挑战,它对 Android 应用程序生命周期的任何部分都是一个挑战。 每次发生变化时都应该考虑和检查代码。 要记住这一点,例如,您可以打印并挂起您的 Android 架构图。 您还可以通过将它们分成三个 Gradle 模块来稍微强制层的独立性,其中域模块不知道其他模块,并且表示和模型模块不相互使用。 但即使这样也不能取代这样一种意识,即应用程序代码中的混乱会在我们最意想不到的时候报复我们。