Section 17/211 menit

17. Implementasi Observable VIP — DisplayState Pattern

17. Implementasi Observable VIP — DisplayState Pattern

Step 1: UserListDisplayState — Jembatan antara Presenter dan View

swift
import Observation

// DisplayState adalah satu-satunya sumber kebenaran untuk tampilan layar ini.
// @Observable: setiap perubahan property otomatis dideteksi oleh observer.
// @MainActor: selalu diakses dari main thread — konsisten dengan UIKit.
@Observable
@MainActor
final class UserListDisplayState {

    // State reguler — berubah berkali-kali sepanjang lifecycle layar
    var displayedUsers: [UserList.FetchUsers.ViewModel.DisplayedUser] = []
    var isLoading: Bool = false

    // "Pending" events — one-time actions yang harus dikonsumsi ViewController
    // dan di-reset segera setelah ditangani.
    //
    // Mengapa "pending" bukan state biasa?
    // Error dan navigasi adalah PERINTAH (commands), bukan state.
    // Jika kita simpan sebagai state permanen, error akan ditampilkan ulang
    // setiap kali applyState() terpanggil akibat perubahan property lain.
    var pendingError: String? = nil
    var pendingNavigation: UserList.SelectUser.ViewModel? = nil
}

Step 2: Protocol (Disederhanakan)

swift
// UserListDisplayLogic tidak lagi dibutuhkan — Presenter tidak memanggil VC.
// Kita bisa hapus protocol ini, atau pertahankan hanya jika butuh interoperabilitas
// dengan Pure VIP di modul lain.
//
// Yang tetap dibutuhkan:
protocol UserListBusinessLogic: AnyObject, Sendable {
    func fetchUsers(request: UserList.FetchUsers.Request) async
    func selectUser(request: UserList.SelectUser.Request) async
}

protocol UserListPresentationLogic: AnyObject {
    func presentUsers(_ response: UserList.FetchUsers.Response)
    func presentLoading(_ isLoading: Bool)
    func presentError(_ error: Error)
    func presentSelectedUser(_ response: UserList.SelectUser.Response)
}

// UserListDataStore dan UserListRoutingLogic tidak berubah dari Pure VIP.

Step 3: Presenter — Update State, Bukan Panggil VC

swift
import UIKit

// Presenter tidak lagi tahu keberadaan ViewController.
// Tugasnya sama: format Response → ViewModel.
// Bedanya: hasil format disimpan ke displayState, bukan dikirim ke VC.
@MainActor
final class UserListPresenter: UserListPresentationLogic {

    // DisplayState dimiliki Presenter — Presenter yang menciptakan dan mengupdate-nya.
    // ViewController mendapat referensi ke object ini via Configurator.
    // Tidak ada lagi 'weak var viewController' — tidak ada retain cycle.
    let displayState = UserListDisplayState()

    // MARK: - UserListPresentationLogic

    func presentUsers(_ response: UserList.FetchUsers.Response) {
        // Formatting logic sama persis dengan Pure VIP — tidak ada yang berubah.
        let displayedUsers = response.users.map { user in
            UserList.FetchUsers.ViewModel.DisplayedUser(
                id: user.id,
                fullName: user.name
                    .split(separator: " ")
                    .map { $0.capitalized }
                    .joined(separator: " "),
                emailLabel: user.email.lowercased(),
                avatarURL: user.avatarURL
            )
        }
        // Alih-alih: viewController?.displayUsers(viewModel)
        // Kita update state — observer akan merespons secara otomatis.
        displayState.displayedUsers = displayedUsers
    }

    func presentLoading(_ isLoading: Bool) {
        displayState.isLoading = isLoading
    }

    func presentError(_ error: Error) {
        // Set pending error — ViewController akan menampilkan dan langsung me-reset.
        // Jika sudah ada pendingError yang belum dikonsumsi, timpa saja —
        // error terbaru lebih relevan.
        displayState.pendingError = error.localizedDescription
    }

    func presentSelectedUser(_ response: UserList.SelectUser.Response) {
        displayState.pendingNavigation = UserList.SelectUser.ViewModel(
            userId: response.selectedUser.id,
            userName: response.selectedUser.name
        )
    }
}

Step 4: ViewController — Observe DisplayState

swift
import UIKit
import Observation

