본문 바로가기

개발/디자인 패턴

[iOS] 디자인패턴 - VIPER

반응형

VIPER 이란?

 

이미지 참고 - objc App Architecture

 

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
    }
    
    ...
    
}

 

실행하면 잘 동작한다.

 

 

farabi/ViperDemoProject

Contribute to farabi/ViperDemoProject development by creating an account on GitHub.

github.com

 


반응형

'개발 > 디자인 패턴' 카테고리의 다른 글

[iOS] 디자인패턴 - MVP  (0) 2020.03.20
[iOS] 디자인 패턴 - VIPER 화면 전환 예제  (0) 2020.03.08