[Swift] [Combine] delegate パターンで記述した View を @Published で書き直してみた


# この記事でわかること

`delegate` パターンで記述していた View と Presenter の処理を `@Published` を使って書き直してみたところ、それぞれのメリット・デメリットとして、以下のことが挙げられた。

- **`delegate` パターン**
  - **メリット**
    - **Computed property を気にせず使える**
  - **デメリット**
    - **`didSet` で `delegete` の処理を走らせて、それが View に反映されるまでの見通しが悪い**
    - Computed Property を多用していると、ネストした Computed Property の値を使用しているときの見通しが悪い
    - `delegete` の `protocol` を実装しなければならない
    - `weak var delegete` への代入をし忘れる可能性がある
- **`@Published`**
  - **メリット**
    - **値の変更がそのまま View に反映するように記述できる = 処理の見通しが良い**
    - `didSet` を廃止できる
    - Computed Property を廃止できる
    - `delegate` パターンでは必要だったような `protocol` を記述する必要がない
  - **デメリット**
    - `@Published` な Computed Property を記述しようとすると Combine で書き直す必要がある

# はじめに

この記事は `delegate` パターンで記述していた View と Presenter の処理を `@Published` を使って書き直して、それぞれのメリットとデメリットをまとめた記事になります。

# やりたいこと

買い物アプリを想定したカート画面で以下のような仕様を想定します。

- **表示されている項目**
  - カートに入れている商品一覧
  - カートに入れた商品数
  - 合計の税込み価格
  - 選択したクーポン
  - クーポンを適応した税込み価格
- **できる操作**
  - カートに商品を入れる
  - クーポンを選択する

# 共通コード

```swift:共通コード
import Combine

enum Coupon: CaseIterable {
    case none
    case tenPercentOff
    case tenDiscount
}

extension Coupon {
    func apply(price: Int) -> Int {
        switch self {
        case .none:
            return price
        case .tenPercentOff:
            return Int(Double(price) * 0.9)
        case .tenDiscount:
            return price - 10
        }
    }
}

protocol ProductProtocol {
    var name: String { get }
    var price: Int { get }
}

struct Apple: ProductProtocol {
    let name: String = "apple"
    let price: Int = 100
}

struct Pen: ProductProtocol {
    let name: String = "pen"
    let price: Int = 30
}

enum Product: CaseIterable {
    case apple
    case pen
}

extension Product {
    var product: ProductProtocol {
        switch self {
        case .apple:
            return Apple()
        case .pen:
            return Pen()
        }
    }
}

class CommonProperty {
    static let consumptionTaxRate: Double = 0.1
}
```

# `delegate` の場合

## Cart の定義

```swift:Cartの定義
protocol CartDelegateProtocol: AnyObject {
    func didUpdateProducts()
}

class Cart {
    weak var delegete: CartDelegateProtocol?
    
    var products: [ProductProtocol] = [] {
        didSet {
            delegete?.didUpdateProducts()
        }
    }
    
    var productsCount: Int {
        products.count
    }
    
    var totalPriceExcludingTax: Int {
        products.reduce(0) { $0 + $1.price }
    }

    func add(product: ProductProtocol) {
        products.append(product)
    }
}
```

## Presenter

```swift:Presenter
protocol ShoppingPresenterDelegateProtocol: AnyObject {
    func didUpdateSelectedCoupon()
}

class ShoppingPresenter {
    weak var delegete: ShoppingPresenterDelegateProtocol?
    
    let productList: [ProductProtocol] = Product.allCases.map { $0.product }
    let couponList: [Coupon] = Coupon.allCases
    
    let cart = Cart()

    var productsInCart: [ProductProtocol] {
        cart.products
    }
    
    var productsCount: Int {
        cart.productsCount
    }
    
    var totalPriceIncludingTax: Int {
        calculateConsumptionTax(priceExcludingTax: cart.totalPriceExcludingTax)
    }
    
    var selectedCoupon: Coupon = .none {
        didSet  {
            delegete?.didUpdateSelectedCoupon()
        }
    }
    
    var couponAppliedTotalPriceIncludingTax: Int {
        selectedCoupon.apply(price: totalPriceIncludingTax)
    }
    
    func addInCart(product: ProductProtocol) {
        cart.add(product: product)
    }
    
    func select(coupon: Coupon) {
        selectedCoupon = coupon
    }
}

extension ShoppingPresenter {
    private func calculateConsumptionTax(priceExcludingTax: Int) -> Int {
        Int(Double(priceExcludingTax) * (CommonProperty.consumptionTaxRate + 1.0))
    }
}
```

