dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

SwiftUIをクラシルに導入した話

f:id:yochidrop:20211205171622p:plain

SwiftUIをクラシルに導入した話

こんにちは。これはdely アドベントカレンダー10日目の記事となります。

今年も残りあと少しとなりました。クラシル開発部でiOSエンジニアをしている@yochidrosです。 前回はharry(@gappy50)さんのクラシルでのSnowflakeデータパイプラインのお話&活用Tips でした。
日頃redashを利用して分析をしている中でより便利になっていっているなと感じました!tipsも今後使っていこうと思います!

今日はSwiftUIをクラシルに導入した話を書いていきたいと思います。

背景

  • 2019年に発表されたSwiftUIですが、iOSのバージョンが13.0以降でないと利用できない等制約があったためなかなかクラシルも導入できずにいました。 しかし、今年のクラシル自体のiOSのサポートバージョンを13.2に引き上げたことによってSwiftUIやCombineなどが特に制限を何もしなくても利用できるようになりました。

  • クラシル自体はUIKit x RxSwiftベースで構成されていてまだSwiftUIでの実装がありませんでした。

  • 自分がちょうど新しく機能を開発するタイミングとサポートバージョン13.2にあげるタイミングが重なったのでチャレンジ的な意味で1機能を全てSwiftUIを使って開発しました。

  • 自分も含め、iOSチームメンバーはSwiftUIについてまだそこまで経験・知見がないため、これらを全てSwiftUI x Combineに書き換えるよりかは新機能開発においてSwiftUI x Combineを導入していくという方針で進めました。

やったこと

今回新機能を開発したのはログイン・新規登録機能です。

ログイン画面 新規登録画面

すでにクラシルではログイン・新規登録画面は存在していましたが、webview経由でアカウントのログイン・新規登録をしていました。今回、SNSログインを導入するに当たってWebviewだった画面を全てNativeに置き換えるようにしました。 ...( 画面自体はシンプルな実装なのでSwiftUIでもできると思い、チャレンジした次第です。)

View

機能自体の画面数は10画面ほどあります。基本的に1画面につき、1 view という構成です。 UI自体似ているところが多く一つのviewで内部のviewを切り替えたりすることもできましたが、コードの肥大化等後々のメンテナンスコストが高くなるため今回は1画面ごとに実装するようにしました。

  • ログイン画面ですと大まかに以下のようなコードになっています。
 struct LoginView: View {
     var body: some View {
         VStack {
             Image("logo")
             Text("ログインする")
             
             ForEach(ProviderType.allCases, id: \.self) { providerType in 
                 Button(action: { 
                 // tap provider button 
                 },label: { 
                     ProviderButtonLabel(type: providerType)
                 })
             }
             HStack(spacing: 0) {
                Text("クラシルは初めてですか?")
                Button(action: { 
                // tap create account button 
                }, label: {
                    Text("アカウント作成")
                })
             }
         }
     }
 }

UIKitだとオートレイアウトやUIStackViewを駆使して記述しなければいけないところがSwiftUIだとこんなにシンプルにかけます。先ほど1画面につき1viewと言いましたが、中のボタンなどで他の画面にも利用するUIなどは一つのUIコンポーネントとして定義しています。(ex. ProviderButtonLabel)

  • 本来であれば、bodyの中に全て記述するのではなく細かくviewを切り出してやる方が複雑なレイアウトなどになった時に見通しがよくなると思います。
var body: some View {
    VStack {
        headerView
        providerButtonListView
        footerView
    }
}

private var headerView: some View {
    Image()
    Text()
}

private var providerButtonListView: some View {
    ForEach(ProviderType.allCases, id: \.self) { providerType in
        Button(action: {
            // tap provider button
        },label: {
            ProviderButtonLabel(type: providerType)
        })
    }
}

private var footerView: some View {
    HStack(spacing: 0) {
        Text("クラシルは初めてですか?")
        Button(action: {
            // tap create account button
        }, label: {
            Text("アカウント作成")
        })
    }
}

