41
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SwiftUI 時代の依存関係逆転のプラクティス [beta]

Posted at

※注意:本記事は Xcode 11.0 beta 3 の環境で動作確認したものです。正式版では動作が変わる可能性もありますのでご参考まで。

本題に入る前の前提知識

依存関係逆転 #とは

詳しくは Wikipedia などで確認できますが、簡単にいうと:

  1. 上位レイヤーのモジュールが下位レイヤーのモジュールに1依存すべきではない、両方抽象に依存すべき
  2. 抽象が実装に依存してはならない、実装が抽象に依存すべき

の 2 点だけです。RxSwift を使って具体的にコードで書いてみるとこんな感じでしょう:

ViewController.swift
import UIKit
import RxSwift
import RxCocoa

protocol ModelObject: AnyObject {
    var count: Observable<Int> { get }
    func countUp()
}

class ViewController: UIViewController {

    let model: ModelObject
    let label: UILabel

    init(model: ModelObject) {
        self.model = model
        model.count
            .map({ "\($0)" })
            .asDriver(...)
            .drive(label.rx.text)
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        model.countUp()
    }

    // 略

}
Model.swift
import Foundation
import RxSwift

final class Model {

    var _count: BehaviorSubject<Int> = .init(value: 0)

    func _countUp() {
        let newValue = try! _count.value + 1
        _count.onNext(newValue)
    }

}

extension Model: ModelObject {

    var count: Observable<Int> {
        return _count.asObservable()
    }

    func countUp() {
        return _countUp()
    }

}

上記の例では、上位レイヤーである ViewController は、直接下位レイヤーのオブジェクト Model に依存するのではなく、下位レイヤーに欲しい機能を抽象化した ModelObject に依存し、下位レイヤーのオブジェクト Model も同じく ModelObject である抽象に適合しています;また、実装に依存した抽象も一切なく、全ての実装は抽象に依存しています。

それで何が嬉しいの?

一番嬉しい場面は、オブジェクトを入れ替えるときです。全ての依存は抽象への依存なので、依存注入するときに必要に応じて具体的な実装を簡単に入れ替えられます。例えば本番アプリでは実際のサーバと通信するオブジェクトを注入しますが、ローカルでロジックのテストをするときに実際のサーバと通信したくないので、通信オブジェクトを実際の通信をしないモックオブジェクトで差し替えればすぐにできます。この依存注入の手法を DI(Dependency Injection)と言います。

例えば上記の例ですと、桁数がとても長い数字の表示をテストしたいときに、本番オブジェクトのような 0 から始まって 1 ずつ上がるオブジェクトではいつまで経ってもその欲しい桁数にたどり着かないので、count がいきなり 100,000,000 から始まったり、一回の countUp で数値ではなく桁数を上げるロジックを入れたりしたオブジェクトを、テスト専用のモックオブジェクトとしてテストコードに注入すればいい訳です。もし ViewController が依存してるのは抽象の ModelObject ではなく、具体的な実装である Model でしたら、その入れ替えがとても難しいです。

また他にも、ソースコードが読みやすくなる(依存宣言で必要最小限のことしか書いてないため)、依存先の修正が依存元への影響が少ない(protocol の適合だけ修正すればいい)などの利点もあります。

抽象と実装 #とは

ここまでで何度も出てきた言葉ですが、もう一回これらの言葉を見てみましょう。

まず「実装」は簡単です、つまりは何かしらのロジックの実装です。「抽象」の対義語として「具象」とも言えるかと思いますがなんかわかりにくいので、「具体的な実装」と思えばいいでしょう(抽象的な実装は何なのかというツッコミはさておき)。例えば上記のサンプルですと、ViewControllertouchesEnded メソッド内で model.countUp() を呼び出すのも、Model_countUp メソッド内で _count.onNext(newValue) を呼び出すのも、すべて具体的な実装です。これらは実際の動作が書かれてあるからです。

逆に「抽象」は何かと言うと、ModelObjectprotocol で宣言した var countfunc countUp() は全て抽象です。これらは ModelObject に適合した部品は「これらのプロパティーやメソッドがあるよ」という宣言しかしていません、彼らは何を返すかとか、呼び出したらどうなるのかとかは一切言ってないからです。

Swift では多くの場合、「抽象」は protocol 宣言で作りますが、class で作る方法もあります。その場合、具体的な動作を自分で定義せず、自分を継承したサブクラスで定義すればいいです。ただし Swift は C# のように明示的に「abstract class(抽象クラス)」の宣言が出来ないため、ビルド時の動作保証が出来ない2のが大きなデメリットとも言えます。

