iOS SwiftUI mobile minne engineering

minne iOS内のUIKit+MVC構成画面をSwiftUI+MVVM構成へのリプレイスした時の振り返り

iOS SwiftUI mobile minne engineering

はじめまして、minne事業部 プロダクト開発チームでiOSエンジニアをしているふみーと申します。

ご縁がありまして、2023年1月よりジョインして、早いもので4ヶ月が経とうとしています。

オンボーディング期間を経てチームへ合流してから、既存画面をSwiftUIへ置き換える対応から始まり、時にはiOSエンジニア同士でペアでコードレビューをしたり、所属チームだけではなく他チームのエンジニアメンバーとも連携したりしながら、充実した日々を過ごすことができています。

今回はその中で、私が初回で担当しました 既存画面をSwiftUIへ置き換える対応 を実施した際のポイントとその際に工夫をした点について、ご紹介ができればと思います。

現在の体験をなるべく変えずに移行する準備

今回対応をするのは、minneの作家さんが●●円以上であれば作品の送料を無料にするための設定を実施するための画面になります。実際の画面を確認してみましょう。

元々はUIKitをベースに作成されていた画面

おっ、結構シンプルな画面と機能ですね…。

これは実はすぐに終わってしまうのでは?とその時は感じていました。

アーキテクチャに関する基本骨格につきましては、過去に紹介されていたminneのアーキテクチャに関する記事等でも紹介されていたり、社内ドキュメントも豊富に用意されていた事もあったので、意外といけるだろうなと思っていたんですが、意外とそうでもない点がありました。

注意深く調べていくと出くわす仕様の落とし穴

実際にBeta版として配信されているiOSアプリで改めて仕様を確認をしてみると、これは意外にも落とし穴になりそうだと感じる部分がありました。という訳で、改めて画面全体での仕様をきちんと整理してみるとこの様になりました。

【細かな仕様(画面での振る舞い)を言語化したものはこちら💁‍♂️】

① 送料無料設定画面を表示した際の 既に送料無料金額を設定しているか? を元にした画面状態を切り替わりは下記の通りになります。

(1) 送料無料金額設定がない:
  - 画面下部に「登録するボタン」が非活性状態で表示されている。
  - 金額入力エリアが空の状態である。
(2) 送料無料金額設定がある:
  - 画面下部に「解除するボタン」・「保存するボタン」が活性状態で表示されている。
  - 金額入力エリアに既に設定されている送料無料金額が反映されている。

② テキスト入力エリアで金額を入力した場合の振る舞いは下記の通りになります。

(1) 送料無料金額設定がない:
  - 1文字以上の金額入力が発生した場合に「登録するボタン」が活性状態に変更される。
  - 金額入力を空に変更した場合に「登録するボタン」が非活性状態に変更される。
(2) 送料無料金額設定がある:
  - 1文字以上の金額入力が発生した場合に「保存するボタン」が活性状態に変更される。
  - 金額入力を空に変更した場合に「保存するボタン」が非活性状態に変更される。

③ 金額を入力した後に、新規追加・更新処理を実施した際の画面の振る舞いは下記の通りになります。

(1) 送料無料金額設定がない(新規追加):
  - 新規登録処理が成功した場合は、成功した旨のToast表示がされた後に、画面下部に「解除するボタン」・「保存するボタン」が活性状態で表示されている形に切り替わる。
  - 新規登録処理が失敗した場合は、エラーが発生旨のToast表示がされる。
(2) 送料無料金額設定がある(更新):
  - 更新処理が成功した場合は、成功した旨のToast表示がされた後に、画面下部に「解除するボタン」・「保存するボタン」が活性状態で表示されている。
  - 更新処理が失敗した場合は、エラーが発生旨のToast表示がされる。

④ すでに送料無料金額設定がある状態から、解除処理を実施した際の画面の振る舞いは下記の通りになります。

