こんにちは。iOSアプリ開発をしている吉岡(@rikusouda)です。リモートワークが続いたためか家を買ったり車を買ったり散財が激しいこの頃です。
業務では、2021年4月に新規リリースした「auサービスToday」というアプリの開発をしています。
概要
Gunosy社は社名と同じ名前のアプリ「グノシー」のイメージが強いかも知れませんが、実は他にもいくつかのアプリをだしています。
そして2021年の4月にも「auサービスToday」という新しいアプリをリリースしました。
このアプリはユーザー視点では既存のサービスを引き継ぐ形でのリリースではあったものの、開発視点では新規アプリとして1からの開発でした。今回はauサービスTodayのiOSアプリを開発するにあたって、どのようなアーキテクチャ、ライブラリの選定を行ったか、SwiftUIを部分利用した例など開発のエピソードを紹介します。
この記事には下記の内容が含まれます。
- 2021年の新規iOSアプリでのアーキテクチャ、ライブラリ選定の実例
- RealmとRxSwiftを使うか使わないかの判断
- UIKitメインアプリでのSwiftUI部分利用の実例
アプリに求められていた要件
このアプリは既存の「auサービスTOP」というauのポータルアプリを置き換える形で「auサービスToday」としてリリースされました。機能としてはau関連の各種情報(ポイントや残りパケットなど)の表示や、各種auサービスへのハブ的機能、そしてニュースを読む機能があります。
Gunosyでは「ニュースパス」というauのニュースアプリも開発しており、そちらのアプリと利用ユーザー層が近いと想定されたためニュースパスのノウハウを流用して開発することとなりました。
方針ぎめ
要件が見えてきたところで、どのように作るのか方針を決めていきます。
ソースコード流用か新規開発か
今回開発するアプリ要件の多くの部分が、既存アプリの「ニュースパス」と重なっていました。そうなると「ニュースパスをベースにちょっと改造すればよいのでは」という考えも頭をよぎります。
しかし、今回はそのようにせず「ニュースパスのコードは参考にしつつも新規開発」としました。
その理由は下記の通りです。
- 既存コードには「今思えば適切ではなかったな」と思うようなコードが見うけられる
- まるごと移植だとauサービスTodayには不要なコードまで持ってきてしまう可能性がある
- 新規アプリとして作ってもピンポイントでのコード流用や、知識の流用は可能
設計方針
auサービスTodayを開発するチームは、既存の「ニュースパス」の開発するチームを中心として規模拡大したチームで、auサービスTodayだけではなくニュースパスの開発も行うこととなります。そのためニュースパスでも採用している「MVVM」をベースとした設計をしました。MVVMは基本的に「ViewとViewModelのやり取り」に関する取り決めなのでその他部分の取り決めも必要です。その他の部分もニュースパスをベースに少し島風にアレンジした結果「VIPER」のような設計となりました。
一言でいうと「VIPERのPresenter部分をViewModelに置き換えた感じ」の設計です。
ニュースパスの設計がベースとなっているので、両方のアプリをメンテする開発者にとって抵抗が少ない設計となりました。
UIフレームワークは、iOSのターゲットバージョンが未定だったこととニュースパスの流用が視野に入るため「SwiftUIメイン」とはせず「UIKitメイン」で作り始めました。
利用ライブラリの選定
ニュースパスでは「Alamofire」、「Realm」、「RxSwift」のような外部ライブラリをいくつか使っていました。auサービスTodayではこれらをどうするのか方針を決めました。
外部ライブラリは開発を便利にする一方で下記のようなデメリットも存在するため、利用すべきか再考しました。
- ビルド時間が伸びることがある
- コードが外部ライブラリ依存となる(ロックインされる)
- Xcodeやライブラリのバージョンアップ時にビルドが通らなくなるなどのトラブルの可能性がある
- ライブラリのメンテが止まってしまう可能性がある
Alamofire
結論としてはAlamofireを使いませんでした。
AlamofireはSwiftでのHTTP通信を便利にするためのライブラリです。
ニュースパスではAPI通信などの部分に利用していました。しかし標準のURLSessionでできる範囲の用途にしか使っておらず、さほど恩恵を受けていない状態でした。
そのため、Alamofireは使わずURLSessionにてAPI通信などを行うこととしました。
Realm
結論としてはRealmを使いませんでした。
Realmはモバイルアプリ向けのローカルDBで、ローカルでDB的にデータを扱うときに使われるライブラリです。
iOSの標準SDKに組み込まれているCoreDataではなくRealmを使う主なメリットとしては「使うのが簡単」や「実行速度が早いらしい」ということが挙げられます。使っているデメリットとしては「Xcodeのバージョンアップなどでトラブルことがある」といったことを実感していました。
ニュースパスでは「検索履歴」や「既読記事一覧」のように、アプリの中核ではない限られた用途で、とてもシンプルな目的でRealmを使っていました。シビアに実行速度が求められるような用途では有りませんし、CoreDataで実装したとしてもそこまで複雑にならないはずです。
受けているメリットに対してデメリットが釣り合っていないと感じていたため、auサービスTodayではRealmは使わずCoreDataを使うこととしました。
RxSwift
結論としてはRxSwiftを使いました。
RxSwiftはSwiftで利用できるライブラリで、iOSアプリ開発においては非同期処理をわかりやすく便利に書けたり、MVVMにおけるViewModelとViewのデータバインディングなどに利用できるライブラリです。
iOS 13以降であればCombineというiOS標準フレームワークで同じようなことができるのですが、iOS 12以下の環境には同等のものがないため、RxSwiftのような外部ライブラリに頼るのが手軽でした。
auサービスTodayでは、iOSのターゲットバージョンをiOS 13以上にできるかどうかが確定していなかったため、Combineの利用は一旦除外する必要がありました。
今までの開発でRxSwiftを使っていた実感では、メリットとしては非同期処理周りのが整理されることやMVVMのようなデータバインディングが手軽に実現できるなどを強く実感していました。デメリットは「コードが外部ライブラリに強く依存する」ことへのおぼろげな不安です。
RxSwiftはメリットがかなり大きく、ニュースパスでもリアクティブ系のフレームワークを広い範囲のコードで導入していたので、コードを流用するとしても何らかのリアクティブフレームワークを使うのが望ましいと考えました。
さらに、将来的に「iOS 13未満のサポートが必要なくなったときにはCombineに乗り換えが可能」なので出口も見えておりました。
ですので、ひとまずはRxSwiftを利用して開発することとなりました。
※後述しますが、開発途中でiOS 13未満のサポートが不要となったのでCombineへの乗り換えを行いました
auサービスTodayで利用しているライブラリ
今の所CocoaPodsを利用してライブラリを導入しており、利用しているのは下記のライブラリです。
pod 'Adjust' pod 'AWSCore' pod 'AWSKinesis' pod 'AWSSNS' pod 'Swinject' pod 'Firebase/Crashlytics', '~> 7.0' pod 'Firebase/Analytics', '~> 7.0' pod 'GoogleAnalytics', '~> 3.17.0' pod 'SwiftLint', '0.45.0' pod 'SDWebImage' pod 'SDWebImageWebPCoder' pod 'CombineCocoa' pod 'ads-sdk-ios', :git => 'git@github.com:gunosy/ads-sdk-ios.git', :tag => '1.8.0'
ちなみに ads-sdk-ios
は社内開発した広告用のライブラリです。
ターゲットiOSバージョンが13に決定
開発が40%ぐらい進んだ頃に、iOS 12未満をサポートしなくても良いこととなりました。それに伴い「RxSwiftからCombineへの乗り換え」と「SwiftUIの部分利用」を行いました。
RxSwiftからCombineへの乗り換え
auサービスTodayでは、RxSwiftの利用は「非同期処理の結果受け渡し」や「ViewとViewModel(状態)のデータバインディング」に限定して使っていたためかほぼ機械的な置き換えでできました。RxSwiftを排除できたので全体のビルド時間も短縮できました。(数値は測っていませんでした)
工夫した点は下記の点です。
- RxSwift.SingleはPromiseで実現し、eraseToAnyPublisher()をしてAnyPublisherで受け渡し
- SwiftUIへのバインディング部分以外では
@Published
は使わず CurrentValueSubjectで実現 - RxCocoa(RxSwift向けのUIKit拡張)の変わりにCombineCocoaを利用した
SwiftUIの部分導入
せっかくSwiftUIを使える環境となったので、これから開発する画面ではSwiftUIも交えて開発することにしました。
部分導入は大きく分けて下記の2通りの方法を用いています。
- 画面をまるごとUIHostingControllerで作成し、UIKitの仕組みで画面遷移
- UIKitの画面の子要素としてUIHostingControllerとそのViewを追加し、一部だけSwiftUIで実装
今回の記事ですべてを語ると長くなってしまうので、触りだけ紹介します。
画面をまるごとUIHostingControllerで作成する例
基本的には新規画面をUIHostingController + SwiftUIで実装するだけでOKです。
ただ、VIPER的な画面生成には少し工夫が必要でした
下記は通常のUIKItの画面を生成する例です。
// UIKitの画面の生成 let vc = HogeViewController.instantiate() // Storyboardからインスタンス作る便利メソッド let router = HogeRouter(viewController: vc) let useCase = HogeUseCase() let viewModel = HogeViewModel(router: router: useCase: useCase) vc.inject(viewModel: viewModel) // Viewの生成(データバインディング)はこのタイミングよりもあととなる
RouterはViewControllerを必要とし、ViewModelはRouterを必要とし、ViewControllerはViewModelを必要とします。これを解決するために、ViewControllerへのViewModelの注入だけは生成時に行わず最後に行っています。ViewControllerの生成時点ではViewは生成されず、少し遅れてviewDidLoadがやってくるので、そのタイミングでViewとViewModelのデータバインディングが行われるため問題となりません。
SwiftUI(UIHostingController)を使った画面は下記のように生成するようにしました。
// SwiftUIの画面の生成 let useCase = HogeUseCase() let viewModel = HogeViewModel(useCase: useCase) // この時点ではViewControllerがないのでRouterを作れない let view = HogeView(viewModel: viewModel) // ここでViewModelが必要となる let vc = UIHostingController<HogeView>(rootView: view) let router = HogeRouter(viewController: vc) viewModel.inject(router: router)
Viewの生成タイミングが画面生成タイミングと同時になるため、Routerを生成する前にViewModelが必要となりました。そのため生成順を変更しViewModelにRouterを注入するタイミングを生成時から遅らせることで成立させました。
SwiftUI画面でのViewModelは下記のように ObservableObject
をデータバインディング用に返却し、 @Published
のプロパティをSwiftUIなどで使用するようにしています。
internal final class HogeViewModel { private var subscriptions = Set<AnyCancellable>() private let useCase: HogeUseCase private var router: HogeRouter! internal init(useCase: HogeUseCase) { self.useCase = useCase } internal func inject(router: HogeRouter) { self.router = router } internal struct Input { internal let tappedOk: AnyPublisher<Void, Never> } internal final class Output: ObservableObject { @Published internal var hogeMessage = "Hogehoge" } internal func transform(input: Input) -> Output { let output = Output() let router = self.router input.tappedOk .sink { _ in router.showAwsomeView() // 別画面への遷移 output.hogeMessage = "Fuga" // ViewがBindするようの値の更新 } .store(in: &subscriptions) return output } }
下記のような画面全体に出てくるポップアップなどで利用しました。
例えばテキスト中で文字色を変更するなど、UIKitでは少し面倒な操作もSwiftUIならサクサクできるので効果を実感できました。
UIKitの画面の子要素としてSwiftUIの表示
UIViewController.addChild(_:) と UIView.addSubview(_:) を使うことでUIViewの子要素としてUIViewControllerを表示することができます。
let hogeView = HogeView() // SwiftUIのView let hostingController = UIHostingController<HogeView>(rootView: hogeView) parentViewController.addChild(hostingController) containerView.addSubview(hostingController.view) // なにか適当なViewの子供にする hostingController.view.constrainToEdges(of: containerView) // AutoLayoutを設定する便利メソッド hostingController.didMove(toParent: parentViewController)
SwiftUIのViewは中に表示するためのデータを configure
のようなメソッドで設定できるようにしておきます。
// いわゆる「Entity」と呼ばれるデータ internal struct HogeData { let text: String } internal struct HogeView: View { @ObservedObject private var item = HogeDataItem() // ObservableObjectでEntityを内包することによりSwiftUIでバインディング可能にする private final class HogeDataItem: ObservableObject { @Published internal var data: HogeData? } // 親のView(ViewController)でデータ更新を検知したときに呼び出される internal func configure(data: HogeData?) { self.item.data = data } internal var body: some View { Text(item.data.text) // itemが更新されると表示文字列が変わる } }
親のView側でデータが変更を検知したときに上記の configure
を呼び出すことでSwiftUI側のViewのデータも更新できます。
例えば下記の画面で使用しました。
画面全体はUICollectionViewで実現しているのですが、ポイント表示部分は少し複雑そうなのでSwiftUIにしました。
SwiftUIの部分導入の感想
iOS 13時点でのSwiftUIは残念ながら万能では有りませんが、UIKitと比べて可読性やメンテナンス性に優れるのでうまく使うことで開発の大きな助けになると実感しました。デザイン変更があったときの変更が容易ですし、GitHub上でのコードレビューも簡単でした。
うまくハマればメリットは大きいので、アプリのターゲットOSバージョンがiOS 13以上であれば「ひとまず部分利用して経験値を積んでみる」ことは価値のあることだと思います。
さいごに
RxSwiftやRealmという大型ライブラリを使わないアプリにできたことで、ビルド時間が短縮でき、環境アップデートなどのハードルが下がったように思います。そのためにはiOS標準となるCombineやCoreDataを利用するということ、状況によっては検討する価値があると感じました。
SwiftUIは、UIKitメインのアプリであっても気軽に部分的に導入していくことが可能です。すべての場面にハマるわけではないと思いますが、ハマればUIKitと比べて飛躍的に実装やメンテの効率がアップするので「ひとまずSwiftUI部分利用して経験値を積む」というのは良い考えだと思いました。