18. Trade-offs: Pure VIP vs Observable VIP
18. Trade-offs: Pure VIP vs Observable VIP
Ini adalah inti dari pertanyaan yang sering muncul ketika mempertimbangkan migrasi. Tidak ada jawaban mutlak — setiap trade-off bergantung pada konteks project.
Trade-off 1: Commands vs State — Masalah One-time Events
Ini adalah trade-off terbesar dan paling sering menjadi sumber bug tersembunyi.
Pure VIP — Presenter berbicara dalam "commands":
// Ini adalah PERINTAH — dipanggil sekali, dieksekusi sekali, selesai.
viewController?.displayError(message: "Koneksi gagal")
// Tidak ada state yang tersimpan — tidak mungkin error ini muncul lagi
// kecuali Presenter memanggil displayError() lagi.
Observable VIP — Presenter berbicara dalam "state":
// Ini adalah STATE — disimpan di displayState.pendingError
displayState.pendingError = "Koneksi gagal"
// Masalah: applyState() bisa dipanggil lagi oleh perubahan property LAIN.
// Misal: isLoading berubah → applyState() → pendingError masih ada → double alert!
//
// Solusi wajib: reset SEGERA setelah dikonsumsi.
if let message = state.pendingError {
displayState?.pendingError = nil // ← WAJIB, atau bug
showErrorAlert(message: message)
}
Mengapa ini berbahaya: Programmer yang tidak familiar dengan pola ini akan lupa me-reset pendingError. Hasilnya: alert error muncul setiap kali ada perubahan state lain — bug yang sangat sulit di-reproduce karena hanya terjadi saat ada perubahan bersamaan.
Solusi lebih defensif — EventQueue:
// Alternatif: bungkus one-time event dalam wrapper yang auto-consume
@Observable
@MainActor
final class UserListDisplayState {
var displayedUsers: [UserList.FetchUsers.ViewModel.DisplayedUser] = []
var isLoading: Bool = false
// Gunakan array sebagai queue — multiple events tidak saling menimpa
private(set) var errorQueue: [String] = []
private(set) var navigationQueue: [UserList.SelectUser.ViewModel] = []
// Hanya Presenter yang boleh enqueue
fileprivate func enqueueError(_ message: String) {
errorQueue.append(message)
}
fileprivate func enqueueNavigation(_ viewModel: UserList.SelectUser.ViewModel) {
navigationQueue.append(viewModel)
}
// ViewController yang dequeue saat mengkonsumsi
func dequeueError() -> String? {
guard !errorQueue.isEmpty else { return nil }
return errorQueue.removeFirst()
}
func dequeueNavigation() -> UserList.SelectUser.ViewModel? {
guard !navigationQueue.isEmpty else { return nil }
return navigationQueue.removeFirst()
}
}
// applyState() lebih aman:
private func applyState() {
guard let state = displayState else { return }
// ...regular state...
// Queue — ambil semua yang pending
while let message = state.dequeueError() {
showErrorAlert(message: message)
}
while let nav = state.dequeueNavigation() {
router?.routeToUserDetail(userId: nav.userId, userName: nav.userName)
}
}
Trade-off 2: Ownership & Retain Cycle
Pure VIP:
Configurator: presenter.viewController = viewController (strong)
↑
Agar tidak retain cycle, harus weak:
Presenter: weak var viewController: (any UserListDisplayLogic)?
↑
Jika lupa weak → retain cycle → memory leak yang sulit dideteksi
Jika weak → viewController bisa nil saat dipanggil → silent failure
Observable VIP:
Presenter: let displayState = UserListDisplayState() (owns)
ViewController: var displayState: UserListDisplayState? (non-owning reference, tidak retain Presenter)
Ownership linear: Presenter → DisplayState ← ViewController
Tidak ada circular reference. Tidak ada yang perlu weak.
Configurator: viewController.configure(displayState: presenter.displayState)
presenter TIDAK tahu keberadaan ViewController — DI lebih bersih.
Pemenang: Observable VIP — model ownership lebih jelas, tidak ada footgun weak yang bisa dilupakan.
Trade-off 3: Explicitness vs Reactivity — Debugging
Pure VIP — sangat mudah di-debug:
// Stack trace saat error muncul di layar:
// 1. UserListInteractor.fetchUsers() — throw
// 2. UserListPresenter.presentError(_:) ← breakpoint di sini
// 3. UserListViewController.displayError(message:) ← atau di sini
// Jelas, linear, mudah di-trace
Observable VIP — lebih implisit:
// Stack trace saat applyState() dipanggil:
// 1. withObservationTracking.onChange ← dipanggil oleh runtime
// 2. continuation.yield(())
// 3. AsyncStream for-await loop
// 4. self?.applyState() ← breakpoint di sini, tapi TIDAK JELAS property mana yang berubah
// Untuk mengetahui property mana yang trigger, harus tambahkan logging manual:
private func applyState() {
guard let state = displayState else { return }
// Debugging: print apa yang berubah
// (ini tidak bisa otomatis karena withObservationTracking tidak memberitahu PROPERTY mana)
if state.displayedUsers != currentDisplayedUsers { print("users changed") }
if state.isLoading { print("loading changed") }
// ...
}
Pemenang: Pure VIP — data flow eksplisit dan deterministik lebih mudah di-debug, terutama saat onboarding developer baru ke codebase.
Trade-off 4: Testing Presenter
Pure VIP — butuh Mock ViewController:
// Mock yang harus dibuat hanya untuk test
@MainActor
final class MockUserListViewController: UserListDisplayLogic {
var displayedUsers: [UserList.FetchUsers.ViewModel.DisplayedUser] = []
var loadingStates: [Bool] = []
var errorMessages: [String] = []
func displayUsers(_ viewModel: UserList.FetchUsers.ViewModel) {
displayedUsers = viewModel.displayedUsers
}
func displayLoading(_ isLoading: Bool) { loadingStates.append(isLoading) }
func displayError(message: String) { errorMessages.append(message) }
func displaySelectedUser(_ viewModel: UserList.SelectUser.ViewModel) {}
}
// Test
@Test func testPresentUsers() async {
let mockVC = MockUserListViewController()
let presenter = UserListPresenter()
presenter.viewController = mockVC
let response = UserList.FetchUsers.Response(users: [User(id: "1", name: "Ari", email: "a@b.com", avatarURL: nil)])
presenter.presentUsers(response)
#expect(mockVC.displayedUsers.first?.fullName == "Ari")
}
Observable VIP — test langsung dari displayState:
// Tidak perlu mock apapun — cukup cek state
@Test func testPresentUsers() async {
let presenter = UserListPresenter()
let response = UserList.FetchUsers.Response(users: [User(id: "1", name: "ari supriatna", email: "a@b.com", avatarURL: nil)])
await MainActor.run { presenter.presentUsers(response) }
// Assert langsung ke displayState — lebih intuitif
let users = await MainActor.run { presenter.displayState.displayedUsers }
#expect(users.first?.fullName == "Ari Supriatna") // test format capitalize
#expect(users.first?.emailLabel == "a@b.com")
}
Pemenang: Observable VIP — test Presenter jauh lebih simpel, tidak ada mock infrastructure yang perlu di-maintain.
Trade-off 5: Testing ViewController
Pure VIP — inject viewModel langsung:
// Test display logic VC dengan memanggil method langsung
@Test @MainActor func testDisplayUsersUpdatesTable() {
let vc = UserListViewController()
vc.loadViewIfNeeded()
let viewModel = UserList.FetchUsers.ViewModel(displayedUsers: [
.init(id: "1", fullName: "Ari Supriatna", emailLabel: "ari@test.com", avatarURL: nil)
])
vc.displayUsers(viewModel) // panggil langsung — synchronous
#expect(vc.tableView.numberOfRows(inSection: 0) == 1)
}
Observable VIP — harus tunggu observasi async:
// Harus setup displayState dan tunggu observation cycle
@Test @MainActor func testDisplayUsersUpdatesTable() async {
let displayState = UserListDisplayState()
let vc = UserListViewController()
vc.configure(displayState: displayState)
vc.loadViewIfNeeded()
// Harus tunggu observasi selesai setup
try? await Task.sleep(for: .milliseconds(50))
displayState.displayedUsers = [
.init(id: "1", fullName: "Ari Supriatna", emailLabel: "ari@test.com", avatarURL: nil)
]
// Harus tunggu observation cycle dan applyState() dipanggil
try? await Task.sleep(for: .milliseconds(50))
#expect(vc.tableView.numberOfRows(inSection: 0) == 1)
}
Pemenang: Pure VIP — test ViewController lebih deterministic karena synchronous. Observable VIP membutuhkan Task.sleep yang rapuh (flaky test risk).
Trade-off 6: iOS Minimum Version
| Fitur | Minimum |
|---|---|
async/await, actor, @MainActor |
iOS 15 |
@Observable, withObservationTracking |
iOS 17 |
Jika project mendukung iOS 15 atau 16, Observable VIP tidak bisa digunakan sama sekali. Pure VIP dengan Swift Concurrency tetap berjalan di iOS 15.
Trade-off 7: Jumlah applyState() Call
Dalam Pure VIP, setiap presentLoading, presentUsers, presentLoading(false) adalah 3 pemanggilan terpisah dan independen.
Dalam Observable VIP, setiap assignment ke displayState memicu onChange → yield → applyState(). Artinya untuk satu fetch yang berhasil:
1. displayState.isLoading = true → applyState() #1
2. displayState.displayedUsers = [...] → applyState() #2
3. displayState.isLoading = false → applyState() #3
Tiga kali applyState() untuk satu fetch — tapi tableView.reloadData() hanya terjadi sekali (karena ada guard users != currentDisplayedUsers). Overhead-nya kecil dan tidak terasa di UI, tapi perlu disadari.
Ringkasan Trade-offs
| Aspek | Pure VIP | Observable VIP |
|---|---|---|
| One-time events | ✅ Natural (commands) | ⚠️ Butuh reset manual — footgun |
| Retain cycle | ⚠️ Perlu weak hati-hati |
✅ Tidak ada retain cycle |
| Debugging | ✅ Linear, mudah trace | ⚠️ Implicit, butuh logging tambahan |
| Test Presenter | ⚠️ Butuh Mock VC | ✅ Langsung cek state |
| Test ViewController | ✅ Synchronous, deterministic | ⚠️ Async, risiko flaky test |
| iOS minimum | ✅ iOS 15 | ❌ iOS 17 only |
| Call applyState | N/A | ⚠️ 3x per fetch (overhead kecil) |
| Onboarding developer | ✅ Mudah — flow eksplisit | ⚠️ Perlu paham Observable |
| DI / coupling | ⚠️ Presenter tahu VC | ✅ Presenter tidak tahu VC |
Kapan Pilih Observable VIP?
Pilih Observable VIP jika:
- Target minimum iOS 17+
- Tim sudah familiar dengan
@Observabledan Swift Concurrency - Test coverage lebih difokuskan ke Presenter (business/formatting logic) daripada VC
- Ingin satu pattern yang sama antara UIKit dan SwiftUI (karena Observable bekerja di keduanya)
- Project baru — tidak ada legacy VIP code yang perlu di-maintain
Tetap dengan Pure VIP jika:
- Target iOS 15 atau 16
- Tim lebih besar dan beragam level — explicitness Pure VIP lebih mudah onboarding
- Test suite banyak di ViewController level (integration test UIKit)
- Sudah ada codebase Pure VIP yang besar — ROI migrasi tidak sebanding
- Debugging adalah prioritas — stack trace eksplisit lebih berharga