Pro Yearly is on sale from $80 to $50! »

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.

A487b8723907637cb1af973bc5957bb4?s=128

Kaushik Gopal

October 26, 2019
Tweet

Transcript

  1. Architecting Android and iOS apps in 2020

  2. Agenda 1 Problems building apps today Architecture - that solves

    problems 2 Implementation 3 Recap 4
  3. Problems building apps today * Managing UI State is hard

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

    2SearchHistoryResult fun onSearchMovie() : SearchMovieResult Managing UI State is hard *
  5. DashboardActivity DashboardViewModel Managing UI State is hard *

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

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

    Debugging is hard * Testing is hard * Building on both takes time! * 1
  8. Agenda 1 Problems building apps today Architecture - that solves

    problems 2 Implementation 3 Recap 4
  9. 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 !
  10. Agenda 1 Problems building apps today Guiding principles for an

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

    UI as a single function Avoid mutating state * Test at the business level not implementation level
  12. Model your UI as a single function * What is

    a function?
  13. ViewModel SEARCH fun onRestoreFromHistory() 2SearchMovieResult fun onAddToHistory() 2SearchHistoryResult fun onSearchMovie()

    : SearchMovieResult Activity or View Controller
  14. ViewModel SEARCH Activity or View Controller input output Modeling UI

    as a function
  15. Agenda 1 Problems building apps today Guiding principles for an

    architecture 2 Implementation 3 Recap 4
  16. Implementation What is a function?

  17. ViewModel Activity or View Controller SEARCH input (View)Event

  18. (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) }
  19. ViewModel (View)Event private val viewEventSubject: PublishSubject<ViewEvent> = PublishSubject.create() Activity or

    View Controller SEARCH input viewEventSubject.onNext(event) fun processInput( event: ViewEvent ) { }
  20. 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()
  21. SEARCH (View)State output ViewModel

  22. 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
  23. 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] }
  24. SEARCH (View)State output ViewModel let viewState: Observable<ViewState>

  25. 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)
  26. 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)
  27. ViewModel Activity or View Controller SEARCH input: ViewEvent

  28. ViewModel Activity or View Controller SEARCH output: ViewState

  29. ViewModel Activity or View Controller SEARCH Subject<ViewEvent > Observable<ViewState>

  30. 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 … }
  31. 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>
  32. 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>
  33. SEARCH ViewResume Full vs vs.toolbarTitle vs.searchDetails Full vs + vs.searchHistory

    + Subject<ViewEvent > Observable<ViewState>
  34. SEARCH ViewResume Full vs vs.toolbarTitle vs.searchDetails Full vs + vs.searchHistory

    + Nil Nil Subject<ViewEvent > Observable<ViewState>
  35. SEARCH ViewResume SearchMovie vs.searchDetails Full vs vs.toolbarTitle + + vs.searchHistory

    Nil Subject<ViewEvent > Observable<ViewState>
  36. Full vs vs.toolbarTitle + + SEARCH ViewResume SearchMovie AddToHistory vs.searchDetails

    vs.searchHistory Subject<ViewEvent > Observable<ViewState>
  37. 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>
  38. 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>
  39. 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>
  40. 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>
  41. .scan

  42. { 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>
  43. { 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>
  44. 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>
  45. .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>
  46. What about one-time ViewState changes ? Implementation

  47. Implementation 1 Toast notifications Alert dialogs 2 Navigation actions 3

    Non-persisting UI changes 4 What about one-time ViewState changes ?
  48. 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 ?
  49. .share() (View)Result ViewEvent * State flow ViewState (View)Effects New Data

    structure Private to VM
  50. .share() ViewState (View)Result (View)Effects ViewEvent viewEventSubject

  51. .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 … }
  52. 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
  53. .flatmap { event `a Observable<ViewResult> in } viewEventSubject switch(event) {

    `b other event cases … } .share() .share() ViewState (View)Result (View)Effects ViewEvent
  54. .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 … }
  55. .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> =
  56. .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> =
  57. .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
  58. .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
  59. .share() ViewState ViewResult ViewEffects ViewEvent Activity or View Controller ViewModel

  60. Movie Search Demo App github.com/kaushikgopal/movies-usf-ios github.com/kaushikgopal/movies-usf-android

  61. 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 } }
  62. `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
  63. 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
  64. That’s all folks !!! 1 Resources 2 Guided Q&A

  65. 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
  66. 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?