## View

```swift:View
class ShoppingView {
    let presenter = ShoppingPresenter()
    
    var productsInCart: [ProductProtocol] = []
    var productsCount: Int = 0
    var selectedCoupon: Coupon = .none
    var totalPriceIncludingTax: Int = 0
    var couponAppliedTotalPriceIncludingTax: Int = 0
    
    init() {
        presenter.delegete = self
        presenter.cart.delegete = self
    }
    
    func update() {
        productsInCart = presenter.productsInCart
        productsCount = presenter.productsCount
        selectedCoupon = presenter.selectedCoupon
        totalPriceIncludingTax = presenter.totalPriceIncludingTax
        couponAppliedTotalPriceIncludingTax = presenter.couponAppliedTotalPriceIncludingTax
    }
}

extension ShoppingView: CartDelegateProtocol {
    func didUpdateProducts() {
        update()
    }
}

extension ShoppingView: ShoppingPresenterDelegateProtocol {
    func didUpdateSelectedCoupon() {
        update()
    }
}
```

## 挙動確認

```swift:挙動確認
extension ShoppingView {
    func randomAction() {
        if Bool.random() {
            presenter.addInCart(product: presenter.productList.randomElement()!)
        } else {
            presenter.select(coupon: presenter.couponList.randomElement()!)
        }
        printAll()
    }
    
    func printAll() {
        print("=============printAll=============")
        print("productsInCart: \(productsInCart)")
        print("productsCount: \(productsCount)")
        print("selectedCoupon: \(selectedCoupon)")
        print("totalPriceIncludingTax: \(totalPriceIncludingTax)")
        print("couponAppliedTotalPriceIncludingTax: \(couponAppliedTotalPriceIncludingTax)")
    }
}

let view = ShoppingView()

view.randomAction()
view.randomAction()
view.randomAction()
view.randomAction()
```

## 出力結果

```code:出力結果
=============printAll=============
productsInCart: [__lldb_expr_72.Pen(name: "pen", price: 50)]
productsCount: 1
selectedCoupon: none
totalPriceIncludingTax: 55
couponAppliedTotalPriceIncludingTax: 55
=============printAll=============
productsInCart: [__lldb_expr_72.Pen(name: "pen", price: 50)]
productsCount: 1
selectedCoupon: none
totalPriceIncludingTax: 55
couponAppliedTotalPriceIncludingTax: 55
=============printAll=============
productsInCart: [__lldb_expr_72.Pen(name: "pen", price: 50), __lldb_expr_72.Apple(name: "apple", price: 100)]
productsCount: 2
selectedCoupon: none
totalPriceIncludingTax: 165
couponAppliedTotalPriceIncludingTax: 165
=============printAll=============
productsInCart: [__lldb_expr_72.Pen(name: "pen", price: 50), __lldb_expr_72.Apple(name: "apple", price: 100), __lldb_expr_72.Pen(name: "pen", price: 50)]
productsCount: 3
selectedCoupon: none
totalPriceIncludingTax: 220
couponAppliedTotalPriceIncludingTax: 220
```

## メリット・デメリット

- **メリット**
  - **Computed property を気にせず使える**
- **デメリット**
  - **`didSet` で `delegete` の処理を走らせて、それが View に反映されるまでの見通しが悪い**
  - Computed Property を多用していると、ネストした Computed Property の値を使用しているときの見通しが悪い
  - `delegete` の `protocol` を実装しなければならない
  - `weak var delegete` への代入をし忘れる可能性がある

# `@Published` の場合

## Cart の定義

```swift:Cartの定義
class Cart {
    @Published private(set) var products: [ProductProtocol] = []
    @Published private(set) var productsCount: Int = 0
    @Published private(set) var totalPriceExcludingTax: Int = 0
    
    init() {
        $products
            .map { $0.count }
            .assign(to: &$productsCount)
        
        $products
            .map { $0.reduce(0) { $0 + $1.price } }
            .assign(to: &$totalPriceExcludingTax)
    }
    
    func add(product: ProductProtocol) {
        products.append(product)
    }
}
```

## Presenter

