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
DisplayedUserharusEquatable?applyState()membandingkanstate.displayedUsers != currentDisplayedUsersuntuk menghindarireloadData()yang tidak perlu. TambahkanEquatablekeDisplayedUser:
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(...)