Naver Tech Concert Day-2

02. Efficient and Testable MVVM pattern

  • 발표자 : 김범준 (레이니스트 / 안드로이드 개발)

  • 동영상 : https://tv.naver.com/v/4635548/list/272653

  • 슬라이드 : https://www.slideshare.net/NaverEngineering/22efficient-and-testable-mvvm-pattern

  • 세션설명 : Koin으로 DI를 하고 AAC, Rx를 조합한 MVVM 패턴에 대하여 이야기 하고자 합니다. 어째서 효율적인지 Testable한지를 함께 고민 해 보고 더 나은 구조를 향한 이야기를 나누어 보았으면 합니다.

  • 목차 :

    1. 발표 동기
    
    2. AAC를 소개(나온 배경등)
    
    3. MVVM을 소개(livedata,ViewModel과 엮으며)
    
    4. Rx를 소개 (KickStarter를 참고하여)
    
    5. Koin을 소개(DI를 간단하게 설명하며)
    
    6. 위의 모든 것을 조합 한 예제 설명
    
    7. 효율적인 부분 고민이 되는 지점등 같이 이야기해볼만 한 부분들을 화두로 던져 소통
    
    8. 테스트코드 또한 위와같이 진행
    
    9. 마무리.
    

Android 코드 아키텍쳐

  • MVC
  • MVP
  • MVVM
    • with Clean Architecture
    • with RFP(rxJava2)
    • with AAC, Koin
    • with Spek
  • MVI
  • VIPER
  • etc...

MVVM

with CleanArchitecture

with RFP (rxJava2)

with AAC, Koin

with Spek

with에 적힌 라이브러리, 프레임워크는 간단히 설명하기로 함.

why MVVM?

MVP로 충분히 잘 구현해도 서비스 운영을 하면서 아래의 문제들이 생김.

요구사항, 비즈니스 로직 늘어남 -> 코드량, 복잡성 높아짐 -> 유지보수성, 테스트 용이성 하락

what MVVM?

(MVVM에 대한 개인적인 견해이므로 다른 의견이 있을 수도 있음.)

  • ModelViewViewModel로 구성되어 있는 패턴
  • ViewModel은 View의 추상화
  • View와 ViewModel은 n:m 의 관계
  • View는 ViewModel에 bindable함

ViewModel은 View의 추상화

  • ViewModel은 View의 상태와 행동이 추상화 된 것
  • ViewModel은 View의 input과 output이 명시되어 있는 인터페이스 (같은 input 엔 항상 같은 output 이라 테스트에 용이함)
  • output은 View의 상태와 Rooute로 나뉨

View와 ViewModel은 n:m 의 관계

  • 하나의 View가 여러 ViewModel에 조합 가능
  • 하나의 ViewModel이 여러 View에 적용 가능
  • 재 사용성이 용이함.

View는 ViewModel에 bindable함

  • 사용자 행동에 의해 입력 받았을 때 (View -> ViewModel)
  • 사용자 행동에 따른 View의 상태를 변경시켜야 할 때 (ViewModel -> View)
  • 모든 로직은 binding되는 시점에 결정됨.

장점

View에 대한 의존성이 제거되어 효과적으로 역할과 책임을 나눌 수 있음.

ViewModel 단독으로 테스트가 가능함으로 테스트 용이성 증가.

binding 되는 시점에 input 대비 output을 산출하는 로직이 정해지기 때문에 개발자가 상태 관리해야 하는 위험 줄여줌.

Databinding을 통해 보일러 플레이트 코드 줄일 수 있음.

How MVVM?

발표자가 생각한 android에서 MVVM

LiveData는 라이프싸이클 처리 등의 문제로 사용.

ViewModel 윗단 Model단부터는 Clean Architecher가 도입함.

Rx로 비동기 처리함.

Activity/Fragment/something은 View의 상태 변화를 제외한 Router역할+ViewModel과 View를 binding하는 역할 수행함.

추가적으로

  • Clean Architecture 지향
  • Koin을 사용하여 IOC(Inversion Of Control) 구현
  • Spek을 사용하여 행동 주도 결과 테스트 작성

예제 코드 : https://github.com/omjoonkim/GitHubBrowserApp

  • Github 이름을 입력하여 repository 리스트를 보여주는 테스트 앱

package 구조