```swift:Presenter
class ShoppingPresenter {
    let productList: [ProductProtocol] = Product.allCases.map { $0.product }
    let couponList: [Coupon] = Coupon.allCases
    
    private let cart = Cart()
    
    @Published private(set) var productsInCart: [ProductProtocol] = []
    @Published private(set) var productsCount: Int = 0
    @Published private(set) var totalPriceIncludingTax: Int = 0
    @Published private(set) var selectedCoupon: Coupon = .none
    @Published private(set) var couponAppliedTotalPriceIncludingTax: Int = 0
    
    init() {
        cart.$products
            .assign(to: &$productsInCart)
        
        cart.$productsCount
            .assign(to: &$productsCount)
        
        cart.$totalPriceExcludingTax
            .combineLatest(Just(CommonProperty.consumptionTaxRate))
            .map { Double($0) * ($1 + 1.0) }
            .map { Int($0) }
            .assign(to: &$totalPriceIncludingTax)

        // ↑の税計算は以下でも可能である
        // cart.$totalPriceExcludingTax
        //    .compactMap { [weak self] in self?.calculateConsumptionTax(priceExcludingTax: $0) }
        //    .assign(to: &$totalPriceIncludingTax)
        
        $selectedCoupon
            .combineLatest($totalPriceIncludingTax)
            .map { $0.apply(price: $1) }
            .assign(to: &$couponAppliedTotalPriceIncludingTax)
    }
    
    func addInCart(product: ProductProtocol) {
        cart.add(product: product)
    }
    
    func select(coupon: Coupon) {
        selectedCoupon = coupon
    }
}

extension ShoppingPresenter {
    private func calculateConsumptionTax(priceExcludingTax: Int) -> Int {
        Int(Double(priceExcludingTax) * (CommonProperty.consumptionTaxRate + 1.0))
    }
}
```

## View

```swift:View
class ShoppingView {
    let presenter = ShoppingPresenter()
    
    var productsInCart: [ProductProtocol] = []
    var productsCount: Int = 0
    var selectedCoupon: Coupon = .none
    var totalPriceIncludingTax: Int = 0
    var couponAppliedTotalPriceIncludingTax: Int = 0
    
    private var cancellables = Set<AnyCancellable>()

    init() {
        presenter.$productsInCart
            .assign(to: \.productsInCart, on: self)
            .store(in: &cancellables)
        
        presenter.$productsCount
            .assign(to: \.productsCount, on: self)
            .store(in: &cancellables)

        presenter.$selectedCoupon
            .assign(to: \.selectedCoupon, on: self)
            .store(in: &cancellables)

        presenter.$totalPriceIncludingTax
            .assign(to: \.totalPriceIncludingTax, on: self)
            .store(in: &cancellables)

        presenter.$couponAppliedTotalPriceIncludingTax
            .assign(to: \.couponAppliedTotalPriceIncludingTax, on: self)
            .store(in: &cancellables)
    }
}
```

## 挙動確認

```swift:挙動確認
extension ShoppingView {
    func randomAction() {
        if Bool.random() {
            presenter.addInCart(product: presenter.productList.randomElement()!)
        } else {
            presenter.select(coupon: presenter.couponList.randomElement()!)
        }
        printAll()
    }
    
    func printAll() {
        print("=============printAll=============")
        print("productsInCart: \(productsInCart)")
        print("productsCount: \(productsCount)")
        print("selectedCoupon: \(selectedCoupon)")
        print("totalPriceIncludingTax: \(totalPriceIncludingTax)")
        print("couponAppliedTotalPriceIncludingTax: \(couponAppliedTotalPriceIncludingTax)")
    }
}

let view = ShoppingView()

view.randomAction()
view.randomAction()
view.randomAction()
view.randomAction()
```

## 出力結果

```code:出力結果
=============printAll=============
productsInCart: [__lldb_expr_74.Apple(name: "apple", price: 100)]
productsCount: 1
selectedCoupon: none
totalPriceIncludingTax: 110
couponAppliedTotalPriceIncludingTax: 110
=============printAll=============
productsInCart: [__lldb_expr_74.Apple(name: "apple", price: 100)]
productsCount: 1
selectedCoupon: none
totalPriceIncludingTax: 110
couponAppliedTotalPriceIncludingTax: 110
=============printAll=============
productsInCart: [__lldb_expr_74.Apple(name: "apple", price: 100)]
productsCount: 1
selectedCoupon: tenPercentOff
totalPriceIncludingTax: 110
couponAppliedTotalPriceIncludingTax: 99
=============printAll=============
productsInCart: [__lldb_expr_74.Apple(name: "apple", price: 100), __lldb_expr_74.Pen(name: "pen", price: 50)]
productsCount: 2
selectedCoupon: tenPercentOff
totalPriceIncludingTax: 165
couponAppliedTotalPriceIncludingTax: 148
```

## メリット・デメリット

- **メリット**
  - **値の変更がそのまま View に反映するように記述できる = 処理の見通しが良い**
  - `didSet` を廃止できる
  - Computed Property を廃止できる
  - `delegate` パターンでは必要だったような `protocol` を記述する必要がない