ではそろそろ本題

アップルが示した実装アプローチはある

SwiftUI は View 自身に @State プロパティーを入れることによって、該当プロパティーの値変更がビューに自動的に反映されます。もう自分で監視処理を書かなくて済むのがとても嬉しいです;ところで外部オブジェクトと連携するとき、例えばモデルオブジェクトと連携するときは @State が使えず、@ObjectBinding 等を使う必要があります。

@ObjectBinding を利用する前提条件として、該当オブジェクトは protocol BindableObject に適合する必要があります、このプロトコルが定めた条件は二つあって:

  1. Combine の Publisher に適合した PublisherType を定義する必要があります;なおこの PublisherTypePublisher.FailureNever である必要があります
  2. didChange という PublisherType のプロパティーを用意する必要があります;SwiftUI はこのプロパティーから receive を受け取ったときに、変更差分を検出してそれに合わせて画面レンダリングを更新します

ですので、上記のサンプルコードをこのアプローチで作り直せば、このような感じになります:

View.swift
import SwiftUI
import Combine

struct ContentView : View {

    @ObjectBinding var model: Model
    
    var body: some View {
        Button.init(action: { [unowned model] in
            model.reset()
        }, label: {
            Text("\(model.int)")
        })
    }

}
Model.swift
import SwiftUI
import Combine

final class Model: BindableObject {
    
    var int: Int = 0 {
        didSet { didChange.send() }
    }
    
    var didChange: PassthroughSubject<Void, Never> = .init()
    
    private var timer: Timer?
    
    init() {
        setupTimer()
    }
    
    private func resetTimer() {
        
        timer?.invalidate()
        int = 0
        
    }
    
    private func setupTimer() {
        
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] (_) in
            self?.int += 1
        })
        
    }
    
    func reset() {
        resetTimer()
        setupTimer()
    }
    
}

さて、ここで問題に気づいた人もいるのではないでしょうか。

アップルが示した実装アプローチは依存関係逆転できてない

View が依存するモデルオブジェクトである Model は、抽象てはなく実装です。そもそもこのアプローチでは抽象がどこにもないのです。

そして他にも、例えば本来 UI 層のことについて知らなくていい(というか知ってはいけない)はずのモデルオブジェクトは、UI 層にあるはずの SwiftUI に依存してしまっています。これは BindableObject は Combine ではなく SwiftUI で定義したプロトコルだからです。

ではとりあえず最初のサンプルコードのように、試しに Model をプロトコルとして ModelObject に抽象化してみましょうか?

View.swift
protocol ModelObject: BindableObject {
    var count: Int { get }
    func countUp()
}

struct ContentView: View {

    @ObjectBinding var model: ModelObject
    
    // 略

}

って、あれ?なんかいきなりエラーが出てきますね。 @ObjectBinding var model: ModelObject の行で、Property type 'ModelObject' does not match that of the 'wrappedValue' property of its wrapper type 'ObjectBinding' のエラーがいきなり出てきます。困った。

解決法 1:class を利用する

一番愚直な方法は、アップルのアプローチと同じように、class で抽象したモデルオブジェクトをプロパティーの型として利用することです:

View.swift
import SwiftUI
import Combine

class ModelObject: BindableObject {
    let didChange: PassthroughSubject<Void, Never> = .init()
    var count: Int = .max {
        didSet { didChange.send() }
    }
    func reset() {
        fatalError()
    }
}

struct ContentView: View {

    @ObjectBinding var model: ModelObject
    
    // 略

}

こうすれば ContentView はちゃんとビルドが通るので、あとはモデルオブジェクトの実装だけです:

Model.swift
import Foundation
import Combine

final class Model: ModelObject {

    private var timer: Timer?

    override func reset() {
        resetTimer()
        setupTimer()
    }

    override init() {
        super.init()
        count = 0
        setupTimer()
    }

}

extension Model {

    private func resetTimer() {
        timer?.invalidate()
        count = 0
    }

    private func setupTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] (_) in
            self?.count += 1
        })
    }
}

この方法ではとりあえずビルドも通りますし、モデル層の実装で import SwiftUI をする必要もありません。ただし抽象と実装の章でも述べたとおり、この方法でいくつかの欠点があります:

  1. 実装ファイルでどこを実装しなくてはならないか、というのがビルドタイムでは明確に特定できず、具体的なソースコードに依存してしまいます
  2. それらの実装において、大量の override が発生してしまいます
  3. 多重継承ができないので、複数の画面を同じモデルオブジェクトで対応するときは、全て同じ抽象化で定義する必要があり、それによりモックオブジェクトが作りにくく、また画面の責務も絞りにくい

