Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Efficient and Testable MVVM pattern on Android

omjoonkim
November 05, 2018

Efficient and Testable MVVM pattern on Android

Efficient and Testable MVVM pattern on Android
with CleanArchitecture, AAC, RxJava2, kotlin, Koin, Spek

2018 11/3 Naver Android tech Concert

omjoonkim

November 05, 2018
Tweet

More Decks by omjoonkim

Other Decks in Programming

Transcript

  1. ?

  2. ?

  3. Presenter codeо 1000Lineਸ ֈযо ࠄ ੸ ࠺तೠ ചݶীࢲ ઺ࠂغয ࢤࢿغח

    ௏٘ܳ ࠄ ੸ ݆ࣻ਷ ࢚క(ݯߡ ߸ࣻ)ܳ ҙܻೞ׮ ݠܻо ই౵ ࠄ ੸ View৬ Presenter੄ ࠁੌ۞ ೒ۨ੉౟ ௏٘о טযա ೖҌ೮؍ ੸ ਃҳࢎ೦☝ Business Logic ☝ ௏٘۝ ☝ ࠂ੟ࢿ ☝ ਬ૑ࠁࣻࢿ పझ౟ ਊ੉ࢿ
  4. MVVM…. - Model View ViewModel۽ ҳࢿغয ੓ח ಁఢ - ViewModel਷

    View੄ ୶࢚ച੉׮. - View৬ ViewModel਷ n:m੄ ҙ҅੉׮. - Viewח ViewModelী bindableೞ׮.
  5. Model View ViewModel۽ ҳࢿغয ੓ח ಁఢ VIEW VIEW MODEL MODEL

    DataBinding Notification Update Notification
  6. ViewModel਷ View੄ ୶࢚ച੉׮. - ViewModel਷ View੄ ࢚క৬ ೯ز੉ ୶࢚ച ػ

    Ѫ੉׮. - ViewModel਷ View੄ inputҗ output੉ ݺदغয ੓ח ੋఠಕ੉झ׮. - ݃஖ ࣽࣻೣࣻ୊ۢ э਷ ч੄ inputীח ೦࢚ э਷ ч੄ output੉ ߈ജ غযঠ ೠ׮. - ૊ ࢎਊ੗੄ ೯ز(input)ী ٮۄ ೦࢚ э਷ Ѿҗ(output)о աఋաѱ ػ׮. - ೯ਤী ٮܲ Ѿҗܳ పझ౟ೞחؘ ਊ੉ೞ׮. - output਷ View੄ ࢚క৬ Route۽ ա׋׮.
  7. View৬ ViewModel਷ n:m੄ ҙ҅੉׮. - ೞա੄ Viewо ৈ۞ ViewModelী ઑ೤

    ؼ ࣻ ੓׮. - ೞա੄ ViewModel੉ ৈ۞ Viewী ੸ਊ ؼ ࣻ ੓׮. - ੤ ࢎਊࢿ੉ ਊ੉ೞ׮.
  8. Viewח ViewModelী bindableೞ׮. - View৬ ViewModelਸ ࢲ۽ োѾ ೞח Ѫ.

    - ࢎਊ੗੄ ೯زী ੄೧ ੑ۱ਸ ߉ওਸ ٸ (View -> ViewModel) - ࢎਊ੗੄ ೯زী ٮܲ View੄ ࢚కܳ ߸҃दெঠ ೡ ٸ (ViewModel -> View) - PS. ݽٚ ۽૒਷ bindingغח द੼ী Ѿ੿ػ׮.
  9. Ӓېࢲ… VIEW VIEW MODEL MODEL DataBinding Databinding, LiveData RxJava Viewী

    ؀ೠ ੄ઓࢿ੉ ઁѢ غয
 ബҗ੸ਵ۽ ৉ೡҗ ଼੐ਸ աׂ ࣻ ੓׮ ViewModel ױةਵ۽ పझ౟о оמೞӝ ٸޙী పझ౟੄ ਊ੉ࢿ ژೠ ૐоػ׮. bindingغח द੼ী ݽٚ inputী ؀ೠ outputਸ ࢑୹ೞח ۽૒੉ ੿ ೧૑ӝ ٸޙী ѐߊ੗о ࢚కܳ ҙܻ೧ঠೞח ਤ೷ਸ ઴ৈળ׮. Databindingਸ ా೧ ݆ࣻ਷ ࠁੌ۞ ೒ۨ੉౟ ௏٘ܳ ઴ੌ ࣻ ੓׮.
  10. VIEW VIEW MODEL MODEL DataBinding LiveData UseCase Rx XML (databindingUtil)

    Activity or Fragment or something View੄ ࢚క߸ചܳ ઁ৻ೠ Router৉ೡ + ViewModelҗ Viewܳ binding ೞ ח ৉ೡਸ ࣻ೯ೠ׮. on Android!
  11. ୶о੸ਵ۽ ✨ - CleanArchitectureܳ ૑ೱ - Koinਸ ࢎਊೞৈ IOC(Inversion Of

    Control) ҳഅ - Spekਸ ࢎਊೞৈ ೯ز ઱ب Ѿҗ పझ౟ܳ ੘ࢿ.
  12. Koin? - ઁয੄ ৉੹ਸ ҳഅ ೡ ࣻ ੓ѱ ب৬઱ח Library.(DI

    x) - Kotlinਵ۽ ҳഅغয ੓׮. - рಞೠ ࢎਊ ߑߨ! - AAC ژೠ ૑ਗ✨ - ઁয੄ ৉੹ਸ Service Locator ߑधਵ۽ ҳഅೠ׮. - Runtimeীࢲ ী۞ܳ ഛੋ ೡ ࣻ ੓׮.
  13. Koin! 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" ) } }
  14. Koin! class App : Application() { override fun onCreate() {

    super.onCreate() startKoin( this, listOf(myModule) ) } }
  15. BaseViewModel abstract class BaseViewModel : ViewModel(){ protected val compositeDisposable :

    CompositeDisposable = CompositeDisposable() override fun onCleared() { super.onCleared() compositeDisposable.clear() } }
  16. SearchViewModel - input, output, state 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 )
  17. SearchViewModel - properties private val name = PublishSubject.create<String>() private val

    clickSearchButton = PublishSubject.create<Parameter>() val input = object : SearchViewModelInPuts { override fun name(name: String) = [email protected](name) override fun clickSearchButton() = [email protected](Parameter.CLICK) } private val state = MutableLiveData<SearchViewState>() private val goResultActivity = MutableLiveData<String>() val output = object : SearchViewModelOutPuts { override fun state() = state override fun goResultActivity() = goResultActivity }
  18. SearchViewModel - logic init { compositeDisposable.addAll( name.map { SearchViewState(it.isNotEmpty()) }

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

    .subscribe(state::setValue, logger::d), name.takeWhen(clickSearchButton) { _, t2 -> t2 } .subscribe(goResultActivity::setValue, logger::d) ) }
  20. SearchView - Databinding <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" />
  21. SearchView - Databinding <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" />
  22. SearchActivity class SearchActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?)

    { super.onCreate(savedInstanceState) val binding = DataBindingUtil.setContentView<ActivitySearchBinding>(this,…) binding.setLifecycleOwner(this) val viewModel = getViewModel<SearchViewModel>() binding.viewModel = viewModel viewModel.output.goResultActivity() .observe { startActivity( Intent( Intent.ACTION_VIEW, Uri.parse("githubbrowser://repos/$it") ) ) } } }
  23. SearchActivity - state binding class SearchActivity : BaseActivity() { override

    fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = DataBindingUtil.setContentView<ActivitySearchBinding>(this,…) binding.setLifecycleOwner(this) val viewModel = getViewModel<SearchViewModel>() binding.viewModel = viewModel viewModel.output.goResultActivity() .observe { startActivity( Intent( Intent.ACTION_VIEW, Uri.parse("githubbrowser://repos/$it") ) ) } } }
  24. SearchActivity - router binding class SearchActivity : BaseActivity() { override

    fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = DataBindingUtil.setContentView<ActivitySearchBinding>(this,…) binding.setLifecycleOwner(this) val viewModel = getViewModel<SearchViewModel>() binding.viewModel = viewModel viewModel.output.goResultActivity() .observe { startActivity( Intent( Intent.ACTION_VIEW, Uri.parse("githubbrowser://repos/$it") ) ) } } }
  25. MainViewModel - input, output, state 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 )
  26. MainViewModel - properties 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 }
  27. MainViewModel - logic 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) )
  28. MainViewModel - logic val error = PublishSubject.create<Throwable>() val userName =

    Observable.just(searchedUserName).share() val requestListData = userName.flatMapMaybe { getUserData.get(it).neverError(error) }.share()
  29. MainViewModel - logic 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) )
  30. MainViewModel - logic 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) )
  31. MainViewModel - logic 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) )
  32. MainViewModel - logic 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) )
  33. MainViewModel - logic 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) )
  34. MainViewModel - logic 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) )
  35. MainView - Databinding <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>
  36. MainView - Databinding <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>
  37. MainActivity val binding = DataBindingUtil.setConten….. binding.setLifecycleOwner(this) val viewModel = getViewModel<MainViewModel>

    { parametersOf(intent.data.path.substring(1)) } binding.viewModel = viewModel actionbarInit(binding.toolbar, onClickHomeButton = { viewModel.input.clickHomeButton() })
  38. MainActivity 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() } }
  39. HOW? - ࢎ੹ળ࠺ - Testܳ ਤೠ SchedulersProviderܳ ٜ݅যঠ ೠ׮. -

    Testܳ ਤೠ DummyApiServiceܳ ٜ݅যঠ ೠ׮. - Spek + LiveDataܳ э੉ పझ౟ ೞӝ ਤೠ ௏٘ ੘ࢿ. - Spekਸ ੉ਊೠ పझ౟ ௏٘ ੘ࢿ - Feature - Scenario - Given - When - Then
  40. TestSchedulerProvider class TestSchedulerProvider : SchedulersProvider { override fun io() =

    Schedulers.trampoline() override fun ui() = Schedulers.trampoline() }
  41. 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") ) ) }
  42. DI for Test val testModule = module { single(override =

    true) { TestSchedulerProvider() as SchedulersProvider } single(override = true) { TestDummyGithubBrowserService() as GithubBrowserService } } val test_module = listOf(myModule, testModule)
  43. for Spek + LiveData 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) }
  44. 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") {…} })
  45. Scenario1 Scenario("ਬ੷о ചݶী ٜযয়ݶ Ѩ࢝ೠ ਬ੷੄ ೐۽೙,੷੢ࣗ ؘ੉ఠо ੿࢚੸ਵ۽ ࠁৈঠ

    ೠ׮") { Given("Ѩ࢝ೞ۰ח ਬ੷੄ ੉ܴ਷ omjoonkim੉׮"){ userName = "omjoonkim" } Then("ചݶী Ѩ࢝ೠ ਬ੷੄ ؘ੉ఠо ੿࢚੸ਵ۽ աఋդ׮") { assertEquals( getUserData.get(userName).blockingGet(), viewModel.output.refreshListData().value ) } }
  46. Scenario2 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 ) } }
  47. Scenario3 Scenario("കߡౡਸ ௿ܼೞݶ ചݶ੉ ੿࢚੸ਵ۽ ઙܐغযঠ ೠ׮.") { When("കߡౡਸ ௿ܼ

    ೮ਸ ٸ") { viewModel.input.clickHomeButton() } Then("ചݶ੉ ੿࢚੸ਵ۽ ઙܐ ػ׮.") { assertEquals( Unit, viewModel.output.finish().value ) } }
  48. Dagger2 vs Koin - Heavy vs light - Dependency Injection

    vs ServiceLocator - CompileTime vs RunTime https://twitter.com/jakewharton/status/908419644742098944
  49. Spekҗ Koin੄ ഐജࢿ - Spek + Koinਸ ࢎਊೞ۰ݶ ୶о੸ਵ۽ ੘স೧ঠ

    ೞח ௏ٜ٘੉ ੓׮. - Spek੄ ҳز ߑधҗ Koinਸ ࢎਊೞח ߑध੉ ࢲ۽ ୽ج غӝ ٸޙ https://github.com/InsertKoinIO/koin/pull/107
  50. ѐࢶ੄ ৈ૑ + ইए਍ ੼ - Datbinding੉ kotlinী 100% ഐജغ૑

    ঋח׮. - ۈ׮ ೣࣻܳ xmlীࢲ чਵ۽ ૑੿೧઴ ࣻ হ׮. - Router - Presentation module ܻ࠙