// ViewController tidak lagi mengimplementasikan UserListDisplayLogic.
// Sebagai gantinya, ia mengobservasi displayState dan merespons perubahan.
@MainActor
final class UserListViewController: UIViewController {

    // MARK: - VIP References

    var interactor: (any UserListBusinessLogic)?
    var router: (any UserListRoutingLogic)?

    // DisplayState datang dari Configurator — dimiliki Presenter, dibaca VC.
    // Private(set) agar hanya Configurator yang bisa inject via configure().
    private var displayState: UserListDisplayState?

    // Task untuk observasi — di-cancel saat view hilang.
    private var observationTask: Task<Void, Never>?

    // Cache lokal untuk diffing (mencegah reloadData yang tidak perlu)
    private var currentDisplayedUsers: [UserList.FetchUsers.ViewModel.DisplayedUser] = []

    // MARK: - UI Components (sama seperti Pure VIP)

    private lazy var tableView: UITableView = {
        let table = UITableView(frame: .zero, style: .plain)
        table.register(UserCell.self, forCellReuseIdentifier: UserCell.reuseID)
        table.translatesAutoresizingMaskIntoConstraints = false
        table.delegate = self
        table.dataSource = self
        return table
    }()

    private lazy var activityIndicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView(style: .large)
        indicator.hidesWhenStopped = true
        indicator.translatesAutoresizingMaskIntoConstraints = false
        return indicator
    }()

    private lazy var refreshControl: UIRefreshControl = {
        let control = UIRefreshControl()
        control.addAction(UIAction { [weak self] _ in self?.handleRefresh() }, for: .valueChanged)
        return control
    }()

    // MARK: - Configuration (dipanggil oleh Configurator)

    // Inject displayState dari luar — VC tidak pernah membuat displayState sendiri.
    func configure(displayState: UserListDisplayState) {
        self.displayState = displayState
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        startObserving()   // mulai observe SEBELUM fetch — jangan sampai ada state update yang terlewat
        fetchUsers()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        observationTask?.cancel()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Restart observasi jika sebelumnya di-cancel saat viewWillDisappear
        if observationTask == nil || observationTask?.isCancelled == true {
            startObserving()
        }
    }

    // MARK: - Observation

    private func startObserving() {
        guard let displayState else { return }

        observationTask = Task { @MainActor [weak self] in
            // Bungkus withObservationTracking dalam AsyncStream untuk continuous tracking.
            // withObservationTracking sendiri adalah "fire-once" — kita perlu re-register
            // setiap kali onChange dipanggil.
            let changes = AsyncStream<Void> { continuation in
                func track() {
                    withObservationTracking {
                        // Akses semua property yang ingin di-track.
                        // SwiftUI melakukan ini secara otomatis di body,
                        // tapi di UIKit kita harus eksplisit.
                        _ = displayState.displayedUsers
                        _ = displayState.isLoading
                        _ = displayState.pendingError
                        _ = displayState.pendingNavigation
                    } onChange: {
                        // Dipanggil satu kali ketika SALAH SATU property berubah.
                        continuation.yield(())
                        // Re-register untuk menangkap perubahan berikutnya.
                        // Task untuk menjamin ini berjalan di @MainActor.
                        Task { @MainActor in track() }
                    }
                }
                track()  // Mulai tracking sekarang

                continuation.onTermination = { _ in }  // cleanup jika stream di-terminate
            }

            for await _ in changes {
                guard !Task.isCancelled else { break }
                self?.applyState()
            }
        }
    }

    // Dipanggil setiap kali ADA property di displayState yang berubah.
    // Fungsi ini idempoten — aman dipanggil berkali-kali dengan state yang sama.
    private func applyState() {
        guard let state = displayState else { return }

        // --- Regular state (idempoten) ---

        // Hanya reload jika ada perubahan nyata — mencegah scroll jump yang tidak perlu.
        if state.displayedUsers != currentDisplayedUsers {
            currentDisplayedUsers = state.displayedUsers
            tableView.reloadData()
        }

        if state.isLoading {
            activityIndicator.startAnimating()
        } else {
            activityIndicator.stopAnimating()
            refreshControl.endRefreshing()
        }

        // --- One-time events (consume and reset) ---
        // PENTING: reset SEBELUM aksi, bukan setelah.
        // Jika reset setelah aksi (misal setelah present alert), ada risiko applyState()
        // dipanggil lagi dari tempat lain sebelum reset selesai — double alert.

        if let message = state.pendingError {
            // Reset segera — mencegah error muncul dua kali jika applyState dipanggil ulang
            displayState?.pendingError = nil
            showErrorAlert(message: message)
        }

        if let nav = state.pendingNavigation {
            // Reset segera — mencegah double navigation
            displayState?.pendingNavigation = nil
            router?.routeToUserDetail(userId: nav.userId, userName: nav.userName)
        }
    }

    // MARK: - Actions

    private func fetchUsers() {
        Task { await interactor?.fetchUsers(request: UserList.FetchUsers.Request()) }
    }

    private func handleRefresh() {
        Task { await interactor?.fetchUsers(request: UserList.FetchUsers.Request()) }
    }

    private func showErrorAlert(message: String) {
        let alert = UIAlertController(title: "Oops!", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Coba Lagi", style: .default) { [weak self] _ in
            self?.fetchUsers()
        })
        alert.addAction(UIAlertAction(title: "Tutup", style: .cancel))
        present(alert, animated: true)
    }

    // MARK: - Setup

    private func setupUI() {
        title = "Users"
        view.backgroundColor = .systemBackground
        tableView.refreshControl = refreshControl
        view.addSubview(tableView)
        view.addSubview(activityIndicator)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}

