40
22

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 3 years have passed since last update.

SwiftAdvent Calendar 2019

Day 7

FunctionBuilderで遊ぼう

Last updated at Posted at 2019-12-06

FunctionBuilder とは

Xcode 11で追加されたSwiftの新機能で、有り体に言えばSwift言語内にDSLを定義できる言語機能になります。
2019/12/07時点で、publicではない機能として定義されていますが、SwiftUIにおいて一部のViewの記述に利用されています。

まだpublicではないので、公式ドキュメントは存在しません。代わりに公式のフォーラムがあるのでそちらを参照すると良いでしょう。
https://forums.swift.org/t/function-builders/25167/10

なおこのFunctionBuilderはORTとは異なりSwift5.1のランタイムを必要としない機能のため、iOS13未満のOSでも動作します。

SwiftUIはORTが必要なのでiOS13以降であることが必須ですが、FunctionBuilderだけなら今から使えるわけです。(推奨するとは言っていない)
体験版ですね。遊んでみましょう。

FunctionBuilderの価値

FunctionBuilderを使うことで、シンプルな構文と複雑な型を両立させることが可能になる。
これがFunctionBuilderにおける最も大きな価値でしょう。
ただシンプルな構文を作るだけなら、Array<AnyFoo>であっても、順番に変数を並べるだけでも構わなかったはず。なぜFunctionBuilderなのか。その結論にコードを書きながら至ることがこの記事の目的です。
結論から書くと、複雑な型のstatic関数が鍵になります。

TableViewにおける考察

「僕の考えた最強のTableView」
浪漫に溢れたコーディングです。誰だって大好きです。私も大好きです。
今回はFunctionBuilderを使って、TableViewDataSourceを生成する事を題材に考えていきます。

FunctionBuilderの集合の型を準備する

FunctionBuilderでは、1行毎の返り値を複数束ねてその集合を型として生成することで、DSLとして成り立つようになっています。
単体ならそのままで良いので、直和と直積、空集合についての定義を追加します。

struct Empty {
}

struct Pair<C0, C1> {
  var c0: C0
  var c1: C1
}

enum Either<C0, C1> {
  case c0(C0)
  case c1(C1)
}

簡単ですねv

FunctionBuilderを構成する

FunctionBuilderが定める関数を定義します。
いずれも返り値の群を引数に取り、集合を返す関数になります。

@_functionBuilder
struct UIBuilder {
    static func buildBlock() -> Empty { .init() }
    static func buildBlock<C>(_ c: C) -> C { c }
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> Pair<C0, C1> { .init(c0: c0, c1: c1) }
    static func buildIf<C>(_ c: C?) -> Either<C, Empty> { .init(from: c) /* extension書いてネ */ }
    static func buildEither<T, F>(first: T) -> Either<T, F> { .c0(first) }
    static func buildEither<T, F>(second: F) -> Either<T, F> { .c1(second) }
}

この宣言で UIBuilder において2値までの返り値が使えるようになります。

集合の型を一般化する

Protocolを作って一般化しましょう。

protocol TableViewCellProtocol {
}

extension Empty: TableViewCellProtocol {
}

extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
}

extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
}

DataSourceを作ってみる - FunctionBuilderを使ってみる

ここまでの作業で実際にFunctionBuilderとしてコンパイル可能になっています。
関数を定義して呼び出し、コンパイルしてみましょう。

extension UITableView {
    func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @UIBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> UITableViewDataSource? {
       dataSource
    }
}
class MyTableViewCell0: UITableViewCell, TableViewCellProtocol {}
class MyTableViewCell1: UITableViewCell, TableViewCellProtocol {}
lazy var dataSource = self.tableView.generateDataSource(items: [1, 2, 3]) { tableView, indexPath, item in
    if item % 2 == 0 {
        MyTableViewCell0() // 今回は本質ではないのでDequeueは省略
    } else {
        MyTableViewCell1()
    }
}

