feature image

2022年8月30日 | ブログ記事

SwiftUIっぽいUIフレームワークを作った話

この記事は夏のブログリレー20日目の記事です。


こんにちは。21Bのkatsです。普段はiOSアプリを作ったりしてます。
今回は情報工学系のプログラミング創造演習という講義の一環で作ったUIフレームワークの仕組みについて書いていきます。

作ったもの

SwiftUIはAppleが2019年に発表した新しめの宣言型UIフレームワークです。詳しい説明は省きますが、下のようなコードでUIを生成することができます。

struct Counter: View {
    @State var count = 0

    var body: some View {
        VStack {
            Text("\(count)")
                .padding(20)

            HStack {
                Button(action: { count -= 1 }) {
                    Color.blue
                        .cornerRadius(10)
                        .overlay(Text("Decrement"))

                }
                    .padding(10)

                Button(action: { count += 1 }) {
                    Color.blue
                        .cornerRadius(10)
                        .overlay(Text("Increment"))
                }
                    .padding(10)
            }
                .frame(height: 60)
        }
    }
}

数値を1ずつ増やしたり減らしたりするだけのシンプルなカウンタ

自分が作ったのは超機能縮小版SwiftUIもどきです。SwiftUIと似たような(というかほぼ同じ)コードでUIを定義するとアプリケーションとして動かしてくれます。SwiftUIのサブセットっぽいですが所々微妙に仕様が違うのでサブセット名乗ったら怒られる気がする

リポジトリ: https://github.com/katsd/MiniSwiftUI
(SwiftPackage化していないため使いづらいですが、Content.swift書き変えれば色々遊べると思います)

この記事では上のカウンターアプリのコードからどのように画面を生成して動かしているのかについて解説していきます。

コードの簡単な解説

SwiftUI触ったことない方々のために雑ですが一応
とりあえずは縦方向/横方向のスタックの中にUIコンポーネント押し込んでいくという感じの認識で十分だと思います
untitled-diagram.drawio-5
(タップして元画像開いた方が見やすいかも)

レイアウト

今回作るカウンタには3つのUIコンポーネント(テキスト、ボタン2つ)が含まれていますが、描画する前にそれらの位置と大きさを知っておく必要があります。ただ、上のコードではHStackの高さが指定されているのみで、他のUIの大きさについては何もわかりません。というわけで、描画する前にそれらのレイアウトを自動的に決めてあげることにします。

実際にレイアウトを行う前に、処理を行いやすくするためUI要素の木を生成します(本家ではViewGraphと呼んでいるらしいのでここでもその呼び名を使います)。

本当はもっとノード多いけどわかりやすくするため省略

ここから各要素の大きさを決めていきます。大きさが指定されていない場合は結局どうするんだという話ですが、未指定の幅や高さについてはとりあえずDouble.infinity(Double型で表せるどの値よりも大きい数)を使います。つまり幅や高さを画面に収まる限り目一杯広げるという意味です。幅が.infinityの要素がn個並んでいる場合は、使えるスペースをn等分する感じになります(実際は幅や高さについてより細かい制約が求められる場合もありますが、実装を簡単にするため今回は単純に等分しました)。

Textの大きさはコード中で指定されていないがフォントサイズ等がわかっていれば計算可能、なお30*20という数値は適当

このように子要素の値を用いて自身の幅や高さを決定しています。
あとは実際に描画するスペース(ウィンドウサイズ)さえわかってしまえば、.infinityで表されている部分の実際の値、そしてUIを配置する位置を全て決めることができます。

描画

お絵描きです。ViewGraphを辿って表示すべき要素(現在は矩形、テキスト、画像のみ)を全て取得し、後ろのレイヤーから順番に描画するだけの簡単なお仕事です。CoreGraphicsを使って描画していますが、最近はSkiaあたりが主流なんですかね?

画面生成部分についてはこれで終わりです。

...正直レイアウトと描画に関してはあんまり書くことないです。何も考えずにViewGraphを辿るだけで各要素の大きさを決定できるため、想像以上に実装が簡単でした(実装量はひたすら多い)。もしSwiftUIがAutoLayoutみたいなレイアウトシステム採用してたら間違いなく発狂してたと思います

状態管理

状態変数の定義

実装に最も苦労した部分です。今回作るアプリではボタンが押された時にカウンタの値が変化するため、カウンタの値を適当な変数に保持しておく必要があります。この変数の値が変わった時、自動的にViewGraphの再構築と再描画が行われて表示されているカウンタの値が更新されると嬉しいわけです。

