Android 공부

감자튀김의 다시 쓰는 Android MVVM(4) - ViewModel & DataBinding


-1. 이전글

2019/04/02 - [Android 공부] - 감자튀김의 다시 쓰는 Android MVVM(3) - Repository

0. ViewModel

MVVM에서는 Model, View, ViewModel이라는 개념이 도입된다. 'ViewModel이란 무엇일까?' 라고 생각했을 때, 쉽게 말하기 위해서 나는 'View에 대한 정보 Model 값' 정도로 말한다.

 여태까지 흔히 알고 있던, Model에 대한 데이터 값은 아니다. ViewModel이라고 이름 붙인 이유는 개인적인 생각으로 View가 변하기 위해서 갖고 있을 Model으로 여기엔 우리의 일반 Data가 포함될 수 있고, 혹은 View에서 사용될 각종 변수값이 담길 수 있다.

 그렇다면 ViewModel이 왜 있어야 하는 것일까? "ViewModel에 관한 공식문서에 대한 번역본을 읽는 것을 추천한다. "


0.1. ViewModel의 사용 이유


 ViewModel은 onSaveInstanceState()가 사용되는 스마트폰이 회전할 때 생기는 이슈들을 해결하기 위해서 만들어졌다고 한다. 예를 들면, 스마트폰을 회전하게 된다면 기존의 데이터가 전부 저장되지 않거나 UI를 보여주기 위해서 과도한 메모리를 사용하는 것이 문제라고 한다. 그래서 ViewModel을 통해서 이러한 점들을 해결하려고 했다. 


0.2. MVVM에서 ViewModel의 사용 이유

 - lifecycleOwner를 통해서 ViewModel을 LifeCycle에 종속시켜서, 다양한 라이프사이클 문제를 해결할 수 있다.

 - ViewModel을 통해서 관심사 분리를 통해 클래스들의 의존성을 낮출 수 있다.

 - ViewModel을 통해서 액티비티, 프레그먼트 등의 데이터를 공유할 수 있다.


0.3 ViewModel을 호출하기 위한 코드


0.3.1 Activity


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MainActivity : DaggerAppCompatActivity() {
    @Inject
    lateinit var mainViewModel: MainViewModel
 
    lateinit var activityMainBinding: ActivityMainBinding
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        activityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        activityMainBinding
            .apply {
                lifecycleOwner = this@MainActivity
                coinSearchResultAdapter = CoinSearchResultAdapter((this@MainActivity).mainViewModel)
                orderBookRVAdapter = OrderBookRVAdapter()
                mainViewModel = (this@MainActivity).mainViewModel
                mainViewModel?.getMarketCodeAll()
                mainViewModel?.getMyAccount()
            }
    }
}
cs

Activity단에서 ViewModel을 호출하기 위해서, 해당 프로젝트에는 Dagger를 사용하고 있습니다. 물론, Dagger를 사용하지 않더라도 onCreate()에 ViewModel을 초기화하면 됩니다. activityMainBinding 또한 Dagger를 통해서 초기화하면 더 좋습니다. activityMainBinding이 null이 아니라면 apply를 통해서 lifecycleOwner, Adapter, ViewModel을 setting해줍니다.

- Activity에서 ViewModel을 초기화한다.
- Activity에서 RecyclerView의 어댑터를 호출한다.
- Activity에서 lifecycleOwner를 설정한다. 여기서 lifecycleOwner는 액티비티의 라이프사이클에 종속시키는 것을 의미하고, onCreate에서 onDestroy까지 라이프사이클에서만 ViewModel이 사용되어 라이프사이클의 각종 이슈에 비교적 자유롭습니다.

0.3.2. MainViewModelModule

1
2
3
4
5
6
7
8
9
10
11
12
@Module
abstract class MainViewModelModule {
    @Module
    companion object {
        @JvmStatic
        @Provides
        @ViewModelScope
        fun provideMainViewModel(upbitRepositoryImpl:UpbitRepositoryImpl, jjwtHelper:JJwtHelper): MainViewModel {
            return MainViewModel(upbitRepositoryImpl, jjwtHelper)
        }
    }
}
cs

0.3.3. ViewModelMoudle

1
2
3
4
5
6
@Module
abstract class ViewModelModule{
    @ViewModelScope
    @ContributesAndroidInjector(modules = [MainViewModelModule::class])
    abstract fun getMainViewModel() : MainViewModel
}
cs

0.3.4. ApplicationComponent


