タクシーアプリ「GO」の iOS アプリを開発している久利です。最近 SwiftUI を使った機能がリリースされたので、どのように導入していったかについてご紹介します。
GO では2021年4月に iOS 12 のサポートを終了し、そろそろ SwitftUI を使っていきたいねとチームで話をしている状況でした。それまでは一部で UIKit で 作られた画面を Xcode Previews を使って Preview 表示してるだけでした。
Xcode Previews の導入については、メルペイさんのXcode Previewsを用いたUIKitベースのプロジェクトの開発効率化を参考にさせて頂きました。
SwiftUI は 2019年の WWDC で発表された、Apple のプラットフォームでアプリケーションを開発するための UI フレームワークです。
個人的には感じていたメリットは以下の3点です。
GO は2020年4月に会社が統合され同年9月にリリースされたアプリで、現在は3チームが日々開発をしています。約月1のペースで大きな機能がリリースされており、UI 部分の追加/改修をするこはもちろん多く、開発しながら要件を固めていくこともあるので UI を作る/修正する速度を向上することが出来ればと考えていました。また、ユーザー、タクシー、位置等の多種多様な状態によって UI の状態を変える必要があるため、実装が複雑になってしまうこともしばしばありました。
上記を踏まえて SwiftUI で UI 部分の開発速度、保守性の向上が出来ればと思い導入の検討を行っていきました。
GO の iOS アプリでは、責務を明確化するために、Uber の RIBsアーキテクチャ(以下RIBs) を採用しています。RIBs は Router + Interactor + Builder + (Presenter + View) を1つのコンポーネントとして扱い、独自のライフサイクルで機能を構築しています。RIBs について初めての RIBs に詳しく書かれているので気になる方は参考にしてみてください。
RIB の構成:https://github.com/uber/RIBs/wiki#parts-of-a-rib
RIBs の特徴を残しつつ、SwiftUI をどのように適用していくか検討していたところ、クックパッドさんの記事SwiftUI を活用した「レシピ」×「買い物」の新機能開発の中で、「【方針】View 層のみで SwiftUI を部分的に導入する」を拝見し、これだ!となりました。
RIB のView層は UIViewController と View になります。SwiftUI で作った View を UIHostingController で扱うようにし、UIViewController に追加します。RIB の中でも View層 のみが SwiftUI と依存関係にあり、他の層や他の RIB は SwiftUI を意識しない実装にしています。
SwiftUI.View を更新するには、先程の記事を参考に DataSource(ObservableObject) を定義し UIViewController から更新する形をとりました。
SwiftUI.View 側で発生したイベントのハンドリングは、SwiftUI.View でも PresentableListener を保持するようにし、PresentableListener を介して Interactor に伝わるようにしています。
// UIViewController の実装例
import RIBs
import RxSwift
import UIKit
import EasyPeasy
import SwiftUI
protocol SamplePresentableListener: AnyObject {
func handleDidTapButton()
}
final class SampleViewController: UIViewController, SamplePresentable, SampleViewControllable {
weak var listener: SamplePresentableListener?
private let dataSource = SampleView.DataSource()
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
}
private func setupViews() {
let rootView = SampleView(dataSource: dataSource)
rootView.listener = listener
let hostingViewController = UIHostingController(rootView: rootView)
addChild(hostingViewController)
view.addSubview(hostingViewController.view)
hostingViewController.didMove(toParent: self)
hostingViewController.view.easy.layout(Edges())
}
}
// MARK: - SamplePresentable (Interactor から呼ばれる処理)
extension SampleViewController {
func update(buttonTitle: String {
dataSource.buttonTitle = buttonTitle
}
func update(isButtonEnabled: Bool) {
dataSource.isButtonEnabled = isButtonEnabled
}
}
// SwiftUI.View の実装例
import SwiftUI
struct SampleView: View {
class DataSource: ObservableObject {
@Published var buttonTitle = ""
@Published var isButtonEnabled = false
}
@ObservedObject var dataSource: DataSource
weak var listener: SamplePresentableListener?
var body: some View {
Button(dataSource.buttonTitle,
action: { listener?.handleDidTapButton() })
.disabled(!dataSource.isButtonEnabled)
}
}
RIBs でも SwiftUI を使えそうなことが分かったので実際にプロダクションへの導入をしていきたいですが、よりイメージを掴むために直近の案件で比較的レイアウトが簡単なものをお試しで SwiftUI 化してみました。
このお試しを実装していくにあたって、いくつか必要な Utillity があったので追加しています。
// UIKit
let titleLabel = UILabel()
titleLabel.setTitle("Hello, World!", for: .normal)
titleLabel.apply(.monkey())
// SwiftUI
struct SwiftUIView: View {
var body: some View {
Text("Hello, World!")
.textStyle(.monkey())
}
}
struct FontStyle: ViewModifier {
var textStyle: TextStyle
func body(content: Content) -> some View {
content
.font(Font(textStyle.font))
}
}
struct FontNarrowStyle: ViewModifier {
var textStyle: TextStyle.Narrow
func body(content: Content) -> some View {
content
.font(Font(textStyle.font))
}
}
extension View {
func textStyle(_ textStyle: TextStyle) -> some View {
ModifiedContent(content: self, modifier: FontStyle(textStyle: textStyle))
}
func textStyle(_ textStyle: TextStyle.Narrow) -> some View {
ModifiedContent(content: self, modifier: FontNarrowStyle(textStyle: textStyle))
}
}
慣れ親しんだ UIKit よりも多く工数が掛かることが想定されたので、比較的時間に余裕がある案件でレイアウトが簡単な画面から導入を行いました。
SwiftUI で作成した画面の一例
下準備をしてからプロダクションへの適用に入ったので、画面を作る上では意外とサクサク進みました。そう、作る上では・・・。SwiftUI 導入で大変だったこと関しては後述で触れます。
既存でアプリ内で共通で使いたいボタンや View を UIKit でコンポーネント化している部品があり、今回 SwiftUI で同じものを作るか、UIViewRepresentable でラップし SwiftUI で扱うようにするか迷いましたが、後者を採用しました。理由としては、しばらく並行運用になるので2重管理にしないためです。この辺は少し面倒なところではありました。
新規で画面を作るときに、まずはデザインを見ながら上から適当な部品を配置していき、ある程度画面の階層が整ったら細かレイアウト処理を入れたりということがあると思います。UIKit の場合は定義部分とレイアウトの処理をいったりきたりする必要がありましたが、SwiftUI の場合は宣言的に書けることで非常に見通しが良いと感じました。
また、自分が作るときのメリットだけではなく、他の人が作ったレイアウトがどのような構成か把握しやすいためレビューがしやすいと感じました。
// プロフィール追加画面の一部
var body: some View {
ScrollView {
VStack {
Text("招待コードを入力")
Spacer()
.frame(height: 12)
Text("GO BUSINESS招待メールに記載された\n招待コードを入力してください")
Spacer()
.frame(height: 40)
VStack {
SUTXBTextFiled(
"BC-1234abcd",
...})
Spacer()
.frame(height: 12)
SUShrinkButton(
"連携する",
action: {
listener?.didTapActivationButton()
})
}
Spacer()
}
}
}
コードを見るだけで何となく、全体が ScrollView で上からテキスト →テキストがあって、入力欄 → ボタンがあるのかとぱっと見で分かる
実際のレイアウト
実装を進める中で、1つの View が大きくなってしまい意味のある単位で分割したくなるときや、別の画面と View の共通化をしたくなるときがあるかと思います。今までは切り出す際に AutoLayout の制約を気にしたりする必要がありました。
SwiftUI では基本他の View との依存が実装としてないので、切り出したい部分をカットして新しいファイルにコピーするだけでいけます。
下からスライドイン/アウトする処理を書くときなどは UIView.animate(withDuration:animations:completion:) を使って、 AutoLayout の制約を変更して実現していたかと思います。
SwiftUI では スライドインする条件の if 文内に View を定義し、animation(_:) modifier と、遷移を定義するための transition(_:) modifier をアニメーションさせたい View で定義するだけで実現できます。これでスライドアウトの処理もやってくれるのかと驚きました。
// スライドインしてくる部分の実装
if !dataSource.isButtonHidden {
ZStack {
SUShrinkButton(presentSetting.buttonType.title,
buttonType: .shrinkButton,
isEnabled: .constant(true),
action: {
listener?.handleDidTapChangeButton()
})
.textStyle(.monkey(.heavy))
.titleColor(Color.white100()!)
.frame(height: 54)
.padding(12)
}
.background(Color.white100.color
.edgesIgnoringSafeArea(.bottom))
.frame(maxWidth: .infinity)
.shadow(color: Color.black100.color.opacity(0.05),
radius: 4,
x: 0, y: -4)
.animation(.linear(duration: 0.15))
.transition(.move(edge: .bottom))
}
これが一番大切かなとも思っています。デザインを見て、頭の中でイメージした View の階層をそのままコードに落とし込めている感覚でとても楽しく書けるなと思いました。エンジニアとして新しいことに触れて、こんなことできるのかな?どの書き方がいいんだろう?と試行錯誤しながらやっていく過程もとても楽しいですね。
当初はあまり意識せず、iOS 14 or 15 で動作確認をしていましたが、レビュー時に「レイアウトが崩れてます」や、QA中に「画面が真っ白です」ということが発生し大変でした。大概は iOS 13 で発生するもので中々辛い思いをしました。以下遭遇した事象の一部。
非表示の処理で if 文のネストが深くなることを避けたかったので、.hidden(isHidden: Bool) を追加したのですが、何故か iOS 13 だと処理が呼ばれず画面が真っ白になる事象に遭遇しました。しょうがないので、 ZStack を1つかまして階層を変えることで回避出来ました。
import SwiftUI
struct Hidden: ViewModifier {
let hidden: Bool
func body(content: Content) -> some View {
VStack {
if !hidden {
content
}
}
}
}
public extension View {
func hidden(_ isHidden: Bool) -> some View {
ModifiedContent(content: self, modifier: Hidden(hidden: isHidden))
}
}
遅延読み込みしたい画面があったので List を使ったんですが、iOS 13 では背景色をいじるのと、セパレーターを非表示にする modifier がなかったので、onAppear(perform:)で UITableView.appearance() を変更することで対応しました。しかし、iOS 14 だけ反映されず、良い回避策が見つからなかったので、 iOS 14 以上のでは ScrollView + LazyVStack で表示するようにしました。
iOS 13 でマイナーバージョンごとに動作を見るのが現実的ではなかったので、PdM と相談し直近のアクセス状況からユーザーへの影響が最小限になる範囲でマイナーバージョンのサポートを切ることを行いました。現在 GO では iOS 13.3 以上のサポートになっています。
ProgressView, LazyVStack/LazyHStack が iOS 14 からだったり、ScrollView で contentOffset を操作したいときに使う、ScrollViewReader と ScrollViewProxy も iOS 14 からだったりと、iOS 13 では出来ないことが結構あります。これは出来る/出来ないの調査と判断が必要になり大変でした。
導入をしてみて、作る/修正するというコードを書く面では一定の開発体験の向上と慣れれば速度向上をしていけそうという事は感じられました。
しかし、iOS 13 ではまだまだ不安定な面と機能不足感が否めないので、チームとして今後は必ず SwiftUI でやっていきましょうというところまではもう少し時間が掛かるかなと思いました。
iOS 13 のサポートを切るタイミングを待ちつつ、引き続きキャッチアップをしていければと思っています。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!