iOS 13と14をサポートするSwiftUIの実装でRettyアプリチームがハマったポイントまとめ

f:id:imaizume:20211206003311p:plain

iOS 13でSwiftUIをサポートするのはツラい

本投稿はRetty Advent Calendar 2021 Part1 6日目の記事です

こんにちは、Rettyアプリチームの @imaizume です。

最近は業務の傍ら、地方を周って郷土料理やご当地グルメの発掘に勤しんでおり、直近では新米のきりたんぽ鍋を食べに秋田へ行ってきました。 こちらはマイベストにもしているお店なので、秋田へ行った際はぜひ行ってみてください!

retty.me

さて今回はiOSのSwiftUIに関するお話になります。

2021年、RettyアプリチームではSwiftUI導入という大きな技術変革を経験しました。

engineer.retty.me

SwiftUI中心の開発によって、新人が開発に参加しやすくなったり、プレビューを活用してUIのデバッグが高速になったり、チームの技術スタックが一本化されるなどの効果が得られました。

一方で、周知のようにSwiftUIのAPIiOS 13と14で仕様が異なるものがあります。

そのためどちらか片方、あるいは一部のバージョンのOSだけでしか再現しないような挙動が発生することがあり、Rettyアプリチームでもハマった点や苦労して実装した点が多々ありました。

そこでこの投稿では、これまで我々が踏み抜いてきたSwiftUIの動作の違いやバグをご紹介したいと思います。

読者のみなさんがこれを読んで、今後同じポイントでハマることがなくなれば幸いに思います。

なお内容によっては実装時から時間が経過していること、解消を優先して調査しきれていないものも含まれる可能性があることをご承知おきください。

iOS 13への対応方針を判断する材料として

ちなみに本題に入る前に、この記事を書こうと思ったきっかけを少しだけお話させてください。

OS間の差を埋めることが簡単にできれば良いのですが、場合によっては工数が大幅に増えたり、頑張っても原因が判明しないケースもありました。

そんな時、我々は修正を頑張りつつ関係者と相談しながら、場合によっては「OSによって仕様を変える」という選択もしました。

iOSの場合、大半のユーザーは新OSリリースから一定期間でバージョンを更新してくれますし、一部の旧OSサポートのために重要なリリースができないのは良いとは言えません。

そして結果として、 Rettyでは2021年末でiOS 13のサポートを終了することとなりました。

今回掲載した数々の不具合やバグの経験を通じ、プランナーやデザイナーにiOS 13サポート終了のメリットや効果を具体的に伝えることで、この判断を下すことができました。

もちろんサポート切りは簡単にできるものではありませんが、他のアプリでも近い将来サポート終了判断をすることになるかと思います。

仕様を変えるかサポートをやめるか、いずれにしても価値提供スピードとOSのサポートのトレードオフをどこかで取る必要はあります。

ぜひこの記事を、今後のiOS 13への対応方針を検討する材料にしていただけたら幸いです。

本題: SwiftUIにおけるiOS 13と14の仕様差とハマりポイント

今回ご紹介するハマりポイントは次のとおりです。

筆者の体感での調査、修正の難易度順にご紹介していきます。

iOS 13ではImageをTextに含めることができない

Rettyのお店TOPで詳細情報を表示する画面

テキストの横にアイコンを配置するようなUIはよく見るかと思います。

例えばRettyだと、お店の情報を見る画面などでこのパターンの実装をしています。

アイコン部分を Image で実装した場合に、iOS 14以降では Text の引数に Image を与える init(_ image: Image) を使うことで画像と文字列を連結するようなシンプルな実装で実現できます。

// iOS 14以降で可能
Text(Image(systemName: "star")) + Text(" お気に入り")

https://developer.apple.com/documentation/swiftui/text/init(_:)-60ax7

しかしこの方法はiOS 13では使えないため HStack で並べたうえで Image にもリサイズなどの処理が必要になります。