Clean Architecture 스럽게

  • app
  • data
  • domain
  • remote

원래 UI와 Presentation 레이어가 나뉘어서 의존성을 주입해야 하지만 AAC의 ViewModel을 사용함으로 Android Framework에 의존성이 생길 수 밖에 없어서 App은 두 레이어에 걸치게 됨.

의존성을 바깥에서 주입한다는 것은 (의존성의 역전)

  • Domain은 Data가 어떤 코드인지 모름, Data는 Remote가 어떤 코드인지 모름
  • Domain은 Presentation이 어떤 코드인지 모름, Presentation은 UI가 어떤 코드인지 모름

Koin

Koin?

  • 제어의 역전을 구현할 수 있게 도와주는 Library. (DI가 아님)

  • Kotlin으로 구현되어 있음.

  • 간편한 사용 방법. (AAC도 지원)

  • 제어의 역전을 Service Locator 방식으로 구현함. (Runtime에서 에러 확인 가능)

    (Dagger같은 경우는 컴파일 시점에 에러 확인 가능)

app_module.kt

val myModule: Module = module {
    viewModel { (id: String) -> MainViewModel(id, get(), get()) }
    viewModel { SearchViewModel(get()) }

    //app
    single { Logger() }
    single { AppSchedulerProvider() as SchedulersProvider }

    //domain
    single { GetUserData(get(), get()) }

    //data
    single { GithubBrowserDataSource(get()) as GitHubBrowserRepository }

    //remote
    single { GithubBrowserRemoteImpl(get(), get(), get()) as GithubBrowserRemote }
    single { RepoEntityMapper() }
    single { UserEntityMapper() }
    single {
        GithubBrowserServiceFactory.makeGithubBrowserService(
            BuildConfig.DEBUG,
            "https://api.github.com"
        )
    }
}

App.kt

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin(
            this,
            listOf(myModule)
        )
    }
}

검색화면의 경우

  • SearchView의 Input : name, clickSearchButton
  • SearchView의 Output (STATE) : enableSearchButton
  • SearchView의 Output (ROUTER) : goResultActivity

SearchView

    <data>
        <import type="android.view.View"/>
        <variable
            name="viewModel"
            type="com.omjoonkim.app.githubBrowserApp.viewmodel.SearchViewModel" />
    </data>
...
        <EditText
            android:id="@+id/editText"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:onTextChanged='@{(s,start,end,before) -> viewModel.input.name(s.toString ?? "")}'
            />
        <Button
            android:id="@+id/button_search"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:enabled="@{viewModel.output.state().enableSearchButton}"
            android:onClick="@{(v) -> viewModel.input.clickSearchButton()}"
            android:text="search"
            />

SearchViewModel

class SearchViewModel(logger: Logger) : BaseViewModel() {    
    private val name = PublishSubject.create<String>()
    private val clickSearchButton = PublishSubject.create<Parameter>()
    val input = object : SearchViewModelInPuts {
        override fun name(name: String) =
            this@SearchViewModel.name.onNext(name)
        override fun clickSearchButton() =
            this@SearchViewModel.clickSearchButton.onNext(Parameter.CLICK)
    }
    private val state = MutableLiveData<SearchViewState>()
    private val goResultActivity = MutableLiveData<String>()
    val output = object : SearchViewModelOutPuts {
        override fun state() = state
        override fun goResultActivity() = goResultActivity
    }

    init {
        compositeDisposable.addAll(
            name.map { SearchViewState(it.isNotEmpty()) }
                .subscribe(state::setValue, logger::d),
            name.takeWhen(clickSearchButton) { _, t2 -> t2 }
                .subscribe(goResultActivity::setValue, logger::d)
        )
    }    
}

interface SearchViewModelInPuts : Input {
    fun name(name: String)
    fun clickSearchButton()
}
interface SearchViewModelOutPuts : Output {
    fun state(): LiveData<SearchViewState>
    fun goResultActivity(): LiveData<String>
}
data class SearchViewState(
    val enableSearchButton: Boolean
)
import androidx.lifecycle.ViewModel
...
abstract class BaseViewModel : ViewModel(){
    protected val compositeDisposable : CompositeDisposable = CompositeDisposable()
    override fun onCleared() {
        super.onCleared()
        compositeDisposable.clear()
    }
}

