跪拜 Guibai
← Back to the summary

From MVP to MVI, Java to Kotlin: The Migration Pitfalls That Cost a Year

Last year at this time, I took over a legacy Android project. The codebase wasn't large, but the Presenter was full of null checks on View, Activity had Handler and AsyncTask ghosts floating around, and a few lines of Kotlin occasionally appeared in Java files like patches. Business needed new features, but every change felt like dancing through a minefield.

So we decided on a thorough upgrade: architecture from MVP to MVI, language from Java to pure Kotlin.

A year later, the project runs smoothly on the new architecture, crash rates are down, and new hires get up to speed faster. But looking back, the pitfalls we stepped on were plenty. This article doesn't explain textbook MVI principles; it only talks about the things during real migration that you'd wish you'd known earlier.


1. First, think clearly: you're not "switching frameworks," you're "switching ways of thinking"

The biggest difference between MVP and MVI isn't renaming Presenter to ViewModel; it's the direction of data flow.

In MVP, Presenter holds the View interface. User clicks button → Presenter calls View's method to show Loading → request returns → Presenter calls View's method to refresh the list. It's a bidirectional, imperative process. Your code is full of if (view != null) and view.showLoading().

MVI reverses this: user clicks button → emits an Intent → ViewModel processes the intent → generates a new State → UI observes the State change → re-renders. UI is always passive; it does only one thing: draw the interface based on State.

This means you can no longer write imperative code like adapter.addData(newItem) in an Activity. All UI changes must go through the chain of "send intent → change state → observe state." If someone on your team is used to directly manipulating View in callbacks, they will suffer a lot after migrating to MVI.

So the first step isn't touching code; it's aligning understanding. Gather the team in front of a whiteboard for an afternoon and draw the difference between the old and new data flows. Let everyone understand: from now on there's no view.showToast(); a toast is either an Event or a field in State.


2. Kotlin migration: don't trust automatic conversion, especially null-safety

From Java to Kotlin, Android Studio's Code → Convert Java File to Kotlin File is handy. But the code it generates is only "compilable Kotlin," not "good Kotlin" , even less "safe Kotlin" .

The most typical pitfall is platform types. A Java String becomes String! in Kotlin; the compiler doesn't force you to check null. If you treat it as non-null directly, NPEs still happen at runtime, only now the stack trace shows a line of Kotlin code, making it harder to locate.

Our approach: manually review every converted file, explicitly declaring every return value from Java as nullable or non-null. Better to write one extra ? than to let platform types run wild. Fill in @Nullable / @NonNull annotations on the Java side so Kotlin generates correct types during conversion.

Another easily overlooked issue is default parameters and Java interop. Kotlin's fun foo(a: Int, b: String = "x") isn't friendly to Java callers because Java has no default parameters. If your old Java code still calls these new Kotlin methods, remember to add @JvmOverloads.

Also static methods and companion objects. Java's static becomes companion object in Kotlin, but calling MyClass.INSTANCE.foo() from Java looks silly. Use @JvmStatic on companion object methods; then Java can call them like static methods.

Recommended migration pace: migrate model and utility classes first (stateless, few dependencies, low risk), then Repository, and finally the Presenter / View layer. Always write new code in Kotlin, and replace old code module by module. Running both side by side for a while is fine; Gradle handles mixed compilation automatically.


3. MVP Presenter to MVI ViewModel: the hardest part is "splitting state"

This is the most time-consuming part of the entire migration, no shortcuts.

A typical MVP Presenter might look like this: showLoading(), hideLoading(), showList(list), showError(msg), showEmpty(). These method calls are scattered across callbacks, and their order is guaranteed manually.

In MVI, you need to consolidate these discrete UI commands into a single state object:

data class UserListState(
    val isLoading: Boolean = false,
    val users: List<User> = emptyList(),
    val errorMessage: String? = null,
    val isEmpty: Boolean = false
)

The difficulty is expressing mutually exclusive states. In old code, showLoading() and showError() might be called at the same time (because different callbacks weren't coordinated), but in a data class, having both isLoading = true and errorMessage != null is a logical bug. Using sealed class to express mutually exclusive states is safer:

sealed interface UserListState {
    object Loading : UserListState
    data class Success(val users: List<User>) : UserListState
    data class Error(val message: String) : UserListState
    object Empty : UserListState
}

But sealed class has the cost of not convenient copy(), while MVI's StateFlow.update { it.copy(...) } heavily depends on data class. A compromise: use data class for the main state, use nullable fields internally to express mutual exclusion, and use require or check in init for validation. Or accept sealed class, but split additional states like "load more" into separate StateFlows.

A pragmatic suggestion: don't chase one-shot perfection. The first version of MVI can keep a data class with slightly redundant fields; first run the unidirectional data flow. Once the team is familiar, gradually tighten state constraints.


4. StateFlow is good, but don't stuff Events into it

MVI tutorials usually only cover State, but real projects definitely have one-shot events: Toast, navigation, closing a page, showing a Dialog. The characteristic of these is: consumed once and gone, and they shouldn't be replayed after screen rotation.

If you put toastMessage in State, the user sees the Toast → rotates the screen → StateFlow replays the latest value → Toast appears again. That's the classic sticky event problem.

The solution is to separate state and events:

private val _events = Channel<UserListEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

// send event
viewModelScope.launch {
    _events.send(UserListEvent.ShowToast("Saved successfully"))
}

On the UI side, collect events with repeatOnLifecycle to ensure consumption only above STARTED state:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                is UserListEvent.ShowToast -> Toast.makeText(...).show()
                is UserListEvent.NavigateToDetail -> navigate(...)
            }
        }
    }
}

