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)ProfileInteractor→actor(business logic, serial access)ProfilePresenter→@MainActor(format data, dipanggil dari main thread)ProfileWorker→async func(stateless, tidak perlu actor)ProfileModels→ semuaSendable(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 |