Viewを作るときに苦労したこと

  • 最新のAPIは気軽に利用できない
    • LazyVStack, AsyncImageなど iOS 14 , 15 でしか利用できないものは今回入れていません。iOS 13では利用できないので何かしら独自の機構を用意して#if availeble(iOS 14.0, *)などをしなければならないのでコストがかかるためです。
  • SwiftUIのコンポーネントだと足りないところが出てきた
    • 上記のAPIにも関連しますが、iOS13を視野に入れて実装しようとするといくつか機能が不十分なところがありました。
    • 例えば メールアドレスやパスワードの入力にTextFieldを利用して画面遷移時にTextFieldにフォーカスを合わせようとしましたがiOS15以上であれば focused(_):を利用すればできそうですが、iOS13以上となるとそれができないです。TextField自体、UITextFieldをラップしているのでやろうとすれば内部のsubviewの中のUITextFieldを見つけてゴニョゴニョするなどできるのですが、あまり良い方法だと思いません。
    • ViewInspecterを使えばより効率的にできると思いますが、今回は導入せず、UITextFieldをラップしたViewを自前で用意しました。

ViewModel

  • クラシルのアーキテクチャーはMVVMを採用しているのでSwiftUIでも同じように実装しました。UIKitでは書き方を揃えるために自前のframeworkを作ってそれを利用してMVVMを構築していました。自前のframeworkはSwiftUIには対応していないので新規にprotocolだけ作ってそれに沿って開発できるようにしました。
public protocol SwiftUIViewModelProtocol {
    associatedtype State
    associatedtype Action
    associatedtype Dependency
    
    init(state: State, dependency: Dependency)
    func send(_ action: Action)
}

基本的には ViewModelはStateとActionを持ち、 initとfunc send(_ action: Action)以外関数を持たないようにしています。 もっとシンプルにしようとすれば

public protocol ViewModelProtocol {
    associatedtype State
    associatedtype Action
    
    func send(_ action: Action)
}

これだけ定義すればUIKitとSwiftUI両方で使えるインターフェースになると思います。

実際上記のSwiftUIViewModelProtocolインターフェースを用いて定義したクラスをみてみます。

import ViewModelInterfaces
import Combine

protocol LoginRepositoryProtocol {
    func login(providerType: ProviderType) -> AnyPublisher<AuthResponse, Error>
}

final class LoginViewModel: ObservableObject, SwiftUIViewModelProtocol {
    @Published private(set) var state: State
    private let actionSubject = PassthroughSubject<Action, Never>()

    var cancellables = Set<AnyCancellable>()

    init(state: State, dependency: Dependency) {
        self.state = state

        var tmpProviderType: ProviderType?

        actionSubject
         .sink(receiveValue: { [weak self] actionType in
            switch actionType {
            case let .tapProviderButton(providerType):
                // do login
            case let .tapCancelButton:
                // do dismiss view
            case .onAppear:
                self?.state.providerTypes = [
                    .email,
                    .apple,
                    ...
                ]
            }
        })
            .store(in: &cancellables)
    }
}

extension LoginViewModel {
    struct State {
        fileprivate(set) var providerTypes: [ProviderType]
    }

    struct Dependency {
        let loginRepository: LoginRepositoryProtocol
    }

}

// MARK: Action

extension LoginViewModel {
    enum Action {
        case tapProviderButton(ProviderType)
        case tapCreateAccountButton
        case onAppear
    }

    func send(_ action: Action) {
        actionSubject.send(action)
    }
}

ViewModel自体はStateとcancellablesと内部でアクションをハンドリングするSubjectだけを保持してその他は持っていません。Protocolに準拠するとこのようになり、誰もが似たよう実装になるので書き方がバラバラにならず統一すると思います。 ただactionSubjectをsinkしたときのクロージャー内で処理を書くとその内部でさらにsinkしたりしてネストが深くなるので見通しが悪くなると思います。なので自分はそれを回避するためにViewModel内でのみ使うActionを別途定義してそれを経由して状態の更新やAPIの通信を走らせたりしてます。

