LoginSignup
33

More than 3 years have passed since last update.

Observer パターンを手軽に実現するためのフレームワーク SteinsKit を作りました

Last updated at Posted at 2019-04-09

タイトル通り、SteinsKit を作りました。Qiita は自作ライブラリーの宣伝サイトではないので、ライブラリーを作ったモチベーション、使い方、そして制作に利用したテクニックを紹介したいと思います。

モチベーション

RxSwift や ReactiveSwift などに慣れた人、特に MVVM などの設計で bind に慣れた人でしたら、ある変数の値が変わったら、その変数に結びついてる別の変数や動作が自動で行われる書き方1がとても便利と感じてるでしょう。

ところが RxSwift が完璧だということでもありません;まず一番すぐ体感できるデメリットはビルド時間の長さではないでしょうか。RxSwift は RxCocoa などの周辺ライブラリーも含まれ、なおかつ実装自体も非常に複雑であるがゆえに、ビルド時間が非常にかかります;CocoaPods ならキャッシュクリアすると 15 分以上、Carthage なら bootstrap に 20 分以上かかります2。そして BehaviorRelay3 を使うために、どっちかというと比較的により UI 層寄りの RxCocoa を import する必要があります。また、筆者個人的には、そもそもの話、RxSwift にしろ ReactiveSwift にしろ、ましては SwiftBond にしろ、彼ら全て「Functional Reactive Programming」という非常にマッシブなコンセプトの実装ですので、Stream や Hot/Cold Observables などの概念や、それらに基づいて作られた数多くの operators は学習コストを上げるだけでなく、コードレビューやデバッグの難易度も上げている側面があります。

しかし我々エンジニアは、多くの場合はガッツリした FRP 設計でプログラムを組みたいわけではなく、単純に「この値が変わったら、この動作を実行したい」という、言わばただの「Observer パターン」の実現だけです。極端な話、Swift の didSet が外からでも組み込めればそれでいいくらいのノリです。それだけのために、わざわざ FRP の概念を実装した RxSwift や ReactiveSwift を導入するのは、ちょっと言い過ぎた例えですが一人の兵士を殺すために巡航ミサイルを使うみたいな利用法だと筆者は感じます。

また、RxSwift に限って言うと、どこにでも現れる disposed(by: disposeBag) 解放処理もただのノイズでしかなくてウザい4ですし、subscribe を使った Closure 購読ですと [weak self] 書いて guard let self = self else 入れないといけないのも面倒いです。

なので、これらの問題点を解決すべく、作ったのがこの SteinsKit です。SteinsKit を使った購読の構文は下記のように非常にシンプルです:

model.count
    .map({ "あなたは \($0) 番目の訪問者です!" })
    .beObserved(by: label, .asyncOnQueue(.main), 
                onChanged: { $0.text = $1 })

SteinsKit の根本的な考え方としては、Cocoa MVC 設計としては ViewController が Model を監視する必要があります5。Objective-C の時代では KVO というランタイムのオブザーバ機能が標準に備えていますが、Swift は静的な性格が強くてそういった機能がありません。なので筆者は Cocoa MVC のこの監視用件だけさえクリアできれば OK だという考えで SteinsKit を作ったため、SteinsKit には余計な機能が一切ないですし、逆にいうと例えば「ボタンの押下を購読してモデルを更新する」という本来の Cocoa MVC の使い方から踏み外した機能も実装していないので、人によっては貧弱だと感じるかもしれません。

使い方

まず SteinsKit には Variable<Value>LazyVariable<Value> の 2 種類の変数の箱があります。大まかの考え方は RxSwift に昔存在した Variable とさほど変わらないのですが、この 2 つの区別としては Variable は必ず初期値が存在するのに対し、LazyVariable は初期値がなくても宣言できます(ただし値の代入操作は必ず何かしらの Value 型の値を代入しなければなりません)。部品の初期化時に値がないけど途中から必ず何かしらの値が入ってくる lazy var の代わりに使う箱だと考えれば分かりやすいかと思います。

そしてこれらの箱を所持する側から、内部の値を変更するときは accept メソッドを使います。直接値を与える func accept(_ newValue: Value) メソッドもあれば、現在の値を参照して新しい値を作る func accept(_ calculation: (Value) -> Value)6 メソッドもあります。また、彼ら現在の値を確認だけしたい時は var currentValue: Value7 プロパティーもあります。

これらの箱を外に公開する時は、基本そのまま公開するのではなく、AnyObservable<Value> として公開します。AnyObservableprotocol Observable の型消去です。Swift の protocol はジェネリクスが使えないので、型消去で具体的な型に落とし込む必要があります8

