可変高なViewとignoresSafeAreaの組み合わせでSwiftUI TabViewはタブ切り替えができなくなる - 分析

こんにちは、株式会社SpacelyでiOSエンジニアをしているmorninです。

TabViewに可変高なViewとignoresSafeAreaを組み合わせるとタブ切り替えができなくなるSwiftUIのバグに遭遇しました。

SwiftUIは内部実装が隠蔽されているためこのようなバグの追跡は困難なパターンが多いです。今回はSymbolic Breakpointとアセンブラの解析を通して、このパターンでTabViewが内部でcontentOffsetを計算する際に常にCGRectZeroを返してしまい、タブ切り替えができなくなるという問題を特定しました。

このバグはSwiftUIレイヤーではなく内部で呼び出しているUIKitレイヤーで発生しているためSwiftUI側から修正することができません。


Affected Code Pattern

struct ContentView: View {
    @State private var selectedIndex: Int = .zero
    @State private var fontSize: CGFloat = 18
    var body: some View {
        VStack {
            Text("text")
                .font(.system(size: fontSize))
                .onTapGesture {
                    fontSize = fontSize == 18 ? 24 : 18
                }
            
            HStack {
                Button("Tab0") { withAnimation { selectedIndex = .zero } }
                Button("Tab1") { withAnimation { selectedIndex = 1 } }
            }

            TabView(selection: $selectedIndex) {
                VStack { Text("Tab0") }
                    .tag(0)
                VStack { Text("Tab1") }
                    .tag(1)
            }
            .tabViewStyle(.page)
        }
        .ignoresSafeArea(edges: .bottom)
    }
}

これは問題を再現するシンプルなSwiftUIコードです。高さが変化するならばフォントサイズの変更に限らず、frameのheightの変更、if文によるViewの切り替えなど、どんな実装でも発生します。また、ignoresSafeAreaはedgeを指定しなくても発生します。

このバグでは可変高なViewの高さが変化した後、selectedIndexを更新することによるTabViewのタブ切り替えが、ファーストタブに戻す以外できなくなります。

奇妙なことにこのViewでは固定高とする以外に以下の修正を加えることで問題が再現しなくなります。

  1. withAnimationを外す
  2. ignoresSafeAreaを外す

Debug Techniques

_printChanges

SwiftUIのViewの問題を特定するためにまず_printChangesを使用します。

struct ContentView: View {
    ...
    var body: some View {
        let _ = Self._printChanges()
        ...
    }
}

iOSはアプリの起動中、常にCFRunLoopによるループ処理が行われています。 UIKitやSwiftUIのViewを管理するCore AnimationはこのCFRunLoopのCFRunLoopActivity.beforeWaitingのタイミングを監視しており、このタイミングで@State変数の変更などがあった場合にViewを評価します。

_printChangesはこのViewの評価が開始した際に呼ばれるため、状態変化が単純なら、ここを通っていない場合は状態変数の管理に問題があるかどうか、またはその後のViewのレイアウト処理中の問題かを切り分けることができます。今回のコードでは_printChangesを通過して状態更新は正常に行われていたため、問題はSwiftUIのレイアウト処理です。

Symbolic Breakpoint

問題がレイアウト処理であることが分かりましたがSwiftUIの内部処理は通常確認することができません。しかし、SwiftUIは内部でUIKitを利用しているということと、Symbolic Breakpointを利用することによって少しだけ楽に問題を追跡することができます。

Symbolic BreakpointはXcodeのBreak Pointsタブの下にある+ボタンから追加することができます。

またはlldbを適当なところで停止して次のように設定することも可能です。

br s -n "-[UIViewController viewDidLoad]"

例えば-[UIViewController viewDidLoad] を指定すると、次のような出力となり、SwiftUIが内部的にUIViewControllerを使用していることが分かります。

-[UIViewController viewDidLoad]
ContentView: @self, @identity, _selectedIndex, _fontSize changed.

