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

Efficient and Testable MVVM pattern on Android

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →
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

Transcript

87 slides
  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 ܻ࠙

More Decks by omjoonkim

Other Decks in Programming