🐝

SwiftUIを1年以上利用して遭遇したSwiftUIのバグ達と互換性問題

2023/06/30に公開

はじめに

SwiftUIを用いて、1年以上cut & loopといアプリを作成してきました。
SwiftUIは、UIKitと異なり現在進行形で開発されているため、iOSバージョンアップ時のAPIの変更によるバグ遭遇率が、(主観ですが)とても高い気がしています。

SwiftUIの解説記事は数多くあると思いますが、どのような不具合をがあるかを紹介している記事は少ないのではないかと思い、開発の際に遭遇したSwiftUIのバグや、下位互換による問題と教訓を紹介します。


🐝1 List内でonAppearとonDisappearが無限ループに陥る(iOS17β) 2023/7/9追加

Infinite Loop Issue with View's onAppear and onDisappear in iOS 17 Beta

事象

List内で、(現時点では推測ですが)表示非表示を伴うViewを利用すると, onAppearとonDisappearが無限に呼ばれ続け操作不能になります。
iOS15, iOS16以前では発生せず, iOS17ベータで発生するようになりました。

再現方法

Infinite Loop Issue with View's onAppear and onDisappear in iOS 17 Betaに投稿しています。

原因

iOS17ベータのSwiftUIのバグか仕様変更

対応方法

この再現コード内では、Groupに対してonAppearとonDisappearを呼んでいましたが、Group内の個々のviewにonAppearとonDisappearを振り分けることで回避は可能です。

(cut&loop上では、上の回避方法では別の不具合が発生しました。結果的にはUIKitでお馴染み(?)DispatchQueue.main.asyncでonAppear, onDisapper内の処理を囲み、実行タイミングを変えることで回避できているように見えます。 2023/7/13 追記)

回避前

struct SampleSection: View {
    @State private var isLoaded = false
    var body: some View {
        let _ = Self._printChanges()
        Group {
            if !isLoaded {
                Section("Header") {}
                    .hidden()
            } else {
                Section("Header") {
                    Text("Text")
                }
            }
        }
        .onAppear {
            NSLog("SampleSection onAppear.")
            isLoaded = true
        }
        .onDisappear() {
            NSLog("Sample Section onDisappear.")
            isLoaded = false
        }
    }
}

回避策1

// アプリの内容によっては、別の不具合が生じる可能性あり
struct SampleSection: View {
    @State private var isLoaded = false
    var body: some View {
        let _ = Self._printChanges()
        if !isLoaded {
            Section("Header") {}
                .hidden()
                .onAppear {
                    NSLog("SampleSection onAppear.")
                    isLoaded = true
                }
        } else {
            Section("Header") {
                Text("Text")
            }
            .onDisappear() {
                NSLog("Sample Section onDisappear.")
                isLoaded = false
            }
        }
    }
}

回避策2

// cut & loopでも回避できた方法
struct SampleSection: View {
    @State private var isLoaded = false
    var body: some View {
        let _ = Self._printChanges()
        Group {
            if !isLoaded {
                Section("Header") {}
                    .hidden()
            } else {
                Section("Header") {
                    Text("Text")
                }
            }
        }
        .onAppear {
            DispatchQueue.main.async { // or Task { @MainActor in
                NSLog("SampleSection onAppear.")
                isLoaded = true
            }
        }
        .onDisappear() {
            DispatchQueue.main.async { // or Task { @MainActor in
                NSLog("Sample Section onDisappear.")
                isLoaded = false
            }
        }
    }
}

その他

この記事を作成した際にまとめ代わりに作成した教訓が役立ちました。
前々から薄々感じていましたが...、SwiftUIのアプリ開発はやがてSwiftUIのデバッグに変わる、気がしてなりません。

🐝2 NavigationStackで画面を遷移しようとすると、NavigationStackの内部処理が無限ループに陥り処理続行不能になる(iOS16.4以降で発生)

Apple Developer Forum iOS 16.4 NavigationStack Behavior Unstable

事象

iOSが16.4にアップデートされた後に、cut & loopで遭遇したバグです。(iOS16.5でも再現します。)

NavigationStack内のNavigationLinkをタップすると、NavigationStackの内部処理が無限ループとなり、アプリが固まります。復旧はアプリの再起動をするしかありません。

発生条件は不明で(NavigationStackと@Environment(\.dismiss)が関係している?)、万が一遭遇してしまうと100%発生し、復旧方法もなくとても厄介なバグです。

再現方法

