はじめに
こんにちは!プロダクト開発部 iOSエンジニアのrunyaです。
エンジニア歴は3年ほどで前職はアパレル系のECアプリを開発していました。Newbeesに入社してからは、恋活・婚活マッチングアプリ「マリッシュ」の開発を担当しています。
趣味はゲームで、ここ1年くらいはモンハンをずっとプレイし続けています🎮
マリッシュにAI自己PRという機能を追加する際、SwiftUIを導入しました。
今回は構成やSwiftUIを使ってみた感想についてお話したいと思います。
導入した理由
SwiftUIを定期的にキャッチアップしていましたが、「iOSのバージョンによって同じコードでもアプリの動きに差が生じることがある」という点が気になっていたので、導入に踏み切れずにいました。
そんななか、マリッシュの最小サポートOSがiOS16になり、私調べで挙動差がかなり減っている印象を受け、導入することにしました。
元々SwiftUIの宣言的な文法により、記述量が減るなどのメリット自体は感じていたので導入できて嬉しいです🙌
構成
画面の外枠はUIKit、レイアウトはSwiftUIという構成になっています。
具体的には、UIHostingControllerを使用してSwiftUI.Viewを全画面に表示するような作りです。
struct SampleView: View {
let viewModel: SampleViewModel
var body: some View {
Text("Hello, World!")
}
}
class SampleViewController: UIHostingController<SampleView> {
private let viewModel: SampleViewModel
init() {
self.viewModel = SampleViewModel()
let rootView = SampleView(viewModel: self.viewModel)
super.init(rootView: rootView)
}
required init?(coder aDecoder: NSCoder) { nil }
override func viewDidLoad() {
super.viewDidLoad()
}
}struct SampleView: View {
let viewModel: SampleViewModel
var body: some View {
Text("Hello, World!")
}
}
class SampleViewController: UIHostingController<SampleView> {
private let viewModel: SampleViewModel
init() {
self.viewModel = SampleViewModel()
let rootView = SampleView(viewModel: self.viewModel)
super.init(rootView: rootView)
}
required init?(coder aDecoder: NSCoder) { nil }
override func viewDidLoad() {
super.viewDidLoad()
}
}ライフサイクル(viewWill〜、viewDid〜系)を細かく制御したかったのと、既存のダイアログがSwiftUIと相性が悪かったため、外枠はUIKitを使用することにしました。
アーキテクチャ
MVVMを採用しました。
各クラスの役割は以下にまとめました。
ViewがViewModel上の値を表示する箇所と、ViewのUIイベントを元にViewControllerで画面遷移するサンプルです。
※実際のコードとは一部異なりますが、おおむねこの形です。
※簡略化しているのでModelは出てきません。
struct SampleView: View {
@ObservedObject var viewModel: SampleViewModel
var body: some View {
VStack {
// ViewModelの値を表示
Text("\(viewModel.count)")
Button("+") {
// UIイベントをViewModelに伝達
viewModel.didTapPlusButton()
}
Button("アラートを表示") { viewModel.didTapShowAlertButton() }
}
}
}
class SampleViewModel: ObservableObject {
@Published var count = 0
let showAlertSubject = PassthroughSubject<Void, Never>()
func didTapPlusButton() {
// 値が変更されたとき、参照しているViewは自動更新される
count += 1
}
func didTapShowAlertButton() {
// Publisherに値を流し、購読しているViewController側で画面遷移させる
showAlertSubject.send(())
}
}
class SampleViewController: UIHostingController<SampleView> {
private let viewModel: SampleViewModel
private var cancellables: [AnyCancellable] = []
init() {
self.viewModel = SampleViewModel()
let rootView = SampleView(viewModel: self.viewModel)
super.init(rootView: rootView)
}
required init?(coder aDecoder: NSCoder) { nil }
override func viewDidLoad() {
super.viewDidLoad()
// Publisherに値が流れてきたら画面遷移をおこなう
viewModel.showAlertSubject
.sink { [weak self] _ in
let alert = UIAlertController(title: "alert", message: nil, preferredStyle: .alert)
self?.present(alert, animated: true)
}
.store(in: &cancellables)
}
}また、ViewControllerとViewModelはそれぞれ基盤クラスを持っていて、そこに強制アップデートやメンテナンスの確認などの共通処理がまとまっています。
世間ではTCAが話題になっていたりViewModel不要論もありますが、マリッシュのiOSアプリは1〜2人で開発していて、そこまで大規模ではないためMVVMで十分だと感じています。
導入した感想
SwiftUIはViewの使い回しがしやすく、開発の効率化に繋がりました。
実際にAI自己PRの開発時、自分が見積もっていた工数より短い期間で開発できて驚きました。
あとは、Gitとの相性が良いです。
iOSエンジニアであれば誰もが共感すると思うのですが、storyboardやxibの実態はXcodeに自動編集されるXMLなので、GitHub上では内容の理解がしずらいです。一方でSwiftUIは人が書いたコードなのでGitHub上でも内容を理解できます。
ただ、良い点ばかりではなく、もちろん悪い点もありました。
まず、当初懸念していたiOSのバージョンごとの挙動差ですが、ScrollViewのscrollDisabled周りで実際に発生しました(※1)。
発覚したのは検証がほとんど完了していたタイミングで、大きな修正が必要であれば「あー、終わったー😇」という気持ちだったのですが、軽微な修正で済んでとても安心しました笑。
あとは、SwiftUIのAPIが不足していますね。TextEditor周りの実装で感じました。
この点に関してはUIKitをラップする形で呼び出せるので、とりあえずは大丈夫かなという印象です。
(※1について補足)
前提として、ScrollViewを入れ子にして、親ScrollViewは指でスクロール不可、子ScrollViewは指でスクロール可という作りです。
その際、iOS17と18では問題ありませんでしたが、iOS16で親ScrollViewのscrollDisabledが子ScrollViewにも引き継がれてしまい、両方ともスクロール不可の状態になっていました。
GeometryReader { geo in
ScrollView(.horizontal) { // スクロールできないようにしたい
ScrollView { // スクロールできるようにしたい
HStack(spacing: 0) {
ForEach(0..<10) { n in
Text("\(n)")
.frame(width: geo.size.width)
}
}
}
// iOS16だと親のscrollDisabled(true)で上書きされてしまいスクロール不可になっていた
.scrollDisabled(false)
}
.scrollDisabled(true)
}子ScrollViewのisScrollEnabled を.environmentで指定して対応しました。
.scrollDisabled(false) → .environment(\.isScrollEnabled, true)今後
マリッシュはSwiftUIを導入したばかりで、まだまだ手探りな部分が多くルールも定まっていません。
開発を進めながらチームにとってベストな方法を見つけたいですね。
またサポートOSがiOS17〜になればObservationを使用できるようになるので、その際にアーキテクチャ周りには再度手を入れようかなと思っています。