🐍

VIPERアーキテクチャでSwiftUIを利用するサンプルコードを動かして理解したい

2022/06/17に公開

はじめに

最近、仕事で技術的なアドバイスをしているクライアント様から、「VIPERアーキテクチャでSwiftUI使えるんスカね?」という質問を受けまして、「それはもちろん使えるッピ」と答えたのでその例を示します。

やり方はいろいろあるんですが、この記事ではCookpadさまのSwiftUI を活用した「レシピ」×「買い物」の新機能開発を参考にし、Playgroundsで動かせるサンプルにしてみます。

まずはVIPERの役割の整理

  • View: ViewController/SwiftUI.View
    • 値の表示
    • ユーザイベントを受け付ける
  • Presenter
    • 内部にInteractorを持つ
    • ViewをViewらしく扱うためにプレゼンテーションロジックを任されてる
  • Interactor
    • プレゼンテーションロジックでないロジックとかあるやつ
    • データを持ってたりもする
    • 今回の例ではややこしいので何もしない
  • Router
    • 画面遷移を担当するやつ
    • Alert表示なんかも担当する
    • 今回の例ではメソッドが呼ばれるだけにする
  • Entity
    • 今回は出番なし

SwiftUI を活用した「レシピ」×「買い物」の新機能開発

https://techlife.cookpad.com/entry/2021/01/18/kaimono-swift-ui

私が理解した範囲で書いていきます

SwiftUI.Viewをどういうふうに割り切るか

  • SwiftUI.ViewはあくまでViewとする
    • ユーザイベントを受けた際
      • それを親ViewControllerにdelegateで伝える
    • 値の表示
      • 自身でObservableObjectを持ち外部から値を更新する
    • VIPERで組み込むために
      • ViewControllerの子ViewとしてaddChildする
        • Cookpad先生の例ではUIViewController化し、VIPERのViewControllerを親とした構造とする

何を使って何を使わないか

記事中ではさらに、使わないSwiftUI.Viewをあらかじめ方針として決めてるのも素晴らしいですね

使う例:Button, Text, VStack, HStack, ZStack, ScrollView
使わない例:NavigationView, List, Form, TextField*6

RxSwiftをPresenterの値をSwiftUI.Viewにbindする際に使っている

もともとRxSwiftを使っておられたらしくて、Combineと並行して使われているようです

  • Presenter -> SwiftUI.View の方向でデータを反映する際
    • PresenterのデータをObservableをSwiftUI.ViewのObservableObjectにbindする
      • 値の変更を自動的に反映する

参考にしてサンプルコードを作る

参考サイトさまとまったくおんなじものを作ろうにも細かいことはよくわからないので、こちらで仕様を考えて、簡単に動かしてためせるようにPlaygroundsで作ってみます。

サンプルコードの仕様

画面

SwiftUI.Viewとして3つ

  • ユーザイベントを受け付けて内部の値を表示する赤いカウンターView
  • 内部の値を表示するだけの青いView
  • 押せる押せないボタンを表現するだけのView

RxSwiftは使いたくない

  • RxSwiftを使わずにCombineにしました

DataSourceという命名について

元記事の例ではDataSourceという型名が使われていて、「DataSourceというのがViewにあるけどきっと本来のアプリケーションのDataSourceはPresenterより奥のInteractorにあってViewのDataSourceはコピーなのね」と感じられるとは思います。

ただ、もし私だったらViewのDataSourceのほうはDataもしくはStateだけにしてSourceという言い方は避けるかもねと言う感じで参考にさせてもらいました。

意図として、なるべく下記のようなことを説明しなくても目立たせたい感じです

  • Countする値はPresenterが持っていて、CounterViewはイベントで増やされてもPresenterの値を増減し、CounterViewが値として持っている表示用のコピー
  • DesuwaLabelViewもそのPresenterの値を都度コピーして表示しているだけ
  • DoneButtonViewもPresenterの値からBooleanの結果だけを保持

サンプルコード

import UIKit
import SwiftUI
import Combine
import PlaygroundSupport

protocol CounterViewOutput: AnyObject {
    func decrement()
    func increment()
}

/// カウントアップとダウンをUIがやってその値を外からとってくる
struct CounterView: View {
    // あくまでViewに表示するための値であってViewのためのState
    class State: ObservableObject {
        // Viewの値
        @Published var count: Int

        init(count: Int) {
            self.count = count
        }
    }

    @ObservedObject var state: State
    private weak var delegate: CounterViewOutput?

    init(
        state: State,
        delegate: CounterViewOutput
    ) {
        self.state = state
        self.delegate = delegate
    }

