Kyash iOS アプリの履歴画面を SwiftUI でリファクタリングした話

こんにちは。Kyash の Mobile チームで iOS アプリを開発している id:muijp です。 Mobile チームでは、日々の機能開発の合間に生産性向上のための取り組みを行っています。この記事では、その一環として行ったリファクタリングの事例を紹介します。

Kyash の履歴詳細画面

Kyash のアプリでは、決済などによるお金の動きが以下のように履歴として一覧できるようになっています。履歴の項目をタップすると、その取引についてより詳しい情報を見られる履歴詳細画面に遷移します。

f:id:muijp:20210721090142p:plain

履歴詳細画面の課題

Kyash では決済以外にも送金・入金・出金など様々な操作ができるので、それによって作られる履歴の種類も多く、2021年5月にリリースした v8.0.0 の時点で22種類の履歴が存在していました。Kyash では MVVM アーキテクチャを採用しており、以下の図のようにそれぞれの履歴種別について ViewModel / ViewController / Storyboard が存在している状態でした。

f:id:muijp:20210720163454p:plain

このような構成になっていることや、画面自体の歴史が長いことが原因で、履歴詳細画面は以下のような課題を抱えていました。

  • 新しい種別の履歴を追加するのが大変。種別ごとに画面の実装が分かれている構成のため、すでに他に似たような画面がある場合でも1からすべてのファイルを追加して実装をすることになり工数がかさんでしまう
  • 既存の履歴詳細画面を改修するのも大変。例えば、新規機能リリースに伴いいくつかの画面に表示項目を追加することになると全ての画面に手を入れる必要がある
  • 同じ種別の履歴でも、ユーザやその取引自体の状態によって特定の項目の表示・非表示を切り替えたり表示内容を変えたりの出し分けをする必要がある。長年の機能追加の結果このロジックが複雑になっており、改修する際に予想以上に手がかかってしまう

リファクタリング

以上の課題を解決したいという話はチーム内でずっと出ていたため、プロジェクトの合間の期間に履歴詳細画面のリファクタリングに取り掛かることにしました。

構成の変更

種別ごとに画面の実装が分かれていることに由来する問題を解消するため、以下のような構成にすることにしました。

f:id:muijp:20210720163514p:plain

種別ごとに存在していた View を、表示要件が似ている複数の種別から同じものを使うように共通化することで、新しい履歴種別の追加と既存の履歴種別の改修が簡単になることを狙っています。ViewModel / ViewController は共通化していないのですが、表示のロジックが View に移るので層として薄くなり、追加や修正の工数は今までよりも減るという想定です *1

SwiftUIの採用

通化する View は、既存の Storyboard / UIKit をそのまま使うのではなく SwiftUI に置き換えることにしました。この方針はチームで相談して決めたのですが、以下の点が判断材料になりました。

  • SwiftUI では要素の出し分けを View の中の if 文で簡単に行えるので、前述した表示の出し分けロジックの複雑化という問題の軽減が期待できる
  • 履歴詳細画面は基本的にはテキストやボタンが並んでいるシンプルなレイアウトであり、現状 UIKit でしか実現できないような複雑な表示要素が少ない
  • 今後 Apple のプラットフォームにおけるスタンダードになっていくであろう SwiftUI を使って知見を得ていきたい。Kyash では2021年の4月にサポート対象を iOS 13.1 以上に引き上げたため SwiftUI を利用できる状況にはなっていたものの、これまで実際に利用した画面はなかった。最初の一歩として、履歴詳細画面のリファクタリングはちょうどよい機会になりそうだった

具体的な実装

リファクタリング後の実装について、コードも交えてご紹介します。

コンポーネントの役割

まず、SwiftUI.View / ViewController / ViewModel の役割分担についてまとめておきます。

SwiftUI.View には画面を描画する役割だけを担わせ、 NavigationView を使っての画面遷移やタップイベントの処理、API リクエストなどの処理は行わないようにしました。これは、SwiftUI でリファクタリングした画面を、その遷移先や遷移元とは独立にリリースできるようにするためです。Kyash では一般的な MVVM に画面遷移を行うコンポーネントである Router を組み合わせたアーキテクチャを採用しており、ViewController が保持する Router で画面遷移を行っています。SwiftUI を利用する画面であっても、 SwiftUI.View を保持する ViewController と Router に遷移をまかせることで、他の画面はリファクタリング後の履歴詳細画面の実装が SwiftUI であるということを意識しなくてよくなります。

そのような構成にすると、ViewController から View に表示のためのデータを渡したり、View 内で発生したイベントを ViewController に伝えたりというやりとりが必要になります。いくつか実装方法が考えられますが、今回は cookpad さんの SwiftUI のブログ記事 で紹介されているものをそのまま参考にさせていただき、

  • 表示に使うデータは ObservableObject のクラスにまとめて View に渡す
  • タップなどイベント処理を View から ViewController に委譲するため、ViewController は自身をイベント処理を行う Delegate として View に渡す

