Section 18/214 menit

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":

swift
// 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":

swift
// 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:

swift
// 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:

swift
Configurator: presenter.viewController = viewController  (strong)
                        ↑
Agar tidak retain cycle, harus weak:
Presenter: weak var viewController: (any UserListDisplayLogic)?
                                    ↑
Jika lupa weakretain cycle → memory leak yang sulit dideteksi
Jika weak → viewController bisa nil saat dipanggil → silent failure

Observable VIP:

swift
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:

swift
// 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:

swift
// 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:

swift
// 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:

swift
// 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:

swift
// 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:

swift
// 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:

swift
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 @Observable dan 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