対応: init(_ image: Image) は使わずに HStack を使う

Rettyではこの仕様に対して、現状 if #available(iOS 14.0, *) の分岐を設ける等はせず HStack を使った実装に統一しています。

またお店の各種情報を表示する部分など「アイコンとテキストのセット」が繰り返し必要な箇所では、実装を簡易化するためのカスタムViewを定義しています。

どの行でも大きさは同じで良いので framepadding などの値も固定値で指定しています。

コード

private struct MultiLineIconRowView<Content>: View where Content: View {
    var body: some View {
        HStack(alignment: .top, spacing: 0) {
            Image(uiImage: icon)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 18)
                .fixedSize()
                .padding(.trailing, 8)

            content()
                .frame(maxWidth: .infinity)
        }
        .padding(.vertical, 16)
    }
}

iOS 13ではProgressViewが使えない

iOSでよくみるこの表示

読み込み状態を表すための ProgressViewSwiftUIで利用可能なのはiOS 14からです。

iOS 13では利用できないので UIViewRepresentable とUIKitの UIActivityIndicatorView を使う必要があります。

対応: ラッパーViewを実装

こちらは簡単な実装で済むため自前でラップするようなViewを作成しました。

それぞれ引数に指定できるオプションが違うのですが、Rettyでは特に差分を吸収する実装は必要がなかったので引数にはしませんでした。

細かいこだわりがなければ、このくらいの実装でも十分使い勝手が良いものが作れているのではないでしょうか。

コード

struct ActivityIndicator: View {
    var body: some View {
        if #available(iOS 14.0, *) {
            ProgressView()
        } else {
            SwiftUIActivityIndicator(isAnimating: true, style: .medium)
        }
    }
}

struct SwiftUIActivityIndicator: UIViewRepresentable {
    typealias UIViewType = UIActivityIndicatorView
    let isAnimating: Bool
    let style: UIActivityIndicatorView.Style

    func makeUIView(context _: UIViewRepresentableContext<SwiftUIActivityIndicator>) -> SwiftUIActivityIndicator.UIViewType {
        UIActivityIndicatorView(style: style)
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context _: UIViewRepresentableContext<SwiftUIActivityIndicator>) {
        isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
    }
}

iOS 13ではImageが特定の色で塗りつぶされてしまう

最初に画像が塗りつぶされる現象を確認した時の様子

こちらもiOS 13だけで起きる不具合。

画像の読み込みをしている箇所で、なぜかリソースを指定しているにもかかわらず青や黒で塗りつぶされてしまうと言う現象が起きることがあります。

しかもすべての実装でバグが再現するわけではなさそうで「たまに」「iOS 13だけ」で起きるというのが難点。

このバグを発見した時も、開発者がiOS 14のエミュレーターデバッグをしていて、QAの段階で初めて報告をもらい気づくことができました。

対応: iOS 13を使ったQAでカバー + 明示的に .renderingMode(.original) を指定

こちらについてはバグの報告があった箇所に対して .renderingMode(.original) 明示的に指定することで現象を回避しています。

Image(Icon.star.image).renderingMode(.original)

現場この実装によるデメリット等は確認しておらず実装も簡単なのですが、忘れた頃になってたまに再現するという点でやっかいですね(汗)

なお解決にあたっては、こちらの記事を参考にしています。

www.hackingwithswift.com

iOS 13では LazyStack を使えない

iOS 13と14で最も有名な仕様が異なるAPIはやはりListでしょう。

iOS 14からは、表示に必要な要素だけを効率よく読み込む LazyHStack LazyVStack が登場し、これを使うことでパフォーマンスを向上させられるようになりました。

しかしiOS 13までは標準で HStackVStack しか利用できないため、 LazyStack を利用したい箇所ではOSによる分岐が必要となります。

さらに VStackHStackiOS標準のリストUIと同じ、Insetや背景色、セパレーターなどがついていますが、これらをリセットするためのModifierがSwiftUIにはなく、UIKitへのバックポートライブラリであるSwiftUI-Introspectを使うなど、工夫が必要となります。