という方式をとっています。この方法の基本的なアイデアについては元記事を参照いただければと思いますが、この記事でも Kyash の履歴詳細画面での実装を簡略化したコードで紹介します。

Visa 決済の履歴詳細画面のうち以下の点線で囲まれた、履歴の種類・金額・カテゴリーの3つの情報を表示することを考えます。

f:id:muijp:20210715164327p:plain

まず、View の実装からです。

struct TimelineDetailPaymentView: View {
    class DataSource: ObservableObject, ReactiveCompatible {
        var transactionType: String // 履歴の種類
        var formattedAmount: String // 金額
        @Published var category: Category // カテゴリー
    }

    @ObservedObject var dataSource: DataSource
    weak var delegate: TimelineDetailViewDelegate?

    var body: some View {
        VStack(spacing: 0) {
            TimelineDetailAmountRow(transactionType: dataSource.transactionType, formattedAmount: dataSource.formattedAmount)

            Divider()

            TimelineDetailCategoryRow(category: category)
                .onTapGesture {
                    delegate?.categoryDidTap()
                }
        }
    }
}

body の中身ではそれぞれ金額 / カテゴリの列を表示するための子 View を呼んでいます。表示に必要なデータは ViewController から渡される DataSource にまとまっており、履歴の種類と金額は画面が表示されて以降に値が変化することがないので通常の stored property に、カテゴリーについてはこの画面からユーザが手動で変更することができるので @Published にしています。 カテゴリーの変更は、カテゴリーの表示をタップすることで遷移する変更用の画面から行うようになっており、これを onTapGesture 内の処理で行っています。処理は ViewController から渡される delegate にまかされており、その具体的な内容を SwiftUI.View が意識することはありません。

次に View とやりとりをする ViewController の実装を見たいと思いますが、その前に ViewController にデータを渡す ViewModel の実装を軽く紹介します。

final class TimelineDetailPaymentVisaViewModel {
    var timelineItem: TimelineItemPaymentVisa

    var transactionType: String {
        switch timelineItem.status {
        case .payment:
            return "購入"
        case .refund:
            return "返金"
        }
    }

    var formattedAmount: String {
        formatAmount(timelineItem.amount)
    }

    var category: BehaviorRelay<Category> = .init(value: timelineItem.category)

    func onCategoryChanged(category: Category) {
        self.category.accept(category)
    }
}

ViewModel は、履歴要素のモデルである timelineItem を保持し、これを表示に適した形に変換するという一般的な ViewModel の責務を果たしています。View の DataSource と対応して、画面内で値が変化しない履歴の種類と金額は computed property 、ユーザ操作によって変化することがあるカテゴリーは RxSwift の BehaviorRelay になっています。Kyash の iOS アプリでは既存のロジックは API リクエストも含めてほとんどすべて RxSwift で書かれていることもあり、ViewModel 層と Model 層はこれまで通り RxSwift を利用しています。もちろん、SwiftUI.View では Combine を利用するので ViewController で RxSwift と Combine の変換を行います。

最後に、ここまでに紹介した ViewModel と SwiftUI.View を仲介する ViewController の実装です。

final class TimelineDetailPaymentVisaViewController: UIViewController {
    private var disposeBag = DisposeBag()

    var viewModel: TimelineDetailPaymentVisaViewModel
    var router: TimelineDetailRouter

    private lazy var dataSource: TimelineDetailPaymentView.DataSource = .init(
        transactionType: viewModel.transactionType,
        formattedAmount: viewModel.formattedAmount,
        category: viewModel.category.value,
    )

    override func viewDidLoad() {
        super.viewDidLoad()

        setupView()
        subscribeViewModel()
    }

    private func setupView() {
        let rootView = TimelineDetailPaymentView(dataSource: dataSource, delegate: self)
        hostSwiftUIView(rootView: rootView)
    }

    private func subscribeViewModel() {
        viewModel
            .category
            .asDriver()
            .drive(dataSource.rx[\.category])
            .disposed(by: disposeBag)
    }
}

extension TimelineDetailPaymentViewController: TimelineDetailDelegate {
    func categoryDidTap() {
        router.pushCategoryChangeView(currentCategory: viewModel.category.value, onChanged: viewModel.onCategoryChanged(category:))
    }
}
  • ViewModel から表示に必要なデータを受け取って DataSource につめる
  • View から発火されるイベント処理を行うため自身を TimelineDetailDelegate として実装する