コンパイルは通ると思いますが、このままではまだ使い物になりません。

DataSourceを作ってみる - Cellのレンダリングをする

DataSourceクラスを作りましょう。Itemの配列を持ち、セットされたらリロードします。また、クロージャーからCellを生成できるようにします。
とは言っても、難しいことは何もせずに受け取ったクロージャを実行するだけです。

class TableViewDataSource<Item>: NSObject, UITableViewDataSource {
    var items: [Item] {
        didSet {
            reloadData(oldValue, self.items)
        }
    }
    var reloadData: ([Item], [Item]) -> ()
    var cellForItem: (UITableView, IndexPath, Item) -> UITableViewCell

    init(items: [Item], reloadData: @escaping ([Item], [Item]) -> (), cellForItem: @escaping (UITableView, IndexPath, Item) -> UITableViewCell) {
        self.items = items
        self.reloadData = reloadData
        self.cellForItem = cellForItem
        super.init()
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        cellForItem(tableView, indexPath, items[indexPath.row])
    }
}

DataSourceとして動作させるために、UITableViewCell を取り出す必要があります。
空の TableViewCellProtocol は実質 Any なので宣言を変更して対応します。

protocol TableViewCellProtocol {
    func asTableViewCell() -> UITableViewCell?
}

extension Empty: TableViewCellProtocol {
    func asTableViewCell() -> UITableViewCell? { nil }
}

extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
    func asTableViewCell() -> UITableViewCell? { c0.asTableViewCell() ?? c1.asTableViewCell() }
}

extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
    func asTableViewCell() -> UITableViewCell? {
        switch self {
        case .c0(let c0): return c0.asTableViewCell()
        case .c1(let c1): return c1.asTableViewCell()
        }
    }
}

extension TableViewCellProtocol where Self: UITableViewCell {
    func asTableViewCell() -> UITableViewCell? { self }
}

このDataSourceクラスを、先の generateDataSource で生成、利用します。

extension UITableView {
    func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @UIBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> TableViewDataSource<Item> {
       let dataSource = TableViewDataSource(
            items: items,
            reloadData: { [weak self] _, _ in self?.reloadData() },
            cellForItem: { tableView, indexPath, item in
                return tableViewCells(tableView, indexPath, item).asTableViewCell()!
        })
        self.dataSource = dataSource
        return dataSource
    }
}

DataSourceが大体動くようになりました。

DataSourceを作ってみる - registerできるようにする

今回はdequeueを省略したので動いてますが、TableViewを使う場合は本来dequeueが必要であり、対となるregisterも必要です。
TableViewCellProtocolregister するための関数を生やします。

// ※先の章で宣言した項目について省略
protocol TableViewCellProtocol {
    static func register(to tableView: UITableView)
}

extension Empty: TableViewCellProtocol {
    static func register(to tableView: UITableView) {

    }
}

extension Pair: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
    static func register(to tableView: UITableView) {
        C0.register(to: tableView)
        C1.register(to: tableView)
    }
}

extension Either: TableViewCellProtocol where C0: TableViewCellProtocol, C1: TableViewCellProtocol {
    static func register(to tableView: UITableView) {
        C0.register(to: tableView)
        C1.register(to: tableView)
    }
}

extension TableViewCellProtocol where Self: UITableViewCell {
    static func register(to tableView: UITableView) {
         print("Called!! \(Self.self)")
    }
}

printの行が呼ばれれば、これを本来のtableView.registerの呼び出しに変更すれば動作することは自明ですね。
呼び出し側でこの関数を利用しましょう。

extension UITableView {
    func generateDataSource<Item, C: TableViewCellProtocol>(items: [Item], @TableViewBuilder _ tableViewCells: @escaping (UITableView, IndexPath, Item) -> C) -> TableViewDataSource<Item> {
       C.register(to: self) // ここに追加!!
       let dataSource = TableViewDataSource(
            items: items,
            reloadData: { [weak self] _, _ in self?.reloadData() },
            cellForItem: { tableView, indexPath, item in
                return tableViewCells(tableView, indexPath, item).asTableViewCell()!
        })
        self.dataSource = dataSource
        return dataSource
    }
}