- **デメリット**
  - `@Published` な Computed Property を記述しようとすると Combine で書き直す必要がある

# 結論

`delegate` パターンで記述していた View と Presenter の処理を `@Published` を使って書き直してみたところ、それぞれのメリット・デメリットとして、以下のことが挙げられた。

- **`delegate` パターン**
  - **メリット**
    - **Computed property を気にせず使える**
  - **デメリット**
    - **`didSet` で `delegete` の処理を走らせて、それが View に反映されるまでの見通しが悪い**
    - Computed Property を多用していると、ネストした Computed Property の値を使用しているときの見通しが悪い
    - `delegete` の `protocol` を実装しなければならない
    - `weak var delegete` への代入をし忘れる可能性がある
- **`@Published`**
  - **メリット**
    - **値の変更がそのまま View に反映するように記述できる = 処理の見通しが良い**
    - `didSet` を廃止できる
    - Computed Property を廃止できる
    - `delegate` パターンでは必要だったような `protocol` を記述する必要がない
  - **デメリット**
    - `@Published` な Computed Property を記述しようとすると Combine で書き直す必要がある

# Playground コピペ用

共通コードを除いて Playground で動かしてみる用にコピペで動くコードを貼っておきます。

## `delegete` パターン

```swift
protocol CartDelegateProtocol: AnyObject {
    func didUpdateProducts()
}

class Cart {
    weak var delegete: CartDelegateProtocol?
    
    var products: [ProductProtocol] = [] {
        didSet {
            delegete?.didUpdateProducts()
        }
    }
    
    var productsCount: Int {
        products.count
    }
    
    var totalPriceExcludingTax: Int {
        products.reduce(0) { $0 + $1.price }
    }

    func add(product: ProductProtocol) {
        products.append(product)
    }
}

protocol ShoppingPresenterDelegateProtocol: AnyObject {
    func didUpdateSelectedCoupon()
}

class ShoppingPresenter {
    weak var delegete: ShoppingPresenterDelegateProtocol?
    
    let productList: [ProductProtocol] = Product.allCases.map { $0.product }
    let couponList: [Coupon] = Coupon.allCases
    
    let cart = Cart()

    var productsInCart: [ProductProtocol] {
        cart.products
    }
    
    var productsCount: Int {
        cart.productsCount
    }
    
    var totalPriceIncludingTax: Int {
        calculateConsumptionTax(priceExcludingTax: cart.totalPriceExcludingTax)
    }
    
    var selectedCoupon: Coupon = .none {
        didSet  {
            delegete?.didUpdateSelectedCoupon()
        }
    }
    
    var couponAppliedTotalPriceIncludingTax: Int {
        selectedCoupon.apply(price: totalPriceIncludingTax)
    }
    
    func addInCart(product: ProductProtocol) {
        cart.add(product: product)
    }
    
    func select(coupon: Coupon) {
        selectedCoupon = coupon
    }
}

extension ShoppingPresenter {
    private func calculateConsumptionTax(priceExcludingTax: Int) -> Int {
        Int(Double(priceExcludingTax) * (CommonProperty.consumptionTaxRate + 1.0))
    }
}

class ShoppingView {
    let presenter = ShoppingPresenter()
    
    var productsInCart: [ProductProtocol] = []
    var productsCount: Int = 0
    var selectedCoupon: Coupon = .none
    var totalPriceIncludingTax: Int = 0
    var couponAppliedTotalPriceIncludingTax: Int = 0
    
    init() {
        presenter.delegete = self
        presenter.cart.delegete = self
    }
    
    func update() {
        productsInCart = presenter.productsInCart
        productsCount = presenter.productsCount
        selectedCoupon = presenter.selectedCoupon
        totalPriceIncludingTax = presenter.totalPriceIncludingTax
        couponAppliedTotalPriceIncludingTax = presenter.couponAppliedTotalPriceIncludingTax
    }
}

extension ShoppingView: CartDelegateProtocol {
    func didUpdateProducts() {
        update()
    }
}

extension ShoppingView: ShoppingPresenterDelegateProtocol {
    func didUpdateSelectedCoupon() {
        update()
    }
}

extension ShoppingView {
    func randomAction() {
        if Bool.random() {
            presenter.addInCart(product: presenter.productList.randomElement()!)
        } else {
            presenter.select(coupon: presenter.couponList.randomElement()!)
        }
        printAll()
    }
    
    func printAll() {
        print("=============printAll=============")
        print("productsInCart: \(productsInCart)")
        print("productsCount: \(productsCount)")
        print("selectedCoupon: \(selectedCoupon)")
        print("totalPriceIncludingTax: \(totalPriceIncludingTax)")
        print("couponAppliedTotalPriceIncludingTax: \(couponAppliedTotalPriceIncludingTax)")
    }
}

let view = ShoppingView()

view.randomAction()
view.randomAction()
view.randomAction()
view.randomAction()
```