の2つを行い、それぞれを View に渡しています。 hostSwiftUIView という便利メソッドを UIViewController の extension として追加しており、中で UIHostingViewController を生成して SwiftUI の View を画面全体に表示しています。

View の中で呼ばれていたカテゴリー変更処理の実装である categoryDidTap では、画面遷移を行う Router のメソッドを呼んでいます。遷移先の画面でカテゴリー変更が行われた際に呼ばれる callback では変更後のカテゴリを ViewModel の onCategoryChanged に渡しています。 onCategoryChanged の中身では BehaviorRelay に値を accept し、その値が ViewControllerで dataSource@Published プロパティに代入されることで View に表示されるカテゴリも更新されるという仕組みです。

表示項目の出し分け

前述したように、履歴詳細画面ではユーザや取引の状態によって特定の項目の表示・非表示の切り替えを行う必要があり、そのロジックが複雑化しているという問題がありました。簡単な例を挙げると、同じ Visa 決済の履歴でも外貨での決済では為替の「適用レート」を表示しますが、日本円での決済ではこの項目は表示しません。

f:id:muijp:20210715164920p:plain

この例は Visa 決済という1つの種別内での表示・非表示切り替えですが、複数の種別の履歴表示を1つの SwiftUI.View に共通化することで、ある種別では必ず表示するが別の種別では表示しない項目というのも出てくるため、出し分けをする項目は多くなります。

このような表示項目の出し分けは、以下のように DataSource のプロパティを optional にし、View の中で if let で unwrap して値が存在したときのみ表示することで行っています。UIKit でも UIStackViewUITableView などの要素の表示・非表示を条件によって切り替えることがありますが、SwiftUI ではこのような処理がより直感的かつに簡潔に書くことができると感じました。

// SwiftUI.View
struct TimelineDetailPaymentView: View {
    class DataSource: ObservableObject, ReactiveCompatible {
        var formattedExchangeRate: String? = nil // 適用レート。日本円の決済ではこの項目は存在しないので optional
    }

    var body: some View {
        VStack(spacing: 0) {
            // ...
            if let formattedExchangeRate = dataSource.formattedExchangeRate {
                TimelineDetailExchangeRateRow(value: formattedExchangeRate)
            }
    }
}

// ViewModel
final class TimelineDetailPaymentVisaViewModel {
    var timelineItem: TimelineItemPaymentVisa
    // ...