再現コードはApple Developer Forum iOS 16.4 NavigationStack Behavior Unstable
にあります。その再現コードでは、Google AdMobがリンクされていないと再現しないのですが、別の方の指摘ではAdMobがなくとも発生しているようです。

View構造によって発生したり、しなかったり、また回避策が安定しないことからも、SwiftUIがメモリ破壊ならぬフレームワーク破壊的な振る舞いを起こしていると感じています。

原因

iOS16.4のリリースノートによると、NavigationStack/NavigationSplitView周りでの変更が行われてたため、それが影響しているかもしれません。

対応方法

回避方法は、原因が不明なため、発生状況に応じて各々作る必要があると思いますが、@Environment(.dismiss)を避けることが効果的なようです。他にもnavigationDestinationの使い方を返る方法もあります。詳細は上記Forumのリンクから確認できます。

その他

iOS16.0以降NavigationStackはバグが多い印象があります。iOS16.3βまでは事前にデバッグをしていたのですが、iOS16.4では落ち着いているだろうと油断したところ、iOS16.4のリリース後に遭遇してしまったバグです。

またこの事象にある回避策の不安定性は、iOS15のNavigationViewでの勝手画面がpopされるバグ:Apple Developer Forum SwiftUI NavigationView pops back when updating observableObjectを思い起こさせるため、これはデグレの一種なのかもと邪推しています。


🐝3 Listのスクロールと、ListにBindingしているデータの更新が合わさるとクラッシュすることがある。(iOS16.0からiOS16.4.0で発生。16.4.1以降で解消)

Apple Developer Forum ScrollViewProxy scrollTo will crash when scrolling outside of bounds of previous (not current) List data source/array in iPad and iOS 16 beta

事象

Viewを表示する際に、リストのスクロール位置をプログラムで調整したり、リストの一行を追加や削除を行った際にスクロールさせるとクラッシュする場合があります。

iOS15では問題なくListのスクロールとデータ更新が同時に行われてもクラッシュしなかったのですが、iOS16以降で発生するようになりました。

再現方法

完全な再現コードは、Apple Developer Forum ScrollViewProxy scrollTo will crash when scrolling outside of bounds of previous (not current) List data source/array in iPad and iOS 16 betaに掲載されています。

概略としては、Listに表示するデータを変更した直後に、Listを表示する際にscrollToでリストの表示位置を変更するとクラッシュすることがあります。


var body: some View {
  ScrollViewReader { proxy in
    List {
      ForEach(data) {}
    }
    .onAppear() { proxy.scrollTo(...) }
    .onChange(data) { newValue in proxy.scrollTo(...) } // たまにクラッシュする。
  }
}

原因

iOS 16 Release Notesには、"The implementation of list no longer uses UITableView. (81571203)"と記載されていて、Listの実装が変わったことが影響しているかもしれません。(確かUICollectionViewに置き換わったと記憶しています。)

対応

データ更新に伴うListの再描画時に、前回の状態が維持されていることに問題があると推測して、Listのインスタンスを一度破棄し、前回状態を破棄させる回避方法を作成しました。
(ただしView構造によっては、上記の対応でもまだクラッシュが発生することがあるようです。)

回避方法の概略は以下です。

var body: some View {
  // ...略
  if isDataChanging {
     List(data)  
      .hidden()
  } else {
     List(data)
      .onChange(data) { _ in proxy.scrollTo() }
  }
}

またSwiftUIではiOSバージョンによって挙動が異なるため、cut & loopでは「iOS15,iOS16.4以降では既存処理を行う」「iOS16.0~16.3では回避策を適用する」といったiOSバージョンによる分岐を行っています。

その他

この回避方法を作成する上で、 WWDC21のDemystify SwiftUIは、大変参考になりました。

SwiftUIを利用する際には、WWDC21のDemystify SwiftUIとWWDC23のDemystify SwiftUI performanceの視聴すると、開発のヒントになるかもしれません。


互換性問題1 NavigationViewを放棄(?)して、iOS16からNavigationStack/NavigationSplitViewを導入

SwiftUIは、バグのあるAPIをdeprecatedとして、新しいiOSバージョンからの新APIでバグを対象することがあります。

公式にアナウンスされた訳ではありませんが、以下のバグは、そのように受け取ることができる例だと思います。

Apple Developer Forum SwiftUI NavigationView pops back when updating observableObject

事象

iOS15のNavigationViewには、View構造によっては、ランダムに遷移先ビューからpopされてしまう不具合があります。