github.com

対応: 自前のラッパーViewを実装

リストはとても汎用的でよく使われるAPIなのと分岐を設けるのが比較的簡単だったため、RettyではiOS 13と14共通で使える簡単なラッパーViewを作って対応しました。

Rettyでは今のところパフォーマンス面の問題は起きていませんが、描画の内容によってはiOS 13でもパフォーマンスを向上させなければいけないケースもあるかもしれません。

iOS 14以上でApple標準APIのみで実現可能になったら、こちらに寄せたいところですね。

コード

struct NoSeparatorVList<Content>: View where Content: View {
    let content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    var body: some View {
        if #available(iOS 14.0, *) {
            ScrollView {
                LazyVStack(spacing: 0) {
                    content()
                }
            }
        } else {
            List {
                content()
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .introspectTableViewCell { cell in
                        cell.backgroundColor = .clear
                    }
            }
            .environment(\.defaultMinListRowHeight, 0)
            .introspectTableView { tableView in
                tableView.separatorStyle = .none
                tableView.backgroundColor = .clear
            }
        }
    }
}

ナビゲーションバー周りのAPIが異なる

細かい点ですがナビゲーションバー周りもiOS 14で地味にアップデートがされています。

まずタイトルの設定をするModifierが navigationBarTitle から navigationTitle に変わっています。

今回は使用しませんでしたが、タイトルのスタイルを変えるためのModifierである navigationBarTitleDisplayModeiOS 14から追加されています。

またナビゲーションアイテムの表現についても、iOS 13が navigationBarItems だったのに対して、iOS 14からは toolbarToolbarItem の組み合わせになりました。

対応: 呼び出し箇所で個別に分岐

こちらについてもラッパーViewを実装することは可能ですが、Rettyでは参照箇所が少ないのもあり必要な箇所で個別に分岐を入れるようにしています。

コード

if #available(iOS 14.0, *) {
    content
        .navigationTitle(navigationTitle)
        .toolbar {
            ToolbarItem(placement: .navigationBarLeading) {
                DismissButton(onTapDismiss: onTapDismiss)
            }
            ToolbarItem(placement: .navigationBarTrailing) {
                SubmitButton(onTapSubmit: {
                    onTapSubmit()
                }, title: navigationItemTitle)
            }
        }
} else {
    content
        .navigationBarTitle(navigationTitle)
        .navigationBarItems(
            leading: DismissButton(onTapDismiss: {
                onTapDismiss()
            }),
            trailing: SubmitButton(onTapSubmit: {
                onTapSubmit()
            }, title: navigationItemTitle)
        )
}

今はこれで対応可能ですが、ナビゲーションバーを実装する画面が増えてくるとこのあたりもネックになってくるので、早くiOS 14以降のスタイルに揃えていきたいですね。

iOS 13.3でList内のScrollViewがうまく描画できない

スクロールしていると赤枠の部分が消えてしまう

同じく検索結果予約カレンダー周りでの不具合。

上図のように、検索結果画面は縦のリスト内に横スクロールのカレンダーが入れ子になっています。

しかしiOS 13.3でスクロールすると、リスト内のScrollView = 予約カレンダーが存在するはずの店舗でなぜか表示されないという不具合がありました。

なお13.7では正常に表示されることを確認しましたが、それ以前のバージョンで起きているかどうかは未確認です。

対応: Listの要素に明示的にIDを指定する

この原因はどうもSwiftUIのバグにあるようで、List側の要素となるViewに明示的に一意な値をIDとして指定することで表示ができるようになりました。

実装自体は簡単でしたが、iOS 13の一部のバージョンでしか再現せず発見されにくい不具合だったので注意が必要です。

Rettyの場合も、特定の検索条件でスクロールをしないと発生しなかったので、今後もUIのデグレを監視していきたいと思います。

コード

