VIPER 이란?
Viper는 View, Interactor, Presenter, Entity, Router의 약자다.
- Entity
- 단순 데이터 모델
- Interaction
- 비즈니스 로직이 위치한다.
- 네트워크 호출이나 데이터베이스 쿼리 등 데이터를 수집한다.
- Presenter
- VIPER 아키텍처의 중심 부분.
- Clean Architecture의 Interface Adaper과 비슷하다.
- 뷰에서 사용하는 데이터 변환과 UI작업 가로채기 등의 작업을 수행.
- View (ViewController) =
- 여기서 View는 ViewController와 ViewContainer 모두를 의미한다.
- 이곳에서는 비즈니스 로직을 피하고 UI코드만 기억한다.
- Presenter에게 뷰에서 일어나는 이벤트와 변화를 전송한다.
- Router
- 화면 이동하는 것을 책임진다.
- 모든 Router는 내비게이션 컨트롤러에 대한 참조를 가져온다.
목적 분리를 위해 View와 Router만 UIKit를 가져와야한다.
아래는 VIPER의 핵심 구성요소는 아니지만, 자주 쓰이는 구성요소다.
이 둘은 VIPER를 구성하는데 필수는 아니지만 효율적인 분리에 사용을 권장한다.
- Builder (=Configurator)
- Builder 클래스를 사용하여 모듈에 대한 모든 초기 코드를 넣을 수 있다.
- 일반적으로 View 주위에 Viper 모듈을 구축하는 정적 클래스만 포함한다. (ViewController)
- Worker
- Interactor에서 분리된 클래스.
- 예로 오프라인 모드에 사용할 rest API와, 로컬 데이터베이스를 가지고 있는 경우, 각 데이터 소스에 대한 Worker class를 만들어 interactor 크기를 줄일 수 있다.
Viper 예제
예제로 만들어볼 프로젝트는 단순히 사탕을 판매하는 가게로,
사용자는 원하는 만큼의 사탕을 집어, 세금이 포함된 총가격을 알 수 있다.
아래는 사탕의 Entity이다.
struct CandyEntity {
let title: String
let description: String
let price: Float
let imageName: String
}
Entity 개체를 생성하고 값을 넣어줄 코드가 필요하다.
이 프로젝트에서는 Worker를 통해 구현했다.
protocol CandyAPIWorkerProtocol {
func fetchCandy(callBack:(CandyEntity) -> Void)
}
// Candy 오브젝트로 데이터를 얻기 위해 웹 서비스 호출을 하는 api worker을 만들었다.
class CandyAPIWorker : CandyAPIWorkerProtocol {
func fetchCandy(callBack:(CandyEntity) -> Void) {
let candyEntity = CandyEntity(title: "천국 사탕", description: "마법 사탕은 천국에서 만들어집니다. 맛보고 싶으시다면 주문하세요! \n#마약 아닙니다. #사기 아닙니다.", price: 100, imageName: "magic_candy")
callBack(candyEntity)
}
}
Worker를 불러들여 비즈니스 로직을 구현할 Interactor를 구성한다.
protocol CandyInteractorProtocol {
func fetchCandy()
func update(candyQuantity quantity:Int)
}
// Interactor의 안에 비즈니스 로직이 있어야한다.
// 네트워크 호출이나 데이터베이스 쿼리 등 데이터 수집 작업을 이곳에서 진행한다.
class CandyInteractor: CandyInteractorProtocol {
private static let vat:Float = 6.5 // 세금 값이니 신경쓰지 않아도 된다.
private var candyEntity:CandyEntity?
private let apiWorker: CandyAPIWorkerProtocol
required init(withApiWorker apiWorker:CandyAPIWorkerProtocol) {
self.apiWorker = apiWorker
}
func fetchCandy() {
}
func update(candyQuantity quantity: Int) {
}
}
위의 Interactor는 apiWorker을 선언만 해놓은 상태로, 아직 구성해놓은 것은 없다.
원하는 함수를 구현하기 위해서는 연결할 Presenter과 View가 필요하다.
Presenter를 구성하기 전에 먼저 레이아웃과 연결될 View를 구성하자.
protocol CandyViewProtocol: class {
var presenter: CandyPresenterProtocol? { get set }
func set(viewModel: CandyViewModel) // Set the view Object of Type CandyEntity
func set(totalPriceViewModel viewModel: TotalPriceViewModel) // Set the view price object
}
class CandyView: UIViewController {
@IBOutlet weak private var candyImageView: UIImageView!
@IBOutlet weak private var titleLabel: UILabel!
@IBOutlet weak private var descriptionLabel: UILabel!
@IBOutlet weak private var priceLabel: UILabel!
@IBOutlet weak private var quantityStepper: UIStepper!
@IBOutlet weak private var quantityLabel: UILabel!
@IBOutlet weak private var totalPriceLabel: UILabel!
@IBOutlet weak private var taxLabel: UILabel!
@IBOutlet weak private var inclTaxLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
}
}
extension CandyView: CandyViewProtocol {
func set(viewModel: CandyViewModel) {
titleLabel.text = viewModel.title
descriptionLabel.text = viewModel.description
priceLabel.text = viewModel.price
candyImageView.image = UIImage(named: viewModel.imageName)
}
func set(totalPriceViewModel viewModel: TotalPriceViewModel) {
//excl tax, incl tax, VAT
quantityLabel.text = viewModel.quantity
totalPriceLabel.text = viewModel.totalPrice
taxLabel.text = viewModel.vat
inclTaxLabel.text = viewModel.totalInclTax
}
}
View를 만들었으니, View를 변화시키고 Interactor과 연결할 중심 모델 Presenter를 만든다.
사탕 뷰 모델과 사용자가 고른 사탕의 총금액을 나타낼 뷰 모델이 필요하다.
struct CandyViewModel {
let title: String
let description: String
let price: String
let imageName: String
}
struct TotalPriceViewModel {
let totalPrice: String
let totalInclTax: String
let vat: String
let quantity: String
}
Presenter는 View와 Interactor 사이에 위치한 중심 모델로,
View에서 사용할 데이터 변환과 UI작업 가로채기 등의 작업을 수행한다.
protocol CandyPresenterProtocol : class {
/// The presenter will fetch data from the Interactor thru implementing the Interactor fetch function.
func fetchCandy()
func update(candyQuantity quantity:Int)
func interactor(_ interactor: CandyInteractorProtocol, didFetch object: CandyEntity) // 병합 성공
func interactor(_ interactor: CandyInteractorProtocol, didFailWith error: Error) // 병합 실패
func interactor(_ interactor: CandyInteractorProtocol, didUpdateTotalPrice totalPrice:Float, totalInclTax:Float,vat:Float, quantity:Int)
}
// Presenter는 View에서 사용할 데이터 변환과 UI 작업 가로채기 작업을 수행한다.
class CandyPresenter {
weak var view: CandyViewProtocol?
var interactor: CandyInteractorProtocol?
}
Interactor과 소통하기 위한 프로토콜과 연결에 사용한 변수를 선언한다.
extension CandyPresenter : PresenterProtocol {
func fetch() {
interactor?.fetchItems()
}
func update(candyQuantity candyquantity: Int, chocoQuantity chocoquantity: Int) {
interactor?.update(candyQuantity: candyquantity, chocoQuantity: chocoquantity)
}
func interactorCandy(_ interactor: InteractorProtocol, didFetchCandy candyObject: CandyEntity) {
let candyPriceText = "\(candyObject.price) 원"
let candyViewModel = CandyViewModel(title: candyObject.title, description: candyObject.description, price: candyPriceText, imageName: candyObject.imageName)
view?.setCandy(viewModel: candyViewModel)
}
func interactorChoco(_ interactor: InteractorProtocol, didFetchChoco chocoObject: CandyEntity) {
let chocoPriceText = "\(chocoObject.price) 원"
let chocoViewModel = CandyViewModel(title: chocoObject.title, description: chocoObject.description, price: chocoPriceText, imageName: chocoObject.imageName)
view?.setChoco(viewModel: chocoViewModel)
}
func interactor(_ interactor: InteractorProtocol, didFailWith error: Error) {
//
}
func interactor(_ interactor: InteractorProtocol,
didUpdateTotalPrice totalPrice: Float,
totalInclTax: Float,
vat: Float, candyQuantity:Int, chocoQuantity:Int) {
let totalPriceText = "Total Price : \(totalPrice)won"
let totalInclTaxText = "Incl Tax : \(totalInclTax)won"
let vatText = "Tax : \(vat)%"
let totalPriceViewModel = TotalPriceViewModel(totalPrice: totalPriceText, totalInclTax: totalInclTaxText, vat: vatText, candyQuantity: String(candyQuantity), chocoQuantity: String(chocoQuantity))
view?.set(totalPriceViewModel: totalPriceViewModel)
}
}
사용자가 사탕을 추가, 삭제할 때마다 변화할 수 있도록 코드를 작성해준다.
즉시 화면이 업데이트될 수 있도록 didUpdateTotalPrice 메서드에 화면 변화 코드를 작성하자.
Presenter을 제작했으니, Interactor로 가서 presenter에 적용시킬 아직 작성하지 않은 비즈니스 로직을 넣어준다.
class CandyInteractor: CandyInteractorProtocol {
...
var presenter : PresenterProtocol?
...
func fetchCandy() {
// unowned는 값이 있음을 가정하고 사용하는 옵셔널. unowned 값이 nil이라 하면 크래쉬가 발생할 수 있음.
apiWorker.fetchCandy { [unowned self] (candyEntity) in
self.candyEntity = candyEntity
self.presenter?.interactor(self, didFetch: candyEntity)
}
}
func update(candyQuantity quantity: Int) {
guard let candyEntity = self.candyEntity else { return }
let totalPrice = candyEntity.price * Float(quantity)
let tax = (totalPrice/100) * CandyInteractor.vat
let totalInclTax = totalPrice + tax
presenter?.interactor(self,
didUpdateTotalPrice: totalPrice,
totalInclTax: totalInclTax,
vat: CandyInteractor.vat,
quantity: quantity)
}
}
뷰에도 적용할 Presenter 코드를 추가해준다.
class CandyView: UIViewController {
var presenter: CandyPresenterProtocol?
...
override func viewDidLoad() {
super.viewDidLoad()
presenter?.fetchCandy()
}
@IBAction func quantityStepperValueChanged(_ sender: Any) {
presenter?.update(candyQuantity: Int(quantityStepper!.value))
}
}
현재까지 Entity, Interactor, Presenter, View의 구성이 완료되었다.
Router을 만들어 필요한 모든 구성요소를 만들어준다.
protocol RouterProtocol {
}
class CandyRouter: RouterProtocol {
}
Router의 코드를 Builder로 대신 구현했다.
Presenter로 돌아가, 연결이 완료되지 않은 Router을 연결해준다.
class CandyPresenter {
...
var wireframe : RouterProtocol?
}
화면을 선언하고 구성요소들을 초기화할 코드를 Builder로 구현해준다.
class CandyBuilder {
class func buildModule(arroundView view: ViewProtocol){
let presenter = CandyPresenter()
let interactor = CandyInteractor(withApiWorker: CandyAPIWorker())
let router = CandyRouter()
view.presenter = presenter
presenter.view = view
presenter.wireframe = router
presenter.interactor = interactor
interactor.presenter = presenter
}
}
마지막으로, 프로젝트를 시작했을 시 뷰를 띄울 수 있도록 Appledegate에서 Window를 만들어주자.
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
window?.makeKeyAndVisible()
let candyView = CandyView(nibName: "CandyView", bundle: nil)
let navController = UINavigationController(rootViewController: candyView) // Integrate navigation controller programmatically if you want
window?.rootViewController = navController
CandyBuilder.buildModule(arroundView: candyView)
return true
}
...
}
실행하면 잘 동작한다.
'개발 > 디자인 패턴' 카테고리의 다른 글
[iOS] 디자인패턴 - MVP (0) | 2020.03.20 |
---|---|
[iOS] 디자인 패턴 - VIPER 화면 전환 예제 (0) | 2020.03.08 |