레이아웃 및 바인딩 표현식

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

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

 

데이타 바인딩 레이아웃 파일은 약간 차이가 있으며 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가 포그라운드에 있을 때 상태를 소비하는 것이 좋다.

+ Recent posts