Why MVI?

Introducing MVI

Introducing MVI

State

UI

Action

(ViewModel/Presenter)

(Fragment/ViewController)

User

Intent

Model

View

Reducer

State

Data source

Intent

Transformer

View

Model

Reducer

State

Data source

Intent

Transformer

View

Model

Side effect

Key concepts

  • Unidirectional cycle of data
  • Non-blocking
  • Immutable state

MVI - benefits

MVI - benefits

  • Simplicity
  • Well defined contract
  • Easy unit testing

MVI - benefits

  • Screenshot testing
  • Interaction testing
  • Time-travel debugging

Testing apps

Use cases & repos

Backend

MVI

UI

Testing apps

Use cases & repos

Backend

MVI

UI

Testing apps

Use cases & repos

Backend

MVI

UI

(c) Martin Fowler

Testing apps

Use cases & repos

Backend

MVI

UI

Screenshot

Unit

Interaction

Contract / Integration

Testing apps

Screenshot E2E UI
Airbnb ~ 30 000  none
Uber thousands handful
Spotify ~ 1 600 ~ 500
Shopify ~ 2 300 ~ 20

"Building Mobile Apps at scale - 39 Engineering Challenges"

State

Screenshot testing

State

Screenshot testing

Screenshot testing

Screenshot testing

Screenshot testing

State

Interaction testing

Interaction testing

Interaction testing

Interaction testing

"button_close": {
  "view": "ImageView",
  "click": [
    {
      "onExitRequested": {
        "params": [
            createCircleRequestDomain: "{circleName="Test",coverPhoto="file://something.jpg"}"
        ]
      }
    }
  ]
}

Interaction testing

"button_close": {
  "view": "ImageView",
  "click": [
    "onExitRequested": {
      "params": [
          createCircleRequestDomain: "{circleName="Test",coverPhoto="file://something.jpg"}"
      ]
    }
  ]
}

Time travel

Reducer

State

Data source

Intent

Transformer

View

Model

Time travel

State

Intent

Time travel

t

MVP to MVI

MVP to MVI

  • Unidirectional cycle of data
  • Non-blocking
  • Immutable state

MVI framework

Step1 - translate contract

State

Side effect

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}

OR

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void showCreateSpinner();
        void showEditSpinner();
        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showCreateCircleErrorDialog();
        void showEditCircleErrorDialog();
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        SHOW_ERROR
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithCreateConfirmationDialog();
        void exitWithEditConfirmationDialog();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        SHOW_ERROR,
        SHOW_EXIT_CONFIRMATION
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void showInitEditErrorDialog();
        void enableCTA();
        void disableCTA();
        void exitWithoutConfirmationDialog();
        void exitToAggregatedFeed();
        void showDeleteConfirmationDialog();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {


    enum Simple implements CreateOrEditSideEffect {
        CLOSE,
        OPEN_AGGREGATED_FEED,
        SHOW_DELETE_CONFIRMATION,
        SHOW_EXIT_CONFIRMATION,
        SHOW_ERROR,
        SHOW_INIT_EDIT_ERROR
    }

}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void redirectToCircleFeedScreen(final String circleId);
        void redirectToCircleInviteScreen(final String circleId);
        void enableCTA();
        void disableCTA();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
public interface CreateOrEditSideEffect {

    @AutoValue
    abstract class OpenCircleInvite implements CreateOrEditSideEffect {

        public abstract String circleId();

        ...
    }

    @AutoValue
    abstract class OpenCircleFeed implements CreateOrEditSideEffect {

        public abstract String circleId();

        ...
    }

    enum Simple implements CreateOrEditSideEffect {
        ...
    }
}

public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        void enableCTA();
        void disableCTA();
        void setEditableCircleData(CircleConfigModel result);
    }

    abstract class Presenter extends SimplePresenter<View> {
        ...
    }

}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();
    
    public abstract Optional<CircleConfigModel> editedCircle();

    public abstract boolean ctaEnabled();

    public enum Mode {
        CREATE,
        EDIT;
    }
}
@AutoValue
public abstract class CreateOrEditState {

    public abstract Mode mode();
    
    public abstract boolean committingChanges();
    
    public abstract Optional<CircleConfigModel> editedCircle();

    public abstract boolean ctaEnabled();
    

    public CreateOrEditState withCommittingChanges(final boolean committingChanges) {
        return toBuilder()
                .committingChanges(committingChanges)
                .build();
    }
    
    public abstract Builder toBuilder();

    public static Builder builder() {
        return new AutoValue_CreateOrEditState.Builder()
                .mode(Mode.CREATE)
                .ctaEnabled(false)
                .committingChanges(false);
    }

    @AutoValue.Builder
    public abstract static class Builder {

        public abstract Builder mode(final Mode mode);

        public abstract Builder editedCircle(final CircleConfigModel editedCircle);

        public abstract Builder ctaEnabled(final boolean ctaEnabled);

        public abstract Builder committingChanges(final boolean committingChanges);

        public abstract CreateOrEditState build();
    }

    public enum Mode {
        CREATE,
        EDIT;
    }
}
@Value.Immutable
public abstract class CreateOrEditState {