(1) 送料無料金額設定がある場合のみ:
  - 解除処理が成功した場合は、成功した旨のToast表示がされた後に、画面下部に「登録するボタン」が非活性状態で表示されている。
  - 解除処理が失敗した場合は、エラーが発生旨のToast表示がされる。

現在登録されている送料無料設定と入力した送料が異なる状態 で、設定画面に戻ろうとした場合の振る舞いは下記の通りになります。

(1) 送料無料金額設定がない:
  - 全ての商品が送料無料になる旨のアラートが表示される。
    - 「キャンセル」を選択時は、ダイアログを消すだけ。
    - 「破棄する」を選択時は、ダイアログを消した後に、入力値を保存せずに設定画面へ戻る。
(2) 送料無料金額設定がある:
  - 「キャンセル」を選択時は、ダイアログを消すだけ。
    - 「破棄する」を選択時は、ダイアログを消した後に、入力値を保存せずに設定画面へ戻る。

0を入力した状態 で、新規追加・更新処理を実施した際の画面の振る舞いは下記の通りになります。

(1) 送料無料金額設定がない:
  - 全ての商品が送料無料になる旨のアラートが表示される。
    - 「キャンセル」を選択時は、ダイアログを消すだけ。
    - 「保存する」を選択時は、ダイアログを消した後に新規追加処理を実施する。
(2) 送料無料金額設定がある:
  - 「キャンセル」を選択時は、ダイアログを消すだけ。
    - 「保存する」を選択時は、ダイアログを消した後に更新処理を実施する。

ネットワークエラー等、NotFound以外のエラーが発生した 場合は、エラーを示すToast表示後に前の画面へ戻ります。

【現状の仕様を整理する作業から立てた方針と気付き】

あれ、これは画面以上に機能が意外と複雑なのでは…ということが再確認できました。(ええ、完全に油断してしまいました。)

今回の改修対応におけるゴールは、元々はUIKit + MVCで作成された画面をなるべく体験を変えない様にSwiftUI + MVVM構成へ変更された状態となっていることなので、仕様の落とし穴といえる部分には注意を払いながら実装を進めていく必要があると思います。というわけで一網打尽と行きたい気持ちを抑えつつも、下記の様なStepを踏んで改修をする方針を取りました。

※ minneは今年で11周年を迎えた歴史あるアプリでもあるので、諸般の事情により対応が後手に回り既存のUIKit + MVCのままとなってしまった画面も存在しています。とはいえ、今後のiOSアプリにおけるアーキテクチャや実装に関する方針があるので、その点は個人的にも心強さを感じております。

Step1. 画面要素をSwiftUI化

まずは、既存のUIをStoryboard & UIViewControllerで作られている画面をSwiftUIへ置き換える場合を考えてみます。今回の改修方針のサマリーをまとめると下記の様になります。

元々はUIKit(UITableViewController)をベースに作成されていた画面

既存画面のデザインとできるだけ同じ状態をSwiftUIで再現をしてみた際に感じた点としては、慣れるまでは少し時間はかかりましたが、(画面遷移やバージョンに依る差異等といった課題はありますが)慣れてくるとパズルを解く様な感覚で作ることができた様に思います。

Step2. MVC実装箇所をMVVM実装へ置き換え

次に元々ViewController内に記載されていたAPI通信処理に関するロジックへ置き換える場合を考えてみます。minne iOSではCombine Frameworkを利用したMVVMアーキテクチャを採用しています。改修方針のサマリーをまとめると下記の様になります。

元々の実装をMVVMの形にするための前準備

この方針を踏まえて、以前はクロージャーでViewControllerへ直接記載していた処理をRequesterクラスとRepositoryクラスへという形で下記の様に分離することができました。

【Requester処理】

protocol FreeShippingSettingRequesterProtocol {
    func getFreeShippingSetting() -> NetworkPublisher<GETFreeShippingSettingEndpoint.Success>
    func postFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> NetworkPublisher<Void>
    func putFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> NetworkPublisher<Void>
    func deleteFreeShippingSetting() -> NetworkPublisher<DELETEFreeShippingSettingEndpoint.Success>
}

