コネヒト開発者ブログ

コネヒト開発者ブログ

SwiftUIでUIを宣言的にかけるようになりコードを書くのが楽しいぞい

こんにちは、ohayoukenchanです。

今回はSwiftUIについてです。 ママリではiOS13をサポートしているので、一部iOS13をサポートする内容が含まれます。

システムを長持ちさせる力

突然ですが、コネヒトのエンジニアリング組織はTech Visionというものを掲げており、概要としては「みんなでエンジニア組織強くしていこうず。」的なことが書いてあるんですが、そのなかの3つの技術力として「システムを長持ちさせる力」を重要な技術力として推進しています。

ママリiOSアプリでも最新技術の恩恵を受け続けられるよう日々コードのアップデートを行っております。

先日、弊社ではTech Vision推進の一環で、技術目標マルシェなるものが開催されました。詳細はこちらのポストをぜひ覗いてみてください!

tech.connehito.com

技術目標マルシェは社内イベントで、各自比較的自由に気になる技術を選んで発表するのですが、自分はCoreMLとVisionを使って画像分類したり、エッジ抽出した画像をSKTextureにしてSpriteKitで遊ぶという内容を発表しました。

f:id:ohayoukenchan:20220329100313p:plain

iOS版のママリも、直近まではStoryboardやUIViewを使った開発をしていました。

Storyboardを使った開発の場合、UIの基底となるStoryboardでは実装内容はわかりません。ここからUIKitを使って実装を付け加えていくのですがUIを組み立てるのに、UITableViewCellやUIViewを継承したファイルを増やしていくことになります。

なにが行われるかわからないstoryboardの例

f:id:ohayoukenchan:20220329100422p:plain

Storyboardを使った開発がレガシーとは言い切れませんが、昨今、reactを筆頭に、宣言的UIで書かれたコードの見通しの良さ、逆にUIKit(storyboard)でUIを組み立てていくコードの見通しの悪さを考えると、サポートバージョンを考慮しつつ、これから開発するシステムに関してはSwiftUIを使って開発していこうという結論になりました。

アーキテクチャについて

SwiftUIと相性の良いライブラリにTCA(The Composable Architecture)があります。状態の集中管理したり、scopeを使うことでwatchするstateの範囲を限定できることで、無駄に再描画が発生しなかったり、テストライブラリも用意されているので非常に魅力的でしたが、TCAの懸念としては、Viewも含めてTCAに強く依存してしまうので、TCAを使わなくなった場合に引きはがずのが大変そうであることが理由でTCAの採用は見送っています。

いままでのiOS開発のライブラリの流行り廃りを考慮すると他によいものがでてきて廃れる可能性もわりと高そうという議論もしました。

余談ですがFluxベースのライブラリの有名どころにreactのreduxがあると思うのですが、reactがhooksを導入したことでreduxなしで状態管理できるようなアプローチをとってきているので状態管理をどの場所で行っていくのか今後が気になっています。

https://github.com/pointfreeco/swift-composable-architecture

ママリiOS版のリアクティブプログラミング構成

ママリiOS版は、MVVMアーキテクチャで構成されており、APIやUIからのイベント送信などにRxSwiftやRxCocoaを使用しています。SwiftUIを導入するにあたり、RxSwiftを切り離し、代わりにCombineを導入することも検討しましたが、RxSwiftへの依存が強いことと、Rxコミュニティは活発でライブラリ更新も積極的に行われていることから、無理に引き離すような選択はしていません。

新規でUIを作成する場合、状況に応じてRxSwiftで流れてきた値をCombimeのPublisherにわたしたりしています。一つのファイルにCancellableDisposeBag両方書かなくてはいけないなど、コードの見通しが若干悪くなるのですが、これは移行期という捉え方が近いとおもっていて、継続的に運用を続けていくことを視野に考えると、その機能自体なくなるかもしれないし、該当機能に大幅なアップデートがかかるかもしれません。可能性を考慮するときりがないので今は移行期としてこのような仕組みになっています。

fileprivate let disposeBag = DisposeBag()

fileprivate var cancellables: [AnyCancellable] = []

Hosting Controllerの取り扱い

ママリでは既存のアーキテクチャとの兼ね合いもあり、画面遷移は UIViewControlerに任せることにしました。UIHostingControllerを継承したクラスの rootView に SwiftUIの View を渡すようにしています。 super.init(rootView:) するときにclass内のプロパティを初期化して渡してあげたいけどSuperクラスの初期化が終わってないのにサブクラスのプロパティにアクセスするなと怒られてしまいます。

コンパイルエラーの例

class DiagnosisInterestingTopicsViewController: UIHostingController<
    DiagnosisInterestingTopicSelectView
