SwiftUI Viewの肥大化対策 個人的5選

153 Views

October 31, 25

スライド概要

SAPPORO ENGINEER BASE #10 モバイルアプリ開発
https://seb-sapporo.connpass.com/event/370615/
における、発表資料です。

profile-image

ソフトウェアエンジニア|Swift中心にモバイルアプリやウェブ開発をやっています。 ESP32や3Dプリンタ(Ender3 S1 Pro)を活用して、自宅の作業環境をカスタマイズ中。 シンプルで使いやすいものを作るのが理想。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

SwiftUI Viewの肥大化対策 個人的5選 鈴木孝宏 (sussan0416), 2025-10-31, SAPPORO ENGINEER BASE #10 モバイルアプリ開発

2.

鈴木 孝宏 sussan0416 / sussan-po.com • 株式会社Helpfeelのエンジニア • 世界で2,200万人以上が使う画像共有サービス「Gyazo」のiOS・macOS アプリ開発を担当 • 公立はこだて未来大学大学院在学中にIPA「未踏IT人材発掘・育成事業」に 採択 • SNSアプリや広告配信システム開発を経て、フィンテック、教育、研究、 ウェディングなど多様な分野のアプリに携わる • 2025年からは札幌を拠点に、IoTや生成AIにも取り組みながらリモート開 発を継続 • 信条は「シンプルで使いやすいものを作ること」 ※ ChatGPTに調査してもらい生成 ※ さらに、Apple Intelligenceで要約

3.

自分用のアプリですが、公開もしています Suitoucho シアトマワ

4.

SwiftUIとは

5.

SwiftUIとは シンプルで読みやすく、書きやすいUIフレームワーク • Appleが提供するUIフレームワーク struct CounterView: View { @State private var count = 0 • 1つのコードで複数のAppleプラット var body: some View { VStack { フォームに対応 Text("Count: \(count)") Button("+1") { count += 1 } • 宣言型シンタックスを使用 } } } Reactのようなもの、Jetpack Composeのようなもの、という理解で問題ありません。 詳しくは https://developer.apple.com/jp/swiftui/ を読んでみてください。

6.
[beta]
書きやすさにひそむ罠

struct CounterView: View {
// 状態
@State private var count = 0
// ロジック
func countUp() {

• 状態・見た目・ロジックを、

count += 1
}

1クラスの中に混在させがち

// 見た目

• 小さいViewだからと、
1ファイルでまとめてしまいがち

var body: some View {
VStack {
Text("Count: \(count)")
.foregroundStyle(

• その後、機能追加により、

count >= 10 ? .red : .black

一気に構造がカオスになっていく

)
Button("+1", action: countUp)
}
}
}

7.

SwiftUI特有の読みにくさ Modi erが遠い var body: some View { List(album.songs) { song in HStack { Image(album.cover) • Modi er: Viewの見た目や振る舞いを .resizable() .scaledToFill() 変更するためのメソッド .clipped() .frame(width: 40, height: 40) • 必要な変更だけを書けるよさがある .border(.gray, width: 1) VStack(alignment: .leading) { • 宣言と変更に距離が空くため、 Text(song.title) Text(song.artist.name) レビューのしにくさ、保守のしにくさ を感じることがある .foregroundStyle(.secondary) } } } ̀List̀のModi er .listStyle(.insetGrouped) .refreshable(action: reload) .onAppear(perform: loadAlbums) fi fi fi }

8.

課題感 • 宣言的シンタックスで、読みやすく書きやすい反面、 状態・ロジック・見た目が1クラスに混在しがち • UIの見た目や振る舞いをメソッドチェーンで変更するため、 宣言と変更に距離ができる 今日の内容: これらを解消するために試してきたこと

9.

肥大化対策でやっていること 個人的5選

10.

目次 • ViewBuilder: 共通のUI宣言を切り出す • Modi er: 包括的なUIを切り出す • Generics: UIの詳細をあとで決める • Environment: 値や処理を外から注入する fi • Delegate: 処理を委譲する

11.

