우선 코루틴에 대해서 기본은 알고 있다고 생각하고 넘어가겠다. 또한, 코루틴에 대한 기본 및 심화는 추가로 올리는걸로..

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가 만들어진다.

'Computer engineering > Android Programming' 카테고리의 다른 글

REST API_HttpURLConnection_Coroutine  (0) 2022.07.13
AAC_ViewModel2  (0) 2022.07.12
AAC_ViewModel_1  (0) 2022.07.12
MVVM with Clean Architecture  (0) 2022.07.11
4. Architecture Component UI Layer Data Binding_3  (1) 2022.07.10

Rest API Definition

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)
    }
}

이제, HTTP request를 다음과 같이 사용할 수 있다!

HTTPAsyncTask().execute("http://hmkcode-api.appspot.com/rest/api/hello/Android")

 

Perform Network Operations on a Seperate Thread Using Coroutine

suspend 함수를 사용해 보자. 

suspend로 표시된 함수는 결과가 반환될 때까지 실행을 일시 중단한다는 특징을 갖는다.

결과값을 기다리는 동안, 다른 함수나 코루틴이 실행될 수 있도록 실행 중인 차단을 해제한다.

private suspend fun httpGet(myURL: String?): String? {...}

 

Dispatcher

Dispatcher는 코루틴 내에서 어떤 쓰레드를 돌릴지 결정한다.

Dispatcher에는 Default, IO, Main이 있다.

지금은 HTTP request 예제이기 때문에 withContext(Dispatchers.IO)를 통해 IO Thread를 사용한다.

 

private suspend fun httpGet(myURL: String?): String? {

        val result = withContext(Dispatchers.IO) {
           
           // HTTP GET request code... 

        }
        return result
    }

Coroutine Scope

Coroutine은 CoroutineScope에서 작동한다.

Scope는 Coroutine의 생명주기를 관리한다.

또한, GlobalScope를 사용하여 코루틴을 시작할 수 있다.

 

GlobalScope는 백그라운드에서 작업을 실행하기 좋지만, 비추천한다.

GlobalScope.launch(Dispatchers.Main) {
                val result = httpGet("http://hmkcode-api.appspot.com/rest/api/hello/Android")
                tvResult.setText(result)
            }

또한, GlobalScope를 LifecycleScope로 대체할 수 있다.

 

LifecycleScope는 KTX 확장에 포함된 AAC의 세가지 기본 제공 코루틴 scope중 하나이다.

자 이제 설명은 끝났으니 변경해보자

 lifecycleScope.launch {
                val result = httpGet("http://hmkcode-api.appspot.com/rest/api/hello/Android")
                tvResult.setText(result)
            }

'Computer engineering > Android Programming' 카테고리의 다른 글

Coroutine_Flow  (0) 2022.07.14
AAC_ViewModel2  (0) 2022.07.12
AAC_ViewModel_1  (0) 2022.07.12
MVVM with Clean Architecture  (0) 2022.07.11
4. Architecture Component UI Layer Data Binding_3  (1) 2022.07.10

프래그먼트 간  데이터 공유

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를 업데이트 한다.

 

'Computer engineering > Android Programming' 카테고리의 다른 글

Coroutine_Flow  (0) 2022.07.14
REST API_HttpURLConnection_Coroutine  (0) 2022.07.13
AAC_ViewModel_1  (0) 2022.07.12
MVVM with Clean Architecture  (0) 2022.07.11
4. Architecture Component UI Layer Data Binding_3  (1) 2022.07.10

개요

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은 존재한다.

 

 

Intro

만일, 안드로이드 개발 시 정확한 아키텍처를 사용하지 않는다면, 코드가 많아지고 팀이 확장됨에 따라 유지보수가 어려워진다.

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 in RemoteDataSource. 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()
}

[출처]

https://www.toptal.com/android/android-apps-mvvm-with-clean-architecture

Observable Data Object Work

식별 가능성은 객체가 데이터 변경에 관해 다른 객체에 알릴 수 있는 기능을 의미한다. 데이터 바인딩 라이브러리를  통해 객체, 필드 또는 컬렉션을 식별 가능하게 만들 수 있다.

 

간단한 기존 객체를 데이터 바인딩에 사용할 수는 있지만 객체를 수정해도 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 클래스가 유용하다.

    ObservableArrayMap<String, Any>().apply {
        put("firstName", "Google")
        put("lastName", "Inc.")
        put("age", 17)
    }

    

다음 과 같이 레이아웃에서 문자열 키를 사용하여 맵을 찾을 수 있다.

<data>
        <import type="android.databinding.ObservableMap"/>
        <variable name="user" type="ObservableMap<String, Object>"/>
    </data>
    …
    <TextView
        android:text="@{user.lastName}"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView
        android:text="@{String.valueOf(1 + (Integer)user.age)}"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    

Ex) 다음과 같이 레이아웃에서 색인을 통해 목록에 엑세스 할 수 있다.

<data>
        <import type="android.databinding.ObservableList"/>
        <import type="com.example.my.app.Fields"/>
        <variable name="user" type="ObservableList<Object>"/>
    </data>
    …
    <TextView
        android:text='@{user[Fields.LAST_NAME]}'
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView
        android:text='@{String.valueOf(1 + (Integer)user[Fields.AGE])}'
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    

식별 가능한 객체

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 필드를 생성한다.

<layout xmlns:android="http://schemas.android.com/apk/res/android">
       <data>
           <variable name="user" type="com.example.User"/>
       </data>
       <LinearLayout
           android:orientation="vertical"
           android:layout_width="match_parent"
           android:layout_height="match_parent">
           <TextView android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:text="@{user.firstName}"
       android:id="@+id/firstName"/>
           <TextView android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:text="@{user.lastName}"
      android:id="@+id/lastName"/>
       </LinearLayout>
    </layout>
    

라이브러리는 단일 패스로 뷰 계층 구조에서 ID가 포함된 뷰를 추출한다. 이 메커니즘은 레이아웃의 모든 뷰에 findViewById() 메소드를 호출하는 것보다 속도가 더 빠를 수 있다.

ID는 데이터 바인딩이 없을 때만큼 필요하지 않지만 코드에서 계속 뷰에 엑세스해야 하는 상황이 여전히 있다.

 

변수

데이터 바인딩 라이브러리는 레이아웃에 선언된 각 변수의 접근자 메소드를 생성한다.