## `@Published`

```swift
class Cart {
    @Published private(set) var products: [ProductProtocol] = []
    @Published private(set) var productsCount: Int = 0
    @Published private(set) var totalPriceExcludingTax: Int = 0
    
    init() {
        $products
            .map { $0.count }
            .assign(to: &$productsCount)
        
        $products
            .map { $0.reduce(0) { $0 + $1.price } }
            .assign(to: &$totalPriceExcludingTax)
    }
    
    func add(product: ProductProtocol) {
        products.append(product)
    }
}
class ShoppingPresenter {
    let productList: [ProductProtocol] = Product.allCases.map { $0.product }
    let couponList: [Coupon] = Coupon.allCases
    
    private let cart = Cart()
    
    @Published private(set) var productsInCart: [ProductProtocol] = []
    @Published private(set) var productsCount: Int = 0
    @Published private(set) var totalPriceIncludingTax: Int = 0
    @Published private(set) var selectedCoupon: Coupon = .none
    @Published private(set) var couponAppliedTotalPriceIncludingTax: Int = 0
    
    init() {
        cart.$products
            .assign(to: &$productsInCart)
        
        cart.$productsCount
            .assign(to: &$productsCount)
        
        cart.$totalPriceExcludingTax
            .combineLatest(Just(CommonProperty.consumptionTaxRate))
            .map { Double($0) * ($1 + 1.0) }
            .map { Int($0) }
            .assign(to: &$totalPriceIncludingTax)

        // ↑の税計算は以下でも可能
        // cart.$totalPriceExcludingTax
        //    .compactMap { [weak self] in self?.calculateConsumptionTax(priceExcludingTax: $0) }
        //    .assign(to: &$totalPriceIncludingTax)
        
        $selectedCoupon
            .combineLatest($totalPriceIncludingTax)
            .map { $0.apply(price: $1) }
            .assign(to: &$couponAppliedTotalPriceIncludingTax)
    }
    
    func addInCart(product: ProductProtocol) {
        cart.add(product: product)
    }
    
    func select(coupon: Coupon) {
        selectedCoupon = coupon
    }
}

extension ShoppingPresenter {
    private func calculateConsumptionTax(priceExcludingTax: Int) -> Int {
        Int(Double(priceExcludingTax) * (CommonProperty.consumptionTaxRate + 1.0))
    }
}

class ShoppingView {
    let presenter = ShoppingPresenter()
    
    var productsInCart: [ProductProtocol] = []
    var productsCount: Int = 0
    var selectedCoupon: Coupon = .none
    var totalPriceIncludingTax: Int = 0
    var couponAppliedTotalPriceIncludingTax: Int = 0
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        presenter.$productsInCart
            .assign(to: \.productsInCart, on: self)
            .store(in: &cancellables)
        
        presenter.$productsCount
            .assign(to: \.productsCount, on: self)
            .store(in: &cancellables)

        presenter.$selectedCoupon
            .assign(to: \.selectedCoupon, on: self)
            .store(in: &cancellables)

        presenter.$totalPriceIncludingTax
            .assign(to: \.totalPriceIncludingTax, on: self)
            .store(in: &cancellables)

        presenter.$couponAppliedTotalPriceIncludingTax
            .assign(to: \.couponAppliedTotalPriceIncludingTax, on: self)
            .store(in: &cancellables)
    }
}

extension ShoppingView {
    func randomAction() {
        if Bool.random() {
            presenter.addInCart(product: presenter.productList.randomElement()!)
        } else {
            presenter.select(coupon: presenter.couponList.randomElement()!)
        }
        printAll()
    }
    
    func printAll() {
        print("=============printAll=============")
        print("productsInCart: \(productsInCart)")
        print("productsCount: \(productsCount)")
        print("selectedCoupon: \(selectedCoupon)")
        print("totalPriceIncludingTax: \(totalPriceIncludingTax)")
        print("couponAppliedTotalPriceIncludingTax: \(couponAppliedTotalPriceIncludingTax)")
    }
}

let view = ShoppingView()

view.randomAction()
view.randomAction()
view.randomAction()
view.randomAction()
```