    var formattedExchangeRate: String? {
        // 外貨の決済では貨幣に関連する情報が入った `currency` プロパティが non-nil になる
        guard let currency = timelineItem.currency {
            return nil
        }
        return formatExchangeRate(currency)
    }
}

// ViewController
final class TimelineDetailPaymentVisaViewController: UIViewController {
    private lazy var dataSource: TimelineDetailPaymentView.DataSource = .init(
        // ...
        formattedExchangeRate: viewModel.formattedExchangeRate
    )
}

ここで、適用レートを表す DataSource のプロパティである formattedExchangeRate はデフォルト値に nil を指定していることで、為替レートが関係ない種別ではこのプロパティのことを気にしなくてよいようにしています。例えば、Visa 決済では外貨での決済ができるので為替レートを表示する場合もしない場合もありますが、Kyash で行える別の決済手段である QUICPay は国内の対面決済なので外貨での決済が行われることはなく、為替レートを表示することがありません。引数のデフォルト値を nil にしておくことで、QUICPay 決済の履歴詳細の ViewModel / ViewController は為替レートに関する記述を何もしなくても SwiftUI.View には formattedExchangeRatenil で渡されることになり、適用レートは表示されないことになります。

もちろん明示的に nil を指定することのメリットもあるとは思うのですが、多数の履歴種別を共通化するとこのような出し分けを行う項目が増えて記述が冗長になってしまうことを鑑みてこのような選択をしています。

SwiftUI-Introspect の導入

今回のリファクタリングは基本的には SwiftUI で問題なく実装できたのですが、コードから ScrollView をスクロールしなければいけない箇所 *2 があり、その実装は標準の方法では難しかったです。UIKit の UIScrollView では可能だったコードからスクロール位置を制御するという処理が、SwiftUI の ScrollView ではできないためです。iOS 14 でスクロール位置を操作するための ScrollViewReader というコンポーネントが追加されたため、iOS 14 未満をサポートしない場合は SwiftUI 標準のコンポーネントのみでスクロール位置の制御が可能なのですが、Kyash では iOS 13 もサポートしているという事情があります。

この問題に関して今回は、 SwiftUI の裏で使われている UIKit のコンポーネントを直接触ることができる SwiftUI Introspect というライブラリを導入し、 ScrollView の裏の UIScrollView に対してスクロール位置を設定することで解決しました。

ライブラリの性質上、新しい iOS バージョンで SwiftUI の実装が変わることにより、クラッシュはしないものの想定通りの動作をしなくなる可能性があることには注意する必要があります。今回問題になったスクロール処理については iOS 13 では Introspect を、iOS 14 以降では標準の ScrollViewReader を使うというように処理を分岐することでこのリスクを回避しています。

SwiftUI に移行しなかった処理

コンポーネントの役割分担でご説明したように、基本的にすべての表示は SwiftUI.View で行っていますが、特定の種別の履歴詳細にのみ存在する表示で、かつ UIKit の方が書きやすいものについては ViewController に表示ロジックを残したものもありました。その一例として、以下のように送金の履歴詳細画面である条件を満たしたときに画面全体にアニメーションを被せる処理があります。

f:id:muijp:20210715164358g:plain

この処理に関しては、もともと UIView を使ってアニメーションさせるライブラリを使っていたことと、送金の履歴でしか発火せず他の種別との共通化の必要がないことから、SwiftUI に移行するよりも既存の処理を維持した方がメリットが大きいと判断して表面的なリファクタリングはしつつそのまま残しています。他にも、迷った末に同様の判断をした処理があります。

余談ですが、Kyash の履歴画面にはこのようなアニメーションがいくつか隠れているのでぜひ探してみてください。

リリース方針

前述のように、SwiftUI でリファクタリングした履歴詳細画面の画面遷移は従来通り UIViewController に任せているので、他の画面との兼ね合いを気にせず種別ごとに独立してリリースできるようになっていました。そのため、比較的シンプルな種別の履歴詳細画面からリファクタリングをはじめ、完了したものから順にリリースしていく方針で進めています。現在までのリリースでほとんどの種別についてはリファクタリング後の画面に置き換わっており、残った種別についてもリリース待ちの状態になっています。

実は、Kyash の Android アプリでも iOS と同様の履歴詳細画面の課題を抱えており、ほぼ同じタイミングでリファクタリングに取り掛かっていたためこの辺りの進め方は Android チームと相談していました。また、iOS が SwiftUI を採用したのに対して AndroidJetpack Compose でリファクタリングを進めていたので実装方針についても似た部分があり、Android エンジニアの ussy が以下のように社内ドキュメントにまとめてくれた実装案を iOS 側でも参考にしたりしていました。

f:id:muijp:20210720162030p:plain

やってみての感想

履歴詳細の実装の生産性向上

今回のリファクタリングの効果はどうだったかという話ですが、まだリファクタリングが完了してから大きな改修が入っていないので完全にはわからないというのが正直なところです。ただ、リファクタリング後の構成で新しい履歴種別を1つ追加したのと、リファクタリング中に画面を作り直していた感触からすると、種別追加も改修も以前と比べてかなり簡単になったと感じています。 これは、当初の目的通り View が共通化されたりコードが整理されたりしたことによって生産性が向上したというのと、そもそも Kyash の履歴詳細のような複雑な表示要素がない画面では Storyboard / UIKit よりも SwiftUI の方が生産性が高いことがあるという2つの側面があると考えています。

また、付随的な効果として、View 周りの共通化によりコード量の削減ができました。今回のリファクタリングでのコード量の変化は

  • 追加: +6363
  • 削除: -15123

で、削除された行のうち storyboard が 5794 行でした。

SwiftUI の導入

今回のリファクタリングをきっかけに Kyash の iOS アプリに SwiftUI を導入できたことは、今後の開発のためにもよかったと思っています。実際に、リファクタリングが完了して以降、新しい画面を追加する際にデザインや要件によっては SwiftUI で実装したり、少し手が空いた時に既存の画面を SwiftUI に置き換えたりしています。

まとめ

この記事では、最近 Kyash の iOS チームで取り組んだ履歴詳細画面のリファクタリングについてご紹介しました。

新しい履歴種別が追加されたり履歴の仕様に改修が入ったりするたびに工数がかさんでいたという問題を、View を共通化することで改善しました。また、このリファクタリングをきっかけに SwiftUI を導入したので、今後知見をためつつ利用を広げていければよいなと思っています。


Kyash ではエンジニアを募集しています。カジュアル面談もやっていますので、もし興味がありましたらぜひ以下のリンクをご確認ください。

open.talentio.com

docs.google.com

*1:構成を検討する過程で ViewModel / ViewController も共通化してしまうことも考えたのですが、明確に共通化した方が良さそうだった View と比較すると共通化のメリットとデメリットどちらが上回るか正直やってみないとわからないと感じたのでいったん共通化しない方針で進めることにしました

*2:送金の履歴詳細画面ではユーザ間で送り合ったメッセージの一覧を表示するのですが、このメッセージが多くて画面内に収まらないときに自動的にスクロールします