RIBs アーキテクチャを採用している既存のアプリに SwiftUI を導入

タクシーアプリ「GO」の iOS アプリを開発している久利です。最近 SwiftUI を使った機能がリリースされたので、どのように導入していったかについてご紹介します。

はじめに

きっかけ

GO では2021年4月に iOS 12 のサポートを終了し、そろそろ SwitftUI を使っていきたいねとチームで話をしている状況でした。それまでは一部で UIKit で 作られた画面を Xcode Previews を使って Preview 表示してるだけでした。

Xcode Previews の導入については、メルペイさんのXcode Previewsを用いたUIKitベースのプロジェクトの開発効率化を参考にさせて頂きました。

なぜ SwiftUI を導入するのか

SwiftUI は 2019年の WWDC で発表された、Apple のプラットフォームでアプリケーションを開発するための UI フレームワークです。

個人的には感じていたメリットは以下の3点です。

GO は2020年4月に会社が統合され同年9月にリリースされたアプリで、現在は3チームが日々開発をしています。約月1のペースで大きな機能がリリースされており、UI 部分の追加/改修をするこはもちろん多く、開発しながら要件を固めていくこともあるので UI を作る/修正する速度を向上することが出来ればと考えていました。また、ユーザー、タクシー、位置等の多種多様な状態によって UI の状態を変える必要があるため、実装が複雑になってしまうこともしばしばありました。

上記を踏まえて SwiftUI で UI 部分の開発速度、保守性の向上が出来ればと思い導入の検討を行っていきました。

導入に向けて

RIBs と SwiftUI

GO の iOS アプリでは、責務を明確化するために、UberRIBsアーキテクチャ(以下RIBs) を採用しています。RIBs は Router + Interactor + Builder + (Presenter + View) を1つのコンポーネントとして扱い、独自のライフサイクルで機能を構築しています。RIBs について初めての RIBs に詳しく書かれているので気になる方は参考にしてみてください。

RIB の構成:https://github.com/uber/RIBs/wiki#parts-of-a-rib

RIBs の特徴を残しつつ、SwiftUI をどのように適用していくか検討していたところ、クックパッドさんの記事SwiftUI を活用した「レシピ」×「買い物」の新機能開発の中で、「【方針】View 層のみで SwiftUI を部分的に導入する」を拝見し、これだ!となりました。

RIB のView層は UIViewController と View になります。SwiftUI で作った View を UIHostingController で扱うようにし、UIViewController に追加します。RIB の中でも View層 のみが SwiftUI と依存関係にあり、他の層や他の RIB は SwiftUI を意識しない実装にしています。

SwiftUI.View を更新するには、先程の記事を参考に DataSource(ObservableObject) を定義し UIViewController から更新する形をとりました。

SwiftUI.View 側で発生したイベントのハンドリングは、SwiftUI.View でも PresentableListener を保持するようにし、PresentableListener を介して Interactor に伝わるようにしています。

// UIViewController の実装例

import RIBs
import RxSwift
import UIKit
import EasyPeasy
import SwiftUI

protocol SamplePresentableListener: AnyObject {
    func handleDidTapButton()
}

final class SampleViewController: UIViewController, SamplePresentable, SampleViewControllable {

    weak var listener: SamplePresentableListener?

    private let dataSource = SampleView.DataSource()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
    }

    private func setupViews() {
        let rootView = SampleView(dataSource: dataSource)
        rootView.listener = listener
        let hostingViewController = UIHostingController(rootView: rootView)
        addChild(hostingViewController)
        view.addSubview(hostingViewController.view)
        hostingViewController.didMove(toParent: self)
        hostingViewController.view.easy.layout(Edges())
    }
}

// MARK: - SamplePresentable (Interactor から呼ばれる処理)
extension SampleViewController {
    func update(buttonTitle: String {
        dataSource.buttonTitle = buttonTitle
    }

        func update(isButtonEnabled: Bool) {
                dataSource.isButtonEnabled = isButtonEnabled
        }
}
// SwiftUI.View の実装例

import SwiftUI

struct SampleView: View {
    class DataSource: ObservableObject {
        @Published var buttonTitle = ""
        @Published var isButtonEnabled = false
    }

    @ObservedObject var dataSource: DataSource
    weak var listener: SamplePresentableListener?

