こんにちは!
コネヒト株式会社でiOSアプリを開発している @kichikuchi です。
今日、明日の記事を担当します!

弊社のiOSアプリにはRxSwiftを導入しています。
RxSwiftを導入することで、データバインディングによるMVVMアーキテクチャの実現や、非同期処理を簡潔に記述できるなどのメリットを得られますが、稀に解決が難しい問題に遭遇することがあります。

今回は、業務中に遭遇したRxのちょっと(かなり)怖い話と、その回避方法をご紹介します。

ちょっと怖い話

まずは、実際の弊社プロダクトのコードの一部をご覧ください。

@IBOutlet weak var tableView: UITableView! {
    didSet {
        // rowHeight, estimatedRowHeight よりも先にtableFooterViewをセットしてしまうとなぜか4.7inch端末で `Index out of range` が発生してクラッシュしてしまうので順番厳守
        // 詳細: https://github.com/RxSwiftCommunity/RxDataSources/pull/75
        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 54.0
        tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 30))
    }
}

とても怖いコメントですね。

これはRxDataSourceを利用して、複数のセクションがあるtableViewを描画しているコードの一部です。

こちらのコメントの通り、tableFooterViewをrowHeightより先にセットした時に、4.7inch端末(実機、シミュレータ共に)でなぜか tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) より先に tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) が呼ばれてしまい Index out of range によりアプリがクラッシュするという現象に遭遇しました。

numberOfRowsが呼ばれるのより先にそのほかのUITableViewDataSourceメソッドが呼ばれるのが不可解なんですが、それに加えて正常な動作をするかどうかが画面サイズに依存しているというなんともいえない不思議な挙動をしています。
辛い。

どう解決したか

クラッシュ発生後、debug navigatorから発生経路をたどり、RxSwift内の // this is needed to flush any delayed old state (https://github.com/RxSwiftCommunity/RxDataSources/pull/75) というコメントを発見しました。

PRでのやり取りを読んでいたら、気になるコメント を発見しました。

dataSourceの後でtableFooterViewをセットしたら問題が解決したよ!(拙訳

というわけで、もしかしたらtableFooterViewをセットするタイミングを変更したら解決するかも?と思い何パターンか試し、最終的にtableFooterViewをセットするタイミングを上述したコードのように rowHeight 及び estimatedRowHeight より後にすることで、全ての端末で正常に動作するようになりました!

なぜこれで正常な動作になるのか全然納得できないんですが、これ以上の調査が難しかったためコメントを残して本件の対応を終了しました。

まとめ

RxDataSourceとtableFooterViewを利用した時に、tableFooterViewをセットするタイミングによって発生する不可解な挙動とその回避方法について紹介させていただきました。

RxSwiftに対するネガティブな側面の紹介となりましたが、もちろん頻繁にこのような挙動に遭遇するわけではありません。
Rxを導入することで得られるメリットは大きいので、弊社では引き続きRxを利用して日々開発を行っています!

なお今回の対応は本質的な解決ではないので、もし同様のケースに遭遇し、より良い解決方法をご存知の方がいましたらご教授いただけると幸いです。