깨알 개념/Android

[Android] LiveData - 양방향 데이터 바인딩의 3가지 방법

interfacer_han 2024. 1. 20. 15:35

#1 단방향 데이터 바인딩

#1-1 단방향과 양방향

지금까지 해온 Data Binding은 단방향(One Way) 데이터 바인딩이었다. Model 또는 ViewModel에서 View로 가는 흐름으로만 데이터가 갱신된다. 역은 성립하지 않는다. 역이 성립하지 않는 게 꼭 나쁜 것도 아니다. 마치 인터넷 쇼핑몰에서 어떤 물건의 표시된 가격을, 브라우저의 개발자 모드로 바꾼다고 실제 가격이 변하지 않는 것과 같다. 만에 하나 변하는 일도 없어야하고 말이다.

 

#1-2 수정할 샘플 앱

 

[Android] LiveData - 암시적으로 '관찰'하기

#1 ViewModel 속에 LiveData가 있는 샘플 앱 [Android] ViewModel - View에 객체(ViewModel) 전달 #1 개요 #1-1 Data Binding과 ViewModel [Android] Data Binding - View에 객체 전달 #1 객체 전달의 필요성 #1-1 이전 글 Data Binding -

kenel.tistory.com

위 게시글의 완성된 앱을 수정해서, 단방향 데이터 바인딩의 작동을 확인해본다.

 

#1-3 activity_main.xml 수정

<?xml version="1.0" encoding="utf-8"?>

<layout ...>

    ...

    <androidx.constraintlayout.widget.ConstraintLayout ...>

        ...

        <EditText ... />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<TextView>를 <EditText>로 바꾼다

 

#1-4 작동 확인 - 단방향 데이터 바인딩

<Button>을 7번 클릭한다. 이러면, ViewModel의 프로퍼티 count에 대해, MainActivityViewModel.updateCount(){ count++ }가 7번 수행된다. 단방향 데이터 바인딩에 의해 <EditText>에는 7이 담긴다.

 

이번엔 사용자(나)가 직접 <EditText>를 조작한다. 7을 3으로 바꾼다. 이 때, 내부적인 count의 값은 여전히 7이다. 사용자가 직접 <EditText>를 조작한다고, ViewModel의 프로퍼티에 어떤 영향을 미칠 수 있는 것이 아니기 때문이다. 그래서 <Button>을 클릭하면 <EditText>에는 4가 아닌 7+1 = 8이 담긴다. 

 

#2 양방향 데이터 바인딩

#2-1 양방향 데이터 바인딩이 '암시'하는 것

이쯤되면 양방향(Two Way) 데이터 바인딩에 대한 그림이 보인다. 바로 기존의 단방향 데이터 바인딩에 추가해, View단에서 이뤄지는 사용자의 입력을 내부 프로퍼티에 암시적으로 대입하는 것이다. 만약, 양방향 데이터 바인딩의 코드를 프로그래머가 명시적으로 밝혀 적는다면 다음과 같은 형태일 것이다.

 

...

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private lateinit var viewModel: MainActivityViewModel
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        binding.countText.addTextChangedListener(object : TextWatcher {
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
                // <EditText>에 담긴 값에 변화가 생길 때 수행할 일
            }

            override fun afterTextChanged(s: Editable?) {
                // <EditText>에 대한 사용자의 입력이 끝났을 때 수행할 일
            }

            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
                // <EditText>에 대한 사용자의 입력이 들어오기 직전에 할 일
            }
        })
    }
}

이러한 코드를 암시적으로 수행하는 게 바로 양방향 데이터 바인딩이다.

 

#2-2 양방향 데이터 바인딩을 구현하기 위해 참조한 웹사이트

 

양방향 데이터 결합  |  Android 개발자  |  Android Developers

양방향 데이터 결합 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 다음과 같이 단방향 데이터 결합을 사용하면 속성에 값을 설정하고 이 속성의 변경에 반

developer.android.com

#3 ~ #5의 모든 코드는 위 링크 그리고 구글 공식 샘플을 참조하여 짰다.

 

이제부터 양방향 데이터 바인딩을 사용해본다. 본 게시글에선 총 3가지 방법으로 양방향 데이터 바인딩을 구현했다.

 

#3 Converter 이용 (방법 1)

#3-1 Project 수준의 build.gradle.kts에 KSP(어노테이션 문법 사용을 위한 API) 플러그인 추가

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    ...

    id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false 
}

KSP GitHub 페이지에서 프로젝트의 Kotlin 버전에 맞는 KSP 버전을 선택해 plugins { ... }에 추가한다.

 

Kotlin의 버전은 안드로이드 스튜디오 창의 [File] - [Settings] - [Languages & Frameworks] - [Kotlin]에서 확인할 수 있다.

 

#3-2 Module 수준의 build.gradle.kts에 KSP를 사용하게 설정

plugins {
    ...
    
    id("com.google.devtools.ksp")
    id("org.jetbrains.kotlin.kapt")
}

android {
    ...
}