Observable には associatedtype Value という具体的な値の型の関連以外に、購読側用のメソッドが 4 つ用意されています:

  1. func runWithLatestValue (_ execution: (Value) -> Void)
    これはとにかく今現在の値を利用して何かの処理をするだけのメソッドです(ただし LazyVariable でまだ値が初期化されていない場合は処理がスキップされます);購読はしませんし、値が変更した時に動くわけでもないので、とにかくいますぐ何かやりたい時に便利です。

  2. func map <NewValue> (_ transform: @escaping (Value) -> NewValue) -> AnyObservable<NewValue>
    これは変換メソッドです。変換する瞬間と、値が変わるたびに transform が動いて、変換されたものは別の AnyObservable の型消去として渡されますので、元の箱には影響ありません。RxSwift の map とほぼほぼ同じ使い方です。

  3. func beObserved <Observer: AnyObject> (by observer: Observer, _ method: ExecutionMethod, onChanged handler: @escaping (Observer, Value) -> Void)
    これは購読するメソッドです。購読する瞬間と、値が変更されるたびに handler が動きます。observer に渡される購読する部品は、handler の最初の引数にも渡されるので、handler$0 が購読オブジェクトとして使えてわざわざキャプチャーリストで weak 宣言する必要がありません;また、この購読オブジェクト自体がそもそも弱参照として箱に保持されますので、購読オブジェクトが解放されたら購読自体も解放され、disposed(by: disposeBag) のような解放処理をわざわざ書く必要もなくなります。そしてここの method ですが、これは .directly.asyncOnQueue(DispatchQueue)syncOnQueue(DispatchQueue) の 3 つのオプションがあります。.directly は値を変更したスレッドがそのまま handler を実行します、.asyncOnQueuehandler を渡されたキューで非同期で実行されます、最後の .syncOnQueuehanderl を渡されたキューで同期で実行されます。

  4. func beObserved <Observer: AnyObject> (by observer: Observer, onChanged handler: @escaping (Observer, Value) -> Void)
    これは上のメソッドとほぼほぼ同じですが、 method 引数が省略されてますが内部では .directly を使っています。

Variable<Value>LazyVariable<Value> のインスタンスに対して、.asAnyObservable() メソッドを使えば、AnyObservable<Value> という型消去されたインスタンスが得られます。

これらを合体させると、ライブラリーに組み込まれている Playground と同じように書けます:

Playground.swift
import UIKit
import PlaygroundSupport
import SteinsKit

// MARK: - Model
final class Model {

    private var _count: Variable<Int> = .init(0)

    private func _countUp() {
        _count.accept({ $0 + 1 })
    }

}

extension Model: ModelProtocol {

    var count: AnyObservable<Int> {
        return _count.asAnyObservable()
    }

    func countUp() {
        _countUp()
    }

}
// MARK: Model End -

// MARK: - View Controller
protocol ModelProtocol: AnyObject {
    var count: AnyObservable<Int> { get }
    func countUp()
}

final class ViewController: UIViewController {

    private lazy var label: UILabel = {
        let label = UILabel()
        label.frame = CGRect(x: 25, y: 200, width: 320, height: 100)
        label.numberOfLines = 0
        return label
    }()

    var model: ModelProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        view.addSubview(label)
        startObservation()
    }

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

    private func startObservation() {
        model.count
            .map({ "Welcome!\nWe have \($0) visitors visited\nour playground!" })
            .beObserved(by: label, .asyncOnQueue(.main), onChanged: { $0.text = $1 })
    }

    private func addCount() {
        model.countUp()
    }

}
// MARK: View Controller End -

// MARK: - Main
let vc = ViewController()
let model = Model()
vc.model = model

PlaygroundPage.current.needsIndefiniteExecution = true
PlaygroundPage.current.liveView = vc

実装に使ったテクニック紹介

型消去(Type Erasure)

こちらは上記の使い方の説明にも出てきてますが、これは要するにジェネリクスが使えない protocol を、ジェネリクスが使える何かの具体的な型に落とし込む手法です。SteinsKit に関していうと、Variable<Value> を外側には AnyObservable<Value> という型で公開9する時に、内部で用いた手法で、実装はこちらから確認できます。また、購読する側も、購読している変数は Variable なのか LazyVariable なのかも意識すべきではないと思います。

型消去するにはいくつかの方法がありますが、SteinsKit では AnySequence の実装にも使われている継承 Box の方法で実装しています。この方法の詳しい実装の仕方は omochimetaru さんの記事で書かれていますので、ここでは簡単にだけ紹介します:

