Share
Embed
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
-
Efficient and Testable MVVM pattern Session 2. Android archietecture @omjoonkim
with using AAC, Rx, Koin -
MVC MVP MVVM MVI VIPER etc… with CleanArchitecture with RFP(rxJava2)
with AAC, Koin with Spek -
Presenter codeо 1000Lineਸ ֈযо ࠄ ࠺तೠ ചݶীࢲ ࠂغয ࢤࢿغח
٘ܳ ࠄ ݆ࣻ ࢚క(ݯߡ ߸ࣻ)ܳ ҙܻೞ ݠܻо ই ࠄ View৬ Presenter ࠁੌ۞ ۨ ٘о טযա ೖҌ೮؍ ਃҳࢎ೦☝ Business Logic ☝ ٘ ☝ ࠂࢿ ☝ ਬࠁࣻࢿ పझ ਊࢿ -
MVVM…. - Model View ViewModel۽ ҳࢿغয ח ಁఢ - ViewModel
View ୶࢚ച. - View৬ ViewModel n:m ҙ҅. - Viewח ViewModelী bindableೞ. -
Model View ViewModel۽ ҳࢿغয ח ಁఢ VIEW VIEW MODEL MODEL
DataBinding Notification Update Notification -
ViewModel View ୶࢚ച. - ViewModel View ࢚క৬ ೯ز ୶࢚ച ػ
Ѫ. - ViewModel View inputҗ output ݺदغয ח ੋఠಕझ. - ݃ ࣽࣻೣࣻۢ э ч inputীח ೦࢚ э ч output ߈ജ غযঠ ೠ. - ࢎਊ ೯ز(input)ী ٮۄ ೦࢚ э Ѿҗ(output)о աఋաѱ ػ. - ೯ਤী ٮܲ Ѿҗܳ పझೞחؘ ਊೞ. - output View ࢚క৬ Route۽ ա. -
View৬ ViewModel n:m ҙ҅. - ೞա Viewо ৈ۞ ViewModelী ઑ
ؼ ࣻ . - ೞա ViewModel ৈ۞ Viewী ਊ ؼ ࣻ . - ࢎਊࢿ ਊೞ. -
Viewח ViewModelী bindableೞ. - View৬ ViewModelਸ ࢲ۽ োѾ ೞח Ѫ.
- ࢎਊ ೯زী ೧ ੑ۱ਸ ߉ওਸ ٸ (View -> ViewModel) - ࢎਊ ೯زী ٮܲ View ࢚కܳ ߸҃दெঠ ೡ ٸ (ViewModel -> View) - PS. ݽٚ ۽ bindingغח दী Ѿػ. -
Ӓېࢲ… VIEW VIEW MODEL MODEL DataBinding Databinding, LiveData RxJava Viewী
ೠ ઓࢿ ઁѢ غয ബҗਵ۽ ೡҗ ଼ਸ աׂ ࣻ ViewModel ױةਵ۽ పझо оמೞӝ ٸޙী పझ ਊࢿ ژೠ ૐоػ. bindingغח दী ݽٚ inputী ೠ outputਸ ೞח ۽ ೧ӝ ٸޙী ѐߊо ࢚కܳ ҙܻ೧ঠೞח ਤਸ ৈળ. Databindingਸ ా೧ ݆ࣻ ࠁੌ۞ ۨ ٘ܳ ੌ ࣻ . -
VIEW VIEW MODEL MODEL DataBinding LiveData UseCase Rx XML (databindingUtil)
Activity or Fragment or something on Android! -
VIEW VIEW MODEL MODEL DataBinding LiveData UseCase Rx XML (databindingUtil)
Activity or Fragment or something View ࢚క߸ചܳ ઁ৻ೠ Routerೡ + ViewModelҗ Viewܳ binding ೞ ח ೡਸ ࣻ೯ೠ. on Android! -
୶оਵ۽ ✨ - CleanArchitectureܳ ೱ - Koinਸ ࢎਊೞৈ IOC(Inversion Of
Control) ҳഅ - Spekਸ ࢎਊೞৈ ೯ز ب Ѿҗ పझܳ ࢿ. -
Koin? - ઁয ਸ ҳഅ ೡ ࣻ ѱ ب৬ח Library.(DI
x) - Kotlinਵ۽ ҳഅغয . - рಞೠ ࢎਊ ߑߨ! - AAC ژೠ ਗ✨ - ઁয ਸ Service Locator ߑधਵ۽ ҳഅೠ. - Runtimeীࢲ ী۞ܳ ഛੋ ೡ ࣻ . -
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" ) } } -
Koin! class App : Application() { override fun onCreate() {
super.onCreate() startKoin( this, listOf(myModule) ) } } -
BaseViewModel abstract class BaseViewModel : ViewModel(){ protected val compositeDisposable :
CompositeDisposable = CompositeDisposable() override fun onCleared() { super.onCleared() compositeDisposable.clear() } } -
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 ) -
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 } -
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) ) } -
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) ) } -
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" /> -
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" /> -
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") ) ) } } } -
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") ) ) } } } -
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") ) ) } } } -
MainViewModel - constructor class MainViewModel( searchedUserName: String, private val getUserData:
GetUserData, logger: Logger ) : BaseViewModel() -
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 ) -
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 } -
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) ) -
MainViewModel - logic val error = PublishSubject.create<Throwable>() val userName =
Observable.just(searchedUserName).share() val requestListData = userName.flatMapMaybe { getUserData.get(it).neverError(error) }.share() -
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) ) -
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) ) -
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) ) -
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) ) -
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) ) -
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) ) -
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> -
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> -
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() }) -
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() } } -
HOW? - ࢎળ࠺ - Testܳ ਤೠ SchedulersProviderܳ ٜ݅যঠ ೠ. -
Testܳ ਤೠ DummyApiServiceܳ ٜ݅যঠ ೠ. - Spek + LiveDataܳ э పझ ೞӝ ਤೠ ٘ ࢿ. - Spekਸ ਊೠ పझ ٘ ࢿ - Feature - Scenario - Given - When - Then -
TestSchedulerProvider class TestSchedulerProvider : SchedulersProvider { override fun io() =
Schedulers.trampoline() override fun ui() = Schedulers.trampoline() } -
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") ) ) } -
DI for Test val testModule = module { single(override =
true) { TestSchedulerProvider() as SchedulersProvider } single(override = true) { TestDummyGithubBrowserService() as GithubBrowserService } } val test_module = listOf(myModule, testModule) -
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) } -
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") {…} }) -
Scenario1 Scenario("ਬо ചݶী ٜযয়ݶ Ѩ࢝ೠ ਬ ۽,ࣗ ؘఠо ࢚ਵ۽ ࠁৈঠ
ೠ") { Given("Ѩ࢝ೞ۰ח ਬ ܴ omjoonkim"){ userName = "omjoonkim" } Then("ചݶী Ѩ࢝ೠ ਬ ؘఠо ࢚ਵ۽ աఋդ") { assertEquals( getUserData.get(userName).blockingGet(), viewModel.output.refreshListData().value ) } } -
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 ) } } -
Scenario3 Scenario("കߡౡਸ ܼೞݶ ചݶ ࢚ਵ۽ ઙܐغযঠ ೠ.") { When("കߡౡਸ ܼ
೮ਸ ٸ") { viewModel.input.clickHomeButton() } Then("ചݶ ࢚ਵ۽ ઙܐ ػ.") { assertEquals( Unit, viewModel.output.finish().value ) } } -
Dagger2 vs Koin - Heavy vs light - Dependency Injection
vs ServiceLocator - CompileTime vs RunTime https://twitter.com/jakewharton/status/908419644742098944 -
Spekҗ Koin ഐജࢿ - Spek + Koinਸ ࢎਊೞ۰ݶ ୶оਵ۽ স೧ঠ
ೞח ٜ٘ . - Spek ҳز ߑधҗ Koinਸ ࢎਊೞח ߑध ࢲ۽ ج غӝ ٸޙ https://github.com/InsertKoinIO/koin/pull/107 -
ѐࢶ ৈ + ইए - Datbinding kotlinী 100% ഐജغ
ঋח. - ۈ ೣࣻܳ xmlীࢲ чਵ۽ ೧ ࣻ হ. - Router - Presentation module ܻ࠙