    var body: some View {
            Button(dataSource.buttonTitle,
               action: { listener?.handleDidTapButton() })
                    .disabled(!dataSource.isButtonEnabled)
        }
}

チームへの提案

RIBs でも SwiftUI を使えそうなことが分かったので実際にプロダクションへの導入をしていきたいですが、よりイメージを掴むために直近の案件で比較的レイアウトが簡単なものをお試しで SwiftUI 化してみました。

このお試しを実装していくにあたって、いくつか必要な Utillity があったので追加しています。

  • R.Swift を扱う

    Resource の呼び出しに R.Swift を使用しており、こちらの PR を参考に SwiftUI で扱うための Extension を追加しました。

  • Font の指定

    Font のサイズ感を生き物の相対的な大きさで表現しており、UIKit と同じ方法で Font を指定したいため SwiftUI でも指定できる ViewModifier を追加しました。

// UIKit

let titleLabel = UILabel()
titleLabel.setTitle("Hello, World!", for: .normal)
titleLabel.apply(.monkey())
// SwiftUI

struct SwiftUIView: View {
    var body: some View {
        Text("Hello, World!")
            .textStyle(.monkey())
    }
}
struct FontStyle: ViewModifier {
    var textStyle: TextStyle

    func body(content: Content) -> some View {
        content
            .font(Font(textStyle.font))
    }
}

struct FontNarrowStyle: ViewModifier {
    var textStyle: TextStyle.Narrow

    func body(content: Content) -> some View {
        content
            .font(Font(textStyle.font))
    }
}

extension View {
    func textStyle(_ textStyle: TextStyle) -> some View {
        ModifiedContent(content: self, modifier: FontStyle(textStyle: textStyle))
    }

    func textStyle(_ textStyle: TextStyle.Narrow) -> some View {
        ModifiedContent(content: self, modifier: FontNarrowStyle(textStyle: textStyle))
    }
}

プロダクションへの導入

慣れ親しんだ UIKit よりも多く工数が掛かることが想定されたので、比較的時間に余裕がある案件でレイアウトが簡単な画面から導入を行いました。

SwiftUI で作成した画面の一例

下準備をしてからプロダクションへの適用に入ったので、画面を作る上では意外とサクサク進みました。そう、作る上では・・・。SwiftUI 導入で大変だったこと関しては後述で触れます。

既存でアプリ内で共通で使いたいボタンや View を UIKit でコンポーネント化している部品があり、今回 SwiftUI で同じものを作るか、UIViewRepresentable ****でラップし SwiftUI で扱うようにするか迷いましたが、後者を採用しました。理由としては、しばらく並行運用になるので2重管理にしないためです。この辺は少し面倒なところではありました。

よかったこと

見通しが良い

新規で画面を作るときに、まずはデザインを見ながら上から適当な部品を配置していき、ある程度画面の階層が整ったら細かレイアウト処理を入れたりということがあると思います。UIKit の場合は定義部分とレイアウトの処理をいったりきたりする必要がありましたが、SwiftUI の場合は宣言的に書けることで非常に見通しが良いと感じました。

また、自分が作るときのメリットだけではなく、他の人が作ったレイアウトがどのような構成か把握しやすいためレビューがしやすいと感じました。

// プロフィール追加画面の一部

var body: some View {
    ScrollView {
        VStack {
            Text("招待コードを入力")

            Spacer()
                .frame(height: 12)

            Text("GO BUSINESS招待メールに記載された\n招待コードを入力してください")

            Spacer()
                .frame(height: 40)

            VStack {
                SUTXBTextFiled(
                    "BC-1234abcd",
                    ...})

                Spacer()
                    .frame(height: 12)

                SUShrinkButton(
                    "連携する",
                    action: {
                        listener?.didTapActivationButton()
                    })
            }

            Spacer()
        }
    }
}

コードを見るだけで何となく、全体が ScrollView で上からテキスト →テキストがあって、入力欄 → ボタンがあるのかとぱっと見で分かる

実際のレイアウト

画面の一部を View に切り出したいときに基本コピペでいける

実装を進める中で、1つの View が大きくなってしまい意味のある単位で分割したくなるときや、別の画面と View の共通化をしたくなるときがあるかと思います。今までは切り出す際に AutoLayout の制約を気にしたりする必要がありました。

SwiftUI では基本他の View との依存が実装としてないので、切り出したい部分をカットして新しいファイルにコピーするだけでいけます。

