Zenn
☃️

SwiftUIで画面が真っ白になった

2025/01/28に公開

株式会社バニッシュ・スタンダードのヒダです。弊社で提供している「STAFF START」というtoBのサービスのiOSアプリ開発を担当しています。

今回はSwiftUIで弊社のアプリを実装している時に出会った不可解な挙動と、そういう挙動にどう対応するかの話を書こうと思います。

1. 実際に起きた問題: 画面が真っ白!

今回起きた問題を1ファイルで再現できたので、そのコードをまず見ていただこうと思います。
登場するのは SomeView と ModalView で、SomeView から .sheet で ModalView を表示します。

import SwiftUI

// MARK: - SomeView

struct SomeView: View {
    @ObservedObject private var someModel = SomeModel()

    var body: some View {
        Rectangle()  // Viewなら何でもいいのでRectangle
            .onAppear {
                Task {
                    someModel.someValue = 1
                }
            }
            .sheet(isPresented: .constant(true)) {  // 切り替えなくても再現するので .constant
                ModalView()
                    .toolbar {
                        ToolbarItem {
                            Button("Print someValue") {
                                print(someModel.someValue)
                            }
                        }
                    }
            }
    }
}

final class SomeModel: ObservableObject {
    @Published public var someValue: Int = 0
    
    public init() {
    }
}

// MARK: - ModalView

struct ModalView: View {
    @ObservedObject var modalViewModel = ModalViewModel()
    
    var body: some View {
        Text(modalViewModel.message)
            .onAppear {
                modalViewModel.message = "This is ModalView."
            }
    }
}

final class ModalViewModel: ObservableObject {
    @Published public var message: String = ""
    
    public init() {
    }
}

命名はともかくとして、これを表示すると何が起きるか想像していただきたいです。ModalView の .onAppear"This is ModalView." を message にセットしていて、それをそのまま Text で表示しています。ということはモーダルが開いた時、このメッセージが表示されていてほしいのですが、実際どうなるかというと…

真っ白です。本当はどうなってほしいかというと、こうです。

2. とりあえず解決策から

コードを見ていて、とりあえずこの @ObservedObject@StateObject に変えればいいんじゃないか? と思った方。自分はそれが1つの正解でいいかなと思います。View自身がModelを管理するなら @StateObject を使えって先生言ってましたよね。ただそうするとこの記事は終わりになるので、もうちょっとだけつっこんで見てみようと思います。

struct ModalView: View {
    // これを @StateObject にすれば解決!
    @ObservedObject var modalViewModel = ModalViewModel()
    // 略
}

コードを見ていると、怪しい箇所が2つ、他にもあるのに気付きましたか? まずはここです。

        Rectangle()
            .onAppear {
                Task {
                    someModel.someValue = 1
                }
            }

await していないのに Task でかこってどうするんだと思った方、それはそうで、実際ここで Task でかこむのをやめれば、うまくいくようになります。

ただこれは元のコードを簡略化しただけ で、これは例えばViewの表示後にAPIを叩いてそのレスポンスの値をObservableObjectに非同期に反映させる時もやっていることは同じになります。そのため現実のコードでいうとここはおそらく変更できません。

もう1つの怪しい箇所はどこかというとここです。

                            Button("Print someValue") {
                                print(someModel.someValue)
                            }

まず print すな💢 という話だと思いますが、これも簡略化したコードで、ここで表したかったのは .sheet の中でこのViewの @ObservedObject の値を参照している状態です。この print をコメントアウトしても問題は解決します。

つまり、.sheet などを使ってViewを表示し、かつその中で参照しているObservedObjectの値が非同期に書き変わると、何やらおかしなことになるらしい、ということが分かります。

※ ちなみにこの挙動になるのは .sheet だけでなく .fullScreenCover なんかも同じです。

3. 一体何が起きているのか

StateObjectで解決する時点で大体察しはつくと思います。ObservedObject と StateObject の違いは、後者(StateObject)はコンテナのビューが再描画されても再生成されないことです。逆にいうと、今回のコードのような条件が重なると、ObservedObject である ModalView のモデルは無駄に(?)再生成されているはず、ということが分かります。

試しに各Viewのbodyの先頭に調査用の _printChanges を仕込んでみます。

        let _ = Self._printChanges()

これがうまくいかない時のログで

SomeView: @self, @identity, _someModel changed.
ModalView: @self, @identity, _modalViewModel changed.
ModalView: _modalViewModel changed.
SomeView: _someModel changed.
ModalView: @self changed.

これがうまくいく時のログです。

SomeView: @self, @identity, _someModel changed.
ModalView: @self, @identity, _modalViewModel changed.
ModalView: _modalViewModel changed.
SomeView: _someModel changed.