SearchActivity

class SearchActivity : BaseActivity() {
    private val keyboardController by lazy { getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = DataBindingUtil.setContentView<ActivitySearchBinding>(this, R.layout.activity_search)
        binding.setLifecycleOwner(this)
        actionbarInit(binding.toolbar, isEnableNavi = false)

        val viewModel = getViewModel<SearchViewModel>()
        binding.viewModel = viewModel

        viewModel.output.goResultActivity()
            .observe {                keyboardController.hideSoftInputFromWindow(binding.editText.windowToken, 0)
                startActivity(
                    Intent(
                        Intent.ACTION_VIEW,
                        Uri.parse("githubbrowser://repos/$it")
                    )
                )
            }
    }
}

결과화면의 경우

  • ResultView의 Input : clickHomeButton, clickUser
  • ResultView의 Output(STATE) : title, showLoading
  • ResultView의 Output(ROUTER) : refreshListData, finish, showErrorToast, goProfileActivity

MainViewModel

class MainViewModel(
    searchedUserName: String,
    private val getUserData: GetUserData,
    logger: Logger
) : BaseViewModel() {
    private val clickUser = PublishSubject.create<User>()
    private val clickHomeButton = PublishSubject.create<Parameter>()
    val input: MainViewModelInputs = object : MainViewModelInputs {
        override fun clickUser(user: User) = clickUser.onNext(user)
        override fun clickHomeButton() = clickHomeButton.onNext(Parameter.CLICK)
    }

    private val state = MutableLiveData<MainViewState>()
    private val refreshListData = MutableLiveData<Pair<User, List<Repo>>>()
    private val showErrorToast = MutableLiveData<String>()
    private val goProfileActivity = MutableLiveData<String>()
    private val finish = MutableLiveData<Unit>()
    val output = object : MainViewModelOutPuts {
        override fun state() = state
        override fun refreshListData() = refreshListData
        override fun showErrorToast() = showErrorToast
        override fun goProfileActivity() = goProfileActivity
        override fun finish() = finish
    }

    init {
        val error = PublishSubject.create<Throwable>()
        val userName = Observable.just(searchedUserName).share()
        val requestListData = userName.flatMapMaybe {
            getUserData.get(it).neverError(error)
        }.share()
        compositeDisposable.addAll(
            Observables
                .combineLatest(
                    Observable.merge(
                        requestListData.map { false },
                        error.map { false }
                    ).startWith(true),
                    userName,
                    ::MainViewState
                ).subscribe(state::setValue, logger::d),
            requestListData.subscribe(refreshListData::setValue, logger::d),
            error.map {
                if (it is Error)
                    it.errorText
                else UnExpected.errorText
            }.subscribe(showErrorToast::setValue, logger::d),
            clickUser.map { it.name }.subscribe(goProfileActivity::setValue, logger::d),
            clickHomeButton.subscribe(finish::call, logger::d)
        )
    }
}

interface MainViewModelInputs : Input {
    fun clickUser(user: User)
    fun clickHomeButton()
}
interface MainViewModelOutPuts : Output {
    fun state(): LiveData<MainViewState>
    fun refreshListData(): LiveData<Pair<User, List<Repo>>>
    fun showErrorToast(): LiveData<String>
    fun goProfileActivity(): LiveData<String>
    fun finish(): LiveData<Unit>
}
data class MainViewState(
    val showLoading: Boolean,
    val title: String
)

MainView

    <data>
        <import type="android.view.View"/>
        <variable
            name="viewModel"
            type="com.omjoonkim.app.githubBrowserApp.viewmodel.MainViewModel" />
    </data>
...
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="@{viewModel.output.state().title}" />
...
        <FrameLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:visibility="@{viewModel.output.state().showLoading ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/appBar" >
            <ProgressBar
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center" />
        </FrameLayout>

MainActivity

...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
        binding.setLifecycleOwner(this)

        val viewModel = getViewModel<MainViewModel> {
            parametersOf(intent.data.path.substring(1))
        }
        binding.viewModel = viewModel

        actionbarInit(binding.toolbar, onClickHomeButton = {
            viewModel.input.clickHomeButton()
        })

        with(viewModel.output) {
            refreshListData().observe { (user, repos) ->
                binding.recyclerView.adapter = MainListAdapter(
                    user,
                    repos,
                    viewModel.input::clickUser
                )
            }
            showErrorToast().observe { showToast(it) }
            goProfileActivity().observe {
                startActivity(
                    Intent(
                        Intent.ACTION_VIEW,
                        Uri.parse("githubbrowser://repos/$it")
                    )
                )
            }
            finish().observe {
                onBackPressed()
            }
        }
    }