Ex) 다음 레이아웃은 user, image, note 변수의 결합 클래스에 setter 및 getter 메서드를 생성한다.

<data>
       <import type="android.graphics.drawable.Drawable"/>
       <variable name="user" type="com.example.User"/>
       <variable name="image" type="Drawable"/>
       <variable name="note" type="String"/>
    </data>
    

ViewStub

일반 뷰와 달리 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>
    

클래스 이름 앞에 마침표를 접두사로 추가하여 다른 패키지에서 바인딩 클래스를 생성할 수 있다.

다음은 모듈 패키지에 바인딩 클래스를 생성한다.

<data class=".ContactItem">
        …
    </data>
    

또한 바인딩 클래스를 생성할 패키지의 전체 이름을 사용할 수 있다.

다음은 com.example 패키지에 ContactItem 결합 클래스를 만드는 예이다.

<data class="com.example.ContactItem">
        …
    </data>
    

레이아웃 및 바인딩 표현식

표현식 언어를 사용하면 뷰에 의해 전달된 이벤트를 처리하는 표현식을 작성할 수 있다.

데이터 바인딩 라이브러리는 레이아웃의 뷰를 데이터 객체와 결합하는 데 필요한 클래스로 자동 생성한다.

 

데이타 바인딩 레이아웃 파일은 약간 차이가 있으며 layout의 루트 태그로 시작하고 data 요소 및 view 루트 요소가 뒤따른다. 이 view 요소는 결합되지 않은 레이아웃 파일에 루트가 있는 요소이다.

<?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android">
       <data>
           <variable name="user" type="com.example.User"/>
       </data>
       <LinearLayout
           android:orientation="vertical"
           android:layout_width="match_parent"
           android:layout_height="match_parent">
           <TextView android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:text="@{user.firstName}"/>
           <TextView android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:text="@{user.lastName}"/>
       </LinearLayout>
    </layout>
    

data 내의 user 변수는 이 레이아웃 내에서 사용할 수 있는 속성을 설명한다.

<variable name="user" type="com.example.User" />
    

레이아웃 내의 표현식은 '@{}' 구문을 사용하여 특정 속성에 작성된다. 여기서 TextView 텍스트는 user 변수의 firstName 속성으로 설정된다.

<TextView android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:text="@{user.firstName}" />
    

* 레이아웃 표현식은 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)

    

표현식 언어

일반적인 기능

표현식 언어는 관리형 코드에서 볼 수 있는 표현식과 매우 비슷하다. 표현식 언어에서는 다음 연산자와 키워드를 사용할 수 있다.

android:text="@{String.valueOf(index + 1)}"
    android:visibility="@{age > 13 ? View.GONE : View.VISIBLE}"
    android:transitionName='@{"image_" + id}'
    

누락된 연산자

관리형 코드에서 사용할 수 있는 표현식 구문에서 누락된 연산자는 다음과 같다.

Null 병합 연산자

null 병합 연산자(??)는 왼쪽 피연산자가 null이 아니면 왼쪽 피연산자를 선택하고 null이면 오른쪽 피연산자를 선택한다.

android:text="@{user.displayName ?? user.lastName}"
    

상기 연산의 기능은 다음과 같다.

android:text="@{user.displayName != null ? user.displayName : user.lastName}"
    

속성 참조

표현식은 다음 형식을 사용해 클래스의 속성을 참조할 수 있으며 이 형식은 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 뷰를 참조한다.

<EditText
        android:id="@+id/example_text"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"/>
    <TextView
        android:id="@+id/example_output"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{exampleText.text}"/>
    

컬렉션

list, map, sparse, array와 같은 일반 컬렉션에는 편의상 [] 연산자를 사용하여 엑세스 할 수 있다.

<data>
        <import type="android.util.SparseArray"/>
        <import type="java.util.Map"/>
        <import type="java.util.List"/>
        <variable name="list" type="List&lt;String>"/>
        <variable name="sparse" type="SparseArray&lt;String>"/>
        <variable name="map" type="Map&lt;String, String>"/>
        <variable name="index" type="int"/>
        <variable name="key" type="String"/>
    </data>
    …
    android:text="@{list[index]}"
    …
    android:text="@{sparse[index]}"
    …
    android:text="@{map[key]}"
    

또한 object.key 표기법을 사용하여 맵의 값을 참조할 수 있다. 

Ex) 상기 에에서 @{map[key]}는 @{map.key}로 대체할 수 있다.

문자열 리터럴

표현식은 다음 구문을 사용하여 앱 리소스를 참조할 수 있다.

android:padding="@{large? @dimen/largePadding : @dimen/smallPadding}"
    

다음과 같이 매개변수를 제공하여 형식 문자열과 복수형을 평가할 수 있다.

android:text="@{@string/nameFormat(firstName, lastName)}"
    android:text="@{@plurals/banana(bananaCount)}"
    

다음과 같이 속성 참조 및 뷰 참조를 리소스 매개변수로 전달할 수 있다.

android:text="@{@string/example_resource(user.lastName, exampleText.text)}"
    

복수형이 여러 매개변수를 사용하면 다음과 같이 모든 매개변수를 전달해야 한다.


      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() 메소드에 할당할 수 있다.

<?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android">
       <data>
           <variable name="handlers" type="com.example.MyHandlers"/>
           <variable name="user" type="com.example.User"/>
       </data>
       <LinearLayout
           android:orientation="vertical"
           android:layout_width="match_parent"
           android:layout_height="match_parent">
           <TextView android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:text="@{user.firstName}"
               android:onClick="@{handlers::onClickFriend}"/>
       </LinearLayout>
    </layout>
    

* 표현식의 메소드 Signature는 리스너 객체에 있는 메소드의 서명과 정확히 일치해야 한다.

리스너 바인딩

리스너 바인딩은 이벤트가 발생할 때 실행되는 결합 표현식이다. 리스너 바인딩은 메소드 참조와 비슷하다. 하지만 리스너 바인딩을 사용하면 임의의 데이터 바인딩 표현식을 실행할 수 있다. 

메소드 참조에서 메소드의 매개변수는 이벤트 리스너의 매개변수와 일치해야 한다. 리스너 바인딩에서는 반환 값만 리스너의 예상 반환 값과 일치해야 한다.(void가 예상되지 않는 한).

Ex) onSaveClick() 메소드가 있는 다음 presenter 클래스를 확인해보자

    class Presenter {
        fun onSaveClick(task: Task){}
    }

    

