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. Architecting
    Android and iOS
    apps in 2020

    View full-size slide

  2. Agenda
    1 Problems building apps today
    Architecture - that solves problems
    2
    Implementation
    3
    Recap
    4

    View full-size slide

  3. Problems building apps today
    * Managing UI State is hard
    Debugging is hard
    *
    Testing is hard
    *
    Building on both takes time!
    *
    1

    View full-size slide

  4. Activity
    ViewModel
    SEARCH
    OMDB api

    fun onRestoreFromHistory()
    2SearchMovieResult
    fun onAddToHistory()
    2SearchHistoryResult
    fun onSearchMovie()
    : SearchMovieResult
    Managing UI State is hard
    *

    View full-size slide

  5. DashboardActivity
    DashboardViewModel

    Managing UI State is hard
    *

    View full-size slide

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

    View full-size slide

  7. Problems building apps today
    * Managing UI State is hard
    Debugging is hard
    *
    Testing is hard
    *
    Building on both takes time!
    *
    1

    View full-size slide

  8. Agenda
    1 Problems building apps today
    Architecture - that solves problems
    2
    Implementation
    3
    Recap
    4

    View full-size slide

  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 !

    View full-size slide

  10. Agenda
    1 Problems building apps today
    Guiding principles for an architecture
    2
    Implementation
    3
    Recap
    4

    View full-size slide

  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

    View full-size slide

  12. Model your UI as a single function
    *
    What is a function?

    View full-size slide

  13. ViewModel
    SEARCH

    fun onRestoreFromHistory()
    2SearchMovieResult
    fun onAddToHistory()
    2SearchHistoryResult
    fun onSearchMovie()
    : SearchMovieResult
    Activity
    or
    View Controller

    View full-size slide

  14. ViewModel
    SEARCH
    Activity
    or
    View Controller

    input
    output
    Modeling UI
    as a
    function

    View full-size slide

  15. Agenda
    1 Problems building apps today
    Guiding principles for an architecture
    2
    Implementation
    3
    Recap
    4

    View full-size slide

  16. Implementation
    What is a function?

    View full-size slide

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

    View full-size slide

  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)
    }

    View full-size slide

  19. ViewModel
    (View)Event
    private val viewEventSubject:
    PublishSubject =
    PublishSubject.create()
    Activity
    or
    View Controller
    SEARCH
    input
    viewEventSubject.onNext(event)
    fun processInput(
    event: ViewEvent
    ) {
    }

    View full-size slide

  20. ViewModel
    (View)Event
    private val viewEventSubject:
    PublishSubject =
    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 = .init()

    View full-size slide

  21. SEARCH
    (View)State
    output
    ViewModel

    View full-size slide

  22. data class ViewState(
    val movieTitle: String = "",
    val moviePosterUrl: String?,
    val genres: String = "",
    val plot: String = "",
    val rating: String = "",
    val searchHistoryList: List
    = emptyList()
    )
    SEARCH
    (View)State
    output

    View full-size slide

  23. data class ViewState(
    val movieTitle: String = "",
    val moviePosterUrl: String?,
    val genres: String = "",
    val plot: String = "",
    val rating: String = "",
    val searchHistoryList: List
    = 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]
    }

    View full-size slide

  24. SEARCH
    (View)State
    output
    ViewModel
    let viewState: Observable

    View full-size slide

  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)

    View full-size slide

  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)

    View full-size slide

  27. ViewModel
    Activity
    or
    View Controller
    SEARCH
    input: ViewEvent

    View full-size slide

  28. ViewModel
    Activity
    or
    View Controller
    SEARCH
    output: ViewState

    View full-size slide

  29. ViewModel
    Activity
    or
    View Controller
    SEARCH

    Subject Observable

    View full-size slide

  30. Subject Observable
    viewEventSubject
    .flatMap { event `a
    when (event) {
    is ViewResume `a {
    Observable.just(startingViewState)
    }
    is SearchMovie `a {
    doNetworkRequest(event)
    .map { result `a
    ntwkResultToViewState(result)
    }
    }
    `b …
    }

    View full-size slide

  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 Observable

    View full-size slide

  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 Observable

    View full-size slide

  33. SEARCH
    ViewResume
    Full vs
    vs.toolbarTitle
    vs.searchDetails
    Full vs
    +
    vs.searchHistory
    +
    Subject Observable

    View full-size slide

  34. SEARCH
    ViewResume
    Full vs
    vs.toolbarTitle
    vs.searchDetails
    Full vs
    +
    vs.searchHistory
    +
    Nil
    Nil
    Subject Observable

    View full-size slide

  35. SEARCH
    ViewResume
    SearchMovie
    vs.searchDetails
    Full vs
    vs.toolbarTitle
    +
    +
    vs.searchHistory
    Nil
    Subject Observable

    View full-size slide

  36. Full vs
    vs.toolbarTitle
    +
    +
    SEARCH
    ViewResume
    SearchMovie
    AddToHistory
    vs.searchDetails
    vs.searchHistory
    Subject Observable

    View full-size slide

  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 Observable

    View full-size slide

  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 Observable

    View full-size slide

  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 Observable

    View full-size slide

  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 Observable

    View full-size slide

  41. { 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 Observable

    View full-size slide

  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
    viewEventSubject
    Subject Observable

    View full-size slide

  43. 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 Observable

    View full-size slide

  44. .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 Observable

    View full-size slide

  45. What about
    one-time
    ViewState changes ?
    Implementation

    View full-size slide

  46. Implementation
    1 Toast notifications
    Alert dialogs
    2
    Navigation actions
    3
    Non-persisting UI changes
    4
    What about
    one-time
    ViewState changes ?

    View full-size slide

  47. 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 ?

    View full-size slide

  48. .share()
    (View)Result
    ViewEvent
    * State flow
    ViewState (View)Effects
    New Data structure
    Private to VM

    View full-size slide

  49. .share()
    ViewState
    (View)Result
    (View)Effects
    ViewEvent viewEventSubject

    View full-size slide

  50. .share()
    ViewState
    (View)Result
    (View)Effects
    ViewEvent
    .flatmap {
    event `a Observable in
    }
    viewEventSubject
    enum ViewResult {
    case viewResumeResult
    case searchMovieResult(
    movieResult: Movie
    loading: Bool,
    error: Error?
    )
    `b …
    }

    View full-size slide

  51. 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 in
    }
    .share()
    ViewState
    (View)Result
    (View)Effects
    ViewEvent

    View full-size slide

  52. .flatmap {
    event `a Observable in
    }
    viewEventSubject
    switch(event) {
    `b other event cases …
    }
    .share()
    .share()
    ViewState
    (View)Result
    (View)Effects
    ViewEvent

    View full-size slide

  53. .share()
    .share()
    ViewState
    (View)Result
    (View)Effects
    ViewEvent
    private let viewResults: Observable =
    .flatmap {
    event `a Observable in
    }
    viewEventSubject
    switch(event) {
    `b other event cases …
    }

    View full-size slide

  54. .share()
    .share()
    ViewState
    (View)Result
    (View)Effects
    private let viewResults: Observable =
    `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 =

    View full-size slide

  55. .share()
    .share()
    ViewState
    (View)Result
    (View)Effects
    private let viewResults: Observable =
    `b …
    viewResults
    .map { viewResult in
    switch viewResult {
    case .screenLoadResult:
    return .noEffect
    case .searchMovieResult(let movie)2
    return .showNotificationToast
    }
    let viewEffects: Observable =

    View full-size slide

  56. .share()
    ViewState
    (View)Result
    (View)Effects
    let viewEffects: Observable =
    let viewState: Observable =
    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

    View full-size slide

  57. .share()
    ViewState
    (View)Result
    (View)Effects
    let viewEffects: Observable =
    let viewState: Observable =
    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

    View full-size slide

  58. .share()
    ViewState
    ViewResult
    ViewEffects
    ViewEvent
    Activity
    or
    View Controller
    ViewModel

    View full-size slide

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

    View full-size slide

  60. 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
    }
    }

    View full-size slide

  61. `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

    View full-size slide

  62. 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

    View full-size slide

  63. That’s all folks !!!
    1 Resources
    2 Guided Q&A

    View full-size slide

  64. 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

    View full-size slide

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

    View full-size slide