List {
    ForEach(0 ..< 100) { _ in
        ScrollView(.horizontal) {
            HStack {
                Text("test").id(UUID())
            }
        }
    }
}

実装にはこちらの情報を参考にさせていただきました。

iOS 13でScrollView内のViewにタップイベントが伝わらない

赤枠内をタップしたら青枠部分は反応してほしくなかったのですが...

続いても検索結果画面でのお話。

この画面はScrollViewをルートとして、中にセルに相当する店舗毎のView(以下親View)、そして予約可能な店舗はその中にカレンダーView(以下子View)が表示されるという仕様です。

タップについては親Viewでは店舗TOPへ、子Viewは予約画面へそれぞれ遷移することになっています。

通常こういったUIであれば、タップ可能なViewが入れ子になっている場合、子Viewのイベントが優先されるように思われます。

ところがiOS 13で子ViewがScrollViewの時には、本来優先されるべき子View内へのタップイベントと同時に、なぜか親Viewへのタップイベントも走ってしまうという不具合があることがわかりました。

そのため、予約カレンダー部分をタップしても予約画面には遷移せず、店舗TOPに遷移してしまうような挙動となってしまいました。

この問題についてもネット上には情報がいくつかあるものの、今回についてはいずれを用いても解消することができませんでした。

対応: 親Viewのタップイベントでロックを取る

少しトリッキーではありますが、親View 子Viewともにタップイベントが同時に発火する(どちらが先に発火するか不明)ため

  • それぞれのタップ処理を非同期処理に対応させ
  • 親の遷移開始を子に比べてわずかに遅延させる
  • 子の非同期処理の実行状況を見て親が処理を続行するかを決める

という対応を取ることで、子のタップをどうにか優先させることができるようになりました。

Rettyは非同期処理の実装にPromiseKitを使用しているのでコードは以下のようになります。

コード

import PromiseKit

class SearchResultViewModel: NSObject, ObservableObject, Restrictable {
    // 子Viewへのタップ処理を定義するPromise
        private var reservationHandlerPromise: Promise<Void>?

    init() {
        reservationHandlerPromise = Promise<Void> { resolver in
            // 予約画面への遷移処理
        }.then {
           // 遷移処理が短すぎると親Viewの遷移処理が続行してしまうケースがあるため遅延させている
           self.createWaitPromise(second: 0.2)
        }
    }
        
        // 親Viewへのタップ処理
        func onTapRestaurantCell() {
            _ = createWaitPromise(second: 0.1) // 親の遷移開始を0.1s遅延させる
            .done { [weak self] _ in
                // 子の遷移が発火していたらそちらを優先
                    guard self?.reservationHandlerPromise?.isFulfilled ?? true else { return }
                    self?.push(RestaurantViewController())
            }
    }

    /// 指定の時間waitするPromise
    private func createWaitPromise(second: Double) -> Promise<Void> {
        Promise<Void> { resolver in
            DispatchQueue.main.asyncAfter(deadline: .now() + second) {
                resolver.fulfill(())
            }
        }
    }
}

reservationHandlerPromise の型がOptionalになっているのは、予約カレンダーが存在する店舗としない店舗があるためです。

かなりアドホックな実装となってしまいましたが、現在のところこのコードで無事カレンダーへのタップが判定できるようになっています。

LazyVGrid / LazyHGridではSelf-SizingなコンテンツをFlow Layoutで配置できない

好きなジャンルのラベルのFlow Layout配置 (アプリには無いため図はWebでの表示)

最後にLazyGrid、いわゆるFlow Layoutの実装も苦労した部分の1つになります。

iOS 14からは LazyGrid が使える一方、iOS 13では対応するAPIがSwiftUIになくUIKitの CollectionView を使わないと実装できないという課題があります。

これら2つの差分を吸収する汎用的なレイアウトを作成するだけでも、それなりの対応工数がかかりますよね。

