Install
openclaw skills install @ahmadhussain0335/mobile-mvcFixes controller-based mobile code: UIKit MVC and Massive View Controllers (child view controllers, coordinators, separate data sources), legacy Android Activity-as-controller and MVP, and Flutter controllers, plus step-by-step migration to MVVM one screen at a time. Use when working on UIViewController, Activity, Fragment or presenter code, splitting a god class, or deciding whether MVC is still fine.
openclaw skills install @ahmadhussain0335/mobile-mvcKeep controller-based screens small, testable and leak-free, and move them to MVVM safely when that pays off. Covers UIKit MVC, legacy Android Activity-as-controller and MVP, and Flutter controllers.
UIViewController, Activity or Fragment has grown past a few hundred lines and mixes
networking, formatting, navigation and view code.State holds all the logic.Not for: designing new screens in MVVM or MVI from scratch (use mobile-mvvm, mobile-mvi).
Migrate when: logic needs unit tests, state is lost on rotation or process death, the same logic is duplicated across screens, or the controller cannot be changed without regressions.
Swift:
// BAD: view controller builds requests and decodes JSON
override func viewDidLoad() {
super.viewDidLoad()
URLSession.shared.dataTask(with: URL(string: "https://api.example.com/devices")!) { data, _, _ in
let devices = try! JSONDecoder().decode([Device].self, from: data!)
DispatchQueue.main.async { self.devices = devices; self.tableView.reloadData() }
}.resume()
}
// GOOD: injected service, the controller only renders
final class DevicesViewController: UIViewController {
private let service: DeviceService
init(service: DeviceService) {
self.service = service
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
}
Java:
// BAD: Activity opens sockets and parses responses
new Thread(() -> {
String json = httpGet("https://api.example.com/devices");
runOnUiThread(() -> adapter.submit(parse(json)));
}).start();
// GOOD: presenter asks a repository; the Activity only renders
presenter.loadDevices();
Swift:
// BAD: the screen knows the next screen and how to build it
let detail = DeviceDetailViewController(service: DeviceServiceImpl(), device: device)
navigationController?.pushViewController(detail, animated: true)
// GOOD: the screen reports intent; a coordinator decides
var onSelectDevice: ((Device.ID) -> Void)?
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
onSelectDevice?(id)
}
Kotlin:
// BAD: presenter starts Activities
class DevicesPresenter(private val activity: Activity) {
fun onDeviceClicked(id: String) = activity.startActivity(Intent(activity, DetailActivity::class.java))
}
// GOOD: presenter calls a view method; the Activity or a navigator performs it
interface DevicesView { fun openDetail(deviceId: String) }
Swift:
// BAD: index-based data source in the controller, reloadData on every change
extension DevicesViewController: UITableViewDataSource {
func tableView(_ t: UITableView, numberOfRowsInSection s: Int) -> Int { devices.count }
func tableView(_ t: UITableView, cellForRowAt i: IndexPath) -> UITableViewCell { ... }
}
// GOOD: diffable data source keyed by stable ids, owned by the controller, configured once
private lazy var dataSource = UITableViewDiffableDataSource<Section, Device.ID>(tableView: tableView) {
[weak self] tableView, indexPath, id in
self?.cellProvider.cell(in: tableView, at: indexPath, for: id)
}
Kotlin:
// BAD: notifyDataSetChanged on every update
adapter.items = devices; adapter.notifyDataSetChanged()
// GOOD: ListAdapter with DiffUtil, stable ids in areItemsTheSame
adapter.submitList(devices)
Swift:
// BAD: new formatter per cell, logic untestable
cell.detail.text = { let f = DateFormatter(); f.dateFormat = "dd.MM.yyyy"; return f.string(from: d.lastSeen) }()
// GOOD: a testable row model built outside the cell
struct DeviceRow { let title: String; let lastSeen: String }
extension DeviceRow {
init(_ d: Device) {
title = d.name
lastSeen = d.lastSeen.formatted(date: .abbreviated, time: .shortened)
}
}
Dart:
// BAD: formatting inside build
Text('${(device.battery * 100).round()}% - ${device.lastSeen.toLocal()}')
// GOOD: controller or row model exposes display strings, tested without widgets
Text(row.batteryLabel)
Java:
// BAD: presenter keeps the Activity after onDestroy; async callback touches a dead view
@Override public void onDevicesLoaded(List<Device> devices) { view.showDevices(devices); }
// GOOD: attach and detach with the lifecycle; callbacks check the view
public void attach(DevicesContract.View view) { this.view = view; }
public void detach() { this.view = null; cancelPending(); }
@Override public void onDevicesLoaded(List<Device> devices) {
if (view != null) view.showDevices(devices);
}
// Activity: presenter.detach() in onDestroy()
WeakReference<View> hides the leak but keeps the work running. Cancel the work too.Kotlin:
// BAD: presenter needs Context and a Looper, so it cannot run in a plain JUnit test
class LoginPresenter(private val context: Context) {
fun onLogin() = Toast.makeText(context, context.getString(R.string.error), Toast.LENGTH_SHORT).show()
}
// GOOD: presenter depends on interfaces; the view decides how to show an error
class LoginPresenter(private val auth: AuthRepository, private val scope: CoroutineScope) {
private var view: LoginContract.View? = null
fun onLogin(email: String, password: String) {
scope.launch {
val result = auth.login(email, password)
view?.render(if (result.isSuccess) LoginState.Done else LoginState.Error(LoginError.InvalidCredentials))
}
}
}
Swift: controllers' helpers (formatters, validators, services) import Foundation, not UIKit.
Dart:
// BAD: controller created in build, logic in State, never disposed
@override
Widget build(BuildContext context) {
final controller = PairingController(repo);
return ListenableBuilder(listenable: controller, builder: (_, __) => Text(controller.status));
}
// GOOD
class _PairingScreenState extends State<PairingScreen> {
late final PairingController _controller = PairingController(widget.repo);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) => Text(_controller.status),
);
}
Swift:
// BAD: retains the controller and updates it after it was popped
loadTask = Task { self.render(try await service.devices()) }
// GOOD: weak self, capture only the service, cancel on disappear
loadTask = Task { [weak self, service] in
do {
let devices = try await service.devices()
self?.render(devices)
} catch {
self?.showError(error)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel()
}
Dart:
// BAD
await _controller.pair();
Navigator.of(context).pop();
// GOOD
await _controller.pair();
if (!mounted) return;
Navigator.of(context).pop();
Swift:
// BAD: hidden singletons, impossible to fake
let devices = try await APIClient.shared.devices()
// GOOD: storyboard screens can still use init injection via a custom init?(coder:service:)
let vc = storyboard.instantiateViewController(identifier: "Devices") { coder in
DevicesViewController(coder: coder, service: service)
}
Kotlin:
// BAD
private val repo = DeviceRepository.getInstance()
// GOOD: presenter constructed with its dependencies (Hilt or a manual factory)
class DevicesPresenter @Inject constructor(private val repo: DeviceRepository)
# BAD
refactor: migrate whole app to MVVM (214 files)
# GOOD: one screen, three small MRs
test(devices): characterization tests for DevicesPresenter
refactor(devices): add DevicesViewModel behind DevicesActivity
refactor(devices): move device list state to DevicesViewModel, remove presenter
Details and code: migration to MVVM.
extensions that only hide the size.Activity or Context and never detached; work keeps running after onDestroy.AsyncTask (deprecated) or Loaders (deprecated) in maintained code.addChild and didMove(toParent:), so appearance
callbacks and trait changes do not propagate.State classes with hundreds of lines of business logic and setState calls from async code.android.* or UIKitinitState or a late final field and disposed in disposemounted, a non-null view or [weak self] before updating UImobile-mvvm -- target architecture when migratingmobile-mvi -- alternative target for complex, event-heavy screensmobile-design-patterns -- coordinator, adapter, strategy and facade patterns used heremobile-swift, mobile-java, mobile-kotlin, mobile-flutter -- language rules for the code being movedmobile-code-review -- reviewing the refactoring MRsmobile-testing -- characterization tests before a migration