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 Slide

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

    View Slide

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

    View Slide

  4. Activity
    ViewModel
    SEARCH
    OMDB api

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

    View Slide

  5. DashboardActivity
    DashboardViewModel

    Managing UI State is hard
    *

    View Slide

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

    View Slide

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

    View Slide

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

    View 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 Slide

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

    View 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 Slide

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

    View Slide

  13. ViewModel
    SEARCH

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

    View Slide

  14. ViewModel
    SEARCH
    Activity
    or
    View Controller

    input
    output
    Modeling UI
    as a
    function

    View Slide

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

    View Slide

  16. Implementation
    What is a function?

    View Slide

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

    View 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 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 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 Slide

  21. SEARCH
    (View)State
    output
    ViewModel

    View 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 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 Slide

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

    View 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 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 Slide

  27. ViewModel
    Activity
    or
    View Controller
    SEARCH
    input: ViewEvent

    View Slide

  28. ViewModel
    Activity
    or
    View Controller
    SEARCH
    output: ViewState

    View Slide

  29. ViewModel
    Activity
    or
    View Controller
    SEARCH

    Subject Observable

    View 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 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 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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View 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 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 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 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 Slide

  41. .scan

    View 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 Option 2
    Subject Observable

    View Slide

  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 Observable

    View Slide

  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 Observable

    View Slide

  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 Observable

    View Slide

  46. What about
    one-time
    ViewState changes ?
    Implementation

    View Slide

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

    View Slide

  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 ?

    View Slide

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

    View Slide

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

    View Slide

  51. .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 Slide

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

    View Slide

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

    View Slide

  54. .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 Slide

  55. .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 Slide

  56. .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 Slide

  57. .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 Slide

  58. .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 Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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?

    View Slide