dependencies {
    ...
}

kapt는 KSP의 구버전이다. 하지만, 데이터 바인딩 어노테이션 사용을 위해서는 kapt가 여전히 필요하다고 한다. 그래서 kapt도 plugins { ... }에 넣는다.

 

#3-3 MyConverter.kt 만들기

// package com.example.twowaybindingusingconverter

import android.widget.EditText
import androidx.databinding.InverseMethod

object MyConverter {
    @InverseMethod("stringToInt")
    @JvmStatic
    fun intToString(
        view: EditText, value: Int
    ): String {
        // 사용자가 음수를 입력할 때, EditText.text가 "회" or "-회"이 되는 순간 "0회"로 갱신되지 않게 하기 위한 if문
        if (value == 0) {
            return view.text.toString()
        }

        return value.toString() + "회"
    }

    @JvmStatic
    fun stringToInt(
        view: EditText, value: String
    ): Int {
        return value.replace("회", "").toIntOrNull() ?: 0
    }
}

intToString()은 ViewModel에서 보낸 어떤 변수를 View에 표시(ViewModel → View)할 때 자동으로 사용되는 함수다. stringToInt()는 그 반대(View →  ViewModel)다. stringToInt()는 View에서 사용자가 입력한 값을 자동으로 ViewModel의 어떤 변수에게 재할당한다. 프로그래머는 ViewModel → View에 사용되는 함수인 intToString()만을 명시적으로 사용한다. View →  ViewModel을 담당하는 stringToInt()는 암시적으로 실행된다. KSP는 stringToInt()가 intToString()의 반대 역할임을 어떻게 식별하는가? 바로 @InverseMethod("stringToInt") 어노테이션을 식별해서다. 다음은 MyConverter의 메소드들이 View(activity_main.xml)에서 사용되는 모습이다.

 

<EditText>에 숫자만 덩그러니 표시하기엔 허전할 것 같아서, 숫자 오른쪽에 "회"를 붙이는 로직도 구현해 넣었다.

 

#3-4 activity_main.xml에서 Converter 사용

<?xml version="1.0" encoding="utf-8"?>

<layout ...>

    <data>
        <import type="com.example.twowaybindingusingconverter.MyConverter" />
        
        <variable
            name="myViewModel"
            type="com.example.twowaybindingusingconverter.MainActivityViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout ...>

        <Button ... />

        <EditText
            android:id="@+id/countText"
            ...
            android:text="@={MyConverter.intToString(countText, myViewModel.getCurrentCount())}"
            ... />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

먼저, <EditText>의 android:text 속성에 있는 바인딩 수식 @{ ... }를 @={ ... }로 변경한다. 그래야만 양방향 데이터 바인딩이 허용된다. <data> 태그 안에 MyConverter를 import한다. 그리고 @={ ... } 안에 intToString()를 사용한 코드를 넣는다. 참고로, id를 count_text와 같이 스네이크 표기법으로 지으면 @={...} 안에서 id가 인식되지 않는다. 따라서 id는 반드시 카멜 표기법을 사용해 지어야 한다.

 

#4 BindingAdapter 및 InverseBindingAdapter 이용 (방법 2)

#4-1 사전 작업