しかしさらなる問題として LazyGrid ではSelf-Sizingな要素をFlow Layoutで配置できないという課題がありました。

例えばRettyでは店舗の種別を表すタグのようなUIを実現するため、当初 LazyHGrid を使った実装を試みていましたが、どうやっても要素が一律の長さにしかならず、長さを可変にする場合はフレキシブルな配置をあきらめなければなりませんでした。

対応: 自前でFlow Layoutを実現するViewを実装 & iOS 13向けの一部の実装をStackに変更

上記のように

  • iOS 13と14でそれぞれ CollectionView (UIKit)と LazyGrid (SwiftUI)を分岐しなければならない
  • LazyGridであってもSelf-Sizingな要素を配置することができない

という結果から、チームで検討した末 GemetryReader を使った自前のFlow Layout Viewを作成することにしました。

ちょっと複雑ですが下記が実装したViewになります。

コード

// Flow Layoutを実現するView
struct FlowLayoutContainer: View {
    private let children: [AnyView] // 内部に配置するコンテンツ
    private let lineSpacing: CGFloat // 行間幅

    init<C0: View>(mode: Mode = .vStack, lineSpacing: CGFloat, @ViewBuilder content: () -> C0) {
        children = [
            AnyView(content()),
        ]
        _totalHeight = State(initialValue: (mode == .scrollable) ? .zero : .infinity)
        self.lineSpacing = lineSpacing
    }

    init<C0: View, C1: View>(mode: Mode = .vStack, lineSpacing: CGFloat, @ViewBuilder content: () -> TupleView<(C0, C1)>) {
        children = [
            AnyView(content().value.0),
            AnyView(content().value.1),
        ]
        _totalHeight = State(initialValue: (mode == .scrollable) ? .zero : .infinity)
        self.lineSpacing = lineSpacing
    }

    // 以下必要な要素数を引数にとるinitを定義

    @State private var flowItemPlaces: FlowPlaces = FlowPlaces(items: [:])

    private func makeBody(viewWidth _: CGFloat) -> some View {
        GeometryReader { geometry in
            ZStack(alignment: .topLeading) {
                Rectangle().foregroundColor(.clear)

                ForEach(children.indices) { index in
                    let child: AnyView = children[index]
                    let place: FlowPlaces.Place? = flowItemPlaces.get(index)
                    child
               // 縦方向の整列位置を決める
                        .alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (place?.y ?? 0) })
                        // 横方向の整列位置を決める
                        .alignmentGuide(.leading, computeValue: { dimension in dimension[.leading] - (place?.x ?? 0) })
                        // 配置と大きさを決める
                        .anchorPreference(
                            key: FlowPreferencesKey.self,
                            value: .bounds,
                            transform: { anchor in
                                let rect: CGRect = geometry[anchor]
                                return FlowPreferencesKey.Preference(
                                    width: geometry.size.width,
                                    items: [index: .init(bounds: rect)]
                                )
                            }
                        )
                }
            }
            // 座標計算の結果が変わった場合に再描画する
            .onPreferenceChange(FlowPreferencesKey.self) { preferences in
                // iOS13で無限ループするので入れている
                if flowItemPlaces.items.count != preferences.items.count {
                    let new: MeasureResult = preferences.getPlaces(lineSpacing: lineSpacing)
                    flowItemPlaces = new.places
                    totalHeight = new.height
                }
            }
        }
    }
}

// Frame情報を保持するための構造体
private struct FlowPreferencesKey: PreferenceKey {
    public static var defaultValue: Preference = .init(width: .zero, items: [:])

    public static func reduce(value: inout Preference, nextValue: () -> Preference) {
        value.merge(pref: nextValue())
    }

    struct Preference: Equatable {
        var width: CGFloat
        var items: [Int: Item]

        struct Item: Equatable {
            let bounds: CGRect
        }

        init(
            width: CGFloat,
            items: [Int: Item]
        ) {
            self.width = width
            self.items = items
        }

