Section 9/131 menit

9. VIP Pattern + UIKit

9. VIP Pattern + UIKit

Arsitektur

VIP (View-Interactor-Presenter) + Swift Concurrency di Swift 5.x dengan prinsip isolation:

swift
ProfileScene/
├── ProfileViewController.swift    — @MainActor, UI + trigger action
├── ProfileInteractor.swift        — actor, business logic + orchestration
├── ProfilePresenter.swift         — @MainActor, format data  ViewModel
├── ProfileWorker.swift            — pure async func, network/cache
├── ProfileModels.swift            — Sendable struct: Request, Response, ViewModel
└── ProfileRouter.swift            — @MainActor, navigasi

Prinsip isolation per layer:

  • ProfileViewController@MainActor (UI, main thread)
  • ProfileInteractoractor (business logic, serial access)
  • ProfilePresenter@MainActor (format data, dipanggil dari main thread)
  • ProfileWorkerasync func (stateless, tidak perlu actor)
  • ProfileModels → semua Sendable (lintas batas actor)

ProfileModels.swift

swift
// Semua tipe Sendable agar bisa dikirim lintas actor

struct ProfileRequest: Sendable {
    let userId: String
}

struct ProfileResponse: Sendable {
    let user: User
    let posts: [Post]
}

struct ProfileViewModel: Sendable {
    let displayName: String
    let email: String
    let postCount: String
    let posts: [PostCellViewModel]
}

struct PostCellViewModel: Sendable {
    let id: Int
    let title: String
    let preview: String
}

struct User: Decodable, Sendable {
    let id: Int
    let name: String
    let email: String
    let username: String
}

struct Post: Decodable, Sendable {
    let id: Int
    let userId: Int
    let title: String
    let body: String
}

ProfileWorker.swift

swift
// Worker: stateless, pure async function
// Tidak perlu actor karena tidak punya mutable state
struct ProfileWorker {
    private let session: URLSession
    private let baseURL = URL(string: "https://jsonplaceholder.typicode.com")!
    private let decoder = JSONDecoder()

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetchUser(id: String) async throws -> User {
        let url = baseURL.appendingPathComponent("users/\(id)")
        let (data, response) = try await session.data(from: url)

        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw ProfileError.networkError
        }

        return try decoder.decode(User.self, from: data)
    }

    func fetchPosts(userId: Int) async throws -> [Post] {
        let url = baseURL.appendingPathComponent("posts")
        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
        components.queryItems = [URLQueryItem(name: "userId", value: "\(userId)")]

        let (data, _) = try await session.data(from: components.url!)
        return try decoder.decode([Post].self, from: data)
    }
}

enum ProfileError: Error, LocalizedError {
    case networkError
    case notFound

    var errorDescription: String? {
        switch self {
        case .networkError: return "Koneksi bermasalah, coba lagi."
        case .notFound: return "Profil tidak ditemukan."
        }
    }
}

ProfileInteractor.swift

swift
// Interactor sebagai actor — melindungi business logic state
// Semua akses dari luar harus lewat await
actor ProfileInteractor {
    // Protocol untuk komunikasi ke presenter
    // Tidak perlu @MainActor — Swift otomatis hop ke MainActor
    // saat memanggil method @MainActor pada protocol
    weak var presenter: ProfilePresentationLogic?
    private let worker: ProfileWorker

    // State internal interactor — dilindungi actor isolation
    private var currentRequest: ProfileRequest?
    private var loadingTask: Task<Void, Never>?

    init(worker: ProfileWorker = ProfileWorker()) {
        self.worker = worker
    }

    // Dipanggil dari ViewController via Task { await interactor.fetchProfile(request:) }
    func fetchProfile(request: ProfileRequest) async {
        // Cancel request sebelumnya jika masih berjalan
        loadingTask?.cancel()
        currentRequest = request

        loadingTask = Task {
            do {
                // Fetch user dan posts secara paralel
                async let user = worker.fetchUser(id: request.userId)
                async let posts = worker.fetchPosts(userId: Int(request.userId) ?? 0)

                let (fetchedUser, fetchedPosts) = try await (user, posts)

                // Cek apakah sudah di-cancel sebelum meneruskan ke presenter
                guard !Task.isCancelled else { return }

                let response = ProfileResponse(user: fetchedUser, posts: fetchedPosts)

                // Swift otomatis hop ke @MainActor karena presentProfile di protocol adalah @MainActor
                await presenter?.presentProfile(response: response)
            } catch is CancellationError {
                // Request di-cancel, abaikan
            } catch {
                await presenter?.presentError(error: error)
            }
        }
    }
}

// Protocol untuk ViewController memanggil Interactor
protocol ProfileBusinessLogic: AnyObject {
    func fetchProfile(request: ProfileRequest) async
}

extension ProfileInteractor: ProfileBusinessLogic {}

ProfilePresenter.swift