うまくいかない方は最後に ModalView: @self changed. が出力されていて、ModalViewが再生成されていることが分かります。いったん表示されたあとで作り直されてしまい、画面が真っ白になった訳です。

ついでにModalViewのmodalViewModelのidを出力してみます。これをModalViewのbodyの先頭に入れます。

        let _ = print(ObjectIdentifier(modalViewModel))

うまくいかない方は、最後だけ値が変わっています。modalViewModelが生成しなおされたことが分かります。

ObjectIdentifier(0x0000600000c76550)
ObjectIdentifier(0x0000600000c76550)
ObjectIdentifier(0x0000600000c5e8e0) <- 違う値!

うまくいくパターンではmodalViewModelのidが変わっていないので、1度しか生成されていないことが分かります。

ObjectIdentifier(0x0000600000c8a7f0)
ObjectIdentifier(0x0000600000c8a7f0)

4. 結局どうすればいいのか

このコードなら @StateObject にすればいいと思いますが、実際のコードはもっと複雑怪奇かもしれないので、基本的には @StateObject をどこに置くか考えて、そこからお行儀よく @ObservableObject で伝播させる、みたいなルールを守るしかないと思います。ついでにObservableマクロに移行するまでは .sheet や .fullScreenCover でそのViewの @ObservedObject を参照するとこういうことになる、と知っておくといいと思います。

逆に個人的によくないと思うのは、○○がうまくいかなかったからとりあえず××にしようみたいな解決の仕方です。今回も画面が真っ白になった時に、とりあえずObservableObjectをそのまま子のViewにつっこんでおく、みたいな解決策はありました。ただ大事なのはそうするのが正しいのかちゃんと吟味することで、うまく表示されなかったから、という理由で必要のないものまで渡してしまうのは正しくないと思っています。また一度いい加減な修正をしてしまうと、今後の修正もそのいい加減な修正をベースにしたものになっていくため負のスパイラルに陥る危険性があると思います。

うまくいかないのには何かしら理由があるはずで、例えばAPIを叩くべきなのに叩けていなかったり、レスポンスのパースに静かに失敗しているかもしれません。その辺がうまくいっているにも関わらず画面に何も表示されていないとなると、今回のようにモデルが再生成されていそう、という察しがつきます。

5. どうやって突き止めるのか

とはいえ、何かが想定通り動かなかった時に、これよりももっと複雑なコードで原因を探すのは大変だと思います。

ものすごく時間があれば、今回、自分が最初に載せたコードのように、問題が再現できる最小限のコードになるまで関係がなさそうな箇所を削ってみるといいと思います(バグ報告や掲示板などで質問する時も結局そうする必要があると思いますし…)。

  1. Viewの無関係な部分や、プロパティ、引数などを削る。オブジェクトのネストを段々浅くする
  2. 実行して問題が再現するのを確認

の1と2を延々繰り返して、できるだけそのコードが小さくなるようにすると、最低でも1つは原因が分かります。ここで難しいのは、うまくいくために残さないといけないコードも一緒に削ってしまう恐れがあることで、先にうまくいくパターンを調べておいて、コードを削った状態でもうまくいくパターンのコードに書き換えるとちゃんとうまくいく、ということを時々確認する必要があります。

とはいえそんな時間は普通ないと思うので、例えば自分が作業しているブランチで問題が起きるようになったのであれば、最終的な形は一度忘れて、そのブランチの変更を1行ずつ元に戻していくしかないかなと思います。あえてずっと再現し続けるように無関係そうなコードを戻していくと、どこかのタイミングで再現しなくなるはずで、そこから調査を始めるといいと思います。

6. ついでに

という訳で、データがとれているはずなのに何かが表示されなくなった時は、どこかでモデルの再生成が起きていそう、ということと、愚直にコードを削っていけばどのコードがきっかけなのか、いつかは分かるという話でした。

あとこんな簡単な問題の調査にわざわざ5でやったような作業をしたのか? と思われる方もいると思いますが、現実はもうちょっとややこしい形で問題が表れていて、バックグラウンドから起動したら問題が起きないのに、完全に終了させた状態から起動すると起きる、ついでに他のViewの一部も表示がおかしくなる、みたいな感じだったので最初は 🤔🤔🤔❓ という感じでした。

最後についでにもっともらしいことをいうと、こういう問題を解決しやすくするために、普段から設計を意識する価値があると思います。5で書いたような「削る」作業をしてみると、各Viewやクラスが依存しているものが少なければ少ないほど、作業が楽だということが分かります。あるオブジェクトに問題がある時は、そのオブジェクトかそれが依存する何かに原因が隠れている訳で、できるだけそれぞれが密につながらないように普段から意識して作ることで、こういう問題も解決しやすくなるんじゃないかと思います🧶

株式会社バニッシュ・スタンダード

Discussion

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