Android 클린 아키텍처의 이점 알아보기

게시 됨: 2022-03-11

당신은 무엇을 선호합니까? 형편없는 아키텍처로 아주 잘 작동하는 앱에 새로운 기능을 추가하거나, 잘 설계되었지만 버그가 있는 Android 애플리케이션의 버그를 수정하시겠습니까? 개인적으로 나는 확실히 두 번째 옵션을 선택할 것입니다. 모든 클래스에 있는 모든 것의 모든 종속성을 고려할 때 간단한 기능일지라도 새로운 기능을 추가하는 것은 앱에서 매우 힘들 수 있습니다. 내 Android 프로젝트 중 하나에서 프로젝트 관리자가 나에게 데이터를 다운로드하고 새 화면에 표시하는 것과 같은 작은 기능을 추가하도록 요청한 것을 기억합니다. 새 직장을 구한 동료 중 한 명이 작성한 앱입니다. 이 기능은 근무일의 반을 넘지 않아야 합니다. 나는 매우 낙관적이었다...

앱이 어떻게 작동하는지, 어떤 모듈이 있는지, 서로 어떻게 통신하는지에 대해 7시간 동안 조사한 후 기능을 시험적으로 구현했습니다. 지옥이었다. 데이터 모델의 작은 변화가 로그인 화면의 큰 변화를 가져왔습니다. 네트워크 요청을 추가하려면 거의 모든 화면과 GodOnlyKnowsWhatThisClassDoes 클래스의 구현을 변경해야 했습니다. 버튼 색상 변경으로 인해 데이터를 데이터베이스에 저장할 때 이상한 동작이 발생하거나 전체 앱 충돌이 발생했습니다. 다음 날 중반에 저는 프로젝트 관리자에게 이렇게 말했습니다. “이 기능을 구현하는 방법에는 두 가지가 있습니다. 첫째, 나는 3일을 더 보낼 수 있고 마침내 그것을 매우 더러운 방식으로 구현할 것이고, 모든 다음 기능이나 버그 수정의 구현 시간은 기하급수적으로 증가할 것입니다. 또는 앱을 다시 작성할 수 있습니다. 2~3주가 걸리겠지만 향후 앱 변경을 위해 시간을 절약할 수 있습니다.” 다행히 두 번째 옵션에 동의했습니다. 앱의 좋은 소프트웨어 아키텍처(아주 작은 것이라도)가 왜 중요한지 의구심이 들었다면, 이 앱은 그것들을 완전히 없앴습니다. 그러나 이러한 문제를 피하기 위해 어떤 Android 아키텍처 패턴을 사용해야 할까요?

이 기사에서는 Android 앱의 깔끔한 아키텍처 예제를 보여드리고자 합니다. 그러나 이 패턴의 주요 아이디어는 모든 플랫폼과 언어에 적용할 수 있습니다. 좋은 아키텍처는 플랫폼, 언어, 데이터베이스 시스템, 입력 또는 출력과 같은 세부 사항과 무관해야 합니다.

예시 앱

다음 기능을 사용하여 위치를 등록하는 간단한 Android 앱을 만들 것입니다.

  • 사용자는 이름으로 계정을 만들 수 있습니다.
  • 사용자는 계정 이름을 편집할 수 있습니다.
  • 사용자는 계정을 삭제할 수 있습니다.
  • 사용자는 활성 계정을 선택할 수 있습니다.
  • 사용자는 위치를 저장할 수 있습니다.
  • 사용자는 사용자의 위치 목록을 볼 수 있습니다.
  • 사용자는 사용자 목록을 볼 수 있습니다.

클린 아키텍처

레이어는 클린 아키텍처의 핵심입니다. 우리 앱에서는 프레젠테이션, 도메인 및 모델의 세 가지 레이어를 사용합니다. 각 레이어는 분리되어야 하며 다른 레이어에 대해 알 필요가 없습니다. 자체 세계에 존재해야 하며 기껏해야 통신을 위한 작은 인터페이스를 공유해야 합니다.

계층 책임:

  • 도메인: 앱의 비즈니스 규칙을 포함합니다. 앱의 기능을 반영하는 사용 사례를 제공해야 합니다.
  • 프레젠테이션: 사용자에게 데이터를 제공하고 사용자 이름과 같은 필요한 데이터도 수집합니다. 이것은 일종의 입출력입니다.
  • 모델: 앱에 대한 데이터를 제공합니다. 외부 소스에서 데이터를 가져와 데이터베이스, 클라우드 서버 등에 저장하는 역할을 합니다.