ViewBuilder: 共通のUI宣言を切り出す メソッドに出すだけでも良い Before BookListItem( name: book.name, balance: book.balance ) .contextMenu { Button(role: .destructive) { // action } label: { Label("Delete Book", systemImage: "trash") } Button { // action } label: { Label("Edit book name", systemImage: "square.and.pencil") } } .swipeActions(allowsFullSwipe: false) { // 同じメニューを表示 } contextMenuとswipeActionsの内容が共通

12.
[beta]
ViewBuilder: 共通のUI宣言を切り出す
メソッドに出すだけでも良い
Before
BookListItem(
name: book.name, balance: book.balance
)
切り出しちゃって良いはず
.contextMenu {
Button(role: .destructive) {
// action
} label: {
Label("Delete Book", systemImage: "trash")
@ViewBuilder menuItems: () -> MenuItem,
}
MenuItem: View
Button {
// action
} label: {
Label("Edit book name", systemImage: "square.and.pencil")
}
}
.swipeActions(allowsFullSwipe: false) {
// 同じメニューを表示
@ViewBuilder content: () -> T, T: View
}

13.
[beta]
ViewBuilder: 共通のUI宣言を切り出す
メソッドに出すだけでも良い
After
BookListItem(
name: book.name, balance: book.balance
)
.contextMenu {
buildItemMenus(book)
}
.swipeActions(allowsFullSwipe: false) {
buildItemMenus(book)
}

↑modi erの距離が近くなり、

fi

仕様の見通しが良くなった👍

@ViewBuilder
private func buildItemMenus(_ book: Book) -> some View {
Button(role: .destructive) {
// action
} label: {
Label("Delete Book", systemImage: "trash")
}
Button {
// action
} label: {
Label(
"Edit book name",
systemImage: "square.and.pencil"
)
}
}

14.

Modi er: 包括的なUIを切り出す 例: 強制ログアウトのエラー表示 /// 通信エラー。あればアラート表示 Before @State private var error: Error? @State private var isShowError: Bool = false var body: some View { List(animals) { animal in Text(animal.name) } .task { do { try await fetchAnimals() } catch { self.error = error isShowError = true } } .alert( "Error", isPresented: $isShowError, presenting: error ) { e in Button("ログアウトする") { /**/ } } message: { e in Text(e.localizedDescription) } fi } ← 定義が遠くなりがち ← errorの代入 ← 使い回しがちな̀alert̀

15.
[beta]
Modi er: 包括的なUIを切り出す
例: 強制ログアウトのエラー表示
After
struct LogoutAlertModifier: ViewModifier {
/// 通信エラー。あればアラート表示
@State private var error: Error?
@State private var isShowError: Bool = false
func body(content: Content) -> some View {
content
.alert(
"Error",
isPresented: $isShowError,
presenting: error
) { e in
Button("ログアウトする") { /**/ }
} message: { e in
Text(e.localizedDescription)
}
.onReceive(.logoutError) { e in
error = e as? Error
isShowError = true
}

var body: some View {
List(animals) { animal in
Text(animal.name)
}
.task {
do {
try await fetchAnimals()
} catch {
notifyError(name: .logoutError, object: error)
}
}
.modifier(LogoutAlertModifier())
}

↑エラーは、Noti cationで投げるだけ👍
← Noti cationを受けるだけ👍

}

fi

※ Noti cation以外にも、エラーを伝搬させる方法は複数ある
※ Viewを操作する時は、MainActorで処理する点に注意(省略した)
fi

fi

fi

}

16.
[beta]
Modi er: 包括的なUIを切り出す
例: アプリ全体の共通処理
アプリがアクティブになった時の処理

Modi erを、最上位でセットするだけ

struct AppActiveProcessModifier: ViewModifier {
@Environment(\.scenePhase) var phase

var body: some Scene {
WindowGroup {
ContentView()
.modifier(AppActiveProcessModifier())
}
}

func body(content: Content) -> some View {
content
.onChange(of: phase) { _, newPhase in
if newPhase == .active {
onAppActive()
}
}
}

アプリ全体の共通処理などにも応用可能👍
責務がはっきりして良い感じ。

fi

fi

※ 参照: https://sussan-po.com/2023/03/24/swiftui-view-style/

17.

Generics: UIの詳細をあとで決める Before struct AnimalsView: View { var body: some View { NavigationStack { ScrollView { TopAnimalView() LazyVGrid(columns: columnsConfig) { ForEach(animalItems) { animal in NavigationLink { AnimalDetailView(animal: animal) } label: { ThumbnailImage(image: animal.image) } } } } } } ↑3つの意図が混ざっている

18.

Generics: UIの詳細をあとで決める ナビゲーション struct AnimalsView: View { var body: some View { NavigationStack { ScrollView { TopAnimalView() LazyVGrid(columns: columnsConfig) { ForEach(animalItems) { animal in NavigationLink { AnimalDetailView(animal: animal) } label: { ThumbnailImage(image: animal.image) } } } } } }

19.

Generics: UIの詳細をあとで決める レイアウト struct AnimalsView: View { var body: some View { NavigationStack { ScrollView { TopAnimalView() LazyVGrid(columns: columnsConfig) { ForEach(animalItems) { animal in NavigationLink { AnimalDetailView(animal: animal) } label: { ThumbnailImage(image: animal.image) } } } } } }

20.

Generics: UIの詳細をあとで決める 詳細 struct AnimalsView: View { var body: some View { NavigationStack { ScrollView { TopAnimalView() LazyVGrid(columns: columnsConfig) { ForEach(animalItems) { animal in NavigationLink { AnimalDetailView(animal: animal) } label: { ThumbnailImage(image: animal.image) } } } } } }

21.

Generics: UIの詳細をあとで決める struct AnimalsView: View { var body: some View { NavigationStack { ScrollView { TopAnimalView() LazyVGrid(columns: columnsConfig) { ForEach(animalItems) { animal in NavigationLink { AnimalDetailView(animal: animal) } label: { ThumbnailImage(image: animal.image) } } } } } } Viewのナビゲーションと、レイアウトと、詳細が混在

22.
[beta]
Generics: UIの詳細をあとで決める
レイアウトを切り出す
After
struct AnimalsView: View {
var body: some View {
NavigationStack {
HeaderAndGridScrollView(items: animalItems) {
TopAnimalView()
} content: { animal in
NavigationLink {
AnimalDetailView(animal: animal)
} label: {
ThumbnailImage(image: animal.image)
}
}
}
}

struct HeaderAndGridScrollView<T, Header, Content>: View
where
T: RandomAccessCollection, T.Element: Identifiable,
Header: View, Content: View
{
let items: T
@ViewBuilder var header: () -> Header
@ViewBuilder var content: (T.Element) -> Content
var body: some View {
ScrollView {
header()
LazyVGrid(columns: columnsConfig) {
ForEach(items) { item in
content(item)
}
}
}
}

23.
[beta]
Generics: UIの詳細をあとで決める
レイアウトを切り出す
After
struct AnimalsView: View {
var body: some View {
NavigationStack {
HeaderAndGridScrollView(items: animalItems) {
TopAnimalView()
} content: { animal in
NavigationLink {
AnimalDetailView(animal: animal)
} label: {
ThumbnailImage(image: animal.image)
}
}
}
}

struct HeaderAndGridScrollView<T, Header, Content>: View
where
T: RandomAccessCollection, T.Element: Identifiable,
Header: View, Content: View
{
let items: T
@ViewBuilder var header: () -> Header
@ViewBuilder var content: (T.Element) -> Content
var body: some View {
ScrollView {
header()
LazyVGrid(columns: columnsConfig) {
ForEach(items) { item in
content(item)
}
}

↑Viewの意図が明確になり、見通しが良くなった👍
}
}

↑レイアウトを再利用できる👍

24.

Environment: 値や処理を外から注入する • Environment: Viewの子孫へデータ var body: some View { HeaderAndGridScrollView(items: animalItems) { を伝搬させる仕組み TopAnimalView() } content: { animal in NavigationLink { • 子孫のViewは、̀@Environment̀ AnimalDetailView(animal: animal) を使ってデータを取り出す } label: { ThumbnailImage(image: animal.image) } • ReactのContextに似ている } .environment(\.userType, user.type) } // 子孫Viewへ伝搬

25.

Environment: 値や処理を外から注入する 例: Analytics Before struct CTAButton: View { var body: some View { Button("Subscribe now!") { startSubscription() Analytics.logEvent("subscription_start") } } ... } ← 各所でimportしがちな FirebaseAnalytics 何度も出現するから切り出したい

26.

Environment: 値や処理を外から注入する 例: Analytics protocol AnalyticsService { func logEvent(name: String, parameters: [String: Any]?) } struct AnalyticsServiceImpl: AnalyticsService { func logEvent(name: String, parameters: [String: Any]?) { Analytics.logEvent(name, parameters: parameters) } } ← 処理を切り出す

27.

Environment: 値や処理を外から注入する 例: Analytics protocol AnalyticsService { func logEvent(name: String, parameters: [String: Any]?) } struct AnalyticsServiceImpl: AnalyticsService { func logEvent(name: String, parameters: [String: Any]?) { Analytics.logEvent(name, parameters: parameters) } } extension EnvironmentValues { @Entry var useAnalytics: AnalyticsService? = nil } ← EnvironmentValuesで取れるようにする ここで初期値をセットしても良い 値が変わらないなら̀@Entrỳである必要もない

28.
[beta]
Environment: 値や処理を外から注入する
例: Analytics
After
struct CTAButton: View {
@Environment(\.useAnalytics) private var analytics

← @Environmentで取り出す

var body: some View {
Button("Subscribe now!") {
startSubscription()
analytics?.logEvent(name: "subscription_start", parameters: nil)

← 使用する

}
}
...
}

Environmentだから、Analyticsをモックに差し替えできる👍

※ この例では、上位のViewでインスタンスをセットする必要がある
※ パフォーマンスについては、未調査

29.

Environment: 値や処理を外から注入する 例: ログインユーザーの伝搬・変更 • ViewModelからユーザーを取り出すこともよくある • ViewModel経由で取り出す場合の問題 • ユーザーを参照するだけなのにViewModelが必要だったり ̀viewModel.user̀ • Previewするにも、ViewModelのモックが必要になったり • 変更用のメソッドとかもモック…… 本当は、値だけ、セッターだけを使いたい

30.
[beta]
Environment: 値や処理を外から注入する
例: ログインユーザーの伝搬・変更
extension EnvironmentValues {
@Entry var currentUser: User? = nil
@Entry var setCurrentUser: SetCurrentUser = .init(user: .constant(nil))
}
struct SetCurrentUser {
private let user: Binding<User?>
init(user: Binding<User?>) {
self.user = user
}
func callAsFunction(_ user: User) {
self.user.wrappedValue = user
}

←値だけ、セッターだけを取れるようにする

←変更処理は、structに閉じる
←structを関数のように呼べる仕組み

}

fi

fi

fi

struct CurrentUserModifier: ViewModifier {
@State private var currentUser: User? = nil
func body(content: Content) -> some View {
content
.environment(\.currentUser, currentUser)
.environment(\.setCurrentUser, SetCurrentUser(user: $currentUser))
}
}

←状態管理は、Modi erに閉じる
Modi er便利👍
アプリの最上位でModi erを適用する

31.

Environment: 値や処理を外から注入する 例: ログインユーザーの伝搬・変更 struct LoginView: View { @Environment(\.currentUser) private var currentUser: User? ← 値だけを取り出せる👍 var body: some View { if let currentUser { Text(currentUser.name) } LoginButtonView() } } struct LoginButtonView: View { @Environment(\.setCurrentUser) var setCurrentUser ← セッターだけを使えるようになる👍 var body: some View { Button("Login") { Task { try? await Task.sleep(for: .seconds(1)) setCurrentUser(.init(name: "John Doe")) ← structを関数のように呼べる } } } } ※ 参考: @Environment(\.keyPath)実践入門, https://qiita.com/lovee/items/0893d3ed7813e66d8188 ※ 昔はクロージャをEnvironmentに入れていたけど、2024年のiOSDCでこれ↑を読んで、Callable Structを使うように変えた

32.
[beta]
Delegate: 処理を委譲する
Before
struct SongView: View {

← アルバム詳細画面にある、曲リストのView

let viewModel: AlbumDetailViewModel
let song: Song
var body: some View {
HStack {
...
Button {
viewModel.setLike(song, !song.isLike)

← お気に入り機能がある

} label: {
Image(systemName: song.isLike ? "heart.fill" : "heart")
}
}
}
}

33.
[beta]
Delegate: 処理を委譲する
Before
struct SongView: View {
let viewModel: AlbumDetailViewModel
let song: Song
var body: some View {
HStack {
...
Button {
viewModel.setLike(song, !song.isLike)
} label: {
Image(systemName: song.isLike ? "heart.fill" : "heart")
}
}
}
}

仕様追加の例: プレイリスト画面を新設。同じViewを使いたい。など

34.

Delegate: 処理を委譲する uses AlbumDetailViewModel AlbumDetailView creates references SongView SongViewがViewModelに直接依存しているから、再利用しづらい

35.
[beta]
Delegate: 処理を委譲する
シンプルな例
After

親側

struct SongView: View {
let song: Song
let likeAction: () -> Void

List(viewModel.album.songs) { song in
SongView(song: song) {
viewModel.setLike(song, !song.isLike)
}
}

← コールバック

var body: some View {
HStack {
...
Button {
likeAction() ← 親にコールバックする
} label: {
Image(systemName: song.isLike ? "heart.fill" : "heart")
}
}
}
}

ViewModelに直接依存しなくて👍

36.

Delegate: 処理を委譲する シンプルな例 uses AlbumDetailViewModel AlbumDetailView creates action SongView ViewModelに直接依存しなくなった👍

37.
[beta]
Delegate: 処理を委譲する
シンプルな例
親側
struct SongView: View {
let song: Song
let likeAction: () -> Void

List(viewModel.album.songs) { song in
SongView(song: song) {
viewModel.setLike(song, !song.isLike)
}
}

var body: some View {
HStack {
...
Button {
likeAction()
} label: {
Image(systemName: song.isLike ? "heart.fill" : "heart")
}
}
}
}

コールバックが増えることになったらどうしよう……?

38.

Delegate: 処理を委譲する 結局これに戻る? owns AlbumDetailViewModel AlbumDetailView conforms creates SongView delegates Delegate これなら、コールバックが増えても引数が増えなくて良さそう👍

39.

Delegate: 処理を委譲する 結局これに戻る? After 親側 protocol SongViewDelegate: AnyObject { func didTapLike(for song: Song) ← funcが増えても安心👍 } List(viewModel.album.songs) { song in SongView(song: song, delegate: viewModel) } struct SongView: View { let song: Song let delegate: SongViewDelegate extension AlbumDetailViewModel: SongViewDelegate { func didTapLike(for song: Song) { // like処理 ← Delegateにした ↑ 引数渡しでもEnv.でも var body: some View { } HStack { ... Button { delegate.didTapLike(for: song) ← Delegate経由で呼ぶ } label: { Image(systemName: song.isLike ? "heart.fill" : "heart") } } } } }

40.

Delegate: 処理を委譲する 結局これに戻る? After 親側 protocol SongViewDelegate: AnyObject { func didTapLike(for song: Song) } List(viewModel.album.songs) { song in SongView(song: song, delegate: viewModel) } struct SongView: View { let song: Song let delegate: SongViewDelegate extension AlbumDetailViewModel: SongViewDelegate { func didTapLike(for song: Song) { // like処理 var body: some View { } HStack { ... Button { delegate.didTapLike(for: song) } label: { Image(systemName: song.isLike ? "heart.fill" : "heart") } } } } } View側でインタフェースを定義できる良さがある👍 ※ パフォーマンスについては、未調査 ※ このパターンは、まだ試してみているところ

41.

まとめ

42.

まとめ 肥大化対策でやっていること個人的5選 • SwiftUIは書きやすい反面、Viewがすぐに肥大化しやすい • 肥大化を解消すべく、これまで試してきた5つの工夫を紹介 • ViewBuilder / Modi er / Generics / Environment / Delegate • 責務を小さく、再利用しやすいViewを実装することを、意識していきたい fi ベストプラクティスは未だわからず 設計の大海原を、引き続きさまよっています……