    @Value.Default
    public Mode mode() {
        return Mode.CREATE;
    }

    public abstract Optional<CircleConfigModel> editedCircle();

    @Value.Default
    public boolean ctaEnabled() {
        return false;
    }

    @Value.Default
    public boolean committingChanges() {
        return false;
    }

    public enum Mode {
        CREATE,
        EDIT;
    }
}
public interface CreateOrEditCircleContract {

    interface View extends BaseView {

        // yay
    }

    abstract class Presenter extends SimplePresenter<View> {
    
        public abstract void onCheckFormRequested(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onFormSubmitted(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onExitFromCreateCircleFlowRequested(final CreateOrEditCircleRequestDomain createCircleRequestDomain);

        public abstract void onEditCircleRequested(final String circleIdToEdit);

        public abstract void onDeleteCircleRequested();

        public abstract void onDeleteCircleConfirmed();
    }

}

Step 2 - processing in background

public class CreateOrEditCirclePresenter extends CreateOrEditCircleContract.Presenter {

    public static final String TAG = "CreateCirclePresenter";
    private final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase;
    private final CirclesRepository circlesRepository;
    private final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase;
    private final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase;
    private CreateOrEditCircleContract.Mode mode = CreateOrEditCircleContract.Mode.CREATE;
    private CircleConfigModel editedCircle;
    
    public CreateOrEditCirclePresenter(
            final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase,
            final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase,
            final CirclesRepository circlesRepository,
            final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase
    ) {
        this.shouldAllowFormSubmissionUseCase = shouldAllowFormSubmissionUseCase;
        this.hasCreateCircleRequestChangedUseCase = hasCreateCircleRequestChangedUseCase;
        this.circlesRepository = circlesRepository;
        this.prepareEditCircleRequestUseCase = prepareEditCircleRequestUseCase;
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    public static final String TAG = "CreateCirclePresenter";
    private final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase;
    private final CirclesRepository circlesRepository;
    private final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase;
    private final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase;
    
    public CreateOrEditCirclePresenter(
            final ShouldAllowFormSubmissionUseCase shouldAllowFormSubmissionUseCase,
            final HasCreateCircleRequestChangedUseCase hasCreateCircleRequestChangedUseCase,
            final CirclesRepository circlesRepository,
            final PrepareEditCircleRequestUseCase prepareEditCircleRequestUseCase
    ) {
        super(ImmutableCreateOrEditState.builder().build());
        this.shouldAllowFormSubmissionUseCase = shouldAllowFormSubmissionUseCase;
        this.hasCreateCircleRequestChangedUseCase = hasCreateCircleRequestChangedUseCase;
        this.circlesRepository = circlesRepository;
        this.prepareEditCircleRequestUseCase = prepareEditCircleRequestUseCase;
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        ifAttached(view -> view.showEditSpinner());

        circlesRepository.deleteCircle(editedCircle.id())
                .subscribeOnUiThread(
                        result -> ifAttached(view -> view.exitToAggregatedFeed()),
                        exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                );
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            ifAttached(view -> view.showEditSpinner());

            circlesRepository.deleteCircle(editedCircle.id())
                    .subscribeOnUiThread(
                            result -> ifAttached(view -> view.exitToAggregatedFeed()),
                            exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                    );
        }
    }
    
    ...
    
}

Step 3 - translate view interactions

public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            ifAttached(view -> view.showEditSpinner());

            circlesRepository.deleteCircle(editedCircle.id())
                    .subscribeOnUiThread(
                            result -> ifAttached(view -> view.exitToAggregatedFeed()),
                            exception -> ifAttached(view -> view.showEditCircleErrorDialog())
                    );
        }
    }
    
    ...
    
}
public class CreateOrEditCirclePresenter extends 
        MviContainerHost<ImmutableCreateOrEditState, CreateOrEditSideEffect> {

    ...
    
    public void onDeleteCircleConfirmed() {
    
        intent(mvi -> {
            mvi.reduce(currentState -> currentState.withCommittingChanges(true));

            circlesRepository.deleteCircle(mvi.getState().editedCircle().get().id())
                    .subscribe(
                            result -> 
                                mvi.postSideEffect(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED),
                            exception -> {
                                mvi.reduce(currentState -> currentState.withCommittingChanges(false));
                                mvi.postSideEffect(CreateOrEditSideEffect.Simple.SHOW_ERROR);
                            }
                    );
        });
    }
    
    ...
    
}

Step 4 - refactor view

override fun onCreate(savedInstanceState: Bundle?) {
        
    ...

    presenter.subscribe(
            lifecycleScope,
            lifecycle,
            ::handleState,
            ::handleSideEffect
        )
}
private fun handleState(createOrEditState: ImmutableCreateOrEditState) {

        with(createOrEditState) {
        
            updateCtaState(ctaEnabled())

            if (committingChanges()) {
                if (mode() == CreateOrEditState.Mode.CREATE) {
                    showCreateSpinner()
                } else {
                    showEditSpinner()
                }
            } else {
                hideSpinner()
            }

            if (mode == CreateOrEditState.Mode.EDIT && 
                     !editInitialized && 
                     editedCircle() != null) {
                editInitialized = true
                setEditableCircleData(editedCircle().get())
            }
        }
    }
