Swift
Bond

お手製Swift Bondでリアクティブプログラミング

More than 3 years have passed since last update.

Swift Bondはv4でAPIが大きく変化しました。BondやDynamicがObservable, EventProducerに変化するなどかなり大胆な変更なのでご注意ください。

※ この記事で作ったコードをSwiftBond v4のAPIにあわせてクラス名をリネームしたものを https://github.com/katryo/MiniBond に上げました。また、CocoaPodsに登録したので pod 'MiniBond' で使えます。

tl;dr

  • Swift Bondはお手軽に使えてよいけど->>という記述方法が独特すぎて好きになれない
  • 実装量自体は小さい。一から作ってみてもすぐ完成するから、自分で一部作ってみた
  • Bond, BondBox, Dynamicを使って循環参照を防ぐ仕組みがSwift Bondの核

背景

クライアントアプリを開発すると、ユーザーの行動でModelの値が変わり、それがViewに影響を与える、という処理の実装を何度もすることになる。

値が変わるたびに関係するViewを書き換える処理を毎回作るのは面倒だし抜け漏れが出てきそうなので、「この値が変わるとあの見かけも変わる」と宣言的な記述で作りたい。

これを実現する方法は多種あって、最近はRFPが流行っている。RFPの実装で、iOSアプリだとReactiveCocoaやReactKitが有名。だがRFPは大がかりだし、学習コストもかかる。Modelの値が変わればViewも変わる、ということを実現するためだけに、巨大なライブラリを採用するのは割に合わない。

と思っていたところで、Swift Bondというライブラリをこのスライド知った。RFPのライブラリよりも簡単に使えそうだし、既存のアプリに組み込めやすそうに感じた。Swiftの言語仕様にあるProperty Observerをコアに使っており、ライブラリ特有の仕組み自体は小さい。が、それでもSwift Bondは癖の強いライブラリで、たとえばデータバインディングに ->> のようなメソッドを使っていたりする。正直、これにロックインされたくない。

色々調べていたら、Swift Bondの仕組みを解説しているページを見つけた。読んでみると、かなり仕組みが簡単で、真似しやすそうだった。

ちなみにタイトルにリアクティブプログラミングと書いたが、このやり方がリアクティブプログラミングの定義に沿うかは正直なところよくわからない。

開発の方針

本家SwiftBondでは、Modelの値をViewが持つ値にbindさせているが、今回はそこまでしない。

  1. Modelの値が変わると、property observerのdidSetで、値が変化したことがViewControllerに伝わる
  2. 値の変化を受け取ったViewControllerは、新しいModelの値をもとに、Viewを再描画する

という仕組みをアプリに導入することにしよう。今回は、小説閲覧アプリを開発する。

「ボタンを押すと、小説をお気に入りでき、もう一度押すとお気に入りから外せる」という機能を作るのに、お手製Swift Bondを使ってみよう。

1つの小説を表すModelの値が変化すると、それがViewControllerに伝わり、表示が変化する仕組みを作る。すぐに思いつくのはObserver patternだ。Swift Bondも基本はObserver patternで実装されている。

ただ、素朴にObserver patternで実装しようとすると、循環参照ができてしまう。

どういうことかというと、まず、このアプリでは、お気に入りをする画面で、ViewControllerが小説Modelを参照する。そしてObserver patternでは、Modelは、自分が持つ値が変化したときに伝える相手を把握する必要がある。なので、Modelは購読者、つまり今回の件ではViewControllerを参照することになる。こうして循環参照が生まれてしまうのだ。

ViewControllerは「前の画面に戻る」などの処理で消されるのだが、循環参照があるとメモリ上から消せず、メモリリークが発生してしまう。これが問題だ。逆にいえば、ModelとViewControllerの循環参照を防ぐことさえできれば、あとはお馴染みのObserver patternでリアクティブプログラミングは実現できる。

では、どうやって循環参照を防ぐのか?

実装

  • Dynamic
  • Bond
  • BondBox

の3つのクラスを作って、それぞれに特有の役割を持たせることで、Swift Bondは循環参照を防いでいる。

Dynamic

Dynamic.swift
class Dynamic<T> {
    var value: T {
        didSet {
            bondBoxes = bondBoxes.filter(
                {
                    (bondBox: BondBox<T>) -> Bool in
                    bondBox.bond != nil
                }
            )
            bondBoxes.map(
                {
                    (bondBox: BondBox<T>) -> Void in
                    bondBox.bond?.listener(self.value)
                }
            )
        }
    }

    var bondBoxes: [BondBox<T>] = []

    init(_ v: T) {
        value = v
    }
}

