※注意:本記事は Xcode 11.0 beta 3 の環境で動作確認したものです。正式版では動作が変わる可能性もありますのでご参考まで。
本題に入る前の前提知識
依存関係逆転 #とは
詳しくは Wikipedia などで確認できますが、簡単にいうと:
- 上位レイヤーのモジュールが下位レイヤーのモジュールに1依存すべきではない、両方抽象に依存すべき
- 抽象が実装に依存してはならない、実装が抽象に依存すべき
の 2 点だけです。RxSwift を使って具体的にコードで書いてみるとこんな感じでしょう:
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()
}
// 略
}
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
の適合だけ修正すればいい)などの利点もあります。
抽象と実装 #とは
ここまでで何度も出てきた言葉ですが、もう一回これらの言葉を見てみましょう。
まず「実装」は簡単です、つまりは何かしらのロジックの実装です。「抽象」の対義語として「具象」とも言えるかと思いますがなんかわかりにくいので、「具体的な実装」と思えばいいでしょう(抽象的な実装は何なのかというツッコミはさておき)。例えば上記のサンプルですと、ViewController
の touchesEnded
メソッド内で model.countUp()
を呼び出すのも、Model
の _countUp
メソッド内で _count.onNext(newValue)
を呼び出すのも、すべて具体的な実装です。これらは実際の動作が書かれてあるからです。
逆に「抽象」は何かと言うと、ModelObject
の protocol
で宣言した var count
や func countUp()
は全て抽象です。これらは ModelObject
に適合した部品は「これらのプロパティーやメソッドがあるよ」という宣言しかしていません、彼らは何を返すかとか、呼び出したらどうなるのかとかは一切言ってないからです。
Swift では多くの場合、「抽象」は protocol
宣言で作りますが、class
で作る方法もあります。その場合、具体的な動作を自分で定義せず、自分を継承したサブクラスで定義すればいいです。ただし Swift は C# のように明示的に「abstract class(抽象クラス)」の宣言が出来ないため、ビルド時の動作保証が出来ない2のが大きなデメリットとも言えます。
ではそろそろ本題
アップルが示した実装アプローチはある
SwiftUI は View
自身に @State
プロパティーを入れることによって、該当プロパティーの値変更がビューに自動的に反映されます。もう自分で監視処理を書かなくて済むのがとても嬉しいです;ところで外部オブジェクトと連携するとき、例えばモデルオブジェクトと連携するときは @State
が使えず、@ObjectBinding
等を使う必要があります。
@ObjectBinding
を利用する前提条件として、該当オブジェクトは protocol BindableObject
に適合する必要があります、このプロトコルが定めた条件は二つあって:
- Combine の
Publisher
に適合したPublisherType
を定義する必要があります;なおこのPublisherType
のPublisher.Failure
はNever
である必要があります -
didChange
というPublisherType
のプロパティーを用意する必要があります;SwiftUI はこのプロパティーからreceive
を受け取ったときに、変更差分を検出してそれに合わせて画面レンダリングを更新します
ですので、上記のサンプルコードをこのアプローチで作り直せば、このような感じになります:
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)")
})
}
}
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
に抽象化してみましょうか?
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 で抽象したモデルオブジェクトをプロパティーの型として利用することです:
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
はちゃんとビルドが通るので、あとはモデルオブジェクトの実装だけです:
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
をする必要もありません。ただし抽象と実装の章でも述べたとおり、この方法でいくつかの欠点があります:
- 実装ファイルでどこを実装しなくてはならないか、というのがビルドタイムでは明確に特定できず、具体的なソースコードに依存してしまいます
- それらの実装において、大量の
override
が発生してしまいます - 多重継承ができないので、複数の画面を同じモデルオブジェクトで対応するときは、全て同じ抽象化で定義する必要があり、それによりモックオブジェクトが作りにくく、また画面の責務も絞りにくい
などなどです。そのため、これは残念ながらいい対応法とは言い難いでしょう。
解決法 2:ジェネリクスを利用する
そもそもの話、Property type 'ModelObject' does not match type 'ObjectBinding'
エラーが発生する理由は、プロトコル自身は、プロトコルに適合していない3からであり、ここはジェネリクスの力を借りて実際の型に落とし込む必要があるからです。
では具体的にどうするのかというとこの View
に <Model: ModelObject>
のジェネリクスを入れてあげればいいです。
protocol ModelObject: BindableObject {
var count: Int { get }
func reset()
}
struct ContentView<Model: ModelObject>: View {
@ObjectBinding var model: Model
init(model: Model) {
self.model = model
}
// 略
}
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 つほど違和感を覚える箇所があります:
- ModelObject が BindableObject を継承しているので分かりきってることとは言え、事前に
didChange
プロパティーを用意しなくてはいけない、拡張で対応できないという点についてはなんとなくちょっと気持ち悪いです - 値型なので
nonmutating
の動作が保証されるとは言え、@ObjectBinding
を利用している Property Wrapper は宣言としてvar
の利用が強要されるのもなんとなくちょっと気持ち悪いです、内包しているのは参照型なので何も変更されることがないはずなのに
もちろん上記の違和感は全て筆者個人の気持ちですので一概に正しいとは言えませんが、そんな筆者の個人的なモヤモヤを解消したもう一つの解決法があります:
解決法 3:そもそも BindableObject
を使わない
これはそもそも最初からモデルオブジェクトに対して SwiftUI への依存を完全に排除し、「ビジネスロジック」としての純粋さを最大限に保った方法です。ただその代わり、データバイディング処理を自分で書かないといけないので、UI 層のコードが多少膨らみます。まずは上のサンプルと同じように UI 層を書きます:
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:)
も忘れてはいけません。
ここまでできたら、あとはモデルオブジェクトの実装だけです、簡単です:
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
にバインドしたいだけです。そこでちょっと工夫をすることで、この手動バインディングの煩わしさがだいぶ軽減できるじゃないかと思います:
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
の実装がこのようになります:
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 層のコード量 | ◎ | ◎ | △ |
ロジック層のコード量 | △ | △ | ○ |
ロジック層の純粋さ | △ | △ | ○ |
モックオブジェクトの柔軟さ | × | ○ | ◎ |
-
例えば画面とビジネスロジックで見れば、画面が上位レイヤーで、ビジネスロジックが下位レイヤーと見なせます。 ↩
-
Swift では「抽象クラス」というキーワードがなく、全てのクラスは平等に扱われるので、抽象クラスのインスタンス化を防いだり、継承したサブクラスが親の宣言したメソッドやプロパティーについて具体的な実装を強制することができない ↩
-
プロトコル自身を型として宣言したプロパティーは、そのプロトコルに適合した実際の型を隠蔽した「存在型(Existencial)」であり、存在型自身は該当プロトコルに適合しません。詳しくはこちらの記事などをご参考ください。 ↩
-
プロトコルの存在型のメソッドディスパッチは「Witness Table」を利用するが、ジェネリクスで落とし込まれた型は自分の「Direct Dispatch」もしくは「Vtable」が使えるので、どれもパフォーマンスが Witness Table より高いです。ただそれはせいぜい 120 fps のモバイルアプリからすると、例えば MacBook Pro が JIS 配列か US 配列かで発生する物理重量の差と同じくらいで、よほどシビアじゃないシチュエーションでは基本的に無視できます。 ↩