private fun handleSideEffect(sideEffect: CreateOrEditSideEffect) {

        when (sideEffect) {
            is CreateOrEditSideEffect.OpenCircleInvite -> redirectToCircleInviteScreen(sideEffect.circleId())
            is CreateOrEditSideEffect.OpenCircleFeed -> redirectToCircleFeedScreen(sideEffect.circleId())
            CreateOrEditSideEffect.Simple.CLOSE -> exitWithoutConfirmationDialog()
            CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED -> exitToAggregatedFeed()
            CreateOrEditSideEffect.Simple.SHOW_DELETE_CONFIRMATION -> showDeleteConfirmationDialog()
            CreateOrEditSideEffect.Simple.SHOW_EXIT_CONFIRMATION -> if (mode == CreateOrEditState.Mode.CREATE) {
                exitWithCreateConfirmationDialog()
            } else {
                exitWithEditConfirmationDialog()
            }
            CreateOrEditSideEffect.Simple.SHOW_ERROR -> if (mode == CreateOrEditState.Mode.CREATE) {
                showCreateCircleErrorDialog()
            } else {
                showEditCircleErrorDialog()
            }
            CreateOrEditSideEffect.Simple.SHOW_INIT_EDIT_ERROR -> showInitEditErrorDialog()
        }
        
    }

How does it look on iOS?

class CreateEditCircleViewController: FabulousViewController {

    ...

    @Inject({ $0.presenter.createCreateOrEditCirclePresenter() })
    private var presenter: CreateOrEditCirclePresenter

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        presenter
            .subscribe(
                stateHandler: { [weak self] state in
                    self?.handleState(state: state)
                },
                sideEffectHandler: { [weak self] sideEffect in
                    self?.handleSideEffect(sideEffect: sideEffect)
                }
            )
            .disposed(by: disposeBag)
    }
    func handleState(state: CreateOrEditState) {
        nameTextField.text = state.circleName()
        if state.coverImage().isPresent(),
           let imagePath = state.coverImage().get() as? String,
           let imageUrl = URL(string: imagePath) {
            imageView.image = try? UIImage(url: imageUrl)
        }
        saveButton.isEnabled = state.ctaEnabled()
        spinner.isHidden = state.committingChanges().isFalse
    }

    @IBAction private func nameChanged(_ sender: Any) {
        presenter.onNameChanged(with: nameTextField.text ?? "")
    }

    @IBAction private func saveButtonTapped(_ sender: Any) {
        presenter.onFormSubmitted()
    }
    func handleSideEffect(sideEffect: CreateOrEditSideEffect) {
        switch sideEffect {

        case let openCircleInvite as CreateOrEditSideEffect_OpenCircleInvite:
            openCircleInviteScreen(circleId: openCircleInvite.circleId())

        case let openCircleFeed as CreateOrEditSideEffect_OpenCircleFeed:
            openCircleFeedScreen(circleId: openCircleFeed.circleId())

        case let simpleEffect as CreateOrEditSideEffect_Simple:
            switch simpleEffect.toNSEnum() {
            case CreateOrEditSideEffect_Simple_Enum.CLOSE:
                dismissScreen()
            case CreateOrEditSideEffect_Simple_Enum.OPEN_AGGREGATED_FEED:
                dismissToAggregatedFeed()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_DELETE_CONFIRMATION:
                showDeleteConfirmation()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_EXIT_CONFIRMATION:
                showExitConfiramtion()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_ERROR:
                showErrorDialog()
            case CreateOrEditSideEffect_Simple_Enum.SHOW_INIT_EDIT_ERROR:
                showInitEditErrorDialog()
            default:
                break
            }

        default:
            break
        }
    }

Unit testing

@Test
fun `should exit when delete is confirmed and completes successfully`() {
    val fakeCircle = fixture<CircleConfigModel>()
    val presenter = createPresenter(
        circlesRepository = mock {
            on { deleteCircle(fakeCircle.id()) } doReturn Task.empty()
        }
    )
    val initialState = ImmutableCreateOrEditState.builder()
        .mode(CreateOrEditState.Mode.EDIT)
        .editedCircle(fakeCircle)
        .build()
    val testPresenter = presenter.test(initialState)

    testPresenter.testIntent {
        onDeleteCircleConfirmed()
    }

    testPresenter.assert(initialState) {
        states(
            { withCommittingChanges(true) }
        )
        postedSideEffects(CreateOrEditSideEffect.Simple.OPEN_AGGREGATED_FEED)
    }
}
testPresenter.assert(defaultInitialState) {
    states(
        { withCommittingChanges(true).withMode(CreateOrEditState.Mode.EDIT) },
        { withCommittingChanges(false) }
    )
}

Conclusions

To do

  • Improve lifecycle management (e.g. ViewModel)
  • Side effect caching
  • State saving

Next steps

  • Do we want this in our app?
  • Draw up implementation plan
  • Implement missing/extra features

Thanks!

Why MVI?

By Mikołaj Leszczyński