Install
openclaw skills install @ahmadhussain0335/mobile-mvvmDesigns, implements and tests screens with MVVM on Android (Compose, ViewModel, StateFlow, Hilt; legacy LiveData), iOS (SwiftUI, @Observable, @MainActor) and Flutter (Riverpod, ChangeNotifier): single immutable UI state, one-off events, lifecycle-aware collection and ViewModel tests. Use when building or refactoring a screen's state handling, or choosing between MVVM, MVI and MVC.
openclaw skills install @ahmadhussain0335/mobile-mvvmBuild screens where the UI is a plain function of one immutable state object, the ViewModel owns that state and the screen-scoped work, and data comes from repositories that the ViewModel receives by injection. The result survives rotation and process death, renders the same state the same way every time, and is testable without a device.
ObservableObject to
@Observable, or setState screens to Riverpod or provider.Not for: screens with many interacting events, strict event ordering or debounced concurrent requests
(use mobile-mvi), or legacy UIKit and Activity-centric code you only maintain (use mobile-mvc).
| MVVM | MVI | MVC | |
|---|---|---|---|
| State | One UiState per screen (or a few observable properties) | One immutable State, changed only by a reducer | Spread across controller and views |
| How UI talks to logic | Calls named ViewModel functions (onRefresh()) | Sends one sealed Intent type | Controller handles view callbacks directly |
| Side effects | Inside ViewModel functions | Explicit effects outside a pure reducer | Inside the controller |
| Boilerplate | Low | Medium to high | Lowest at first, grows fast |
| Testing | ViewModel tests with fakes | Pure reducer tests plus effect tests | Hard, logic bound to views |
| Good fit | Most screens: lists, details, forms, settings | Search with debounce, editors, wizards, chat, real-time device dashboards | Prototypes, small UIKit or Activity code already in that style |
| Platform fit | Android Architecture guidance, SwiftUI @Observable, Riverpod, provider | Compose + StateFlow store, TCA, Bloc | UIKit UIViewController, legacy Activities |
Rules of thumb:
UiState. It already gives unidirectional data flow.UI (Composable, SwiftUI View, Widget) renders UiState, forwards user events
| events ^ UiState
v |
ViewModel owns screen state and screen-scoped work, maps domain data to UiState
|
v
Use cases (optional) business rules reused by several ViewModels, or too complex for one
|
v
Repository single source of truth, merges sources, maps DTOs and entities to domain models
|
v
Data sources network, database, BLE, files, platform APIs
UiState: everything the screen renders, with defaults. Derive values (filtered lists,
button enabled flags) instead of storing them twice.onRefresh, onFavoriteClick).UiState, handle events, map errors to state.@State, Riverpod provider, ChangeNotifierProvider).UiState plus callbacks. Preview it with fake states.Several independent mutable properties drift out of sync and let the UI write state.
Kotlin:
// BAD: public mutable state, three flows that can disagree
val devices = MutableStateFlow<List<Device>>(emptyList())
val isLoading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
// GOOD
data class DeviceListUiState(
val devices: List<Device> = emptyList(),
val isLoading: Boolean = false,
val userMessage: UserMessage? = null,
)
private val _uiState = MutableStateFlow(DeviceListUiState())
val uiState: StateFlow<DeviceListUiState> = _uiState.asStateFlow()
fun onRefresh() = _uiState.update { it.copy(isLoading = true) }
Swift:
// BAD: the view can write any of these
@Observable final class DeviceListModel { var devices: [Device] = []; var isLoading = false }
// GOOD
@MainActor @Observable
final class DeviceListModel {
private(set) var state = DeviceListState()
}
Dart:
// BAD: mutable fields, callers mutate the list in place
class DeviceListController extends ChangeNotifier { List<Device> devices = []; }
// GOOD: immutable state, replaced as a whole
class DeviceListNotifier extends Notifier<DeviceListState> {
@override
DeviceListState build() => const DeviceListState();
}
Java:
// BAD
public final MutableLiveData<List<Device>> devices = new MutableLiveData<>();
// GOOD
private final MediatorLiveData<DeviceListUiState> uiState = new MediatorLiveData<>();
public LiveData<DeviceListUiState> getUiState() { return uiState; }
private(set) properties also work and @Observable tracks them individually.The UI calls a named function for each user event and renders whatever state comes back. Composables, Views and Widgets below the screen level receive state and lambdas, not the ViewModel.
// BAD: leaf composable owns the ViewModel, cannot be previewed or reused
@Composable fun DeviceRow(id: String, vm: DeviceListViewModel = hiltViewModel()) { ... }
// GOOD
@Composable fun DeviceRow(device: Device, onFavoriteClick: () -> Unit, modifier: Modifier = Modifier) { ... }
// BAD: view mutates model internals, no place for validation or side effects
Toggle("Online only", isOn: $model.onlineOnly)
// GOOD: the change goes through the model
Toggle("Online only", isOn: Binding(get: { model.state.onlineOnly }, set: { model.setOnlineOnly($0) }))
@Bindable binding to a model property is acceptable for plain form fields with no logic.Context, Activity, View, NavController, UIViewController or BuildContext in a ViewModel.// BAD: leaks the Activity, breaks on locale change, needs Robolectric to test
class DeviceListViewModel(private val context: Context) : ViewModel() {
fun error() = context.getString(R.string.refresh_failed)
}
// GOOD
enum class UserMessage { RefreshFailed }
// UI: UserMessage.RefreshFailed -> stringResource(R.string.refresh_failed)
BuildContext. Set state (saved: true) and let the widget
navigate from ref.listen.Kotlin: convert repository flows once with stateIn and WhileSubscribed(5_000). Collection stops five
seconds after the last subscriber leaves (survives rotation, stops in the background).
// BAD: loads in init, ignores lifecycle, hard to test the initial state
init { viewModelScope.launch { _uiState.value = DeviceListUiState(devices = repo.load()) } }
// GOOD
val uiState: StateFlow<DeviceListUiState> = repository.observeDevices()
.map { DeviceListUiState(devices = it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DeviceListUiState(isLoading = true))
Swift: start loading from .task, which cancels when the view disappears, not from init.
Dart: load in AsyncNotifier.build; use autoDispose providers for screen state.
// BAD: keeps collecting while the app is in the background
val state by viewModel.uiState.collectAsState()
// GOOD
val state by viewModel.uiState.collectAsStateWithLifecycle()
// BAD: Fragment lifecycle outlives its view, observers pile up after back navigation
viewModel.getUiState().observe(this, this::render);
// GOOD
viewModel.getUiState().observe(getViewLifecycleOwner(), this::render);
viewLifecycleOwner.lifecycleScope plus repeatOnLifecycle(Lifecycle.State.STARTED)..task / .task(id:); @Observable reads in body are tracked automatically.ref.watch in build, ref.read in callbacks, ref.listen for side effects.Snackbars, navigation after save and dialogs are modelled as nullable state. The UI performs the effect, then tells the ViewModel it is done. Nothing is lost if the UI is not collecting at that moment.
Kotlin:
// BAD: SharedFlow without replay drops events when nobody collects (rotation, background)
private val _events = MutableSharedFlow<String>()
// GOOD
fun onUserMessageShown() = userMessage.update { null }
// UI
state.userMessage?.let { message ->
val text = message.toText()
LaunchedEffect(message) {
snackbarHostState.showSnackbar(text)
viewModel.onUserMessageShown()
}
}
Swift:
.alert(model.state.userMessage?.title ?? "", isPresented: Binding(
get: { model.state.userMessage != nil },
set: { if !$0 { model.userMessageShown() } }
)) { Button("OK", role: .cancel) {} }
Dart:
ref.listen(deviceListProvider.select((s) => s.value?.userMessage), (_, message) {
if (message == null) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message.text(context))));
ref.read(deviceListProvider.notifier).userMessageShown();
});
Channel trade-offs, if the team still prefers an event stream:
Channel(Channel.BUFFERED) + receiveAsFlow() keeps events while no one collects, but an event can
still be lost if the collector is cancelled between receiving and handling it.repeatOnLifecycle(STARTED) on Dispatchers.Main.immediate to shrink that window.SingleLiveEvent in new code; it supports one observer and hides the same race.// BAD: hidden dependency, cannot be faked
class DeviceListViewModel : ViewModel() { private val repo = DeviceRepositoryImpl(ApiClient.instance) }
// GOOD
@HiltViewModel
class DeviceListViewModel @Inject constructor(
private val repository: DeviceRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
_model = State(initialValue: DeviceListModel(repository: repository)) in the view's init.ref.watch(deviceRepositoryProvider); tests override that provider.@Singleton, a global or an app-wide provider.SavedStateHandle or assisted injection, not a setter
called from the UI after creation.Rotation is handled by the ViewModel. Process death is not: the ViewModel is recreated empty.
| Platform | ViewModel-owned input | View-only state |
|---|---|---|
| Android | SavedStateHandle (getStateFlow, set) | rememberSaveable |
| iOS | Restore from the view on appear | @SceneStorage |
| Flutter | Pass restored values in from the widget | RestorationMixin, RestorableInt, RestorableTextEditingController |
// BAD: filter resets after process death
private val onlineOnly = MutableStateFlow(false)
// GOOD
private val onlineOnly = savedStateHandle.getStateFlow(KEY_ONLINE_ONLY, false)
fun onToggleOnlineOnly() { savedStateHandle[KEY_ONLINE_ONLY] = !onlineOnly.value }
// BAD: ViewModel talks to Retrofit and Room, maps DTOs, decides caching
class DeviceListViewModel(private val api: DeviceApi, private val dao: DeviceDao) : ViewModel()
// GOOD: ViewModel sees domain models only
class DeviceListViewModel(private val repository: DeviceRepository) : ViewModel()
Flow, AsyncSequence, Stream), so writes elsewhere update
the screen without a manual reload.// BAD: runCatching swallows CancellationException; catch-all hides bugs
runCatching { repository.refresh() }
// GOOD
try {
repository.refresh()
} catch (e: IOException) {
userMessage.value = UserMessage.RefreshFailed
}
do {
state.devices = try await repository.fetchDevices()
} catch is CancellationError {
return
} catch {
state.userMessage = .loadFailed
}
on DeviceException). In AsyncNotifier, AsyncValue.guard turns a thrown
error into AsyncError for the screen to render.@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test fun `refresh failure shows message until acknowledged`() = runTest {
repository.refreshError = IOException("offline")
val viewModel = DeviceListViewModel(repository, SavedStateHandle())
viewModel.uiState.test {
viewModel.onRefresh()
advanceUntilIdle()
assertEquals(UserMessage.RefreshFailed, expectMostRecentItem().userMessage)
viewModel.onUserMessageShown()
assertNull(awaitItem().userMessage)
}
}
@MainActor Swift Testing suite awaits the model's async functions directly and uses #expect.ProviderContainer with the repository provider overridden by a fake; container.listen keeps
autoDispose providers alive. Full examples are in each reference file.Thread.sleep, Task.sleep or real timers in ViewModel tests.LiveData or ObservableObject in new code without a reason (interop with legacy screens is a reason).remember or @State and then edited there, so two sources of truth exist.body or build (sorting, filtering, validation).body instead of an effect or event handler.Dispatchers.IO in the ViewModel. Repositories own their dispatchers.@ObservedObject var model = Model(): a new model on every parent redraw. @State var model = Model()
is correct, but its initializer still runs on every parent redraw (only the first instance is kept),
so keep init cheap and start work in .task.mobile-mvi)UiState per screen, exposed read-onlyContext, View, NavController, BuildContext or resolved strings in the ViewModelstateIn(viewModelScope, WhileSubscribed(5_000), initial)collectAsStateWithLifecycle, getViewLifecycleOwner(), .task or ref.watchmobile-mvi -- when the screen needs a reducer, explicit intents and effectsmobile-mvc -- maintaining UIKit or Activity-centric screensmobile-design-patterns -- repository, factory, adapter and other building blocks used heremobile-kotlin, mobile-java, mobile-swift, mobile-flutter -- language rules for each platformmobile-code-review -- reviewing the resulting ViewModels and screensmobile-testing -- where ViewModel tests sit in the overall test strategymobile-kmp -- sharing ViewModels between Android and iOS