🔨

SwiftUIと宣言的UI

2022/04/18に公開約17,200字

SwiftUIと宣言的UI

SwiftUIの登場によりiOSでも宣言的UIを意識してUIを構成する日々が始まりました。
この記事ではそもそも宣言的UIとは何か。また、宣言的UIの利点やSwiftUIで宣言的UIを実現するためにどういう機能が提供されているかを書いていきます。

また、SwiftUIで提供されている状態管理についても解説します。

宣言的UIと命令的UI

そもそもの宣言的UIと命令的UIがどういうものかを見ていきましょう。言葉にすると下のような違いがあります

  • 宣言的UI: 何でViewが構成されているのか記述されている
  • 命令的UI: どのようにViewが構成されているのか記述されている

具体的な例とともに見ていきます

命令的UI

まずは命令的UIです。UIKitで書いていきます。ここでは命令的UIの説明に集中したいので実際の動くコードではなく省略したもので書いていきます。実際の動くコードも折りたたんでおきますので気になる方は見てください

下の例ではボタンを押すたびに⭐️が増えていく魔法のようなボタンです。素敵ですね。

実際の動くコード
class ViewController: UIViewController {
    var button: UIButton!
    var label: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        label = UILabel()
        label.textColor = .black
        label.text = "🙌"
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])

        button = UIButton(configuration: .bordered(), primaryAction: .init(handler: { [weak self] _ in
            self?.action()
        }))
        button.setTitle("Button", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 60),
            button.widthAnchor.constraint(equalToConstant: 200),
            button.heightAnchor.constraint(equalToConstant: 50),
        ])
    }

    private func action() {
        if label.text == "🙌" {
            label.text = "⭐️"
        } else {
            label.text! += "⭐️"
        }
    }
}
class ViewController: UIViewController {
    var button: UIButton!
    var label: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        label = UILabel()
        label.text = "🙌"

        button = UIButton(configuration: .bordered(), primaryAction: .init(handler: { [weak self] _ in
            self?.action()
        }))
    }
    
    private func action() {
        if label.text == "🙌" {
            label.text = "⭐️"
        } else {
            label.text! += "⭐️"
        }
    }
}

ここでの着眼点は私たちがlabelの表示を想像する時の思考のプロセスです。箇条書きにすると

  • 最初はlabel.textには"🙌"が1つ入っている
  • 1度ボタンを押すと最初の"🙌"1つの状態から、"⭐️"1つになる
  • 2度ボタンを押すと前回の"⭐️"が1つの状態から、"⭐️"が1つ増えて "⭐️⭐️" になる
  • n度ボタンを押すと...

この例ではもっと直感的に考えられるかもしれませんが、正確にViewの状態を言語化すると上のような考え方になります。これはどのようにView(label)が構成されているのか を考えていると言えます。この例では

  • どのように = 最初は"🙌"1つ。そこからユーザーの操作によって"⭐️"に変わり、それから1つずつ"⭐️"が増えていく...

と置き換えることができるでしょう。このように最終的なViewの形を記述されている情報以上に想像する必要があるのが命令的UIの特徴と言えます。この場合は以前の状態・時系列を加味してViewの内容を想像しています。

宣言的UI

UIKitで表現したかったものをSwiftUIで書くと下のようになります。

struct ContentView: View {
    @State var text = "🙌"

    var body: some View {
       // UIKitの例で言うとbutton
        VStack {
            Button {
                action()
            } label: {
                Image(...)
            }
	    
	    // UIKitの例で言うとlabel
            Text(text) 
        }
    }

    private func action() {
        if text == "🙌" {
            text = "⭐️"
        } else {
            text += "⭐️"
        }
    }
}

さて、ここでUIKitの時にlabelだったものの内容を考えてみましょう。 そうですね。 @State var text の内容が表示されます。宣言的UIは言葉にすると 何でView(UIKit部分のlabel)が構成されているのか記述されている ものでした。これも今回の例で置き換えてみましょう

  • 何で = @State var text