1
2
3
4
5
6
7
@Singleton
@Component(modules = [AndroidSupportInjectionModule::class, ApplicationModule::class, ActivityModule::class,
    ViewModelModule::class, NetModule::class, RepositoryModule::class])
interface ApplicationComponent : AndroidInjector<BaseApplication> {
    @Component.Builder
    abstract class Builder : AndroidInjector.Builder<BaseApplication>()
}
cs

MainViewModelModule을 통해서 MainViewModel을 생성해서 provide해주고 있고, ViewModelModule을 통해서 다양한 ViewModel들을 관리한다. ApplicationComponent에서는 이와 같은 모든 모듈을 관리해준다.

1. LiveData

 LiveData는 MVVM을 사용하는 것에 있어 필수조건이다. LiveData는 LifeCycle이 STARTEDRESUMED인 상태에 작동한다. 그리고, 활성화 된 옵저버에게 데이터를 제공해주는 역할을 한다고 한다. 그리고, LifeCycleOwner를 통해서 해당 액티비티의 생명주기가 DESTROY 됐을 때, 옵저버의 활동을 중지시켜서 생명주기로부터 오는 문제를 해결할 수 있다.

 이처럼 LiveData는 액티비티의 생명주기와 함께 동작하여, 다양한 이슈를 해결할 수 있는 장점이 있다. LiveData와 ViewModel 모두 LifeCycle에서 생기는 이슈를 해결하고, 해당 데이터가 변하면 값을 Set해주는 존재이다.


MVVM에서 UI를 갱신시키기까지

 이와 같이 ViewModel에선 repository패턴을 통해 얻어온 데이터를 MutableLiveData 자료형을 갖고 있는 객체에 데이터를 postValue를 하고, LiveData는 이 mutableLiveData를 Observe해서 값이 변화하는 것을 감지하고, 이 값을 DataBinding에 뿌려주는 구조이다.

 - MutableLiveData : LiveData 객체 안에 값을 변경시킬 수 있는 자료형

 - LiveData : LiveData 객체 안에 값을 변경할 수 없는 자료형

 여기에서 MutableLiveData와 LiveData객체를 한 쌍으로 만들어서 데이터를 관리하는 예제를 많이 볼 수 있다. 실제로 이와 같은 구조를 잡게 되면, LiveData는 단순히 UI를 변경시키는 용도이며, 혹여나 UI단에서 LiveData를 변경시키는 이슈를 방지할 수 있는 장점이 있다.


1.1. LiveData 사용방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private val _marketCodeLiveData: MutableLiveData<List<MarketCode>> = MutableLiveData()
val marketCodeLiveData: LiveData<List<MarketCode>> = _marketCodeLiveData
 
_marketCodeLiveData.postValue(marketCodes) //코인에 대한 정보를 담아서 live 담음
override fun getMarketCodeAll() {
    upbitRepositoryImpl.getMarketCodeAll()
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({ marketCodes ->
             val markerCodeStringArray = arrayListOf<String>()
             marketCodes.forEach { marketCode ->
                 markerCodeStringArray.add(marketCode.koreanName.toString())
             }
             _marketCodeLiveData.postValue(marketCodes) //코인에 대한 정보를 담아서 live 담음
             _marketCodeStringLiveData.postValue(markerCodeStringArray) //spinner에 타이틀을 담음
         },
             {
                 Log.d("HttpError", it.message)
             })
 }
cs

 이와 같은 구조로 하여 MutableLiveData 앞에는 암묵적으로 언더바를 붙여주고 private 형태로 해당 ViewModel에서만 사용할 수 있게 한다. 그리고, LiveData는 scope를 붙이지 않음으로 public형태로 만들고, 해당 Data를 DataBindingUtil에서 사용할 수 있게끔 한다. 또한, postValue를 통해서 값을 담아주면 된다.


2. DataBinding

 DataBinding은 xml단에서 UI를 편하게 바꿀 수 있도록 도와준다. 보통 xml에 변경해야할 속성의 LiveData를 DataBinding을 통해서 매칭시킨다. DataBinding은 이전에도 글을 쓴 적이 있는데, 해당 기술을 알게 되면, 안드로이드 개발의 큰 분기점이 되는 부분이라고 생각한다.

 DataBinding을 사용하면서 다양한 오류를 보고, 원인 모를 오류를 보게 될 것이다. 그래서 많은 변화를 시도하기보다 정형화된 방법으로 DataBinding을 사용하는 것이 정신건강에 좋다고 생각한다.


2.1. Gradle 설정