그러면 다음과 같이 클릭 이벤트를 onSaveClick() 메소드에 결합할 수 있다.

<?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android">
        <data>
            <variable name="task" type="com.android.example.Task" />
            <variable name="presenter" type="com.android.example.Presenter" />
        </data>
        <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent">
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:onClick="@{() -> presenter.onSaveClick(task)}" />
        </LinearLayout>
    </layout>
    

표현식에 콜백을 사용하면 데이터 바인딩은 필요한 리스너를 자동으로 생성하여 이벤트에 등록한다. 뷰에서 이벤트가 발생하면 데이터 바인딩은 주어진 표현식을 계산한다. 일반 결합 표현식에서와 같이 이러한 리스너 표현식이 계산되는 동안 계속 데이터 바인딩의 null 및 스레드 안전성이 확보된다.

위 예는 onClick(View)에 전달되는 view 파라미터가 정의되지 않았다. 리스너 결합에서는 두 가지 방식으로 리스너 매개변수를 선택할 수 있다. 즉, 메소드의 모든 파라미터를 무시하거나 모든 파라미터의 이름을 지정할 수 있다. 파라미터 이름 지정을 선택하면 표현식에 파라미터를 사용할 수 있다.

Ex) 상기 표현식을 다음과 같이 작성할 수 있다.

android:onClick="@{(view) -> presenter.onSaveClick(task)}"
    

또는 표현식에서 파라미터를 사용하려는 경우 다음과 같이 작성할 수 있다.

    class Presenter {
        fun onSaveClick(view: View, task: Task){}
    }

    
android:onClick="@{(theView) -> presenter.onSaveClick(theView, task)}"
    

다음과 같이 둘 이상의 파라미터와 함께 람다 표현식을 사용할 수 있다.

    class Presenter {
        fun onCompletedChanged(task: Task, completed: Boolean){}
    }

    
<CheckBox android:layout_width="wrap_content" android:layout_height="wrap_content"
          android:onCheckedChanged="@{(cb, isChecked) -> presenter.completeChanged(task, isChecked)}" />
    

수신 대기 중인 이벤트가 void가 아닌 유형의 값을 반환하면 표현식도 같은 유형의 값을 반환해야 한다. 

ex) LongClick 이벤트를 수신 대기하려면 표현식에서 Boolean을 반환해야 한다.

    class Presenter {
        fun onLongClick(view: View, task: Task): Boolean { }
    }

    
android:onLongClick="@{(theView) -> presenter.onLongClick(theView, task)}"
    

null 객체로 인해 표현식을 계산할 수 없으면 데이터 바인딩은 각기 해당하는 유형의 기본값을 반환한다.

Ex) 참조 유형은 null을, int는 0을 boolean은 false를 기본값으로 반환한다.

 

조건자와 함께 표현식(ex: 삼항연산자을 사용해야 한다면 void를 기호로 사용할 수 있다.

android:onClick="@{(v) -> v.isVisible() ? doSomething() : void}"
    

복잡한 리스너 방지

리스너 표현식은 매우 강력하다. 리스너 표현식을 사용하면 코드를 매우 쉽게 읽을 수 있다. 반면, 복잡한 표현식이 포함된 리스너를 사용하면 레이아웃을 읽고 유지하기 어려워진다. 이러한 표현식은 사용 가능한 데이터를 UI에서 콜백 메소드로 전달하는 것만큼 간단해야 한다. 리스너 표현식에서 호출한 콜백 메소드 내에 비즈니스 로직을 구현해야 한다.

 

즉, 리스너 표현식은 간결하게, 무거운 경우 콜백을 받아 비즈니스 로직 구현

가져오기, 변수 및 포함

데이터 바인딩 라이브러리는 가져오기(Imports), 변수(Variables) 및 포함(Includes)과 같은 기능을 제공한다. 가져오기를 사용하면 레이아웃 파일 내에서 클래스를 쉽게 참조할 수 있다. 변수를 사용하면 결합 표현식에 사용할 수 있는 속성을 설명할 수 있다. 포함을 사용하면 앱 전체에 복잡한 레이아웃을 재사용할 수 있다.

 

Imports(가져오기)

가져오기를 사용하면 관리형 코드에서와 같이 레이아웃 파일 내에서 클래스를 쉽게 참조할 수 있다. data 요소 내에서 0개 이상의 import 요소를 사용할 수 있다.

Ex) 다음은 View 클래스를 레이아웃 파일로 가져온다.

 

<data>
        <import type="android.view.View"/>
    </data>
    

View 클래스를 가져오면 결합 표현식에서 참조할 수 있다.

Ex) 다음은 View 클래스의 VISIBILE 및 GONE 상수를 참조하는 방법이다.

<TextView
       android:text="@{user.lastName}"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:visibility="@{user.isAdult ? View.VISIBLE : View.GONE}"/>
    

유형 별칭

클래스 이름 충돌이 발생하면 클래스 중 하나의 이름을 별칭으로 바꿀 수 있다. 

Ex) 다음은 com.example.real.estate 패키지이 View 클래스 이름을 Vista로 바꾼다.

<import type="android.view.View"/>
    <import type="com.example.real.estate.View"
            alias="Vista"/>
    

이제 Vista를 사용하여 com.example.real.estate.View를 참조할 수 있다. 그리고 레이아웃 파일 내에서 android.view.View를 참조하는 데 View를 사용할 수 있다.

 

다른 클래스 가져오기

가져온 유형은 변수 및 표현식에서 유형 참조를 사용할 수 있다. 다음 예는 변수 유형으로 사용되는 User 및 List를 보여준다.

<data>
        <import type="com.example.User"/>
        <import type="java.util.List"/>
        <variable name="user" type="User"/>
        <variable name="userList" type="List&lt;User>"/>
    </data>
    

또한 가져온 유형을 사용하여 표현식의 일부를 변환할 수 있다.

Ex) 다음은 connection 속성을 User 유형으로 변환한다.

<TextView
       android:text="@{((User)(user.connection)).lastName}"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"/>
    

또한 표현식에서 정적 필드 및 메소드를 참조할 때 가져온 유형을 사용할 수 있다. 

Ex) MyStringUtils 클래스를 가져와서 capitalize 메소드를 참조한다.

<data>
        <import type="com.example.MyStringUtils"/>
        <variable name="user" type="com.example.User"/>
    </data>
    …
    <TextView
       android:text="@{MyStringUtils.capitalize(user.lastName)}"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"/>
    

 