...

Test

사전준비

  • SchedulersProvider 생성

    TestSchedulersProvier

    class TestSchedulerProvider : SchedulersProvider {
        override fun io() = Schedulers.trampoline()
    
        override fun ui() = Schedulers.trampoline()
    }
  • DummyApiService 생성

    TestDummyGithubBrowserService

    class TestDummyGithubBrowserService : GithubBrowserService {
        override fun getUserInfo(userName: String): Single<UserModel> =
            Single.just(
                UserModel("omjoonkim", "")
            )
        override fun getUserRepos(userName: String): Single<List<RepoModel>> =
            Single.just(
                listOf(
                    RepoModel("repo1", "repo1 description", "1"),
                    RepoModel("repo2", "repo2 description", "2"),
                    RepoModel("repo3", "repo3 description", "3")
                )
            )
    }
  • Spek + LiveData를 같이 테스트하기 위한 코드 작성

    (JUnit이 불가해 동작을 위한 코드 필요함)

        beforeEachTest {
            ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
                override fun executeOnDiskIO(runnable: Runnable) {
                    runnable.run()
                }
                override fun isMainThread(): Boolean {
                    return true
                }
                override fun postToMainThread(runnable: Runnable) {
                    runnable.run()
                }
            })
        }
        afterEachTest {
            ArchTaskExecutor.getInstance().setDelegate(null)
        }

    MainViewModelSpec

    object MainViewModelSpec : KoinSpek({
        beforeEachTest { ... }
        afterEachTest { ... }
    
        lateinit var userName: String
        val viewModel: MainViewModel by inject { parametersOf(userName) }
        val getUserData: GetUserData by inject()
    
        Feature("MainViewModel spec") {
            Scenario("유저가 화면에 들어오면 검색한 유저의 프로필,저장소 데이터가 정상적으로 보여야 한다") {
                Given("검색하려는 유저의 이름은 omjoonkim이다"){
                    userName = "omjoonkim"
                }
                Then("화면에 검색한 유저의 데이터가 정상적으로 나타난다") {
                    assertEquals(
                        getUserData.get(userName).blockingGet(),
                        viewModel.output.refreshListData().value
                    )
                }
            }
            Scenario("유저 프로필을 클릭하면 유저의 프로필 화면으로 이동되어야 한다") {
                When("프로필을 클릭 했을 때") {
                    viewModel.input.clickUser(
                        viewModel.output.refreshListData().value?.first
                            ?: throw IllegalStateException()
                    )
                }
                Then("해당 유저의 프로필 화면으로 이동 된다") {
                    assertEquals(
                        viewModel.output.refreshListData().value?.first?.name
                            ?: throw IllegalStateException(),
                        viewModel.output.goProfileActivity().value
                    )
                }
            }
            Scenario("홈버튼을 클릭하면 화면이 정상적으로 종료되어야 한다.") {
                When("홈버튼을 클릭 했을 때") {
                    viewModel.input.clickHomeButton()
                }
                Then("화면이 정상적으로 종료 된다.") {
                    assertEquals(
                        Unit,
                        viewModel.output.finish().value
                    )
                }
            }
        }
    })

  • DI for Test

    test_modules

    val testModule = module {
        single(override = true) {
            TestSchedulerProvider() as SchedulersProvider
        }
        single(override = true) {
            TestDummyGithubBrowserService() as GithubBrowserService
        }
    }
    
    val test_module = listOf(myModule, testModule)

Spek을 이용한 테스트 코드 작성

  • Feature
  • Scenario
  • Given
  • When
  • Then

More... + TMI

Dagger2 vs Koin

  • Heavy vs light
  • Dependency Injection vs ServiceLocator
  • CompileTime vs RunTime

Spek과 Koin의 호환성

  • Spek + Koin을 사용하려면 추가적으로 작업해야 하는 코드들 있음.

    (Spek 구동방식과 Koin을 사용하는 방식이 서로 충돌되기 때문)