継承 box は 3 つの型が必要です:まずは外側に公開するための AnyObservable<Value> はもちろん必要ですが、内部にはさらに AnyObservableBox という内包の抽象型と、この抽象型を継承した ObservableBox という具象型が必要です。継承が必要ですので Box の 2 つは class ですが、型消去の AnyObservableclass でも struct でも構いません、筆者は実行効率のために struct の方を選びましたが10

さて継承 Box の面白いところは、class の継承によるジェネリクスの上書きを使っているところです。まず、大元の抽象クラスである AnyObservableBox のジェネリクスは Observable の関連型であり、Variable のジェネリクスでもある <Value> です。しかし、AnyObservableBox は内包するプロパティーもなければ、具体的な実装もありません。さらには Observable を継承もしていません。ただし protocol Observable として宣言した全てのメソッドの「宣言」だけはしてあります、中身の実装は全て fatalError で落としていますが。

次にこの AnyObservableBox を継承して作った具象クラス ObservableBox ですが、今回ジェネリクスが <O: Observable> という、Observable を準拠した <O> に変わりました。この <O>ObservableBox がプロパティーとして持つ base の型になるので、つまり ObservableBoxVariableLazyVariable のインスタンスを渡せば base として内包されます。そして親クラスから継承した全てのメソッドは、オーバーライドして全てこの base に渡してしまいます。ただし <O><Value> ではないので、ジェネリクスも変わって、ObservableBox<O>AnyObservableBox<O.Value> の継承です。この <O.Value> というジェネリクスの上書きが、継承 Box による型消去のキモです。

最後は外側に公開する AnyObservable ですが、これはもちろんジェネリクスが <Value> になります。<Value> ですので、同じジェネリクスを使っている AnyObservableBox を自分自身の内包するオブジェクト base の型になりますが、しかし実際内包しているオブジェクトは AnyObservableBox を継承した ObservableBox のインスタンスです。これでさらに AnyObservableObservable として準拠し、Observable の全てのメソッドを base にそのまま渡せば、Observable の正体を隠したまま <Value> のジェネリクスが使えるようになります。

メタプログラミング

メタプログラミングとは、実際のプログラムのコードをそのまま書くのではなく、何かのテンプレートを書いて機械でプログラムのコードを吐き出してもらう手法です。通常のプログラムと違って、同じようなコードを大量に量産できるので非常に便利です。

同じく使い方の章でも出てきていますが、 VariableLazyVariable の 2 種類の変数箱があります。彼らはほぼほぼ同じようなものでして、違いは単純に片方が初期値が必ずかるがもう片方は初期値がなくても作れるということと、それによって片方は確実に最新の値があるがもう片方は必ずしも最新の値がないだけの区別です。この微妙の動作の違いを実現するには基本 3 通りの方法があります:

  1. そのまま手で両方実装します。メリットは特に思い当たりません、デメリットは書くの面倒くさいのと、DRY 原則に違反し、将来保守するときにどっちかの不一致が生じる可能性があります。

  2. class で継承を使って作ります。メリットは Variable を継承して LazyVariable が必要な動作を実装すれば大抵のものは 2 度手間なしに実装できるので DRY 原則も守りやすいですが、デメリットは Variable の実装でサブクラスである LazyVariable を常に考慮する必要があるので、アプローチとしては間違っているし、Variable に不必要な Optional が入ってしまうのでスマートとも言い難いです

  3. メタプログラミングでそれぞれの違いをスクリプトで書き分けます。これも実際の SteinsKit が取り入れてる方法です。メリットは DRY 原則がきちんと守られて継続開発による情報の不一致が生じないのと、継承による不必要な考慮がないためスマートで安全なコードが実現されます、デメリットはメタプログラミングはやはりハードルが少々高めです。

SteinsKit ではメタプログラミングの実現に Sourcery を利用しています。今回の場合は、VariableLazyVariable を同じ Variable というテンプレートで生成しています。

