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 .alert
Modifierを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さんが話しているので興味ある方は聴いてみてください!
最後に
delyではエンジニア、デザイナー、PdMを積極採用しています。新しい技術とかも積極的に導入しているので 少しでも興味がありましたら、お話だけでもできればと思います。