MVI : une architecture robuste et moderne pour vos applications mobiles

MVI : une architecture robuste et moderne pour vos applications mobiles

Co-présentée par Arnaud Piroelle et Simone Civetta

Oui, on le sait : vous en avez assez des talks d'architecture mobile. On vous comprend, vous avez dû en voir des tonnes : utiles, superflus, simples, alambiqués, certains même imprononçables et là vous n’en pouvez plus.

Mais donnez-nous une chance : cette fois-ci nous voulons vous présenter une architecture que nous apprécions véritablement : simple, robuste, facilement testable et tirant profit des langages modernes comme Kotlin et Swift - Model-View-Intention. Inspirée de Redux, mais adaptée aux applications mobiles, MVI se sert d'immutabilité, flux de données unidirectionnel et binding pour faciliter débogage et testing et améliorer donc votre productivité. Applicable à Android et iOS, elle permet aussi de simplifier les échanges entre les équipes de développeurs d'applications mobiles natives.

Nous vous présenterons les éléments clés de l'architecture, sa mise en place, ainsi que l'implémentation des cas d'usage les plus communs, issus d'applications que nous avons réellement développées.

3f309c992e2b1a5c3c014e63810a2f68?s=128

Simone Civetta

April 24, 2019
Tweet

Transcript

  1. une architecture robuste et moderne pour vos applications mobiles

  2. Simone Civetta Développeur, plutôt iOS Arnaud Piroelle Développeur, plutôt Android

    2
  3. 3

  4. une architecture robuste et moderne pour vos applications mobiles

  5. PHOTO_BATIMENT_TROUVÉE_SUR_GOOGLE_ SANS_PAYER_LES_ROYALTIES.JPG 5

  6. - C koi ton archi ? 6

  7. Moi, mon archi, c’est du MVP Un dév au hasard

    7
  8. Moi, mon archi, c’est du MVVM Un dév au hasard

    8
  9. Moi, mon archi, c’est du VIPER Un dév au hasard

    9
  10. Moi, mon archi, c’est de la CLEAN Un dév au

    hasard 10
  11. Moi, mon archi, c’est de la HEXA Un dév au

    hasard 11
  12. Moi, mon archi, c’est du MVC Un dév au hasard

    12
  13. Moi, mon archi, c’est du [INSÉRER UNE QUANTITÉ LIMITÉE DE

    LETTRES AU HASARD] Un dév au hasard 13
  14. - C koi ton archi ? - Qu’est-ce que t’entends

    par archi ? 14
  15. - Qu’est-ce que t’entends par archi ? 15

  16. #DEFINE ARCHITECTURE On peut identifier plusieurs scénarios d’architecture : -

    Architecture de Système - Architecture de Navigation - Architecture de Présentation 16
  17. #DEFINE ARCHITECTURE On peut identifier plusieurs scénarios d’architecture : -

    Architecture de Système (Onion, Clean, ...) - Architecture de Navigation (Router, Coordinators) - Architecture de Présentation (MVC, MVP, MVVM, …) 17
  18. Le plus grand défi d’une application mobile : la couche

    de présentation 18
  19. Répartition du code dans une appli moyenne de code concerne

    la présentation (dans une application moyenne) Source : StackOverflow 19
  20. ~50% de code concerne la couche de présentation (dans une

    application moyenne) 20 Source : StackOverflow
  21. Le caractéristiques du code de la couche de présentation -

    Généralement Stateful - Difficile à tester - Sujet à changement - Modifié par un grand nombre d’intervenants - Devient de plus en plus complexe avec le temps - Usine à bugTM 21
  22. Réduire les risques de bugs de la couche de présentation

    : l’architecture 22
  23. 23

  24. MVC • 1/2 class MyActivity: Activity() { val taskService =

    TaskService() lateinit var tasksTextView: TextView lateinit var loadingIndicator: ProgressBar lateinit var errorView: View lateinit var emptyView: View override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... loadTasks() } // Continues... 24
  25. MVC • 2/2 fun loadTasks() { loadingIndicator.visibility = VISIBLE taskService.loadTasks

    { tasks, error -> loadingIndicator.visibility = GONE errorView.visibility = if (error == null) GONE else VISIBLE if (error != null) return emptyView.visibility = if (tasks.isEmpty()) VISIBLE else GONE tasksTextView.text = tasks.allTitles() } } } 25
  26. iOS : MVC • Recap class MyViewController: UIViewController { let

    taskService = TaskService() @IBOutlet weak var tasksTextView: UITextView! @IBOutlet weak var loadingIndicator: UIActivityIndicatorView! @IBOutlet weak var errorView: UIView! @IBOutlet weak var emptyView: UIView! override func viewDidLoad() { super.viewDidLoad() loadTasks() } func loadTasks() { loadingIndicator.startAnimating() taskService.loadTasks { [weak self] tasks, error in loadingIndicator.stopAnimating() self?.errorView.isHidden = error != nil guard error != nil else { return } self?.emptyView.isHidden = !(tasks?.isEmpty ?? true) self?.tasksTextView.text = tasks?.allTitles() } } } 26
  27. 27

  28. MVP 1/3 public interface MyContract { interface View { fun

    setLoadingIndicator(active: Boolean) fun showTasks(tasks: List<Task>) fun showLoadingTasksError() fun showNoTasks() } interface Presenter { fun loadTasks(forceUpdate: Boolean) } } 28
  29. MVP 2/3 class MyFragment : Fragment(), MyContract.View { private val

    presenter: MyContract.Presenter by lazy { MyPresenter(this) } fun onResume(){ super.onResume() presenter.loadTasks(true) } override fun setLoadingIndicator(active: Boolean) { // Do something } override fun showTasks(tasks: List<Task>) { // Do something } override fun showLoadingTasksError() { // Do something } override fun showNoTasks() { // Do something } } 29
  30. Android : MVP 3/3 class MyPresenter(val view: MyContract.View) : MyContract.Presenter

    { override fun loadTasks(forceUpdate: Boolean) { view.setLoadingIndicator(true) try { val tasks = repository.loadTasks() if (tasks.isEmpty()) { view.showNoTasks() } else { view.showTasks(tasks) } view.setLoadingIndicator(false) } catch(e: LoadingException){ view.showLoadingTasksError() view.setLoadingIndicator(false) } } } 30
  31. THIS_IS_FINE.JPG 31

  32. 32

  33. THIS_IS_FINE.JPG 33

  34. Simple, mais... MVC - Couplage fort entre métier et présentation

    - La logique métier se perd dans la logique de vue - Le fonctionnement dépend du cycle de vie de l’Activity/Fragment - Approche impérative - Les comportements sont difficiles à mocker - Le système est difficile à tester 34
  35. Simple, mais... MVP - Couplage fort entre la vue et

    son presenter - La vue définit implicitement l’état... - ...et est responsable des actions envoyées au Presenter - Approche impérative 35
  36. Une couche d’indirection de plus 36

  37. All problems in computer science can be solved by another

    level of indirection - Moi, quand je dois dire “non” au client et que j’envoie mon collègue à ma place 37
  38. MVVM : ViewModel class MyViewModel : ViewModel(){ val taskService =

    TaskService() val taskText = MutableLiveData<String>() val isLoading = MutableLiveData<Boolean>().apply { value = false } val errorViewHidden = MutableLiveData<Boolean>().apply { value = true } val emptyViewHidden = MutableLiveData<Boolean>().apply { value = true } fun loadTasks() { isLoading.value = true taskService.loadTasks { tasks, error -> isLoading.value = false errorViewHidden.value = error == nil emptyViewHidden.value = !(tasks?.isEmpty() :? true) taskText.value = tasks?.allTitles() } } } 38
  39. MVVM : ViewModel class MyViewModel : ViewModel(){ val taskService =

    TaskService() val taskText = MutableLiveData<String>() val isLoading = MutableLiveData<Boolean>().apply { value = false } val errorViewHidden = MutableLiveData<Boolean>().apply { value = true } val emptyViewHidden = MutableLiveData<Boolean>().apply { value = true } fun loadTasks() { isLoading.value = true taskService.loadTasks { tasks, error -> isLoading.value = false errorViewHidden.value = error == nil emptyViewHidden.value = !(tasks?.isEmpty() :? true) taskText.value = tasks?.allTitles() } } } 39
  40. MVVM : ViewModel class MyViewModel : ViewModel(){ val taskService =

    TaskService() val taskText = MutableLiveData<String>() val isLoading = MutableLiveData<Boolean>() val errorViewHidden = MutableLiveData<Boolean>() val emptyViewHidden = MutableLiveData<Boolean>() fun loadTasks() { isLoading.value = true taskService.loadTasks { tasks, error -> isLoading.value = false errorViewHidden.value = error == nil emptyViewHidden.value = !(tasks?.isEmpty() :? true) taskText.value = tasks?.allTitles() } } } LiveData mutable ! 40
  41. MVVM : View class MyActivity: Activity() { val viewModel: MyViewModel

    by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... viewModel.taskText.observe(this, Observer { taskTextView.text = it }) viewModel.errorViewHidden.observe(this, Observer { errorView.setVisible(it) }) viewModel.emptyViewHidden.observe(this, Observer { emptyView.setVisible(it) }) viewModel.isLoading.observe(this, Observer { editTextView.setVisible(it) }) viewModel.loadTasks() } } 41
  42. MVVM : View class MyActivity: Activity() { val viewModel: MyViewModel

    by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... viewModel.taskText.observe(this, Observer { taskTextView.text = it }) viewModel.errorViewHidden.observe(this, Observer { errorView.setVisible(it) }) viewModel.emptyViewHidden.observe(this, Observer { emptyView.setVisible(it) }) viewModel.isLoading.observe(this, Observer { editTextView.setVisible(it) }) viewModel.loadTasks() } } 42
  43. Testabilité de MVVM // Prepare val mockService: TaskService = MockTaskService(listOf("Bananas"))

    val viewModel = MyViewModel(mockService) // Run viewModel.loadTasks() // Verify assertEquals(true, viewModel.errorViewHidden.value) assertEquals(true, viewModel.emptyViewHidden.value) assertEquals("Bananas", viewModel.taskText.value) 43
  44. HAPPY_MINIONS.GIF 44

  45. MONDE_DES_BISOUNOURS.GIF 45

  46. Un exemple plus complexe • 1 class MyViewModel : ViewModel()

    { val taskText = MutableLiveData<String>() fun loadTasks() { taskService.loadTasks { tasks, error -> taskText.value = tasks?.allTitles() } } } 46
  47. Un exemple plus complexe • 2 class MyViewModel : ViewModel()

    { // L’Activity peut modifier la valeur du LiveData, ce qui comportera // implicitement des side-effects car le ViewModel est muable // Nous devons donc implémenter une sorte de contrôle d’accès val taskText = MutableLiveData<String>() fun loadTasks() { taskService.loadTasks { tasks, error -> taskText.value = tasks?.allTitles() } } } 47
  48. Un exemple plus complexe • 2 - Solution class MyViewModel

    : ViewModel() { private val _taskText = MutableLiveData<String>() val taskText: LiveData<String> get() = _taskText fun loadTasks() { taskService.loadTasks { tasks, error -> _taskText.value = tasks?.allTitles() } } } 48
  49. Un exemple plus complexe • 3 class MyViewModel : ViewModel()

    { private val _taskText = MutableLiveData<String>() private val _appointmentText = MutableLiveData<String>() val taskText: LiveData<String> get() = _taskText val appointmentText: LiveData<String> get() = _appointmentText func loadTasks() { /* [...] */ } func loadAppointments() { /* [...] */ } 49
  50. Un exemple plus complexe • 3 class MyViewModel : ViewModel()

    { private val _taskText = MutableLiveData<String>() private val _appointmentText = MutableLiveData<String>() val taskText: LiveData<String> get() = _taskText val appointmentText: LiveData<String> get() = _appointmentText // ⇐ true where NO taskError and NO appointmentError val errorViewHidden: LiveData<Boolean> } 50
  51. Un exemple plus complexe • 3 - Solution class MyViewModel

    : ViewModel() { private val _taskText = MutableLiveData<String>() private val _appointmentText = MutableLiveData<String>() val taskText: LiveData<String> get() = _taskText val appointmentText: LiveData<String> get() = _appointmentText private val _taskError = MutableLiveData<Error>() private val _appointmentError = MutableLiveData<Error>() val errorViewHidden = MediatorLiveData<Boolean>() init() { errorViewHidden.addSource(_taskError) {...} errorViewHidden.addSource(_appointmentError) {...} } } 51
  52. MVVM : Attente VS Réalité class MyViewModelExpectation : ViewModel() {

    val taskService = TaskService() val appointmentService = AppointmentService() val taskText = MutableLiveData<String>() val taskError = MutableLiveData<Error>() val appointmentText = MutableLiveData<String>() val appointmentError = MutableLiveData<Error>() val errorViewHidden = MediatorLiveData<Boolean>() init() { errorViewHidden.addSource(taskError) {...} errorViewHidden.addSource(appointmentError) {...} } fun loadTasks() { taskService.loadTasks { tasks, error -> taskError.value = error taskText.value = tasks?.allTitles() } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> appointmentError.value = error appointmentText.value = appointments?.allTitles() } } class MyViewModelReality : ViewModel() { val taskService = TaskService() val appointmentService = AppointmentService() private val _taskText = MutableLiveData<String>() private val _appointmentText = MutableLiveData<String>() private val _taskError = MutableLiveData<Error>() private val _appointmentError = MutableLiveData<Error>() val taskText: LiveData<String> get() = _taskText val appointmentText: LiveData<String> get() = _appointmentText val errorViewHidden: LiveData<Boolean> init() { errorViewHidden.addSource(_taskError) {...} errorViewHidden.addSource(_appointmentError) {...} } func loadTasks() { taskService.loadTasks { tasks, error -> _taskError.value = error _taskText.value = tasks?.allTitles() } } func loadAppointments() { appointmentService.loadAppointments { appointments, error -> _appointmentError.value = error _appointmentText.value = appointments?.allTitles() } } } 52
  53. Et si on voulait tester ? // Prepare val mockTaskService

    = MockTaskService(listOf(Task(name= "Prepare the talk"))) val mockAppointmentService = MockAppointmentService(listOf(Appointment(name= "Talk @ Android Makers"))) val viewModel = MyViewModel(mockTaskService, mockAppointmentService) // Run viewModel.loadTasks() viewModel.loadAppointments() // Verify // ⇐ true where NO taskError and NO appointmentError assertEquals(true, viewModel.errorViewHidden.value) 53
  54. Et si on voulait tester ? // Prepare val mockTaskService

    = MockTaskService(listOf(Task(name= "Prepare the talk"))) val mockAppointmentService = MockAppointmentService(listOf(Appointment(name= "Talk @ AndroidMakers"))) val viewModel = MyViewModel(mockTaskService, mockAppointmentService) // Run viewModel.loadTasks() viewModel.loadAppointments() // Verify // ⇐ true where NO taskError and NO appointmentError assertEquals(true, viewModel.errorViewHidden.value) 54
  55. Et si on voulait tester ? // Prepare val mockTaskService

    = MockTaskService(listOf(Task(name: "Prepare the talk"))) val mockAppointmentService = MockAppointmentService(listOf(Appointment(name: "AndroidMakers"))) val viewModel = MyViewModel(mockTaskService, mockAppointmentService) // Run viewModel.loadTasks() viewModel.loadAppointments() // Verify // ⇐ true where NO taskError and NO appointmentError assertEquals(true, viewModel.errorViewHidden.value) // Mais Comment tester les side-effects d'un des services en isolation ? (c-a-d, sans exécuter loadTasks ET loadAppointments?) 55
  56. MVVM Benefices : - Séparation de la logique métier de

    la logique de vue - Bindings : la vue devient extrêmement simple - Testabilité accrue 56
  57. MVVM Complexités : - Contrôle d’accès verbeux - Inspectabilité de

    l’état courant - Isolation des side-effects de chaque action lors des tests - Nécessite des compétences Reactive 57
  58. Au delà de MVVM : 58

  59. BUZZ_LIGHTYEAR_ VERS_L_INFINI_ ET_AU_DELA_ NO_WATERMARK.JPG 59

  60. Une première découpe : un State immuable data class MyState(

    val taskText: String? = null val appointmentText: String? = null val taskError: Error? = null val appointmentError: Error? = null ) 60
  61. Une première découpe : un State immuable data class MyState

    { val taskText: String? = null val appointmentText: String? = null val taskError: Error? = null val appointmentError: Error? = null ) { val errorViewHidden: Boolean get() = (taskError == null && appointmentError == null) } 61
  62. ...et son ViewModel • 1 class MyViewModel: ViewModel() { val

    taskService = TaskService() val appointmentService = AppointmentService() private val _state = MutableLiveData<MyState>().apply { value = MyState() } val state: LiveData<MyState> get() = _state 62
  63. ...et son ViewModel • 2 fun loadTasks() { taskService.loadTasks {

    tasks, error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( taskText = tasks?.allTitles(), taskError = error ) _state.value = newState } } fun loadAppointments() { appointmentService.loadAppointments { [weak self] (appointments, 63
  64. ...et son ViewModel • 2 _state.value = newState } }

    fun loadAppointments() { appointmentService.load { appointments, error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( appointmentText = appointments?.allTitles(), appointmentError = error ) _state.value = newState } } } 64
  65. Enlevons le code redondant... fun loadTasks() { taskService.loadTasks { tasks,

    error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( taskText = tasks?.allTitles(), taskError = error ) _state.value = newState } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( appointmentText = appointments?.allTitles(), appointmentError = error ) _state.value = newState } } 65
  66. Enlevons le code redondant... fun loadTasks() { taskService.loadTasks { tasks,

    error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( taskText = tasks?.allTitles(), taskError = error ) _state.value = newState } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> val currentState = _state.value ?: MyState() val newState = currentState.copy( appointmentText = appointments?.allTitles(), appointmentError = error ) _state.value = newState } } 66
  67. Enlevons le code redondant... open class BaseViewModel<S: State>(private val initialState:

    S) { private val _state = MutableLiveData<S>().apply { value = initialState } val state: LiveData<S> get() = _state fun updateState(handler: (S) -> (S)) { val currentState = _state.value ?: initialState _state.value = handler(currentState) } } 67
  68. class MyViewModel(intialState: MyState): BaseViewModel<MyState>(intialState) { val taskService = TaskService() val

    appointmentService = AppointmentService() fun loadTasks() { taskService.loadTasks { tasks, error -> updateState { state -> state.copy(taskText = tasks?.allTitles(), taskError = error) } } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> updateState { state -> state.copy( appointmentText = appointments?.allTitles(), appointmentError = error ) } } } } 68
  69. class MyViewModel(intialState: MyState): BaseViewModel<MyState>(intialState) { val taskService = TaskService() val

    appointmentService = AppointmentService() fun loadTasks() { taskService.loadTasks { tasks, error -> updateState { state -> state.copy(taskText = tasks?.allTitles(), taskError = error) } } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> updateState { state -> state.copy( appointmentText = appointments?.allTitles(), appointmentError = error ) } } } } class MyViewModelReality : ViewModel() { val taskService = TaskService() val appointmentService = AppointmentService() private val _taskText = MutableLiveData<String>() private val _appointmentText = MutableLiveData<String>() private val _taskError = MutableLiveData<Error>() private val _appointmentError = MutableLiveData<Error>() val taskText: LiveData<String> get() = _taskText val appointmentText: LiveData<String> get() = _appointmentText val errorViewHidden: LiveData<Boolean> init() { errorViewHidden.addSource(_taskError) {...} errorViewHidden.addSource(_appointmentError) {...} } fun loadTasks() { taskService.loadTasks { tasks, error -> _taskError.value = error _taskText.value = tasks?.allTitles() } } fun loadAppointments() { appointmentService.loadAppointments { appointments, error -> _appointmentError.value = error _appointmentText.value = appointments?.allTitles() } } } 69
  70. Et la view ? class MyActivity: Activity { var viewModel:

    MyViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) [ ... ] viewModel.state.map { it.taskText }.distinctUntilChanged() .bind(this, this::onTaskTextChanged) viewModel.state.map { it.appointmentText }.distinctUntilChanged() .bind(this, this::onAppointmentTextChanged) viewModel.loadTasks() } } 70
  71. Et la view ? class MyActivity: Activity { var viewModel:

    MyViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) … viewModel.state.map { it.taskText }.distinctUntilChanged() .bind(this, this::onTaskTextChanged) viewModel.state.map { it.appointmentText }.distinctUntilChanged() .bind(this, this::onAppointmentTextChanged) viewModel.loadTasks() } } 71
  72. Et les tests ? // Prepare val mockTaskService = MockTaskService(listOf(Task(name:

    "Prepare the talk"))) val mockAppointmentService = MockAppointmentService(listOf( Appointment(name: "Talk @ Android Makers"))) val viewModel = MyViewModel(mockTaskService, mockAppointmentService, MyState()) // Run viewModel.loadTasks() viewModel.loadAppointments() // Et si on voulait tester en isolation ? // Verify assertEquals(true, viewModel.errorViewHidden.value) 72
  73. Et les tests ? // Prepare val mockTaskService = MockTaskService(listOf(Task(name:

    "Prepare the talk"))) val mockAppointmentService = MockAppointmentService(listOf( Appointment(name: "Talk @ Android Makers"))) val state = MyState(taskError = MyError()) val viewModel = MyViewModel(mockTaskService, mockAppointmentService, state) // Run viewModel.loadAppointments() // Verify assertEquals(true, viewModel.state.errorViewHidden.value) 73
  74. Qu’est-ce qu’on vient de voir ? - Un state immuable,

    qui réduit les side effects - Une représentation complète et facilement compréhensible du state - Des tests simplifiés et plus précis 74
  75. Model-View-Intention ? 75

  76. Où est l’intention ? sealed class MyAction { object LoadTasks:

    MyAction() object LoadAppointments: MyAction() } 76
  77. Où est l’intention ? abstract class BaseViewModel<S: State, A: Action>

    { [...] abstract fun handle(action: A) [...] } interface Action interface State 77
  78. Où est l’intention ? class MyViewModel: BaseViewModel<MyState, MyAction> { override

    fun handle(action: MyAction) { when (action) { is LoadTasks -> loadTasks() is LoadAppointments -> loadAppointments() } } private fun loadTasks() { taskService.loadTasks { tasks, error -> updateState { state -> state.copy( taskText = tasks?.allTitles(), taskError = error ) } } } 78
  79. Et la view ? class MyActivity: Activity { var viewModel:

    MyViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) [...] //state bindings tasksButton.setOnClickListener { viewModel.handle(MyAction.LoadTasks) } appointmentsButton.setOnClickListener { viewModel.handle(MyAction.LoadAppointments) } } } 79
  80. Et la view ? class MyActivity: Activity { var viewModel:

    MyViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) [...] tasksButton.setOnClickListener { viewModel.handle(MyAction.LoadTasks) } appointmentsButton.setOnClickListener { viewModel.handle(MyAction.LoadAppointments) } } } 80
  81. Les bénéfices de l’Action - Interdit les bindings bidirectionnels -

    Décrit l’API de l’écran - Définit un formalisme pour la communication entre View et ViewModel - En Kotlin, les when doivent être exhaustifs 81
  82. Les concepts clés de MVI 82

  83. Les concepts clés - ViewModel - State (Immuable) - Intentions

    (ou Actions) 83
  84. Les concepts clés - ViewModel - State (Immuable) - Intentions

    (ou Actions) - Unidirectional Data Flow - Absence de two-way-bindings - Echange de donnée plus contraignant, mais simplifié - Plus facile à tester 84
  85. Quelques déjà vu peut-être ? Mêmes concepts que sur :

    - Elm architecture - Redux - MvRx (AirBnb) 85
  86. Bonus : collaboration iOS et Android 86

  87. Collaboration Android et iOS : Action sealed class Action {

    object LoadTasksAction: Action() object LoadAppointmentsAction: Action() } 87
  88. Collaboration Android et iOS : Action enum Action { case

    loadTasks case loadAppointments } 88
  89. Collaboration Android et iOS : State data class MyState( val

    isLoading: Boolean = false, val taskText: String? = null, val taskError: Error? = null ) 89
  90. Collaboration Android et iOS : State struct MyState { var

    isLoading: Bool = false var taskText: String? = nil var taskError: Error? = nil } 90
  91. Collaboration Android et iOS : ViewModel class MyViewModel: BaseViewModel<MyState, MyAction>

    { fun handle(action: MyAction, state: MyState){ when(action){ is LoadTasksAction -> loadTasks(state) is LoadAppointmentsAction -> loadAppointments(state) } } private fun loadTasks(state: MyState) { if (state.isLoading) { return } repository.loadTasks { tasks, error -> updateState { state -> state.copy( isLoading = false, taskText = tasks.allTitles(), taskError = error )} } } } 91
  92. Collaboration Android et iOS : ViewModel class MyViewModel: BaseViewModel<MyState, MyAction>

    { func handle(action: MyAction, state: MyState) { switch action { case .loadTasks: loadTasks(state: state) case .loadAppointments: loadAppointments(state: state) } } private func loadTasks(state: MyState) { guard !state.isLoading else { return } taskService.loadTasks { [weak self] (tasks, error) in self?.updateState { $0.isLoading = false; $0.taskText = tasks?.allTitles(); $0.taskError = error } } } 92
  93. Collaboration Android et iOS : View class TasksFragment : Fragment()

    { val viewModel: TasksViewModel by viewModel() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.state.map { it.isLoading }.distinctUntilChanged() .bind(this, this::onLoadingChanged) viewModel.state.map { it.taskText }.distinctUntilChanged() .bind(this, this::onTasksChanged) } } 93
  94. Collaboration Android et iOS : View class MyViewController: BaseViewController<MyState, MyAction,

    MyViewModel> { var viewModel: MyViewModel? public override func setup(context: SettingsContext) { super.setup(context: context) stateBindings = [ viewModel.state.map { $0.isLoading }.distinctUntilChanged() .bind(loadingIndicator.rx.isAnimating) viewModel.state.map { $0.taskText }.distinctUntilChanged() .bind(tasksTextView.rx.text) ] } } 94
  95. Un bilan 95

  96. MVI Benefices : - Séparation de la logique métier et

    de la logique de vue - Testabilité accrue (y compris isolation des side-effects) - Bindings : la vue devient extrêmement simple - Inspectabilité de l’état - Possibilité de sérialiser (et donc rejouer) actions et de ré-appliquer états 96
  97. MVI Complexités : - Un niveau d’indirection de plus -

    Prise en main plus complexe que sur MVVM - Nécessite de compétences Reactive - [Android] Pas entièrement compatible avec les composants JetPack (Navigation et Paging) - Peut nécessiter l’ajout d’une State Machine 97
  98. All problems in computer science can be solved by another

    level of indirection ...except for the problem of too many layers of indirection. - - Moi, quand je dois dire “non” au client et que j’envoie mon collègue à ma place, qui envoie son collègue à sa place, qui est bourré 98
  99. All problems in computer science can be solved by another

    level of indirection ...except for the problem of too many layers of indirection. - - Moi, quand je dois dire “non” au client et que j’envoie mon collègue à ma place, qui envoie son collègue à sa place, qui est bourré 99
  100. Questions ? 100

  101. Questions ? 101

  102. 7 et 8 octobre 2019 Beffroi de Montrouge CocoaHeads Paris

    102
  103. Questions ? 103