어떤 계층이 다른 계층에 대해 알아야 합니까? 답을 얻는 가장 간단한 방법은 변화에 대해 생각하는 것입니다. 프레젠테이션 계층을 살펴보겠습니다. 사용자에게 무언가를 제시할 것입니다. 프레젠테이션에서 무언가를 변경하면 모델 레이어도 변경해야 합니까? 사용자 이름과 마지막 위치가 있는 "사용자" 화면이 있다고 상상해 보십시오. 사용자의 마지막 두 위치를 하나만 표시하지 않고 표시하려면 모델이 영향을 받지 않아야 합니다. 따라서 첫 번째 원칙 이 있습니다. 프레젠테이션 계층은 모델 계층에 대해 알지 못합니다.

그리고 그 반대입니다. 모델 계층은 프레젠테이션 계층에 대해 알아야 합니까? 다시 말하지만, 예를 들어 데이터베이스에서 네트워크로 데이터 소스를 변경하면 UI에서 아무 것도 변경하지 않아야 하기 때문입니다. 데이터베이스를 사용할 때). 따라서 두 레이어는 완전히 분리됩니다. 엄청난!

도메인 레이어는 어떻습니까? 모든 주요 비즈니스 로직을 포함하고 있기 때문에 가장 중요한 것입니다. 여기에서 데이터를 모델 계층으로 전달하거나 사용자에게 제공하기 전에 데이터를 처리해야 합니다. 데이터베이스, 네트워크 또는 사용자 인터페이스에 대해 전혀 알지 못하는 다른 계층과 독립적이어야 합니다. 이것이 핵심이므로 다른 계층은 이 계층과만 통신합니다. 왜 우리는 이것을 완전히 독립적으로 원합니까? 비즈니스 규칙은 아마도 UI 디자인이나 데이터베이스나 네트워크 스토리지에 있는 것보다 덜 자주 변경될 것입니다. 우리는 제공된 인터페이스를 통해 이 계층과 통신할 것입니다. 구체적인 모델이나 UI 구현을 사용하지 않습니다. 이것은 세부 사항이며 세부 사항이 변경된다는 점을 기억하십시오. 좋은 아키텍처는 세부 사항에 구속되지 않습니다.

지금은 이론으로 충분합니다. 코딩을 시작해 봅시다! 이 기사는 코드를 중심으로 진행되므로 더 나은 이해를 위해 GitHub에서 코드를 다운로드하고 내부에 무엇이 있는지 확인해야 합니다. 기사의 부분에 해당하는 세 가지 Git 태그(architecture_v1, architecture_v2 및 architecture_v3)가 생성되었습니다.

앱 기술

앱에서 나는 의존성 주입을 위해 Kotlin과 Dagger 2를 사용합니다. 여기서는 Kotlin이나 Dagger 2가 필요하지 않지만 작업이 훨씬 쉬워집니다. 내가 RxJava(또는 RxKotlin)를 사용하지 않는다는 사실에 놀랄지 모르지만 여기에서 사용할 수 있는 것을 찾지 못했고 어떤 라이브러리도 사용하는 것을 좋아하지 않습니다. 상위에 있고 누군가가 필수라고 말하기 때문입니다. 내가 말했듯이 언어와 라이브러리는 세부 사항이므로 원하는 것을 사용할 수 있습니다. JUnit, Robolectric 및 Mockito와 같은 일부 Android 단위 테스트 라이브러리도 사용됩니다.

도메인

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 개발자는 데이터 저장을 위한 새로운 Android 라이브러리인 Room을 선택할 것입니다. 그러나 경영진이 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) } }

프레젠테이션

2년 전, 나는 안드로이드를 위한 아주 좋은 앱 구조로서 MVP에 대한 기사를 썼습니다. 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 } }

ViewModels에 익숙하지 않은 사람들을 위한 약간의 설명 - 우리 데이터는 위치 변수에 저장됩니다. 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" } }

프레젠테이션 레이어도 경계가 명확한 작은 레이어로 분리되어 있음을 알 수 있습니다. activities , fragments , ViewHolders 등과 같은 보기는 데이터 표시에만 책임이 있습니다. 그들은 ViewModel 레이어에 대해서만 알고 있으며 사용자와 위치를 가져오거나 보내는 데만 사용합니다. 도메인과 통신하는 ViewModel입니다. ViewModel 구현은 도메인에 대한 UseCase와 보기에 대한 것과 동일합니다. 바꾸어 말하면 클린 아키텍처는 양파와 같습니다. 레이어가 있고 레이어도 레이어를 가질 수 있습니다.