변수

data 요소 내에서 여러 variable 요소를 사용할 수 있다. 각 variable 요소는 레이아웃 파일 내 결합 표현식에 사용될 레이아웃에 설정할 수 있는 속성을 설명한다.

Ex) 다음은 user, image 및 note 변수를 선언한다.

<data>
        <import type="android.graphics.drawable.Drawable"/>
        <variable name="user" type="com.example.User"/>
        <variable name="image" type="Drawable"/>
        <variable name="note" type="String"/>
    </data>
    

변수 유형은 컴파일 타임에 검사된다. 따라서 변수가 Observable을 구현하거나 식별 가능한 컬렉션이라면 그 사항이 유형에 반영되어야 한다.

변수가 Observable 인터페이스를 구현하지 않는 기본 클래스 또는 인터페이스라면 변수들이 식별되지 않는다.

다양한 구성(Ex: 가로모드, 세로모드)의 레이아웃 파일이 서로 다를 때 변수가 결합된다. 이러한 레이아웃 파일 간에 충돌하는 변수 정의가 있어서는 안된다.

생성된 바인딩 클래스에는 설명된 각 변수의 setter 및 getter가 있다. 변수는 setter가 호출될 때까지 기본 관리형 코드 값을 사용한다. 

Ex) 참조 유형은 null을, int는 0을, boolean은 false를 기본값으로 사용한다.

 

필요에 따라 결합표현식에 사용하기 위해 context라는 이름의 특수 변수를 생성한다. context의 값은 루트 뷰의 getContext() 메소드에서 가져온 Context 객체이다. context 변수는 이 이름을 사용하는 명시적 변수 선언으로 재정의된다.

 

포함

속성에 앱 네임스페이스 및 변수 이름을 사용함으로써 포함하는 레이아웃에서 포함된 레이아웃의 결합으로 변수를 전달할 수 있다. 

Ex) name.xml 및 contact.xml 레이아웃 파일로부터 포함된 user 변수를 보여준다.

<?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:bind="http://schemas.android.com/apk/res-auto">
       <data>
           <variable name="user" type="com.example.User"/>
       </data>
       <LinearLayout
           android:orientation="vertical"
           android:layout_width="match_parent"
           android:layout_height="match_parent">
           <include layout="@layout/name"
               bind:user="@{user}"/>
           <include layout="@layout/contact"
               bind:user="@{user}"/>
       </LinearLayout>
    </layout>
    

데이터 바인딩은 포함을 병합 요소의 직접 하위 요소로 지원하지 않는다.

Ex) 다음 레이아웃은 지원되지 않는다.

<?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:bind="http://schemas.android.com/apk/res-auto">
       <data>
           <variable name="user" type="com.example.User"/>
       </data>
       <merge><!-- Doesn't work -->
           <include layout="@layout/name"
               bind:user="@{user}"/>
           <include layout="@layout/contact"
               bind:user="@{user}"/>
       </merge>
    </layout>
    

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로 래핑된다.

<layout xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto">
        <data>
            <variable
                name="viewmodel"
                type="com.myapp.data.ViewModel" />
        </data>
        <ConstraintLayout... /> <!-- UI layout's root element -->
    </layout>

식별 가능한 데이터 객체 작업

데이터 바인딩 라이브러리는 데이터 변경을 쉽게 식별하기 위한 클래스 및 메소드를 제공한다. 기본 데이터 소스가 변경될 때 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 값이 표시된다.

 

<TextView android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{user.firstName, default=my_default}"/>
    

 

View Binding 기능을 사용하면 View와 상호작용하는 코드를 쉽게 작성 가능.

모듈에서 사용 설정된 View Binding 은 모듈에 있는 각 XML 레이아웃 파일의 결합 클랫를 생성.

Binding Class의 Instance에는 상응하는 Layout에 ID가 있는 모든 뷰의 직접 참조가 포함.

 

설정

뷰 결합은 모듈별로 사용 설정된다. 모듈에서 뷰 결합을 사용 설정하려면 다음과 같이 viewBinding 요소를 build.gradle 파일에 복사한다.

android {
        ...
        viewBinding {
            enabled = true
        }
    }
    

Binding Class를 생성하는 동안 레이아웃 파일을 무시하려면 tools:viewBindingIgnore = "true" 속성을 레이아웃 파일의 루트뷰에 추가하라.

<LinearLayout
            ...
            tools:viewBindingIgnore="true" >
        ...
    </LinearLayout>
    

사용법

상기 설정 이후 각 XML 레이아웃 파일의 Binding Class가 생성된다. 각 Binding Class에는 루트 뷰 및 ID가 있는 모든 뷰의 참조가 포함된다. 

Binding Class의 이름은 XMl파일의 이름을 카멜 표기법으로 변환하고 끝에 'Binding'을 추가하여 생성된다.

Ex) 레이아웃 파일 이름이 result_profile.xml 이면 생성된 Binding Class 이름은 ResultProfileBinding이 된다.

 

Activity에서의 ViewBinding 사용

Activity에 사용할 ViewBinding Class 인스턴스를 설정하려면 onCreate() 메소드에서 설정한다.

1. 생성된 Binding Class에 포함된 정적 inflate() 메소드를 호출한다. 그러면 Activity에서 사용할 Binding Class 인스턴스가 생성된다.

2. getRoot() 메소드를 호출하거나 Kotlin 속성 구문을 사용하여 루트 뷰 참조를 가져온다.

3. 루트 뷰를 setContentView()에 전달하여 화면상의 활성 뷰로 만든다.

 

    private lateinit var binding: ResultProfileBinding

    override fun onCreate(savedInstanceState: Bundle) {
        super.onCreate(savedInstanceState)
        binding = ResultProfileBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
    }
    

이후 Binding Class 인스턴스를 사용하여 뷰를 참조할 수 있다.

    binding.name.text = viewModel.name
    binding.button.setOnClickListener { viewModel.userClicked() }

 

Fragment에서 ViewBinding 사용

Fragment에 사용할 BindingClass 인스턴스를 설정하려면 Fragment의 onCreateView() 메소드에서 설정한다.

1. 생성된 Binding Class에 포함된 정적 inflate() 메소드를 호출한다. 그러면 Fragment에서 사용할 Binding Class 인스턴스가 생성된다.