DynamicはModelが持つ値をラップするオブジェクト。値の実体であるvalueをプロパティに持ち、このvalueが変化すると、didSetを呼び出す。後述するBondBoxを配列でプロパティとして持ち、didSetの中の処理で、BondBoxが持つBondのListenerを呼び出す。

story.swift
class Story {
    let title: String
    let body: String
    var starCount: Dynamic<Int>


    init(title: String, body: String, starCount: Int = 0) {
        self.title = title
        self.body = body
        self.starCount = Dynamic(starCount)
    }

Dynamicは、Modelのプロパティとして使う。今回は1作品を表すStoryクラスのプロパティ、starCountがDynamicだ。

BondBox

BondBox.swift
class BondBox<T> {
    weak var bond: Bond<T>?
    init(_ b:Bond<T>) {
        bond = b
    }
}

BondBoxはBondを弱参照のプロパティとして持つ。弱参照なのがポイントで、こうすることで循環参照を防いでいる。

ちなみに、Swiftには弱参照Arrayがないので、しかたなくBondBoxというBondを弱参照するクラスを作っている。JavaにはWeakListというのがあるらしいけど、Swiftにはないので渋々やっている。

Bond

Bond.swift
class Bond<T> {
    typealias Listener = T -> Void
    var listener: Listener

    init(_ listener: Listener) {
        self.listener = listener
    }

    func bind(dynamic: Dynamic<T>) {
        dynamic.bondBoxes.append(BondBox(self))
    }
}

Bondは、ModelのDynamicの値が変化したときに呼び出されるListenerと名づけたクロージャを保持する。

ViewController

以上3つのクラスを使って、ViewControllerはModelと紐付けられる。

MyViewController.swift
class MenuViewController: UITableViewController {
    var story: Story?
    var starCountBond: Bond<Int>?

    override func viewDidLoad() {
        super.viewDidLoad()
        starCountBond = Bond<Int> { [unowned self] viewingCount in
            self.tableView!.reloadData()
        }
        starCountBond!.bind(story!.starCount)
    }

    ()

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

    ()

        cell!.detailTextLabel!.text = String(story!.starCount.value)

こんな風に、viewDidLoadで、「viewを再描画する」というクロージャを作り、それをプロパティにしたstarContBondを作り、story modelのstarCountというDynamicをバインドしている。

これでデータバインディングが成立したことになる。もしstarCountの値が変化すると、self.tableView!.reloadData()が呼ばれてViewが再描画され、starCountの最新の値が表示される。

Bondのライフサイクル

ユーザーが小説をお気に入りする流れをまとめるとこうなる。

1. 準備

お気に入りしますか? 画面が表示されるとき、MyViewControllerのviewDidLoadでstarCountBondが作られて、「作品のお気に入り数(starCount)が変化したときはtableViewを再描画する」というクロージャがbondのプロパティになる。

starCountBondとstoryは両方ともMyViewControllerのプロパティであり、強い参照をされている。

2. 「お気に入り」ボタンを押す

StoryのプロパティのstoryCountのvalueが+1されると、Dynamicで定義されているvalueのdidSetが呼ばれる。

Dynamic.swift
    var value: T {
        didSet {
            bondBoxes = bondBoxes.filter(
                {
                    (bondBox: BondBox<T>) -> Bool in
                    bondBox.bond != nil
                }
            )
            bondBoxes.map(
                {
                    (bondBox: BondBox<T>) -> Void in
                    bondBox.bond?.listener(self.value)
                }
            )
        }
    }

ここの部分。Dynamicが持つbondBoxesのbondすべてに対して、valueを引数にしたlistenerを呼ぶ。

3. 再描画

MyViewControllerのstarCountBondでは、「作品のお気に入り数(starCount)が変化したときはtableViewを再描画する」というlistenerをプロパティに持っているので、それが呼ばれて、MyViewControllerはViewを再描画する。そして、最新のお気に入り状態がユーザーに届けられる。

4. ViewControllerを消す

「前の画面に戻る」などをすると、現在のViewControllerは消えて、どこからも参照されなくなり、メモリ上から削除される。このとき、MyViewControllerのプロパティであるstarCountBondも、どこからも強参照されなくなり、メモリ上から削除される。

BondBoxはstarCountBondを弱参照で保持しているので、BondBoxのstarCountBondプロパティはnilになる。が、そのことをBondBoxは感知しない。

Story.starCountというDynamicからすれば、自分が持つBondBoxesのうちひとつがいつのまにか中身がnilのBondBoxになっていたことになる。

これを何度も繰り返すと空のBondBoxが増えていくことになり、ちょっと困るので、Dynamicのvalueが変化するたびに

Dynamic.swift
            bondBoxes = bondBoxes.filter(
                {
                    (bondBox: BondBox<T>) -> Bool in
                    bondBox.bond != nil
                }
            )

で、空のBoxを消している。

参考