1
2
3
dataBinding {
        enabled = true
}
cs


2.2. DataBinding 사용 방법


2.2.1. 기본적인 DataBinding을 사용하는 xml


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
                name="marketCode"
                type="hbs.com.myupbitbot.data.MarketCode"/>
        <variable
                name="mainViewModel"
                type="hbs.com.myupbitbot.ui.main.MainViewModel"/>
    </data>
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="54dp"
            android:orientation="horizontal"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:paddingLeft="16dp"
            android:id="@+id/itemLL"
            onMainViewModel="@{mainViewModel}"
            onMarketCode="@{marketCode}"
            android:background="@color/white_color">
        <!-- 더 많은 코드들 ...-->
        <TextView
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="2"
                android:gravity="center"
                android:textSize="16sp"
                android:text="@{marketCode.englishName}"/>
    </LinearLayout>
</layout>
cs


DataBinding을 사용하는 뷰는 <data> 태그에 <variable>을 넣어준다. 그러면 name과 type을 통해서 xml에서 사용하는 객체를 명시하게 된다. 위와 같은 코드들만으로 각종 클래스를 자동화해서 만들어준다.


2.2.2. 액티비티 코드에서 binding 연결


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : DaggerAppCompatActivity() {
    @Inject
    lateinit var mainViewModel: MainViewModel
 
    lateinit var activityMainBinding: ActivityMainBinding
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        activityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        activityMainBinding
            .apply {
                lifecycleOwner = this@MainActivity
                coinSearchResultAdapter = CoinSearchResultAdapter((this@MainActivity).mainViewModel)
                orderBookRVAdapter = OrderBookRVAdapter()\
            }
    }
}
cs

2.2.3. DataBinding으로 만들어진 Binding class


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public abstract class ItemResultSearchCoinBinding extends ViewDataBinding {
  @NonNull
  public final LinearLayout itemLL;
 
  @Bindable
  protected MarketCode mMarketCode;
 
  @Bindable
  protected MainViewModel mMainViewModel;
 
  protected ItemResultSearchCoinBinding(DataBindingComponent _bindingComponent, View _root,
      int _localFieldCount, LinearLayout itemLL) {
    super(_bindingComponent, _root, _localFieldCount);
    this.itemLL = itemLL;
  }
 
  public abstract void setMarketCode(@Nullable MarketCode marketCode);
 
  @Nullable
  public MarketCode getMarketCode() {
    return mMarketCode;
  }
 
  public abstract void setMainViewModel(@Nullable MainViewModel mainViewModel);
 
  @Nullable
  public MainViewModel getMainViewModel() {
    return mMainViewModel;
  }
 
  @NonNull
  public static ItemResultSearchCoinBinding inflate(@NonNull LayoutInflater inflater,
      @Nullable ViewGroup root, boolean attachToRoot) {
    return inflate(inflater, root, attachToRoot, DataBindingUtil.getDefaultComponent());
  }
 
  @NonNull
  public static ItemResultSearchCoinBinding inflate(@NonNull LayoutInflater inflater,
      @Nullable ViewGroup root, boolean attachToRoot, @Nullable DataBindingComponent component) {
    return DataBindingUtil.<ItemResultSearchCoinBinding>inflate(inflater, hbs.com.myupbitbot.R.layout.item_result_search_coin, root, attachToRoot, component);
  }
 
  @NonNull
  public static ItemResultSearchCoinBinding inflate(@NonNull LayoutInflater inflater) {
    return inflate(inflater, DataBindingUtil.getDefaultComponent());
  }
 
  @NonNull
  public static ItemResultSearchCoinBinding inflate(@NonNull LayoutInflater inflater,
      @Nullable DataBindingComponent component) {
    return DataBindingUtil.<ItemResultSearchCoinBinding>inflate(inflater, hbs.com.myupbitbot.R.layout.item_result_search_coin, nullfalse, component);
  }
 
  public static ItemResultSearchCoinBinding bind(@NonNull View view) {
    return bind(view, DataBindingUtil.getDefaultComponent());
  }
 
  public static ItemResultSearchCoinBinding bind(@NonNull View view,
      @Nullable DataBindingComponent component) {
    return (ItemResultSearchCoinBinding)bind(component, view, hbs.com.myupbitbot.R.layout.item_result_search_coin);
  }
}
 
cs