問題のTabViewは内部でUICollectionViewを使用しているため、UICollectionViewのレイアウト処理に関連するメソッドをSymbolic Breakpointに追加していきます。

ignoresSafeAreaを取り除いて正常に動作するようにしたパターンでは、可変高Viewの高さが変わってからタブを切り替えるまで以下のような挙動をとることが分かりました。

ContentView: _fontSize changed.
-[UICollectionViewLayout invalidateLayout]
-[UICollectionView layoutSubviews]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView layoutSubviews]

ContentView: _selectedIndex changed.
-[UICollectionView setContentOffset:animated:]
-[UICollectionViewLayout invalidateLayout]
-[UICollectionView layoutSubviews]
-[UICollectionViewLayout invalidateLayout]
-[UICollectionView layoutSubviews]
...
// アニメーションのためinvalidateLayoutとlayoutSubviewsが続く

一方で、今回のバグパターンでは以下の挙動をとります。

ContentView: _fontSize changed.
-[UICollectionViewLayout invalidateLayout]
-[UICollectionView layoutSubviews]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView setContentOffset:animated:]
-[UICollectionView layoutSubviews]

ContentView: _selectedIndex changed.
-[UICollectionView setContentOffset:animated:]

直接的な問題は -[UICollectionView setContentOffset:animated:] が呼ばれた後に-[UICollectionViewLayout invalidateLayout]がないことです。これはUICollectionViewが内部的に状態変更がなかったと判定していることを示しています。


Root Cause Analysis

-[UICollectionViewLayout invalidateLayout]の処理がスキップされる原因は、UICollectionViewにおけるページ計算のフォールバック処理に起因しています。

原因

UICollectionViewはUIScrollViewを継承していますが、継承元の関数-[UIScrollView _rectForPageContainingRect:]に対して予期しないCGRectが渡されることでフォールバックとして常にCGRectZeroが返されます。この結果を-[UICollectionView setContentOffset:animated:]が利用することで、常にファーストタブが指されるようになります。

コマンド

今回は具体的な計算が問題になるためアセンブラで記載された処理をいくつかみていく必要があります。 主に出てくるコマンドおよび概要は次の通りです。

カテゴリ コマンド 命令
メモリ操作 ldr メモリからレジスタにデータを読み込む
ldp メモリから2つの連続するレジスタにデータを読み込む
算術・変換 fadd 2つの浮動小数点値を加算する
fdiv 浮動小数点値の除算を行う
fcvtms 浮動小数を整数に変換する
比較 cmp 2つの整数値を比較する
fcmp 2つの浮動小数点値を比較する
fccmp 条件付きで浮動小数点値を比較する(前の条件が真の場合のみ実行)
制御フロー b.eq 比較結果が等しい場合に指定位置へジャンプ
b.ne 比較結果が等しくない場合に指定位置へジャンプ
bl 指定したアドレスに分岐した後、直後の位置に戻ってくる

レジスタ

また、レジスタに格納されている値も逐次確認していきます。 レジスタに格納されている値を調べる際は次のようにregister readや

(lldb) register read
General Purpose Registers:
        x0 = 0x0000000104896600
        x1 = 0x00000001fb960c41
        ...
       x28 = 0x0000600003308b18
        fp = 0x000000016d839760
        lr = 0x0000000185997e20  UIKitCore`-[UICollectionView _contentOffsetForScrollingToItemAtIndexPath:atScrollPosition:additionalInsets:itemFrame:containingScrollView:clampToScrollableArea:] + 808
        sp = 0x000000016d839610
        pc = 0x000000018686de58  UIKitCore`-[UIScrollView _rectForPageContainingRect:]
      cpsr = 0x60000000

pないしpoで値の確認が可能です。

(lldb) p $d0
(double) 750

また、iOSなどのARM64CPUには浮動小数計算用のNEONレジスタがあり、64ビットの倍精度浮動小数レジスタを計算する際はd0 ~ d31レジスタを利用します。