의존성 주입

우리는 아키텍처를 위한 모든 클래스를 만들었지만 한 가지 더 해야 할 일이 있습니다. 모든 것을 함께 연결하는 무언가가 필요합니다. 프리젠테이션, 도메인 및 모델 레이어는 깨끗하게 유지되지만 더티 모듈이 필요하고 모든 것에 대해 모든 것을 알 수 있습니다. 이 지식을 통해 레이어를 연결할 수 있습니다. 이를 만드는 가장 좋은 방법은 일반적인 디자인 패턴 중 하나(SOLID에 정의된 깨끗한 코드 원칙 중 하나)인 종속성 주입을 사용하는 것입니다. 종속성 주입은 적절한 개체를 만들고 원하는 종속성에 주입합니다. 여기서는 Dagger 2를 사용했지만(프로젝트 중간에 상용구가 적은 2.16으로 버전을 변경했습니다) 원하는 메커니즘을 사용할 수 있습니다. 최근에 코인라이브러리로 조금 플레이해봤는데 해볼만 하다고 생각합니다. 여기서 써보고 싶었지만 테스트할 때 ViewModel을 조롱하는데 문제가 많았다. 빨리 해결할 수 있는 방법을 찾길 바랍니다. 그렇다면 Koin과 Dagger 2를 사용할 때 이 앱의 차이점을 제시할 수 있습니다.

아키텍처_v1 태그를 사용하여 GitHub에서 이 단계에 대한 앱을 확인할 수 있습니다.

변경 사항

레이어를 완성하고 앱을 테스트했습니다. 모든 것이 작동합니다! 한 가지를 제외하고는 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 에 대한 구현을 구현해야 합니다. 여기에 작은 문제가 있습니다. ILocationsRepositoryUserLocation 을 반환해야 하지만 데이터베이스에서 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())

내가 전에 언급한 작은 거짓말은 도메인 엔티티에 관한 것입니다. 나는 그들이 안드로이드에 대해 아무것도 모른다고 썼지만 이것은 완전히 사실이 아닙니다. User 엔터티에 @Parcelize 주석을 추가하고 Parcelable 을 확장하여 엔터티를 프래그먼트에 전달할 수 있도록 했습니다. 더 복잡한 구조의 경우 뷰 레이어의 자체 데이터 클래스를 제공하고 도메인 모델과 데이터 모델 간에 매퍼를 생성해야 합니다. Parcelable 을 도메인 엔터티에 추가하는 것은 감히 감수할 수 있는 작은 위험입니다. 저는 그것을 알고 있으며 User 엔터티가 변경되는 경우 프레젠테이션을 위한 별도의 데이터 클래스를 만들고 도메인 계층에서 Parcelable 을 제거할 것입니다.

마지막으로 할 일은 이전 MemoryRepository 대신 새로 생성된 Repository 구현을 제공하도록 종속성 주입 모듈을 변경하는 것입니다. 앱을 빌드하고 실행한 후 PM으로 이동하여 Room 데이터베이스가 있는 작업 앱을 표시할 수 있습니다. 또한 PM에게 네트워크를 추가하는 데 너무 많은 시간이 소요되지 않으며 경영진이 원하는 모든 스토리지 라이브러리에 개방되어 있음을 알릴 수 있습니다. 어떤 파일이 변경되었는지 확인할 수 있습니다. 모델 레이어에 있는 파일만 가능합니다. 우리 건축물은 정말 깔끔합니다! 모든 다음 스토리지 유형은 리포지토리를 확장하고 적절한 구현을 제공하기만 하면 동일한 방식으로 구축할 수 있습니다. 물론 데이터베이스 및 네트워크와 같은 여러 데이터 소스가 필요할 수 있습니다. 그럼? 별거 없습니다. 우리는 세 개의 리포지토리 구현을 생성하기만 하면 됩니다. 하나는 네트워크용, 다른 하나는 데이터베이스용, 그리고 올바른 데이터 소스가 선택되는 기본 구현(예: 네트워크가 있는 경우 네트워크, 그렇지 않은 경우 데이터베이스에서 로드).

아키텍처_v2 태그를 사용하여 GitHub에서 이 단계에 대한 앱을 확인할 수 있습니다.