이와 같이 선언한 variable에 따라서 @Bindable한 Annotation을 갖고 있는 변수가 자동으로 만들어진다. 또한 setter와 getter가 자동으로 만들어져 Binding에 대한 모델을 액티비티에서 넣어줄 수 있는 것을 의미한다. 그리고 그 외에는 inflate와 bind 매소드들을 통해서 쉽게 ui를 set할 수 있는 것을 볼 수 있다.


2.2.4. BindingAdapter


1
2
3
4
5
6
7
8
9
10
11
<LinearLayout
            android:layout_width="match_parent"
            android:layout_height="54dp"
            android:orientation="horizontal"
            android:layout_marginStart="8dp"
            android:layout_marginEnd="8dp"
            android:paddingLeft="16dp"
            android:id="@+id/itemLL"
            onMainViewModel="@{mainViewModel}"
            onMarketCode="@{marketCode}"
            android:background="@color/white_color"/>
cs


위와 같은 xml에서는 onMainViewModel이나 onMarketCode와 같이 커스텀한 attribute 값을 볼 수 있다. 이것을 BindingAdapter라고 하고, 해당 BindingAdapter는 class에서 xml의 변수를 사용할 수 있다.


1
2
3
4
5
6
7
@BindingAdapter(value = ["onMainViewModel""onMarketCode"])
fun LinearLayout.onTouchResultItem(mainViewModel: MainViewModel, marketCode: MarketCode) {
    setOnClickListener {
        mainViewModel.getMarketOrder(marketCode.market)
        mainViewModel.updateClearItemPosition(SearchResult.SEARCH_SUCCESS.status)
    }
}
cs


BindingAdapter는 위와 같이 value의 값을 LinearLayout에서 충족시켜줘야 한다. 하지만, boolean 값을 통해서 무조건 충족시킬 필요는 없다. xml에서 받은 mainViewModel과 marketCode를 class에서 사용할 수 있는 것을 볼 수 있다.


3. ViewModel & Repository


Repostiroy Poster : https://gamjatwigim.tistory.com/85


ViewModel은 너무 많은 로직을 갖고 있으면 안되기 때문에 Repisotory패턴을 활용해서 ViewModel을 덜어줄 수 있다. 그래서 해당 프로젝트에서도 Repository패턴에서 DB나 Retrofit을 별도로 관리하고 있다.


3-1. ViewModel


1
2
3
4
5
6
7
class MainViewModel @Inject constructor(
    private val upbitRepositoryImpl: UpbitRepositoryImpl,
    private val jjwtHelper: JJwtHelper
) : BaseViewModel(),
    UpbitHelper {
    //비대해진 코드를 Repository 패턴을 통해서 분리
}
cs


3-2. Repository


1
2
3
4
5
6
7
8
9
10
11
12
13
class UpbitRepositoryImpl @Inject constructor(val upbitAPI: UpbitAPI) : UpbitRepository {
    override fun getMyAccount(jwtToken: String): Observable<List<AccountCoin>> {
        return upbitAPI.getMyAccount(jwtToken)
    }
 
    override fun getMarketCodeAll(): Single<List<MarketCode>> {
        return upbitAPI.getMarketInfo()
    }
 
    override fun getOrderBook(market: String): Single<List<OrderBook>> {
        return upbitAPI.getOrderBook(market)
    }
}
cs


3-3. BaseViewModel


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
open class BaseViewModel : ViewModel() {
    private val compositeDisposable : CompositeDisposable = CompositeDisposable()
 
    protected fun addDisposable(disposable: Disposable){
        compositeDisposable.add(disposable)
    }
 
    protected fun clearDisposable(){
        compositeDisposable.clear()
    }
 
    override fun onCleared() {
        clearDisposable()
        super.onCleared()
    }
}
cs


BaseViewModel을 통해서 ViewModel을 모두 관리할 수 있다. 해당 코드에서는 Disposable을 관리해주는 로직을 통해서 ViewModel의 Disposable을 관리해준다.


4. 우오옹?


 해당 예제는 Best Practice가 아니라는 것을 말하고 싶다. ViewModel에 관련되어 더 명확하게 구현하기 위해선 꾸준한 공부가 필요하다고 생각한다. 컨퍼런스에서는 AAC의 VIewModel을 사용하지 않고 구현하는 방법을 알아야 하며, 명확한 MVVM을 구현하기 위한 꾸준한 공부가 필요하다는 것을 알아야한다. 또한, MVI, MVP, MVVM등 다양한 아키텍쳐를 공부하는 숙제를 풀어야 정말 고수가 된다는 것을 컨퍼런스를 갈 때마다 느끼고 있다.