などなどです。そのため、これは残念ながらいい対応法とは言い難いでしょう。

解決法 2:ジェネリクスを利用する

そもそもの話、Property type 'ModelObject' does not match type 'ObjectBinding' エラーが発生する理由は、プロトコル自身は、プロトコルに適合していない3からであり、ここはジェネリクスの力を借りて実際の型に落とし込む必要があるからです。

では具体的にどうするのかというとこの View<Model: ModelObject> のジェネリクスを入れてあげればいいです。

View.swift
protocol ModelObject: BindableObject {
    var count: Int { get }
    func reset()
}

struct ContentView<Model: ModelObject>: View {
    
    @ObjectBinding var model: Model
    
    init(model: Model) {
        self.model = model
    }
    
    // 略
    
}
Model.swift
final class Model {
    
    private var _count: Int = 0 {
        didSet { didChange.send() }
    }
    private var timer: Timer?
    
    let didChange: PassthroughSubject<Void, Never> = .init()
    
    // 略
    
}

extension Model: ModelObject {
    
    var count: Int {
        return _count
    }
    
    func reset() {
        resetTimer()
        setupTimer()
    }
    
}

この方法の最大のメリットは、抽象から実装に落とし込むときの暗黙なルール、つまりどのメソッドはオーバーライドすべきか等のルールはないので、コンパイラーが動作の成功を保証してくれます;また目障りな大量の override キーワードもないので、コードがだいぶ綺麗になります。

そしてもちろん、アップルが提示したアプローチから道を大きく踏み外していないのもポイント高いと思います。それはすなわち SwiftUI が用意してくれてる差分抽出などの資産を最大限に利用できることを意味します。

なお実は最初のサンプルコードと比べれば、もう一つ小さいメリットがあります:パフォーマンスの良さです。最初のサンプルでは protocol をそのままプロパティーの型として宣言したため、実際の利用は存在型となり、メソッドディスパッチで微妙なオーバーヘッドが発生します4。実際のプロダクトではほぼほぼ無視できるオーバーヘッドかと思いますが。

ただし筆者としてはあと 2 つほど違和感を覚える箇所があります:

  1. ModelObject が BindableObject を継承しているので分かりきってることとは言え、事前に didChange プロパティーを用意しなくてはいけない、拡張で対応できないという点についてはなんとなくちょっと気持ち悪いです
  2. 値型なので nonmutating の動作が保証されるとは言え、@ObjectBinding を利用している Property Wrapper は宣言として var の利用が強要されるのもなんとなくちょっと気持ち悪いです、内包しているのは参照型なので何も変更されることがないはずなのに

もちろん上記の違和感は全て筆者個人の気持ちですので一概に正しいとは言えませんが、そんな筆者の個人的なモヤモヤを解消したもう一つの解決法があります:

解決法 3:そもそも BindableObject を使わない

これはそもそも最初からモデルオブジェクトに対して SwiftUI への依存を完全に排除し、「ビジネスロジック」としての純粋さを最大限に保った方法です。ただその代わり、データバイディング処理を自分で書かないといけないので、UI 層のコードが多少膨らみます。まずは上のサンプルと同じように UI 層を書きます:

View.swift
protocol ModelObject: AnyObject {
    var countPublisher: AnyPublisher<Int, Never> { get }
    func reset()
}

struct ContentView<Model: ModelObject>: View {
    
    let model: Model
    
    @State private var count: Int = .max
    
    init(model: Model) {
        self.model = model
    }
    
    var body: some View {
        Button.init(action: { [unowned model] in
            model.reset()
        }, label: {
            Text("\(count)")
        }).onReceive(model.countPublisher.receive(on: DispatchQueue.main)) { [self] count in
            self.count = count
        }
    }
    
}

モデルオブジェクトの抽象化は最初のサンプルと同じように、結果プロパティーをそのまま Int としてではなく、変更を通知してくれる型(RxSwift では Observable<Int>、Combine では AnyPublisher<Int>)にしています;そしてプロトコルの継承は BindableObject ではなく、ただの参照型を示す AnyObject にすることによって、モデルオブジェクトの所持をそのまま let で宣言できちゃいます。ただその代わり、SwiftUI のデータバイディングが一切使えないので、ContentView の中には自分でバイディング用の @State var count: Int を実装し、そして body の実装でそのバイディングメカニズムの onReceive を実装しなくてはいけません。しかも更にここで注意しないといけないのは、モデルオブジェクトの countPublisher が通知を送ってくるのはメインスレッドじゃない可能性もあるので、UI 変更に必要なメインスレッドにジャンプする処理 receive(on:) も忘れてはいけません。

