『NAVITIME』アプリをリニューアルして分かったSwiftUIとの付き合い方
こんにちは、揚げ銀杏です。ナビタイムジャパンでiOS版『NAVITIME』アプリの開発を担当しています。
iOS版『NAVITIME』アプリは、2022年7月に全面リニューアルしました。このリニューアルアプリでは、ほぼ全ての画面でSwiftUIを採用しています。今回は、SwiftUIを用いた大規模なアプリ開発を経験して得た知見や気をつけたいことなどについてお話ししていきたいと思います。
SwiftUIについての概要や学習方法などについてはこの記事では触れませんが、別プロダクトで導入した際の投稿で紹介していますのでこちらも是非ご覧ください。
また、アプリのリニューアルに至った背景や取り組みについて紹介している記事もありますので、併せてご覧いただければと思います。
『NAVITIME』アプリでSwiftUIを採用した経緯
『NAVITIME』アプリでは、2020年のiOS 14公開に合わせてリリースした「ホーム画面ウィジェット」で初めてSwiftUIを使用しました。
私が初めてSwiftUIに触れたのもこの時で、UIKitとは全く異なる書き方に慣れるまでは多少時間がかかりましたが、コードを見れば画面の構造が簡単に把握できるという点が非常に素晴らしいと感じました。
そして、何といっても開発効率の高さは驚異的です。下の図に示すように、同じ画面を作るのに要するコード量はUIKitと比べて非常に少なく、見通しも良いため、新規の画面作成だけでなくその後の修正もスムーズです。
多数の画面を開発していく中でこれだけの差が積み上がっていくと考えれば、SwiftUIを利用するメリットの大きさがお分かりいただけるかと思います。
『NAVITIME』アプリを全面リニューアルするに当たっては上記のような利点に加え、将来的にSwiftUIを用いた開発が主流となっていくだろうという見込みも踏まえて、SwiftUIを採用することになりました。
最終的には、WebViewや社内で開発している地図など一部機能を除き、ほぼ全ての画面をSwiftUIで開発することができました。
とはいえ、どうしても足りない機能や不具合もあり、それらを回避するため独自にページング機能やリストビュー、時刻ピッカーを作るなどの苦労をすることもありました。
そういった中で得られた、SwiftUI開発におけるハマりポイントや工夫した点について、いくつか紹介していきます。
いかにして再描画を抑制するか
SwiftUIで画面を作っていくのは非常に簡単なのですが、何も考えずに作っているとすぐに画面遷移が遅い、スクロールが引っかかるなどの問題にぶつかります。
このようなパフォーマンスの低下は多くの場合、不要な再描画処理が走ってしまっていることが原因です。
小規模なアプリでは多少の無駄があっても気になるほどにはなりませんが、複雑な画面構成になると無視できないレベルのパフォーマンス悪化につながります。
そのため、どういった条件で再描画処理が走るのかを理解し、再描画が最小限となるように実装していくことが重要になります。
Viewは小さい単位で分割する
Viewが参照しているプロパティ(自身の@StateプロパティやObservableObjectの@Publishedプロパティなど)の値が変更されると、そのViewのbodyプロパティが再評価されて表示が更新されます。
当然、bodyの中身が大きくて複雑なほど処理にかかるコストは大きくなりますが、別のstructとして定義されている子Viewについては、更新の影響がない場合にはbody再評価が行われないようになっています。
そのため、画面の構成要素を小さいViewに分割して作成することで、変更のない部分を更新対象から外し、再描画範囲を減らすことができます。
また、巨大なViewは単純にコードが読みづらくなりますので、可読性の面でもViewの分割は重要です。
class Presenter: ObservableObject {
@Published var text = "りんご"
@Published var count = 0
func update() {
text += "りんご"
}
}
struct ContentView: View {
@StateObject var presenter = Presenter()
// presenter.textが更新されるとContentView全体が再描画される
var body: some View {
VStack {
Button(action: { presenter.update() }) {
Text("再描画されるボタン")
}
CustomButton1(text: presenter.text)
CustomButton2()
}
}
}
struct CustomButton1: View {
var text: String
// textプロパティに変化がある場合のみ、bodyが再評価される
var body: some View {
Button(action: { presenter.update() }) {
Text("再描画されるボタン")
}
}
}
struct CustomButton2: View {
// presenter.textが更新されても表示に影響しないため、bodyは再評価されない
var body: some View {
Button(action: { presenter.update() }) {
Text("再描画されないボタン")
}
}
}
参照していないプロパティの更新でbody再評価が行われることがある
注意したいのが、ObservableObjectを参照する場合です。
ObservableObjectを実装したクラスでは、@Publishedを付けたプロパティの更新をViewに反映することができますが、想定より広い範囲が再描画されてしまうことがあります。
これは、更新のあったObservableObjectと結びついたViewすべてが、参照しているプロパティが変化したかとは関係なくbody再評価の対象となるためです。
ObservableObjectをViewで参照する際は、@ObservedObjectや@EnvironmentObjectを付けたプロパティとして定義しますが、この場合は仮に@Publishedプロパティを一切参照していなかったとしてもbody再評価の対象になります。
また、@Bindingを用いてプロパティを参照している場合も同様です。
バインディングしているプロパティ自体には変化がなくても、同じObservableObjectの別プロパティが変更されるとbody再評価の対象になります。
@Publishedプロパティは使わないけれど、ObservableObjectのプロパティやメソッドにアクセスしたい場合は、@ObservedObjectなどを付けずに普通のプロパティとして定義すれば、Viewをbody再評価の対象から外すことができます(当然ですが、@Publishedプロパティを参照しても更新は反映されません)。
struct ContentView: View {
@StateObject var presenter = Presenter()
// presenter.textが更新されるとContentView全体が再描画される
var body: some View {
VStack {
CustomButton3(presenter: presenter)
CustomButton4(count: $presenter.count)
CustomButton5(presenter: presenter)
}
}
}
struct CustomButton3: View {
// 参照していないプロパティの更新でもbody再評価の対象になる
@ObservedObject var presenter: Presenter
var body: some View {
Button(action: { presenter.update() }) {
Text("再描画されるボタン")
}
}
}
struct CustomButton4: View {
// Bindingしていないプロパティの更新でもbody再評価の対象になる
@Binding var count: Int
var body: some View {
Button(action: { count += 1 }) {
Text("再描画されるボタン")
}
}
}
struct CustomButton5: View {
// 通常のプロパティとして定義すれば、更新があってもbody再評価の対象にならない
var presenter: Presenter
var body: some View {
Button(action: { presenter.update() }) {
Text("再描画されないボタン")
}
}
}
Viewで扱うデータにも気をつけよう
『NAVITIME』のリニューアルアプリでは、最大6つのルートバリエーションをタブに並べて検索し、比較することができます。
それぞれのバリエーションごとに4経路の結果があれば全部で24経路。場合によってはさらに増えることもありますので、データサイズは結構な大きさになります。
ルート検索結果の画面を開発していた当初は、経路のオブジェクトをstructで定義していました。
しかし、structは別変数に代入するとオブジェクトのコピーが作られるため、表示する結果が多いとView更新のたびに発生するコピーコストが無視できないレベルになり、動作が非常に重くなってしまいました。
そこで、View更新時にオブジェクト自体のコピーが発生しないように定義をclassへ変更したところ、一気に動作が軽くなりました。
大きなデータを扱う場合には、こういった部分にも気をつける必要があります。
UIKitとの混在が生む苦労
SwiftUIは誕生してまだ日の浅いフレームワークであるため、機能的に不十分であったり、どうしても回避できない不具合があったりします。
そうした場合に役立つのが、UIKitのViewをラップして使うことのできるUIViewRepresentable/UIViewControllerRepresentableプロトコルです。
これとは逆に、UIKitのViewにSwiftUIを組み込むことのできるUIHostingControllerも用意されており、SwiftUIとUIKitを混在させた画面を作ることも容易なのですが、完全に一体的に使えるというわけではありません。
よくあるのが、UIScrollViewをラップして使う場合です。
SwiftUIで用意されているScrollViewは、UIScrollViewと比べてできることが少なく、例えばスクロールのオフセット位置を取得したり、bounceを無効化したりといったことができません。
そのためこれらの機能の必要な場合は、UIViewRepresentableを実装したViewにUIScrollViewを組み込んで使うことになります。
さらにスクロールさせる中身の部分はSwiftUIで作りたいとなると、UIHostingControllerでラップしてUIScrollViewのsubViewに入れることになりますので、SwiftUI > UIKit > SwiftUI という構造が出来上がります。
このとき、外側のSwiftUI Viewと内側のSwiftUI Viewの間では、アニメーションが連動しません。プロパティの変化自体は伝わりますが、アニメーションせず一気に変化後の状態になるためガタガタした動きになったりします。
struct OuterView: View {
@State private var animatingHeight: CGFloat = 100
var body: some View {
VStack {
// UIScrollViewをUIViewRepresentableでラップしたView
UIScrollViewWrapper(.horizontal) {
InnerView()
.frame(height: animatingHeight) // アニメーションが効かない
}
}
.frame(height: animatingHeight + 100) // こっちはアニメーションする
}
}
このような場合への対処として、変化させる値を入れたObservableObjectを作り、外側と内側のViewで共有させるようにしました。
プロパティの変化を外側と内側でそれぞれ検知し、同じアニメーションを実行することで、擬似的に同期した動きに見せることができます。
// UIKitを挟んだSwiftUIの間で値の変化を共有するためのオブジェクト
class SharedValue<T: Hashable>: ObservableObject {
@Published var value: T
init(_ value: T) {
self.value = value
}
}
struct OuterView: View {
@StateObject var height = SharedValue<CGFloat>(100)
@State private var animatingHeight: CGFloat = 100
var body: some View {
VStack {
// UIScrollViewをUIViewRepresentableでラップしたView
UIScrollViewWrapper(.horizontal) {
InnerView(height: height)
}
}
.frame(height: animatingHeight + 100)
.onChange(of: height.value) { value in
// OuterViewとInnerViewで同じアニメーションを実行
withAnimation {
animatingHeight = value
}
}
}
}
struct InnerView: View {
@ObservedObject var height: SharedValue<CGFloat>
@State private var animatingHeight: CGFloat
init(height: SharedValue<CGFloat>) {
self.height = height
_animatingHeight = State(initialValue: height.value)
}
var body: some View {
HStack {
// 省略
}
.frame(height: animatingHeight)
.onChange(of: height.value) { value in
// OuterViewとInnerViewで同じアニメーションを実行
withAnimation {
animatingHeight = value
}
}
}
}
他にも、SwiftUIとUIKitのViewを重ねてオフセットさせた際にタップ領域が元の場所に残ってしまうようなこともあり、複雑に混在させるのはできるだけ避けたほうがよいと感じました。
最後に
この記事ではSwiftUIを用いた開発で気をつけたいことを中心に紹介してきましたので、読んでみて手を出しにくそうだと感じた方もいるかもしれません。
確かに、SwiftUIは機能の不足や想定外の挙動をすることがあるなど、利用する上ではまだまだ苦労が多いです。それでも、私は『NAVITIME』アプリでSwiftUIを採用してよかったと思っています。
画面作りのスピードと改修の手軽さ、コード上で容易に構造を把握できることには代え難い魅力があり、もうUIKitには戻りたくないと思うほどです。
リニューアルアプリのリリース後も、不具合の修正や頂いたご意見をもとにした改善を行っていますが、以前と比べても高い頻度でリリースできており、SwiftUIを採用した効果も一因であると考えています。
SwiftUIの登場から3年以上が経って、不安定な挙動も解消されつつあり、機能面も徐々に充実してきました。
『NAVITIME』アプリのリニューアル開発が始まった当初と比べても、解説資料や採用事例を目にすることが増えてきたと実感しています。
それらの一つとして、この記事が皆さんのSwiftUI開発のお役に立てば幸いです。