Share
Embed
Architecting Android and iOS apps in 2020
Inspired by some well known architecture patterns like MVVM/MVI, I set out to come up with an agnostic set of principles that would help developers build features in their app in a robust, safe and (importantly) “testable” way. At Instacart, we've started to use these principles to build features on both iOS and Android.
In this talk, we'll examine these principles, discuss the merits and see how these can be implemented with precise code examples. Having implemented this pattern for sometime now at Instacart, I'll also share some of our learnings along the way for both platforms.
Kaushik Gopal
October 26, 2019
Transcript
66 slides
-
Agenda 1 Problems building apps today Architecture - that solves
problems 2 Implementation 3 Recap 4 -
Problems building apps today * Managing UI State is hard
Debugging is hard * Testing is hard * Building on both takes time! * 1 -
Activity ViewModel SEARCH OMDB api fun onRestoreFromHistory() 2SearchMovieResult fun onAddToHistory()
2SearchHistoryResult fun onSearchMovie() : SearchMovieResult Managing UI State is hard * -
Problems building apps today * Managing UI State is hard
1 Existing architectures don’t scale with complexity !!! -
Problems building apps today * Managing UI State is hard
Debugging is hard * Testing is hard * Building on both takes time! * 1 -
Agenda 1 Problems building apps today Architecture - that solves
problems 2 Implementation 3 Recap 4 -
Agenda 1 Problems building apps today Architecture - that solves
problems 2 Guiding principles for an architecture Use MVC, MVP, MVVM, MVI or whatever your ❤ pleases ! -
Agenda 1 Problems building apps today Guiding principles for an
architecture 2 Implementation 3 Recap 4 -
Guiding principles for an architecture * * 2 Model your
UI as a single function Avoid mutating state * Test at the business level not implementation level -
Model your UI as a single function * What is
a function? -
ViewModel SEARCH fun onRestoreFromHistory() 2SearchMovieResult fun onAddToHistory() 2SearchHistoryResult fun onSearchMovie()
: SearchMovieResult Activity or View Controller -
Agenda 1 Problems building apps today Guiding principles for an
architecture 2 Implementation 3 Recap 4 -
(View)Event Activity or View Controller SEARCH input sealed class ViewEvent
{ object ViewResume : ViewEvent() data class SearchMovie( val searchedMovieTitle: String = “" ) : ViewEvent() data class AddToHistory( val searchedMovie: MovieSearchResult ) : ViewEvent() } enum ViewEvent { case viewResume case searchMovie(String) case addToHistory(MovieSearchResult) } -
ViewModel (View)Event private val viewEventSubject: PublishSubject<ViewEvent> = PublishSubject.create() Activity or
View Controller SEARCH input viewEventSubject.onNext(event) fun processInput( event: ViewEvent ) { } -
ViewModel (View)Event private val viewEventSubject: PublishSubject<ViewEvent> = PublishSubject.create() Activity or
View Controller SEARCH input fun processInput( event: ViewEvent ) { viewEventSubject.onNext(event) } func processInput( event: ViewEvent ) { viewEventSubject.onNext(event) } private let viewEventSubject: PublishSubject<ViewEvent> = .init() -
data class ViewState( val movieTitle: String = "", val moviePosterUrl:
String?, val genres: String = "", val plot: String = "", val rating: String = "", val searchHistoryList: List<MovieSearchResult> = emptyList() ) SEARCH (View)State output -
data class ViewState( val movieTitle: String = "", val moviePosterUrl:
String?, val genres: String = "", val plot: String = "", val rating: String = "", val searchHistoryList: List<MovieSearchResult> = emptyList() ) SEARCH (View)State struct ViewState: Equatable { let movieTitle: String let moviePosterUrl: String? let genres: String let plot: String let rating: String let rating22 String let searchHistoryList: [MovieSearchResult] } -
SEARCH (View)State le Activity or View Controller viewModel.viewState .subscribeOn( ConcurrentDispatchQueueScheduler(
qos: .background) ) .observeOn(MainScheduler.instance) .subscribe( onNext: { [weak self] vs in posterImageView.load( imageUrl: vs.moviePosterUrl) movieTitleView.text = vs.movieTitle genreInfoView.text = vs.genres plotSummaryView.text = vs.plot srRating1.text = vs.rating1 srRating2.text = vs.rating2 }, onError: { err in print(" we got an error \(err)") } ) .disposed(by: dbag) -
SEARCH (View)State le Activity or View Controller viewModel.viewState .subscribeOn( ConcurrentDispatchQueueScheduler(
qos: .background) ) .observeOn(MainScheduler.instance) .subscribe( onNext: { [weak self] vs in posterImageView.load( imageUrl: vs.moviePosterUrl) movieTitleView.text = vs.movieTitle genreInfoView.text = vs.genres plotSummaryView.text = vs.plot srRating1.text = vs.rating1 srRating2.text = vs.rating2 }, onError: { err in print(" we got an error \(err)") } ) .disposed(by: dbag) -
Subject<ViewEvent > Observable<ViewState> viewEventSubject .flatMap { event `a when (event)
{ is ViewResume `a { Observable.just(startingViewState) } is SearchMovie `a { doNetworkRequest(event) .map { result `a ntwkResultToViewState(result) } } `b … } -
viewEventSubject .flatMap { event `a when (event) { is ViewResume
`a { Observable.just(startingViewState) } is SearchMovie `a { doNetworkRequest(event.movieSearched) .map { movie `a ntwkResultToViewState(movie) } } `b … } Subject<ViewEvent > Observable<ViewState> -
viewEventSubject .flatMap { event `a when (event) { is ViewResumeEvent
`a { Observable.just(startingViewState) } is SearchMovieEvent `a { doNetworkRequest(event.movieSearched) .map { movie `a ntwkResultToViewState(movie) } } `b … } Subject<ViewEvent > Observable<ViewState> -
SEARCH ViewResume Full vs vs.toolbarTitle vs.searchDetails Full vs + vs.searchHistory
+ Subject<ViewEvent > Observable<ViewState> -
SEARCH ViewResume Full vs vs.toolbarTitle vs.searchDetails Full vs + vs.searchHistory
+ Nil Nil Subject<ViewEvent > Observable<ViewState> -
SEARCH ViewResume SearchMovie vs.searchDetails Full vs vs.toolbarTitle + + vs.searchHistory
Nil Subject<ViewEvent > Observable<ViewState> -
Full vs vs.toolbarTitle + + SEARCH ViewResume SearchMovie AddToHistory vs.searchDetails
vs.searchHistory Subject<ViewEvent > Observable<ViewState> -
viewEventSubject .flatMap { event `a when (event) { is ViewResumeEvent
`a { Observable.just(startingViewState) } is SearchMovieEvent `a { doNetworkRequest(event) .map { result `a ntwkResultToViewState(result) } } `b … } Subject<ViewEvent > Observable<ViewState> -
viewEventSubject .flatMap { event `a when (event) { is ViewResumeEvent
`a { Observable.just(startingViewState) } is SearchMovieEvent `a { doNetworkRequest(event) .map { result `a ntwkResultToViewState(result) } } `b … } Subject<ViewEvent > Observable<ViewState> -
viewEventSubject .flatMap { event `a when (event) { is ViewResumeEvent
`a { Observable.just(startingViewState) } is SearchMovieEvent `a { doNetworkRequest(event) .map { result `a ntwkResultToViewState(result) } } `b … } Subject<ViewEvent > Observable<ViewState> -
var prevVS = ViewState() var newVs = ntwkResultToViewState(result) prevVS.movieTitle =
newVs.movieTitle prevVS.moviePosterUrl = newVs.moviePosterUrl `b ``c prevVS Option 1 viewEventSubject .flatMap { event `a when (event) { is ViewResumeEvent `a { Observable.just(startingViewState) } is SearchMovieEvent `a { doNetworkRequest(event) .map { result `a ntwkResultToViewState(result) } } `b … } Subject<ViewEvent > Observable<ViewState> -
{ previousVS2 ViewState, (ViewState()) event : ViewEvent `a event `a
gives new view state previousVS `a take what you need here Send down (event + previousVS) .scan Option 2 Subject<ViewEvent > Observable<ViewState> -
{ previousVS2 ViewState, (ViewState()) event : ViewEvent `a event `a
gives new view state previousVS `a take what you need here Send down (event + previousVS) .scan viewEventSubject Subject<ViewEvent > Observable<ViewState> -
event `a gives new view state previousVS `a take what
you need here Send down (event + previousVS) .scan viewEventSubject { previousVS2 ViewState, event : ViewEvent `a (ViewState()) when (event) { is ViewResumeEvent `a `b… is SearchMovieEvent `a `b… `b … } } Subject<ViewEvent > Observable<ViewState> -
.scan viewEventSubject { previousVS2 ViewState, event : ViewEvent `a (ViewState())
when (event) { is ViewResumeEvent `a `b… is SearchMovieEvent `a `b… `b … } } event `a gives new view state previousVS `a take what you need here Send down (event + previousVS) Subject<ViewEvent > Observable<ViewState> -
Implementation 1 Toast notifications Alert dialogs 2 Navigation actions 3
Non-persisting UI changes 4 What about one-time ViewState changes ? -
Implementation 1 Toast notifications Alert dialogs 2 Navigation actions 3
Non-persisting UI changes 4 (View)State (View)Effects + What about one-time ViewState changes ? -
.share() (View)Result ViewEvent * State flow ViewState (View)Effects New Data
structure Private to VM -
.share() ViewState (View)Result (View)Effects ViewEvent .flatmap { event `a Observable<ViewResult>
in } viewEventSubject enum ViewResult { case viewResumeResult case searchMovieResult( movieResult: Movie loading: Bool, error: Error? ) `b … } -
viewEventSubject switch(event) { case .viewResume: return Observable .just(.viewResumeResult) case .searchMovie(let
movieTitle)2 return doNetworkRequest(movieTitle) `b other event cases … } .flatmap { event `a Observable<ViewResult> in } .share() ViewState (View)Result (View)Effects ViewEvent -
.flatmap { event `a Observable<ViewResult> in } viewEventSubject switch(event) {
`b other event cases … } .share() .share() ViewState (View)Result (View)Effects ViewEvent -
.share() .share() ViewState (View)Result (View)Effects ViewEvent private let viewResults: Observable<ViewResult>
= .flatmap { event `a Observable<ViewResult> in } viewEventSubject switch(event) { `b other event cases … } -
.share() .share() ViewState (View)Result (View)Effects private let viewResults: Observable<ViewResult> =
`b … viewResults .scan(startingViewState) { previousVS, viewResult in switch viewResult { case .screenLoadResult: return previousVS.copy() case .searchMovieResult(let movie)2 return previousVS + movie } let viewState: Observable<ViewState> = -
.share() .share() ViewState (View)Result (View)Effects private let viewResults: Observable<ViewResult> =
`b … viewResults .map { viewResult in switch viewResult { case .screenLoadResult: return .noEffect case .searchMovieResult(let movie)2 return .showNotificationToast } let viewEffects: Observable<ViewEffect> = -
.share() ViewState (View)Result (View)Effects let viewEffects: Observable<ViewEffect> = let viewState:
Observable<ViewState> = viewModel.viewState .subscribeOn( ConcurrentDispatchQueueScheduler( qos: .background) ) .observeOn(MainScheduler.instance) .subscribe( onNext: { [weak self] vs in posterImageView.load( imageUrl: vs.moviePosterUrl) movieTitleView.text = vs.movieTitle genreInfoView.text = vs.genres plotSummaryView.text = vs.plot }, onError: { err in print(" we got an error \(err)") } ) .disposed(by: dbag) Activity or View Controller -
.share() ViewState (View)Result (View)Effects let viewEffects: Observable<ViewEffect> = let viewState:
Observable<ViewState> = viewModel.viewEffects .subscribeOn( ConcurrentDispatchQueueScheduler( qos: .background) ) .observeOn(MainScheduler.instance) .subscribe( onNext: { [weak self] ve in case .showNotificationToast: self`fbanner`fshow() case .noEffect: print("no effect") }, onError: { err in print(" we got an error \(err)") } ) .disposed(by: dbag) Activity or View Controller -
Tests `b given: movie "Blade Runner 2049" exists `b when
: searching for movie "blade runner 2049" `b then : show result @Test fun onSearchingMovie_shouldSeeSearchResults() { viewModel = MovieSearchVM(mockApp, mockMovieRepo) val viewStateTester = viewModel.viewState.test() viewModel.processInput(SearchMovieEvent("blade runner 2049")) viewStateTester.assertValueAt(1) { assertThat(it.movieTitle).isEqualTo("Searching Movie``c") true } viewStateTester.assertValueAt(2) { assertThat(it.movieTitle).isEqualTo("Blade Runner 2049") assertThat(it.moviePosterUrl) .isEqualTo("https:`bmedialamz.com/images/M/MV5_V1_SX300.jpg") assertThat(it.rating1).isEqualTo("\n8.1/10 (IMDB)\n87% (RT)") true } } -
`b given: movie "Blade" exists `b when : searching for
movie "Blade" `b then : show result func test_movieBladeExists_searchingForIt_showMovieResult() { let viewModel = MovieSearchVM(FakeMovieSearchService()) let vsObserver = scheduler.createObserver( MovieSearchVM.ViewState.self ) viewModel.viewState .subscribe(vsObserver) .disposed(by: dbag) scheduler.scheduleAt(0) { viewModel.processViewEvent( event: MovieSearchVM.ViewEvent.screenLoad ) } scheduler.scheduleAt(1) { viewModel.processViewEvent( event: MovieSearchVM.ViewEvent.searchMovie("Blade")) } scheduler.start() let vs12 MovieSearchVM.ViewState = Tests -
viewModel.processViewEvent( event: MovieSearchVM.ViewEvent.screenLoad ) } scheduler.scheduleAt(1) { viewModel.processViewEvent( event: MovieSearchVM.ViewEvent.searchMovie("Blade"))
} scheduler.start() let vs12 MovieSearchVM.ViewState = vsObserver.events .filter { $0.time `v 1 } .compactMap { $0.value.element } .last! XCTAssertEqual(vs1.movieTitle, "Blade") XCTAssertEqual(vs1.genres, "Action, Horror, Sci-Fi") XCTAssertEqual( vs1.moviePosterUrl, "https:`bm.medialamz.com/images/M/V1_SX300.jpg" ) XCTAssertEqual(vs1.plot, "A halflvampire, halflmortal…") XCTAssertEqual(vs1.rating1, "IMDB : 7.1/10") XCTAssertEqual(vs1.rating2, "Rotten T2 54%") } Tests -
Resources 1 Movie Search Demo App github.com/kaushikgopal/movies-usf-ios github.com/kaushikgopal/movies-usf-android @kaushikgopal blog.kaush.co
fragmentedpodcast.com We're hiring mobile devs! tech.instacart.com #148 #151 -
Guided Q&A 1 What about frameworks like Formula/MVRx/Mobius? 2 What
about things like Navigation/Analytics? 3 Is testing and debugging really much easier? 4 Does this handle activity rotation, savedInstanceState? 5 Any disadvantages or shortcomings of this approach? 6 Is performance a problem if you have many viewState emissions? 7 What if setting a ViewState causes a ViewEvent to be triggered? 8 What if I need to emit multiple ViewEffects for one ViewEvent?