A State Container Architecture for mobile applications

A State Container Architecture for mobile applications

820df515de752bffa0ce2644a7927186?s=128

David González

June 25, 2018
Tweet

Transcript

  1. A State Container based approach to Architecting Mobile applications David

    González @dggonzalez davidgonzalez@helpscout.com
  2. Beacon Mobile SDK

  3. Beacon Mobile SDK

  4. Beacon Mobile SDK

  5. Beacon Mobile SDK

  6. Motivation

  7. Page heading Motivation Explore a common (and shared) architecture language

    (and code) between iOS and Android
  8. Page heading Three traits of good design Balanced distribution of

    responsibilities
  9. Page heading Three traits of good design Testability

  10. Page heading Three traits of good design Easy to use

  11. Good code is the one that you can easily delete

    - David González
  12. Page heading Motivation Greenfield

  13. Page heading Motivation Brownfield

  14. A good design is easier to change than a bad

    design - Dave Thomas
  15. Beacon Mobile SDK

  16. MVC

  17. MVP

  18. Page heading The assembly problem let viewController = UIViewController() let

    presenter = Presenter(viewController, model) viewController.presenter = presenter
  19. MVVM

  20. Conflictive states Invisible outputs - MVP: Command from presenter to

    View - MVVM: Observable property No traceability Hard to follow the course of events” - showLoading(), showData(), refresh(), showMoreData()
  21. Page heading Motivation Null references The billion dollar mistake

  22. A State Container approach

  23. Page heading Implementation View State

  24. Page heading ViewState sealed class ReplyViewState : ViewState() { data

    class Form(val formOptions: BeaconContactForm, val message: String, val attachments: Map<String, AttachmentsViewState>, val formValid: Boolean, val draft: String) : ReplyViewState() object SendingReply : ReplyViewState() object ReplySent : ReplyViewState() class SendReplyError(error: Throwable) : ViewState.Error(error) }
  25. Page heading ViewState sealed class ReplyViewState : ViewState() { data

    class Form(val formOptions: BeaconContactForm, val message: String, val attachments: Map<String, AttachmentsViewState>, val formValid: Boolean, val draft: String) : ReplyViewState() object SendingReply : ReplyViewState() object ReplySent : ReplyViewState() class SendReplyError(error: Throwable) : ViewState.Error(error) }
  26. Page heading ViewState sealed class ReplyViewState : ViewState() { data

    class Form(val formOptions: BeaconContactForm, val message: String, val attachments: Map<String, AttachmentsViewState>, val formValid: Boolean, val draft: String) : ReplyViewState() object SendingReply : ReplyViewState() object ReplySent : ReplyViewState() class SendReplyError(error: Throwable) : ViewState.Error(error) }
  27. Page heading ViewState sealed class ReplyViewState : ViewState() { data

    class Form(val formOptions: BeaconContactForm, val message: String, val attachments: Map<String, AttachmentsViewState>, val formValid: Boolean, val draft: String) : ReplyViewState() object SendingReply : ReplyViewState() object ReplySent : ReplyViewState() class SendReplyError(error: Throwable) : ViewState.Error(error) }
  28. Page heading ViewState sealed class ReplyViewState : ViewState() { data

    class Form(val formOptions: BeaconContactForm, val message: String, val attachments: Map<String, AttachmentsViewState>, val formValid: Boolean, val draft: String) : ReplyViewState() object SendingReply : ReplyViewState() object ReplySent : ReplyViewState() class SendReplyError(error: Throwable) : ViewState.Error(error) }
  29. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  30. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  31. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  32. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  33. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  34. Page heading Pattern matching override fun render(state: ViewState) { when

    (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  35. Page heading Implementation UI as a State Machine

  36. Page heading UI as a State Machine sealed class ViewState

    { object Idle : ViewState() object Loading : ViewState() object Empty : ViewState() open class Error(val exception: Throwable) : ViewState() fun toLoading(viewState: ViewState): ViewState { return when(viewState){ is Loading -> Error(Throwable("Wrong state transition")) else -> Loading } } }
  37. Page heading UI as a State Machine sealed class ViewState

    { object Idle : ViewState() object Loading : ViewState() object Empty : ViewState() open class Error(val exception: Throwable) : ViewState() fun toLoading(viewState: ViewState): ViewState { return when(viewState){ is Loading -> Error(Throwable("Wrong state transition")) else -> Loading } } }
  38. Page heading UI as a State Machine sealed class ViewState

    { object Idle : ViewState() object Loading : ViewState() object Empty : ViewState() open class Error(val exception: Throwable) : ViewState() fun toLoading(viewState: ViewState): ViewState { return when(viewState){ is Loading -> Error(Throwable("Wrong state transition")) else -> Loading } } }
  39. Page heading UI as a State Machine sealed class ViewState

    { object Idle : ViewState() object Loading : ViewState() object Empty : ViewState() open class Error(val exception: Throwable) : ViewState() fun toLoading(viewState: ViewState): ViewState { return when(viewState){ is Loading -> Error(Throwable("Wrong state transition")) else -> Loading } } }
  40. Page heading Implementation Action (Command) Identifies actions from UI layer

  41. Page heading Implementation sealed class SendMessageAction : BeaconAction() { object

    Cleanup : SendMessageAction() object SelectAttachment : SendMessageAction() object InitialAction : SendMessageAction() object LoadForm : SendMessageAction() data class SendMessage(val formValues: FormValues) : SendMessageAction() data class ValidateForm(val formValues: FormValues) : SendMessageAction() }
  42. Page heading Implementation sealed class SendMessageAction : BeaconAction() { object

    Cleanup : SendMessageAction() object SelectAttachment : SendMessageAction() object InitialAction : SendMessageAction() object LoadForm : SendMessageAction() data class SendMessage(val formValues: FormValues) : SendMessageAction() data class ValidateForm(val formValues: FormValues) : SendMessageAction() }
  43. Page heading Implementation sealed class SendMessageAction : BeaconAction() { object

    Cleanup : SendMessageAction() object SelectAttachment : SendMessageAction() object InitialAction : SendMessageAction() object LoadForm : SendMessageAction() data class SendMessage(val formValues: FormValues) : SendMessageAction() data class ValidateForm(val formValues: FormValues) : SendMessageAction() }
  44. Page heading Implementation ViewModel

  45. Page heading ViewModel abstract class BaseViewModel : ViewModel() { val

    state = MediatorLiveData<ViewState>() protected fun stateDidChange(viewState: ViewState){ state.value = viewState } abstract fun interpret(action: Action) }
  46. Page heading ViewModel abstract class BaseViewModel : ViewModel() { val

    state = MediatorLiveData<ViewState>() protected fun stateDidChange(viewState: ViewState){ state.value = viewState } abstract fun interpret(action: Action) }
  47. Page heading ViewModel abstract class BaseViewModel : ViewModel() { val

    state = MediatorLiveData<ViewState>() protected fun stateDidChange(viewState: ViewState){ state.value = viewState } abstract fun interpret(action: Action) }
  48. Page heading ViewModel abstract class BaseViewModel : ViewModel() { val

    state = MediatorLiveData<ViewState>() protected fun stateDidChange(viewState: ViewState){ state.value = viewState } abstract fun interpret(action: Action) }
  49. Page heading ViewModel class MoviesViewModel: BaseViewModel() { init { stateDidChange(ViewState.Idle)

    … } }
  50. Page heading Implementation The glue Observing the view state from

    the Activity
  51. Page heading Implementation viewModel.viewState.observe(this, Observer<ViewState> { it?.let { render(it) }

    }) override fun render(state: BeaconViewState) { when (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  52. Page heading Implementation viewModel.viewState.observe(this, Observer<ViewState> { it?.let { render(it) }

    }) override fun render(state: BeaconViewState) { when (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  53. Page heading Implementation viewModel.viewState.observe(this, Observer<ViewState> { it?.let { render(it) }

    }) override fun render(state: BeaconViewState) { when (state) { is ReplyViewState.Form -> renderForm(state) is ReplyViewState.ReplySent -> renderReplySent() is ReplyViewState.MissingMessage -> renderMissingMessage() is ReplyViewState.SendingReply -> renderLoading() is ReplyViewState.SendReplyError -> renderError(state) } }
  54. Page heading Implementation ViewState vs DomainState

  55. Page heading Implementation App = UI(state)

  56. Page heading Implementation UI(viewState(domainState))

  57. Page heading Implementation Store (Reducers)

  58. Page heading The missing piece interface Store { fun reduce(action:

    Action) fun subscribe() LiveData<DomainState> }
  59. Page heading The missing piece sealed class DomainState { object

    LoadingMovies : DomainState() data class MoviesLoaded(movies: List<Movies>) }
  60. Page heading The missing piece class MoviesViewModel(private val store: MoviesStore)

    : BaseViewModel() { init { stateDidChange(ViewState.Idle) state.addSource(store.subscribe(), { when (it) { is DomainState.LoadingMovies -> stateDidChange(ViewState.Loading) } }) } override fun interpret(action: Action) { store.reduce(action) } }
  61. Page heading The missing piece class MoviesViewModel(private val store: MoviesStore)

    : BaseViewModel() { init { stateDidChange(ViewState.Idle) state.addSource(store.subscribe(), { when (it) { is DomainState.LoadingMovies -> stateDidChange(ViewState.Loading) } }) } override fun interpret(action: Action) { store.reduce(action) } }
  62. Page heading The missing piece class MoviesViewModel(private val store: MoviesStore)

    : BaseViewModel() { init { stateDidChange(ViewState.Idle) state.addSource(store.subscribe(), { when (it) { is DomainState.LoadingMovies -> stateDidChange(ViewState.Loading) } }) } override fun interpret(action: Action) { store.reduce(action) } }
  63. Page heading The missing piece class MoviesViewModel(private val store: MoviesStore)

    : BaseViewModel() { init { stateDidChange(ViewState.Idle) state.addSource(store.subscribe(), { when (it) { is DomainState.LoadingMovies -> stateDidChange(ViewState.Loading) } }) } override fun interpret(action: Action) { store.reduce(action) } }
  64. Page heading The missing piece class MoviesViewModel(private val store: MoviesStore)

    : BaseViewModel() { init { stateDidChange(ViewState.Idle) state.addSource(store.subscribe(), { when (it) { is DomainState.LoadingMovies -> stateDidChange(ViewState.Loading) } }) } override fun interpret(action: Action) { store.reduce(action) } }
  65. Page heading The missing piece

  66. Page heading The missing piece Functional Use Cases

  67. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  68. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  69. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  70. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  71. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  72. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  73. Page heading The missing piece abstract class UseCase<out Type, in

    Params> where Type : Any { abstract suspend fun run(params: Params): Either<Failure, Type> fun execute(onResult: (Either<Failure, Type>) -> Unit, params: Params) { val job = async(CommonPool) { run(params) } launch(UI) { onResult.invoke(job.await()) } } class None }
  74. Page heading The missing piece class GetMovies(private val repository: MoviesRepository)

    : UseCase<DomainState, UseCase.None>() { override suspend fun run(params: None): Either<Failure, DomainState> { return repository.getAllMovies() } }
  75. Page heading The missing piece Error Handling

  76. Page heading The missing piece sealed class Either<out L, out

    R> { data class Left<out L>(val a: L) : Either<L, Nothing>() data class Right<out R>(val b: R) : Either<Nothing, R>() val isRight get() = this is Right<R> val isLeft get() = this is Left<L> fun either(fnL: (L) -> Any, fnR: (R) -> Any): Any = when (this) { is Either.Left -> fnL(a) is Either.Right -> fnR(b) } fun <T> flatMap(fn: (R) -> Either<L, T>): Either<L, T> {...} fun <T> map(fn: (R) -> (T)): Either<L, T> {...} }
  77. Page heading The missing piece sealed class Failure { class

    NetworkConnection: Failure() class ServerError: Failure() /** Feature specific failures */ abstract class FeatureFailure: Failure() }
  78. Page heading The missing piece sealed class Failure { class

    NetworkConnection: Failure() class ServerError: Failure() /** Feature specific failures */ abstract class FeatureFailure: Failure() }
  79. Page heading The missing piece sealed class Failure { class

    NetworkConnection: Failure() class ServerError: Failure() /** Feature specific failures */ abstract class FeatureFailure: Failure() }
  80. Page heading The missing piece sealed class Failure { class

    NetworkConnection: Failure() class ServerError: Failure() /** Feature specific failures */ abstract class FeatureFailure: Failure() }
  81. Page heading The missing piece private fun handleFailure(failure: Failure?) {

    when (failure) { is NetworkConnection -> renderFailure(R.string.failure_network) is ServerError -> renderFailure(R.string.failure_server) is ListNotAvailable -> renderFailure(R.string.failure_movies_error } }
  82. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  83. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  84. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  85. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  86. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  87. Page heading Error Handling class MoviesViewModel @Inject constructor(private val store:

    MoviesStore) { var movies: MutableLiveData<MoviesViewState> = MutableLiveData() var failure: MutableLiveData<Failure> = MutableLiveData() fun loadMovies() = getMovies.reduce({ it.either(::handleFailure, ::handleMovieList) }, None()) private fun handleMovieList(domainState: DomainState) { this.movies.value = MoviesViewState(domainState) } private fun handleFailure(failure: Failure) { this.failure.value = failure } }
  88. Page heading Implementation Taking advantage of Kotlin

  89. Page heading The missing piece inline fun <reified T :

    ViewModel> Activity.viewModel(factory: Factory, body: T.() -> Unit): T { val vm = ViewModelProviders.of(this, factory)[T::class.java] vm.body() return vm }
  90. Page heading The missing piece fun <T : Any, L

    : LiveData<T>> LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) = liveData.observe(this, Observer(body)) fun <L : LiveData<Failure>> LifecycleOwner.failure(liveData: L, body: (Failure?) -> Unit) = liveData.observe(this, Observer(body))
  91. Page heading The missing piece fun <T : Any, L

    : LiveData<T>> LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) = liveData.observe(this, Observer(body)) fun <L : LiveData<Failure>> LifecycleOwner.failure(liveData: L, body: (Failure?) -> Unit) = liveData.observe(this, Observer(body))
  92. Page heading The missing piece fun <T : Any, L

    : LiveData<T>> LifecycleOwner.observe(liveData: L, body: (T?) -> Unit) = liveData.observe(this, Observer(body)) fun <L : LiveData<Failure>> LifecycleOwner.failure(liveData: L, body: (Failure?) -> Unit) = liveData.observe(this, Observer(body))
  93. Page heading The missing piece //subscription to LiveData in MoviesViewModel

    moviesViewModel = viewModel(viewModelFactory) { observe(movies, ::renderMoviesList) failure(failure, ::handleFailure) }
  94. What’s next?

  95. Page heading The missing piece ViewState vs Event

  96. Page heading ViewState vs ViewEvent class BeaconViewModel(val store: BeaconStore) :

    ViewModel() { val viewState = MediatorLiveData<BeaconViewState>() val viewEvents = MediatorLiveData<BeaconEvent>() init { viewState.value = BeaconViewState.Idle viewState.addSource(reducer.subscribeToViewStates(), { if (viewState is BeaconViewState.Error) { viewState.shouldReThrow().let { throw viewState.exception } } viewState.setValue(it) }) viewEvents.addSource(reducer.subscribeToEvents(), { viewEvents.setValue(it) }) } fun interpret(action: BeaconAction) { reducer.reduce(action, viewState.value!!) } }
  97. Page heading ViewState vs ViewEvent class BeaconViewModel(val store: BeaconStore) :

    ViewModel() { val viewState = MediatorLiveData<BeaconViewState>() val viewEvents = MediatorLiveData<BeaconEvent>() init { viewState.value = BeaconViewState.Idle viewState.addSource(reducer.subscribeToViewStates(), { if (viewState is BeaconViewState.Error) { viewState.shouldReThrow().let { throw viewState.exception } } viewState.setValue(it) }) viewEvents.addSource(reducer.subscribeToEvents(), { viewEvents.setValue(it) }) } fun interpret(action: BeaconAction) { reducer.reduce(action, viewState.value!!) } }
  98. Page heading ViewState vs ViewEvent override fun reactTo(event: BeaconEvent) {

    when (event) { is BeaconEvent.SelectAttachment -> openFileSelector() is BeaconEvent.AttachmentDownloaded -> open(event.File) is BeaconEvent.Close -> closeActivity() } }
  99. What’s next? Kotlin Multiplatform DomainState persistence App as a State

    Machine
  100. Thank you! David González @dggonzalez davidgonzalez@helpscout.com