final class FreeShippingSettingRequester: FreeShippingSettingRequesterProtocol {

    // MARK: - Property

    private let apiClient: APIClientProtocol

    // MARK: - Initializer

    init(apiClient: APIClientProtocol = APIClient()) {
        self.apiClient = apiClient
    }

    // MARK: - Function

    func getFreeShippingSetting() -> NetworkPublisher<GETFreeShippingSettingEndpoint.Success> {
        let endpoint = GETFreeShippingSettingEndpoint()
        return apiClient.request(endpoint: endpoint)
            .mapError { error in
                NetworkError((error as? APIError) ?? .unknown)
            }
            .eraseToAnyPublisher()
    }

    func postFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> NetworkPublisher<Void> {
        let endpoint = POSTFreeShippingSettingEndpoint(freeShippingSetting: freeShippingSetting)
        return apiClient.request(endpoint: endpoint)
            .mapError(NetworkError.init)
            .eraseToAnyPublisher()
    }

    func putFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> NetworkPublisher<Void> {
        let endpoint = PUTFreeShippingSettingEndpoint(freeShippingSetting: freeShippingSetting)
        return apiClient.request(endpoint: endpoint)
            .mapError(NetworkError.init)
            .eraseToAnyPublisher()
    }

    func deleteFreeShippingSetting() -> NetworkPublisher<DELETEFreeShippingSettingEndpoint.Success> {
        let endpoint = DELETEFreeShippingSettingEndpoint()
        return apiClient.request(endpoint: endpoint)
            .mapError(NetworkError.init)
            .eraseToAnyPublisher()
    }
}

【Repository処理】

protocol FreeShippingSettingRepositoryProtocol: AutoMockable {
    var freeShippingSetting: AnyPublisher<FreeShippingSetting?, Never> { get }
    func deleteFreeShippingSetting() -> RepositoryPublisher<Void>
    func getFreeShippingSetting() -> RepositoryPublisher<FreeShippingSetting>
    func registerFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> RepositoryPublisher<Void>
    func updateFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> RepositoryPublisher<Void>
}

final class FreeShippingSettingRepository: FreeShippingSettingRepositoryProtocol {

    // MARK: - Property

    private let requester: FreeShippingSettingRequesterProtocol

    // MEMO: Repository内部の処理において中継地点となる変数
    private let _freeShippingSetting = CurrentValueSubject<FreeShippingSetting?, Never>(nil)

    // MEMO: CurrentValueSubject<FreeShippingSetting?, Never> 👉 AnyPublisher<FreeShippingSetting?, Never>へ変換
    // ※ 値変化に対応したView状態更新処理をするために利用する部分
    var freeShippingSetting: AnyPublisher<FreeShippingSetting?, Never> {
        _freeShippingSetting.eraseToAnyPublisher()
    }

    // MARK: - Initializer

    init(requester: FreeShippingSettingRequesterProtocol = FreeShippingSettingRequester()) {
        self.requester = requester
    }

    // MARK: - Function

    func deleteFreeShippingSetting() -> RepositoryPublisher<Void> {
        requester.deleteFreeShippingSetting()
            .mapError(RepositoryError.network)
            .eraseToAnyPublisher()
    }

    // MEMO: NotFoundの時は「設定されていない」、0の時は「すべての作品が送料無料」としてViewModel側で扱う
    // freeShippingSetting: AnyPublisher<FreeShippingSetting?, Never>をViewModel内の処理で利用する形にする
    func getFreeShippingSetting() -> RepositoryPublisher<FreeShippingSetting> {
        requester.getFreeShippingSetting()
            .mapError(RepositoryError.network)
            .handleEvents(receiveOutput: { [weak self] freeShippingSetting in
                self?._freeShippingSetting.send(freeShippingSetting)
            })
            .eraseToAnyPublisher()
    }

