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

Architecting Android and iOS apps in 2020

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
Tweet

More Decks by Kaushik Gopal

Other Decks in Programming

Transcript

  1. Problems building apps today * Managing UI State is hard

    Debugging is hard * Testing is hard * Building on both takes time! * 1
  2. Activity ViewModel SEARCH OMDB api fun onRestoreFromHistory() 2SearchMovieResult fun onAddToHistory()

    2SearchHistoryResult fun onSearchMovie() : SearchMovieResult Managing UI State is hard *
  3. Problems building apps today * Managing UI State is hard

    1 Existing architectures don’t scale with complexity !!!
  4. Problems building apps today * Managing UI State is hard

    Debugging is hard * Testing is hard * Building on both takes time! * 1
  5. 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 !
  6. Agenda 1 Problems building apps today Guiding principles for an

    architecture 2 Implementation 3 Recap 4
  7. Guiding principles for an architecture * * 2 Model your

    UI as a single function Avoid mutating state * Test at the business level not implementation level
  8. Agenda 1 Problems building apps today Guiding principles for an

    architecture 2 Implementation 3 Recap 4
  9. (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) }
  10. ViewModel (View)Event private val viewEventSubject: PublishSubject<ViewEvent> = PublishSubject.create() Activity or

    View Controller SEARCH input viewEventSubject.onNext(event) fun processInput( event: ViewEvent ) { }
  11. 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()
  12. 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
  13. 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] }
  14. 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)
  15. 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)
  16. 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 … }
  17. 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>
  18. 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>
  19. Full vs vs.toolbarTitle + + SEARCH ViewResume SearchMovie AddToHistory vs.searchDetails

    vs.searchHistory Subject<ViewEvent > Observable<ViewState>
  20. 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>
  21. 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>
  22. 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>
  23. 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>
  24. { 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>
  25. { 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>
  26. 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>
  27. .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>
  28. Implementation 1 Toast notifications Alert dialogs 2 Navigation actions 3

    Non-persisting UI changes 4 What about one-time ViewState changes ?
  29. 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 ?
  30. .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 … }
  31. 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
  32. .flatmap { event `a Observable<ViewResult> in } viewEventSubject switch(event) {

    `b other event cases … } .share() .share() ViewState (View)Result (View)Effects ViewEvent
  33. .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 … }
  34. .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> =
  35. .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> =
  36. .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
  37. .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
  38. 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 } }
  39. `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
  40. 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
  41. 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?