iOS/Android 宣言的UIのFW(SwiftUI & Jetpack Compose) の共通/相違点
最近、スマホアプリ開発においても宣言的UIが盛り上がりを見せています。当初はクロスプラットフォーム開発(ReactNative/Flutter)において宣言的UIが率先して取り入れられていましたが、ネイティブアプリ開発においても、SwiftUIのAPIが最近充実してきたり、Jetpack ComposeがStableになったことで、宣言的UIが徐々に浸透しています。
サスメドでは、高品質なスマホアプリを開発するためiOS/Androidをそれぞれネイティブアプリで開発してます。同時に、開発効率向上のため実装を極力揃える方針を採っています。そのような背景のもとで、宣言的UIを取り入れるために、SwiftUI/Jetpack Composeの実装方針を揃えられる部分と揃えられない部分の調査を行いました。
本記事は、iOSとAndroidをそれぞれネイティブアプリで開発する場合に、SwiftUI/Jetpack Composeにはどんな共通/相違点があるのか簡単なサンプルコードと共に紹介します!
【文章中の用語について】
Component
・アプリ上に表示されるUIを定義したソースコード
・SwiftUIにおけるViewを示す
・Jetpack ComposeにおけるComposableを示す
State
・アプリ上で保持する状態
・Observableであり、Stateの更新がUIの再描画のトリガーとなる
共通点
SwiftUI/Jetpack Composeは共に宣言的UIをベースにしていることから、Componentをより簡潔に部品化しやすくすることを目的とした下記のような共通点が存在しています。
1. Stateが別メモリ領域で一元管理される
2. State更新によりUIが適時再描画される
3. ライフサイクル等の副作用を取り扱う API が標準で用意されている
4. Componentのネストによるオーバーヘッドが限りなく少ない
5. Modifierをチェーンできる
共通点1: Stateが別メモリ領域で一元管理される
SwiftUI/Jetpack Composeで定義したComponentは直接Stateを持つことができません。Jetpack Composeはそもそも関数(@Composable)で Componentを定義しますし、SwiftUIは構造体でComponentを定義しますが、描画処理の度に破棄 → 生成されます。そのため両フレームワークは、Componentに紐付くStateを別メモリ領域に保持する仕組みを提供し、間接的な状態管理を実現しています。
【SwiftUI】
View上にPropertyWrapper(@State, @StateObject, etc...)を定義します。
struct ContentView: View {
// ボタンのClick回数を`@State`を使って別メモリに保持
@State private var count = 0
var body: some View {
VStack {
Text("\(count)回Clickした")
Button("Click Me!") {
// ボタンClick時にState更新
count += 1
}
}
}
}
【Jetpack Compose】
Composable上にrememberComposableを使ってState<T>を定義します。
*AACにおけるViewModelを使ってStateをまとめる場合は、viewModel()で ViewModelをComposableに紐付けます。
@Composable
private fun Content() {
// rememberComposableを使ってState<T>を別メモリに保持
var count: Int by remember { mutableStateOf(0) }
Column {
Text(text = "${count}回Clickした")
Button(
// ボタンClick時にState更新
onClick = { count += 1 }
) {
Text(text = "Click Me!!")
}
}
}
共通点2: State更新によりUIが適時再描画される
SwiftUI/Jetpack Composeで定義したComponentは、State更新により何度も再描画される可能性があります。両フレームワークとも、繰り返される再描画処理のパフォーマンスを高くするために、更新されたStateに依存する Componentを差分更新する最適化処理が組み込まれています(Componentに実装した描画処理が常に全て再実行されるとは限らない)。そのためComponentの実装時には、以下の点に気をつける必要があります。
Componentの描画処理は軽量にすること
・外部リソース(API通信、DB アクセス等)間の処理は別スレッドで行う
冪等であること
・何度描画処理が実行されても、Component の描画結果は変わらないようにする
冪等にできない場合は、副作用として定義すること
・ライフサイクルに依存する処理などは、冪等性を満たさないため、副作用として取り扱う必要がある(* 共通点3で詳しく説明)
共通点3: ライフサイクル等の副作用を取り扱うAPI が標準で用意されている
前項でComponentは冪等であるべき旨を記載しましたが、アプリの仕様により、場合によってはComponentの表示/非表示時といったライフサイクルイベント等の特定の条件下で処理を行いたい場合があります。SwiftUI/Jetpack Composeともに、これらの状況に対応できるAPIが標準提供されています。下記に主なAPIを記載します。
【SwiftUI】
・View表示時: onAppear()
・View非表示時: onDisappear()
・CombineのPublisher更新時: onReceive()
struct ContentView: View {
var body: some View {
VStack {
・・・
}.onAppear {
print("Viewが表示")
}.onDisappear {
print("Viewが非表示")
}.onReceive(publisher) { value in
print("Publisherが更新")
}
}
}
【Jetpack Compose】
・Composable表示時: LaunchedEffect(Unit)
・Composable非表示時: DisposableEffect(Unit) { onDispose {} }
@Composable
fun Content() {
// Composable表示時の処理
LaunchedEffect(Unit) {
print("Composableが表示")
// Flow、LiveDataの購読など...
}
// Composable表示/非表示時の処理
DisposableEffect(Unit) {
print("Composableが表示")
// Flow、LiveDataの購読、Listenerの登録など...
onDispose {
print("Composableが非表示")
// Listenerの解除など...
}
}
}
*上記以外の API については、SwiftUIはInput and Eventsを、Jetpack ComposeはComposeにおける副作用を参照ください。
共通点4: Componentのネストによるオーバーヘッドが限りなく少ない
これまでのUIKit/Android View Systemを用いた開発では、Componentのネストによるオーバーヘッドを意識しながら開発を行う必要がありました。例えば、Android View Systemの場合は、ConstraintLayout等を用いてなるべくネストを浅くする実装が公式で推奨されています。SwiftUI/Jetpack Composeの場合は、このようなネストによるオーバーヘッドが限りなく少なく設計されているため、積極的にコンポーネントを部品化、ネストさせることができます。一方で、ネストが深くなりすぎると Component 全体の可読性が悪くなる恐れがあるため、部品化ルールをチーム内で予め決めておく必要があります。
共通点5: Modifierをチェーンできる
SwiftUI/Jetpack Composeには、Componentに対して外観の調整やタップ等のユーザー操作検知の装飾ができるModifierと呼ばれるAPIが提供されています。SwiftUIはViewに対してModifierを適用し、Jetpack ComposeはModifierクラスをComposableの引数に渡すことで適用します。どちらのModifierもチェーンして複数の装飾をまとめることができます。チェーンする順番によって最終的な装飾結果が変わるので注意が必要です(*相違点2で詳しく説明)。先述したComponentの切り出しと同様に、UIを共通化/部品化をする上での有力な手段になります。
【SwiftUI】
struct ContentView: View {
var body: some View {
Text("Text")
.background(Color.blue) //背景色を青色にするModifierを設定
.border(.red) // 赤色の枠線を設定するModifierを設定
}
}
【Jetpack Compose】
@Composable
fun Content() {
Text(
// Modifierを引数で渡す
modifier = Modifier
.background(Color.Blue) //背景色を青色に設定
.border(BorderStroke(width = 1.dp, color = Color.Red)), // 赤色の枠線を設定
text = "Text"
)
}
SwiftUI/Jetpack Composeの相違点
続いて、SwiftUI/Jetpack Composeでそれぞれ仕様が異なる箇所を下記にまとめていきます。中には設計に影響が出てくるものあるので、注意が必要です。
1: Componentのデフォルトの配置位置が異なる
2: Modifierの適用方法と正確性が異なる
3: データバインディングの方向性が異なる
相違点1: Componentのデフォルトの配置位置が異なる
SwiftUIは中央寄せ、Jetpack Composeは左上詰めで配置されます。
下記に、縦方向にComponentを並べた例をそれぞれ記載します。
【SwiftUI】
struct ContentView: View {
var body: some View {
VStack {
Text("First")
Text("SecondSecond")
}
}
}
・縦方向にViewを並べるVStackが中央に配置
・VStack内のViewも中央寄せ
【Jetpack Compose】
@Composable
fun Content() {
Column {
Text("First")
Text("SecondSecond")
}
}
・縦方向にComposableを並べるColumnが左上に配置
・Column内のComposableも左寄せ
スマホアプリのUIは左右対象のレイアウトが一般的ですが、このような場合にはSwiftUIの方が簡潔/直感的にComponentを配置できます。
相違点2: Modifierの適用方法と正確性が異なる
先述のように、SwiftUI/Jetpack ComposeのModifierはチェーンできるなど、Componentの装飾方法には共通点もありますが、一方で差異も存在します。
ⅰ. 装飾の適用方法
【SwiftUI】
SwiftUIはModifierを都度Viewに適用します。各種Modifierは装飾後のViewを返却するので、前のModifierは後ろのModifierの影響を受けません。そのため、Modifierが書いた順に適用されていくようなイメージになります。
struct ContentView: View {
var body: some View {
Text("Text")
.background(Color.blue) //背景色を青色にする
.frame(width: 100, height: 100) // 縦横サイズを100にする(青色の適用範囲は変わらない)
.border(.red) // 赤色の枠線を設定
}
}
【Jetpack Compose】
一方Jetpack Composeの場合、チェーンしたModifierをまとめて Composableに適用するため、前のModifierの適用範囲が後ろのModifierによって変更されることがあります。そのため、前のModifierが後ろのModifierに引っ張られて広がっていくようなイメージになります。
@Composable
fun Content() {
Text(
modifier = Modifier
.background(Color.Blue) //背景色を青色にする
.size(100.dp) // 縦横サイズを100dpにする(青色の適用範囲が変わる)
.border(BorderStroke(width = 1.dp, color = Color.Red)), // 赤色の枠線を設定
text = "Text"
)
}
ⅱ. 装飾の正確性
【SwiftUI】
SwiftUIのModifierは、先述の通りViewを戻り値にすることでチェーンを実現するため、Viewプロトコルの拡張関数として用意されています。その仕様上、型安全にすることができません。例えばTextに対し、ButtonStyleを設定するbuttonStyle()を適用してしまった場合など、意図しない実装をしてもコンパイルエラーとならない点に注意が必要です。
struct ContentView: View {
var body: some View {
Text("Text")
// コンパイルエラーにならない
// 実行時に無視される
.buttonStyle(.borderedProminent)
}
}
【Jetpack Compose】
一方、Jetpack Composeの場合、特定のComposable内でのみ適用できる Modifierが用意されています。Column/Row/Boxなどのレイアウト用Composableは子Composableを受け取るラムダをColumn/Row/BoxScopeとして定義しており、これらの型の関数としてModifierを用意することで型安全を実現しています。
@Composable
fun Content() {
Box {
// このラムダはBoxScope型
Spacer(
// 自身のサイズを親と同じするModifier ※BoxScope内でのみ使用可能
Modifier.matchParentSize()
)
・・・
}
}
// BoxScope Interfaceのソースコード抜粋
@LayoutScopeMarker
@Immutable
interface BoxScope {
・・・
fun Modifier.matchParentSize(): Modifier
}
また、特定のComposableのみに適用したい装飾については、Modifierではなく、Composableの引数を設ける形で実現する点もJetpack Composeの大きな特徴です。例えば、TextFieldのキーボードタイプ指定について、SwiftUIの場合はkeyboardType()を使って指定しますが、Jetpack Composeの場合は Modifierではなく、TextFieldの引数に用意されたKeyboardOptionsで指定します。
このような違いから、Jetpack ComposeはSwiftUIと比べて、装飾の正確性が重視されているため、曖昧な書き方を許さない作りになっていることが分かります。
相違点3: データバインディングの方向性が異なる
SwiftUI/Jetpack Composeの間で、最も設計に影響が出そうな差異と感じたのがデータバインディングです。SwiftUIは双方向データバインディングを許容した作りになっているのに対し、Jetpack Composeは単方向のデータバインディングを強く意識した作りになっています。
両フレームワークで標準提供されているTextFieldを例に差異を見ていきましょう。
【SwiftUI】
// 親View
struct ContentView: View {
// 入力値を親側でState保持
@State private var name = ""
var body: some View {
VStack {
//子View
// text(入力欄)に、親側で持つStateの参照($)を渡している
// キーボード入力でStateが直接更新される
TextField("Name", text: $name)
}
}
}
親側で持つStateの参照を子のTextFieldに渡します。
親のStateがキーボード入力で直接更新されるので、Stateがどのように更新されるかについて、親側は把握することも関与することもできません。
*自作したViewにてTextFieldのような双方向データバインディングを実現したい場合は、@Binding PropertyWrapperを設けることで親側で持つStateの参照を受け取ることができます。詳しくは公式のManaging User Interface Stateを参照ください。
【Jetpack Compose】
// 親Composable
@Composable
fun Content() {
Column {
// 入力値を親側でState保持
var name by remember { mutableStateOf("") }
// 子Composable
TextField(
// 入力欄に表示する文字を設定
value = name,
// コールバックで新しい入力値を受け取る
// コールバックで何もしなければ、キーボードを入力しても何もStateが更新されない
// Stateの更新方法は親側で実装し、子は関与しない
onValueChange = { changedName
name = changedName
},
label = { Text("Name") }
)
}
}
SwiftUIと異なり、キーボード入力時に直接親のStateが更新されることはなく、代わりに新しい入力値をコールバック(onValueChange)で受け取ります。このコールバックで受け取る新しい入力値を親Composable側で明示的に代入することでState更新を行います。
*このように、子Composable側のイベントをコールバックで公開し、親Composable側にそのイベントの取扱を委ねることで 、Stateの在り処を上位に移動するパターンを公式ではStateHoistingと呼んでいます。
この差異は、MVVMにおけるViewModelのようなStateを管理するクラスの設計に影響が出てきます。SwiftUIの場合はViewModelで保持するStateを直接更新させるため、対象のStateを外部から変更可能にする必要がありますが、Jetpack Composeの場合は対象のStateを外部からは読み取り専用に留めておける代わりに、コールバックを別途用意する必要があります。
【SwiftUI】
final class SampleViewModel: ObservableObject {
// PublisherでState(名前)を保持
@Published var name : String = ""
}
struct ContentView: View {
@StateObject var viewModel = SampleViewModel()
var body: some View {
TextField("Name", text: $viewModel.name)
}
}
【Jetpack Compose】
class SampleViewModel : ViewModel() {
// MutableStateFlowのState(名前)をPrivateで保持
private val _name = MutableStateFlow("")
// 外部へはStateFlowで読み取り専用にした形で公開
val name: StateFlow<String> = _name
// コールバックを公開
fun onNameChange(name: String) {
// コールバック内でState更新
_name.value = name
}
}
@Composable
private fun Content(
viewModel: SampleViewModel = viewModel()
) {
// StateFlowをStateに変換
val name: String by viewModel.name.collectAsState()
TextField(
value = name,
// キーボード入力時にViewModelのコールバックを呼ぶ
onValueChange = viewModel::onNameChange,
)
}
Jetpack Composeは単方向データバインディングであり、そのためコールバックを用意する必要がある分コード量が増えますが、外部へのStateの公開/編集範囲を制限できるので、予期せぬState更新を防ぎやすくなります。
まとめ
今回はSwfitUI/Jetpack Composeの技術調査を行った上での共通点/相違点をそれぞれまとめてみました。両方とも宣言型UIの考え方がベースにあることで、これまでのUIKit/ViewSystemによるUI構築と比べて共通する部分が多く、両方学ぶ場合の差分学習のコストが非常に少なくなっているなと感じました。一方でデータバインディングなど、設計に影響しそうな相違点もあるため、その差異をそれぞれ意識しながら、実際のプロダクト導入に活かそうと考えています。
終わりに
サスメドでは現在、ネイティブで高品質なスマホアプリをじっくりと開発したいスマホアプリエンジニアをはじめ、積極的にエンジニア/デザイナーを募集しています。持続可能な医療の実現に興味のある方は、応募フォームより是非お問い合わせください!