たった一行追加するだけで、TableViewCellのRegisterが完了するようになりました。

3個以上の返り値に対応する

必要な材料は揃っているので、 TableViewBuilder に雪だるま式に buildBlock を追記していくだけで動くようになります。

extension TableViewBuilder {
static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> Pair<Pair<C0, C1>, C2> {
        .init(c0: .init(c0: c0, c1: c1), c1: c2)
    }

    static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> Pair<Pair<Pair<C0, C1>, C2>, C3> {
        .init(c0: .init(c0: .init(c0: c0, c1: c1), c1: c2), c1: c3)
    }

    ...
}
lazy var dataSource = self.tableView.generateDataSource(items: [1, 2, 3]) { tableView, indexPath, item in
    if item == 0 {
        MyTableViewCell0()
    } else if item == 1 {
        MyTableViewCell1()
    } else if item == 2 {
        MyTableViewCell2()
    } else {
        MyTableViewCell3()
    }
}

MyTableViewCell0 ~ MyTableViewCell3register が呼び出せることが確認できます。

FunctionBuilderの価値(2回目)

最後に書いた4種類のCellを返す関数ですが、どこにも PairEither 、ましてやそれがネストした凶悪な型はどこにも見当たりません。返り値もそれなりにシンプルな TableViewDataSource<Item> です。
しかし、実行時に各Cellの register をコールするスタックトレースの底にある型は、Either<Either<MyTableViewCell0, MyTableViewCell1>, Either<MyTableViewCell2, MyTableViewCell3>> です。
この型から Eitherregister を再帰的に呼び出すことで、MyTableViewCell0 ~ MyTableViewCell3register がコールされる。
ちょうどMLにおけるDefine and Runで計算グラフを作るように、事前の処理を定義できるのです。
ビルドされた関数を実行するだけでなく、その関数の型の評価に価値がある、そういったケースでFunctionBuilderは真価を発揮します。

ココがダメだよFunctionBuilder

では実際にこれが最強のTableViewになるかというと、そんな事はありません。
FunctionBuilderには以下の制限があります。

if let, if caseが使えない

OptionalBindingが使えません。したがってOptionalを非Optionalとして取り出す方法は

if value != nil {
  MyComponent(value!)
}

になります。SmartCastが欲しくなる。

switchが使えない

残念ながらSwitchも使えません。したがってAssociatedValueを取り出す方法は、

extension MyEnum {
  var myCase: MyCase? {
    switch self {
    case myCase(let value): return value
    default: return nil
    }
  }
}
if value.myCase != nil {
  MyComponent(value.myCase!)
}

になります。これはちょっと厳しい…

capture listが使えない

SwiftUIは全て値型だったのでcapture listを必要としない世界でした。
UIKitはそうではないので、参照カウントに気をつけなければならない。しかしどうやらcapture listは使えません。(バグ?)
したがって関数の外に手書きします。しかもOptionalBindingの呪いも刺さります。

weak var weakSelf = self
tableView.generateDataSource(items: [1,2,3]) { (_, _, _) in
    if weakSelf != nil {
        weakSelf!.createCell(...)
    }
}

おまけ

すぐに動かせるサンプルをGithubに用意しておきました。
https://github.com/tarunon/UIViewBuilder
プロダクトで使うクオリティではない、というのとFunctionBuilderは未だpublicではないということにご注意下さい。
CollectionViewへの拡張や、差分更新などはやっていませんが、既存の他のコードと組み合わせて十分再現可能だと思います。
是非遊んでみて、そしてあわよくば自分のコードに取り込んでみてください。完成させてくれても嬉しいのよ。
それでは。

40
22
2

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
40
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?