🌾
[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
を使って書き直して、それぞれのメリットとデメリットをまとめた記事になります。
やりたいこと
買い物アプリを想定したカート画面で以下のような仕様を想定します。
-
表示されている項目
- カートに入れている商品一覧
- カートに入れた商品数
- 合計の税込み価格
- 選択したクーポン
- クーポンを適応した税込み価格
-
できる操作
- カートに商品を入れる
- クーポンを選択する
共通コード
共通コード
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 を気にせず使える
-
デメリット
didSet
でdelegete
の処理を走らせて、それが View に反映されるまでの見通しが悪い- Computed Property を多用していると、ネストした Computed Property の値を使用しているときの見通しが悪い
-
delegete
のprotocol
を実装しなければならない -
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 を気にせず使える
-
デメリット
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
パターン
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()
Discussion