>
{

    private var cancellables: [AnyCancellable] = []

    var viewModel: DiagnosisInterestingTopicsViewModel()

    init(interstingTopics: InterestingTopicsResponse) {
        super
            .init(
                rootView: DiagnosisInterestingTopicSelectView(
                    viewModel: viewModel // 'self' used in property access 'viewModel' before 'super.init' call
                )
            )
    }

・・・

この場合、super.init(rootView:) の前にViewModelを作っておくとコンパイルエラーを回避することが出来ます。rootViewに指定したいViewの引数とClass内部で取り扱うviewModelを一致させるためにこうしてますが、見通しは悪いですね。

コンパイルが成功する例

class DiagnosisInterestingTopicsViewController: UIHostingController<
    DiagnosisInterestingTopicSelectView
>
{

    private var cancellables: [AnyCancellable] = []

    var viewModel: DiagnosisInterestingTopicsViewModel!

    init(interstingTopics: InterestingTopicsResponse) {
        let viewModel = DiagnosisInterestingTopicsViewModel(
            interstingTopics: interstingTopics
        )

        super
            .init(
                rootView: DiagnosisInterestingTopicSelectView(
                    viewModel: viewModel
                )
            )
        self.viewModel = viewModel
    }

・・・

HostingControllerで既存UIKitの画面を表示する

通信中画面はSVProgressHUDを使用しています。SwiftUIを使った画面でも既存のUIを使用したいので、SwiftUI側でSVProgressHUDを表示すると、SwiftUIの描画領域しかオーバーレイされず、NavigationBarなどがオーバーレイの上に表示されてしまいました。そのため、SVProgressHUDUIHostingController から呼ぶようにしました。

ママリiOS版はiOS13をサポートしているため、iOS13で検証したところ通信が発生してもオーバーレイが表示されず、検証したところviewDidLoadで処理してもviewModel.$progressState に値が流れず、viewDidAppearで呼ぶことで回避できました。原因は分かってないです。

class DiagnosisRegionSelectViewController: UIHostingController<DiagnosisRegionSelectContainerView>,
    DiagnosisPageable
{

    ... 初期化処理など省略

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // iOS13だとviewDidLoadにおくと呼ばれないのでviewDidAppearで処理
        bindUI()
    }

    func bindUI() {
        viewModel.$progressState
            .receive(on: DispatchQueue.main)
            .sink { state in
                self.showProgressView(state)
            }
            .store(in: &cancellables)
    }

    func showProgressView(_ state: ProgressState) {
        switch state {
        case .asleep:
            SVProgressHUD.dismiss()
        case .connecting:
            SVProgressHUD.show(withStatus: "通信中", maskType: .black)
        }
    }

SwiftUIで宣言的にかける良さ

SwiftUI導入のメリットである宣言的UIを実現させたいので、複雑なロジックは持たず、UIの組み立てに集中させています。こちらはSwiftUIで書いた機能ですが1画面を構成するのに50行くらいのSwiftUIファイルを書くだけだったので大変見通しもよく(storyboardもcellもいらないなんて!)

感動しました

f:id:ohayoukenchan:20220329104948p:plain

struct DiagnosisRegionSelectSearchView: View {

    @ObservedObject var viewModel: DiagnosisRegionSelectViewModel

    private let maxCharacterLength = 7

    var body: some View {
        VStack(spacing: 0) {
            SearchBarRepresentable(
                text: $viewModel.zipCode,
                maxCharacterLength: maxCharacterLength,
                placeholder: "郵便番号を入力する",
                keyboardType: .numberPad
            )
            .onReceive(
                viewModel.$zipCode.dropFirst(),
                perform: { zipCode in
                    if maxCharacterLength == zipCode.count {
                        viewModel.apply(
                            .onSearchZipCode(zipCode)
                        )
                        self.closeKeyboard()
                    } else {
                        // なにもしない
                    }
                }
            )

                        ... 一部省略

            if viewModel.cities.isEmpty {
                Text("入力した郵便番号は存在しませんでした。\n再度入力をお試しください")
                    .font(.system(size: 11))
                    .foregroundColor(Color("Error"))
                    .multilineTextAlignment(.center)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(.top, 24)
            } else {
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(Array(viewModel.cities.enumerated()), id: \.offset) { index, city in
                        Text("\(city.prefectureName) \(city.cityName1) \(city.cityName2)")
                            .font(.system(size: 12))
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .contentShape(RoundedRectangle(cornerRadius: 20))
                            .onTapGesture {
                                viewModel.apply(.onChangeViewStateTapped(.confirm(city: city)))
                            }
                        if index < viewModel.cities.count - 1 {
                            Divider()
                                .padding(.leading, 15)
                        } else {
                            Divider()
                        }
                    }
                }
            }
            Spacer()
        }
        .padding(.top, statusBarHeight())
    }
}

また、ViewModelとUIで単一方向のバインディングを実現したいので、ViewModelは外から入力値を受け取ることができるように以下のprotocolに準拠させておきます。

protocol UnidirectionalDataFlowType {
    associatedtype InputType

    func apply(_ input: InputType)
}
final class DiagnosisRegionSelectViewModel: UnidirectionalDataFlowType {

    typealias InputType = Input

    private var cancellables: [AnyCancellable] = []
    private let disposeBag = DisposeBag()

        // Combine
        private let onSearchZipCodeSubject = PassthroughSubject<String, Never>()

    // MARK: Input
    enum Input {
        case onSearchZipCode(String)[f:id:ohayoukenchan:20220329104948p:plain][f:id:ohayoukenchan:20220329104948p:plain]
                ... 略
    }

    func apply(_ input: Input) {
        switch input {
                case .onSearchZipCode(let zipCode): onSearchZipCodeSubject.send(zipCode)
                ... 略
        }
    }

                ...

こうすることでviewModelの外から viewModel.apply(.onSearchZipCode(zipCode))のようにviewModelへ値を流すことができます。swiftUIの .onTapGesture に複雑な処理を書かないことでコードの見通しが良くなっていると感じています。

最後に

iOS13だとGeometryReaderをうまく初期化できなかったり、.ignoresSafeArea(.keyboard, edges: .bottom) が非対応なのでkeyboardを開いたときに画面を押し上げる処理をわざわざ自前で用意しないといけなかったり NSTextAttachment の色が変わらないなど、毎施策必ずといっていいほどiOS13への対応を行っていました。追加でiOS13向けの対応をしなければいけないことを考えると、サポートバージョンがiOS14以降になってからSwiftUIを導入したほうが無難かなという印象です。

近いうちに弊社アプリもサポートバージョンの見直しを行い、iOS14以降でサポートされている StateObject や LazyVGrid なども使えるようになり、ますます開発が楽しくなってきそうです。今後も引き続きシステムを長持ちさせる力を養っていくぞい。