SwiftUIでは状態を以下のように定義します。

@State var count = 0

@Stateの詳しい意味については割愛しますが、やってることは以下のコードと大体同じです。

struct State<T> {
    var value: T // 値の実体
    var wrappedValue: T {
        get { value }
        set { value = newValue }
    }
}

var _count = State<Int>()
_count.wrappedValue = 0 // 上のコードの count = 0 に相当

要は値の実体をStateの内部に隠しておき、wrappedValue経由で値の取得/代入を行っているというわけです。

countの値の更新はwrappedValueのsetterで検知することができます。よって、countが変更されたときにViewGraph全体を再構築するという処理は可能です。しかし、Stateの値は自身と自身の子以下のViewからしかアクセスできないため、再構築すべきノードはStateが属するViewだけで十分です。

untitled-diagram.drawio-8

というわけで、wrappedValueの実装は以下のようになります。これでcountが変更されると勝手に画面が更新されるようになりました。

var wrappedValue: Int {
    get { value }
    set {
        value = newValue
        viewNode.rebuild() // ノードを再構築
    }
}

めでたしめでたし




... viewNodeってどこから取ってきたんだ?

†黒魔術†

ここで一度コードを見直してみます。

struct Counter: View {
    @State var count = 0
    
    var body: some View {
        // Viewの内容を定義
    }
}

今回は状態変数がcountしかないということがわかっているため、Counter().count.viewNode = nodeという感じでcountに構築すべきノードを教えてあげることができます。しかし、状態変数はどんな名前でも、そして何個でも定義できるため、任意のViewについて外部から状態変数全てを取得することは不可能です。

struct A: View {
    @State var a0 = 0
    @State var a1 = 1
}

struct B: View {
    @State var b = 0
}

setViewNode(A())
setViewNode(B())

func setViewNode<T>(view: T) where T: View {
    view.???.viewNode = node // 代入すべき変数がわからない
}

要は実行時にviewの型情報を取得できてしまえばいいのでは? というわけで、お行儀のいいプログラミングは諦めてReflectionをします。Runtimeという便利なライブラリがあったので今回はこれを使わせていただきました。

import Runtime

let viewInfo = try! typeInfo(of: type(of: view)) // viewの型情報を取得

// viewに含まれるプロパティを列挙
for prop in viewInfo.properties {    
}

こんな感じで簡単にview内の全プロパティを取得することが可能です。あとはpropがStateであるかどうかを確かめ、Stateだった場合は内部のviewNodeを書き換えてしまえばいいわけです(このような値の取得/代入もRuntimeを使って行うことができます)。
なお、SwiftにはMirrorという割と簡単にReflectionを行える機能があるのですが、こちらは書き込みに対応していないため今回は使いませんでした。

あとはマウスポインターの座標を毎フレーム取得してボタンが押されたかどうかを検知し、押された時にボタンに紐づけられた処理を実行することでViewGraphの再構築が行われ、画面が再描画されます。このようにして、ようやくカウンターアプリを実行することができました。

おわりに

自分の実装方針を長々と語ってきましたが、本家SwiftUIはソースコードが公開されてないため実際どのように実装されているかは謎です。絶対もっと効率いいやり方あると思います。どうせ模倣するんならFlutterあたりにしとけばよかった...

今回作ったものは構文がほぼSwiftUIの丸パクリなのでオリジナリティは皆無ですが、色々と改良加えて実際のアプリケーション開発でもそこそこ使えるレベルにしたいなーくらいには考えてます。自作UIフレームワークで本格的なアプリ作って最強になりたいですね

明日は@tatyamさんの記事です。お楽しみに

参考記事

Inside SwiftUI @State編 https://kateinoigakukun.hatenablog.com/entry/2019/06/08/232142
(Stateの実装でものすごく参考になりました)

kats icon
この記事を書いた人
kats

21B 情報工学系

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2022年9月16日
5日でゲームを作った #tararira
Komichi icon Komichi
2023年9月27日
夏のブログリレーは終わらない【駄文】
Komichi icon Komichi
2023年9月13日
ブログリレーを支えるリマインダー
H1rono_K icon H1rono_K
2023年8月21日
名取さなになりたくてOBSと連携する配信画面を作った
d_etteiu8383 icon d_etteiu8383
記事一覧 タグ一覧 Google アナリティクスについて