    var body: some View {
        VStack {
            Text("Viewイベントを外部に伝えて値を外部から取得するサンプル\n")

            HStack {
                Button("-") {
                    delegate?.decrement()
                }

                Text("\(state.count)")

                Button("+") {
                    delegate?.increment()
                }
            }
        }
        .foregroundColor(Color.white)
    }
}

/// Stringを表示するだけのView
struct DesuwaLabelView: View {
    // あくまでViewのState
    class State: ObservableObject {
        // Viewの値
        @Published var text: String

        init(text: String = "") {
            self.text = text
        }
    }

    @ObservedObject var state: State

    var body: some View {
        VStack {
            Text("外部から値を取得するだけのサンプル\n")

            Text("\(state.text) DESUWA〜!!")
        }
        .foregroundColor(Color.white)
    }
}

// SwiftUIのactionをViewControllerへ伝える
protocol DoneButtonOutput: AnyObject {
    func ditTapDoneButton()
}

/// Viewのイベントをdelegateで外に伝えて、値を内部に反映する例のView。
struct DoneButtonView: View {
    // あくまでViewに表示するための値であってViewのためのState
    class State: ObservableObject {
        @Published var enabled: Bool

        init(enabled: Bool = false) {
            self.enabled = enabled
        }
    }

    @ObservedObject var state: State
    private weak var delegate: DoneButtonOutput?

    init(
        state: State,
        delegate: DoneButtonOutput
    ) {
        self.state = state
        self.delegate = delegate
    }

    var body: some View {
        VStack {
            Button("遷移ボタン") {
                delegate?.ditTapDoneButton()
            }
            .disabled(!state.enabled)
        }
    }
}

// MARK: -

class Presenter {
    let router: Router
    let countSubject = PassthroughSubject<Int, Never>()
    /// 本来のDataSource。
    var count = 0 {
        didSet {
            countSubject.send(count)
        }
    }

    var cancellable: Cancellable?

    init(router: Router) {
        self.router = router
    }

    func viewDidLoad() {
        // 外部イベントで値を変えられるよ、というだけの例
        count =  9_999_999
    }

    func presentDetailView() {
        router.presentDetail()
    }

    func increment() {
        count += 1
    }

    func decrement() {
        count -= 1
    }
}

struct Router {
    func presentDetail() {
        print("画面遷移したというテイ")
    }
}

class MyViewController : UIViewController {
    var presenter: Presenter!
    // 値をいつでも渡せるようにViewControlelrがStateを持ってる?
    private let counterViewState = CounterView.State(count: 0)
    private let desuwaViewState = DesuwaLabelView.State()
    private let doneButtonViewState = DoneButtonView.State()

    private var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()

        addChild(
            CounterView(state: counterViewState, delegate: self),
            frame: .init(x: 0, y: 0, width: 200, height: 200),
            color: .red
        )

        addChild(
            DesuwaLabelView(state: desuwaViewState),
            frame: .init(x: 200, y: 0, width: 200, height: 200),
            color: .blue
        )

        addChild(
            DoneButtonView(state: doneButtonViewState, delegate: self),
            frame: .init(x: 400, y: 0, width: 200, height: 200)
        )

        presenter.countSubject.sink { [weak self] value in
            print("sink: \(value)")
            self?.counterViewState.count = value
            self?.desuwaViewState.text = "\(value)"
            self?.doneButtonViewState.enabled = (value >= 10_000_000)
        }
        .store(in: &cancellables)

        presenter.viewDidLoad()
    }
}

private extension MyViewController {
    // これはサンプル用に処理を集めただけなので参考にしないでくださいまし
    func addChild<Content>(_ childView: Content, frame: CGRect, color: UIColor? = nil) where Content : SwiftUI.View {
        let viewController = UIHostingController(rootView: childView)
        addChild(viewController)
        viewController.didMove(toParent: self)
        view.addSubview(viewController.view)

        viewController.view.frame = frame
        if let color = color {
            viewController.view.backgroundColor = color
        }
    }
}

extension MyViewController: DoneButtonOutput {
    func ditTapDoneButton() {
        presenter.presentDetailView()
    }
}

extension MyViewController: CounterViewOutput {
    func increment() {
        presenter.increment()
    }

    func decrement() {
        presenter.decrement()
    }
}

// MARK: -

//// Present the view controller in the Live View window
let viewController = MyViewController()
viewController.presenter = Presenter(router: Router())
PlaygroundPage.current.liveView = viewController

感想

クックパッドさんの記事はVIPERでSwiftUIを使う例としてとっても参考になりました。感謝です。

Discussion