정상에서 IT를 외치다

[Android, MVVM] MVVM 따라하기 - 4 (MVVM 구현) 본문

안드로이드

[Android, MVVM] MVVM 따라하기 - 4 (MVVM 구현)

Black-Jin 2020. 6. 16. 02:05
반응형

포스팅 순서는 아래와 같습니다.

 

1. 아무런 기술 적용 없이 요구사항에 맞춰 프로젝트를 구현합니다. 

2. Repository 패턴을 적용하여 Model을 구현합니다.

3. Rx를 적용하여 구현합니다.

4. Databinding을 사용해 MVVM을 구현합니다.(현재)

5. AAC의 ViewModel을 사용해 MVVM을 구현합니다.

 

순차적으로 코드를 리펙토링 하면서 어떤 점에서 코드의 유지보수가 좋아졌는지 보겠습니다.

 

MVVM 구현하기

 

이번 시간에는 DataBinding을 사용한 MVVM을 본격적으로 구현해 보겠습니다. 

 

 

MVVM 이란?

 

아키텍처에서 가장 중요한 부분은 바로 관심사의 분리 입니다. MVVM은 Model과 View 그리고 ViewModel 이라는 3가지 부분으로 이뤄져 있습니다. 

 

<참고>

MVVM은 Microsoft에서 시작되었습니다.  -> [번역] MVVM 디자인 패턴의 기본 이해

 

 

Model

 

모델은 데이터와 관련된 부분을 담당합니다. 로그인 부분을 예로 들자면 아이디와 패스워드를 입력받아 서버로 보냅니다. 그리고 입력한 아이디와 패스워드가 서버에 저장되어 있는 데이터와 비교 후 올바른 값이면 '성공'을 그렇지 않으면 '실패'라는 값을 받게되겠죠. 이렇게 서버 혹은 내부 디비와 통신하는 부분을 Model이 당당하게 됩니다.

 

 

View

 

뷰는 화면과 관련된 부분을 담당합니다. 아이디와 패스워드를 화면에 보여주고 내용을 입력받아 로그인 버튼을 누르는 일련의 사용자 액션을 받는 모든 부분을 말합니다.

 

 

ViewModel

 

뷰와 모델을 연결하며 화면과 관련된 비즈니스 로직을 처리합니다. 여기서 중요한 점은 부모델은 뷰를 몰라야 합니다. 뷰와의 커플링 즉 의존도를 가지고 있지 않아야 합니다. 이렇게 구현하므로써 뷰와 관련된 로직을 뷰와 독립적으로 테스트할 수 있고 재사용 할 수 있게 됩니다.

 

 

출처 - Microsoft

 

 

위 그림에서 보듯이 의존 방향은 View -> ViewModel -> Model 로 향하고 있습니다. 즉 View는 ViewModel을 알고 있지만 ViewModel은 View를 몰라야 합니다. ViewModel은 Model을 알지만 반대로 Model은 ViewModel을 몰라야 합니다.

 

ViewModel은 Model에게 데이터가 업데이트 되었음을 알려줍니다. 여기서 ViewModel에서는 Model을 옵저빙 하고 있다가 데이터가 변경되면 이를 노티하여 상태를 변경합니다. 이와같이 옵저버 패턴을 사용해 Model -> ViewModel 방향으로의 의존성을 없앨 수 있습니다. 이전 포스팅에서(Repository 구현하기) 보았듯이 이 부분은 RxJava를 사용해 구현할 수 있습니다. (물론 다른 여러 방법이 존재합니다.)

 

View 또한 마찬가지로 ViewModel을 관찰하고 있다가 상태가 변경되면 화면을 갱신함으로써 ViewModel -> View로의 의존성을 없앨 수 있습니다. 여기서 View는 Activiy 또는 Fragment가 될 수 있습니다. 하지만 Acitvity와 Fragment는 그 자체로 안드로이드와의 의존성을 가지고 있습니다. 이를 완전하게 없애줄 수 있는 방법이 바로 DataBinding을 사용한 방법입니다. 이렇게 함으로써 View는 더이상 Acitvity와 Fragment 아닌 xml 즉 온전하게 화면만을 담당하는 부분이 될 수 있습니다.

 

 

 

databinding 이란?

 

데이터를 xml에서 처리할 수 있도록 도와주는 라이브러리 입니다. 이를 통해 ViewModel에서 변경된 데이터를 Acitvity나 Fragment가 아닌 xml에서 바로 처리할 수 있게됩니다.

 

 

 

사용방법

 

사용방법은 문서를 참고해 주세요.

 

 

 

토이프로젝트 구현

 

 

SearchViewModel.kt

 

1. 데이터 통신을 위해 Repository를 인자로 받는 SearchViewMdoel을 만들어 줍니다.

class SearchViewModel(
private val searchRepository: RepoRepository,
) {
//...
}

 

 