import Combine
import ViewModelInterfaces

protocol LoginRepositoryProtocol {
    func login(providerType: ProviderType) -> AnyPublisher<AuthResponse, Error>
}

final class LoginViewModel: ObservableObject, SwiftUIViewModelProtocol {
    @Published private(set) var state: State
    private let actionSubject = PassthroughSubject<Action, Never>()

    var cancellables = Set<AnyCancellable>()

    private struct InnerAction {
        let loginAction = PassthroughSubject<ProviderType, Never>()
    }

    init(state: State, dependency: Dependency) {
        self.state = state

        let innerAction = InnerAction()

        innerAction.loginAction
            .flatMap {
                dependency.login(providerType: $0)
                    .handleEvents(receiveCompletion: { completion in
                        switch completion {
                        case let .failure(error):
                            // TODO: show error alert
                            break
                        case .finished:
                            break
                        }
                    })
                    .map(Optional.init)
                    .replaceError(with: nil)
            }
            .sink(receiveValue: { responseOptional in
                guard let response = responseOptional else {
                    return
                }
                // Success
            })
            .store(in: &cancellables)

        actionSubject
            .sink(receiveValue: { [weak self] actionType in
                switch actionType {
                case let .tapProviderButton(providerType):
                    innerAction.loginAction.send(providerType)
                case let .tapCancelButton:
                    // do dismiss view
                }
            }).store(in: &cancellables)
    }
}

以下省略...

ここで注意なのがRxSwiftでも同じですが、 flatMapをするときにErrorを失敗時に返すようにするとそのaction自体の川はerrorを受け取って川を閉じてしまい、それ以降値は流れてこないです。なのでerrorを流さないようにerrorを置き換えてNever型にしてます。

  .map(Optional.init)
  .replaceError(with: nil)

何かしらerrorが起きてアラート等を出したい時はhandleEventsを介して表示するようにしてます。 sink時に受け取る値は必ずoptionalになってしまいますが、unwrapできるということは成功したと受け取れるのでわかりやすいかもしれません。 別の方法としてAnyPublisher<Result<T, Error>, Never>を返すようにすればOptional型に変換しなくても川を閉ざすことなく処理を続行できます。

protocol LoginRepository {
    func login() -> AnyPublisher<AuthResponse, Error>, Never>
}

final class LoginViewModel: ObservableObject, SwiftUIViewModelProtocol {
    ...

    init(...) {
        innerAction.loginAction
            .flatMap {
                dependency.login(providerType: $0)
            }
            .sink(receiveValue: { result in
                switch result {
                case let .success(response):
                    // success
                case let .error(error):
                    // error
                }
            })
            .store(in: &cancellables)

        ...
    }
}
  • View側でViewModelを使う時はこうなります。
struct LoginView: View {
    @ObservedObject private var viewModel: LoginViewModel

    init(viewModel: ViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        VStack {
            Image("logo")
            Text("ログインする")

            ForEach(viewModel.state.providerTypes, id: \.self) { providerType in
                Button(action: {
                    viewModel.send(.tapProviderButton(providerType))
                }, label: {
                    ProviderButtonLabel(type: providerType)
                })
            }
            HStack(spacing: 0) {
                Text("クラシルは初めてですか?")
                Button(action: {
                    viewModel.send(.tapCreateAccountButton)
                }, label: {
                    Text("アカウント作成")
                })
            }
        }
    }
}

@StateObjectはiOS14からなので今回は@ObservedObjectを使用しています。

Viewはstateとsendしか使っていないことがみてわかると思います。

  • View <--- ViewModel.state
  • View ---> ViewModel.send

と単一方向での処理が簡潔に書けると思います。

View側ではLoginViewModelを直接持っていますが、今後protocolで持つようにはすればより疎結合になったテストがしやすくなると思います。