        mutating func merge(pref: Preference) {
            width = pref.width
            items.merge(pref.items, uniquingKeysWith: { _, right in right })
        }

        func getPlaces(lineSpacing: CGFloat) -> MeasureResult {
            var resultPlace: [Int: FlowPlaces.Place] = [:]
            var currentX: CGFloat = 0
            var currentY: CGFloat = 0
            var currentLineMaxHeight: CGFloat = 0

            let maxIndex: Int? = items.keys.max()
            for (index, item) in (0 ... (maxIndex ?? 0))
                .map { index in (index, items[index]) } {
                guard let item = item else {
                    continue
                }

                if width - currentX < item.bounds.width {
                    currentY += currentLineMaxHeight + lineSpacing
                    currentX = 0
                    currentLineMaxHeight = 0
                }

                resultPlace[index] = FlowPlaces.Place(
                    x: currentX,
                    y: currentY
                )

                currentX += item.bounds.width
                currentLineMaxHeight = max(currentLineMaxHeight, item.bounds.height)
            }

            return MeasureResult(
                height: currentY + currentLineMaxHeight,
                places: .init(items: resultPlace)
            )
        }
    }
}

// 全体の高さとアイテムの配置を保持する構造体
private struct MeasureResult: Equatable {
    let height: CGFloat
    let places: FlowPlaces
}

// レイアウト上の座標を表現する構造体
private struct FlowPlaces: Equatable {
    let items: [Int: Place]

    func get(_ index: Int) -> Place? {
        items[index]
    }

    struct Place: Equatable {
        let x: CGFloat
        let y: CGFloat
    }
}

anchorPreferencealignmentGuide を使って要素の配置を決定し、 PreferencesKey 継承の型を定義して内部で要素のサイズ計算と指定幅をはみ出す場合に次の行に配置する処理を行っています。

これで差分を吸収できたかと思ったのですが、さらに何故かiOS 13では onPreferenceChange が呼ばれると「全体の高さが再計算される → onPreferenceChange が呼ばれる → 全体の高さが再計算される → ...」の無限ループが発生しクラッシュするという現象が確認されました。

そのためiOS 13向けに、配置したアイテムの数を数えて強制的にループを止めるという処理が追加されています。

今回は表示する要素数の上限やパターンが決まっており対応できましたが、それらが分からないようなケースには残念ながら対応できません。

そのような場合にはもうSwiftUIを諦め CollectionViewFlowLayout で実装せざるを得ないでしょう。

最後に

以上が今年RettyアプリチームでハマったSwiftUIでのiOS 13と14の実装ポイントでした。

これ以外にも細かい点やSwiftUI以外でのハマりポイントなどもありましたので、実際はさらに多くの対応を行っていることになります。

ところでRettyにはUser HappyというValueがあります。

これは「常にユーザーさんを第一に考えよう」という信念に基づく価値観になります。

もちろん理想的には「全OS・アプリバージョン、あらゆる利用環境のユーザーさんにアプリを提供することがUser Happyだ」と言うこともできるでしょう。

しかしそれら全てに対応していくと当然メンテナンスコストは上がりますし、必要な機能のリリースが遅れてしまうなど、本来の価値提供ができない事で「User Unhappy」な状態とも言えるかもしれません。

これに対する答えは1つではありませんし正解もありませんが、Rettyはエンジニアもそれ以外のメンバーもこういった問題を一緒になって考えていくことができるような組織であると思っています。

今回はこのタイミングでのiOS 13サポート終了を決定した次第ですが、これからも常に価値提供とサポート範囲のバランスを考えながら真のUser Happyを目指す組織になれたらと思っています。

最後までご覧いただきありがとうございました!

明日はプランナー Daito Tanakaさんの投稿となりますので、よければ引き続きご覧ください。

p.s. iOSやモバイルのお話をざっくばらんにできるカジュアル面談をmeetyで公開していますのでぜひ一緒にお話しましょう!

meety.net