2. getRoot() 메소드를 호출하거나 Kotlin 속성 구문을 사용하여 루트 뷰 참조를 가져온다.

3. onCreateView()메소드에서 루트 뷰를 반환하여 화면상의 활성 뷰로 만든다.

* inflate() 메소드를 사용하려면 Layout Inflater를 전달해야 한다. 레이아웃이 이미 확장되어 있다면 Binding Class의 bind() 메소드를 호출하면된다.

 

    private var _binding: ResultProfileBinding? = null
    // This property is only valid between onCreateView and
    // onDestroyView.
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = ResultProfileBinding.inflate(inflater, container, false)
        val view = binding.root
        return view
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
    

이후 Binding Class 인스턴스를 사용하여 뷰를 참조할 수 있다.

    binding.name.text = viewModel.name
    binding.button.setOnClickListener { viewModel.userClicked() }
    

* 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) { ... }
}

 이름 지정 규칙

저장소 클래스의 이름은 담당하는 데이터의 이름을 따라 지정된다. 규칙은 다음과 같다.

데이터 유형 + 저장소

Ex: NewsRepository, MoviesRepository, PaymentRepository

데이터 소스 클래스의 이름은 담당하는 데이터와 사용하는 소스의 이름을 따라 지정된다. 규칙은 다음과 같다.

데이터 유형 + 소스 유형 + 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가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인이다.

 

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 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용한다.

Ex) LiveData -> observe(), Kotlin Flow -> collect()

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 프레임워크 클래스의 종속 항목 컨테이너를 만든다.

 

 

RecyclerView Item

다음과 같이 각 아이템들로 구성된 리사이클러 뷰가 있다고 가정하자.

이후, 아이템 별 데이터를 세팅해주고 클릭 버튼을 누른 후 스크롤을 하면 데이터가 사라진다.

이렇게 되었을때 해결 방법 중 하나는 다음과 같다.

Adapter 내에 데이터가 많이 들어가 있을 경우 임시 저장  HashMap<Int, Object>를..

단순 CheckButton일 경우, SparseBooleanArray를 사용하면된다.

이는 들어오는 데이터 종류에 따라 자유롭게 사용하면된다.

 

백그라운드에서 어떻게 돌아가는지 생각해보자.

본 작성자는 Activity(TabLayout) -> Fragments(RecyclerView in Fragment) & Adapter 로 구성되어있다.

Activity가 꺼지지 않는 이상(onDestroy) Fragment 및 Adapter는 살아있다고 볼 수 있다.

그러므로 Adapter 내에 전역변수로 위 임시 저장 자료형을 선언해주고 데이터 변경 이벤트에 따라 itemPosition, Data를 넣어주면 된다.

이후 다른 탭을 가거나 스크롤을해도 문제 없어진다.

package com.Health.health.Trainer.UserListPackage.ProfilePack.ExerciseRequestPack.RequestExercise.RequestingExercise.Anaerobic

import android.graphics.Color
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatButton
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.Health.health.ModulePackage.ExercisePack.ExerciseModule
import com.Health.health.Model.TrainerDataModel
import com.Health.health.R
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions

class RequestAbsAdapter : RecyclerView.Adapter<RequestAbsAdapter.ViewHolder>(){
    var exerciseModels = mutableListOf<TrainerDataModel.RequestingExerciseData>()
    var savedList = mutableListOf<TrainerDataModel.RequestingFinalExerciseData>()
    private val tempList = hashMapOf<Int, TrainerDataModel.TempRequestingData>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RequestAbsAdapter.ViewHolder {
        val view: View? = LayoutInflater.from(parent.context).inflate(R.layout.anaerobic_item, parent, false)
        return ViewHolder(view!!)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(exerciseModels[position], position)
    }

    override fun getItemCount(): Int = exerciseModels.size

    override fun getItemViewType(position: Int): Int = position


    interface OnItemClickListener{
        fun onItemClick(v: View, position: Int, exercise: String, parameter: Double, url: String, weight: Int, set: Int, time: Int, hydro: String, minute: Int, part: String)
    }

    private var listener: OnItemClickListener? = null
    fun setOnItemClickListener(listener: OnItemClickListener){
        this.listener = listener
    }

    inner class ViewHolder(view: View): RecyclerView.ViewHolder(view){
        var weight: String = "10"
        var time: String = "12"
        var set: String = "3"
        val profileImage: ImageView = view.findViewById(R.id.image)
        val levelImage: ImageView = view.findViewById(R.id.levelImage)
        val levelText: TextView = view.findViewById(R.id.part)
        val exerciseName: TextView = view.findViewById(R.id.exercise)
        val weightEdit: EditText = view.findViewById(R.id.weightEdit)
        val timeEdit: EditText = view.findViewById(R.id.timeEdit)
        val setEdit: EditText = view.findViewById(R.id.setEdit)
        private val saveCheckButton: AppCompatButton = view.findViewById(R.id.SaveButton)
        fun bind(model: TrainerDataModel.RequestingExerciseData, position: Int){

            exerciseName.text = model.exerciseName


            //운동 사진
            ExerciseModule().imageSetter(profileImage, model.exerciseUrl, true)

            weightEdit.hint = weight
            timeEdit.hint = time
            setEdit.hint = set

            //강도 이미지
            val level = ExerciseModule().getDoubleValue(model.exerciseParameter)
            ExerciseModule().levelFilter(level, levelImage, levelText)



            for(saved in savedList){
                if(saved.exerciseName == model.exerciseName){
                    weightEdit.setText(saved.data["Weight"].toString())
                    timeEdit.setText(saved.data["Time"].toString())
                    setEdit.setText(saved.data["Sets"].toString())
                    weightEdit.isEnabled = false
                    timeEdit.isEnabled = false
                    setEdit.isEnabled = false
                    saveCheckButton.isClickable = false
                    saveCheckButton.isPressed = true
                }
            }

            if(tempList.containsKey(position)){
                weightEdit.setText(tempList[position]!!.weight)
                timeEdit.setText(tempList[position]!!.time)
                setEdit.setText(tempList[position]!!.set)
                saveCheckButton.isPressed = tempList[position]!!.bool
            }


            saveCheckButton.setOnClickListener{
                val minute = ExerciseModule().minuteFilter(time, set)
                if(position != RecyclerView.NO_POSITION){
                    listener?.onItemClick(itemView,
                        position,
                        model.exerciseName,
                        model.exerciseParameter.toString().toDouble(),
                        model.exerciseUrl,
                        weightEdit.text.toString().toInt(),
                        setEdit.text.toString().toInt(),
                        timeEdit.text.toString().toInt(),
                        "AnAerobic",
                        minute.toInt(),
                        "Abs")
                    it.background = ContextCompat.getDrawable(it.context, R.drawable.ic_coaching_select)
                    it.isClickable = false
                    tempList[position] = TrainerDataModel.TempRequestingData(
                        weightEdit.text.toString(),
                        timeEdit.text.toString(),
                        setEdit.text.toString(),
                        null,
                        true
                    )
                }
            }
        }
    }
}

 