    func registerFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> RepositoryPublisher<Void> {
        requester.postFreeShippingSetting(freeShippingSetting)
            .mapError(RepositoryError.network)
            .eraseToAnyPublisher()
    }

    func updateFreeShippingSetting(_ freeShippingSetting: FreeShippingSetting) -> RepositoryPublisher<Void> {
        requester.putFreeShippingSetting(freeShippingSetting)
            .mapError(RepositoryError.network)
            .eraseToAnyPublisher()
    }
}

今回は幸いにもシンプルな追加・更新・削除機能のみで構成だったことや、元々RxSwift / RxJavaを利用した開発経験が多かった事もあり、この部分はすんなりと行けた様に思います。

Step3. SwiftUI化した画面要素とViewModelを結合する

ここまで来ればいよいよ大詰め、画面要素と画面の振る舞いを含めたViewModelクラスを結合していくことになります。SwiftUI化した画面では、配置しているView要素と @Published で定義した値または、これを利用した Computed Property を双方向バインディングをする方針を取っています。なるべくはSwiftUI製の画面に寄せていく基本方針としていますが、画面によってはUIKit製の画面の方が実装によってはその強みを活かせる場合もあるので、既存機能改修・新規追加等が必要になったタイミングで改修方針を決めながら進めていく様にしています。

画面要素とロジックを組み合わせていく際のポイント

今回は画面要素自体もSwiftUIのお陰もあり、画面に必要な要素も少なくかつシンプルに済ませる事ができました。とはいえ、既存仕様を踏襲した際に 「入力欄に0を入力した場合」「入力値変更を反映せずに前の画面へ戻る場合」 には、ユーザーへの注意を促すダイアログを表示する必要があったので、この様に条件次第でViewModel処理内に分岐が必要となる場合がありました。

基本的には、REST API or GraphQLを利用した機能が中心となるものの、この処理が実行された際にView側はどの状態にあって欲しいかという観点を常に持ち続ける事が、当たり前ではあれども大切だと改めて痛感した次第です。

Step4. この機会にViewModel側のUnitTestを整備

ようやく無事に、既存画面処理をSwiftUI+MVVM化する事ができました。画面要素自体はシンプルな形で済んだものの、注意深く調べていくと出くわす仕様の落とし穴 は気になるものです。

そして、自分への自戒を込めてUnitTestをしっかりと書こうと思いました。基本的にはCombineをベースとした処理となっているため、ViewModel側のUnitTestにおいては CombineExpectations を利用することで、@Published で定義した変数における値変化を監視可能な形としている点が特徴的な点だと感じました。(今回紹介したもの以外では combine-schedulers 等も今後は候補かもしれません。) 

テストコードで確認したいこと

例えば、送料無料金額が未設定の状態からユーザーが金額を入力し、その後に追加ボタン押下することで、サーバー側で保持する値 & ViewModel内部に保持する、現在金額の値が反映されるテストコードは下記の様にできます。変更後の値を見るというよりも、前後の変化を見るという感じのイメージを持って頂くと理解がしやすいと思います。)

// 👉 CombineExpectationsで提供されているRecorderの雛形
var currentFreeShippingSettingRecorder: Recorder<FreeShippingSetting?, Never>!

// (補足)こちらは実際のテストケースの抜粋になります。
// ※ beforeEachで対象の値を記録対象に設定する / afterEachでnilに戻す
// 「変数:sut」は、UnitTest対象であるFreeShippingSettingViewModelクラスへMockを適用したもの

