タイトル通り、SteinsKit を作りました。Qiita は自作ライブラリーの宣伝サイトではないので、ライブラリーを作ったモチベーション、使い方、そして制作に利用したテクニックを紹介したいと思います。
モチベーション
RxSwift や ReactiveSwift などに慣れた人、特に MVVM などの設計で bind
に慣れた人でしたら、ある変数の値が変わったら、その変数に結びついてる別の変数や動作が自動で行われる書き方1がとても便利と感じてるでしょう。
ところが RxSwift が完璧だということでもありません;まず一番すぐ体感できるデメリットはビルド時間の長さではないでしょうか。RxSwift は RxCocoa などの周辺ライブラリーも含まれ、なおかつ実装自体も非常に複雑であるがゆえに、ビルド時間が非常にかかります;CocoaPods ならキャッシュクリアすると 15 分以上、Carthage なら bootstrap に 20 分以上かかります2。そして BehaviorRelay
3 を使うために、どっちかというと比較的により 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: Value
7 プロパティーもあります。
これらの箱を外に公開する時は、基本そのまま公開するのではなく、AnyObservable<Value>
として公開します。AnyObservable
は protocol Observable
の型消去です。Swift の protocol
はジェネリクスが使えないので、型消去で具体的な型に落とし込む必要があります8。
Observable
には associatedtype Value
という具体的な値の型の関連以外に、購読側用のメソッドが 4 つ用意されています:
func runWithLatestValue (_ execution: (Value) -> Void)
これはとにかく今現在の値を利用して何かの処理をするだけのメソッドです(ただしLazyVariable
でまだ値が初期化されていない場合は処理がスキップされます);購読はしませんし、値が変更した時に動くわけでもないので、とにかくいますぐ何かやりたい時に便利です。func map <NewValue> (_ transform: @escaping (Value) -> NewValue) -> AnyObservable<NewValue>
これは変換メソッドです。変換する瞬間と、値が変わるたびにtransform
が動いて、変換されたものは別のAnyObservable
の型消去として渡されますので、元の箱には影響ありません。RxSwift のmap
とほぼほぼ同じ使い方です。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
を実行します、.asyncOnQueue
はhandler
を渡されたキューで非同期で実行されます、最後の.syncOnQueue
はhanderl
を渡されたキューで同期で実行されます。func beObserved <Observer: AnyObject> (by observer: Observer, onChanged handler: @escaping (Observer, Value) -> Void)
これは上のメソッドとほぼほぼ同じですが、method
引数が省略されてますが内部では.directly
を使っています。
Variable<Value>
と LazyVariable<Value>
のインスタンスに対して、.asAnyObservable()
メソッドを使えば、AnyObservable<Value>
という型消去されたインスタンスが得られます。
これらを合体させると、ライブラリーに組み込まれている Playground と同じように書けます:
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
ですが、型消去の AnyObservable
は class
でも struct
でも構いません、筆者は実行効率のために struct
の方を選びましたが10。
さて継承 Box の面白いところは、class
の継承によるジェネリクスの上書きを使っているところです。まず、大元の抽象クラスである AnyObservableBox
のジェネリクスは Observable
の関連型であり、Variable
のジェネリクスでもある <Value>
です。しかし、AnyObservableBox
は内包するプロパティーもなければ、具体的な実装もありません。さらには Observable
を継承もしていません。ただし protocol Observable
として宣言した全てのメソッドの「宣言」だけはしてあります、中身の実装は全て fatalError
で落としていますが。
次にこの AnyObservableBox
を継承して作った具象クラス ObservableBox
ですが、今回ジェネリクスが <O: Observable>
という、Observable
を準拠した <O>
に変わりました。この <O>
は ObservableBox
がプロパティーとして持つ base
の型になるので、つまり ObservableBox
に Variable
や LazyVariable
のインスタンスを渡せば base
として内包されます。そして親クラスから継承した全てのメソッドは、オーバーライドして全てこの base
に渡してしまいます。ただし <O>
は <Value>
ではないので、ジェネリクスも変わって、ObservableBox<O>
は AnyObservableBox<O.Value>
の継承です。この <O.Value>
というジェネリクスの上書きが、継承 Box による型消去のキモです。
最後は外側に公開する AnyObservable
ですが、これはもちろんジェネリクスが <Value>
になります。<Value>
ですので、同じジェネリクスを使っている AnyObservableBox
を自分自身の内包するオブジェクト base
の型になりますが、しかし実際内包しているオブジェクトは AnyObservableBox
を継承した ObservableBox
のインスタンスです。これでさらに AnyObservable
を Observable
として準拠し、Observable
の全てのメソッドを base
にそのまま渡せば、Observable
の正体を隠したまま <Value>
のジェネリクスが使えるようになります。
メタプログラミング
メタプログラミングとは、実際のプログラムのコードをそのまま書くのではなく、何かのテンプレートを書いて機械でプログラムのコードを吐き出してもらう手法です。通常のプログラムと違って、同じようなコードを大量に量産できるので非常に便利です。
同じく使い方の章でも出てきていますが、 Variable
と LazyVariable
の 2 種類の変数箱があります。彼らはほぼほぼ同じようなものでして、違いは単純に片方が初期値が必ずかるがもう片方は初期値がなくても作れるということと、それによって片方は確実に最新の値があるがもう片方は必ずしも最新の値がないだけの区別です。この微妙の動作の違いを実現するには基本 3 通りの方法があります:
そのまま手で両方実装します。メリットは特に思い当たりません、デメリットは書くの面倒くさいのと、DRY 原則に違反し、将来保守するときにどっちかの不一致が生じる可能性があります。
class
で継承を使って作ります。メリットはVariable
を継承してLazyVariable
が必要な動作を実装すれば大抵のものは 2 度手間なしに実装できるので DRY 原則も守りやすいですが、デメリットはVariable
の実装でサブクラスであるLazyVariable
を常に考慮する必要があるので、アプローチとしては間違っているし、Variable
に不必要な Optional が入ってしまうのでスマートとも言い難いですメタプログラミングでそれぞれの違いをスクリプトで書き分けます。これも実際の SteinsKit が取り入れてる方法です。メリットは DRY 原則がきちんと守られて継続開発による情報の不一致が生じないのと、継承による不必要な考慮がないためスマートで安全なコードが実現されます、デメリットはメタプログラミングはやはりハードルが少々高めです。
SteinsKit ではメタプログラミングの実現に Sourcery を利用しています。今回の場合は、Variable
と LazyVariable
を同じ 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
Variable
と LazyVariable
の実装で、まずは Stencil で ["Variable", "LazyVariable"]
の配列を持つ variableTypes
という変数を定義します。独自変数の定義は {% set variableTypes "Variable,LazyVariable"|split:"," %}
で実現しています。これは "Variable,LazyVariable"
という文字列を ","
で分割して配列にし、variableTypes
という変数に値として保存するという命令です。これができたら、次は {% for type in variableTypes %}
だけで、Variable
型と LazyVariable
の型をループで実装できます。そして実装するときに、Variable
と LazyVariable
で違う実装が必要なときに、{% if type|contains:"Lazy" %}
で、これは LazyVariable
なのか、それとも普通の Variable
なのかを判別できます。
テンプレートファイルを書いたら、次は Sourcery の設定をしないといけません。まずは sourcery
コマンドを打つディレクトリー(基本は .xcodeproj
もしくは .xcworkspace
ファイルと同じディレクトリー)で設定ファイル .sourcery.xml
を作る必要があります11。このファイルではプロジェクトファイルのパス(もしくは全てのソースファイルのパス)、テンプレートファイルのパス、そして出力するディレクトリーを書いておけば最低限の情報が揃います。SteinsKit の設定ファイルはこのように書かれています:
project:
file: SteinsKit.xcodeproj
target:
name: SteinsKit
templates:
- SteinsKit/Variables/Variable.stencil
output:
SteinsKit/Variables
ただし注意しないといけないのは実はこの出力ディレクトリーの書き方はあまりよろしくないです。テンプレートファイルは複数設定できますが、出力ディレクトリーの設定は一つだけです。これはすなわち全てのテンプレートはその出力ディレクトリーにファイルが出力されます。ですのでどっちかというと、テンプレート依存しない出力ディレクトリーの方が望ましいでしょう。
そして、このように設定ファイルが作れたら、最後は Build Phases に行って、Compile Sources の前に Sourcery を動かすフェーズを追加すればいいです。
Sourcery についての詳しい情報は Sourcery のドキュメントでご確認できます。
この名前はどういう意味?
特に意味はない。
-
「リアクティブプログラミング」や「宣言型プログラミング」とも呼ばれたりします ↩
-
もちろんビルド環境のスペックにも左右されるので一概には言えませんが ↩
-
Error
とCompletion
がないObservable
で、一つ前のメジャーリリースでのVariable
とほぼほぼ同じようなものです。 ↩ -
あくまで個人の感想です ↩
-
参考:https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Model-View-Controller/Model-View-Controller.html#//apple_ref/doc/uid/TP40010810-CH14-SW9 ↩
-
LazyVariable
の場合はfunc accept(_ calculation: (Value?) -> Value)
になります ↩ -
LazyVariable
の場合はvar currentValue: Value?
になります ↩ -
Swift 自身もこの「型消去」というアプローチを多用しています、例えば
Sequence
を型消去したAnySequence
やHashable
を型消去したAnyHashable
などがあります ↩ -
もちろん
Variable
としてそのまま公開するのも、Observable
としては使えますが、問題はVariable
には外側に公開したくないメソッド(accept
などの、保持する側が使うメソッド)も公開されてしまうのでよろしくないです ↩ -
struct
は値型ですが、そもそも内包しているAnyObservableBox
はclass
で参照型ですので、代入による大元の本体のインスタンスの増加はありませんので心配する必要はありません ↩ -
$ sourcery --option parameter
の感じで使うことも可能ですが、設定の一覧ができた方が後から保守もしやすいので、設定ファイルを作って$ sourcery
だけ打った方がいいと筆者は思います ↩