// MARK: - UITableViewDataSource & Delegate

extension UserListViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        currentDisplayedUsers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: UserCell.reuseID, for: indexPath) as! UserCell
        cell.configure(with: currentDisplayedUsers[indexPath.row])
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        Task { await interactor?.selectUser(request: UserList.SelectUser.Request(index: indexPath.row)) }
    }
}

Mengapa DisplayedUser harus Equatable? applyState() membandingkan state.displayedUsers != currentDisplayedUsers untuk menghindari reloadData() yang tidak perlu. Tambahkan Equatable ke DisplayedUser:

swift
struct DisplayedUser: Sendable, Equatable {
    let id: String
    let fullName: String
    let emailLabel: String
    let avatarURL: URL?
}

Step 5: Configurator — Wiring Tanpa Circular Reference

swift
@MainActor
enum UserListConfigurator {

    static func makeViewController() -> UserListViewController {

        // 1. Buat semua komponen
        let viewController = UserListViewController()
        let interactor = UserListInteractor()
        let presenter = UserListPresenter()
        let router = UserListRouter()

        // 2. Inject displayState ke ViewController
        //    Presenter yang memiliki displayState — VC hanya membacanya.
        //    Tidak ada lagi: presenter.viewController = viewController
        //    Sehingga tidak ada circular reference sama sekali.
        viewController.configure(displayState: presenter.displayState)

        // 3. Wiring standar VIP
        viewController.interactor = interactor
        viewController.router = router

        // 4. Sambungkan Interactor → Presenter (lewat actor boundary)
        Task {
            await interactor.setPresenter(presenter)
        }

        // 5. Sambungkan Router
        router.viewController = viewController
        router.dataStore = interactor

        return viewController
    }
}

Alur Data Lengkap (Observable VIP)

swift
1. viewDidLoad → startObserving() → withObservationTracking terdaftar
   └─ fetchUsers() → Task { await interactor.fetchUsers() }

2. Interactor.fetchUsers() [actor]
   ├─ await presenter.presentLoading(true)
   │   └─ displayState.isLoading = true ←── withObservationTracking fires
   │       └─ applyState(): activityIndicator.startAnimating()
   │
   ├─ let users = try await worker.fetchUsers()
   │
   ├─ await presenter.presentUsers(response)
   │   └─ displayState.displayedUsers = [...] ←── fires
   │       └─ applyState(): tableView.reloadData()
   │
   └─ await presenter.presentLoading(false)
       └─ displayState.isLoading = false ←── fires
           └─ applyState(): activityIndicator.stopAnimating()

3. User tap cell → interactor.selectUser()
   └─ presenter.presentSelectedUser(response)
       └─ displayState.pendingNavigation = ViewModel(...) ←── fires
           └─ applyState():
               displayState.pendingNavigation = nil  (reset)
               router.routeToUserDetail(...)