ここまでできたら、あとはモデルオブジェクトの実装だけです、簡単です:

Model.swift
final class Model {
    
    private var count: CurrentValueSubject<Int, Never> = .init(0)
    private var timer: Timer?
    
    init() {
        setupTimer()
    }
    
    // 略
    
}

extension Model: ModelObject {
    
    var countPublisher: AnyPublisher<Int, Never> {
        return count.eraseToAnyPublisher()
    }
    
    func reset() {
        resetTimer()
        setupTimer()
    }
    
}

これにより、Model が知る必要があることを最小限に留めておいて、ModelObject への適合が可能になり、上記の筆者のモヤモヤも全て解消されます。ただもちろん、View 側のコードが大変です。せっかく SwiftUI で宣言的な画面作りができたのに、結局こんな大量なバインディングコードを書かなくてはいけないのは確かに面倒です。今回は count プロパティー一つだけのバインディングなのでまだマシですが、そのプロパティーが 10 個 20 個に膨らんできたら正気を保てないですね。

でも、一番大きな問題はこの onReceive メソッドの煩わしさです。我々が本当にやりたいのはただ単に model.countPublisher の値を、自分自身の count にバインドしたいだけです。そこでちょっと工夫をすることで、この手動バインディングの煩わしさがだいぶ軽減できるじゃないかと思います:

View+.swift
extension View {
    
    func assign <P> (_ publisher: P, to state: Binding<P.Output>) -> SubscriptionView<Publishers.ReceiveOn<P, DispatchQueue>, Self> where P : Publisher, P.Failure == Never {
        return onReceive(publisher.receive(on: DispatchQueue.main)) { result in
            state.value = result
        }
    }
    
}

これは View に対して assign(_: to:) メソッドを追加しています。具体的なバインディング作業、つまりスレッドの切り替えやデータが来た時の処理は全部メソッド内で自動でやってくれますので、これで ContentView の実装がこのようになります:

View.swift
struct ContentView<Model: ModelObject>: View {
    
    // 略
    
    var body: some View {
        Button.init(action: { [unowned model] in
            model.reset()
        }, label: {
            Text("\(count)")
        })
        .assign(model.countPublisher, to: $count)
    }
    
}

上記のコードで、$count$ は今回の Swift 5.1 で追加したバインディング修飾子で、count という Binding の値を直接アクセスするためのものです。

まとめ

SwiftUI 時代の依存関係逆転で使えるデータバインディングの手法をとりあえず 3 つ紹介してきましたが、他にもいろいろあるとは思いますのでぜひコメントで教えていただけたらと思います。ひとまずここではそれぞれのメリット/デメリットをまとめてみます:

class で SwiftUI を利用する方法 ジェネリクスで SwiftUI を利用する方法 手動でデータバインディングする方法
習得のしやすさ
ビルド時の動作保証 ×
UI 層のコード量
ロジック層のコード量
ロジック層の純粋さ
モックオブジェクトの柔軟さ ×
  1. 例えば画面とビジネスロジックで見れば、画面が上位レイヤーで、ビジネスロジックが下位レイヤーと見なせます。

  2. Swift では「抽象クラス」というキーワードがなく、全てのクラスは平等に扱われるので、抽象クラスのインスタンス化を防いだり、継承したサブクラスが親の宣言したメソッドやプロパティーについて具体的な実装を強制することができない

  3. プロトコル自身を型として宣言したプロパティーは、そのプロトコルに適合した実際の型を隠蔽した「存在型(Existencial)」であり、存在型自身は該当プロトコルに適合しません。詳しくはこちらの記事などをご参考ください。

  4. プロトコルの存在型のメソッドディスパッチは「Witness Table」を利用するが、ジェネリクスで落とし込まれた型は自分の「Direct Dispatch」もしくは「Vtable」が使えるので、どれもパフォーマンスが Witness Table より高いです。ただそれはせいぜい 120 fps のモバイルアプリからすると、例えば MacBook Pro が JIS 配列か US 配列かで発生する物理重量の差と同じくらいで、よほどシビアじゃないシチュエーションでは基本的に無視できます。

41
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?