と置き換えることができました。シンプルに考えられます。ここで 「どちらにしても text を求める場合の複雑さは変わってないのでは」と思う方もいるかもしれません。その通りです。なので整理します。宣言的UIはあくまで View が何で構成されているかに焦点を当ててます。これにより View の最終系がコードを見るだけで分かるようになります。加えて @State var text はいわゆる状態(State)と呼ばれるものです。ViewState は分けて考えられます。状態管理については次のセクションで解説します。

こういった変数・複雑性をStateとして捉えて分離することで、過去にViewに対してどのような変更が行われていたかにかかわらずに、同じStateであれば同じView(見た目)になります。これらを鑑みると宣言的UIでViewとは次のようなUIを表現するための純粋関数として捉えることもできそうです

UI = View(State)

SwiftUIでの状態管理

SwiftUIでの状態管理についてです。SwiftUIではいくつか標準で状態を管理するための仕組みが用意されています。具体的にはSwiftUIフレームワークが提供しているPropertyWrapper達です。

SwiftUIの思想としてSingle Source of Truthを謳っています。信頼できる情報源は1つにする考え方です。違う言い方をすると本質的には同じ情報であるはずなのに2つ以上の情報源が無いようにすることです。Single Source of Truth については後に少し触れますが、このことだけについての具体例は割愛します。SwiftUIではこの思想を比較的守りやすくなっていますが、あくまで実装者依存になるものなのでこれらを意識して状態管理をしていくと良いでしょう

State, Binding

お馴染みのやつです。SwiftUIでコードを書いたことがあるようならまずはこれらのPropertyWrapper達に触れると思います。下に書いてあるコードのContentViewの例を元に話を続けます。

State の用途は View の定義の中で完結する状態変化を管理するためのものです。先ほどの例で挙げた @State var textContentView の中で値が変更され、その変更が反映されるのは ContentView のみです。

Binding はほとんどの場合は親のViewの@StateのprojectedValueが指定されます。これは親の@Stateを用いて状態を変更をしたい場合に宣言します。もちろん受け取った状態を表示する用途にも使えます。表示だけなら@Bindingを用いずに値だけを受け取れば良いので、それと比較すると親の状態を更新したい場合に使うものになります。@Stateじゃ無い場合は例えば@AppStorageもprojectedValueがBindingになっています。

例えば実際にTextFieldを使ってみるとイメージが湧くと思います。StateをBindingとして渡すには$text のように $ プレフィックスをつけることで実現できます。これでTextFieldに値が入力されると @State var text の値も変化します。それにより text.count も変化していきヒントテキストで表示している現在入力されている文字の数も変化していきます。

struct ContentView: View {
    @State var text = "🙌"

    var body: some View {
        VStack(alignment: .trailing, spacing: 4) {
            TextField("Title", text: $text)
                .textFieldStyle(.roundedBorder)
            Text("\(text.count)/100")
        }
        .frame(width: 200)
        .padding()
    }
}

この例ではピンとは来ないかもしれないですが、これも親のViewのStateを単一の絶対的な情報源として子Viewが使用している点にも意識してください。この狭い範囲の中で Single Source of Truth が成立しています。

そしてこの2つのPropertyWrapperの特徴としてはstruct,enumといった値型で使うことが想定されています。参照型でも宣言できますがプロパティの値を再代入しない限り状態の変更として扱われないため、参照型で扱う理由は無いでしょう。参照型として何かしら状態を保持・管理したい場合は後述する@StateObject,@ObservedObject,@EnvironmentObjectあたりを使用していきます。これらの特徴を踏まえてState,Bindingは比較的狭いスコープでの状態管理の場合に使用します。

StateObject,OversedObject,EnvironmentObject

StateObject,OversedObject,EnvironmentObject について書きます。これらはCombineフレームワークで提供している@ObservableObject@Publishedが使用されます。

使い方はObservableObjectに準拠したclass,actorなどの参照型で使用できる機能になります。この例ではログイン状態をアプリ全体で管理することを想定してLoginStateと名付けます。

final class LoginState: ObservableObject {
    @Published var isLoggedIn = false

    func login() {
        API.call("/login") { _ in
            self.isLoggedIn = true
        }
    }
}

LoginState.isLoggedInがあるとします。これがfalseの場合は、ログインボタンを表示して、ボタンが押されたらAPIによるログイン処理を行います。APIによるログインの処理が終わったらisLoggedInをtrueにして、本来の機能であるお気に入りボタンを表示します。