以降は主にこれらの値を参照しながら具体的な処理を見ていきます。

-[UICollectionView setContentOffset:animated:]の解析

-[UICollectionView setContentOffset:animated:]の処理は次のようになっており、ステップオーバーしていくと、invalidateLayoutを有効化するかどうかの処理をスキップしていることが分かりました。

UIKitCore`-[UICollectionView setContentOffset:animated:]:
    ...
    // これからスクロール予定のcontentOffsetとUICollectionViewのcontentOffsetの比較
    0x185998aa4 <+48>:  bl     0x18727c5a0               ; objc_msgSend$contentOffset
    0x185998aa8 <+52>:  fcmp   d9, d0
    0x185998aac <+56>:  fccmp  d8, d1, #0x0, eq
    0x185998ab0 <+60>:  b.eq   0x185998b78               ; <+260>

    ...

    // invalidateLayoutするかどうかの処理
    shouldInvalidateLayoutForBoundsChange

    ...

    // <+260>の処理

スキップする前の処理を確認すると、UICollectionViewのcontentOffsetを取得して戻ってきています。

    0x185998aa4 <+48>:  bl     0x18727c5a0 ; objc_msgSend$contentOffset

contentOffsetの戻り値は浮動小数2つなのでd0とd1レジスタに格納します。それをd8, d9レジスタと比較しています。

    0x185998aa8 <+52>:  fcmp   d9, d0
    0x185998aac <+56>:  fccmp  d8, d1, #0x0, eq

このd8, d9レジスタにはあらかじめ引数から退避させていた引数のcontentOffsetの値が入っています。

    0x185998a98 <+36>:  fmov   d8, d1
    0x185998a9c <+40>:  fmov   d9, d0

引数のcontentOffsetとUICollectionViewのcontentOffsetを比較して、同一の場合invalidateLayoutに至るまでの処理はスキップされてしまいます。

    0x185998ab0 <+60>:  b.eq   0x185998b78               ; <+260>

問題は引数として渡されるcontentOffsetが常に同じ値を返すことです。-[UICollectionView setContentOffset:animated:]に渡されるcontentOffsetがどこで計算されているかを確認するためスタックトレースを遡ります。

-[UIScrollView _rectForPageContainingRect:]の解析

調査の結果この原因は-[UIScrollView _rectForPageContainingRect:]内にあることが分かりました。ここから常にCGRectZeroが返ってきており、ページ遷移ができなくなっています。

UIKitCore`-[UIScrollView _rectForPageContainingRect:]:
->  0x1860de0b4 <+0>:   sub    sp, sp, #0x80
    0x1860de0b8 <+4>:   stp    d15, d14, [sp, #0x20]
    0x1860de0bc <+8>:   stp    d13, d12, [sp, #0x30]
    ...
    0x1860de1fc <+328>: ret    

-[UIScrollView _rectForPageContainingRect:]は、引数でもらった矩形がどのページに格納されるかを判定して、そのページの矩形を返します。

この関数に渡される引数はCGRectが渡されているのでd0 ~ d3レジスタに値が格納されています。ブレークポイントを張っていればlldbコマンドから、それぞれの値を確認できます。

この時、引数に渡される矩形にはバグパターンの場合マイナス値(-17)が含まれており、このマイナス値があることによりCGRectZeroを返すようになっていました。

(CGRect) (origin = (x=402, y=-17), size=(width=402, height=645.333...))

y値の計算に関して-[UIScrollView _rectForPageContainingRect:]の処理を見ていきます。

まずd10レジスタにCGRectGetMinYの戻り値を格納します

    0x1860de16c <+184>: bl     0x18621c384               ; symbol stub for: CGRectGetMinY
    0x1860de170 <+188>: fmov   d10, d0

次にd10をd14で割った値をd1レジスタに格納して整数に変換してw8レジスタに格納します。このとき、このd14をしばらく遡って確認するとスクロールViewのbounds.heightが入っています。ここでは与えられたrectが縦方向に何ページ目か、を計算しており今の場合-1となります。横に遷移するだけのTabなのでここは本来0となるはずですが、-17分だけ上にずれているようです。

    0x1860de190 <+220>: fdiv   d1, d10, d14
    0x1860de194 <+224>: fcvtms w8, d1

同様に、CGRectGetMaxYの計算についてもページ数を計算します。ここは-17分だけずれていますが、スクロールViewの高さ内にはおさまっているので0となります。

    0x1860de184 <+208>: bl     0x18621c354               ; symbol stub for: CGRectGetMaxY
    ...
    0x1860de198 <+228>: fmov   d1, #-1.00000000
    0x1860de19c <+232>: fadd   d0, d0, d1
    0x1860de1a0 <+236>: fdiv   d0, d0, d14
    0x1860de1a4 <+240>: fcvtms w9, d0

この場合はw8とw9の比較は-1と0で結果として<+296>へジャンプします。

    0x1860de1a8 <+244>: cmp    w8, w9
    0x1860de1ac <+248>: ldp    d1, d0, [sp, #0x10]
    0x1860de1b0 <+252>: ldr    d3, [sp, #0x8]
    0x1860de1b4 <+256>: b.ne   0x1860de1dc               ; <+296>

最終的な戻り値はCGRectなのでジャンプした先でのd0 ~ d3の最終の値を確認すると全て0となっています。

    0x1860de1d0 <+284>: b      0x1860de1dc               ; <+296>
    0x1860de1d4 <+288>: ldp    d1, d0, [sp, #0x10]
    0x1860de1d8 <+292>: ldr    d3, [sp, #0x8]
    0x1860de1dc <+296>: fmov   d2, d11
    // d0 = 0, d1 = 0, d2 = 0, d3 = 0

<+288>、<+292>にあるように、これらの値はsp、つまりスタックポインタに格納されていた値を復帰させています。 このスタックポインタへの値は_rectForPageContainingRectの冒頭で設定された値です。

    0x1860de0e8 <+52>:  adrp   x9, 399019
    0x1860de0ec <+56>:  ldr    x9, [x9, #0xf88]
    0x1860de0f0 <+60>:  ldp    d0, d1, [x9]
    0x1860de0f4 <+64>:  ldp    d11, d3, [x9, #0x10]
    0x1860de0f8 <+68>:  add    x8, x0, x8
    0x1860de0fc <+72>:  ldrb   w8, [x8, #0xc]
    0x1860de100 <+76>:  tbz    w8, #0x5, 0x1860de1dc     ; <+296>
    0x1860de104 <+80>:  fmov   d15, d2
    0x1860de108 <+84>:  stp    d3, d1, [sp, #0x8]
    0x1860de10c <+88>:  str    d0, [sp, #0x18]

ここでは、現在のプログラムカウンタ0x1860de0e8からアドレス399019を指定して移動した後、オフセット#0xf88先にある値をd0、d1に

    0x1860de0e8 <+52>:  adrp   x9, 399019
    0x1860de0ec <+56>:  ldr    x9, [x9, #0xf88]
    0x1860de0f0 <+60>:  ldp    d0, d1, [x9]

さらにオフセット#0x10先にある値をd11、d3に格納しています。

    0x1860de0f4 <+64>:  ldp    d11, d3, [x9, #0x10]

厳密にこの値が何かを特定するのは困難ですが、指定したアドレスから値を取得しているので定数であることが推測され、また、これらの値を確認すると全て0であることから_rectForPageContainingRectは初期化としてCGRectZeroを呼び出していると推測されます。

d2だけd11から復帰しているので、ここだけ初期値ではないのではと思われるかもしれませんが、w8とw9でジャンプした場合にはd11は初期値から更新されないためやはり0が返ります。

    0x1860de1dc <+296>: fmov   d2, d11

前半の方で見た-[UICollectionView setContentOffset:animated:]のcontentOffsetはここから返されたoriginの値を利用するため(0, 0)が渡されます。これによりselectedIndexの状態に関わらず常にファーストタブに遷移しようとしてしまい、Tab1の状態で高さを変更した後Tab0にすることはできるが他のタブには遷移できない、という挙動になったことが分かります。

一方で、withAnimationを外してselectedIndexを変更する際もレイアウトに変更はないため同様のバグに遭遇する可能性があります。しかし、withAnimationを外した場合は、この処理を通らずに別のクランプ処理にジャンプしそこでは問題なくタブ切り替えが行われます。

実際、この_rectForPageContainingRectの処理自体に問題があるわけではなく、その前段階のマイナス値を含む不正なcontentSizeの計算にあります。

マイナス値の原因について

このマイナス値はちょうどsafeAreaInsetsのbottomの半分で、UICollectionViewはこのsafeAreaInsetsを考慮して上下中央に調整したCGRectを計算して_rectForPageContainingRectに渡してしまっています。

// 任意の関数でSymbolic Breakpointを止めてsafeAreaInsetsを確認する
(lldb) p (UIEdgeInsets)[(UIView *)$arg1 safeAreaInsets]
(UIEdgeInsets)  (top = 0, left = 0, bottom = 33.999999999999886, right = 0)

なお、safeAreaInsetsは可変高なViewが変化した時に追加されているわけではなくignoresSafeAreaを設定した場合は最初から設定されているため、TabViewの上部のViewの高さが変化し、UICollectionViewのフレームサイズが元の高さより小さくなった際、contentSizeの計算にsafeAreaInsetsの分を考慮してしまって発生するようです。


回避策

上述の通り、今回のバグはSwiftUI内部のUIKitレイヤーで発生しているため、SwiftUIのAPIを利用して修正することはできません。回避策としては画面内の要件のどれかを落とすしかありません。

1. 可変高Viewをやめて固定高にする

高さの変動が少なく固定長にできる要素なら固定長にすることでcontentSizeの計算不正を回避することができます。一方、if文による条件分岐によって高さが変わる場合この解決方法をとることはできません。

2. ignoresSafeAreaをやめる

_rectForPageContainingRectの計算は実際何の問題もなく、またフォールバックが適切に働いた結果としての今回のバグなので、不正なrectを渡す原因になる方が問題です。根本の計算誤差の原因となるsafeAreaInsetsが入らないため、可能であれば最も望ましい解決策です。

3. withAnimationをやめる

ページ遷移時にアニメーションを要件から外せるならwithAnimationを外すことで、CGRectZeroが返るパターンを回避できるので不具合を回避できます。


まとめ

今回はSwiftUIの内部挙動の分析を通してTabViewのバグを特定しました。分析時のポイントは

  1. _printChangesで状態管理が問題かレイアウト問題かを切り分ける
  2. Symbolic Breakpointを貼ってパブリックAPIの処理の流れを追う
  3. さらに内部の問題であるとき、プライベートAPIの処理を追う
  4. 具体的な計算が問題であるとき、アセンブラを確認する

SwiftUIの内部バグだと判定したタイミングで、なぜだかわからないけれど治るコードを選択することが多いですが、少し手間をかけることでどの選択肢をとるべきか判断の基準が得られます。SwiftUIのバグぽいものに遭遇したけれど、分析が難しいなと思った時はSymbolic Breakpointや_printChangesを是非使ってみてください。

最後に、スペースリーでは一緒に働いてくださる方を大募集中です。 少しでもご興味がある方は上記よりご連絡ください!


検証環境


参考

  1. The Art of ARM Assembly, Volume 1: 64-Bit ARM Machine Organization and Programming
  2. The SwiftUI render loop