swift
// Presenter berjalan di @MainActor — aman update UI langsung
@MainActor
class ProfilePresenter {
    weak var viewController: ProfileDisplayLogic?

    func presentProfile(response: ProfileResponse) {
        // Format data mentah menjadi ViewModel yang siap display
        let postViewModels = response.posts.map { post in
            PostCellViewModel(
                id: post.id,
                title: post.title,
                preview: String(post.body.prefix(80)).appending("...")
            )
        }

        let viewModel = ProfileViewModel(
            displayName: response.user.name,
            email: response.user.email,
            postCount: "\(response.posts.count) postingan",
            posts: postViewModels
        )

        viewController?.displayProfile(viewModel: viewModel)
    }

    func presentError(error: Error) {
        let message = (error as? LocalizedError)?.errorDescription
            ?? "Terjadi kesalahan. Silakan coba lagi."
        viewController?.displayError(message: message)
    }
}

// Protocol untuk Interactor memanggil Presenter
@MainActor
protocol ProfilePresentationLogic: AnyObject {
    func presentProfile(response: ProfileResponse)
    func presentError(error: Error)
}

extension ProfilePresenter: ProfilePresentationLogic {}

ProfileViewController.swift

swift
// ViewController di @MainActor — semua UI update aman di main thread
@MainActor
class ProfileViewController: UIViewController {

    // MARK: - UI Components
    private let nameLabel = UILabel()
    private let emailLabel = UILabel()
    private let postsCountLabel = UILabel()
    private let tableView = UITableView()
    private let activityIndicator = UIActivityIndicatorView(style: .medium)

    // MARK: - VIP
    var interactor: ProfileBusinessLogic?
    var router: ProfileRoutingLogic?

    // MARK: - State (dilindungi @MainActor)
    private var posts: [PostCellViewModel] = []

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupVIP()
        setupUI()
        fetchProfile()
    }

    private func setupVIP() {
        let interactor = ProfileInteractor()
        let presenter = ProfilePresenter()
        let router = ProfileRouter()

        presenter.viewController = self
        interactor.presenter = presenter
        router.viewController = self

        self.interactor = interactor
        self.router = router
    }

    // MARK: - Actions
    private func fetchProfile() {
        activityIndicator.startAnimating()
        let request = ProfileRequest(userId: "1")

        // Task {} mewarisi @MainActor dari ViewController
        // Setelah await, kembali ke main thread otomatis
        Task {
            await interactor?.fetchProfile(request: request)
        }
    }

    // MARK: - Display Logic
    func displayProfile(viewModel: ProfileViewModel) {
        // Sudah di @MainActor — langsung update UI
        activityIndicator.stopAnimating()
        nameLabel.text = viewModel.displayName
        emailLabel.text = viewModel.email
        postsCountLabel.text = viewModel.postCount
        posts = viewModel.posts
        tableView.reloadData()
    }

    func displayError(message: String) {
        activityIndicator.stopAnimating()
        let alert = UIAlertController(title: "Error",
                                      message: message,
                                      preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }

    // MARK: - UI Setup (omitted for brevity)
    private func setupUI() { /* ... */ }
}

// Protocol untuk Presenter memanggil ViewController
@MainActor
protocol ProfileDisplayLogic: AnyObject {
    func displayProfile(viewModel: ProfileViewModel)
    func displayError(message: String)
}

extension ProfileViewController: ProfileDisplayLogic {}

ProfileRouter.swift

swift
// Router di @MainActor — navigasi selalu di main thread
@MainActor
class ProfileRouter {
    weak var viewController: UIViewController?

    func routeToPostDetail(post: PostCellViewModel) {
        let detailVC = PostDetailViewController(post: post)
        viewController?.navigationController?.pushViewController(detailVC, animated: true)
    }
}

@MainActor
protocol ProfileRoutingLogic: AnyObject {
    func routeToPostDetail(post: PostCellViewModel)
}

extension ProfileRouter: ProfileRoutingLogic {}

Alur Data VIP + Concurrency

swift
User Tap                    MainActor (ViewController)
    │                               │
    └─ Task { await interactor.fetchProfile() }
                                    │
                            actor (Interactor)
                                    │
                         async let user = worker.fetch()
                         async let posts = worker.fetch()
                                    │  ← keduanya paralel
                            await (user, posts)
                                    │
                      await presenter?.presentProfile(response) // auto-hop ke @MainActor@MainActor (Presenter)
                                    │
                     viewController?.displayProfile()
                                    │
                            @MainActor (ViewController)
                                    │
                              Update UI ✅

Trade-off di Swift 5.x

Aspek Swift 5.x Swift 6
Crossing actor boundary tanpa await Warning ⚠️ Error ❌
@MainActor class dengan non-isolated method Warning ⚠️ Error ❌
Mutable capture di @Sendable closure Warning ⚠️ Error ❌
Disiplin yang dibutuhkan Tinggi (manual) Dijamin compiler