LoginState で宣言されているisLoggedIn@Publishedの属性を持っており、 このプロパティの変更はViewに通知されます。通知されるViewはLoginStateが、 @StateObject, @ObservedObject, @EnvironmentObject のPropertyWrappeと共に宣言されインスタンスが共有されているViewになります。SwiftUIのViewはこの isLoggedIn の変更を元に表示を更新します。今回は @StateObject を使用するのが適切なのでこれを使います

struct ContentView: View {
    @StateObject var loginState = LoginState()

    var body: some View {
        Group {
            if loginState.isLoggedIn {
                // Logged In Content View...
            } else {
                Button {
                    loginState.login()
                } label: {
                    Text("Login")
                }
            }
        }
    }
}

これによりログインが絡んだ状態管理ができるようになりました。ここで気づいた方はいるかもしれませんが、この例については@Stateでも実現できます。実現する場合は下のようなコードになると思います

struct ContentView: View {
    @State var isLoggedIn = false

    var body: some View {
        Group {
            if isLoggedIn {
                // Logged In Content View...
            } else {
                Button {
                    API.call("/login") { _ in
		      isLoggedIn = true
		    }
                } label: {
                    Text("Login")
                }
            }
        }
    }
}

では、 @State@StateObjectのどちらを使うのが良いのでしょうか。これについての判断軸は他にもあるかもしれませんが、この例においてはこの状態ContentView に留まるものなのか、それとも ContentView よりも外側のもっと広い範囲の状態になるかを考えてみましょう。
このセクションの最初の方でLoginStateはログイン状態をアプリ全体で管理するものと定義付けました。例えばタブ構成のアプリにおいて、Aのタブの画面でログインは完了しているのに対し、Bのタブでは未ログイン状態ということは避けたいです。ContentView内部で@Stateでを宣言して状態管理を行うとアプリのユーザーがログインしているかどうかの状態がタブの間で共有できなくなります。違う言い方をすれば、この状態はSingle Source of Truthではなくなります。

では、loginStateの例を@State@Bindingを用いて構成できるか試してみましょう。これらは一つ前のセクションで狭いスコープでの状態管理の場面に適していると紹介しました。

struct RootView: View {
    @State var isLoggedIn = false

    var body: some View {
        TabView {
            ContentViewA(isLoggedIn: $isLoggedIn)
            ContentViewB(isLoggedIn: $isLoggedIn)
        }
    }
}

struct ContentViewA: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Group {
            if isLoggedIn {
                // Logged In Content View...
            } else {
                Button {
                    API.call("/login") { _ in
		       isLoggedIn = true
		    }
                } label: {
                    Text("Login")
                }
            }
        }
    }
}

struct ContentViewB: View { ... }

書けましたね。そして、ログインのためのボタンはContentViewAもContentViewBも全く同じものだったとします。なので、これはコンポーネント化を検討します。次のようになりそうです

struct LoginButton: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Button {
            API.call("/login") { _ in
                isLoggedIn = true
            }
        } label: {
            Text("Login")
        }
    }
}

さらにContentViewAのViewの要素が増えたと考えます。縦並びのViewでheader・body・footerに分かれるとします。bodyの部分はContentViewAの下にあったGroupをそのまま移動します。

struct ContentViewABody: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Group {
            if isLoggedIn {
                // Logged In Content View...
            } else {
                LoginButton(isLoggedIn: _isLoggedIn)
            }
        }
    }
}
struct Header: View { ... } 
struct Footer: View { ... } 

改めてコードの全体像を見てみましょう。Header,Footer等は省略します。

struct RootView: View {
    @State var isLoggedIn = false

    var body: some View {
        TabView {
            ContentViewA(isLoggedIn: $isLoggedIn)
            ContentViewB(isLoggedIn: $isLoggedIn)
        }
    }
}

struct ContentViewA: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        VStack {
            Header()
            ContentViewABody(isLoggedIn: _isLoggedIn)
            Footer()
        }
    }
}

struct ContentViewABody: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Group {
            if isLoggedIn {
                // Logged In Content View...
            } else {
                LoginButton(isLoggedIn: _isLoggedIn)
            }
        }
    }
}