Note: Channel is a hot stream. If the UI hasn't started collecting when an event is sent, the event is buffered but won't be lost (in BUFFERED mode). However, if events are sent too frequently and consumption is slow, the buffer fills and send suspends. For low-frequency events (navigation, Toast), Channel is sufficient. High-frequency events (like per-frame scroll position) shouldn't be events at all; they belong in State.


5. Coroutines: the distance from "usable" to "used well"

Kotlin coroutines are MVI's natural partner, but developers coming from Java make two common mistakes.

First mistake: swallowing CancellationException. Catching all exceptions in runCatching or try-catch, including the CancellationException thrown when a coroutine is cancelled, and then displaying it as a normal error to the user. Result: the user leaves the page, the coroutine gets cancelled, and the UI pops a "network error." The correct approach is to check in catch first: if (e is CancellationException) throw e.

Second mistake: not handling request races. The user quickly switches filter conditions; request A and request B are sent concurrently, and A returns later and overwrites B's result. This problem existed in MVP too, but MVI's StateFlow masks it because state updates are asynchronous and hard to notice from the UI. The solution is to cancel the previous task or use a request sequence number:

private var searchJob: Job? = null

fun search(query: String) {
    searchJob?.cancel()
    searchJob = viewModelScope.launch {
        // ...
    }
}

If the underlying data source doesn't support coroutine cancellation (like some old networking libraries), assign each request an incrementing requestId and check whether the requestId is still the latest before updating the state.

Third mistake (more subtle): starting a coroutine in viewModelScope, but inside Repository methods using GlobalScope. As a result, the ViewModel is cleared, the background task keeps running, and later tries to update an already destroyed State. Unify scopes: use viewModelScope in the ViewModel layer; tasks that need to outlive the ViewModel (like database writes) go down to the Repository's own scope, with clear ownership of who cancels.


6. Gradual migration: let MVP and MVI coexist for a while

An old project can't be fully converted overnight. Our strategy: new pages directly use MVI + Kotlin, old pages stay on MVP, and they connect via navigation and a shared Repository.

The benefit is controlled risk: the new architecture is validated in a small scope, and problems don't affect core flows. The downside is cognitive load: the same person may write MVI Intent in the morning and fix MVP Presenter in the afternoon. Our mitigation: put an ARCHITECTURE.md at the project root, with a diagram distinguishing the boundaries between the "new world" and "old world," and strictly review new code to ensure it follows unidirectional data flow.

Another lesson: don't pick the core module for the first migration. Pick a medium-complexity page with clear input/output (like a "Settings page" or "About page") to run through the complete chain of Intent → State → UI, establish code style and utilities (like StateFlow collection extensions, Channel event wrappers). Once this "template" is stable, then push to core modules. The Passbolt team also mentioned during their migration that the first two modules are slowest because you're defining rules; every subsequent module gets faster.


7. Testing: Presenter tests must be rewritten, don't think you can cut corners

In the MVP era, you might have written many PresenterTests, using Mockito to mock a View interface and verifying verify(view).showList(...). These tests are all invalid under MVI.

In MVI, the test target becomes the ViewModel, and the verification method changes: send an Intent to the ViewModel, then assert that the current value of StateFlow matches expectations. No need to mock View because the ViewModel doesn't hold a View reference.

@Test
fun `loading success changes state to Success`() = runTest {
    val viewModel = UserListViewModel(FakeRepository(users = listOf(user)))
    viewModel.sendIntent(UserListIntent.Load)
    advanceUntilIdle()
    assertEquals(UserListState.Success(listOf(user)), viewModel.state.value)
}

This is actually simpler because you no longer simulate various callback timings of View. But the cost is all old Presenter tests must be rewritten, and there's no automatic conversion tool. Include this effort in the migration plan; don't wait until just before test submission to discover test coverage has dropped significantly.


Finally: when you shouldn't migrate

To be honest, not every old project is worth doing this.

If the project is already in maintenance mode, with no new features for half a year, the migration's payoff is low. If the team has only one or two people familiar with Kotlin coroutines, forcing MVI could lead to inconsistent code style. If the core module deeply depends on a Java-ecosystem library (like complex JNI wrappers), staying with Java is safer.

MVI + Kotlin did make our code cleaner: the if (view != null) in Presenter disappeared, state logic is centralized, and a new hire can understand the page from a single State class. But the cost is the pain of the initial migration and the team's learning curve. If you decide to go this route, run one page through first, then replicate the experience. The first page takes two weeks, the second page takes three days, and the rest go smoothly.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

無表情

Isn't it all just Kotlin + MVVM anyway?