KSP API와 kapt API를 플러그인에 추가한다. (#3-1과 #3-2 참조)

 

#4-2 MyBindingAdapter.kt 만들기

// package com.example.twowaybindingusingcustomattributes

import android.widget.EditText
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter

object MyBindingAdapters {
    @BindingAdapter("android:text")
    @JvmStatic
    fun setCountText(editText: EditText, value: Int?) {
        // 사용자가 음수를 입력할 때, EditText.text가 "회" or "-회"이 되는 순간 "0회"로 갱신되지 않게 하기 위한 if문
        if ((editText.text.toString().replace("회", "").toIntOrNull() ?: 0) == (value ?: 0)) {
            return
        }

        editText.setText(value?.toString() + "회")
    }

    @InverseBindingAdapter(attribute = "android:text")
    @JvmStatic
    fun getCountText(editText: EditText): Int {
        return editText.text.toString().replace("회", "").toIntOrNull() ?: 0
    }
}

/*
위의 코드는 (https://developer.android.com/topic/libraries/data-binding/two-way?hl=ko)에 있는 코드를 참조한 코드다.
그 코드는 다음과 같다.

    예를 들어 MyView라는 맞춤 뷰의 "time" 속성에 양방향 데이터 결합을 사용하는 경우,

    @BindingAdapter("time")
    @JvmStatic fun setTime(view: MyView, newValue: Time) {
        // Important to break potential infinite loops.
        if (view.time != newValue) {
            view.time = newValue
        }
    }

    @InverseBindingAdapter("time")
    @JvmStatic fun getTime(view: MyView) : Time {
        return view.getTime()
    }
*/

Converter와 달리 BindingAdapter 및 InverseBindingAdatper는 뷰 자체에 적용되는 리스너다. setCountText()와 getCountText()는 android:text 속성을 가진 객체가 변화를 감지해 자동으로 실행된다. Converter에 비해 일괄적인 데이터 바인딩이 가능하다. '일괄적'이라는 장점은, '세부적' 설정이 어렵다라는 단점으로 작용할 수도 있겠지만 말이다. 코드 아랫부분에 있는 주석처럼 사용자 정의 View에도 사용할 수 있다.

 

#4-3 activity_main.xml에서 BindingAdapter 및 InverseBindingAdapter 이용

<?xml version="1.0" encoding="utf-8"?>

<layout ...>

    <data>
        <import type="com.example.twowaybindingusingcustomattributes.MyBindingAdapters" />

        <variable
            name="myViewModel"
            type="com.example.twowaybindingusingcustomattributes.MainActivityViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout >

        <Button ... />

        <EditText
            ...
            android:text="@={(myViewModel.getCurrentCount())}"
            ... />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<EditText>의 android:text 속성에 있는 바인딩 수식 @{ ... }를 @={ ... }로 변경하고, <data> 태그 안에 MyBindingAdapter를 import한다. import된 순간부터 activity_main.xml에서 android:text 속성이 변화될때마다 데이터 바인딩 라이브러리에 의해 그 변화가 감지된다.

 

#5 'Built-In' BindingAdapter 및 InverseBindingAdapter 이용 (방법 3)

#5-1 사전 작업 필요없음

사실 <TextView>의 android:text 속성은 자체적으로 '기본적인' 양방향 데이터 바인딩을 지원한다. 이미 구현되어 Built-In된 BindingAdapter 및 InverseBindingAdapter가 있다는 이야기다. <TextView>의 자식인 <EditText>에도 마찬가지일 것이다. 즉, #4처럼 MyBindingAdapter를 만들 필요도 없고 그 속에 있던 어노테이션 문법 또한 쓰지 않아도 된다. 그래서 #3 및 #4에서 했던 KSP API와 kapt API를 플러그인에 추가할 필요가 없다.

 

다만, Built-In된 것은 어디까지나 '기본적인' 양방향 데이터 바인딩이라는 것을 기억하자. #4에서와 달리 사용자 정의 View에는 사용할 수 없다. 제한적인 용도로만 사용된다는 것은 단점이지만, 본 게시글의 데이터 바인딩 구현 방식 중에서는 그 구현 난이도가 제일 낮다는 장점이 있다.

 

#5-2 MainActivityViewModel.kt 수정

// package com.example.twowaybindingusingbuiltinattributes

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class MainActivityViewModel : ViewModel() {
    private var count: MutableLiveData<String> = MutableLiveData("0회")

    fun getCurrentCount(): MutableLiveData<String> {
        return count
    }

    fun updateCount() {
        val valueInt = count.value?.replace("회", "")?.toIntOrNull() ?: 0
        count.value = (valueInt + 1).toString() + "회"
    }
}

count 변수의 제네릭을 String으로 바꾼다. 그에 맞춰 updateCount() 함수의 내용도 손본다. 왜 이러는걸까? 바로 Built-In된 BindingAdapter 및 InverseBindingAdapter가 '기본적인' 기능만을 제공하기 때문이다. 즉, 예를 들어 Int → String으로 바꾸거나 String → Int로 바꾸는 기능이 없다. 오직 String ↔ String 만 가능하다. 그래서 count의 제네릭을 그에 맞추는 것이다. 만약, 복잡한 변환을 구현하려면 사용자 정의 데이터 바인딩 즉 #3 또는 #4의 방식을 이용해야 한다.

 

#5-3 activity_main.xml 수정

<?xml version="1.0" encoding="utf-8"?>

<layout ...>

    ...

    <androidx.constraintlayout.widget.ConstraintLayout ...>

        <Button ... />

        <EditText
            ...
            android:text="@={(myViewModel.getCurrentCount())}"
            ... />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<EditText>의 android:text 속성에 있는 바인딩 수식 @{ ... }를 @={ ... }로 변경하면 끝이다. 추가로 count는 이제 String이니 toString()도 제거한다.

 

#6 작동 확인 - 양방향 데이터 바인딩

 

#7 요약

단방향 데이터 바인딩을 통해 Activity과 View의 결합도를 줄였던 것처럼, 양방향 데이터 바인딩 또한 (#2-1의 MainActivity.kt 코드를 암시적으로 수행하면서) Activity과 View의 결합도를 줄인다.

 

#8 완성된 앱

#8-1 Converter 이용 (방법 1)

https://github.com/Kanmanemone/android-practice/tree/master/live-data/TwoWayBindingUsingConverter

 

#8-2 BindingAdapter 및 InverseBindingAdapter 이용 (방법 2)

https://github.com/Kanmanemone/android-practice/tree/master/live-data/TwoWayBindingUsingCustomAttributes

 

#8-3 'Built-In' BindingAdapter 및 InverseBindingAdapter 이용 (방법 3)

https://github.com/Kanmanemone/android-practice/tree/master/live-data/TwoWayBindingUsingBuiltInAttributes