VIPER (View-Interactor-Presenter-Entity-Router) là một kiến trúc mô-đun được phát triển tại Mutual Mobile cho các ứng dụng iOS. VIPER chia ứng dụng thành năm lớp: View chịu trách nhiệm hiển thị, Interactor chịu trách nhiệm logic nghiệp vụ, Presenter chịu trách nhiệm chuẩn bị dữ liệu, Entity chịu trách nhiệm mô hình dữ liệu, Router chịu trách nhiệm điều hướng giữa các mô-đun. VIPER là triển khai chi tiết nhất của nguyên tắc đơn trách nhiệm trong các kiến trúc di động. Đọc thêm trong bài viết trên objc.io.
Những điểm chính
VIPER (View-Interactor-Presenter-Entity-Router) là một mẫu kiến trúc được phát triển vào năm 2013–2014 tại Mutual Mobile cho các dự án iOS lớn. Mỗi màn hình ứng dụng là một mô-đun riêng biệt gồm năm thành phần với trách nhiệm được xác định chặt chẽ. VIPER là triển khai nghiêm ngặt nhất của Nguyên tắc Đơn trách nhiệm (Single Responsibility Principle) trong phát triển di động: không thành phần nào làm việc mà thành phần khác có thể làm.
View là thành phần thụ động chỉ chịu trách nhiệm hiển thị dữ liệu do Presenter truyền đến. View không chứa logic nghiệp vụ, không xử lý điều hướng, không thực hiện yêu cầu mạng. Trong iOS — UIViewController với ViewProtocol. Interactor là lớp logic nghiệp vụ làm việc với Entity và các dịch vụ (mạng, DB, GPS). Interactor không import UIKit. Presenter là trung gian giữa View và Interactor: nhận dữ liệu từ Interactor, định dạng để hiển thị, truyền cho View. Presenter cũng không import UIKit. Entity — mô hình dữ liệu (struct, class). Router — quản lý điều hướng: tạo mô-đun, mở màn hình, truyền dữ liệu giữa các mô-đun.
| Thành phần | Trách nhiệm | Phụ thuộc |
|---|---|---|
| View | Hiển thị, hoạt ảnh, cử chỉ | UIKit (chỉ View) |
| Interactor | Logic nghiệp vụ, mạng, DB | Entity, dịch vụ |
| Presenter | Định dạng dữ liệu, lệnh View | ViewProtocol, Interactor |
| Entity | Mô hình dữ liệu | Không |
| Router | Điều hướng, tạo mô-đun | UIViewController (cho chuyển tiếp) |
Mối quan hệ giữa các thành phần được mô tả bằng giao thức. ViewProtocol định nghĩa phương thức hiển thị, InteractorProtocol định nghĩa phương thức logic nghiệp vụ, PresenterProtocol định nghĩa phương thức xử lý sự kiện, RouterProtocol định nghĩa phương thức điều hướng. Mỗi thành phần giao tiếp với thành phần khác chỉ thông qua giao thức, cho phép dễ dàng thay thế triển khai và kiểm thử cô lập. Trung bình, một mô-đun VIPER cho một màn hình chứa 5 giao thức + 5 lớp + 1 Builder/Assembler = 11 tệp cho mỗi màn hình.
Xây dựng mô-đun VIPER được thực hiện trong Builder (hoặc Assembler), tạo ra cả năm thành phần và kết nối chúng qua các giao thức. Builder là nơi duy nhất các thành phần biết kiểu cụ thể của nhau. Sau khi lắp ráp, View được trả ra bên ngoài để hiển thị, phần còn lại của chuỗi sạch sẽ và được kiểm thử cô lập.
// Giao thức — View
protocol UserViewProtocol: AnyObject {
func display(name: String)
func display(email: String)
func showLoading()
func hideLoading()
}
// Giao thức — Interactor
protocol UserInteractorProtocol {
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void)
}
// Giao thức — Router
protocol UserRouterProtocol {
func navigateToProfile(userId: Int)
}
// Interactor — logic nghiệp vụ
final class UserInteractor: UserInteractorProtocol {
private let service: UserService
init(service: UserService) { self.service = service }
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
service.fetchUser(id: id, completion: completion)
}
}
// Presenter — chuẩn bị dữ liệu
final class UserPresenter {
private weak var view: UserViewProtocol?
private let interactor: UserInteractorProtocol
private let router: UserRouterProtocol
init(interactor: UserInteractorProtocol, router: UserRouterProtocol) {
self.interactor = interactor
self.router = router
}
func setView(_ view: UserViewProtocol) {
self.view = view
}
func viewDidLoad() {
view?.showLoading()
interactor.fetchUser(id: 42) { [weak self] result in
guard let self else { return }
self.view?.hideLoading()
switch result {
case .success(let user):
self.view?.display(name: user.name)
self.view?.display(email: user.email)
case .failure(let error):
// xử lý lỗi
}
}
}
}
// Router — điều hướng
final class UserRouter: UserRouterProtocol {
private weak var viewController: UIViewController?
func setViewController(_ vc: UIViewController) {
viewController = vc
}
func navigateToProfile(userId: Int) {
let profileModule = ProfileModuleBuilder.build(userId: userId)
viewController?.navigationController?.pushViewController(profileModule, animated: true)
}
}
// Builder — lắp ráp mô-đun
enum UserModuleBuilder {
static func build(userId: Int) -> UIViewController {
let service = UserService()
let interactor = UserInteractor(service: service)
let router = UserRouter()
let presenter = UserPresenter(interactor: interactor, router: router)
let viewController = UserViewController(presenter: presenter)
presenter.setView(viewController)
router.setViewController(viewController)
return viewController
}
}
Builder/Assembler là thành phần chính của VIPER, triển khai Dependency Injection thủ công. Việc tiêm phụ thuộc qua hàm tạo (constructor injection) đảm bảo rằng một thành phần không thể được tạo mà không có phụ thuộc của nó. Trong VIPER hiện đại, Builder có thể sử dụng Swinject (container DI), nhưng lắp ráp thủ công vẫn minh bạch hơn cho việc kiểm thử. Tại IT Sectr, chúng tôi áp dụng VIPER với lắp ráp thủ công cho các mô-đun có logic phức tạp — điều này giúp đơn giản hóa việc đọc mã cho nhà phát triển mới.
Mapper (Formatter) là thành phần thứ sáu tùy chọn của VIPER. Mapper chuyển đổi Entity (mô hình DB/máy chủ) thành ViewModel (mô hình hiển thị). Entity chứa UserDTO với các trường id, first_name, last_name, email. ViewModel — UserDisplayItem với name (first_name + last_name) và email. Mapper được thực thi trong Presenter. Nếu ánh xạ dữ liệu phức tạp (nhiều Entity → một ViewModel), Mapper được trích xuất thành một lớp riêng để kiểm thử.
Các mô-đun VIPER được cô lập và không biết về nhau. Giao tiếp giữa các mô-đun xảy ra qua Router. Khi người dùng nhấp vào nút "Hồ sơ" trên màn hình người dùng, Presenter gọi router.navigateToProfile(userId: 42). Router tạo một mô-đun mới qua ProfileModuleBuilder.build(userId: 42) và mở nó qua navigationController.push. Luồng dữ liệu: Mô-đun A → Router A → Builder Mô-đun B → Mô-đun B được tạo và mở.
Truyền dữ liệu trở lại (ví dụ: chọn thành phố trên màn hình lựa chọn → quay lại màn hình chỉnh sửa hồ sơ) trong VIPER được triển khai qua delegate hoặc closure. Mô-đun B định nghĩa giao thức ModuleBDelegate với phương thức didSelectCity(_ city: City). Mô-đun A triển khai giao thức này. Router A truyền delegate cho Builder Mô-đun B. Khi một thành phố được chọn, Mô-đun B gọi delegate?.didSelectCity(city). Đây là thực tiễn tiêu chuẩn của iOS, quen thuộc với mọi nhà phát triển UIKit.
| Kịch bản | Cơ chế | Ví dụ |
|---|---|---|
| Điều hướng tiếp | Router → Builder | navigateToProfile(userId:) |
| Truyền dữ liệu trở lại | Delegate | didSelectCity(_:) |
| Thông báo hệ thống | NotificationCenter | UserDidLogout |
| Sự kiện từ Interactor | Presenter → View | Tin nhắn WebSocket |
NotificationCenter được sử dụng cho các sự kiện hệ thống (đăng xuất, thay đổi gói, thông báo đẩy) ảnh hưởng đến nhiều mô-đun cùng lúc. Router hoặc AppDelegate đăng ký Notification, tạo mô-đun cần thiết hoặc cập nhật trạng thái. VIPER không cấm NotificationCenter — điều quan trọng là nó chỉ được sử dụng cho các sự kiện 1-nhiều, trong khi giao tiếp 1-1 sử dụng delegate hoặc closure.
VIPER vs MVVM — VIPER yêu cầu lượng mã gấp 2–3 lần cho mỗi màn hình nhưng cung cấp sự cô lập thành phần tuyệt đối. MVVM với ViewModel + SwiftUI đơn giản và nhanh hơn nhưng khả năng mở rộng kém hơn cho nhóm 5+ nhà phát triển. VIPER xác định chặt chẽ ai chịu trách nhiệm cho việc gì: Interactor — chỉ logic nghiệp vụ, Presenter — định dạng, Router — điều hướng. Trong MVVM, ViewModel thường phình to, đảm nhận điều hướng và logic nghiệp vụ.
VIPER vs Clean Architecture — VIPER là một trường hợp cụ thể của Clean Architecture được điều chỉnh cho iOS UIKit. Interactor = Use Case, Entity = Domain Model, Presenter = Presentation, Router = Controller theo thuật ngữ của Robert Martin. Clean Architecture thêm lớp Gateway/Repository giữa Interactor và dữ liệu, thường không được tách riêng trong VIPER. Đối với các dự án SwiftUI hiện đại, hầu hết các nhóm chọn Clean Architecture (The Composable Architecture) hoặc MVVM, để lại VIPER cho các dự án UIKit cũ.
Khi nào chọn VIPER — nhóm 5+ nhà phát triển, dự án UIKit có 50+ màn hình, yêu cầu kiểm thử trên 80%, chỉ iOS (VIPER không thể chuyển sang Android mà không viết lại). VIPER cung cấp cấu trúc có thể dự đoán: một nhà phát triển mới hiểu một mô-đun trong 15 phút. Tuy nhiên, tốc độ phát triển thấp hơn 20–30% so với MVVM do số lượng tệp lớn hơn. Tại IT Sectr, chúng tôi sử dụng VIPER cho các dự án doanh nghiệp UIKit với nhóm từ 3 người và ưu tiên Clean Architecture cho các dự án SwiftUI mới.
VIPER được thiết kế cho kiểm thử — mỗi thành phần được kiểm thử cô lập qua giao thức. Interactor được kiểm thử với dịch vụ giả: kiểm tra rằng fetchUser được gọi với ID chính xác và kết quả được truyền cho Presenter. Presenter được kiểm thử với đối tượng giả của View và Interactor. Router được kiểm thử với điều hướng giả: kiểm tra rằng navigateToProfile được gọi với userId chính xác và mô-đun chính xác được tạo. View được kiểm thử bằng kiểm thử UI (XCUITest).
import XCTest
final class UserPresenterTests: XCTestCase {
func testViewDidLoad_callsFetchUserAndUpdatesView() {
// Given
let view = MockUserView()
let interactor = MockUserInteractor()
let router = MockUserRouter()
let presenter = UserPresenter(interactor: interactor, router: router)
presenter.setView(view)
let expectedUser = User(id: 42, name: "John", email: "john@test.com")
interactor.result = .success(expectedUser)
// When
presenter.viewDidLoad()
// Then
XCTAssertEqual(interactor.capturedUserId, 42)
XCTAssertEqual(view.displayedName, "John")
XCTAssertTrue(view.didShowLoading)
}
}
Đối tượng giả cho VIPER được tạo thủ công (lớp với thuộc tính capture được lưu trữ) hoặc qua thư viện như Cuckoo / Mockingbird. Các lớp giả thủ công đơn giản và rõ ràng hơn, đặc biệt cho việc đào tạo nhà phát triển mới. Mỗi đối tượng giả lưu trữ giá trị capture (capturedUserId, displayedName) và cờ gọi (didShowLoading). Kết thúc kiểm thử, không chỉ kiểm tra rằng phương thức đã được gọi, mà còn với tham số nào — điều này mang lại sự tự tin về tính đúng đắn của luồng dữ liệu.
Độ phủ mã trong các dự án VIPER của IT Sectr đạt 85–95% cho Interactor, 90–95% cho Presenter, 70–80% cho Router, 30–50% cho View (qua kiểm thử UI). View được kiểm thử bằng kiểm thử ảnh chụp màn hình (SnapshotTesting, 1,5K sao) — nhanh hơn XCUITest và bao phủ nhiều trường hợp hơn. Độ phủ tổng thể của một dự án VIPER thường là 70–80%, cao hơn dự án MVVM (50–65%), nhưng cần nhiều thời gian hơn để viết kiểm thử (30–40% thời gian phát triển so với 20–25% trong MVVM).
Câu hỏi thường gặp
Tối thiểu 11 tệp: 5 giao thức (ViewProtocol, InteractorProtocol, PresenterProtocol, RouterProtocol, Entity), 5 triển khai (ViewController, Interactor, Presenter, Router, Entity) và Builder/Assembler. Với Mapper (Formatter) — 12–13. Cho một dự án 50 màn hình, đó là 550–650 tệp chỉ riêng mô-đun VIPER. MVVM yêu cầu 3 tệp cho mỗi màn hình (ViewModel, View, Model) — 150 tệp cho 50 màn hình.
Có, về mặt lý thuyết VIPER có thể chuyển sang Android, nhưng trong thực tế không được sử dụng — Google khuyến nghị MVVM với Jetpack. VIPER được tạo cho iOS UIKit, nơi ViewController khó kiểm thử do vòng đời của nó. Trên Android, Jetpack ViewModel giải quyết vấn đề kiểm thử mà không cần cô lập VIPER. Tương đương của VIPER trên Android là Clean Architecture với phân chia mô-đun/tính năng.
VIPER là triển khai dành riêng cho iOS của Clean Architecture. Interactor tương ứng với Use Case, Entity — Domain Model, Presenter — lớp Presentation. Clean Architecture thêm Repository/Gateway giữa Interactor và dữ liệu, trong VIPER thường được triển khai bên trong Interactor. Clean Architecture không quy định Router — điều hướng được để tùy theo triển khai.
Không — SwiftUI được thiết kế cho MVVM + Combine. VIPER trong SwiftUI là dư thừa: năm thành phần cho mỗi màn hình với UI khai báo là chi phí không có lợi ích. Cho SwiftUI, hãy chọn MVVM hoặc TCA (The Composable Architecture). VIPER vẫn phù hợp cho các dự án UIKit cũ và các dự án có iOS 12 trở xuống là phiên bản tối thiểu.
Qua Router. Mô-đun A gọi router.navigateToProfile(userId: id). Router A tạo mô-đun B qua Builder, truyền userId. Giao tiếp trả về — qua delegate: mô-đun B định nghĩa giao thức ModuleBDelegate, mô-đun A triển khai nó và truyền qua Router. Sự kiện hệ thống (đăng xuất) — qua NotificationCenter.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm