Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Android における Model-View-Intent アーキテクチャ

Android における Model-View-Intent アーキテクチャ

Video: https://youtu.be/MIV7Mi9zko8

Android上の開発は非同期の扱いを避ける事はできないです。ネットワーク、フレームワーク、ユーザの操作などから非同期処理が発生してます。油断してしまうとアプリが複雑化してメンテナンスが難しくなります。皆様はネットワークからレスポンスを待っている途中、ユーザが画面をローテーションしても問題ないですか?並行してユーザがいろんな操作しても大丈夫でしょうか?

Model-View-Intent アーキテクチャは非同期処理が発生する前提で考えられたため、すべてがストリームとして扱ってデータの流れを一方通行にするかつ不変オブジェクトを使うのが MVI アーキテクチャの方針です。マルチスレッディングやAndroidのライフサイクルの対応から生じる問題がアーキテクチャによって解決されるおかげでアプリのロジックに集中できるようになり、コードが書きやすく、今後の保守も楽になります!

皆様が MVI アーキテクチャの強みを理解し自分で実現できるようにする目標で Kotlin と RxJava を使って並行処理、画面ローテーション、スナックバーを含んだ画面をどうやって作っていくかを語るつもりです!

Benoît Quenaudon

February 08, 2018
Tweet

More Decks by Benoît Quenaudon

Other Decks in Programming