苦労したこと(ViewModel編)

  • RxSwift --> Combineの変換
    • 既存の実装だとRxSwiftで書いているのでCombineではそのまま利用できないです。なので何かしら変換する処理を書かないといけないのが大変でした。

    • OSSにRxCombineというRxSwiftとCombine間を双方向変換できるライブラリがあったのですが、当時RxSwiftをCarthageからCocoapodsに移行している段階で導入を断念しました。Carthage経由だとxcode13でビルドするのに一工夫必要だったりして大変だった記憶があります。。。🙏

    • 今回は既存のAPI通信をする時に返り値がRxSwift.SingleだったのでそこだけCombineに変換するようにしました。

import RxSwift

enum APISession {
    static func send<Response: Decodable>(request _: URLRequest) -> Single<Response> {
        .create(subscribe: { _ in
             ...
            Disposables.create()
        })
    }
}
import Combine
import RxSwift

struct Repository {
    func login() -> AnyPublisher<Response, Error> {
        var disposable: Disposable?
        return Deferred {
            Future<Response, Error>.init { promise in
                disposable = APISession
                    .send(request: .init(url: URL()))
                    .subscribe(
                        onSuccess: { response in
                            promise(.success(response))
                        },
                        onFailure: { error in
                            promise(.failure(error))
                        }
                    )
            }
            .handleEvents(receiveCancel: {
                disposable?.dispose()
            })
        }.eraseToAnyPublisher()
    }
}

現在はRxCombineを導入してもっと簡潔に書けるようになりました。

Routing

画面遷移などのrouting処理はSwiftUIだとNavigation, NavigationLink.sheet .alertModifierをview内で利用すれば遷移できるのですが、UIKitベースのViewと組み合わせると相性がよくありません。 さらにNavigationBarを画面ごとにカスタマイズするときにiOS13以上となると UINavigationBar.appearence()でしかできないのでこの画面だけ背景を変えたい時でもしっかりとライフサイクルを考えながら設定しないと全体のNavigationBarに影響が出るので注意が必要です。 なので今回はUIKitベースで遷移できるようにUIHostingControllerを利用してます。

enum LoginBuidler {
    static func build() -> UIViewController {
        let vm = LoginViewModel()
        let vc = UIHostingController(rootView: LoginView(viewModel: vm))
        vm.routePublisher
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { [weak vc] routeType in
                switch routeType {
                case let .presentAlert(message)
                     let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
                     alert.addAction(UIAlertAction(title: "cancel", style: .cancel, handler: nil))
                     vc?.present(alert, animated: true, completion: nil)
                     case .dismiss:
                    vc?.dismiss(animated: true, completion: nil)
                }
            })
            .store(in: &vm.cancellables)
        return vc
    }
}

enum LoginRouteType {
    case dismiss
    case presentAlert(String)
}

final class LoginViewModel {
    let routePublisher: AnyPublisher<LoginRouteType, Never>
    var cancellables = Set<AnyCancellable>()

    init(...) {
        ...
        let routeSubject = PassthroughSubject<LoginRouteType, Never>()
        routePublisher = routeSubject.eraseAnyPublisher()

            ...
            routeSubject.send(.dismiss)
        ...
    }
}

まとめ

いかがでしたでしょうか? SwiftUIでのプロダクション開発について全体的に説明をしたので少しボリュームが大きくなってしまいました。 現在のクラシルだとログイン・新規登録画面や設定画面は全てSwiftUIで実装されています! 今回、初めてプロダクションでSwiftUIを使いましたが、まだまだ複雑なUIの実装をするとなるとUIKitで実装した方が良いところもあると感じました。

最後まで読んでくださった皆様に少しでも役に立てたら幸いです!

機能開発以外にもクラシルのiOSではリアーキプロジェクトも並行して動いています。

詳しくはdely Tech Talkで@inamiyさんと@RyogaBarbieさんが話しているので興味ある方は聴いてみてください!

open.spotify.com

最後に

delyではエンジニア、デザイナー、PdMを積極採用しています。新しい技術とかも積極的に導入しているので 少しでも興味がありましたら、お話だけでもできればと思います。

dely.jp