개선의 여지 + 아쉬운 점

  • Databinding이 kotlin에 100% 호환되지 않음 (람다 함수를 xml에서 값으로 지정해줄 수 없음)
  • Router
  • Presentation module 분리

Q & A

Q : MVVM 적용시 피곤한 점은?

A : 기존 뱅크샐러드의 경우 MVP 베이스임. 그래서 MVP에 Koin 추가나 코드 수정해야 했음.

​ 팀원 협의에 대한 부분. Rx나 MVVM 러닝커브가 높은 점.

Q : 클린 아키텍쳐면 presentation, domain, entity, data 4가지 레이어 정의를 보면 presentation이 ui에 해당되는데 왜 presentation에 data와 같은 레벨로 하고 ui는 remote로 한건지?

A : ui가 remote라기 보다는 ui와 remote는 서버에서 데이터를 받아오는가장 바깥 레이어로 생각하면 됨.

Q : Koin의 runtime error가 compile time error 대비 장점은?

A : Dagger는 적용하기 어려웠음. 선수지식과 많은 코드가 필요했음. Compile error시 어디서 에러가 나는지 찾아야 했음.

​ Koin을 하면서 코드 스타일로 의존성 주입을 하면서 에러 찾기가 편함.

​ 복잡한 서비스의 경우는 Dagger, 심플한 경우 Koin이 괜찮아 보였음.

Q : TDD가 아닌 BDD를 한 이유는?

A : TDD와 BDD의 가장 큰 차이는 구현 대신 행동 즉 행위를 테스트하는 것임. Functional Programming 관점에서 행동 기반이 맞다고 생각했음.

Q : Model의 수에 따라 Mapper의 수도 지속적으로 증가하는데, Mapper가 특별한 경우가 아닐 땐 1:1로 매핑되고 Koin으로 바인딩 해주는 것들이 보일러 플레이트로 느껴짐. 이런 코드를 좀 더 줄일만한 아이디어는?

A : 진정한 클린 아키텍쳐라면 레이어 별로 테스트가 가능해야 하고, Mapper도 테스트되어야 함. 똑같은 input이 들어갈 경우 똑같은 entity를 내보내주는 부분이 확인 되어야 함. 그것 때문에 인터페이스를 만들고 개선의 여지가 있다고 봄. 모델에 단순히 컨버팅해주는 코드들이 많아 필요 없다거나 확장함수로 처리하는 경우도 있지만 제대로 하겠다면 매퍼를 만드는게 필요하다 봄.

Q : 개발 일정을 단축시킨다거나 안드로이드 인력을 줄였다거나 하는 경우는?

A : input과 output을 먼저 정의하고 ViewModel을 View를 짜고 그 다음에 binding 코드를 짜는 방식이 사고의 흐름에 맞게 개발한다고 봄. 그래서 빠르게 개발한다고 보지만, 선수지식과 경험이 필요한 부분이라 경우에 따라 다를 것으로 봄.

Q : input & output 등을 인터페이스로 구현하고 ViewState 모델링한 부분은 MVVM과 별개로 적용한 것인지?

A : 고민했던 부분. output 자체를 state로 나누는 것과 아예 router명을 명시해서 나누는 것도 고민했음. 개념적으로 같고 코드상의 차이로 보고, 모든 결과에 state라는 것과 그 외에는 router 코드로 판단하기로 함. MVVM과 별개로 봐도 될 것으로 봄.

완벽한 아키텍처, 완벽한 패턴 그리고 정답은 없다고 봄.

각각의 프레임워크, 플랫폼, 서비스 그리고 어떤 사람들이 어떤 컨벤션을 가지고 개발하는지에 따라 달라진다고 봄.

어떻게 할지에 고민보다는 왜 이렇게 하는지에 대한 이유만 명확하게 생각하고 논의하면 될 것으로 봄.

'IT > 행사' 카테고리의 다른 글

[행사] Naver Tech Concert Day-2 04  (0) 2019.01.14
[행사] Naver Tech Concert Day-2 03  (0) 2019.01.14
[행사] Naver Tech Concert Day-2 01  (0) 2019.01.14
[행사] Microsoft Azure Everywhere  (0) 2019.01.11
[행사] Naver Tech Concert Day-1 06  (0) 2019.01.07

+ Recent posts