describe("add or edit inputFreeShippingSettingAmount value") {
    let newFreeShippingSetting = FreeShippingSetting(amount: 8_000)
    beforeEach {
        subject = {
            sut.addOrEditButtonDidTap()
        }
    }
    context("add inputFreeShippingSettingAmount value success") {
        beforeEach {
            // MEMO: 本番のAPI処理結果を想定したMockを適用する
            repository.getFreeShippingSettingReturnValue = getFreeShippingSetting.notFound
            repository.registerFreeShippingSettingReturnValue = requestFreeShippingSetting.success

            // MEMO: 画面ロジックや画面表示のハンドリングに関連する @Published における変化監視対象とする
            currentFreeShippingSettingRecorder = sut.$currentFreeShippingSetting.record()

            // MEMO: 画面表示後にTextFieldへ8000を入力した際と同様の状態を再現する
            sut.onFirstAppear()
            sut.inputFreeShippingSettingAmount = "8000"
        }
        afterEach {
            currentFreeShippingSettingRecorder = nil
        }
        it("update currentFreeShippingSetting") {
            subject()

            // 意図した処理が実施されている事を確認できればOK👍
            let currentFreeShippingSettingRecorderResult = try self.wait(for: currentFreeShippingSettingRecorder.availableElements)
            expect(currentFreeShippingSettingRecorderResult) == [nil, newFreeShippingSetting]
        }
    }
}

紹介した事例は実際のUnitTest内のほんの一部となりますが、取りうる画面の状態が多岐かつ複雑になるものや、複数のAPIエンドポイントを利用する必要がある画面もあるので、私自身もこの点は積極的に追加する勢いで取り組んで行きたい次第です。

その他ではどんな事をしているかを少しご紹介

今回紹介したものは、既存機能リファクタリングにおける一例ですが、minneではモバイルアプリも今後とも購入者様・作家様が今よりも更に利用しやすいアプリにするために今後も新機能開発や改善対応に取り組んでいく次第です。

こちらは最近私が合間を見つけて取り組んでいる事になりますが、API開発側とモバイルアプリ開発側との連携をより上手に取るために何かできることはないか?という観点での取り組みを模索しています。

【一環として現在取り組んでいること例】

  • 以前にサーバー側の実装担当者から頂いた事項の中で、この部分は今後もハマる可能性がありそうな箇所をドキュメント化しておく
  • 自分自身でもサーバー側の処理を把握しておきたいので、可能な限りRails側のコードリーディングを試してみる

現在感じている事とこれからについて

まだまだ社歴は浅いですが、本当にメンバーの挑戦や取り組みを前向きに応援する雰囲気を強く感じる場面は多く、GMOペパボが掲げている「いるだけで成長できる環境」の風を感じています。業務内外を問わずに実施している技術的な研鑽や勉強会・カンファレンスへの登壇や参加等にも前向きな姿勢を持っているエンジニアの方々が多くいらっしゃる印象が入社前からもありましたが、この度改めてその真っ只中に身を置いてみると、平素のSlackチャンネル内での会話内や、Pepabo Tech Friday(PTF)の技術共有やLT会といった社内エンジニアイベント内においても、多彩な知見や技術トピックが展開されているので、とても刺激を頂けている実感を改めて肌で感じる事ができています。

また、オンボーディング期間中にも積極的な皆様の技術的なサポートや様々な交流機会があったために、スムーズがキャッチアップができましたので、今後の業務においても今度は自分が少しでも何かお役に立つ事ができればという気持ちと初心を忘れずに取り組んでいきたいと感じている次第です。

現在はiOSアプリ開発をメインとしていますが、1つの技術に特化してきた訳ではなく、過去にはPHPやRailsの開発経験やAndroidアプリ開発経験も多少はありましたので、今後はiOSアプリ開発を軸の中心に置きつつも、関連が深い領域についても少しずつ前向きに実践できればと思います。

とはいえ…モバイルアプリ側もやりたい事や取り組みたい課題は沢山あるものの、まだまだ人手が足りない現実もあります。ですので「この記事を読んで面白かったです」・「興味があります」・「挑戦してみたいです!」という方がいらっしゃいましたら、是非とも採用情報からご応募頂けますと幸いです。そしてカジュアル面談等も受け付けておりますので、お待ちしております!