2. DataBinding의 ObservableField를 사용해 상태 변화에 필요한 필드를 선언해 줍니다. View에서는 이 필드들을 관찰해 상태를 변경해 줍니다. 예를 들어 아래 isLoading 필드를 선언했습니다. 로딩 필드가 false일 때는 로딩바를 숨기고 true일때는 로딩바를 보여줄 예정입니다. 이와 같이 화면에 관한 비즈니스 로직들을 ViewModel에서 관리할 수 있게 됩니다.

//로딩 필드
val isLoading = ObservableField(false)
//검색 버튼 활성 필드
val enableSearchButton = ObservableField(true)
//에러 메시지 필드
val errorMessage = ObservableField("")
//검색 입력 필드
val editSearchText = ObservableField("")
//리포지터리 아이템 필드
val items = ObservableField<List<RepoItem>>(emptyList())

 

필드의 경우 다음과 같이 "@{ }" syntax를 사용해 xml에서 선언할 수 있습니다.

 

 

- 로딩 여부

android:visibility="@"

 

- 버튼 활성화 여부

android:enabled="@"

 

- 에러 메시지 여부

android:visibility="@"
android:text="@"

 

 

- 양방향 바인딩

 

양방향 바인딩의 경우 "@={ }" syntax를 사용해 선언합니다. "="이 추가되었다는 점을 주의깊게 보셔야 합니다. 양방향 바인딩의 대표적인 예로는 editText가 있습니다. 코드를 사용해 값을 변경할 수 있지만 사용자가 직접 텍스트를 입력해서 데이터를 변경할 수도 있습니다. 이 경우 사용자가 텍스트를 입력할 때 데이터의 변경사항을 알 수 있게 하는 것이 양방향 바인딩 입니다.

 

android:text="@="

 

ObservableField를 관찰하는 함수는 addOnPropertyChangedCallback이 있습니다. editSearchText 필드를 관찰하기 시작해 바뀌는 데이터를 실시간으로 알 수 있습니다.

editSearchText.addOnPropertyChangedCallback(object :
    Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
        //사용자가 입력한 값을 관찰하고 있어 실시간으로 데이터를 받아올 수 있습니다.
        val query = editSearchText.get()
        if (query.isNullOrEmpty()) {
            enableSearchButton.set(false)
        } else {
            enableSearchButton.set(true)
        }
    }
})

 

 

 

4. 검색 결과를 받아올 수 있는 함수 searchRepository를 선언했습니다. 여기에는 2가지 인자를 받습니다. 

fun searchRepository(context: Context, query: String) {
searchRepository.searchRepositories(query, object : BaseResponse<RepoSearchResponse> {
override fun onSuccess(data: RepoSearchResponse) {
items.set(data.items.map { it.mapToPresentation(context) })

if (0 == data.totalCount) {
showErrorMessage(context.getString(R.string.no_search_result))
}
}

override fun onFail(description: String) {
showErrorMessage(description)
}

override fun onError(throwable: Throwable) {
showErrorMessage(throwable.message ?: context.getString(R.string.unexpected_error))
}

override fun onLoading() {
clearItems()
showLoading()
hideErrorMessage()
}

override fun onLoaded() {
hideLoading()
}
})
}

 

함수의 경우 xml에서는 다음과 같이 설정할 수 있습니다.

android:onClick="@{(v) -> model.searchRepository(v.getContext(), model.editSearchText.toString())}

 

 

 

fragment_search.xml

 

1. DataBinding을 사용하기 위해서는 <layout></layout>안에 뷰를 선언해주어야 합니다. 그리고 <data></data>안에서는 뷰와 관련된 데이터들을 선언할 수 있습니다.

<layout 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">

    <data>

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

        <import type="android.text.TextUtils" />

        <variable
            name="model"
            type="com.example.toyproject.ui.search.SearchViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/gray9"
        android:orientation="vertical"
        android:padding="16dp">

        //..

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

 

 

 

SearchFragment.kt

searchModel.items.addOnPropertyChangedCallback(object :
    Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
        searchModel.items.get()?.let {
            repoAdapter.setItems(it)
        }
    }
})

 

Fragment에서 viewModel의 items를 관찰합니다. 필자는 Adapter을 Fragment에서 선언해 사용하고 있어 위와 같이 Framgnet에서 VIewModel의 아이템을 관찰해 변화가 생기면 어댑터를 갱신해 줄수 있게 작업했습니다. 위와 같이 작업할 수 있지만 Adapter를 ViewModel로 넘기거나 xml로 넘길 수도 있습니다. 데이터바인딩과 같이 MVVM을 구현하는 방법은 매우 다양합니다. 여기서 중요한 점은 얼마나 관심사를 분리하며 작업했는지 입니다.

 

 

 

아래는 위에서 예를 든 전체 코드 입니다.

 

SearchFramgnet.kt

 

더보기

 

class SearchFragment : Fragment() {

    companion object {

        fun newInstance() = SearchFragment()
    }