Transcript

  1. Android ʹ͓͚Δ
    ʊModel-View-Intentʊ
    ΞʔΩςΫνϟ
    Benoît Quenaudon @oldergod

    View full-size slide

  2. “What if the user was a function?” by Andre Staltz

    View full-size slide

  3. “What if the user was a function?” by Andre Staltz

    View full-size slide

  4. intent(user())

    View full-size slide

  5. model(intent(user()))

    View full-size slide

  6. view(model(intent(user())))

    View full-size slide

  7. user(view(model(intent(user()))))

    View full-size slide

  8. view(model(intent()))

    View full-size slide

  9. sealed class TasksIntent {
    }@

    View full-size slide

  10. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    }@

    View full-size slide

  11. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    object RefreshIntent : TasksIntent()
    }@

    View full-size slide

  12. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    object RefreshIntent : TasksIntent()
    data class ActivateTaskIntent(val task: Task) : TasksIntent()
    data class CompleteTaskIntent(val task: Task) : TasksIntent()
    }@

    View full-size slide

  13. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    object RefreshIntent : TasksIntent()
    data class ActivateTaskIntent(val task: Task) : TasksIntent()
    data class CompleteTaskIntent(val task: Task) : TasksIntent()
    object ClearCompletedTasksIntent : TasksIntent()
    }@

    View full-size slide

  14. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    object RefreshIntent : TasksIntent()
    data class ActivateTaskIntent(val task: Task) : TasksIntent()
    data class CompleteTaskIntent(val task: Task) : TasksIntent()
    object ClearCompletedTasksIntent : TasksIntent()
    data class ChangeFilterIntent(val filterType: TasksFilterType) : TasksIntent()
    }@

    View full-size slide

  15. sealed class TasksIntent {
    object InitialIntent : TasksIntent()
    object RefreshIntent : TasksIntent()
    data class ActivateTaskIntent(val task: Task) : TasksIntent()
    data class CompleteTaskIntent(val task: Task) : TasksIntent()
    object ClearCompletedTasksIntent : TasksIntent()
    data class ChangeFilterIntent(val filterType: TasksFilterType) : TasksIntent()
    }@

    View full-size slide

  16. class TasksFragment
    fun intents(): Observable {
    }@
    }@

    View full-size slide

  17. class TasksFragment
    fun intents(): Observable {
    return initialIntent()
    }@a
    private fun initialIntent(): Observable {
    return Observable.just(InitialIntent)
    }
    }@

    View full-size slide

  18. class TasksFragment
    fun_intents():_Observable {
    return Observable.merge(initialIntent(),
    refreshIntent())
    }@a
    private fun refreshIntent(): Observable {
    return RxSwipeRefreshLayout.refreshes(swipeRefreshLayout)
    .map { RefreshIntent }
    }@
    }@

    View full-size slide

  19. class TasksFragment
    fun_intents():_Observable {
    return Observable.merge(initialIntent(),
    refreshIntent(),
    completeTaskIntent(),
    activateTaskIntent(),
    clearCompletedTaskIntent(),
    changeFilterIntent())
    }@
    }@

    View full-size slide

  20. USER
    INTENTS
    Intent

    View full-size slide

  21. intents // Observable

    View full-size slide

  22. intents // Observable
    .map { intent -> actionFromIntent(intent) } // Observable

    View full-size slide

  23. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent ->
    is RefreshIntent ->
    is ActivateTaskIntent ->
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  24. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent ->
    is RefreshIntent ->
    is ActivateTaskIntent ->
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  25. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent ->
    is RefreshIntent ->
    is ActivateTaskIntent ->
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  26. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent ->
    is ActivateTaskIntent ->
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  27. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent ->
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  28. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent ->
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  29. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent -> CompleteTaskAction(intent.task)
    is ClearCompletedTasksIntent ->
    is ChangeFilterIntent ->
    }@

    View full-size slide

  30. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent -> CompleteTaskAction(intent.task)
    is ClearCompletedTasksIntent -> ClearCompletedTasksAction
    is ChangeFilterIntent ->
    }@

    View full-size slide

  31. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent -> CompleteTaskAction(intent.task)
    is ClearCompletedTasksIntent -> ClearCompletedTasksAction
    is ChangeFilterIntent -> LoadAndFilterTasksAction(intent.filterType)
    }@

    View full-size slide

  32. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent -> CompleteTaskAction(intent.task)
    is ClearCompletedTasksIntent -> ClearCompletedTasksAction
    is ChangeFilterIntent -> LoadAndFilterTasksAction(intent.filterType)
    }@

    View full-size slide

  33. private TasksAction actionFromIntent(MviIntent intent) {
    if (intent instanceof InitialIntent) {
    return LoadTasks.loadAndFilter(true, TasksFilterType.ALL_TASKS);
    }
    if (intent instanceof ChangeFilterIntent) {
    return LoadTasks.loadAndFilter(false, ((ChangeFilterIntent) intent).filterType());
    }
    if (intent instanceof RefreshIntent) {
    return LoadTasks.load(((RefreshIntent) intent).forceUpdate());
    }
    if (intent instanceof ActivateTaskIntent) {
    return ActivateTaskAction.create(((ActivateTaskIntent) intent).task());
    }
    if (intent instanceof CompleteTaskIntent) {
    return CompleteTaskAction.create(((CompleteTaskIntent) intent).task());
    }
    if (intent instanceof ClearCompletedTasksIntent) {
    return ClearCompletedTasksAction.create();
    }
    throw new IllegalArgumentException("do not know how to treat this intent " + intent);
    }

    View full-size slide

  34. private TasksAction actionFromIntent(MviIntent intent) {
    if (intent instanceof InitialIntent) {
    return LoadTasks.loadAndFilter(true, TasksFilterType.ALL_TASKS);
    }
    if (intent instanceof ChangeFilterIntent) {
    return LoadTasks.loadAndFilter(false, ((ChangeFilterIntent) intent).filterType());
    }
    if (intent instanceof RefreshIntent) {
    return LoadTasks.load(((RefreshIntent) intent).forceUpdate());
    }
    if (intent instanceof ActivateTaskIntent) {
    return ActivateTaskAction.create(((ActivateTaskIntent) intent).task());
    }
    if (intent instanceof CompleteTaskIntent) {
    return CompleteTaskAction.create(((CompleteTaskIntent) intent).task());
    }
    if (intent instanceof ClearCompletedTasksIntent) {
    return ClearCompletedTasksAction.create();
    }
    throw new IllegalArgumentException("do not know how to treat this intent " + intent);
    }

    View full-size slide

  35. private TasksAction actionFromIntent(MviIntent intent) {
    if (intent instanceof InitialIntent) {
    return LoadTasks.loadAndFilter(true, TasksFilterType.ALL_TASKS);
    }
    if (intent instanceof ChangeFilterIntent) {
    return LoadTasks.loadAndFilter(false, ((ChangeFilterIntent) intent).filterType());
    }
    if (intent instanceof RefreshIntent) {
    return LoadTasks.load(((RefreshIntent) intent).forceUpdate());
    }
    if (intent instanceof ActivateTaskIntent) {
    return ActivateTaskAction.create(((ActivateTaskIntent) intent).task());
    }
    if (intent instanceof CompleteTaskIntent) {
    return CompleteTaskAction.create(((CompleteTaskIntent) intent).task());
    }
    if (intent instanceof ClearCompletedTasksIntent) {
    return ClearCompletedTasksAction.create();
    }
    throw new IllegalArgumentException("do not know how to treat this intent " + intent);
    }

    View full-size slide

  36. private TasksAction actionFromIntent(MviIntent intent) {
    if (intent instanceof InitialIntent) {
    return LoadTasks.loadAndFilter(true, TasksFilterType.ALL_TASKS);
    }
    if (intent instanceof ChangeFilterIntent) {
    return LoadTasks.loadAndFilter(false, ((ChangeFilterIntent) intent).filterType());
    }
    if (intent instanceof RefreshIntent) {
    return LoadTasks.load(((RefreshIntent) intent).forceUpdate());
    }
    if (intent instanceof ActivateTaskIntent) {
    return ActivateTaskAction.create(((ActivateTaskIntent) intent).task());
    }
    if (intent instanceof CompleteTaskIntent) {
    return CompleteTaskAction.create(((CompleteTaskIntent) intent).task());
    }
    if (intent instanceof ClearCompletedTasksIntent) {
    return ClearCompletedTasksAction.create();
    }
    throw new IllegalArgumentException("do not know how to treat this intent " + intent);
    }

    View full-size slide

  37. fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction(TasksFilterType.ALL_TASKS)
    is RefreshIntent -> LoadTasksAction
    is ActivateTaskIntent -> ActivateTaskAction(intent.task)
    is CompleteTaskIntent -> CompleteTaskAction(intent.task)
    is ClearCompletedTasksIntent -> ClearCompletedTasksAction
    is ChangeFilterIntent -> LoadAndFilterTasksAction(intent.filterType)
    }@

    View full-size slide

  38. sealed class TasksAction {
    data class LoadAndFilterTasksAction(val filterType: TasksFilterType) : TasksAction()
    object LoadTasksAction : TasksAction()
    data class ActivateTaskAction(val task: Task) : TasksAction()
    data class CompleteTaskAction(val task: Task) : TasksAction()
    object ClearCompletedTasksAction : TasksAction()
    }@

    View full-size slide

  39. USER
    INTENTS
    Intent
    Action
    INTENT
    INTERPRETOR

    View full-size slide

  40. Action
    Load and Filter Tasks Logic
    Clear Completed Task Logic
    Complete Task Logic
    Activate Task Logic
    Load Tasks Logic
    Result

    View full-size slide

  41. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    }c

    View full-size slide

  42. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    }c

    View full-size slide

  43. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    }b
    }c

    View full-size slide

  44. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge()a
    }b
    }c

    View full-size slide

  45. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor)
    )a
    }b
    }c

    View full-size slide

  46. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor)
    )a
    }b
    }c

    View full-size slide

  47. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor),
    shared.ofType(ActivateTaskAction::class.java).compose(activateTaskProcessor)
    )a
    }b
    }c

    View full-size slide

  48. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor),
    shared.ofType(ActivateTaskAction::class.java).compose(activateTaskProcessor),
    shared.ofType(ClearCompletedTasksAction::class.java).compose(clearCompletedTasksProcessor)
    )a
    }b
    }c

    View full-size slide

  49. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor),
    shared.ofType(ActivateTaskAction::class.java).compose(activateTaskProcessor),
    shared.ofType(ClearCompletedTasksAction::class.java).compose(clearCompletedTasksProcessor),
    shared.ofType(CompleteTaskAction::class.java).compose(completeTaskProcessor)
    )a
    }b
    }c

    View full-size slide

  50. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor),
    shared.ofType(ActivateTaskAction::class.java).compose(activateTaskProcessor),
    shared.ofType(ClearCompletedTasksAction::class.java).compose(clearCompletedTasksProcessor),
    shared.ofType(CompleteTaskAction::class.java).compose(completeTaskProcessor)
    )a
    }b
    }c

    View full-size slide

  51. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    }@
    }@

    View full-size slide

  52. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .startWith(LoadTasksResult.InFlight)
    }@
    }@

    View full-size slide

  53. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .startWith(LoadTasksResult.InFlight)
    .map { tasks -> LoadTasksResult.Success(tasks) }
    }@
    }@

    View full-size slide

  54. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .startWith(LoadTasksResult.InFlight)
    .map { tasks -> LoadTasksResult.Success(tasks) }
    .onErrorReturn { t -> loadTasksResult.Failure(t) }
    }@
    }@

    View full-size slide

  55. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .startWith(LoadTasksResult.InFlight)
    .map { tasks -> LoadTasksResult.Success(tasks) }
    .onErrorReturn { t -> loadTasksResult.Failure(t) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    }@
    }@

    View full-size slide

  56. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .startWith(LoadTasksResult.InFlight)
    .map { tasks -> LoadTasksResult.Success(tasks) }
    .onErrorReturn { t -> loadTasksResult.Failure(t) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    }@
    }@

    View full-size slide

  57. val loadTasksProcessor =
    ObservableTransformer { actions: Observable ->
    actions.switchMap {
    tasksRepository.getTasks() // Observable>
    .map { tasks -> LoadTasksResult.Success(tasks) }
    .onErrorReturn { t -> loadTasksResult.Failure(t) }
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .startWith(LoadTasksResult.InFlight)
    }@
    }@

    View full-size slide

  58. var actionProcessor: ObservableTransformer =
    ObservableTransformer { actions: Observable ->
    actions.publish { shared ->
    Observable.merge(
    shared.ofType(LoadTasksAction::class.java).compose(loadTasksProcessor),
    shared.ofType(LoadAndFilterTasksAction::class.java).compose(loadAndFilterTasksProcessor),
    shared.ofType(ActivateTaskAction::class.java).compose(activateTaskProcessor),
    shared.ofType(ClearCompletedTasksAction::class.java).compose(clearCompletedTasksProcessor),
    shared.ofType(CompleteTaskAction::class.java).compose(completeTaskProcessor)
    )a
    }b
    }c

    View full-size slide

  59. intents // Observable
    .map { intent -> actionFromIntent(intent) } // Observable
    .compose(actionProcessor) // Observable

    View full-size slide

  60. USER
    INTENTS
    Intent
    Result Action
    INTENT
    INTERPRETOR
    PROCESSOR
    REPOSITORY

    View full-size slide

  61. θϩ͔Βը໘Λ࡞Δʹ͸ʁ

    View full-size slide

  62. data class TasksViewState(z
    )@

    View full-size slide

  63. data class TasksViewState(z
    val isLoading: Boolean
    )@

    View full-size slide

  64. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType
    )@

    View full-size slide

  65. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType,
    val tasks: List
    )@

    View full-size slide

  66. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType,
    val tasks: List,
    val error: Throwable?
    )@

    View full-size slide

  67. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType,
    val tasks: List,
    val error: Throwable?,
    val taskComplete: Boolean,
    val taskActivated: Boolean,
    val completedTasksCleared: Boolean
    )@

    View full-size slide

  68. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType,
    val tasks: List,
    val error: Throwable?,
    val taskComplete: Boolean,
    val taskActivated: Boolean,
    val completedTasksCleared: Boolean
    )@

    View full-size slide

  69. data class TasksViewState(z
    val isLoading: Boolean,
    val tasksFilterType: TasksFilterType,
    val tasks: List,
    val error: Throwable?,
    val taskComplete: Boolean,
    val taskActivated: Boolean,
    val completedTasksCleared: Boolean
    )@{
    companion object Factory {
    fun default() = TasksViewState(
    isLoading = false,
    tasksFilterType = ALL_TASKS,
    tasks = emptyList(),
    error = null,
    taskComplete = false,
    taskActivated = false,
    completedTasksCleared = false)
    }
    }

    View full-size slide

  70. New
    State
    Result
    Default
    State

    View full-size slide

  71. New
    State
    REDUCER
    Result
    Default
    State

    View full-size slide

  72. New
    State
    REDUCER
    Result
    Previous
    State

    View full-size slide

  73. intents // Observable
    .map { intent -> actionFromIntent(intent) } // Observable
    .compose(actionProcessor) // Observable
    .scan(TasksViewState.default(), reducer) // Observable

    View full-size slide

  74. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    }e

    View full-size slide

  75. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    }e

    View full-size slide

  76. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> /***/
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  77. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> {
    when (result) {
    is LoadTasksResult.InFlight -> /***/
    is LoadTasksResult.Failure -> /***/
    is LoadTasksResult.Success -> /***/
    }b
    }c
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  78. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> {
    when (result) {
    is LoadTasksResult.InFlight -> previousState.copy(isLoading = true)
    is LoadTasksResult.Failure -> /***/
    is LoadTasksResult.Success -> /***/
    }b
    }c
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  79. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> {
    when (result) {
    is LoadTasksResult.InFlight -> previousState.copy(isLoading = true)
    is LoadTasksResult.Failure -> previousState.copy(isLoading = false,
    error = result.error)
    is LoadTasksResult.Success -> /***/
    }b
    }c
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  80. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> {
    when (result) {
    is LoadTasksResult.InFlight -> previousState.copy(isLoading = true)
    is LoadTasksResult.Failure -> previousState.copy(isLoading = false,
    error = result.error)
    is LoadTasksResult.Success -> {
    previousState.copy(isLoading = false,
    tasks = result.tasks)
    }a
    }b
    }c
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  81. val reducer = BiFunction
    { previousState: TasksViewState, result: TasksResult ->
    when (result)_{
    is LoadTasksResult -> {
    when (result) {
    is LoadTasksResult.InFlight -> previousState.copy(isLoading = true)
    is LoadTasksResult.Failure -> previousState.copy(isLoading = false,
    error = result.error)
    is LoadTasksResult.Success -> {
    previousState.copy(isLoading = false,
    tasks = result.tasks)
    }a
    }b
    }c
    is LoadAndFilterTasksResult -> /***/
    is CompleteTaskResult -> /***/
    is ActivateTaskResult -> /***/
    is ClearCompletedTasksResult -> /***/
    }d
    }e

    View full-size slide

  82. USER
    INTENTS
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  83. intents // Observable
    .map { intent -> actionFromIntent(intent) } // Observable
    .compose(actionProcessor) // Observable
    .scan(TasksViewState.default(), reducer) // Observable

    View full-size slide

  84. intents // Observable
    .map { intent -> actionFromIntent(intent) } // Observable
    .compose(actionProcessor) // Observable
    .scan(TasksViewState.default(), reducer) // Observable
    .subscribe { state -> render(state) }

    View full-size slide

  85. fun render(state: TasksViewState) {
    }u

    View full-size slide

  86. fun render(state: TasksViewState) {
    swipeRefreshLayout.isRefreshing = state.isLoading
    }u

    View full-size slide

  87. fun render(state: TasksViewState) {
    swipeRefreshLayout.isRefreshing = state.isLoading
    if (state.error != null) {
    showLoadingTasksError()
    return
    }a
    }u

    View full-size slide

  88. fun render(state: TasksViewState) {
    swipeRefreshLayout.isRefreshing = state.isLoading
    if (state.error != null) {
    showLoadingTasksError()
    return
    }a
    if (state.taskActivated) {
    showMessage(getString(R.string.task_marked_active))
    }b
    if (state.taskComplete) {
    showMessage(getString(R.string.task_marked_complete))
    }c
    if (state.completedTasksCleared) {
    showMessage(getString(R.string.completed_tasks_cleared))
    }d
    }u

    View full-size slide

  89. if (state.taskActivated) {
    showMessage(getString(R.string.task_marked_active))
    }b
    if (state.taskComplete) {
    showMessage(getString(R.string.task_marked_complete))
    }c
    if (state.completedTasksCleared) {
    showMessage(getString(R.string.completed_tasks_cleared))
    }d
    if (state.tasks.isEmpty()) {
    when (state.tasksFilterType) {z
    ACTIVE_TASKS -> showNoActiveTasks()
    COMPLETED_TASKS -> showNoCompletedTasks()
    ALL_TASKS -> showNoTasks()
    }e
    }_
    }u

    View full-size slide

  90. showMessage(getString(R.string.completed_tasks_cleared))
    }d
    if (state.tasks.isEmpty()) {
    when (state.tasksFilterType) {z
    ACTIVE_TASKS -> showNoActiveTasks()
    COMPLETED_TASKS -> showNoCompletedTasks()
    ALL_TASKS -> showNoTasks()
    }e
    }_else {
    listAdapter.replaceData(state.tasks)
    tasksView.visibility = View.VISIBLE
    noTasksView.visibility = View.GONE
    when (state.tasksFilterType) {
    ACTIVE_TASKS -> showActiveFilterLabel()
    COMPLETED_TASKS -> showCompletedFilterLabel()
    ALL_TASKS -> showAllFilterLabel()
    }g
    }h
    }u

    View full-size slide

  91. fun render(state: TasksViewState) {
    swipeRefreshLayout.isRefreshing = state.isLoading
    if (state.error != null) {
    showLoadingTasksError()
    return
    }a
    if (state.taskActivated) {
    showMessage(getString(R.string.task_marked_active))
    }b
    if (state.taskComplete) {
    showMessage(getString(R.string.task_marked_complete))
    }c
    if (state.completedTasksCleared) {
    showMessage(getString(R.string.completed_tasks_cleared))
    }d
    if (state.tasks.isEmpty()) {
    when (state.tasksFilterType) {
    ACTIVE_TASKS -> showNoActiveTasks()
    COMPLETED_TASKS -> showNoCompletedTasks()
    ALL_TASKS -> showNoTasks()
    }e
    }_else {
    listAdapter.replaceData(state.tasks)
    tasksView.visibility = View.VISIBLE
    noTasksView.visibility = View.GONE
    when (state.tasksFilterType) {
    ACTIVE_TASKS -> showActiveFilterLabel()
    COMPLETED_TASKS -> showCompletedFilterLabel()
    ALL_TASKS -> showAllFilterLabel()
    }g
    }h
    }u

    View full-size slide

  92. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  93. effects sidS
    Side effects

    View full-size slide

  94. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  95. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  96. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  97. USER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    REPOSITORY
    INTENTS
    RENDER
    PROCESSOR

    View full-size slide

  98. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY
    Unidirectional data flow

    View full-size slide

  99. USER
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  100. USER
    USER INTERFACE INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  101. USER
    USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  102. interface TasksUi {
    fun render(state: TasksViewState)
    fun intents(): Observable
    }@
    interface TasksViewModel {
    fun processIntents(intents: Observable)
    fun states(): Observable
    }@

    View full-size slide

  103. interface TasksUi {
    fun render(state: TasksViewState)
    fun intents(): Observable
    }@
    interface TasksViewModel {
    fun processIntents(intents: Observable)
    fun states(): Observable
    }@

    View full-size slide

  104. interface TasksUi {
    fun render(state: TasksViewState)
    fun intents(): Observable
    }@
    interface TasksViewModel {
    fun processIntents(intents: Observable)
    fun states(): Observable
    }@

    View full-size slide

  105. ฼͔Βͷ͓ి࿩Ͱ͢

    View full-size slide

  106. User: initialIntent()
    App: render()
    User: activateTask(1)
    User: activateTask(2)
    User: refresh()
    Mum:
    Android: you.onStop()

    View full-size slide

  107. User: initialIntent()
    App: render()
    User: activateTask(1)
    User: activateTask(2)
    User: refresh()
    Mum:
    Android: you.onStop()

    View full-size slide

  108. User: initialIntent()
    App: render()
    User: activateTask(1)
    User: activateTask(2)
    User: refresh()
    Mum:
    Android: you.onStop()

    View full-size slide

  109. User: initialIntent()
    App: render()
    User: activateTask(1)
    User: activateTask(2)
    User: refresh()
    Mum:
    Android: app.onStop()

    View full-size slide

  110. USER
    USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY
    C
    B
    A

    View full-size slide

  111. INTENTS
    RENDER
    VIEW MODEL
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY
    B
    C
    A
    USER
    USER INTERFACE
    B
    C
    A

    View full-size slide

  112. USER
    USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY
    A

    View full-size slide

  113. Config change ΤϒϦ΢ΣΞ

    View full-size slide

  114. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  115. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER

    View full-size slide

  116. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER

    View full-size slide

  117. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    SUBJECT

    View full-size slide

  118. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    SUBJECT

    View full-size slide

  119. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    SUBJECT

    View full-size slide

  120. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    SUBJECT
    .autoConnect(0)

    View full-size slide

  121. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    .autoConnect(0) SUBJECT

    View full-size slide

  122. VIEW MODEL
    .autoConnect(0) SUBJECT

    View full-size slide

  123. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    .autoConnect(0) SUBJECT

    View full-size slide

  124. USER INTERFACE
    VIEW MODEL
    SOURCE
    SUBSCRIBER
    .replay(1)
    .autoConnect(0)
    SUBJECT

    View full-size slide

  125. class TasksViewModel : ViewModel() {
    fun processIntents(intents: Observable) {
    }b
    fun states(): Observable {
    }c
    }e

    View full-size slide

  126. class TasksViewModel : ViewModel() {
    private val intentsSubject = PublishSubject.create()
    fun processIntents(intents: Observable) {
    intents.subscribe(intentsSubject)
    }b
    fun states(): Observable {
    }c
    }e

    View full-size slide

  127. class TasksViewModel : ViewModel() {
    private val intentsSubject = PublishSubject.createval states: Observable = compose()
    fun processIntents(intents: Observable) {
    intents.subscribe(intentsSubject)
    }b
    private fun compose(): Observable {
    }c
    }e

    View full-size slide

  128. class TasksViewModel : ViewModel() {
    private val intentsSubject = PublishSubject.create()
    val states: Observable = compose()
    fun processIntents(intents: Observable) {
    intents.subscribe(intentsSubject)
    }b
    private fun compose(): Observable {
    return intentsSubject
    .map { intent -> actionFromIntent(intent) }
    .compose(actionProcessor)
    .scan(TasksViewState.default(), reducer)
    }c
    }e

    View full-size slide

  129. class TasksViewModel : ViewModel() {
    private val intentsSubject = PublishSubject.create()
    val states: Observable = compose()
    fun processIntents(intents: Observable) {
    intents.subscribe(intentsSubject)
    }b
    private fun compose(): Observable {
    return intentsSubject
    .map { intent -> actionFromIntent(intent) }
    .compose(actionProcessor)
    .scan(TasksViewState.default(), reducer)
    .replay(1)
    .autoConnect(0)
    }c
    }e

    View full-size slide

  130. class TasksViewModel : ViewModel() {
    private val intentsSubject = PublishSubject.create()
    val states: Observable = compose()
    fun processIntents(intents: Observable) {
    intents.subscribe(intentsSubject)
    }b
    private fun compose(): Observable {
    return intentsSubject
    .map { intent -> actionFromIntent(intent) }
    .compose(actionProcessor)
    .scan(TasksViewState.default(), reducer)
    .replay(1)
    .autoConnect(0)
    }c
    }e

    View full-size slide

  131. class TasksFragment : Fragment() {
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    }d

    View full-size slide

  132. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    }d

    View full-size slide

  133. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    viewModel.states().subscribe(this::render)
    }b
    }d

    View full-size slide

  134. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    private val disposables = CompositeDisposable()a
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    disposables.add(viewModel.states().subscribe(this::render))
    }b
    }d

    View full-size slide

  135. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    private val disposables = CompositeDisposable()
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    disposables.add(viewModel.states().subscribe(this::render))
    viewModel.processIntents(intents())
    }b
    }d

    View full-size slide

  136. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    private val disposables = CompositeDisposable()
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    disposables.add(viewModel.states().subscribe(this::render))
    viewModel.processIntents(intents())
    }b
    override fun onDestroy() {
    disposables.dispose()
    super.onDestroy()
    }c
    }d

    View full-size slide

  137. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    private val disposables = CompositeDisposable()
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    disposables.add(viewModel.states().subscribe(this::render))
    viewModel.processIntents(intents())
    }b
    override fun onDestroy() {
    disposables.dispose()
    super.onDestroy()
    }c
    }d

    View full-size slide

  138. class TasksFragment : Fragment() {
    private val viewModel: TasksViewModel by lazy(NONE) {
    ViewModelProviders.of(this).get(TasksViewModel::class.java)
    }a
    private val disposables = CompositeDisposable()
    fun intents(): Observable { /***/ }
    fun render(state: TasksViewState) { /***/ }
    override fun onStart() {
    disposables.add(viewModel.states().subscribe(this::render))
    viewModel.processIntents(intents())
    }b
    override fun onDestroy() {
    disposables.dispose()
    super.onDestroy()
    }c
    }d

    View full-size slide

  139. class TasksFragment
    fun intents(): Observable {
    return Observable.merge(initialIntent(),
    refreshIntent(),
    completeTaskIntent(),
    activateTaskIntent(),
    clearCompletedTaskIntent(),
    changeFilterIntent())
    }@
    }@

    View full-size slide

  140. class TasksFragment
    fun intents(): Observable {
    return Observable.merge(initialIntent(),
    refreshIntent(),
    completeTaskIntent(),
    activateTaskIntent(),
    clearCompletedTaskIntent(),
    changeFilterIntent())
    }@
    }@
    fun actionFromIntent(intent: TasksIntent): TasksAction =
    when (intent) {
    is InitialIntent -> LoadAndFilterTasksAction
    /***/
    }@

    View full-size slide

  141. private fun compose(): Observable {
    return intentsSubject
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a

    View full-size slide

  142. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a

    View full-size slide

  143. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a
    private val intentFilter: ObservableTransformer
    get() = ObservableTransformer { intents ->
    }z

    View full-size slide

  144. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a
    private val intentFilter: ObservableTransformer
    get() = ObservableTransformer { intents ->
    intents.publish { shared ->
    Observable.merge()w
    }o
    }z

    View full-size slide

  145. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a
    private val intentFilter: ObservableTransformer
    get() = ObservableTransformer { intents ->
    intents.publish { shared ->
    Observable.merge(
    shared.ofType(InitialIntent::class.java).take(1)
    )w
    }o
    }z

    View full-size slide

  146. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a
    private val intentFilter: ObservableTransformer
    get() = ObservableTransformer { intents ->
    intents.publish { shared ->
    Observable.merge(
    shared.ofType(InitialIntent::class.java).take(1),
    shared.notOfType(InitialIntent::class.java)
    )w
    }o
    }z

    View full-size slide

  147. private fun compose(): Observable {
    return intentsSubject
    .compose(intentFilter)
    .map { this.actionFromIntent(it) }
    .compose(actionProcessorHolder.actionProcessor)
    .scan(TasksViewState.idle(), reducer)
    .replay(1)
    .autoConnect(0)
    }a
    private val intentFilter: ObservableTransformer
    get() = ObservableTransformer { intents ->
    intents.publish { shared ->
    Observable.merge(
    shared.ofType(InitialIntent::class.java).take(1),
    shared.notOfType(InitialIntent::class.java)
    )w
    }o
    }z

    View full-size slide

  148. Ͷ͐஌ͬͯΔʁςετʂ

    View full-size slide

  149. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  150. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    SUBJECT
    PROCESSOR
    REPOSITORY

    View full-size slide

  151. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    TEST OBSERVER
    PROCESSOR
    REPOSITORY

    View full-size slide

  152. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  153. USER INTERFACE
    VIEW MODEL
    SUBJECT
    TEST OBSERVER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  154. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  155. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  156. USER INTERFACE
    VIEW MODEL
    INTENTS
    RENDER
    Intent
    Result Action
    State
    INTENT
    INTERPRETOR
    REDUCER
    PROCESSOR
    REPOSITORY

    View full-size slide

  157. Action
    Load and Filter Tasks Logic
    Clear Completed Task Logic
    Complete Task Logic
    Activate Task Logic
    Load Tasks Logic
    Result PROCESSOR

    View full-size slide

  158. Load and Filter Tasks Logic
    Clear Completed Task Logic
    Complete Task Logic
    Activate Task Logic
    Load Tasks Logic SUBJECT
    TEST OBSERVER
    SUBJECT
    SUBJECT
    SUBJECT
    SUBJECT
    TEST OBSERVER
    TEST OBSERVER
    TEST OBSERVER
    TEST OBSERVER

    View full-size slide

  159. Fin
    Benoît Quenaudon @oldergod

    View full-size slide