Now in REALITY Tech #32 needleを使ったモジュール間の画面遷移をSwiftUIで試してみた話
はじめに
今週のNow in REALITY Techは最近育児休暇から復帰したかむいが担当です。イクメンの心得は「母乳以外何でもできる!」らしいです。見習っていきたいです。
お休み中の前半はPCを触れないほど多忙で頭がなまっていましたが、後半は友人の力を借りながらコーディングのリハビリを行ないました。その際取り組んだのが表題のものです。
これは昨年投稿した以下の記事のSwiftUI対応版となりますが、プロダクトで採用した話ではなくこのお休み中にやってみた感想記事となります。
返り値をUIViewControllerからSwiftUI.Viewへ変えるだけと思っていた時期が僕にもありました。
前回の記事では画面遷移を実現する際、RIBsのBuilderパタンを用いて返り値を実際の画面クラスの型ではなくUIViewController型にする手法と取りました。
// Buildableに準拠した専用Buildableを用意
public protocol GachaBuildable: Buildable {
func makeViewController() -> UIViewController
}
今回SwiftUIでこの技法を使いたい場合、返り値にはUIViewControllerの代わりにSwiftUI.Viewを返すだけで良いのでは?と思っていました。しかしこれはコンパイルの時点で失敗します。
これはSwiftUI.ViewがUIViewの様なclassではなくBodyという連想型(associatedtype)を持つprotocolであるためです。
連想型を持つprotocolは型として扱えないSwift特有の制約については多くの記事で紹介されているため割愛しますが、SwiftUIで最初に目にするbody という変数が実はView protocolが持つ連想型だということに気がつきませんでした。
型消去かAnyViewか
連想型を持つprotocolを型指定するためのワークアラウンドとしては型消去(type erasure)を思いつきます。またはAnyViewを使って型情報を消す方法もあると思います。しかし後者の場合、特定のView階層で使われるViewの型を消せる一方で、古い階層ごと破棄し新しい階層を再描画するため、多用し過ぎるとSwiftUIの持つレンダリングエンジンに深刻なパフォーマンス問題を引き起こします。
型消去を使って実装してみた
今回はAnyViewは使わず、型消去を使った実現方法をトライしてみました。
まずSwiftUI.Viewをassociatedtypeとして持つprotocolを用意します。
protocol Buildable: AnyObject {}
protocol AnyBuildable: Buildable {
associatedtype ViewType: View
@MainActor func makeView() -> ViewType
}
typealias MainTabBuildable = AnyBuildable
UIKit版で用意したBuilderクラスではneedleのDependencyプロコトルに準拠したものをGenerics型に指定していましたが、SwiftUI.View用では上記のAnyBuildableをGenerics型に指定しassociatedtypeを消去します。
class AnyBuilder<T: AnyBuildable>: AnyBuildable {
let buildable: T
public init(buildable: T) {
self.buildable = buildable
}
public func makeView() -> some View {
// AnyBuildableの実体にメソッド転送
buildable.makeView()
}
}
型消去したことによりUIKit版で実現していたBuilderクラスをDIすることは出来なくなったため、associatedtypeを持たない間接化されたクラスとしてBuilderProvider(BuilderProvidable)を用意しました。
protocol MainTabBuilderProvidable {
// 型消去されたAnyBuilder<T>を渡す
var mainTabBuilder: AnyBuilder<MainTabBuilder> { get }
}
class MainTabBuilderProvider: MainTabBuilderProvidable {
// AnyBuildableの実体であるBuilderを内包した
// AnyBuilder<XXBuilder>を渡す
var mainTabBuilder: AnyBuilder<MainTabBuilder> {
AnyBuilder(buildable: MainTabBuilder())
}
}
class MainTabBuilder: MainTabBuildable {
@MainActor
func makeView() -> some View {
MainTabView()
}
}
このBuilderProvidableを使いBuilderの値をDIします。
class MainTabComponent: Component<EmptyDependency> {
func mainTabBuilderProvider() -> MainTabBuilderProvidable {
MainTabBuilderProvider()
}
}
class RootComponent: BootstrapComponent {
func makeView() -> some View {
var mainTabBuilderProvider = mainTabComponent.mainTabBuilderProvider()
return ContentView(mainTabBuilder: mainTabBuilderProvider.mainTabBuilder)
}
// MARK: - Children Component
var mainTabComponent: MainTabComponent {
MainTabComponent(parent: self)
}
}
SwiftUI.Viewの画面クラスでDIをする際にAnyBuilder型を渡すため以下の様な書き方になります。
struct ContentView<T: AnyBuildable>: View {
private let mainTabBuilder: AnyBuilder<T>
init(mainTabBuilder: AnyBuilder<T> {
self.mainTabBuilder = mainTabBuilder
}
var body: some View {
NavigationView {
List {
NavigationLint("MainTab", destination: mainTabBuilder.makeView())
}
}
}
}
REALITYのプロダクトで採用したか
社内勉強会で共有はしたものの、プロダクトで採用には至りませんでした。理由としては以下の3点です。
UIKitベースで実装された既存の画面と相互に遷移することが多い関係上、画面遷移はUIKitで行う(Viewの部分のみSwiftUIを採用する)ケースが多い
画面遷移のためのボイラープレートが増え実装コストが高くなる(SwiftUIの実装コストを下げるメリットを阻害してしまう)
ボイラープレートの自動生成の仕組みを用意していない
1は元よりあったルールのため、どちらかというと2, 3による原因が大きいです。3はneedleでDIを実現するために必要なComponentクラス, Dependencyインターフェースの用意についても言えるため、今後のneedleによるDI整備としても課題として残っています。
まとめ
課題にあげたボイラープレートの自動生成はDIの話に限らず、さまざまな場面で出くわす問題では無いでしょうか。
VIPERアーキテクチャでは必要なファイルの自動生成を行ってくれるツールが数多く支持されていますが、弊プロダクトでもneedleを使う上で登場するファイルをできる限り自動生成できればと思っているため、次回はその技術についてトライした内容を記事化できればと思っています。
弊社ではSwiftUIの導入やneedleをはじめトライしてみたい技術に積極的に挑戦できる環境が揃っています。それはiOSに限らず他の分野でも盛んに行われているため、もし興味があればmeetyにて気軽にお声がけ頂けると幸いです。
また新しいオフィスにお引っ越したばかりのため、ご都合があえば是非遊びに来て下さい。カジュアル面談と合わせてお待ちしておりますmm