안드로이드는 기본적으로 상단에 ActionBar 제공한다.

 

Toolbar를 만들때 기본적인 방법은 

https://developer.android.com/training/appbar/setting-up?hl=ko&authuser=1 

 

앱 바 설정하기  |  Android 개발자  |  Android Developers

앱 바 설정하기 가장 기본적인 형태의 작업 모음은 한쪽에는 활동 제목을 표시하고 다른 쪽에는 더보기 메뉴를 표시합니다. 앱 바는 이렇게 간단한 형태로도 유용한 정보를 사용자에게 제공하

developer.android.com

상위 개발자 문서에서 이해할 수 있다.

예를 들어 다음과 같은 예를 보자.

 

단순 ConstraintLayout

이렇게 되었을 경우 우측 이미지 두개는 View 내 비즈니스 코드(onClickListener)로 처리해야 하며, 좌측 메뉴 버튼은 Toolbar의 기능을 가져오고 싶다.

 

이럴 경우 https://developer.android.com/reference/android/widget/PopupMenu

 

PopupMenu  |  Android Developers

android.net.wifi.hotspot2.omadm

developer.android.com

PopupMenu를 사용해준다.

A PopupMenu displays a Menu in a modal popup window anchored to a View. 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()
}

 

DB: Firebase

Architecture: MVVM + LiveData

UI Flow

Calendar Item Click -> getData by matched Date -> refresh UI

 

GridSpacingItemDecoration Customizing

@SuppressLint("NotifyDataSetChanged")
fun trainerManager(recycler: RecyclerView, adapter: JournalFoodView.FoodJournalAdapter, foodData: MutableList<FirebaseFetchFood>, context: Context){
    Log.d("trainerManager","Called")
    adapter.dataList = foodData
    val gridLayoutManager = GridLayoutManager(context, 3)
    recycler.layoutManager = gridLayoutManager

    recycler.addItemDecoration(GridSpacingItemDecoration(3, 50, true))

    //Note: ItemDecorationCount
    //if(recycler.itemDecorationCount == 0){
    //    recycler.addItemDecoration(GridSpacingItemDecoration(3, 50, true))
    //}
    recycler.adapter = adapter
    adapter.notifyDataSetChanged()
}
class GridSpacingItemDecoration(private val spanCount: Int, private val spacing: Int, private val includeEdge: Boolean): RecyclerView.ItemDecoration() {
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildLayoutPosition(view)
//            .apply{ Log.d("position", "Called")}
        val column = position % spanCount
//        Log.d("getItemOffsets", "Called")
//        Log.d("column", column.toString())
        if(includeEdge){
            outRect.left = spacing - column * spacing / spanCount // spacing - column * ((1f / spanCount) * spacing)
            outRect.right = (column + 1) * spacing / spanCount // (column + 1) * ((1f / spanCount) * spacing)
            if(position < spanCount){
                outRect.top = spacing
            }
            outRect.bottom = spacing
        }else{
            outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing)
            outRect.right = spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f /    spanCount) * spacing)
//            Log.d("F outRect.left", "${column * spacing / spanCount}")
//            Log.d("F outRect.right", "${spacing - (column + 1) * spacing / spanCount}")
            if(position >= spanCount){
                outRect.top = spacing
            }
        }
    }
}

 

Condition 1.

Ex) 2022-06-13 날짜를 처음 클릭시 

Firebase AddValueEventListener -> send Data to UI

Condition 2.

EX) 2022-06-13 -> 2022-06-14 -> 2022-06-13 날짜 클릭 후 다른날짜 클릭후 다시 날짜를 클릭시

내부 과정

-> (2022-06-13 클릭)이미 리사이클러뷰 & 그리드 뷰는 CustomDecoration으로 한번 그려진 상태이다. 

-> (2022-06-14 ->  2022-06-13 재클릭) 이미 그려진 곳에 또 다시 그려지면서 그리드 뷰 아이템이 점점 내려간다.

 

원인

이미 그려진 상태에서 Decoration은 또 그려준다.. 이로 인해 점점 내려가는 것으로 확인

 

해결 방안

recyclerview의 Adapter의 데코레이션 카운트가 0인지 1인지 그 이상인지를 확인하면된다

 

if(recycler.itemDecorationCount == 0){
    recycler.addItemDecoration(GridSpacingItemDecoration(3, 50, true))
}

 

버튼,텍스트뷰,에디트텍스트 등 안드로이드에서 사용하는 위젯은 레이아웃이라는 틀 위에 존재해야 한다. 레이아웃은 위젯을 배치하여 안드로이드 화면을 목적에 맞게 배열 할 수 있게 한다.

레이아웃의 기본 개념

레이아웃은 ViewGroup 클래스로부터 상속받으며 내부에 무엇을 담는 용도로 쓰인다.

레이아웃 중에서 가장 많이 사용되는 것은 리니어 레이아웃이며, 이를 선형 레이아웃이라고도 한다.

레이아웃에서 자주 사용되는 속성

-orientation: 레이아웃 안에 배치할 위젯의 수직 또는 수평 방향을 설정

-gravity: 레이아웃 안에 배치할 위젯의 정렬 방향을 좌측,우측,중앙 등으로 설정

-padding: 레이아웃 안에 배치할 위젯의 여백을 설정

-layout_weight:레이아웃이 전체 화면에서 차지하는 공간의 가중값을 설정하는데, 여러 개의 레이아웃이 중복될 때 주로 사용

-baselineAligned:레이아웃 안에 배치할 위젯을 보기 좋게 정렬

레이아웃도 View 클래스의 하위 클래스이므로 View 클래스의 XML속서오가 메서드를 모두 사용할 수 있다.

레이아웃의 종류