struct LoginButton: View {
    @Binding var isLoggedIn: Bool

    var body: some View {
        Button {
            API.call("/login") { _ in
                isLoggedIn = true
            }
        } label: {
            Text("Login")
        }
    }
}

ここで状態の受け渡しの流れに注目します。RootView#isLoggedInがContentViewにバインドされて、ContentViewABodyにバインドされ、そしてそれがさらに下のLoginButtonにまでバインドされています。ContentViewAは実はisLoggedInを使っていないことにも気づきます。@State@Bindingでは状態を共有する範囲が大きくなるにつれて扱いづらくなっていくことが想像できます。

それではObservableObjectを使用した例も見てみましょう。SwiftUIでは ObservableObjectを共有するための仕組みとして@EnvironmentObjectが用意されています。 これは.environmentObject(_:)経由でObservableObjectのインスタンスを共有することができます。先ほどのタブ構成のアプリだと下の書き方でloginStateが各タブのViewに共有できます

struct RootView: View {
    @StateObject var loginState = LoginState()

    var body: some View {
        TabView {
            ContentViewA()
                .environmentObject(loginState)
            ContentViewB()
                .environmentObject(loginState)
        }
    }
}

struct ContentViewA: View {
    var body: some View {
        VStack {
            Header()
            ContentViewABody()
            Footer()
        }
    }
}

struct ContentViewABody: View {
    // EnvironmentObject を使って親から渡されるObservableObjectの型を宣言する
    @EnvironmentObject var loginState: LoginState

    var body: some View {
        Group {
            if loginState.isLoggedIn {
                // Logged In Content View...
            } else {
                LoginButton()
            }
        }
    }
}

struct LoginButton: View {
    // EnvironmentObject を使って親から渡されるObservableObjectの型を宣言する
    @EnvironmentObject var loginState: LoginState

    var body: some View {
        Button {
            loginState.login()
        } label: {
            Text("Login")
        }
    }
}

ContentViewAの定義は先ほどの @State@Bindingを使用した例で出てきたコードと酷似しています。しかし、いくつか改良された点があります。Viewのinitがシンプルになりました。これはRootViewから.environmentObjectを通してloginStateを子Viewに共有することができているためです。次にContentViewAではloginStateを宣言する必要がなくなりました。前述した.environmentObjectは子供の世代に関係なく子Viewに共有されるからです。

これでObservableObjectを利用した広い範囲での状態管理が実現しました。状態を共有する仕組みから察するに広範囲での状態管理はObservableObjectを利用すると良さそうです。改めて、ContentViewBにもisLoggedIn含めたLoginStateの状態が共有されるようになっていることにも着目しましょう。ユーザーがログインしているかどうかはLoginState.isLoggedInに情報が集約されました。つまり、Signle State of Truthが保つこともできます。

ObservedObject vs StateObject

最後にObservedObjectStateObjectの違いを解説します。長くなってきたので違いをサラッと書いてしまいます。

  • ObservedObjectはViewが作り直されるとObservableObject自体も再生成されます。つまり@Publishedの変数も作り直され、状態の維持ができないです
  • StateObjectはObservedObjectとは対照的にViewが作り直されても[^1]状態を維持できます

基本的にObservableObjectのインスタンスの作成時は@StateObjectを使うで良いでしょう。ObservedObjectの使い道を紹介します。一つはあえてObservableObjectを使用して、そのクラスが保持ている内容を維持したく無い場合です。これはすぐには良い例が思いつかなかったので具体例等は無しで。もう一つは、例えば一つ上の親からの@StateObjectを受け取りたい場合は@ObservedObjectとしてinitで受け取るという手法があります。@EnvironmentObjectの代わりに書くとこうなります

struct ContentViewA: View {
    // ObservedObject を使って親から渡されるであろうObservableObjectの型を宣言する
    @ObservedObject var loginState: LoginState

    var body: some View {
        Group {
            if loginState.isLoggedIn {
                // Logged In Content View...
            } else {
                Button {
                    loginState.login()
                } label: {
                    Text("Login")
                }
            }
        }
    }
}

