🌾

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

2022/02/25に公開

この記事でわかること

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

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

はじめに

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

やりたいこと

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

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

共通コード

共通コード
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 の定義

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

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

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()
    }
}

挙動確認

挙動確認
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()

出力結果

出力結果
=============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 を気にせず使える
  • デメリット
    • didSetdelegete の処理を走らせて、それが View に反映されるまでの見通しが悪い
    • Computed Property を多用していると、ネストした Computed Property の値を使用しているときの見通しが悪い
    • delegeteprotocol を実装しなければならない
    • weak var delegete への代入をし忘れる可能性がある

@Published の場合

Cart の定義

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

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

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

挙動確認

挙動確認
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()

出力結果

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

Playground コピペ用

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

delegete パターン

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

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()
GitHubで編集を提案

Discussion