자주 사용되는 레이아웃은 리니어레이아웃(LinearLayout), 렐러티브레이아웃(RelativeLayout), 프레임레이아웃(FrameLayout), 테이블레이아웃(TableLayout), 그리드레이아웃(GridLayout)등이다.

*절대 좌푯값으로 지정하는 앱솔루트레이아웃(AbsoluteLayout)도 있었으나 해상도가 다른 안드로이드폰에서 문제가 발생하여 더 이상 사용하지 않는다.

레이아웃 종류

LinearLayout(선형 레이아웃)

레이아웃의 왼쪽 위부터 아래쪽 또는 오른쪽으로 차례로 배치

RelativeLayout(상대 레이아웃)

위젯 자신이 속한 레이아웃의 상하좌우 위치를 지정하여 배치하거나 다른 위젯으로부터 상대적인 위치를 지정한다.

TableLayout

행과 열의 개수를 지정한 테이블 형태로 위젯을 배열한다.

GreedLayout

테이블레이아웃과 비슷하지만 행 또는 열을 확장하여 다양하게 배치할 때 더 편리하다.

FrameLayout

위젯을 왼쪽 위에 일률적으로 겹쳐서 배치하여 중복되어 보이는 효과를 낼 수 있다. 여러 개의 위젯을 배치한 후 상황에 따라서 필요한 위젯을 보이는 방식에 주로 활용된다.

 

=>선형레이아웃만으로도 대부분의 레이아웃 형태를 구성할 수 있어 선형레이아웃의 사용도가 가장 높다. 그러므로 선형레이아웃만 잘 이해하면 다른 레이아웃도 쉽게 이해할 수 있다.

 

 

컴파운드버튼

CompoundButton 클래스는 Buttom 클래스의 하위 클래스로 체크박스, 라디오버튼,스위치,토글버튼의 상위 클래스이다.

이 네가지는 공통적으로 체크 또는 언체크 상태가 될 수 있다. (실제로 비슷한 형태를 띠지만 용도는 조금씩 다르다)

컴파운드버튼 계층도

체크박스

체크박스는 클릭할 때마다 상태가 체크,언체크로 바뀐다. 여러 개의 체크박스가 있어도 서로 독립적으로 동작한다는 특징이 있어 여러 개를 동시에 체크할 수 있다.

line4,11에서 checked 속성이 true로 되어 있는데, 이처럼 동시에 여러 개를 선택할 수 있다.

-setChecked() : 강제로 체크를 켜거나 끄는 메서드

-toggle(): 체크 상태를 반대로 바꾸는 메서드

-isChecked(): 체크되었는지를 확인하는 메서드

버튼에서 클릭 이벤트를 발생 시 OnClickListener를 사용했듯이, 체크박스에는 체크 또는 언체크 이벤트 발생 시 OnCheckedChangeListener를 사용할 수 있다.

체크와 언체크가 바뀌는 것의 처리과정(Java)

1.체크박스 변수 선언

CheckBox mycheck;

2.변수에 체크박스 위젯 대입

mycheck=(CheckBox) findViewById(R.id.android);

3.체크박스가 변경될 때 동작하는 클래스 정의

mycheck.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){

   public void onCheckedChanged(CompoundButton buttonView,boolean isChecked){

       //동작 내용을 이 부분에 코딩

   }

});

 

스위치와 토글버튼

스위치와 토글버튼은 모양만 조금 다를 뿐 용도는 거의 동일하다. 스위치의 주 용도는 on/off 상태 표시이다.

*컴파운드버튼(체크박스,라디오버튼, 스위치, 토글버튼 등)은 AVD 버전,SDK 버전, 테마 설정 등에 따라서 모양이나 색상이 조금씩 다르지만 기능은 동일하다.

checked 속성은 true와 false에 따라서 색상과 글자가 다르게 표현된다.

 

라디오버튼과 라디오그룹

라디오버튼은 XML속성이나 메서드가 체크박스와 거의 동일하지만 용도가 다르다. 성별을 선택하는 것처럼 여러 개 중 하나만 선택해야 하는 경우에 사용한다. 그러나 라디오버튼만 여러 개 나열하면 클릭하는 것마다 모두 중복 선택되므로 라디오그룹(RadioGroup)과 함께ㅔ 사용해야 한다.

 

라디오그룹은 ViewGroup_LinearLayout의 하위 클래스로 존재하며, 지금 사용하고 있는 TextView 하위의 위젯들과는 성격이 조금 다르다. 라디오 그룹은 대부분 라디오 버튼을 묶는 역할만 하므로 다음 예제만 이해하면 된다.

-clearCheck()

해당 라디오그룹 안에 체크된 것을 모두 해제해준다. line1~7의 라디오그룹으로 묶었기 때문에 이 라디오그룹 안의 모든 라이도 버튼은 한 번에 하나씩만 선택된다. 

각 라디오버튼 id속성이 꼭 있어야 한다. id 속성이 없으면 해당 라디오버튼이 계속 선택된 것으로 지정되어 해제되지 않는다.

 

이미지뷰와 이미지버튼

이미지뷰는 그림을 출력하는 위젯이다. 그림 파일은 일반적으로 프로젝트의 [res]-[drawable]폴더에 있어야 한다.

접근은 XML에서 "@drawable/그림 아이디" 형식으로 한다.

위 그림을 보면 ImageView 클래스는 View 클래스에서 바로 상속받기 때문에 앞에서 배운 TextView의 하위 위젯들과 속성이 조금 다르다. 특히 이미지와 관련된 속성과 메서드를 주의깊게 살펴볼 필요가 있다. ImageButton 클래스는 ImageView 클래스에서 상속받으며 거의 동일한 용도로 사용하지만 버튼처럼 클릭하는 데 쓰인다. 이미지버튼은 그림으로 표현된 예쁜 버튼을 만들 때 사용할 수 있다.

 

 

안드로이드에서 가장 기본적으로 사용되는 텍스트뷰, 버튼, 에디트텍스트를 보자. 

XML로 화면을 구성한 후 Java코드로 동작하는 기법을 익히자.

 

1. 텍스트뷰

텍스트뷰 계층도

텍스트뷰는 View 클래스 바로 다음에 위치하며 다양한 위젯이 그 하위에 존재한다.

텍스트뷰의 하위(에디트텍스트, 버튼,체크박스 등)는 모두 텍스트뷰로부터 상속받기 때문에 텍스트뷰의 속성을 잘 이해한다면 다른 위젯의 속성도 이해하기 쉬울 것이다.