이제 하루가 거의 끝나가고 있습니다. 컴퓨터 앞에 앉아 커피 한 잔을 들고 앱을 Google Play로 보낼 준비가 되었습니다. 그런데 갑자기 프로젝트 관리자가 와서 “다음 기능을 추가할 수 있습니까? GPS에서 사용자의 현재 위치를 저장할 수 있습니까?”

변경 2

모든 것이 변합니다. 특히 소프트웨어가 그렇습니다. 이것이 우리에게 깨끗한 코드와 깨끗한 아키텍처가 필요한 이유입니다. 그러나 생각 없이 코딩하면 가장 깨끗한 것조차도 더러워질 수 있습니다. GPS에서 위치 가져오기를 구현할 때 첫 번째 생각은 활동에 모든 위치 인식 코드를 추가하고 SaveLocationDialogFragment 에서 실행하고 해당 데이터로 새 UserLocation 을 만드는 것입니다. 이것이 가장 빠른 방법일 수 있습니다. 그러나 우리의 미친 PM이 우리에게 와서 GPS에서 다른 공급자(예: Bluetooth 또는 네트워크와 같은 것)로 위치 가져오기를 변경하도록 요청하면 어떻게 될까요? 변경 사항은 곧 손에서 벗어날 수 있습니다. 어떻게 하면 깨끗하게 할 수 있습니까?

사용자 위치는 데이터입니다. 그리고 위치를 얻는 것은 UseCase 이므로 도메인 및 모델 계층도 여기에 포함되어야 한다고 생각합니다. 따라서 구현해야 할 UseCase 가 하나 더 있습니다. GetCurrentLocation 입니다. 또한 UseCase 를 GPS 센서와 같은 세부 정보와 독립적으로 만들기 위해 위치를 제공할 ILocationProvider 인터페이스가 필요합니다.

 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를 활성화하지 않고도 현재 위치 처리(자동 또는 수동)를 쉽게 테스트할 수 있습니다. 작동하게 하려면 새로 생성된 UseCaseLocationsViewModel 에 추가해야 합니다. ViewModel에는 실제로 사용 사례를 호출하는 getCurrentLocation 이라는 새 메서드가 있어야 합니다. 그것을 호출하고 Dagger에 GPSProvider를 등록하기 위한 몇 가지 작은 UI 변경으로 — 그리고 짜잔, 우리 앱이 완성되었습니다!

요약

유지 관리, 테스트 및 변경이 쉬운 Android 앱을 개발하는 방법을 보여주려고 했습니다. 또한 이해하기 쉬워야 합니다. 새로운 사람이 작업을 시작하더라도 데이터 흐름이나 구조를 이해하는 데 문제가 없어야 합니다. 아키텍처가 깨끗하다는 것을 알고 있다면 UI의 변경 사항이 모델의 어떤 것도 영향을 미치지 않으며 새 기능을 추가하는 데 예상보다 많은 시간이 걸리지 않는다는 것을 확신할 수 있습니다. 그러나 이것이 여행의 끝이 아닙니다. 우리가 멋지게 구조화된 앱을 가지고 있더라도 "그냥 잠시, 단지 작동하기 위해" 지저분한 코드 변경으로 인해 앱을 깨뜨리는 것은 매우 쉽습니다. 기억하십시오. "지금은" 코드가 없습니다. 우리의 규칙을 위반하는 각 코드는 코드베이스에 남아 있을 수 있으며 미래의 더 큰 중단의 원인이 될 수 있습니다. 일주일 후에 해당 코드를 보면 누군가 해당 코드에 강력한 종속성을 구현한 것처럼 보일 것이며 이를 해결하려면 앱의 다른 많은 부분을 살펴봐야 합니다. 좋은 코드 아키텍처는 프로젝트 시작 단계에서뿐만 아니라 Android 앱 수명의 모든 부분에서 해결해야 할 과제입니다. 코드를 생각하고 확인하는 것은 무언가가 변경될 때마다 고려되어야 합니다. 이를 기억하기 위해 예를 들어 Android 아키텍처 다이어그램을 인쇄하고 걸 수 있습니다. 도메인 모듈이 다른 모듈을 인식하지 못하고 프레젠테이션 및 모델 모듈이 서로를 사용하지 않는 세 개의 Gradle 모듈로 레이어를 분리하여 레이어의 독립성을 약간 강제할 수도 있습니다. 그러나 이것조차도 앱 코드의 엉망이 우리가 예상하지 못한 때에 우리에게 복수할 것이라는 인식을 대체할 수는 없습니다.