    private val repoAdapter by lazy {
        RepositoryAdapter().apply {
            onItemClick = {
                (requireActivity() as MainActivity).goToDetailFragment(
                    it.owner.ownerName,
                    it.repoName
                )
            }
        }
    }

    private val searchModel by lazy {
        SearchViewModel(
            Injection.provideRepoRepository(),
            compositeDisposable
        )
    }

    private val compositeDisposable = CompositeDisposable()

    private lateinit var binding: FragmentSearchBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_search, container, false)
        binding.model = searchModel

        return binding.root
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        initRecyclerView()
        initEditText()
        initObserve()
    }

    private fun initRecyclerView() {
        listSearchRepository.adapter = repoAdapter
    }

    private fun initEditText() {
        etSearch.setOnEditorActionListener(object : TextView.OnEditorActionListener {
            override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
                when (actionId) {
                    EditorInfo.IME_ACTION_SEARCH -> {
                        val query = v?.text.toString()
                        searchModel.searchRepository(requireContext(), query)
                        return true
                    }
                    else -> {
                        return false
                    }
                }
            }
        })
    }

    private fun initObserve() {
        searchModel.items.addOnPropertyChangedCallback(object :
            Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
                searchModel.items.get()?.let {
                    repoAdapter.setItems(it)
                }
            }
        })
    }

    override fun onStop() {
        compositeDisposable.dispose()
        super.onStop()
    }
}

 

 

SearchViewModel.kt

 

더보기

 

class SearchViewModel(
    private val searchRepository: RepoRepository,
    private val compositeDisposable: CompositeDisposable
) {

    //로딩 필드
    val isLoading = ObservableField(false)

    //검색 버튼 활성 필드
    val enableSearchButton = ObservableField(true)

    //에러 메시지 필드
    val errorMessage = ObservableField("")

    //검색 입력 필드
    val editSearchText = ObservableField("")

    //리포지터리 아이템 필드
    val items = ObservableField<List<RepoItem>>(emptyList())

    init {
        editSearchText.addOnPropertyChangedCallback(object :
            Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
                //사용자가 입력한 값을 관찰하고 있어 실시간으로 데이터를 받아올 수 있습니다.
                val query = editSearchText.get()
                if (query.isNullOrEmpty()) {
                    enableSearchButton.set(false)
                } else {
                    enableSearchButton.set(true)
                }
            }
        })
    }

    fun searchRepository(context: Context, query: String) {
        searchRepository.searchRepositories(query, object : BaseResponse<RepoSearchResponse> {
            override fun onSuccess(data: RepoSearchResponse) {
                items.set(data.items.map { it.mapToPresentation(context) })

                if (0 == data.totalCount) {
                    showErrorMessage(context.getString(R.string.no_search_result))
                }
            }

            override fun onFail(description: String) {
                showErrorMessage(description)
            }

            override fun onError(throwable: Throwable) {
                showErrorMessage(throwable.message ?: context.getString(R.string.unexpected_error))
            }

            override fun onLoading() {
                clearItems()
                showLoading()
                hideErrorMessage()
            }

            override fun onLoaded() {
                hideLoading()
            }
        }).also {
            compositeDisposable.add(it)
        }
    }

    private fun clearItems() {
        items.set(emptyList())
    }

    private fun showLoading() {
        isLoading.set(true)
    }

    private fun hideLoading() {
        isLoading.set(false)
    }

    private fun showErrorMessage(error: String) {
        errorMessage.set(error)
    }

    private fun hideErrorMessage() {
        errorMessage.set("")
    }
}

 

 

 

fragment_search.xml

 

더보기

 

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>

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

        <import type="android.text.TextUtils" />

        <variable
            name="model"
            type="com.example.toyproject.ui.search.SearchViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/gray9"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:id="@+id/txtTitle"
            style="@style/TextStyle.Large"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/blackjin_toy_project"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <EditText
            android:id="@+id/etSearch"
            style="@style/EditTextStyle.NormalInputField"
            android:layout_width="0dp"
            android:layout_height="?attr/actionBarSize"
            android:hint="@string/please_write_repository_name"
            android:imeOptions="actionSearch"
            android:inputType="text"
            android:text="@="
            app:layout_constraintEnd_toStartOf="@+id/btnSearch"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/txtTitle" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/btnSearch"
            style="@style/ButtonStyle.Primary"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/search"
            android:enabled="@"
            android:onClick="@{(v) -> model.searchRepository(v.getContext(), model.editSearchText.toString())}"
            app:layout_constraintBottom_toBottomOf="@+id/etSearch"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/etSearch"
            app:layout_constraintTop_toTopOf="@+id/etSearch" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/listSearchRepository"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/etSearch" />

        <ProgressBar
            android:id="@+id/pbLoading"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="@"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/etSearch"
            tools:visibility="visible" />

        <TextView
            android:id="@+id/tvMessage"
            style="@style/TextStyle.Medium"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="@"
            android:text="@"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/etSearch"
            tools:text="message"
            tools:visibility="visible" />

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

 

반응형
Comments