このバグに遭遇すると、ランダムに画面がpopされるようになったり、iOSの表示設定によっては再現しなかったりととても厄介なバグです。

原因

NavigationViewによる問題で、詳細は不明です。

傾向としてはListの再描画か、表示されていない遷移元ビューの再描画に関係がありそうです。
リストをスクロールした時、@EnvironmentObjectの有無、@StateObjectの有無、iOSのフォントサイズ...などなど様々な要因が絡んでい発生しています。

対応

根本的には、iOS16以降のNavigationStackを利用する必要があります。

iOS15では、Apple Developer Forum SwiftUI NavigationView pops back when updating observableObjectの回避方法にある .navigationViewStyle(.stack) を適用は、経験上必須です。
それでも低確率で発生することを確認しています。

そのためiOS15.navigationViewStyle(.stack)を適用した上で、このバグを受け入れる妥協が必要になります。

暫定的な回避方法として、View構造の調整(@EnvironmentObjectの利用を止めたり、バインディングを見直したりなど)などで、一時的に回避できることがあります。ただし一時的に発生しなくなっても、コードを変更した際に再発する可能性があるので、View構造の調整での回避は避けた方が無難と思います。

その他

原因に挙げたものは全て体験したもので、例えばiOSの文字サイズが標準以下だと発生せず、大きくすると発生するなどといったことがありました。iOS15時にcut & loopの開発をしていて、最も訳が分からず最もストレスのあったバグです。また、このバグを考慮すると、Viewのリファクタリングは、慎重にならざるを得ません。

別の問題としてiOS間の互換性があります。cut & loopでは、iOS15ではNavigationView, iOS16ではNavigationStackを利用していますが、以下のようなラッパークラスを作成して対処しました。

public struct CLNavigationView<Destination: View>: View {
    
    @ViewBuilder
    public let destination: () -> (Destination)
    
    public init(@ViewBuilder destination: @escaping () -> (Destination)) {
        self.destination = destination
    }
    
    public var body: some View {
        if #available(iOS 16.0, macOS 13.0, *) {
            NavigationStack {
                self.destination()
            }
        } else {
            NavigationView {
                self.destination()
            }
            .navigationViewStyle(.stack)
        }
    }
}

互換性問題2 iOS17のObservationへ、どのように移行すれば良いか分からない問題

これはバグではありませんが...

iOS16以前のObservalObjectに変わり、iOS17からObservationが導入されました。

今の所Deprecatedされていませんが、WWDC23のDemystify SwiftUI performanceでも推奨されていることから、今後はObservationを利用した方が良いのでしょう。(詳細な理由は把握できていません。パフォーマンスが向上するのでしょうか。)

Migrating from the Observable Object protocol to the Observable macroにMigrationに関するドキュメントがありますが、これはアプリのiOSのターゲットバージョンをiOS17以降にした場合にのみ利用できる方法です。

下位互換を保ったまま、どのようにObservationに移行すれば良いのか、とても悩ましいです。


教訓

ここまで、SwiftUIで遭遇したいくつかのバグ達を紹介してきました。
せっかくなので教訓としてまとめてみます。

  • iOSのβバージョンのリリースノートが公開されたら、SwiftUI関連は必ず確認すること。
  • βのリリースノートに、利用しているAPIに関する変更が記載されている場合、βが取れるまで継続的に動作検証すること。
  • iOS βの間のバグやXcodeのWarningの修正は、状況に応じて修正すること。βの間に訳も分からないWarningが出るようになり、勝手に修正されるものも多い。Apple Developer Forumなどを確認しつつ必要に応じて対処すること。(そのまま放置されることもあるので、回避策は検討しておく方が良いです)
  • SwiftUIのAPIを新しく利用する際は、プロダクトの想定する使い方をサンプルプログラム作成するなどして事前に検証すること。
  • アプリのバグかSwiftUIのバグかどうか分からないことも多いので、Apple Developer Forum等を活用して確認すること。
  • 不具合が発生したら、再現する最小コードを作成し、まずはその上で回避策を検討すること。(2023/7/9追加)

最後に

SwiftUIのバグ達(愚痴?)や下位互換の問題についてあれこれ書きました。SwiftUIは色々問題も多いですが、慣れてしまうとUIKitよりメリットがあり、画面開発ツールとしておすすめです。

最後にですが、2022年にSwiftUIで一新したcut & loopをぜひ触ってみてください。MP3やCD音源を利用して語学学習の機会がある方にお勧めします。

Discussion