우선 코루틴에 대해서 기본은 알고 있다고 생각하고 넘어가겠다. 또한, 코루틴에 대한 기본 및 심화는 추가로 올리는걸로..
Android Kotlin Flow Definition
코루틴에서의 Flow는 단일 값만 반환하는 suspend function과 달리 여러 값을 순차적으로 내보낼 수 있는 유형이다.
Ex) Flow를 사용하여 데이터베이스에서 실시간 업데이트를 수신할 수 있다.
Flow는 코루틴을 기반으로 빌드되며 어려 값을 제공할 수 있다. 또한! 내보낸 값은 동일한 유형이어야 한다.
Ex) Flow<Int>는 정수 값을 내보내는 Flow이다.
Flow는 값 시퀀스를 생성하는 Iterator와 매우 비슷하지만 suspend function을 사용하여 값을 비동기적으로 생성하고 사용한다.
Ex) Flow는 기본 스레드를 차단하지 않고 다음 값을 생성할 네트워크 요청을 안전하게 만들 수 있다.
즉, Coroutine의 Flow는 Data Stream이며, Coroutine에서 리엑티브 프로그래밍을 할 수 있도록 지원하기 위한 구성요소이다.
간단하게 리엑티브 프로그래밍에 대해 알아보자.
What is Reactive Programming?
리엑티브 프로그래밍이란 데이터가 변경될 때 이벤트를 발생시켜 데이터를 계속해서 전달하도록 하는 프로그래밍 방식을 뜻한다.
기존 명령형 프로그래밍에서는 Data Consumer는 데이터를 요청한 후 받은 결과값을 일회성으로 수신한다. 하지만, 이러한 방식은 데이터가 필요할 때마다 결과값을 매번 요청해야한다는 점에서 매우 비효율적이다.
리엑티브 프로그래밍에서는 데이터를 발행하는 발행자가 있고, 데이터의 컨슈머는 데이터의 발행자에게 구독 요청을 한다.
그러면! 데이터의 발행자는 새로운 데이터가 들어오면 데이터의 소비자에게 지속적으로 발행한다.
즉, Reactive Programming에는 하나의 데이터를 발행하는 발행자가 있고 해당 발행자는 데이터의 소비자에게 지속적으로 데이터를 전달하는 역할을 한다. 이를!! Data Stream이라 한다.
데이터 스트림에는 다음과 같은 세 가지 항목이 있다.
1. 생산자(Producer)는 스트림에 추가되는 데이터를 생산한다. 코루틴 덕분에 흐름에서 비동기적으로 데이터가 생산될 수 있다.
2. (선택사항) 중개자(Intermediary)는 스트림에 내보내는 각각의 값이나 스트림 자체를 수정할 수 있다.
3. 소비자(Consumer)는 스트림의 값을 사용한다.
예를들어, 날씨 앱을 만든다고 가정한다면 아래와 같은 과정을 거친다.
1. Flow 선언
2. 날씨 데이터를 서버로부터 가져온다.
3. Producer가 데이터르를 생성한다.
4. 2,3번 과정을 30초마다 반복하여 데이터를 계속 생성
class WeatherRemoteDataSource(private val weatherApi: WeatherAPI){
fun getWeatherFlow(): Flow<List<Wheather>> = flow{
while(true){
val weeklyWeather = weatherApi.fetchWeekWeather()
emit(weeklyWeather)
delay(INTERVAL_REFRESH)
}
}
companion object{
private const val INTERVAL_REFRESH: Long = 30000
}
}
Intermediary(중간 연산자)
생산자가 데이터를 생성했으면 중간 연산자는 생성된 데이터를 수정한다.
Ex) 예를들어 생성자는 A라는 객체로 이루어진 데이터를 발행했는데 B라는 객체가 필요한 경우 Flow에서 지원하는 중간 연산자를 이용해 A객체를 B객체로 바꿀 수 있다.
중간연산자로는 map, filter, onEach 등이 있다.
다시 앞선 날씨 앱을 보면, View에 전달할 때는 processing된 데이터가 전달되어야 한다.
사실 Wahter Data Class에 11개의 변수가 있다고 가정하자. 그 중 전부가 필요하지 않고 특정 위치(locale)의 날씨 데이터만 필요하다.
따라서 중간 연산자를 통해 원하는 데이터만 가져온다.
class WeatherRepository(private val weatherRemoteDataSource: WeatherRemoteDataSource){
fun getWeatherItem(locale: Locale) =
weatherRemoteDataSource.getWeatherFlow().map{ it.filter{ this.locale == locale}}
}
Consumer(소비자)
Intermediate가 데이터를 수정한 후 소비자에게 데이터를 전달한다. 이후 Flow에서는 collect를 이용해 전달된 데이터를 소비할 수 있다.
안드로이드 다큐먼트에 따르면 Data Consumer는 UI Layer에 있는 UI Component가 데이터를 소비하여 이에 맞는 UI를 그린다.
이제 받은 날씨 데이터를 이용해 ViewModel에서 필요한 처리를 한 후 UI State에 띄어준다.(UI Layer 작용 방식)
class WeatherViewModel(private val weatherRepository: WeatherRepository): ViewModel(){
fun collectWeather(locale: Locale) =
viewModelScope.launch{
weatherRepository.getWeather(locale).collect{ weatherInfo ->
//...///
}
}
}
}
Flow의 한계
Flow는 앞서 코루틴이 리엑티브 프로그래밍을 지원하기 위해 만든 Data Stream이다.
하지만, Flow는 Data Stream을 발생시키기만 할 뿐 데이터가 저장되지 않는다...
따라서, Flow만을 사용해서 Android UI State에 띄우기 위한 방법으로는 두 가지가 있다.
1. 화면이 refresh 될때마다 다시 Server, DB로부터 데이터 가져오기
-> 상기 방식은 비효율적이다. 안드로이드의 생명주기를 따라 화면이 회전되거나 다른 행위를 하고 다시 돌아왔을때 onDestroy가 호출된 후 onCreate가 호출되는데, 이 과정마다 새로운 데이터를 가져와야 하기 때문이다..서버 리소스 비용 급상승....
2. Flow로부터 collect한 데이터를 ViewModel에 저장해놓고 사용하기
-> ViewModel은 생명주기를 고려해 onDestroy가 호출되더라도 살아있기에 데이터를 저장시킬 수 있다!
그러나, 2번 방법인 viewModel 저장에서 데이터를 저장하고 있으려면 별도의 Holder를 생성해야 한다. 또한 Holder는 Reactive하지 않기 때문에 UI에서 해당 Holder를 사용하기 위해 별도의 fetching function을 만들어야 한다..
또 다른 방법으로는 viewModel에서 Holder와 flow를 같이 사용하는 방법이다. flow를 사용하고 Holder는 flow에서 마지막으로 발행한 데이터를 저장하고 있으면 된다. 따라서 UI에서는 flow에서 값을 발행하기 전에는 Holder의 데이터를 사용하면 된다.
이 방식으로 만들면 Holder가 마지막 데이터를 갖고 있어 다시 서버로 데이터를 요청할 필요가 없어진다.
하지만! 위 두 방식은 Boiler Plate Code를 만든다는 것이다..... 안드로이드에서 UIState가 단일이 아닌데 모두를 갖기 위해 유사 코드를 매번 작성해 가독성을 떨어뜨릴 수 있기 때문..
상기 문제점들을 해결해 줄 수 있는 것이 State Flow이다.
StateFlow Definition
StateFlow는 현재 상태와 새로운 상태 업데이트를 Collector에 내보내는 Observable한 StateHolder Flow이다.
즉, StateFlow는 Data Holder(Repository) 역할을 하면서 Flow의 Data Stream 역할까지 한다는것.
UI Layer에서 StateFlow를 갖고 UIState에 업데이트 하면 화면이 재구성될 때마다 다시 서버로 데이터를 요청할 필요가 없어진다.
결국, UI는 단순히 StateFlow를 구독만 하고 있으면 된다는 것..
Flow to StateFlow
보통 ReativeProgramming을 할 때 다양한 Flow를 하나의 Flow로 합친다.
예를 들어 피트니스 회원 주간 성과표를 만들 경우, 1주일간의 운동 데이터, 1주일간의 식단 데이터, 1주일 간의 섭취 칼로리 등 성과표를 만드는 데이터를 갖기위한 정보를 각 테이블에서 가져와 하나의 객체로 만들어줘야 한다..
이렇게 합쳐 만들어진 Flow는 UI가 단순히 구독하기 위한 StateFlow로 변환해주어야 한다.
추가로 StateFlow가 Flow를 계속 구독하고 있으면 Memory Leak가 생기므로 Coroutine Scope를 통해 StateFlow의 수명을 관리하어야 한다.
우선 Flow를 StateFlow로 변환하려면 stateIn 중간연산자를 사용한다.
stateIn Definition
fun <T> Flow<T>.stateIn(
scope: CoroutineScope,
started: SharingStarted,
initialValue: T
): StateFlow<T>
ColdFlow를 지정된 코루틴 범위에서 시작되는 hotStateFlow로 변환하여 업스트림 흐름의 단일 실행 인스턴스에서 가장 최근에 내보낸 값을 여러 다운스트림 구독자와 공유합니다.
다음을 사용하기 위해
scope: StateFlow가 Flow로부터 데이터를 구독받을 CoroutineScope를 명시한다.
stated: Flow로부터 언제 구독을 할지 명시한다.
initialValue: StateFlow에 저장될 초기값을 설정한다.
사용방법은 다음과 같다.
val exampleFlow: FLow<Int> = flow{
for(i in 0..10){
emit(i*i)
delay(400)
}
}
상기 flow를 stateIn 중간 연산자를 사용해 StateFlow로 변환해보자
val stateFlow = stringFlow.stateIn(
initialValue = 0,
started = SharingStarted.WhileSubscribed(1000),
scope = viewModelScope
)
이로써, 초기 저장 값은 이고, 구독 후 1초 뒤 처음 발행, 또한, viewModel의 생명주기만큼 구독받는 행동을 하는 StateFlow가 만들어진다.
REST(Representational State Transfer) API 란 REST 아키텍처의 제약 조건을 준수하는 어플리케이션 프로그래밍 인터페이스를 뜻한다.
Rest API Feature
1. Server 와 Client의 구조
-> 좀 더 편하게 생각해 본다면, DB를 통신할 수 있는 서버와 앱
2. Socket 통신과 다르게 양방향이 아닌 단방향 통신
3. Request와 Response로 이루어짐
4. Get, Post, Put, Delete 등의 메소드를 사용한다.
결국, 클라이언트 즉 앱에서 서버에 원하는 것을 요청하면 서버는 그에 맞는 원하는 것을 주는 구조이다.
Rest API를 사용하는 라이브러리는 다음과 같다.
1. HttpURLConnection, HttpsURLConnection
2. OKHttp
3. Retrofit1, 2
1. HttpURLConnection
보통 안드로이드 스튜디오 내에서 AsyncTask를 사용하지만 deprecated되었다..
그래서 다음 예제는 Coroutine을 사용하여 비동기 처리작업을 하였다.
여기서 잠깐, 코루틴을 왜쓰느냐...?
-> 코루틴은 장기 테스크를 백그라운드 스레드에서 관리할 수 있도록 비동기로 처리할 수 있게 해준다.
좀더 디테일 하게 얘기해보면
CPU 집약적 작업, 네트워크 요청, DISK IO와 같은 장기 테스크는 메인스레드에서 실행하면 안된다.
메인 스레드에서 이러한 작업을 실행하면 UI 상호 작용이 차단될 수 있다.
또한, 메인 스레드에서 네트워크 요청을 실행하는 것은 Android SDK에 의해 방지되고 Exception Throw를 한다.
이는 Android Docs에도 나와 있는 설명이다.
HTTP GET Request
1. 네트워크 작동의 가장 간단한 예는 HTTP get 요청이다.
2. 생성자에 url 문자열을 전달하여 새로운 URL 객체를 생성.
3. URL 객체는 HttpURLConnection을 생성하기 위해 사용된다.
4. 그 후, 실행한 뒤 InputStream으로 response를 받는다.
5. 마지막으로, heplper 메소드를 사용해 inputStream -> string으로 변환한다.
private fun httpGet(myURL: String?): String {
val inputStream:InputStream
val result:String
// URL 생성
val url:URL = URL(myURL)
// HttpURLConnection 생성
val conn:HttpURLConnection = url.openConnection() as HttpURLConnection
// 주어진 URL로 get을 요청
conn.connect()
// inputStream으로 response를 받기
inputStream = conn.inputStream
// inputStream을 String으로 변환
if(inputStream != null)
result = convertInputStreamToString(inputStream)
else
result = "잉!? 안되잖아!"
return result
}
Perform Network Operations on a Seperate Thread Using AsyncTask
1. 네트워크 작동은 UI가 멈추는 것을 막기 위해 메인이 아닌 백그라운드 쓰레드에서 사용한다.
2. AsyncTask 클래스는 UI Thread(메인)로부터 분리시킬 수 있는 가장 간단한 방법 중 하나이다.
3. AsncTask를 확장한 innerClass 하나 만들어주자.
4. 그 후, doInBackground() & onPostExecute를 오버라이딩 하자.
inner class HTTPAsyncTask : AsyncTask<String, Void, String>() {
override fun doInBackground(vararg urls: String?): String {
return HttpGet(urls[0])
}
override fun onPostExecute(result: String?) {
tvResult.setText(result)
}
}
Activity에 포함된 둘 이상의 프래그먼트는 흔히 서로 커뮤니케이션 한다고 알려져 있다. 사용자가 목록에서 항목을 선택하는 프래그먼트와 선택된 항목의 컨텐츠를 표시하는 또 다른 프래그먼트가 있는 split-view(list-detail) 프래그먼트의 일반적인 사례를 가정해보자.
두 프래그먼트가 모두 인터페이스 설명을 정의해야 하고 메인 엑티비티가 두 프래그먼트를 함께 결합해야 하므로 이 사례는 간단히 처리할 수 있는 작업이 아니다.
또한 두 프레그먼트는 모두 다른 프래그먼트가 아직 생성되지 않았거나 표시되지 않은 시나리오도 처리해야 한다.
이러한 일반적인 고충은 ViewModel 객체를 사용하면 해결할 수 있다. 이러한 프래그먼트는 다음 샘플 코드와 같이 이 커뮤니케이션 처리하기 위한 활동범위를 사용하여 ViewModel을 공유할 수 있다.
class SharedViewModel : ViewModel() {
val selected = MutableLiveData<Item>()
fun select(item: Item) {
selected.value = item
}
}
class ListFragment : Fragment() {
private lateinit var itemSelector: Selector
// Use the 'by activityViewModels()' Kotlin property delegate
// from the fragment-ktx artifact
private val model: SharedViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
itemSelector.setOnClickListener { item ->
// Update the UI
}
}
}
class DetailFragment : Fragment() {
// Use the 'by activityViewModels()' Kotlin property delegate
// from the fragment-ktx artifact
private val model: SharedViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.selected.observe(viewLifecycleOwner, Observer<Item> { item ->
// Update the UI
})
}
}
두 프래그먼트는 모두 자신이 포함된 엑티비티를 검색한다. 그러면 각 프래그먼트는 ViewModelProvider를 가져올 때 이 엑티비티 범위가 지정된 동일한 SharedViewModel 인스턴스를 받는다.
이 접근 방법에는 다음과 같은 이점이 있다.
1. Activity는 아무것도 할 필요가 없거나 이 커뮤니케이션에 관해 어떤 것도 알 필요가 없다.
2. Fragment는 SharedViewModel 계약 외에 서로 알 필요가 없다. Fragment 중 하나가 사라져도 다른 Fragment는 계속 평소대로 작동한다.
3. 각 Fragment는 자체 수명 주기가 있으며, 다른 Fragment 수명 주기의 영향을 받지 않는다. 한 Fragment가 다른 Fragment를 대체해도, UI는 아무 문제 없이 계속 작동한다.
ViewModel로 로더 대체하기
CursorLoader와 같은 로더 클래스는 앱 UI의 데이터와 데이터베이스 간의 동기화를 유지하는 데 자주 사용된다. ViewModel을 몇 가지 클래스와 함께 사용하여 로더를 대체할 수 있다. ViewModel을 사용하면 UI 컨트롤러가 데이터 로드 작업에서 분리된다.
즉, 클래스 간에 강력한 참조가 적어진다.
일반적인 로더 사용 방법 중 하나로, 앱이 CursorLoader를 사용하여 데이터베이스의 내용을 관찰할 수 있다. 데이터베이스에서 값이 변경되면 로더가 자동으로 데이터 새로고침을 트리거하고 UI를 업데이트 한다.
ViewModel은 Room 및 LiveData와 함께 작업하여 로더를 대체한다. ViewModel은 기기 설정이 변경되어도 데이터가 유지되도록 보장한다. 데이터베이스가 변경되면 Room에서 LiveData에 변경을 알리고, 알림을 받은 LiveData는 수정된 데이터로 UI를 업데이트 한다.
ViewModel 클래스는 수명 주기를 고려하여 UI 관련 데이터를 저장하고 관리하도로고 설계되었다.
Android Framework는 Activity나 Fragment 같은 UI Controller의 수명 주기를 관리한다. Framework는 특정 사용자 작업이나 완전히 통제할 수 없는 기기 이벤트에 대한 응답으로 UI 컨트롤러를 제거하거나 다시 만들도록 결정할 수 있다.
시스템에서 UI Controller를 제거하거나 다시 만드는 경우, Controller에 저장된 모든 일시적인 UI 관련 Data는 삭제된다.
물론, 데이터가 단순한 경우 Acitivity는 onSaveInstanceState() 메소드를 사용하여 onCreate()의 번들에서 데이터를 복원할 수 있다.
-> 하지만 에로사항들이 있는데,
1. 이 접근 방법은 사용자 목록이나 비트맵과 같은 대용량일 가능성이 높은 데이터가 아닌, 직렬화 했다가 다시 역직렬화할 수 있는 소량의 데이터에만 적합하다.
2. UI Controller가 반환하는 데 시간이 걸릴 수 있는 비동기 호출을 자주 해야 한다는 점이다. UI Controller는 비동기 호출을 관리해야 하며, 메모리 누수 가능성을 방지하기 위해 호출이 제거된 후 시스템에서 호출을 정리하는지 확인해야 한다.
*관리에는 많은 유지관리 필요, 구성 변경 시 개체가 다시 생성되는 경우 개체가 이미 수행된 호출을 다시 호출해야 할 수 있으므로 리소스가 낭비된다.
ViewModel 구현
아키텍처 구성요소는 UI의 데이터 준비를 담당하는 UI Controller에 ViewModel 클래스를 제공한다.
ViewModel 객체는 구성이 변경되는 동안 자동으로 보관되므로, 이러한 객체가 보유한 데이터는 다음 Activity, Fragment에서 즉시 사용할 수 있다.
Ex) 앱에서 사용자 목록을 표시해야 한다면 다음 코드 대로 사용자 목록을 확보하여 Activity나 Fragment 대신 ViewModel에 보관하도록 책임을 할당한다.
class MyViewModel : ViewModel() {
private val users: MutableLiveData<List<User>> by lazy {
MutableLiveData<List<User>>().also {
loadUsers()
}
}
fun getUsers(): LiveData<List<User>> {
return users
}
private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
이후 다음과 같이 Activity에서 목록에 엑세스 할 수 있다.
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Create a ViewModel the first time the system calls an activity's onCreate() method.
// Re-created activities receive the same MyViewModel instance created by the first activity.
// Use the 'by viewModels()' Kotlin property delegate
// from the activity-ktx artifact
val model: MyViewModel by viewModels()
model.getUsers().observe(this, Observer<List<User>>{ users ->
// update UI
})
}
}
Activity가 다시 생성되면 첫 번째 Activity에서 생성된 동일한 MyViewModel 인스턴스를 받는다. Activity가 완료되면 프레임워크는 리소스를 정리할 수 있도록 ViewModel 객체의 onCleared() 메소드를 호출한다.
ViewModel 객체는 뷰 또는 LifecycleOwners의 특정 인스턴스화보다 오래 지속되도록 설계되었다. 이러한 설계로 인해 뷰 및 Lifecycle 객체에 관해 알지 못할 때도 ViewModel을 다루는 테스트를 더 쉽게 작성할 수 있다. ViewModel 객체에는 LiveData 객체와 같은 LifecycleObservers가 포함될 수 있다. 그러나 ViewModel 객체는 LiveData 객체와 같이 수명 주기를 인식하는 Observable의 변경사항을 관찰해서는 안된다.
ViewModel의 수명 주기
ViewModel 객체의 범위는 ViewModel을 가져올 때 ViewModelProvider에 전달되는 Lifecycle로 지정된다.
ViewModel은 범위가 지정된 Lifecycle이 영구적으로 경과될 때까지, 즉 Activity에서 Activity가 끝날 때까지 그리고 프래그먼트에서 프래그먼트가 분리될 때까지 메모리에 남아 있다.
일반적으로 시스템에서 Activity 객체의 onCreate() 메소드를 처음 호출할 때 ViewModel을 요청한다. 시스템은 Activity 기간 내내 onCreate() 메소드를 여러 번 호출할 수 있다. ViewModel이 처음 요청되었을 때부터 활동이 끝나고 폐기될 때까지 ViewModel은 존재한다.
만일, 안드로이드 개발 시 정확한 아키텍처를 사용하지 않는다면, 코드가 많아지고 팀이 확장됨에 따라 유지보수가 어려워진다.
MVVM과 Clean Architecture를 통해 분리되고, 테스트 가능성을 높이고, 유지보수가 용이한 코드를 작성하는 방법을 알아보자.
왜 MVVM과 Clean Architecture인가?
MVVM(Model + View + ViewModel)은 View(ex: Activity, Fragment 등)와 비즈니스 로직을 분리해준다. MVVM은 간단한 ㅂ=프로젝트로는 충분히 괜찮지만 코드가 점점 커지면 ViewModel 또한 커진다. 그렇다면 각자의 역할을 분리하기가 어려워지는데..
MVVM과 Clean Architecture는 코드가 점점 커지는 것과 같은 문제에 아주 효율적이다. 이 조합은 우리의 코드의 역할 분리를 한단계 넘어선 방법이다. 즉, 앱에서 수행할 수 있는 작업의 논리를 명확하게 추상화한다.
*물론, MVP(Model-View-Presenter)과 Clean Architecture도 좋다. 하지만 AAC(Android Architecture Components)는 이미 ViewModel 클래스를 내장해 제공해줘 MVVM 프레임워크가 필요하지 않은 MVP를 통한 MVVM을 사용할 것이다.
Clean Architecture의 장점
1. 일반 MVVM 보다 훨씬 더 테스트를 쉽게 할 수 있다.
2. 코드가 더 잘 분리된다. **굉장히 큰 장점
3. 패키지 구조를 찾기가 더 쉽다.
4. 프로젝트 유지보수가 더 쉽다.
5. 팀원이 새로운 기능을 추가하는데 더 빨라진다.
Clean Architecture의 단점
1. 공부하는데 꽤 걸린다..간단한 MVVM, MVP와 같은 패턴만 공부한경우 모든 레이어의 작동방식을 이해하는데 시간이 걸린다..
2. 많은 부가적인 클래스가 추가됨으로써, 간단한 프로젝트에는 적합하지 않다.
이번 예제의 데이터 플로우는 다음과 같다.
비즈니스 로직은 UI와 완전 분리된다. 이는 코드를 아주 쉽게 유지보수하고 테스트 할 수 있도록 만들어준다.
예제는 아주 간단하다.. 사용자가 새 게시물을 만들고 그들이 만든 게시물 목록을 볼 수 있다.
물론, 이 예제는 라이브러리(Dagger, RxJava등)를 사용하지 않는다.
MVVM의 레이어와 Clean Architecture
코드는 다음 세가지로 분리된다.
1. Presentation Layer(=UI Layer)
2. Domain Layer
3. Data Layer
아래는 각 레이어에 대한 패키지 상세 내용이다.
우리가 사용하는 Android 앱 아키텍처 내에서도 파일/폴더 계층 구조를 구성하는 다양한 방법들이 있다.
Presentation Layer(=UI Layer)
Presentation Layer는 View(Activity, Fragment)와, 그리고 ViewModel들을 포함한다.
**View는 가능한 단순해야된다. 절대 절대 비즈니스 로직을 넣지말아야 한다....**
안드로이드 개발 문서 AAC에도 나와 있다.. 간단한 UI Logic은 괜찮지만, 비즈니스 로직은 놉.
Activity는 ViewModel과 소통하고, ViewModel은 액션을 취하기 위해 Domain Layer와 소통한다.
우선, 하나의 UseCaseHandler과 두개의 Usecase를 ViewModel에 전달한다. 아래 자세히 다루겠지만, 이 아키텍처에서 UseCase는 ViewModel이 Data Layer과 상호작용하는 방식을 정의하는 작업이다.
class PostListViewModel(
val useCaseHandler: UseCaseHandler,
val getPosts: GetPosts,
val savePost: SavePost): ViewModel() {
fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) {
val requestValue = GetPosts.RequestValues(userId)
useCaseHandler.execute(getPosts, requestValue, object :
UseCase.UseCaseCallback<GetPosts.ResponseValue> {
override fun onSuccess(response: GetPosts.ResponseValue) {
callback.onPostsLoaded(response.posts)
}
override fun onError(t: Throwable) {
callback.onError(t)
}
})
}
fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) {
val requestValues = SavePost.RequestValues(post)
useCaseHandler.execute(savePost, requestValues, object :
UseCase.UseCaseCallback<SavePost.ResponseValue> {
override fun onSuccess(response: SavePost.ResponseValue) {
callback.onSaveSuccess()
}
override fun onError(t: Throwable) {
callback.onError(t)
}
})
}
}
Domain Layer
Domain Layer는 어플리케이션의 모든 UseCase를 갖고있다. 이 예에서는 추상화 클래스인 UseCase를 갖고 있으며, 모든 UseCase들은 이 클래스를 확장할 것이다.
abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> {
var requestValues: Q? = null
var useCaseCallback: UseCaseCallback<P>? = null
internal fun run() {
executeUseCase(requestValues)
}
protected abstract fun executeUseCase(requestValues: Q?)
/**
* Data passed to a request.
*/
interface RequestValues
/**
* Data received from a request.
*/
interface ResponseValue
interface UseCaseCallback<R> {
fun onSuccess(response: R)
fun onError(t: Throwable)
}
}
또한, UseCaseHandler는 UseCase의 핸들링을 담당한다. 절대 데이터베이스나 서버로부터 데이터를 가져오는 과정에서 UI가 멈추면 안된다.
다음 코드는 백그라운드 스레드에서 UseCase를 실행하고 메인 스레드에서 응답을 수신하기로 결정하는 곳이다.
class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) {
fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute(
useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) {
useCase.requestValues = values
useCase.useCaseCallback = UiCallbackWrapper(callback, this)
mUseCaseScheduler.execute(Runnable {
useCase.run()
})
}
private fun <V : UseCase.ResponseValue> notifyResponse(response: V,
useCaseCallback: UseCase.UseCaseCallback<V>) {
mUseCaseScheduler.notifyResponse(response, useCaseCallback)
}
private fun <V : UseCase.ResponseValue> notifyError(
useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) {
mUseCaseScheduler.onError(useCaseCallback, t)
}
private class UiCallbackWrapper<V : UseCase.ResponseValue>(
private val mCallback: UseCase.UseCaseCallback<V>,
private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> {
override fun onSuccess(response: V) {
mUseCaseHandler.notifyResponse(response, mCallback)
}
override fun onError(t: Throwable) {
mUseCaseHandler.notifyError(mCallback, t)
}
}
companion object {
private var INSTANCE: UseCaseHandler? = null
fun getInstance(): UseCaseHandler {
if (INSTANCE == null) {
INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler())
}
return INSTANCE!!
}
}
}
이름에서 알 수 있듯이 GetPosts UseCase는 사용자의 모든 게시물을 가져오는 역할을 한다.
class GetPosts(private val mDataSource: PostDataSource) :
UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() {
protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) {
mDataSource.getPosts(requestValues?.userId ?: -1, object :
PostDataSource.LoadPostsCallback {
override fun onPostsLoaded(posts: List<Post>) {
val responseValue = ResponseValue(posts)
useCaseCallback?.onSuccess(responseValue)
}
override fun onError(t: Throwable) {
// Never use generic exceptions. Create proper exceptions. Since
// our use case is different we will go with generic throwable
useCaseCallback?.onError(Throwable("Data not found"))
}
})
}
class RequestValues(val userId: Int) : UseCase.RequestValues
class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue
}
UseCase의 목적은 ViewModel과 Repository 사이의 중재자 역할이다.
만약, 나중에 "게시물 작성" 기능을 추가한다고 가정해보자. 해야할것은 새로운 "EditPost" UseCase이고, 모든 코드는 다른 UseCase들로부터 완전 분리되어질 것이다.
아마, 공부를 좀 해보고 예제를 만들어봤다면 새로운 기능이 도입되고 기존 코드가 망가지는 현상을 많이 봤을 것이다...
분리된 UseCase는 이를 피할 수 있도록 만들어준다.
물론, 100퍼센트 이런 문제를 제거할 수는 없지만 최소화 할 수 있다.
이것이 Clean Architecture를 다른 패턴과 구분하는 이유다. 코드를 완전히 분리시켜 모든 레이어를 블랙박스로 취급할 수 있다.
Data Layer
이 레이어는 도메인 레이어에서 사용할 수 있는 모든 레포지토리(저장소)를 갖고 있다. 이 레이어는 데이터 소스 API들을 외부 클래스로 노출시킨다.
interface PostDataSource {
interface LoadPostsCallback {
fun onPostsLoaded(posts: List<Post>)
fun onError(t: Throwable)
}
interface SaveTaskCallback {
fun onSaveSuccess()
fun onError(t: Throwable)
}
fun getPosts(userId: Int, callback: LoadPostsCallback)
fun savePost(post: Post)
}
PostDataRepository는 PostDataSouce를 시행한다. 이는 우리가 로컬 데이터베이스로부터 데이터를 가져오는지 혹은 서버로부터 요청하는지를 결정한다.
class PostDataRepository private constructor(
private val localDataSource: PostDataSource,
private val remoteDataSource: PostDataSource): PostDataSource {
companion object {
private var INSTANCE: PostDataRepository? = null
fun getInstance(localDataSource: PostDataSource,
remoteDataSource: PostDataSource): PostDataRepository {
if (INSTANCE == null) {
INSTANCE = PostDataRepository(localDataSource, remoteDataSource)
}
return INSTANCE!!
}
}
var isCacheDirty = false
override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) {
if (isCacheDirty) {
getPostsFromServer(userId, callback)
} else {
localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback {
override fun onPostsLoaded(posts: List<Post>) {
refreshCache()
callback.onPostsLoaded(posts)
}
override fun onError(t: Throwable) {
getPostsFromServer(userId, callback)
}
})
}
}
override fun savePost(post: Post) {
localDataSource.savePost(post)
remoteDataSource.savePost(post)
}
private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) {
remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback {
override fun onPostsLoaded(posts: List<Post>) {
refreshCache()
refreshLocalDataSource(posts)
callback.onPostsLoaded(posts)
}
override fun onError(t: Throwable) {
callback.onError(t)
}
})
}
private fun refreshLocalDataSource(posts: List<Post>) {
posts.forEach {
localDataSource.savePost(it)
}
}
private fun refreshCache() {
isCacheDirty = false
}
}
이 클래스에는 localDataSource와 remoteDataSource라는 두개의 변수가 있다. 이 타입들은 PostDataSource이므로 실제로 내부적 구현 방식은 신경 쓰지 않는다.
상기 작가는 이렇게 말을한다.
[In my personal experience, this architecture has proved to be invaluable. In one of my apps, I started with Firebase on the back end which is great for quickly building your app. I knew eventually I’d have to shift to my own server.
When I did,all I had to do was change the implementation inRemoteDataSource. I didn’t have to touch any other class even after such a huge change.That is the advantage of decoupled code. Changing any given class shouldn’t affect other parts of your code.]
-> 개인적인 경험에 따르면, 이 아키텍처는 아주 귀중한 것으로 증명되었다. 나의 앱들 중 하나는, 백엔드에서 Firebase를 시작했는데 이는 앱을 빠르게 빌드하는데 유용하다. 하지만 결국 내 서버로 옮겨야 한다는 것을 알았다.
내가 해본결과, RemoteDataSource에서 구현을 변경하기만 하면된다. 이렇게 큰 변화를 겪은 뒤에도 다른 클래스를 건드릴 필요가 없었다. 이것이 분리된 코드의 장점이다. 주어진 클래스를 변경해도 코드의 다른 부분에는 영향을 미치지 않는다.
이거를 보고, 내 프로젝트 경험과 빗대어 생각해 보니 명확히 이해가 가며 이 아키텍처의 소중함을 다시한번 깨닫게 되었다.....와...
몇몇 부가적인 클래스는 다음과 같다.
interface UseCaseScheduler {
fun execute(runnable: Runnable)
fun <V : UseCase.ResponseValue> notifyResponse(response: V,
useCaseCallback: UseCase.UseCaseCallback<V>)
fun <V : UseCase.ResponseValue> onError(
useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable)
}
class UseCaseThreadPoolScheduler : UseCaseScheduler {
val POOL_SIZE = 2
val MAX_POOL_SIZE = 4
val TIMEOUT = 30
private val mHandler = Handler()
internal var mThreadPoolExecutor: ThreadPoolExecutor
init {
mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(),
TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE))
}
override fun execute(runnable: Runnable) {
mThreadPoolExecutor.execute(runnable)
}
override fun <V : UseCase.ResponseValue> notifyResponse(response: V,
useCaseCallback: UseCase.UseCaseCallback<V>) {
mHandler.post { useCaseCallback.onSuccess(response) }
}
override fun <V : UseCase.ResponseValue> onError(
useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) {
mHandler.post { useCaseCallback.onError(t) }
}
}
UseCaseThreadPoolScheduler는 ThreadPoolExecuter를 사용해 비도기 작업을 실행하는 역할을 한다.
class ViewModelFactory : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass == PostListViewModel::class.java) {
return PostListViewModel(
Injection.provideUseCaseHandler()
, Injection.provideGetPosts(), Injection.provideSavePost()) as T
}
throw IllegalArgumentException("unknown model class $modelClass")
}
companion object {
private var INSTANCE: ViewModelFactory? = null
fun getInstance(): ViewModelFactory {
if (INSTANCE == null) {
INSTANCE = ViewModelFactory()
}
return INSTANCE!!
}
}
}
상기 viewModelFactory를 통해 우리는 viewModel 생성자에서 인수를 전달하려면 이를 생성해야 한다.
Dependency Injection
다음 예를 통해 depenjency injection을 설명해본다. PostDataRepository 클래스를 보면 LocalDataSource와 RemoteDataSource가 있다. 우리는 Injection클래스를 통해 이러한 디펜던시를 PostDataRepository 클래스에 제공해준다.
디펜던시를 injectiong하는 것은 두 가지 이점이 있다.
1. 전체 코드베이스에 걸쳐 개체를 분산시키는 대신 중앙 위치에서 개체의 인스턴스화를 제어할 수 있다는 것이다.
2. PostDataRepository에 대한 단위 테스트를 작성하는 데 도움이 된다. 왜냐하면 실제 값 대신 PostDataRepository 생성자에 LocalDataSource, RemoteDataSource의 모의 버전을 전달할 수 있기 때문이다.
object Injection {
fun providePostDataRepository(): PostDataRepository {
return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource())
}
fun provideViewModelFactory() = ViewModelFactory.getInstance()
fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance()
fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance()
fun provideGetPosts() = GetPosts(providePostDataRepository())
fun provideSavePost() = SavePost(providePostDataRepository())
fun provideUseCaseHandler() = UseCaseHandler.getInstance()
}
식별 가능성은 객체가 데이터 변경에 관해 다른 객체에 알릴 수 있는 기능을 의미한다. 데이터 바인딩 라이브러리를 통해 객체, 필드 또는 컬렉션을 식별 가능하게 만들 수 있다.
간단한 기존 객체를 데이터 바인딩에 사용할 수는 있지만 객체를 수정해도 UI가 자동으로 업데이트 되지는 않는다. 데이터 바인딩을 사용하면 데이터 변경 시 리스너라는 다른 객체에 알리는 기능을 데이터 객체에 제공할 수 있다.
식별 가능한 클래스에는 세 가지 유형, 즉 객체, 필드 및 컬레션이 있다.
식별 가능한 데이터 객체 중 하나가 UI에 결합되고 데이터 객체의 속성이 변경되면 UI가 자동으로 업데이트 된다.
식별 가능한 필드
일부 작업은 Observable 인터페이스를 구현하는 클래스를 생성하는 작업과 관련이 있지만 클래스에 몇 가지 속성만 있다면 그다지 애쓸 필요 없다. 이러한 상황에서는 일반 Observable 클래스 및 다음과 같은 Primitive 관련 클래스를 사용하여 필드를 식별 가능하게 만들 수 있다.
식별 가능한 필드는 단일 피드가 있는 독립적인 식별 가능한 객체이다. Primitive 버전은 엑세스 작업 중에 박싱 및 언박싱을 방지한다. 이 메커니즘을 사용하려면 다음과 같이 자바언어로는 public final, Kotlin으로는 읽기 전용 속성을 만들어야 한다.
class User {
val firstName = ObservableField<String>()
val lastName = ObservableField<String>()
val age = ObservableInt()
}
private static class User {
public final ObservableField<String> firstName = new ObservableField<>();
public final ObservableField<String> lastName = new ObservableField<>();
public final ObservableInt age = new ObservableInt();
}
필드 값에 엑세스 하려면 다음과 같이 set() 및 get() 접근자 메소드를 사용하거나 Kotlin 속성 구문을 사용한다.
user.firstName = "Google"
val age = user.age
식별 가능한 컬렉션
일부 앱은 동적 구조를 사용하여 데이터를 보유한다. 식별 가능한 컬렉션을 통해 키를 사용하여 이러한 구조에 엑세스 할 수 있다.
Ex) 다음은 키가 String과 같은 참조 유형일 때는 ObservableArrayMap 클래스가 유용하다.
Observable 인터페이스를 구현하는 클래스를 사용하면 식별 가능한 객체의 속성 변경에 관해 알림을 받으려는 리스너를 등록할 수 있다.
Observable 인터페이스에 리스너를 추가 및 삭제하는 매커니즘이 있지만 알림이 전송되는 시점은 개발자가 직접 결정해야 한다.
더 쉽게 개발할 수 있도록 데이터 바인딩 라이브러리는 리스너 등록 메커니즘을 구현하는 BaseObservable 클래스를 제공한다.
BaseObservable을 구현하는 데이터 클래스는 속성이 변경될 때 알리는 역할을 한다.
Ex) 다음과 같이 Bindable 주석을 getter에 할당하고 setter의 notifyPropertyChanged()메소드를 호출함으로써 이 작업을 완료한다.
class User : BaseObservable() {
@get:Bindable
var firstName: String = ""
set(value) {
field = value
notifyPropertyChanged(BR.firstName)
}
@get:Bindable
var lastName: String = ""
set(value) {
field = value
notifyPropertyChanged(BR.lastName)
}
}
데이터 바인딩은 데이터 바인딩에 사용된 리소스의 ID를 포함하는 모듈 패키지에 이름이 BR인 클래스를 생성한다. Binable 주석은 컴파일 중에 BR 클래스 파일에 항목을 생성한다. 데이터 클래스의 기본 클래스를 변경할 수 없으면 PropertyChangeRegistry 객체를 사용하여 Observable 인터페이스를 구현함으로써 효육적으로 리스너를 등록하고 리스너에 알림을 제공할 수 있다.
생성된 결합 클래스
데이터 바인딩 라이브러리는 레이아웃의 변수 및 뷰에 엑세스하는 데 사용되는 바인딩 클래스를 생성한다. 이번에는 생성된 바인딩 클래스를 만들고 커스텀하는 방법을 알아보자.
생성된 바인딩 클래스는 레이아웃 변수를 레이아웃 내의 뷰와 연결한다. 바인딩 클래스의 이름 및 패키지는 커스텀할 수 있다. 생성된 모든 바인딩 클래스는 ViewDataBinding 클래스에서 상속된다.
각 레이아웃 파일의 결합 클래스가 생성된다. 기본적으로 클래스 이름은 레이아웃 파일 이름을 기반으로 하여 파스칼 표기법으로 변환하고 Binding 접미사를 추가한다.
Ex) 상기 레이아웃 파일 이름은 activity_main.xml이므로 생성되는 클래스는 ActivityMainBinding이다. 이 클래스는 레이아웃 속성(Ex: user 변수)에서 레이아웃 뷰까지 모든 결합을 보유하며 결합 표현식에 값을 할당하는 방법도 인식한다.
결합 객체 만들기
레이아웃 내에서 표현식을 통해 뷰에 결합되기 전에는 뷰 계층 구조가 수정되지 않도록 하기 위해, 레이아웃 확장 후에 바로 결합 객체가 생성된다. 객체를 레이아웃에 결합하는 가장 일반적인 방법은 결합 클래스에서 정적 메소드를 사용하는 것이다.
Ex) 다음은 결합 클래스의 inflate() 메소드를 사용하여 뷰 계층 구조를 확장하고 객체를 뷰 계층 구조에 결합한다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: MyLayoutBinding = MyLayoutBinding.inflate(layoutInflater)
setContentView(binding.root)
}
다음과 같이 LayoutInflater 객체 외에도 ViewGroup 객체를 사용하는 inflate() 메소드의 대체 버전이 있다.
val binding: MyLayoutBinding = MyLayoutBinding.inflate(getLayoutInflater(), viewGroup, false)
레이아웃이 다른 매커니즘을 사용하여 확장되었다면 다음과 같이 별도로 결합될 수 있다.
val binding: MyLayoutBinding = MyLayoutBinding.bind(viewRoot)
결합 유형을 미리 알 수 없는 상황도 있다. 그런 상황에서는 다음과 같이 DataBindingUtil 클래스를 사용하여 결합을 생성할 수 있다.
val viewRoot = LayoutInflater.from(this).inflate(layoutId, parent, attachToParent)
val binding: ViewDataBinding? = DataBindingUtil.bind(viewRoot)
Faagment, ListView 또는 RecyclerView Adapter에서 데이터 바인딩 항목을 사용하고 있다면, 다음 코드와 같이 바인딩 클래스 또는 DataBindingUtil 클래스의 inflate() 메소드를 사용할 수 있다.
val listItemBinding = ListItemBinding.inflate(layoutInflater, viewGroup, false)
// or
val listItemBinding = DataBindingUtil.inflate(layoutInflater, R.layout.list_item, viewGroup, false)
ID가 있는 뷰
데이터 바인딩 라이브러리는 레이아웃에 ID가 있는 각 뷰의 결합 클래스에 불변 필드를 생성한다.
Ex) 데이터 바인딩 라이브러리는 다음 레이아웃에서 TextView 유형의 firstName 및 lastName 필드를 생성한다.
일반 뷰와 달리 ViewStub 객체는 보이지 않는 뷰로 시작된다. 이 객체는 가시적으로 표시되거나 확장을 명시적으로 지시받으면 또 다른 레이아웃을 확장함으로써 레이아웃의 자체 뷰를 대체한다.
ViewStub이 기본적으로 뷰 계층 구조에서 사라지기 때문에 가비지 컬렉션을 통해 메모리 회수가 가능하도록 결합 객체의 뷰도 사라져야한다.
뷰가 최종적이므로 ViewStubProxy 객체는 생성된 결합 클래스에서 ViewStub를 대체하며, 이에 따라 개발자는 ViewStub가 존재할 경우 이에 엑세스 할 수 있고 ViewSub가 확장된 경우 확장된 뷰 계층 구조에도 엑세스 할 수 있다.
또 다른 레이아웃을 확장할 때 새 레이아웃의 결합을 설정해야 한다. 따라서 ViewStubProxy는 ViewStub OnInflateListener를 수신 대기하고 필요할 때 결합을 설정해야 한다. 지정된 시간에 하나의 리스너만 존재할 수 있으므로 ViewStubProxy를 통해 OnInflateListener를 설정할 수 있다. 설정된 이 리스너는 설정하면 호출된다.
즉시 결합
변수 또는 관찰 가능한 객체가 변경될 때 결합은 다음 프레임 이전에 변경되도록 예약된다. 하지만 결합이 즉시 실행되어야 하는 때도 있다. 이럴 때 강제로 실행하려면 executePendingBindings() 메소드를 사용하라.
고급 결합
동적 변수
구체적인 바인딩 클래스를 알 수 없을때도 있다.
Ex) 임의의 레이아웃에 작동하는 RecyclerView.Adapter는 특정 바인딩 클래스를 인식하지 못한다. 따라서 이 어댑터는 onBindViewHolder() 메소드를 호출하는 동안에도 계속해서 결합 값을 할당해야 한다.
Ex) 다음은 RecyclerView가 결합되는 모든 레이아웃에 item 변수가 있다.
BindingHolder 객체에는 ViewDataBinding 기본 클래스를 반환하는 getBinding() 메소드가 있다.
override fun onBindViewHolder(holder: BindingHolder, position: Int) {
item: T = items.get(position)
holder.binding.setVariable(BR.item, item);
holder.binding.executePendingBindings();
}
* 데이터 바인딩 라이브러리는 모듈 패키지에 BR이라는 클래스를 생성한다. 이 클래스에는 데이터 바인딩에 사용된 리소스의 ID가 포함되어 있다. 위 예에서 라이브러리는 BR.item 변수를 자동으로 생성한다.
백그라운드 스레드
컬렉션이 아닌 한 백그라운드 스레드에서 데이터 모델을 변경할 수 있다. 데이터 바인딩은 계산 중에 각 변수/필드를 Localize하여 동시 실행 문제를 방지한다.
커스텀 바인딩 클래스 이름
기본적으로 바인딩 클래스는 레이아웃 파일 이름을 기반으로 하여 대문자로 시작하고 밑줄(_)을 삭제하며 다음 문자를 대문자로 표기하고 Binding이라는 단어를 접미사로 추가하는 방법으로 생성된다. 이 클래스는 모듈 패키지 아래의 databinding 패키지에 배치된다.
Ex) 레이아웃 파일 contact_item.xml 이 ContactItemBinding클래스를 생성합니다. 모듈 패키지가 com.example.my.app 이면 바인딩 클래스가 com.example.my.app.databinding 패키지에 배치된다.
data요소의 class속성을 조정하여 바인딩 클래스의 이름을 바꾸거나 바인딩 클래스를 다른 패키지에 배치할 수 있다.
Ex) 다음 레이아웃은 현재 모듈의 databinding 패키지에 ContactItem 결합 클래스를 생성한다.
<data class="ContactItem">
…
</data>
클래스 이름 앞에 마침표를 접두사로 추가하여 다른 패키지에서 바인딩 클래스를 생성할 수 있다.
* 레이아웃 표현식은 Unit Test가 불가능하고 IDE 지원이 제한적이므로 작고 단순하게 유지해야 한다. 커스텀 바인딩 어댑터를 사용하면 레이아웃 표현식을 단순화 할 수 있다.
데이터 객체
User 항목을 설명하기 위해 간단한 기존 객체가 있다고 가정해 보자.
data class User(val firstName: String, val lastName: String)
이 유형의 객체에는 변경되지 않는 데이터가 있다. 어플리케이션에는 한 번 읽은 이후에 변경되지 않는 데이터가 있는 게 일반적이다. 또한 다음 예와 같이 자바의 접근자 메서드 사용과 같이 일련의 규칙을 준수하는 객체를 사용할 수 있다.
public class User {
private final String firstName;
private final String lastName;
public User(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName() {
return this.firstName;
}
public String getLastName() {
return this.lastName;
}
}
데이터 바인딩 관점에서 이러한 두 클래스는 동등하다. android:text 속성에 사용된 @{user.firstName} 표현식은 전자 클래스의 firstName 필드 및 후자 클래스의 getFirstName() 메소드에 엑세스 한다. 또는 firstName() 메소드가 있다면 이 메소드로 확인된다.
데이터 바인딩
각 레이아웃 파일의 결합 클래스가 생성된다. 기본적으로 클래스 이름은 레이아웃 파일 이름을 기반으로 하여 파스칼 표기법으로 변환하고 Binding 접미사를 추가한다. 위 레이아웃 파일 이름은 activity_maiin.xml이므로 생성되는 클래스는 ActivityMainBinding이다.
이 클래스는 레이아웃 속성(Ex: user 변수)에서 레이아웃 뷰까지 모든 결합을 보유하며 결합 표현식의 값을 할당하는 방법을 알고 있다. 권장되는 바인딩 생성 방법은 다음과 같다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding = DataBindingUtil.setContentView(
this, R.layout.activity_main)
binding.user = User("Test", "User")
}
런타임 시 앱의 UI에는 테스트 사용자가 표시된다. 또는 다음과 같이 LayoutInflater를 사용하여 뷰를 가져올 수 있다.
val binding: ActivityMainBinding = ActivityMainBinding.inflate(getLayoutInflater())
Fragment, ListView 또는 RecyclerView 어댑터 내에서 데이터 바인딩 항목을 사용하고 있다면 다음과 같이 바인딩 클래스 또는 DataBindingUtil 클래스의 inflate() 메소드를 사용할 수 있다.
val listItemBinding = ListItemBinding.inflate(layoutInflater, viewGroup, false)
// or
val listItemBinding = DataBindingUtil.inflate(layoutInflater, R.layout.list_item, viewGroup, false)
표현식 언어
일반적인 기능
표현식 언어는 관리형 코드에서 볼 수 있는 표현식과 매우 비슷하다. 표현식 언어에서는 다음 연산자와 키워드를 사용할 수 있다.
표현식은 다음 형식을 사용해 클래스의 속성을 참조할 수 있으며 이 형식은 fields, getters 및 ObservableField 객체에서도 동일하다.
android:text="@{user.lastName}"
Null Pointer Exception 방지
생성된 데이터 바인딩 코드는 자동으로 null 값을 확인하고 Null Pointer Exception을 방지한다. 예를 들어 @{user.name 표현식에서 user가 null이면 user.name에 null이 기본값으로 할당된다. age의 유형이 int 인 user.ag를 참조하면 데이터 바인딩은 0의 기본값을 사용한다.
View 참조
표현식은 다음 구문을 사용하여 ID로 레이아웃의 다른 뷰를 참조할 수 있다.
android:text="@{exampleText.text}"
다음 예는 TextView View 또는 동일한 레이아웃의 EditText 뷰를 참조한다.
Have an orange
Have %d oranges
android:text="@{@plurals/orange(orangeCount, orangeCount)}"
이벤트 처리
데이터 바인딩을 사용하면 뷰엣서 전달되는 표현식 처리 이벤트를 작성할 수 있다.
이벤트 속성 이름은 몇가지 예외를 제외하고 리스너 메서드의 이름에 따라 결정된다.
Ex) View.onClickListener에는 onClick() 메소드가 있으므로 이 이벤트 속성은 android:onClick이다.
클릭 이벤트는 충돌을 방지하기 위해 android:onClick 이외의 다른 속성이 필요한 특수한 이벤트 핸들러가 있다.
다음 메커니즘을 사용하여 이벤트를 처리할 수 있다.
1. 메소드 참조: 표현식에서 리스너 메소드의 서명과 일치하는 메소드를 참조할 수 있다. 표현식이 메소드 참조로 계산되면 데이터 바인딩은 리스너에서 메소드 참조 및 소유자 객체를 래핑하고 타겟 뷰에서 이 리스너를 설정한다.표현식이 null로 계산되면 데이터 바인딩은 리스너를 생성하지 않고 대신 null 리스너를 설정한다.
2. 리스너 결합: 이벤트가 발생할 때 계산되는 람다 표현식이다. 데이터 바인딩은 항상 리스너를 생성하여 뷰에서 설정한다. 이벤트가 전달되면 리스너는 람다 표현식을 계산한다.
메소드 참조
이벤트는 android:onClick이 Acitivty 메소드에 할당되는 방식과 유사하게 핸들러 메소드에 직접 결합될 수 있다. View onClick 속성과 비교했을 때 한 가지 주요 이점은 표현식이 컴파일 타임에 처리되는 것이다. 따라서 메소드가 없거나 서명이 올바르지 않으면 컴파일 타임 오류가 발생한다.
메소드 참조와 리스너 결합의 주요 차이점은 실제 리스너 구현이 이벤트가 트리거 될 때가 아니라 데이터가 결합될 때 생성된다는 점이다. 이벤트가 발생할 때 표현식을 계산하려면 리스너 결합을 사용해야 한다.
핸들러에 이벤트를 할당하려면 호출할 메소드 이름이 될 값을 사용하여 일반 결합 표현식을 사용해야 한다.
Ex) 다음과 같은 레이아웃 데이터 객체 예를 확인해 보자
class MyHandlers {
fun onClickFriend(view: View) { ... }
}
결합 표현식은 다음과 같이 뷰의 클릭 리스너를 onClickFriend() 메소드에 할당할 수 있다.
표현식에 콜백을 사용하면 데이터 바인딩은 필요한 리스너를 자동으로 생성하여 이벤트에 등록한다. 뷰에서 이벤트가 발생하면 데이터 바인딩은 주어진 표현식을 계산한다. 일반 결합 표현식에서와 같이 이러한 리스너 표현식이 계산되는 동안 계속 데이터 바인딩의 null 및 스레드 안전성이 확보된다.
위 예는 onClick(View)에 전달되는 view 파라미터가 정의되지 않았다. 리스너 결합에서는 두 가지 방식으로 리스너 매개변수를 선택할 수 있다. 즉, 메소드의 모든 파라미터를 무시하거나 모든 파라미터의 이름을 지정할 수 있다. 파라미터 이름 지정을 선택하면 표현식에 파라미터를 사용할 수 있다.
리스너 표현식은 매우 강력하다. 리스너 표현식을 사용하면 코드를 매우 쉽게 읽을 수 있다. 반면, 복잡한 표현식이 포함된 리스너를 사용하면 레이아웃을 읽고 유지하기 어려워진다. 이러한 표현식은 사용 가능한 데이터를 UI에서 콜백 메소드로 전달하는 것만큼 간단해야 한다. 리스너 표현식에서 호출한 콜백 메소드 내에 비즈니스 로직을 구현해야 한다.
즉, 리스너 표현식은 간결하게, 무거운 경우 콜백을 받아 비즈니스 로직 구현
가져오기, 변수 및 포함
데이터 바인딩 라이브러리는 가져오기(Imports), 변수(Variables) 및 포함(Includes)과 같은 기능을 제공한다. 가져오기를 사용하면 레이아웃 파일 내에서 클래스를 쉽게 참조할 수 있다. 변수를 사용하면 결합 표현식에 사용할 수 있는 속성을 설명할 수 있다. 포함을 사용하면 앱 전체에 복잡한 레이아웃을 재사용할 수 있다.
Imports(가져오기)
가져오기를 사용하면 관리형 코드에서와 같이 레이아웃 파일 내에서 클래스를 쉽게 참조할 수 있다. data 요소 내에서 0개 이상의 import 요소를 사용할 수 있다.
Data Binding 라이브러리는 Programatic 방식이 아닌 선언적 형식으로 레이아웃의 UI 구성 요소를 앱의 데이터 소스와 결합할 수 있는 지원 라이브러리이다.
레이아웃은 흔히 UI FrameWork 메소드를 호출하는 코드가 포함된 Activity에서 정의된다.
Ex) 하단 코드는 findViewById()를 호출하여 TextView 위젯을 찾아 viewModel 변수의 userName 속성에 결합한다.
findViewById<TextView>(R.id.sample_text).apply {
text = viewModel.userName
}
Ex) 다음은 Data Binding 라이브러리를 사용하여 레이아웃 파일에서 직접 위젯에 텍스트를 할당하는 방법을 보여준다. 이 방법을 사용하면 상기 자바 코드를 호출할 필요가 없다. 할당 표션식에 사용되는 @{} 구문을 유의하자.
<TextView
android:text="@{viewmodel.userName}" />
레이아웃 파일에서 구성요소를 결합하면 활동에서 많은 UI FrameWork 호출을 삭제할 수 있어 파일이 더욱 단순화되고 유지관리 또한 쉬워진다. 앱 성능이 향상되며 메모리 누수 및 null Pointer Exception을 방지할 수 있다.
Data Binding 라이브러리 사용
시작하기
Android Studio의 Data Binding 코드 지원을 비롯하여 개발 환경에서 Data Binding 라이브러리를 함께 사용하도록 준비하는 방법을 보자.
Layout 및 Binding Expression
표현식 언어로 레이아웃의 뷰와 변수를 연결하는 표현식을 작성할 수 있다. Data Binding(이하 데이터바인딩) 라이브러리는 레이아웃의 뷰를 데이터 개체와 결합하는 데 필요한 클래스를 자동으로 생성한다. 라이브러리는 가져오기, 변수 및 포함과 같이 레이아웃에서 사용할 수 있는 기능을 제공한다.
라이브러리의 이러한 기능은 기존 레이아웃과 원활하게 공존한다.
Ex) 표현식에서 사용할 수 있는 결합 변수는 UI Layout 루트 요소의 동위 요소인 data 요소 내에서 정의된다. 아래 예에 나와 있는 것처럼 두 요소는 모두 layout Tag로 래핑된다.
데이터 바인딩 라이브러리는 데이터 변경을 쉽게 식별하기 위한 클래스 및 메소드를 제공한다. 기본 데이터 소스가 변경될 때 UI 새로고침에 관해 신경쓰지 않아도 된다. 변수 또는 속성을 식별 가능하게 만들 수 있다. 라이브러리를 통해 객체, 필드 또는 컬렉션을 식별 가능하게 만들 수 있다.
생성된 바인딩 클래스
데이터 바인딩 라이브러리는 레이아웃의 변수 및 뷰에 엑세스하는 데 사용되는 결합 클래스를 생성한다.
바인딩 어뎁터
모든 레이아웃 표현식에는 속성 또는 리스너를 설정하는 데 필요한 프레임워크를 호출하는 바인딩 어댑터가 있다.
Ex) 바인딩 어댑터는 setText() 메소드를 호출하여 텍스트 속성을 설정하거나 setOnClickListener() 메소드를 호출하여 리스너를 클릭 이벤트에 추가할 수 있다.
@BindingAdapter("app:goneUnless")
fun goneUnless(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.GONE
}
Architecture Component Layout View 연결
Android 지원 라이브러리에는 성능이 뛰어나고 테스트와 유지관리가 쉬운 앱을 디자인하는 데 사용할 수 있는 AAC가 포함되어 있다.
AAC와 DataBinding 라이브러리와 함께 사용하여 UI 개발을 한층 단순화할 수 있다.
양방향 데이터 바인딩
데이터 바인딩 라이브러리는 양방향 데이터 바인딩을 지원한다. 이 바인딩 유형에 사용된 표기법은 속성의 데이터 변경사항을 받는 동시에 속성의 사용자 업데이트를 수신 대기하는 기능을 지원한다.
시작하기
데이터 바인딩 라이브러리는 유연성과 광법위한 호환성을 모두 제공하는 지원 라이브러리이며 Android 4.0(API Level 14) 이상을 실행하는 기기에서 사용할 수 있다.
최신 Gradle용 Android Plugini을 프로젝트에 사용하는 것이 좋다. 그러나 데이터 바인딩은 버전 1.5.0 이상에서 지원된다.
빌드 환경
데이터 바인딩을 시작하려면 Android SDK Manager의 지원 저장소에서 라이브러리를 다운로드 하자.
데이터 결합을 사용하도록 앱을 구성하려면 아래 예에 나와 있는 것처럼 앱 모듈에서 dataBinding 요소를 build.gradle 파일에 추가하자.
android {
...
dataBinding {
enabled = true
}
}
Android Studio의 데이터 바인딩 지원
Android 스튜디오는 다수의 데이터 바인딩 코드 편집 기능을 지원한다. 예를 들어 데이터 바인딩 표현식과 관련하여 다음 기능을 지원한다.
1. 구문 강조 표시
2. 표현식 언어 구문 오류 플래그 지정
3. XML 코드 완성
4. 탐색 및 빠른 문서 포함 참조
Layout Editor의 Preview 창에는 데이터 바인딩 표현식의 기본값(제공된 경우)이 표시된다.
Ex) Preview 창에는 다음 예에서 선언된 TextView 위젯의 my_default 값이 표시된다.
* Fragment는 View보다 오래 지속된다. Fragment의 onDestroyView() 메소드에서 결합 클래스 인스턴스 참조를 정리해야 한다.
class BindFragment : Fragment(R.layout.fragment_blank) {
// Scoped to the lifecycle of the fragment's view (between onCreateView and onDestroyView)
private var fragmentBlankBinding: FragmentBlankBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = FragmentBlankBinding.bind(view)
fragmentBlankBinding = binding
binding.textViewFragment.text = getString(string.hello_from_vb_bindfragment)
}
override fun onDestroyView() {
// Consider not storing the binding instance in a field, if not needed.
fragmentBlankBinding = null
super.onDestroyView()
}
}
findViewById와의 차이점
View Binding에는 findViewById를 사용하는 것에 비해 다음과 같은 중요한 장점이 있다.
1. Null Safety: View Binding 은 View의 직접 참조를 생성하므로 유효하지 않은 View ID로 인해 null pointer exception이 발생할 위험이 없다. 또한 레이아웃의 일부 구성에만 뷰가 있는 경우 Binding Class에서 참조를 포함하는 필드가 @Nullable로 표시된다.
2. Type Safety: 각 Binding Class에 있는 필드의 유형이 XML 파일에서 참조하는 뷰와 일치하다. 즉, 클래스 변환 예외가 발생할 위험이 없다.
=> 이러한 차이점은 레이아웃과 코드 사이의 비호환성으로 인해 런타임이 아닌 컴파일 시간에 빌드가 실패하게 된다는 것을 의미한다.
Data Binding과 비교
View Binding과 Data Binding은 모두 뷰를 직접 참조하는데 사용할 수 있는 Binding Class를 생성한다. 하지만 View Binding은 보다 단순한 사용 사례를 처리하기 위한 것이며 Data Binding에 비해 다음과 같은 이점을 제공한다.
1. 더 빠른 컴파일: View Binding에는 주석 처리가 필요하지 않으므로 컴파일 시간이 더 짧다.
2. 사용 편의성: View Binding에는 특별히 태그된 XML 레이아웃 파일이 필요하지 않으므로 앱에서 더 신속하게 채택할 수 있다. 모듈에서 View Binding을 사용 설정하면 모듈의 모든 레이아웃에 View Binding이 자동으로 적용된다.
반대로 View Binding 에는 Data Binding과 비교할 때 다음과 같은 제한사항이 있다.
1. View Binding Layout variables 또는 Layout expression을 지원하지 않으므로 XML 레이아웃 파일에서 직접 dynamic UI 콘텐츠를 선언하는데 사용할 수 없다.
2. View Binding 은 two-way Data Binding을 지원하지 않는다.
* 위 사항을 고려할 때 일부 사례에서 프로젝트에 View Binding과 Data Binding을 모두 사용하는 것이 가장 좋다. 고급 기능이 필요한 레이아웃에는 Data Binding을, 고급 기능이 필요 없는 레이아웃에는 View Binding을 사용할 수 있다.
UI Layer에는 UI 관련 State, Logic이 포함되지만 데이터 영역에서는 Application Data 및 Business Logic(이하 비즈니스 로직)이 포함된다. 비즈니스 로직은 앱에 가치를 부여하는 요소로, 어플리케이션의 데이터 생성 저장, 변경 방식을 결정하는 실제 비즈니스 규칙으로 구성된다.
이렇게 관심사를 분리하면 데이터 영역을 여러 화면에서 사용하고, 앱의 여러 부분 간에 정보를 공유하고, 단위 테스트를 위해 UI 외부에 비즈니스 로직을 재현할 수 있다.
데이터 영역 아키텍처
데이터 영역은 0개부터 여러 개의 데이터 소스를 각각 포함할 수 있는 Repository(이하 저장소)로 구성된다. 앱에서 처리하는 다양한 유형의 데이터 별로 저장소 클래스를 만들어야 한다.
Ex) 영화 관련 데이터에는 MoviesRepository 클래스를 만들거나 관련 데이터에는 PaymentRepository 클래스를 만들 수 있다.
저장소 클래스에서 담당하는 작업은 다음과 같다.
1. 앱의 나머지 부분에 데이터 노출
2. 데이터 변경사항을 한 곳에 집중
3. 여러 데이터 소스 간의 충돌 해결
4. 앱의 나머지 부분에서 데이터 소스 추상화
5. 비즈니스 로직 포함
각 데이터 소스 클래스 파일, 네트워크 소스, 로컬 DB와 같은 하나의 데이터 소스만 사용해야 한다. 데이터 소스 클래스는 데이터 작업을 위해 어플리케이션과 시스템 간의 가교 역할을 한다.
계층 구조의 다른 레이어는 데이터 소스에 직접 액세스 하면 안된다. 데이터 영역의 진입점은 항상 저장소 클래스여야 한다.
State Holder 클래스 또는 UseCase 클래스에는 데이터 소스가 직접 종속항목으로 있어서는 안된다.
저장소 클래스를 진입점으로 사용하면 아키텍처의 다양한 레이어를 독립적으로 확장할 수 있다.
이 레이어에서 노출된 데이터는 변경 불가능해야 한다. 그래야 값을 일관되지 않은 상태로 만들 위험이 있는 다른 클래스에 의한 조작이 불가능해진다. 또한 변경 불가능한 데이터는 여러 스레드에서 안전하게 처리될 수 있다.
종속 항목 삽입 권장사항에 따라 저장소는 데이터 소스를 생성자의 종속항목으로 사용한다.
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }
API 노출
데이터 영역의 클래스는 일반적으로 원샷 생성, 조회, 업데이트 및 삭제(CRUD: Create, Read, Update, Delete) 호출을 실행하거나 시간 경과에 따른 데이터 변경사항에 관해 알림을 받는 함수를 노출한다. 데이터 영역은 다음과 같은 경우에 각 항목을 노출해야 한다.
1. 원샷 작업: 데이터 영역에서 Kotlin의 suspend 함수를 노출해야한다. 자바의 경우 데이터 영역에서 작업 결과 또는 RxJava, Single, Maybe, Completable 유형에 대한 콜백을 제공하는 함수를 노출해야 한다.
2. 시간 경과에 따른 데이터 변경사항에 관해 알림을 받으려면: 데이터 영역에서 Kotlin의 Flow를 노출해야 한다. 자바의 경우 데이터 영역에서 새 데이터 또는 RxJava Observable 또는 Flowable 유형을 내보내는 콜백을 노출해야 한다.
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) {
val data: Flow<Example> = ...
suspend fun modifyData(example: Example) { ... }
}
데이터 소스 클래스의 이름은 담당하는 데이터와 사용하는 소스의 이름을 따라 지정된다. 규칙은 다음과 같다.
데이터 유형 + 소스 유형 + DataSource
데이터 유형의 경우 구현이 변경될 수 있으므로 좀 더 일반적인 Remote 또는 Local을 사용한다.
Ex: NewsRemoteDataSource, NewsLocalDataSource가 있다. 소스가 중요한 경우를 좀 더 구체적으로 지정하려면 소스 유형을 사용한다. 예를 들면 NewsNetworkDataSource 또는 NewsDiskDataSource가 있다.
구현 세부정보에 따라 데이터 소스의 이름을 지정하지 마라. (Ex: UserSharedPreferencesDataSource) 해당 데이터 소스를 사용하는 저장소가 데이터 저장 방법을 알 수 없다. 이 규칙을 따르면 데이터 소스의 구현을 변경하면서도 해당 소스를 호출하는 레이어에 영향을 주지 않을 수 있다.
여러 수준의 저장소
더 복잡한 비즈니스 요구사항이 포함된 일부 경우에는 저장소가 다른 저장소에 종속되어야 할 수 있다. 관련 데이터가 여러 데이터 소스의 집계이거나 책임이 다른 저장소 클래스에 캡슐화되어야 하기 때문일 수 있다.
Ex) 사용자 인증 데이터를 처리하는 저장소인 UserRepository는 요구사항을 충족하기 위해 LoginRepository 및 RegistrationRepository와 같은 다른 저장소에 종속될 수 있다.
정보 소스
각 저장소가 하나의 정보 소스를 정의하는 것이 중요하다. 정보 소스는 항상 일관되고 정확하며 최신 상태인 데이터를 포함한다. 실제로 저장소에서 노출되는 데이터는 항상 정보 소스에서 직접 가져온 데이터여야 한다.
정보 소스는 데이터 소스(Ex: Database)이거나 저장소에 포함될 수 있는 메모리 내 캐시일 수 있다. 저장소는 서로 다른 데이터 소스를 결합하고 데이터 소스 간의 잠재적인 충돌을 해결하여 정기적으로 또는 사용자 입력 이벤트에 따라 정보 소스를 업데이트 한다.
앱의 저장소마다 정보 소스가 다를 수 있다. 예를 들어 LoginRepository 클래스는 캐시를 정보 소스로 사용하고 PaymentRepository 클래스는 네트워크 데이터 소스를 사용할 수 있다.
오프라인 우선 지원을 제공하려면 데이터베이스와 같은 로컬 데이터 소스로 사용하는 것이 좋다.
스레딩
데이터 소스와 저장소 호출은 기본 스레드에서 호출하기에 안전하도록 기본 안전성이 보장되어야 한다. 이러한 클래스는 장기 실행 ㅏ단 작업을 실행할 때 로직 실행을 적절한 스레드로 이동한다.
Ex) 데이터 소스가 파일에서 읽거나 저장소가 큰 목록에서 비용이 많이 드는 필터링을 수행할 때 기본 안전성이 보장되어야 한다.
대부분의 데이터 소스는 이미 Room 또는 Retrofit에서 제공하는 정지 메서드 호출과 같은 기본 안전성을 갖춘 API를 제공한다. API를 사용할 수 있게 되면 저장소에서 API를 활용할 수 있다.
수명 주기
데이터 영역에서 클래스 인스턴스는 가비지 컬렉션 루트에서 연결할 수 있는 한 메모리에 남아 있다. 이는 대개 앱의 다른 객체에서 참조된다.
클래스에 메모리 내 데이터가 포함된 경우(Ex: Cache) 특정 기간 동안 해당 클래스의 동일한 인스턴스를 재사용하고자 할 수 있다. 이를 클래스 인스턴스의 수명 주기라고도 한다.
클래스의 책임이 전체 어플리케이션에 중요한 경우 해당 클래스의 인스턴스 범위를 Application 클래스로 지정할 수 있다. 이렇게 하면 인스턴스가 어플리케이션의 수명 주기를 따르게 된다. 또는 앱의 특정 흐름(Ex: 등록 또는 로그인 Flow)에서만 동일한 인스턴스를 재사용해야 하는 경우 흐름의 수명 주기를 소유한 클래스로 인스턴스 범위를 지정해야 한다. 예를 들어 메모리 내 데이터가 포함된 RegistrationRepository 범위를 RegistrationActivity 또는 등록 흐름의 탐색 그래프로 지정할 수 있다.
각 인스턴스의 수명 주기는 앱 내에서 종속 항목을 제공하는 방법을 결정할 때 중요한 요소이다. 종속 항목이 관리되고 종속 항목 컨테이너로 범위가 지정될 수 있는 종속 항목 삽입 권장사항을 따르는 것이 좋다.
대표 비즈니스 모델
데이터 영역에서 노출하려는 데이터 모델은 다양한 데이터 소스에서 가져오는 정보의 하위 집합일 수 있다. 네트워크 및 로컬의 다양한 데이터 소스가 어플리케이션에 필요한 정보만 반환하는 것이 좋으나 실제 이런 경우는 많지 않다.
예를들어 기사 정보 뿐만 아니라 수정 기록, 사용자 댓글, 일부 메타데이터도 반환하는 News API 서버가 있다고 하자.
data class ArticleApiModel(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val modifications: Array<ArticleApiModel>,
val comments: Array<CommentApiModel>,
val lastModificationDate: Date,
val authorId: Long,
val authorName: String,
val authorDateOfBirth: Date,
val readTimeMin: Int
)
화면에 기사 콘텐츠와 작성자에 관한 기본 정보만 표시하므로 앱은 기사에 관한 많은 정보를 필요로 하지 않는다. 모델 클래스로 분리하고 저장소에서 계층 구조의 다른 레이어에 필요한 데이터만 노출하도록 하는 것이 좋다.
예를 들어 다음은 Article 모델 클래스를 Domain 및 UI Layer에 노출하기 위해 네트워크에서 ArticleApiModel을 다듬는 방법이다.
data class Article(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val authorName: String,
val readTimeMin: Int
)
모델 클래스를 분리하면 다음과 같은 이점이 있다.
1. 필요한 수준으로 데이터를 줄여 앱 메모리를 절약한다.
2. 앱에서 사용하는 데이터 유형에 맞게 외부 데이터 유형을 조절한다. 예를 들어 앱은 날짜를 나타내는 데 다른 데이터 유형을 사용할 수 있다.
3. 이를 통해 관심사를 더 잘 분리할 수 있다. 예를 들어 모델 클래스가 미리 정의된 경우 대규모 팀원이 기능의 네트워크 레이어와 UI 레이어에서 개별적으로 작업할 수 있다.
이 방식을 확장하고 앱 아키텍처의 다른 부분(Ex: DataSource Class , ViewModel)에서도 별도의 모델 클래스를 정의할 수 있다.
그러나 이를 위해서는 적절하게 문서화하고 테스트해야 하는 추가 클래스 및 로직을 정의해야 한다. 최소한 데이터 소스가 앱의 나머지 부분에서 예상하는 데이터와 일치하지 않는 데이터를 수신하는 경우에는 새 모델을 만드는 것이 좋다.
데이터 작업 유형
데이터 영역에서 중요도에 따라 다양한 유형의 작업(Ex: UI 지향, 앱 지향, 비즈니스 지향 작업)을 처리할 수 있다.
UI 지향 작업
UI지향 작업은 사용자가 특정 화면에 있을 때 관련이 있고 사용자가 화면에서 멀어지면 취소된다. 예를 들어 데이터베이스에서 얻은 일부 데이터를 표시한다.
UI지향 작업은 일반적으로 UI 레이어에 의해 트리거되며 호출자의 수명 주기(Ex: ViewModel의 수명 주기)를 따른다.
앱 지향 작업
앱 지향작업은 앱이 열려 있는 한 관련이 없다. 앱이 닫히거나 프로세스가 종료되면 이러한 작업은 취소된다.
Ex) 네트워크 요청의 결과를 필요에 따라 나중에 사용할 수 있도록 캐시하는 경우가 있다.
비즈니스 지향 작업
비즈니스 지향 작업은 취소할 수 없다. 프로세스 종료 후에도 유지된다.
Ex) 사용자가 프로필에 게시하고 싶은 사진 업로드를 완료하는 작업이 있다.
비즈니스 지향 작업의 경우 WorkManager를 사용하는 것이 좋다.
오류 노출
저장소 및 데이터 소스와의 상호작용은 성공하거나 실패 시 예외를 발생시킬 수 있다. 코루틴과 흐름의 경우 Kotlin의 기본 제공 오류 처리 메커니즘을 사용해야 한다. suspend 함수에 의해 트리거 될 수 있는 오류의 경우 적절한 try/catch 블록을 사용하며 Flow에서는 catch 연산자를 사용한다. 이 접근 방식을 사용하면 데이터 영역을 호출할 때 UI Layer가 예외를 처리해야 한다.
데이터 영역은 다양한 유형의 오류를 이해하고 처리하며 Custom Exception(Ex: UserNotAuthenticatedException)를 사용하여 이를 노출할 수 있다.
일반적인 작업
이번에는 Android 앱에서 일반적으로 사용되는 특정 작업을 실행하기 위해 데이터 영역을 사용하고 설계하는 방법의 예를 본다.
네트워크 요청
네트워크 요청은 Android 앱에서 실행할 수 있는 가장 일반적인 작업 중 하나이다. 뉴스 앱은 네트워크에서 가져온 최신 뉴스를 사용자에게 표시해야 한다. 따라서 앱에서 네트워크 작업을 관리하기 위한 데이터 소스 클래스 NewsRemoteDataSource가 필요하다. 앱의 나머지 부분에 정보를 노출하기 위해 뉴스 데이터에 관한 작업을 처리하는 새로운 저장소 NewsRepository를 만든다.
요구사항은 사용자가 화면을 열 때 항상 최신 뉴스를 업데이트 하도록 하는 것이다 .따라서 이는 UI 지향 작업이다.
1. 데이터 소스 만들기
데이터 소스는 최신 뉴스를 반환하는 함수, 즉 ArticleHeadline 인스턴스 목록을 노출해야 한다. 데이터 소스는 네트워크에서 최신 뉴스를 가져오는 기본 안정성을 갖춘 방법을 제공해야 한다. 이 경우 작업을 실행할 CoroutineDispatcher 또는 Executor에 종속 항목을 가져와야 한다.
네트워크 요청은 새로운 fetchLatesNews() 메소드에서 처리되는 원샷 호출이다.
class NewsRemoteDataSource(
private val newsApi: NewsApi,
private val ioDispatcher: CoroutineDispatcher
) {
/**
* Fetches the latest news from the network and returns the result.
* This executes on an IO-optimized thread pool, the function is main-safe.
*/
suspend fun fetchLatestNews(): List<ArticleHeadline> =
// Move the execution to an IO-optimized thread since the ApiService
// doesn't support coroutines and makes synchronous requests.
withContext(ioDispatcher) {
newsApi.fetchLatestNews()
}
}
}
// Makes news-related network synchronous requests.
interface NewsApi {
fun fetchLatestNews(): List<ArticleHeadline>
}
NewsApi 인터페이스는 네트워크 API 클라이언트의 구현을 숨긴다. 인터페이스가 Retrofit 또는 HttpURLConnection의 지원을 받는지에 따라 달라지지 않는다. 인터페이스에 의존하면 앱에서 API 구현을 교체할 수 있다.
2. 저장소 만들기
이 작업의 저장소 클래스에는 추가 로직이 필요하지 않으므로 NewsRepository는 네트워크 데이터 소스의 프록시 역할을 한다.
// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
suspend fun fetchLatestNews(): List<ArticleHeadline> =
newsRemoteDataSource.fetchLatestNews()
}
메모리 내 데이터 캐싱 구현
뉴스 앱에 새로운 요구사항이 도입되었다고 가정해 보자. 사용자가 화면을 열면 이전에 요청이 생성된 경우 캐시된 뉴스가 사용자에게 표시되어야 한다. 그러지 않으면 앱이 최신 뉴스를 가져오기 위해 네트워크 요청을 해야 한다.
새로운 요구사항이 있으므로 앱은 사용자가 앱을 열고 있는 동안 메모리에 최신 뉴스를 보존해야 한다. 따라서 이는 앱 지향 작업이다.
1. 캐시
사용자가 앱에 있는 동안 메모리 내 데이터 캐싱을 추가하여 데이터를 보존할 수 있다. 캐시는 사용자가 앱에 있는 한 특정 시간 동안 메모리에 일부 정보를 저장하기 위해 실행된다. 캐시 구현은 다양한 형태를 취할 수 있다. 간단한 변경 가능 변수부터 여러 스레드에서 읽기/쓰기 작업을 금지하는 더욱 정교한 클래스에 이르기까지 다양할 수 있다. UseCase에 따라 저장소 또는 데이터 소스 클래스 내에 캐싱을 구현할 수 있다.
2. 네트워크 요청 결과 캐시
편의상 NewsRepository는 변경 가능한 변수를 사용하여 최신 뉴스르 캐시한다. 여러 스레드에서 읽기 및 쓰기를 금지하기 위해 Mutex가 사용된다.
다음 구현은 Mutex로 쓰기가 금지된 저장소의 변수에 최신 뉴스 정보를 캐시한다. 네트워크 요청 결과가 성공하면 데이타기 latestNews 변수에 할당된다.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
// Mutex to make writes to cached values thread-safe.
private val latestNewsMutex = Mutex()
// Cache of the latest news got from the network.
private var latestNews: List<ArticleHeadline> = emptyList()
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
if (refresh || latestNews.isEmpty()) {
val networkResult = newsRemoteDataSource.fetchLatestNews()
// Thread-safe write to latestNews
latestNewsMutex.withLock {
this.latestNews = networkResult
}
}
return latestNewsMutex.withLock { this.latestNews }
}
}
3. 작업을 화면보다 길게 유지
네트워크 요청이 진행되는 동안 사용자가 화면에서 벗어나면 취소되고 결과가 캐시되지 않는다. NewsRepository는 이 로직을 실행하는 데 호출자의 CoroutineScope를 사용해서는 안된다. 대신 NewsRepository는 수명 주기에 연결된 CoroutineScope를 사용해야 한다.
최신 뉴스를 가져오는 작업은 앱 지향 작업이어야 한다.
종속 항목 삽입 권장사항을 따르려면 NewsRepository는 자체 CoroutineScope를 만드는 대신 생성자의 매개변수로 범위를 수신해야 한다. 저장소는 대부분의 작업을 백그라운드 스레드에서 해야 하므로 CoroutineScope를 Dispatchers.Default 또는 자체 스레드 풀로 구성해야 한다.
class NewsRepository(
...,
// This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
private val externalScope: CoroutineScope
) { ... }
NewsRepository는 외부 CoroutineScope를 사용하여 앱 지향 작업을 실행할 준비가 되어 있으므로 데이터 소스 호출을 실행하고 그 범위에서 시작된 새 코루틴으로 결과를 저장해야 한다.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource,
private val externalScope: CoroutineScope
) {
/* ... */
suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
return if (refresh) {
externalScope.async {
newsRemoteDataSource.fetchLatestNews().also { networkResult ->
// Thread-safe write to latestNews.
latestNewsMutex.withLock {
latestNews = networkResult
}
}
}.await()
} else {
return latestNewsMutex.withLock { this.latestNews }
}
}
}
async는 외부 범위에서 코루틴을 시작하는 데 사용된다. 네트워크 요청이 다시 발생하고 결과가 캐시에 저장될 때까지 정지하기 위해 await가 새 코루틴에서 호출된다. 그때 사용자가 여전히 화면에 있다면 최신 뉴스가 표시된다. 사용자가 화면에 벗어나면 await가 취소되지만 async 내부의 로직은 계속 실행된다.
데이터 저장 및 디스크에서 가져오기
북마크한 뉴스와 사용자 환경서렁과 같은 데이터를 저장하려 한다고 가정해보자. 이러한 유형의 데이터는 사용자가 네트워크에 연결되어 있지 않더라도 프로세스가 종료된 후에도 남아 있어 엑세스 할 수 있어야 한다.
작업 중인 데이터가 프로세스 중단 후에도 유지되어야 하는 경우 다음 방법 중 하나로 데이터를 디스크에 저장해야 한다.
1. 쿼리해야 하거나 참조 무결성이 필요하거나 부분 업데이트가 필요한 대규모 데이터 세트의 경우 Room 데이터베이스에 데이터를 저장한다. 뉴스 앱 예시에서는 뉴스 기사나 작성자를 데이터베이스에 저장할 수 있다.
2. 쿼리하거나 부분적으로 업데이트하지 않고 검색 및 설정해야 하는 소규모 데이터 세트에는 DataStore를 사용한다. 뉴스 앱 예시에서 사용자의 기본 날짜 형식 또는 기타 표시 환경설정은 DataStore에 저장할 수 있다.
3. JSON 객체와 같은 데이터 청크의 경우 파일을 사용한다.
정보 소스 에서 언급했듯이 각 데이터 소스는 하나의 소스에서만 작동하며 특정 데이터 유형(Ex: News, Authors, NewsAndAuthors, UserPreferences)에 대응한다. 데이터 소스를 사용하는 클래스는 데이터가 저장되는 방식(Ex: 데이터베이스 또는 파일)을 알 수 없다.
데이터 소스로 사용되는 Room
각 데이터 소스는 특정 유형의 데이터에 관해 하나의 소스만 사용해야 하므로, Room 데이터 소스는 데이터 엑세스 객체(DAO) 또는 데이터베이스 자체를 매개변수로 수신할 수 있다.
Ex) NewsLocalDataSource는 NewsDao 인스턴스를 매개변수로 사용하고 AuthorsLocalDataSource는 AuthorsDao 인스턴스를 사용할 수 있다.
추가 로직이 필요하지 않은 경우 테스트에서 쉽게 대체할 수 있는 인터페이스이므로 DAO를 저장소에 직접 삽입할 수 있다.
데이터 소스로 사용되는 Datastore
DataStore는 사용자 설정과 같은 Key-Value 쌍을 저장하는데 적합하다.
Ex) 시간 형식, 알림, 환경설정, 사용자가 뉴스 항목을 읽은 후 표시하거나 숨길지 여부 등이 있다.
다른 객체와 마찬가지로 Datastore가 지원하는 데이터 소스에는 특정 유형이나 앱의 특정 부분에 해당하는 데이터가 포함되어야 한다. DataStore 읽기는 값이 업데이트될 때마다 방출되는 흐름으로 노출되므로 더욱 그렇다. 따라서 관련 환경설정을 동일한 Datastore에 저장해야 한다.
Ex) 알림 관련 환경설정만 처리하는 NotificationDataStore와 뉴스 화면 관련 환경설정만 처리하는 NewsPreferenceDataStore가 있을 수 있다. 이렇게 하면 업데이트 범위를 더 잘 지정할 수있다.
newsScreenPreferenceDataStore.data 흐름이 화면과 관련된 환경설정이 변경될 때만 발생하기 때문이다. 또한 객체의 수명 주기는 뉴스 화면이 표시되어 있는 동안에만 표시될 수 있으므로 더 짧을 수 있다.
데이터 소스로 사용되는 파일
JSON 객체나 비트맵과 같은 큰 객체로 작업할 때는 File 객체로 작업하고 스레드 전환을 처리해야 한다.
WorkManager를 사용하여 작업 예약
뉴스 앱에 또 다른 요구사항이 도입되었다고 가정해 보자. 이 앱은 기기가 충전되고 무제한 네트워크에 연결되어 있는 한 최신 뉴스를 정기적으로 자동으로 가져오는 옵션을 사용자에게 제공해야 한다. 따라서 비즈니스 지향작업이 된다.
이렇게 하면 사용자가 앱을 열 때 기기가 연결되지 않아도 사용자가 최신 뉴스를 볼 수 있다.
WorkManager를 사용하면 신뢰할 수 있는 비동기 작업을 쉽게 예약할 수 있으며 제약 조건을 관리할 수 있다. 영구 작업에 권장되는 라이브러리이다. 위 정의된 작업을 실행하기 위해 Worker 클래스인 FetchLatestNewsWorker가 생성된다. 이 클래스는 최신 뉴스를 가져와서 디스크에 캐시하기 위해 NewsRepository를 종속 항목으로 사용한다.
class RefreshLatestNewsWorker(
private val newsRepository: NewsRepository,
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
newsRepository.refreshLatestNews()
Result.success()
} catch (error: Throwable) {
Result.failure()
}
}
이 작업 유형의 비즈니스 로직은 자체 클래스에 캡슐화되고 별도의 데이터 소스로 처리되어야 한다. 그러면 WorkManager는 모든 제약 조건이 충족될 때 작업이 백그라운드 스레드에서 실행되도록 해야 한다. 이 패턴을 준수하면 필요에 따라 다른 환경의 구현을 신속하게 교체할 수 있다.
이 예시에서는 뉴스 관련 작업이 NewsRepository에서 호출되어야 한다. 그럼 새 데이터 소스를 NewsTaskDataSource 종속 항목으로 삼게 되며 다음과 같이 구현된다.
private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"
class NewsTasksDataSource(
private val workManager: WorkManager
) {
fun fetchNewsPeriodically() {
val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
REFRESH_RATE_HOURS, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
.setRequiresCharging(true)
.build()
)
.addTag(TAG_FETCH_LATEST_NEWS)
workManager.enqueueUniquePeriodicWork(
FETCH_LATEST_NEWS_TASK,
ExistingPeriodicWorkPolicy.KEEP,
fetchNewsRequest.build()
)
}
fun cancelFetchingNewsPeriodically() {
workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
}
}
이러한 유형의 클래스는 NewsTasksDataSource 또는 PaymentsTaskDataSource와 같이 책임이 있는 데이터에 따라 이름이 지정된다. 특정 데이터 유형과 관련된 모든 작업은 동일한 클래스에 캡슐화되어야 한다.
앱을 시작할 때 작업을 트리거해야 하는 경우 Initializer에서 저장소를 호출하는 앱 시작 라이브러리를 사용하여 WorkManager 요청을 트리거하는 것이 좋다.
Domain Layer은 UI Layer와 Data Layer 사이에 있는 Optional Layer이다.
Domain Layer는 복잡한 Business Logic이나 여러 ViewModel에서 재사용되는 간단한 Business Logic의 Encapsulation을 담당한다.
이는 선택적이므로 복잡성을 처리하거나 재사용성을 선호하는 등 필요한 경우에만 Domain Layer를 사용해야 한다.
Domain Layer는 다음과 같은 이점을 제공한다.
1. 코드 중복을 방지한다.
2. Domain Layer Class를 사용하는 Class의 가독성을 개선한다.
3. 앱의 테스트 가능성을 높인다.
4. 책임을 분할하여 대형 클래스를 피할 수 있다.
이러한 클래스를 간단하고 가볍게 유지하려면 각 사용 사례에서는 기능 하나만 담당해야 하고 변경 가능한 데이터를 포함해서는 안된다.
대신 UI Layer 또는 Data Layer에서 변경 가능한 데이터를 처리해야 한다.
캡슐화(Encapsulation)
객체의 속성(data fields)과 행위(methid)를 하나로 묶고, 실제 구현 내용 일부를 외부에 감추어 은닉한다.
이름 지정 규칙
현재 시제의 동사 + 명사/대상(선택사항) + UseCase
종속 항목
일반적인 앱 아키텍처에서 사용 사례 클래스는 UI Layer의 ViewModel과 Data Layer의 Repository 사이에 위치한다. 즉, UseCase 클래스는 일반적으로 Repository 클래스에 종속되며, Repository와 동일한 방법으로 콜백 또는 코루틴을 사용하여 UI Layer와 통신한다.
Ex) 뉴스 저장소의 데이터와 작성자 저장소의 데이터를 가져와서 이를 결합하는 UseCase 클래스가 앱에 있을 수 있다.
class GetLatestNewsWithAuthorsUseCase(
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository
) { /* ... */ }
UseCase는 재사용 가능한 로직을 포함하기 때문에 다른 UseCase에 의해 사용될 수 있다. Domain Layer에 여러 수준의 UseCase가 있는 것은 정상이다.
Ex) 아래 정의된 사용 사례는 UI Layer의 여러 클래스가 시간대를 사용하여 화면에 적절한 메시지를 표시하는 경우 FormatDateUseCase 사용 사례를 사용할 수 있다.
class GetLatestNewsWithAuthorsUseCase(
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository,
private val formatDateUseCase: FormatDateUseCase
) { /* ... */ }
Kotlin에서 UseCase 호출
Kotlin에서 operator constructor와 함께 invoke() 함수를 정의하여 UseCase 인스턴스를 함수처럼 호출 가능하게 만들 수 있다.
class FormatDateUseCase(userRepository: UserRepository) {
private val formatter = SimpleDateFormat(
userRepository.getPreferredDateFormat(),
userRepository.getPreferredLocale()
)
operator fun invoke(date: Date): String {
return formatter.format(date)
}
}
이 예에서 FormatDataUseCase의 invoke() 메소드를 사용하여 클래스 인스턴스 함수인것처럼 호출할 수 있다.
invoke() 메소드는 특정 Signature로 제한되지 않는다. 매개변수를 개수에 상관없이 취하고 모든 유형을 반환할 수 있다. 개발자는 클래스의 서로 다른 Signature로 invoke()를 오버로드 할 수 있다.
class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
init {
val today = Calendar.getInstance()
val todaysDate = formatDateUseCase(today)
/* ... */
}
}
LifeCycle
UseCase는 고유한 수명 주기를 갖지 않는다. 대신 그 UseCase를 사용하는 클래스의 범위가 적용된다. 즉, UI Layer 클래스에서, 서비스에서 또는 Application 클래스 자체에서 UseCase를 호출할 수 있다. UseCase는 변경 가능한 데이터를 포함해서는 안 되므로 개발자가 UseCase의 새 인스턴스를 종속 항목으로 전달할 때마다 그 인스턴스를 만들어야 한다.
Threading
Domain Layer의 UseCase는 기본 안정성을 갖추어야 한다. 즉, 기본 스레드에서 안전하게 호출되어야 한다. 장기 실행 차단 작업을 실행하는 UseCase는 관련 로직을 적절한 스레드로 옮기게 된다. 그러나 개발자는 이 작업이 이루어지기 전에 계층 구조에서 이러한 차단 작업이 더 잘 배치되는 다른 레이어가 있는지 확인해야 한다.
일반적으로 복잡한 계산은 재사용이나 캐싱을 유도하기 위해 Data Layer에서 이루어진다.
Ex) 결과를 캐시하여 앱의 여러 화면에서 재사용해야 하는 경우 대용량 목록을 대상으로 한 리소스 집약적인 작업은 도메인 레이어보다 데이터 레이어에 더 잘 배치된다.
class MyUseCase(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend operator fun invoke(...) = withContext(defaultDispatcher) {
// Long-running blocking operations happen on a background thread.
}
}
상기 예는 백그라운드 스레드에서 작업을 실행하는 사용 사례를 보여준다.
일반적인 작업
일반적인 도메인 레이어 작업을 실행하는 방법을 보자
재사용 가능한 간단한 비즈니스 로직
UI Layer에 있는 반복 가능한 비즈니스 로직은 UseCase 클래스에 캡슐화 해야 한다. 그러면 그 로직이 사용된 모든 곳에 변경사항을 더 쉽게 적용할 수 있다. 또한 로직을 독립적으로 테스트 할 수 있다.
앞서 설명한 FormatDataUseCase 예를 생각해보자. 향후에 날짜 형식과 관련된 비즈니스 요구사항이 변경되는 경우 코드를 중앙의 한 위치에서만 변경하면 된다.
Combine Repository
뉴스 앱에는 뉴스와 작성자 데이터 작업을 각각 처리하는 NewsRepository 클래스와 AuthorRepository 클래스가 있을 수 있다.
NewsRepository에서 노출되는 Article 클래스에는 작성자 이름만 포함된다. 하지만 개발자는 화면에 자세한 작성자 정보를 표시하고자 할 수 있다. 작성자 정보는 AuthorsRepository에서 얻을 수 있다.
로직은 여러 Repository와 관련되어 있고 복잡해질 수 있으므로 GetLatestNewsWithAuthorsUseCase 클래스를 만들어 ViewModel에서 로직을 추상화하고 가독성을 높일 수 있다. 또한 로직을 보다 쉽게 개별적으로 테스트하고 앱의 다른 부분에서 재사용할 수도 있다.
/**
* This use case fetches the latest news and the associated author.
*/
class GetLatestNewsWithAuthorsUseCase(
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend operator fun invoke(): List<ArticleWithAuthor> =
withContext(defaultDispatcher) {
val news = newsRepository.fetchLatestNews()
val result: MutableList<ArticleWithAuthor> = mutableListOf()
// This is not parallelized, the use case is linearly slow.
for (article in news) {
// The repository exposes suspend functions
val author = authorsRepository.getAuthor(article.authorId)
result.add(ArticleWithAuthor(article, author))
}
result
}
}
로직은 news 목록의 모든 항목을 매핑한다. 따라서 Data Layer가 기본 안정성을 갖추고 있더라도 이 작업은 기본 스레드를 차단하지 않는다. Data Layer에서 처리되는 항목 수를 알 수 없기 때문이다. UseCase에서 기본 디스패처를 사용하여 백그라운드 스레드로 작업을 옮기는 이유도 여기있다.
UI Event는 UI Layer에서 UI 또는 ViewModel로 처리해야 하는 작업이다.
가장 일반적인 이벤트 유형은 사용자 이벤트이다. 사용자는 화면 탭하기 또는 동작 생성과 같은 앱과의 상호작용을 통해 사용자 이벤트를 생성한다. 그러면 UI에서 onClick() 리스너와 같은 콜백을 소비하여 이러한 이벤트를 사용한다.
ViewModel은 일반적으로 특정 사용자 이벤트의 비즈니스 로직을 처리한다. ViewModel은 보통 UI에서 호출할 수 있는 함수를 노출하여 이러한 로직을 처리한다. 사용자 이벤트에는 UI에서 직접 처리할 수 있는 UI 동작 로직이 있을 수 있다.
UI Layer 페이지에서 이러한 유형의 로직을 다음과 같이 정의한다.
1. Business Logic은 결제 또는 사용자 환경설정 저장과 같은 State 변경과 관련하여 필요한 조치를 말한다. Domain, Data Layer는 일반적으로 이 로직을 처리한다.
2. UI behavior logic 이나 UI logic은 탐색 로직과 같이 상태 변경사항을 표시하는 방법 또는 사용자에게 메시지를 표시하는 방법을 나타낸다. 이 로직은 UI에서 처리한다.
UI Event Decision Tree
다음 다이어그램은 특정 이벤트 사용 사례를 처리하는 최상의 방법을 찾기 위한 결정트리를 보여준다.
사용자 이벤트 처리
확장 가능한 항목의 상태와 같이 UI 요소의 상태 수정과 관련된 경우 UI에서 사용자 이벤트를 직접 처리할 수 있다. 이벤트가 화면상 데이터의 새로고침 같은 비즈니스 로직을 실행해야 하는 경우 ViewModel로 처리해야 한다.
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand details event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
상기 예제는 다양한 버튼을 사용하여 UI 요소를 확장하는 방법(UI Logic)과 화면상 데이터를 새로고침하는 방법(Business Logic)을 보여준다.
RecyclerView의 사용자 이벤트
RecyclerView 항목 또는 Custom View 와 같이 UI Tree 아래쪽에서 작업이 생성되는 경우에도 ViewModel이 사용자 이벤트를 처리해야 한다.
Ex) NewsActivity의 모든 뉴스 항목에 북마크 버튼이 포함되어 있다고 가정해보자. ViewModel은 북마크된 뉴스 항목의 ID를 알아야 한다. 사용자가 뉴스 항목을 북마크에 추가하면 RecyclerView Adapter는 ViewModel에서 노출된 addBookmark(newsId) 함수를 호출하지 않으며, 여기에는 ViewModel의 종속 항목이 필요하다. 대신 ViewModel은 이벤트 처리를 위한 구현이 포함된 NewsItemUiState라는 상태 객체를 노출한다.
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
이렇게 하면 RecyclerView Adapter가 NewsItemUiState 객체 목록과 같이 필요한 데이터만 사용할 수 있다. Adapter가 전체 ViewModel에 엑세스 할 수 없으므로 ViewModel에 의해 노출된 기능을 악용할 가능성이 낮다. Activity클래스에서만 ViewModel을 사용하도록 허용하는 경우 책임이 분리된다. 이렇게 하면 View or RecyclerView Adapter와 같은 UI 별 객체가 ViewModel과 직접 상호작용하지 않는다.
ViewModel Event 처리
ViewModel 이벤트에서 발생하는 UI 작업(ViewModel Event)은 항상 UI 상태 업데이트로 이어진다. 이는 단방향 데이터 흐름의 원칙을 준수한다. 구성 변경 후에 이벤트를 재현할 수 있으며 UI 작업이 손실되지 않는다. 저장된 상태 모듈을 사용하는 경우 선택적으로 프로세스 중단 후에도 이벤트를 재현 가능하게 만들 수 있다.
UI Action을 UI State에 매핑하는 것이 항상 간단한 프로세스인 것은 아니지만, 로직은 더 간단해진다.
Ex) 사고 과정이 UI를 특정 화면으로 이동하는 방법을 결정하는 데서 끝나서는 안된다. UI 상태에서 사용자가 흐름을 나타내는 방법을 좀 더 생각해봐야 한다. 즉, UI에서 실행해야 하는 작업이 아니라 이런 작업이 UI State에 어떤 영향을 미치는지 생각해보라.
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
예를 들어 사용자가 로그인 화면에 로그인한 후 홈 화면으로 이동하는 경우를 생각해보면 이 상황은 UI State에서 위와 같이 모델링할 수 있다. 이 UI는 isUserLoggedIn State 변경에 반응하고 필요에 따라 올바른 대상으로 이동한다.
이벤트를 소비하면 상태 업데이트가 트리거될 수 있다.
UI에서 특정 ViewModel 이벤트를 소비하면 다른 UI 상태가 업데이트될 수 있다.
Ex) 화면에 임시 메시지를 표시하여 사용자에게 무언가가 발생했음을 알리는 경우, 메시지가 화면에 표시되었을 때 UI가 다른 상태 업데이트를 trigger하도록 ViewModel에 알려야 한다. 사용자가 메시지를 소비했을 때(이벤트를 닫거나 시간이 초과됨) 발생하는 이벤트는 '사용자 입력'으로 다루어야 할 수 있으므로 ViewModel에 이를 알아야 한다. 이 상황에서는 다음과 같이 UI 상태를 모델링할 수 있다.
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
Business Logic이 사용자에게 임시 메시지를 새로 표시해야 하는 경우 ViewModel은 다음과 같이 UI State를 업데이트한다.
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
ViewModel은 UI가 화면에 메시지를 표시하는 방식을 알 필요가 없다. 사용자에게 표시해야 하는 사용자 메시지가 있다는 사실만 알면 된다. 임시 메시지가 표시되면 UI가 ViewModel에 이를 알려야 하며 그러면 userMessage 속성을 삭제하기 위해 또 다른 UI 상태 업데이트가 발생한다.
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
기타 사용 사례
UI State 업데이트로 UI Event 사용 사례를 해결할 수 없다고 생각되면 앱의 데이터 흐름 방식을 다시 고려해야 할 수 있다. 다음 원칙을 고려하라.
1. 각 클래스에서 각자의 역할만을 수행해야 한다. UI는 탐색 호출, 클릭 이벤트, 권한 요청 가져오기와 같은 화면별 동작 로직을 담당한다. ViewModel은 비즈니스 로직을 포함하며 계층 구조의 하위 레이어에서 얻은 결과를 UI State로 변환한다.
2. 이벤트가 발생하는 위치를 생각하라. 상기 결정 트리를 따르고 각 클래스가 담당하는 역할을 처리하게 한다. 예를 들어 이벤트가 UI에서 발생하고 그 결과 탐색 이벤트가 발생하면 이 이벤트는 UI에서 처리되어야 한다. 일부 로직이 ViewModel에 위임될 수 있지만, 이벤트 처리는 ViewModel에 완전히 위임될 수 없다.
3. 소비자가 여러 명이고 이벤트가 여러 번 소비될 것이 우려된다면 앱 아키텍처를 다시 고려해야 할 수 있다.
동시 실행 소비자가 여럿인 경우 정확히 한 번 제공되는 계약을 보장하기가 매우 어려워지므로 복잡성과 미묘한 동작의 양이 폭발적으로 증가한다. 이 문제가 발생하면 UI Tree의 위쪽으로 문제를 푸시하라. 계층 구조의 상위로 범위가 지정된 다른 항목이 필요할 수 있다.
4. 상태를 소비해야 하는 경우를 생각해 보라. 어떤 상황에서는 앱이 백그라운드에 있다면 계속 소비하지 않는 것이 좋다.Ex) Toast
UI의 역할은 화면에 어플리케이션 데이터를 표시하고 사용자 상호작용의 기본 지점으로서의 역할을 수행하는 것.
사실상 UI는 데이터 레이어에서 가져온 어플리케이션의 상태를 시각적으로 나타낸다.
하지만 일반적으로 데이터 레이어에서 가져오는 어플리케이션 데이트는 표시해야 하는 정보와 다른 형식
-> UI용으로 데이터의 일부만 필요
-> 사용자에게 관련성 있는 정보를 표시하기 위해 서로 다른 두 데이터 소스를 병합
UI 레이어는 어플리케이션 데이터 변경사항을 UI가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인이다.
UI Layer Architecture
UI라는 용어는 사용하는 API(View or Jetpack Compose)와 관계없이 데이터를 표시하는 Activity or Fragment와 같은 UI 요소를 가리킨다.
Data Layer의 역할은 앱 데이터를 보유하고 관리하며 앱 데이터에 엑세스할 권한을 제공하는 것이므로 UI 레이어에서 다음 단계를 실행한다.
1. 앱 데이터를 사용하고 UI에서 쉽게 렌더링할 수 있는 데이터로 변환한다.
2. UI 렌더링 가능 데이터를 사용하고 사용자에게 표시할 UI 요소를 변환한다.
3. 이렇게 조합된 UI 요소의 사용자 입력 이벤트를 사용하고 입력 이벤트의 결과를 필요에 따라 UI 데이터에 반영한다.
4. 1~3단계를 필요에 따라 반복
UI State Definition
간단히 말해 UI에는 각 기사의 일부 MetaData와 함께 기사 목록이 표시된다.
즉, 사용자가 보는 화면이 UI라면 UI State는 앱에서 사용자가 봐야 한다고 지정하는 항목이다.
UI State 변경 -> UI 즉시 반영
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
불변성
위 예에서 UI State 정의는 변경할 수 없다. 불변성의 주요 이점은 변경 불가능한 객체가 순간의 어플리케이션 상태를 보장한다는 점이다.
이로 인해 UI는 상태를 읽고 이에 따라 UI 요소를 업데이트하는 한 가지 역할에 집중할 수 있다. 따라서 UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI State를 직접 수정해서는 안된다. 이 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가 발생한다.
상기 가이드의 이름 지정 규칙은 다음과 같다.
기능 + UIState
예를 들어 뉴스를 표시하는 화면의 상태는 NewsUiState, 뉴스 항목 목록에 있는 뉴스 항목의 상태는 NesItemUiState일 수 있다.
단방향 데이터 흐름으로 UI State 관리
앞서 상기 UI State가 UI Rendering에 필요한 세부정보가 포함된 변경 불가능한 Snapshot임을 확인했다.
하지만 앱 데이터의 동적 특성에 따라 상태는 시간이 지나면서 변경될 수 있으며 이는 앱을 채우는 데 사용되는 기본 데이터를 수정하는 사용자 상호작용이나 기타 이벤트로인해 발생하기도 한다.
단방향 데이터 흐름(Unidirectional Data Flow)
상태 홀더
UI State를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스를 상태 홀더(State Holder)라고 한다.
전체 화면이나 탐색 대상의 경우 일반적인 구현은 ViewModel의 인스턴스이지만 어플리케이션의 요구사항에 따라 간단한 클래스로도 충분하다.
UI 와 State 생성자 간의 상호 종속을 모델링하는 방법은 다양하다. 하지만 UI와 ViewModel클래스 사이의 상호작용은 대체로 이벤트 입력과 입력의 후속 상태인 출력으로 간두될 수 있으며 관계는 상기 다이어그램과 같다.
상태가 아래로 향하고 이벤트는 위로 향하는 패턴을 단방향 데이터 흐름(UDF)이라고 한다. 이 패턴이 앱 아키텍처에 미치는 영향은 다음과 같다.
1. ViewModel이 UI에 사용될 상태를 보유하고 노출한다. UI State는 ViewModel에 의해 변환된 어플리케이션 데이터이다.
2. UI가 ViewModel에 사용자 이벤트를 알린다.
3. ViewModel이 사용자 작업을 처리하고 State(상태)를 업데이트한다.
4. 업데이트된 상태가 렌더링할 UI에 다시 제공된다.
5. 상태 변경을 야기하는 모든 이벤트 위의 작업이 반복된다.
상기 다이어그램을 보자. 사용자의 기사 북마크 요청은 상태 변경을 야기할 수 있는 이벤트의 예이다. 상태 생성자의 경우 UI State의 모든 필드를 채우고 UI가 완전히 렌더링되는데 필요한 이벤트를 처리하기 위해 모든 필수 로직을 정의하는 역할은 ViewModel이 담당한다.
로직의 유형
상기 예처럼 기사 북마크는 앱에 가치를 부여하므로 비즈니스 로직의 예이다.
-> 비즈니스 로직은 State 변경에 따라 진행해야 할 작업이다. 비즈니스 로직은 일반적으로 도메인 또는 데이터 레이어에 배치되지만 UI Layer에는 배치되지 않는다.
-> UI 동작 로직 또는 UI 로직은 State 변경 사항을 표시하는 방법이다.
* 특히 Context와 같은 UI 유형의 경우 UI 로직은 ViewModel이 아닌 UI에 있어야 한다. 테스트 가능성을 높이고 문제 구분에 도움이 되도록 UI로직을 다른 클래스에 위임하고자 하며 UI가 점점 복잡해지는 경우 간단한 클래스를 State Holder로 만들 수 있다.
UDF(Unidirectional Data Flow)을 사용하는 이유
UDF는 상기 다이어그램과 같이 상태 생성 주기를 모델링한다. 또한 상태 변경이 발생하는 위치, 변환되는 위치, 최종적으로 사용되는 위치를 구분한다. 이렇게 구분하면 UI가 이름에 드러난 의미 그대로 동작할 수 있다.
즉, 상태 변경사항을 관찰하여 표시하고 변경사항을 ViewModel에 전달하여 사용자 인텐트를 전달한다.
UDF 를 사용하면 다음이 가능하다.
1. 데이터 일관성: UI용 정보 소스가 하나이다.
2. 테스트 가능성: 상태 소스가 분리되므로 UI와 별개로 테스트 할 수 있다.
3. 유지 관리성: 상태 변경은 잘 정의된 패턴을 따른다. 즉, 변경은 사용자 이벤트 및 데이터를 가져온 소스 모두의 영향을 받는다.
UI 상태 노출
UI 상태를 정의하고 이 상태의 생성을 관리할 방법을 결정한 후에는 생성된 상태를 UI에 표시하는 단계를 진행한다.
UDF를 사용하여 상태 생성을 관리하므로 생성된 상태를 스트림으로 간주할 수 있다.
즉, 시간 경과에 따라 여러 버전의 상태가 생성된다. 따라서 LiveData, StateFlow와 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야 한다. 이유는 ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있도록 하기 위해서이다.
이러한 유형은 항상 최신 버전의 UI 상태를 캐시한다는 이점도 있다. 이는 구성 변경 후 빠른 상태 복원에 유용하다.
class NewsViewModel(...) : ViewModel() {
val uiState: StateFlow<NewsUiState> = …
}
UI에 노출되는 데이터가 비교적 간단할 때는 UI 상태 유형으로 데이터를 래핑하는 것이 좋은 경우가 많다.
내보낸 상태 홀더와 관련 View/UI Component 간의 관계를 전달하기 때문. 또한 UI Component가 더 복잡해질 때 언제나 간편하게 UI State Definition을 추가하여 UI Component를 렌더링하는데 필요한 더 많은 정보를 포함할 수 있다.
UIState Stream을 만드는 일반적인 방법은 ViewModel에서 지원되는 변경 가능한 스트림을 변경 불가능한 스트림으로 노출하는 것이다.
Ex) MutableStateFlow<UIState> 를 StateFlow<UIState>로 노출한다.
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
...
}
이후, ViewModel은 상태를 내부적으로 변경하는 메서드를 노출하여 UI에 사용되도록 업데이트를 게시한다. 예를 들어 비동기 작업을 실행해야 하는 경우 viewModelScope를 사용하여 코루틴을 실행하고 코루틴이 완료되면 변경 가능한 상태를 업데이트 할 수 있다.
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
상기 예에서 NewsViewModel 클래스는 특정 카테고리의 기사를 가져오려고 시도한 후에 결과에 따라 UI가 적절하게 반응할 수 있도록 시try catch결과를 UI 상태에 반영한다.
추가 고려사항
1. UI State 객체는 서로 관련성 있는 State를 처리해야 한다. 이렇게 하면 불일치가 줄어들고 코드를 이해하기 더 쉽다. 뉴스 항목 목록과 북마크 수를 서로 다른 두 스트림에 노출하면 한 스트림이 업데이트 되고 다른 스트림은 업데이트되지 않은 상황이 발생할 수 있다. 단일 스트림을 사용하면 두 요소가 최신 상태로 유지된다. 또한 일부 비즈니스 로직에는 소스 조합이 필요할 수 있다.
Ex) 로그인한 상태인 동시에 프리미엄 뉴스 서비스의 구독자인 사용자에게만 북마크 버튼을 표시해야 한다면 다음과 같이 UI State클래스를 정의하면된다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
2. UI State: 단일 스트림인지 여러 스트림인지. UI State 노출 대상을 단일 스트림과 여러 스트림 중에서 선택할 때 주요 원칙은 항목 간의 관계이다. 단일 스트림 노출의 가장 큰 장점은 편의성과 데이터 일관성이다. 즉, State 사용자가 언제나 즉시 최신 정보를 확인할 수 있다. 하지만 ViewModel 상태의 스트림이 별개일 때 적합한 경우가 있다.
-> 관련 없는 데이터 유형: UI를 렌더링하는 데 필요한 일부 상태는 서로 완전히 별개일 수 있다. 이때 서로 다른 상태를 번들로 묶는 데 드는 비용이 이점보다 더 클 수 있으며 이는 상태 중 하나가 다른 상태보다 더 자주 업데이트 되는 경우에 특히 그렇다.
-> UIState 비교(diff): UIState 객체에 필드가 많을수록 필드 중 하나를 업데이트하면 스트림이 내보내질 가능성이 크다. 뷰에는 연속적으로 이러우지는 내보내기가 같은지 다른지 파악하는 비교(diff) 메커니즘이 없으므로 내보내기할 때마다 뷰가 업데이트된다. 따라서 Flow API 또는 LiveData의 distinctUntilChaged()와 같은 메서드를 사용한 완화 작업이 필요할 수있다.
UI State 사용
UI에서 UIState 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용한다.
UI에서 관찰 가능한 데이터 홀더를 사용할 때는 UI의 수명 주기를 고려해야 한다. 수명 주기를 고려해야 하는 이유는 사용자에게 뷰가 표시되지 않을 때 UI가 UI 상태를 관찰해서는 안되기 때문. LiveData를 사용하면 LifecycleOwner가 수명 주기 문제를 암시적으로 처리한다. Flow를 사용할 때는 적절한 코루틴 범위와 repeatOnLifecycle API로 처리하는 것이 가장 좋다.
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
진행 중인 작업 표시
UIState 클래스의 로드 상태를 나타내는 간단한 방법은 Boolean Field를 사용하는 것이다.
data class NewsUiState(
val isFetchingArticles: Boolean = false,
...
)
이 플래그의 값은 UI에 진행률 표시줄이 존재하는지를 나타낸다.
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Bind the visibility of the progressBar to the state
// of isFetchingArticles.
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
}
}
}
화면에 오류 표시
UI에서의 오류 표시는 진행 중인 작업 표시와 비슷하다. 두 작업은 모두 존재 여부를 나타내는 Boolean 값으로 쉽게 표현되기 때문이다.
하지만 오류에는 사용자에게 다시 전달하는 관련 메시지 또는 실패한 작업을 다시 시도하는 관련 작업이 포함될 수 있다.
따라서 진행중인 작업을 로드하고 있거나 로드하고 있지 않은 동안 오류 컨텍스트에 적절한 메타데이터를 호스팅하는 데이터 클래스를 사용하여 오류 상태를 모델링해야 할 수 있다.
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(),
...
)
Threading and concurrency
ViewModel에서 실행되는 모든 작업은 기본 스레드에서 안전하게 호출된다는 기본 안전성을 갖추어야 한다. 데이터 레이어와 도메인 레이어가 작업을 다른 스레드로 옮기는 역할을 담당하기 때문.
또한 장기 실행 작업의 경우 ViewModel에서 로직을 백그라운드 스레드로 옮기는 역할을 담당한다. Kotlin 코루틴은 동시 실행 작업을 관리하는 좋은 방법이며, JetPack 아키텍처 구성요소는 Kotlin Coroutine을 기본적으로 지원한다.
Navigation
앱 Navigation 변경사항은 주로 이벤트 같은 내보내기에 의해 이루어진다. 예를 들어 SignInViewModel Class가 로그인을 실행하면 UIState는 isSignedin 필드를 true로 설정할 수 있다. 이러한 Trigger 위의 UI State 사용에서 설명한 대로 사용해야 한다.
단, 사용 구현이 탐색 구성요소를 지연해야 한다.
Paging
Paging 라이브러리는 UI에서 PagingData라는 유형과 함께 사용된다. PagingData는 시간 경과에 따라 변경될 수 있는(즉, 변경 불가능한 유형이 아님)항목을 나타내고 포함하므로 변경 불가능한 UI 상태로 표현되엇는 안된다. 대신 ViewModel의 데이터를 독립적으로 자체 스트림에서 노출해야한다.
Animation
부드럽고 원활한 최상위 탐색 전환을 제공하기 위해 다음 화면의 데이터가 로드될 때까지 기다린 후에 애니메이션을 시작하는 것이 좋다.
Android View Framework는 postponeEnterTransition() 및 startPostponedEnterTransition() API를 사용하여 프래그먼트 대상 간의 전환을 지연하는 후크를 제공한다.
Android App component: Activity, Fragment, Service, Api, broadcast receiver 등 여러 앱 구성 요소가 포함된다.
-> 이것들을 menifest에서 선언하며 OS에서는 상기 파일들을 사용해 전반적인 사용자 경험에 앱을 통합한다.
환경조건:
1. 사용자는 짧은 시간 내에 여러 앱과 상호작용하는 경우가 있다. -> 즉, 헤비유저같은..?
2. 휴대기기는 리소스가 제한되어 있으므로, 운영체제에서 새로운 앱을 위한 공간을 확보하도록 언제든지 일부 앱 프로세스를 종료해야할 수 있다.
이러한 이벤트는 직접 제어할 수 없기 때문에 앱 구성요소에 애플리케이션 데이터나 상태를 저장해서는 안 되며 앱 구성요소가 서로 종속되면 안 됩니다.
일반 아키텍처 원칙
Android 앱은 크기가 커지기 때문에 앱을 확장하고 앱의 견고성을 높이며 앱을 더 쉽게 테스트 할 수 있도록 아키텍처를 정의해야한다.
앱 아키텍처는 앱의 부분과 그 각 부분에 필요한 기능 간의 경계를 정의한다.
준수해야 할 특정원칙은 다음과 같다.
1. 관심사 분리
Activity 또는 Fragment에 모든 코드를 작성하는건 굉장히 큰 실수. 이러한 UI 기반 클래스는 UI 및 운영체제 상호작용을 처리하는 로직만 포함
2. 데이터 모델에서 UI 도출하기
데이터 모델이란 앱의 데이터를 나타내며, 앱의 UI 요소 및 기타 구성요소로부터 독립되어 있다. 즉, 이들은 UI 및 앱 구성요소 수명주기와는 관련이 없다. 하지만, OS가 메모리에서 앱의 프로세스를 삭제하기로 결정하면 데이터 모델도 삭제된다.
이상적인 지속 모델이 중요한 이유
-> Android OS에서 리소스를 확보하기 위해 앱을 제거해도 사용자 데이터가 삭제되지 않는다.
-> 네트워크 연결이 취약하거나 연결되어 있지 않아도 앱이 계속 작동한다.
권장 앱 아키텍처
UI Layer
UI Layer(또는 프레젠테이션 레이어)의 역할은 화면에 어플리케이션 데이터를 표시하는 것이다.
사용자 상호작용(버튼 액션) 또는 외부 입력(네트워크 응답)으로 인해 데이터가 변할 때마다 변경사항을 반영하도록 UI가 업데이트 되어야 한다.
UI 레이어는 다음 두 가지로 구성된다.
1. 화면에 데이터를 렌더링하는 UI 요소, 이러한 요소는 뷰 또는 Jetpack Compose 함수를 사용하여 빌드할 수 있다.
2. 데이터를 보유하고 이를 UI에 노출하며 로직을 처리하는 상태 홀더(ex: ViewModel)
Data Layer
앱의 데이터 레이어에는 비즈니스 로직이 포함되어 있다. 비즈니스 로직은 앱에 가치를 부여하는 요소로, 앱의 데이터 생성, 저장, 변경 방식을 결정하는 규칙으로 구성된다.
데이터 레이어는 각각 0개부터 여러 개의 데이터 소스를 포함할 수 있는 저장소로 구성된다. 앱에서 처리하는 다양한 유형의 데이터별로 저장소 클래스를 만들어야 한다.
예) 영화 관련 데이터에는 MoviesRepository Class, 결제 관련 데이터에는 PaymentRepository 클래스를 만들 수 있다.
저장소 클래스에서 담당하는 작업은 다음과 같다.
1. 앱의 나머지 부분에 데이터 노출
2. 데이터 변경사항을 한 곳에 집중
3. 여러 데이터 소스 간의 충돌 해결
4. 앱의 나머지 부분에서 데이터 소스 추상화
5. 비즈니스 로직 포함
각 데이터 소스 클래스는 파일, 네트워크 소스, 로컬 데이터베이스와 같은 하나의 데이터 소스만 사용해야 한다. 데이터 소스 클래스는 데이터 작업을 위해 어플리케이션과 시스템 간의 가교 역할을 한다.
Domain Layer(Optional)
Domain Layer는 복잡한 비즈니스 로직, 또는 여러 ViewModel에서 재사용되는 간단한 비즈니스 로직의 캡슐화를 담당한다. 모든 앱에 이러한 요구사항이 있는 것이 아니므로 이 레이어는 선택사항이다. 따라서 복잡성을 처리하거나 재사용성을 선호하는 등 필요한 경우에만 사용.
구성요소 간 종속성 관리
특정 클래스의 종속 항목을 수집하는 데 다음 디자인 패턴 중 하나를 사용할 수 있다.
-> 종속 항목 주입(DI): 종속 항목 주입을 사용하면 클래스가 자신의 종속 항목을 구성할 필요 없이 종속 항목을 정의할 수 있다.
-> 서비스 로케이터: 서비스 로케이터 패턴은 클래스가 자신의 종속 항목을 구성하는 대신 종속 항목을 가져올 수 있는 레지스트리를 제공.
*이 패턴은 코드를 중복학나 복잡성을 추가하지 않아도 종속 항목을 관리하기 위한 명확한 패턴을 제공하므로 코드를 확장할 수 있다.
또한 이러한 패턴을 사용하면 테스트와 프로덕션 간에 신속하게 전환할 수 있다.
종속 항목 삽입 패턴을 따르고 Android 앱에서 Hilt 라이브러리를 사용하는 것이 좋다.
-> Hilt는 종속 항목 트리를 따라 이동하여 객체를 자동으로 구성하고 종속 항목의 컴파일 시간을 보장하며 Android 프레임워크 클래스의 종속 항목 컨테이너를 만든다.
A PopupMenu displays aMenuin a modal popup window anchored to aView. The popup will appear below the anchor view if there is room, or above it if there is not. If the IME is visible the popup will not overlap it until it is touched. Touching outside of the popup will dismiss it.[설명발췌]
-> 팝업 메뉴는 뷰에 고정된 뷰에 고정된 양식이 정해진 메뉴를 보여준다. 팝업은 만약 그 뷰에 공간이 있다면 그 아래로 보여지거나, 그 공간이 없다면 그 위로 보여진다. 만약 입력기(키패드와같은)가 보여질때 팝업은 그것이 터치 될때까지 오버랩 하지 않는다.
팝업 외의 부분을 터치할 경우 팝업은 사라진다.
이처럼 툴바에서 찾을 수 있는 기능들을 단순하게 팝업을 사용함으로써 간단하게 쓸 수 있다.
코드는 다음과 같다.
optionMenu = findViewById(R.id.memberTabMenuButton)
//memberTabMenuButton : ImageView
optionMenu.setOnClickListener {
val popup = PopupMenu(this, it)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(R.menu.menu, popup.menu)
//The int of R.menu.menu is Same as Toobar Layout Setting
popup.setOnMenuItemClickListener { menuItem ->
when(menuItem.itemId){
R.id.logout -> {
Log.d("logout", "selected")
}
}
true
}
popup.show()
}