SwiftUIで混乱した話

RabbitBoxの開発では メインのUIフレームワークとして SwiftUI を使用しています。

理由としては新しいっぽいからと言うものだったのですが、慣れるまではなかなか思ったように実装できませんでした。今回は理解しづらかったポイントなどを他のUIフレームワークなどと比較しつつ書いていこうと思います。

RabbitBox - 画像収集と整理アプリ

RabbitBox - 画像収集と整理アプリ

  • Yoshinobu Sugain
  • 写真/ビデオ
  • 無料
apps.apple.com

要約

  • SwiftUIをQtなどのようなGUIライブラリと同じような認識で理解すると混乱する
  • ViewはUIコンポーネントwidget(Qt), Form(.NET)など)のようなものではない
  • Viewは動的な状態を保持するクラスオブジェクトではなく、静的な状態を返す構造体
  • @State, @Bindingなどが指定されている変数が更新されるたびに、bodyが再評価される
  • 動的なUIコンポーネントはSwiftUIフレームワークが管理しており隠蔽されている
  • SwiftUIフレームワークがViewのbodyを評価し、ビュー状態の差分を動的なUIコンポーネントに反映する
  • 動的なUIコンポーネントの制御が必要な場合はUIKitを利用する

参考など

しくみから理解するSwiftUI - Speaker Deck

SwiftUIの基本

SwiftUIの使い方については公式チュートリアルがわかりやすいので一通りやってみるのがおすすめです。ただ、チュートリアル通りに書いていくとUIは作れるのですが、本質的なところが今ひとつ理解できませんでした。

宣言型シンタックス

SwiftUIは宣言型シンタックスらしいです。🤔🤔🤔🤔🤔

最近の若者ことばはよくわからないので、お恥ずかしいことに正直どう言う定義か自信を持って答えられません。なのでなんとなく理解した内容を書いてみようと思います。

私としてはUIフレームワークといえば、 QtやWindows FormだったりwxWidgetsとか化石みたいなやつしか知らないんですが、これらのノリで使おうと思うと全然理解できませんでした。

例えばQt(PySide)で Hello World を表示しようとすると次のような雰囲気になると思います。(うろ覚えで書いただけなので間違ってるかも)

class ContentView(QDialog):
    def __init__(self, parent=None):
        super(ContentView, self).__init__(parent)

        label = QLabel("Hello World")

        layout = QHBoxLayout()
        layout.addWidget(label)
        self.setLayout(layout)

これは個人的には慣れ親しんだスタイルで、表示内容を変えたければ QLabelオブジェクトに setText() すればいいわけです。WebアプリであってもDOMのノードの属性とかを変えてあげれば更新できますし、わかりやすいです。

ただ、これがSwiftUIだと次みたいな感じになります。

import SwiftUI

struct ContentView: View {
    var body: some View {
        HStack {
            Text("Hello world!")
        }
    }
}

はじめてさわってた時に思ったのが、「あれ、これText()オブジェクトに触れなくね?🤔」でした。

チュートリアルを進めていくと、どうやら@Stateをつけた変数を介して更新するらしいと言うことまで辿り着けます。ふむふむ。

import SwiftUI

struct ContentView: View {
    @State var text = "Hello world!"

    var body: some View {
        HStack {
            Text(text)
            Button("Hoge", action: {
                text = "hogehoge"
            })
        }
    }
}

この時は「なんかよくわからんけど、text変数とText()オブジェクトが謎の仕組みでリンクされるのね」と言う認識に落ち着きました。

Viewがクラスじゃなかった件

なんで変数に@Stateをつけるかもよくわからないまま、SwiftUIでアプリを開発し始めたのですが、今ひとつ挙動が理解できません。

この原因が ViewをQtなどのようにクラスオブジェクトとして考えていたためでした。 さっきの例では「Buttonを押した後はButtonを非表示にしたい。」ってなった時、どうやってButtonオブジェクトのhide()メソッドを呼び出せばいいんだろ?と言う発想になっていました。次のような感じです。

import SwiftUI

struct ContentView: View {
    @State var text = "Hello world!"

    var body: some View {
        HStack {
            Text(text)
            let button = Button("Hoge", action: {
                text = "hogehoge"
                button.hidden()
            })
        }
    }
}

そのほかにも「View表示前に var body: some View が一度だけ呼び出されてViewを初期化する」と思っていたり、「一度生成されたViewオブジェクトは親Viewの破棄まで存続する」と思い込んでいたりしました。 このせいで、body内でViewの変数を初期化しようとしてエラーになったり、destory()的なメソッドを探したりと今考えると意味のわからないことをしてました。

そんな時ふと気づきました。「実装してるViewって構造体じゃね?」と……

View ≒ UIテンプレート?

Swiftにおいてstructは値型です。このため、仮に前述ようにButtonオブジェクトを変数に代入できたとしても単にコピーされるだけで参照ではありません。なので、その変数を介して操作は行えません。何か根本的に勘違いしているのでは……🤔

そこで、ViewはUIコンポーネントWidget, コントロール)の実装ではなく、UIのテンプレート(Qtの.uiやqml, jinja2のテンプレート)のようなもので、動的なデータ構造ではなく静的な構造を宣言していると認識を改めました。この宣言をSwiftUIに与えることでUIの実オブジェクトはフレームワーク側が良きように計らってくれているんじゃないかなと。

そう考えるとSwiftUIで理解できなかった挙動も理解できるようになってきました。

例えば、次のようにif文でButtonを表示するかどうか制御できるのですが、これが意図通りに動くのが妙にしっくりきませんでした。

import SwiftUI

struct ContentView: View {
    @State var text = "Hello world!"

    var body: some View {
        HStack {
            Text(text)
            if text != "hogehoge" {
                Button("Hoge", action: {
                    text = "hogehoge"
                })
            }
        }
    }
}

これは描画のために、毎回bodyプロパティの再評価(=UIコンポーネントのオブジェクトを再生成)なんてパフォーマンスの観点からするとは思えないのに、挙動としてはtext変数が変更されるたびに再評価していてなんかモヤモヤしていました。

ただ、これもViewで定義しているものがビューの構造情報のみであれば話は別で、次のような処理だと考えれば納得できます。

  1. SwiftUIフレームワークが@State変数の変更を検知
  2. Viewのbodyプロパティを評価し、Viewの静的な構造情報を取得
  3. 取得した構造情報からSwiftUIフレームワークが管理している動的なUIコンポーネントオブジェクトへ差分を反映

さいごに

このような経緯を辿ってSwiftUIの認識を改めたところ、かなりすんなりと挙動が理解できるようになりました。

別記事として書くかもしれませんが、 @ObservedObject, @StateObjectだったり、@Bindingの変数を更新した際の親ビューの挙動、NavigationLinkなどはもはや最初の「View=UIコンポーネント」という認識では挙動が全く理解できません。

SwiftUIは非常に便利なのですが、ただ実現できることに限界があります。 ある程度以上高度なアプリを組もうと思うとどうしても動的なUIコンポーネント自体にアクセスが必要になってきます。このような要件の場合はUIKitフレームワークで実装して、SwiftUIに組み込むという実装の形を取ります。イメージとしては「QtDesigner=SwiftUI」、「カスタムWidget=UIKitのカスタムView」のような関係性です。なので、アプリのレイアウトやViewコンポーネントの関連付けはSwiftUI、高度な実装はUIKitのように全てをSwiftUIで行おうとせず限界を認識しつつ利用するのが良いかと思います。