text 속성

텍스트뷰에 나타나는 문자열을 표현한다. '문자열' 형식으로 값을 직접 입력하거나 "@string/변수명" 형식으로 설정한 후 strings.xml 파일에 지정할 수 있다.

 

[Tip]

안드로이드 메뉴얼에서는 activity_main_xml 파일에 문자열을 직접 입력하는 것보다 "@string/변수명" 형식의 사용을 권장한다. 이는 strings.xml의 내용만 수정하면 다른 언어로도 쉽게 변환할 수 있기 때문이다. 

textColor 속성

글자의 색상을 지정하며, background 속성처럼 같은 #RRGGBB나 #AARRGGBB 형식이다.

textSize 속성

글자의 크기를 dp,px,in,mm,sp 단위로 지정한다.

typeface 속성

글자의 글꼴을 지정한다. 값으로 sans,serif,monospace를 설정할 수 있고 default는 normal이다.

textStyle 속성

글자의 스타일을 지정한다. 값으로 bold, italic, bold|italic을 설정할 수 있고 default는 normal이다.

singleLine 속성

글이 길어 줄이 넘어갈 경우 강제로 한 줄까지만 출력하고 문자열의 맨 뒤에 '...'를 표시한다. 값으로 true와 false를 설정할 수 있고 디폴트는 false이다.

 

[Tip]

이 외에도 글자와 관련된 속성으로 textAllCaps, textAppearance, textColorHighlight, textColorHint, textColorLink, textlsSelectable, textScaleX 등이 있는데 활용도는 그닥 높지는 않다.

 

위 속성들을 다음 예제를 통해 살펴보자.

2. Java 코드로 XML 속성 설정

XML 속성은 View 클래스와 TextView 클래스에서 배운 것으로도 충분히 활용가능하다. 배우지 않은 것들도 거의 비슷한 형식이므로 쉽게 응용 가능하다. 다음 예에서는 activity_main.xml 파일에  지정한 xml 속성을 Java 코드에서 설정하는 법이다.

기본적인 텍스트뷰만 만들어 놓고 id 속성과 text만 설정한 XML 파일은 다음과 같다.

xml에서는 다음과 같이 처리해주고, Java 코드를 설정하여 화면에 적용해주자.

[Tip]

XML 속성과 메소드는 많은 위젯에서 거의 동일한 방식으로 활용된다. 

XML 속성과 관련 메서드1
XML 속성과 관련 메서드2

3. 버튼과 에디트텍스트

버튼과 에디트텍스트는 사용자에게서 어떤 값을 입력받기 위한 가장 기본적인 위젯으로 활용도가 높다.

두 위젯은 View 클래스와 TextView 클래스를 상속받으므로 거의 비슷하게 사용할 수 있다.

 

먼저, 간단한 텍스트뷰를 예로 살펴보면 다음과 같다.

위 텍스트뷰를 버튼으로 변경하려면 'TextView'를 'Button'으로만 바꾸면 된다.

필요시 TextView의 하위 클래스인 EditText, RadioButton, CheckBox, ToggleButton 등으로 바꾼다.

버튼

버튼에서는 버튼을 클릭하는 이벤트를 가장 많이 사용한다. 일반적인 버튼의 XML 코드는 다음과 같다.

이 버튼을 클릭했을 때 동작하는 Java 코드를 세 단계로 작성해준다.

1. 버튼 변수 선언

   Button mybutton;

2. 변수에 버튼 위젯 대입

   mybutton=(Button) findViewById(R.id.button1);
3. 버튼을 클릭할 때 동작하는 클래스 정의

   mybutton.setOnClickListener(new View.OnclickListener(){

             publick void onClick(View v){

             // 동작 내용을 이 부분에 코딩

            }

    });

위 세 단계는 대부분의 위젯(라디오버튼, 이미지버튼, 체크박스, 토글버튼 등)에서 거의 동일하게 사용되므로 통째로 외우는 것이 좋다. 

 

에디트텍스트

에디트텍스트는 값을 입력받은 후 해당 값을 Java 코드에서 가져와 사용하는 용도로 많이 쓰인다.

Java 코드에서 에디트텍스트에 값을 입력하는 경우도 종종 있다. 일반적인 에디트텍스트의 XML 코드는 다음과 같다.

에디트텍스트도 변수를 선언하고 이 변수에 해당 아이디 값을 넣은 후에 접근한다. 다음과 같은 형식을 많이 사용한다.

1. 에디트텍스트 변수 선언

   EditText myEdit;

2. 변수에 에디트텍스트 위젯 대입

   myEdit=(EditText) findViewById(R.id.edittext1);

3. 에디트텍스트에 입력된 값 가져오기->주로 버튼 클릭 이벤트 리스너 안에 삽입

   String myStr=myEdit.getText().toString();

 

getText() 메소드는 에디트텍스트에 입력한 값을 반환하는데, 이를 문자열로 바꾸기 위해 toString()을 함께 사용했다.

반환값을 문자열로 변경할 때 가장 많이 사용하는 방식이므로 잘 기억해두자.

 

초간단 계산기 앱 만들기

package com.example.a20160922;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    private EditText editText;
    private EditText editText1;
    private TextView textView;



    public class MyListener implements View.OnClickListener {

        @Override
        public void onClick(View v) {
            int a = Integer.parseInt(editText.getText().toString());
            int b = Integer.parseInt(editText1.getText().toString());

            double result = 0;
            if (v.getId() == R.id.buttonAdd)
                result = a + b;
            else if (v.getId() == R.id.buttonSub)
                result = a - b;
            else if (v.getId() == R.id.buttonMul)
                result = a * b;
            else
                result = (double)a / (double)b;

            textView.setText("계산 결과: " + result);
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        editText = findViewById(R.id.editText1);
        editText1 = findViewById(R.id.editText);
        Button buttonAdd = findViewById(R.id.buttonAdd);
        Button buttonSub = findViewById(R.id.buttonSub);
        Button buttonMul = findViewById(R.id.buttonMul);
        Button buttonDiv = findViewById(R.id.buttonDiv);textView = findViewById(R.id.textView);

        MyListener listener = new MyListener();

        buttonAdd.setOnClickListener(listener);
        buttonSub.setOnClickListener(listener);
        buttonMul.setOnClickListener(listener);
        buttonDiv.setOnClickListener(listener);
    }
}

 

+ Recent posts