そして、呼び出し元ではinitに@StateObjectを渡すことができます。これはStateObjectのprojectedValueがObservedObjectになっているからです

struct RootView: View {
    @StateObject var loginState = LoginState()

    var body: some View {
        TabView {
            ContentViewA(loginState: loginState)
            ContentViewB()
                .environmentObject(loginState)
        }
    }
}

@EnvironmentObjectによるインスタンスの共有との差分は、ObservableObjectの@Publishedプロパティが更新されても更新範囲が限定的になることです。

[^1]例えば親Viewの@Stateが更新された場合、子Viewは作り直され(initされ直され)@ObservedObjectObservableObject内部のプロパティはリセットされます。

SwiftUIでの状態管理まとめ

長くなってしまったのでここまでの概要をまとめます

  • 前提として Single Source of Truth を意識しました
  • State,Bindingは値型を使い、狭いスコープでの状態管理に適している
  • StateObject,ObservedObject,EnvironmentObjectは参照型を使い、広いスコープでの状態管理に適しています
  • EnvironmentObjectを使用することでView間の状態の共有が容易になります
  • あとは@ObservedObject@Publishedについてもおさらいしました

また、これらの例はあくまで1例なのでそれぞれ何ができるかを理解した上で組み合わせて使っていきましょう

その他の状態管理

SwiftUIで提供されている一般的な状態管理のための機能をおさらいしました。方法はこれだけか。と言われると決してそんなことは無いです。例えばCoreDataを中心としたアプリならFetchRequestの使用により状態管理ができます。

SwiftUI以外にも目を向けてみましょう。

多くのサーバー・クライアント間でやりとりするアプリケーションはサーバーサイド(DB,API)から提供されるデータをどのようにして状態として管理するかに関心が強くなります。この本来であれば同一なデータを簡単にクライアントでSingle Stateと扱えるようになれば状態管理がグッと楽になってくるはずです。

GraphQLであればApolloなどはAPIから取得したデータに対して、IDをごとに単一にキャッシュとして保つことができます。例えば、あるIDを持つObjectのキャッシュがある場合、そのIDのObjectをAPIから取得して、キャッシュのデータと差分があった場合を考えます。この場合は後でAPIから取得したデータの方が「正」となります。この場合はApolloでキャッシュを更新してくれ、さらにそのキャッシュを監視している場所に対して更新後のObjectのデータを流すことができます。この仕組みによりサーバーから帰ってきたデータからSingle Stateを保つことが容易になります。

Firestoreでは、snapshot listenerを使うことでDBの変更を検知できます。これにより最新のドキュメントがリアルタイムでリッスンできるので常に最新の状態を保つことができます

今パッと出てくるデータに対しての状態管理がアプローチがうまくいきそうな例を2つ挙げましたが、この他にも探せばあるかもしれません。もしこれらの手段を取れない場合、またはもっと違う部分に状態管理の課題がある場合は他の方法で状態管理をすべてやる必要が出てくるでしょう。上記に挙げた方法に比べると自分達で書く部分が多いですが、swift-composable-architecture はもっと一般的で幅広い状態管理ができる方法でもあります。

これらの方法はSwiftUI以外に目を向けた場合の話をしました。もちろん標準の状態管理をそのまま使用することはとても良い選択肢の一つだと考えます。

まとめ

個人的なこれらの採用基準の話も少し。まずは標準のやり方を理解してそれからアプリを実現する上で課題に感じたことに対してツールの導入やアーキテクチャを決定していくプロセスが好ましいと考えています。

最近周辺でSwiftUIを始めた人も多くこれらの人たちの参考になれば。というモチベーションもあり記事を書きました。SwiftUIでアプリを作る上での基礎(基本じゃないよ)となる概念の捉え方に対して解説しました。具体的には宣言的UIとはから始まり、SwiftUIでなぜその機能が提供されているのかを理解する上でのヒントになると思います。

また、これらは業務委託先のAppify私が学んだことでもあります。そのAppifyで今やっていることを発信してほしい。といった後押しもあったのでまずは序章と自身の理解のために記事を書きました

というわけで皆さん素敵なSwiftUIライフを。この後に出す記事にも乞うご期待

おしまい\(^o^)/

Discussion

ログインするとコメントできます