アニメーションの処理が楽

下からスライドイン/アウトする処理を書くときなどは UIView.animate(withDuration:animations:completion:) を使って、 AutoLayout の制約を変更して実現していたかと思います。

SwiftUI では スライドインする条件の if 文内に View を定義し、animation(_:) modifier と、遷移を定義するための transition(_:) modifier をアニメーションさせたい View で定義するだけで実現できます。これでスライドアウトの処理もやってくれるのかと驚きました。

// スライドインしてくる部分の実装

if !dataSource.isButtonHidden {
    ZStack {
        SUShrinkButton(presentSetting.buttonType.title,
                        buttonType: .shrinkButton,
                        isEnabled: .constant(true),
                        action: {
            listener?.handleDidTapChangeButton()
        })
            .textStyle(.monkey(.heavy))
            .titleColor(Color.white100()!)
            .frame(height: 54)
            .padding(12)
    }
    .background(Color.white100.color
                    .edgesIgnoringSafeArea(.bottom))
    .frame(maxWidth: .infinity)
    .shadow(color: Color.black100.color.opacity(0.05),
            radius: 4,
            x: 0, y: -4)
    .animation(.linear(duration: 0.15))
    .transition(.move(edge: .bottom))
}

書いていて楽しい

これが一番大切かなとも思っています。デザインを見て、頭の中でイメージした View の階層をそのままコードに落とし込めている感覚でとても楽しく書けるなと思いました。エンジニアとして新しいことに触れて、こんなことできるのかな?どの書き方がいいんだろう?と試行錯誤しながらやっていく過程もとても楽しいですね。

大変だったこと

iOS のメジャーバージョンごとで挙動が違う、iOS 13 ではマイナーバージョンで動きが違う

当初はあまり意識せず、iOS 14 or 15 で動作確認をしていましたが、レビュー時に「レイアウトが崩れてます」や、QA中に「画面が真っ白です」ということが発生し大変でした。大概は iOS 13 で発生するもので中々辛い思いをしました。以下遭遇した事象の一部。

  • 独自に追加した非表示にする ViewModifier が iOS 13 だと一番下の階層の View で効かない。

非表示の処理で if 文のネストが深くなることを避けたかったので、.hidden(isHidden: Bool) を追加したのですが、何故か iOS 13 だと処理が呼ばれず画面が真っ白になる事象に遭遇しました。しょうがないので、 ZStack を1つかまして階層を変えることで回避出来ました。

import SwiftUI

struct Hidden: ViewModifier {
    let hidden: Bool

    func body(content: Content) -> some View {
        VStack {
            if !hidden {
                content
            }
        }
    }
}

public extension View {
    func hidden(_ isHidden: Bool) -> some View {
        ModifiedContent(content: self, modifier: Hidden(hidden: isHidden))
    }
}
  • iOS 14 の List で UITableView.appearance() が効かない

遅延読み込みしたい画面があったので List を使ったんですが、iOS 13 では背景色をいじるのと、セパレーターを非表示にする modifier がなかったので、onAppear(perform:)UITableView.appearance() を変更することで対応しました。しかし、iOS 14 だけ反映されず、良い回避策が見つからなかったので、 iOS 14 以上のでは ScrollView + LazyVStack で表示するようにしました。

iOS 13 でマイナーバージョンごとに動作を見るのが現実的ではなかったので、PdM と相談し直近のアクセス状況からユーザーへの影響が最小限になる範囲でマイナーバージョンのサポートを切ることを行いました。現在 GO では iOS 13.3 以上のサポートになっています。

iOS 13 時点ではできないことが多い

ProgressView, LazyVStack/LazyHStack が iOS 14 からだったり、ScrollView で contentOffset を操作したいときに使う、ScrollViewReader と ScrollViewProxy も iOS 14 からだったりと、iOS 13 では出来ないことが結構あります。これは出来る/出来ないの調査と判断が必要になり大変でした。

今後

導入をしてみて、作る/修正するというコードを書く面では一定の開発体験の向上と慣れれば速度向上をしていけそうという事は感じられました。

しかし、iOS 13 ではまだまだ不安定な面と機能不足感が否めないので、チームとして今後は必ず SwiftUI でやっていきましょうというところまではもう少し時間が掛かるかなと思いました。

iOS 13 のサポートを切るタイミングを待ちつつ、引き続きキャッチアップをしていければと思っています。