Sourcery は Stencil というテンプレートファイルを使ってコード生成します。Stencil の基本的な使い方は、テンプレートファイルで特殊な文法構文がなければ書いたものをそのまま生成します;特殊な文法構文があったらその構文に応じて処理します。Stencil の文法構文は 3 種類あります:{{ }} で囲む変数構文、{% %} で囲む制御構文、そして {# #} で囲むコメント構文です。

まず変数構文について紹介します、これは非常に単純で、変数構文は要するにこの変数の値をそのまま生成ファイルに書き出すものです、例えば a という変数があって、この変数の値が Qiita なら、テンプレートファイルで {{ a }} を書けば、生成ファイルに Qiita が吐き出されます。

次にちょっと飛ばしてコメント構文です、これも非常に単純で何を書いてもただの読み手用のコメントですので何も吐き出されません。例えばテンプレートファイルで {# これはコメントです #} と書いても、生成ファイルには何も出てきません。

最後は戻って制御構文についてですが、これは少し複雑になります。Stencil には制御のための演算子やタグがたくさんあります、例えばループ演算用の for タグ、条件分岐用の if タグ、別のテンプレートファイルを導入する include タグ、比較に使う ==> 演算子などなどがあります。例えばテンプレートファイルで下記のようなコードを書いてみます:

{% for i in 1...5 %}
  {% if i == 3 %}
// ٩( ᐛ )و
  {% else %}
// {{ i }}
  {% endif %}
{% endfor %}

これでは生成ファイルには下記のようなコードが吐き出されます:

// 1
// 2
// ٩( ᐛ )و
// 4
// 5

VariableLazyVariable の実装で、まずは Stencil で ["Variable", "LazyVariable"] の配列を持つ variableTypes という変数を定義します。独自変数の定義は {% set variableTypes "Variable,LazyVariable"|split:"," %} で実現しています。これは "Variable,LazyVariable" という文字列を "," で分割して配列にし、variableTypes という変数に値として保存するという命令です。これができたら、次は {% for type in variableTypes %} だけで、Variable 型と LazyVariable の型をループで実装できます。そして実装するときに、VariableLazyVariable で違う実装が必要なときに、{% if type|contains:"Lazy" %} で、これは LazyVariable なのか、それとも普通の Variable なのかを判別できます。

テンプレートファイルを書いたら、次は Sourcery の設定をしないといけません。まずは sourcery コマンドを打つディレクトリー(基本は .xcodeproj もしくは .xcworkspace ファイルと同じディレクトリー)で設定ファイル .sourcery.xml を作る必要があります11。このファイルではプロジェクトファイルのパス(もしくは全てのソースファイルのパス)、テンプレートファイルのパス、そして出力するディレクトリーを書いておけば最低限の情報が揃います。SteinsKit の設定ファイルはこのように書かれています:

.sourcery.xml
project:
  file: SteinsKit.xcodeproj
  target:
    name: SteinsKit

templates:
  - SteinsKit/Variables/Variable.stencil

output:
  SteinsKit/Variables

ただし注意しないといけないのは実はこの出力ディレクトリーの書き方はあまりよろしくないです。テンプレートファイルは複数設定できますが、出力ディレクトリーの設定は一つだけです。これはすなわち全てのテンプレートはその出力ディレクトリーにファイルが出力されます。ですのでどっちかというと、テンプレート依存しない出力ディレクトリーの方が望ましいでしょう。

そして、このように設定ファイルが作れたら、最後は Build Phases に行って、Compile Sources の前に Sourcery を動かすフェーズを追加すればいいです。

Sourcery についての詳しい情報は Sourcery のドキュメントでご確認できます。

この名前はどういう意味?

特に意味はない。


  1. 「リアクティブプログラミング」や「宣言型プログラミング」とも呼ばれたりします 

  2. もちろんビルド環境のスペックにも左右されるので一概には言えませんが 

  3. ErrorCompletion がない Observable で、一つ前のメジャーリリースでの Variable とほぼほぼ同じようなものです。 

  4. あくまで個人の感想です 

  5. 参考:https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Model-View-Controller/Model-View-Controller.html#//apple_ref/doc/uid/TP40010810-CH14-SW9 

  6. LazyVariable の場合は func accept(_ calculation: (Value?) -> Value) になります 

  7. LazyVariable の場合は var currentValue: Value? になります 

  8. Swift 自身もこの「型消去」というアプローチを多用しています、例えば Sequence を型消去した AnySequenceHashable を型消去した AnyHashable などがあります 

  9. もちろん Variable としてそのまま公開するのも、Observable としては使えますが、問題は Variable には外側に公開したくないメソッド(accept などの、保持する側が使うメソッド)も公開されてしまうのでよろしくないです 

  10. struct は値型ですが、そもそも内包している AnyObservableBoxclass で参照型ですので、代入による大元の本体のインスタンスの増加はありませんので心配する必要はありません 

  11. $ sourcery --option parameter の感じで使うことも可能ですが、設定の一覧ができた方が後から保守もしやすいので、設定ファイルを作って $ sourcery だけ打った方がいいと筆者は思います 

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
33