반응형

LiveData 는 ViewModel 클래스와 DataBinding 등과 자주 쓰이는 클래스입니다. 처음 접하시는 분들은 대게 LiveData 와 MutableLiveData 만 사용하실텐데요.

 

개발을 하다보면 하나의 데이터가 바뀔 때마다 다른 여러 데이터들도 함께 바껴야하는 상황을 많이 만나게 됩니다. 또 Room이나 Retrofit 등 데이터베이스나 네트워크 통신을 도와주는 라이브러리와 함께 사용되기도 합니다.

 

이럴 때 사용하는 것이 바로 Transformations 의 map 과 switchMap 메소드입니다.

 

Transformations 메소드를 사용하면 LiveData와 마찬가지로 Observer의 lifecycle에 안전하게 데이터를 전달할 수 있습니다. 그리고 지연 평가(Lazy Estimation)로 동작하기 때문에 원천이 되는 객체의 변화가 일어나지 않는다면 동작하지 않습니다.

 

Transformations.map

Transformations.map 메소드는 다은과 같이 구현되어 있습니다.

    @MainThread
    @NonNull
    public static <X, Y> LiveData<Y> map(
            @NonNull LiveData<X> source,
            @NonNull final Function<X, Y> mapFunction) {
        final MediatorLiveData<Y> result = new MediatorLiveData<>();
        result.addSource(source, new Observer<X>() {
            @Override
            public void onChanged(@Nullable X x) {
                result.setValue(mapFunction.apply(x));
            }
        });
        return result;
    }

 

위 코드는 java 코드입니다. 보시면 static 메소드이기 때문에 Transformations 인스턴스 없이 클래스 참조로 바로 사용가능합니다. Main Thread 에서 실행되며, 파라미터에 null 값을 넣어서는 안됩니다.

 

파라미터에 대해서도 가볍게 설명하자면 첫 번째 파라미터인 source는 결과물로 만들어진 LiveData의 원천 역할을 합니다. 그리고 두 번째 파라미터인 func 함수의 결과로 인해 어떤 LiveData 가 리턴될지 결정됩니다.

 

내부적으로 MediatorLiveData가 사용되고 있습니다만, MediatorLiveData에 대해서는 별도로 다루도록 하겠습니다.

 

Trnasformations.map 의 핵심은 값의 변형입니다. Kotlin Collections의 map 함수나 RxJava의 map 함수를 아시는 분은 보다 쉽게 이해할 수 있을텐데요. source 객체의 값을 Observing 하면서 그 값이 바뀔때마다 새로 만들어진 LiveData의 값도 연쇄적으로 바뀌게 됩니다.

 

다소 이해가 안가실 수 있지만 이따가 예제 코드를 보시면 바로 이해할 수 있으실 겁니다.

 

Transformations.switchMap

Transformations.switchMap 은 map 메소드와 비슷합니다만 차이점이 있다면 두 번째 파라미터로 들어오는 함수형 인터페이스 내 메소드의 리턴 타입이 값이 아닌 LiveData 타입이라는 것입니다. 이는 Room 등 LiveData를 지원하는 다른 라이브러리들과 함께 사용하기에 좋습니다.

 

역시나 메소드가 어떻게 구현됐는지 보겠습니다.

    @MainThread
    @NonNull
    public static <X, Y> LiveData<Y> switchMap(
            @NonNull LiveData<X> source,
            @NonNull final Function<X, LiveData<Y>> switchMapFunction) {
        final MediatorLiveData<Y> result = new MediatorLiveData<>();
        result.addSource(source, new Observer<X>() {
            LiveData<Y> mSource;

            @Override
            public void onChanged(@Nullable X x) {
                LiveData<Y> newLiveData = switchMapFunction.apply(x);
                if (mSource == newLiveData) {
                    return;
                }
                if (mSource != null) {
                    result.removeSource(mSource);
                }
                mSource = newLiveData;
                if (mSource != null) {
                    result.addSource(mSource, new Observer<Y>() {
                        @Override
                        public void onChanged(@Nullable Y y) {
                            result.setValue(y);
                        }
                    });
                }
            }
        });
        return result;
    }

 

map 메소드보다는 다소 코드가 길지만 null 체크가 늘었고, 앞서 말씀드린 것처럼 결과물로 값이 아닌 LiveData를 만들어 리턴하기 때문에 그에대한 코드가 조금 더 늘어났을 뿐 사실상 map 메소드와 로직은 유사합니다.

 

이렇게 메소드의 내부 코드만 봐서는 이해가 잘 안될 수 있는데요. 예제를 통해서 쉽게 이해해보도록 하겠습니다.

 

예제 코드 - 원/정사각형 넓이 구하기

이번 예제는 반지름(또는 한 변의 길이)를 입력하면 그에따른 원 넓이와 정사각형의 넓이를 화면 중앙에 출력하는 코드를 작성해보겠습니다. 아직 데이터바인딩에 대한 포스트를 작성하진 않았기 때문에 이번 예제 코드에서 데이터바인딩을 사용하진 않았습니다만, ViewModel-LiveData-DataBinding 은 함께 사용했을 때 굉장히 좋은 시너지를 발휘합니다.

 

먼저 레이아웃 코드를 작성합니다. (activity_main.xml)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/edit_text"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:layout_margin="10dp"
        android:hint="반지름(한 변의 길이)를 입력하세요."
        android:inputType="numberDecimal"
        app:layout_constraintEnd_toStartOf="@id/ok_button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/ok_button"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_margin="10dp"
        android:background="@drawable/button_selector"
        android:gravity="center"
        android:text="OK"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/circle_area_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="원 넓이 : "
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/square_area_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="정사각형 넓이 : "
        android:layout_marginTop="20dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/circle_area_text" />

</androidx.constraintlayout.widget.ConstraintLayout>

EditText에 길이를 입력하면 그에 따른 원 넓이와 정사각형 넓이를 화면 중앙에 출력할 수 있도록 레이아웃을 구성합니다.

 

다음으로는 ViewModel 클래스를 만듭니다.

class MainViewModel : ViewModel() {
    private val widthText: MutableLiveData<Int> = MutableLiveData()
    val areaOfSquare: LiveData<Int> = Transformations.map(widthText) { it * it }
    val areaOfCircle: LiveData<Double> = Transformations.switchMap(widthText) { width ->
        getAreaOfCircle(width)
    }

    fun updateText(newWidth: Int) {
        widthText.value = newWidth
    }

    private fun getAreaOfCircle(width: Int): LiveData<Double> {
        val liveData: MutableLiveData<Double> = MutableLiveData()
        liveData.run {
            value = width * width * PI
        }
        return liveData
    }
}

여기서 주의깊게 보실 부분은 areaOfSquare areaOfCircle 입니다. 보시면 areaOfSquare는 Transformations.map 메소드를 사용하기 때문에 LiveData 객체가 아닌 값을 리턴하는 람다식을 두 번째 파라미터로 넘겨줍니다. 그리고 areaOfCircle 는 LiveData 객체를 리턴하는 함수가 두 번째 파라미터에 와야하기 때문에 getAreaOfCircle() 이라는 함수를 만들어 활용했습니다.

 

마지막으로 MainActivity 클래스를 작성하면 끝!

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val viewModel = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory())
                            .get(MainViewModel::class.java)

        ok_button.setOnClickListener {
            viewModel.updateText(edit_text.text.toString().toInt())
        }

        viewModel.areaOfCircle.observe(this, Observer<Double> { area ->
            circle_area_text.text = "원 넓이 : $area"
        })

        viewModel.areaOfSquare.observe(this, Observer<Int> { area ->
            square_area_text.text = "정사각형 넓이 : $area"
        })
    }
}

 

작성을 완료했다면 실행해봅시다. 잘 동작하는 것을 확인할 수 있습니다.

 

결과

 

Transformations를 사용하면 그냥 LiveData 만 사용했을 때보다 유연하게 개발할 수 있습니다. 다음 포스팅에서는 앞서 내부 구현코드에서 등장했던 MediatorLiveData를 알아보도록 하겠습니다.

반응형
  • ss 2020.09.10 12:28

    글 잘보았습니다. 궁금한 것이 있는데
    MainViewModel 클래스에서 구현한
    areaOfCircle의 switchMap부분을
    map(width) { it * it * PI } 하고 해도 결과는 같은 것 같은데 굳이 switchMap 을 쓰신 이유가 있나요?

    • Favicon of https://readystory.tistory.com BlogIcon Dev. Ready Kim 2020.09.10 21:39 신고

      안녕하세요.
      말씀하신 대로 map 을 사용해도 결과는 같습니다만, 예제에서는 map 과 switchMap 둘 다 예시를 들기 위해 사용한 것이구요. map 과 switchMap 의 차이는 두 번째 파라미터로 받는 mapFunction 리턴 타입인 Function<> 의 제네릭 타입이 다르다는 것에 있습니다.

      본문에도 작성했지만, switchMap 의 경우 Room 이나 Retrofit 등 LiveData 와 동기화 해서 편리하게 사용하기에 좋은 함수이기에 map 함수와는 구별되는 니즈가 분명히 있답니다!

  • esk 2021.10.25 13:26

    깔끔한 설명 잘 보고 갑니다 ^^

반응형