153 Views
October 31, 25
スライド概要
SAPPORO ENGINEER BASE #10 モバイルアプリ開発
https://seb-sapporo.connpass.com/event/370615/
における、発表資料です。
ソフトウェアエンジニア|Swift中心にモバイルアプリやウェブ開発をやっています。 ESP32や3Dプリンタ(Ender3 S1 Pro)を活用して、自宅の作業環境をカスタマイズ中。 シンプルで使いやすいものを作るのが理想。
SwiftUI Viewの肥大化対策 個人的5選 鈴木孝宏 (sussan0416), 2025-10-31, SAPPORO ENGINEER BASE #10 モバイルアプリ開発
鈴木 孝宏 sussan0416 / sussan-po.com • 株式会社Helpfeelのエンジニア • 世界で2,200万人以上が使う画像共有サービス「Gyazo」のiOS・macOS アプリ開発を担当 • 公立はこだて未来大学大学院在学中にIPA「未踏IT人材発掘・育成事業」に 採択 • SNSアプリや広告配信システム開発を経て、フィンテック、教育、研究、 ウェディングなど多様な分野のアプリに携わる • 2025年からは札幌を拠点に、IoTや生成AIにも取り組みながらリモート開 発を継続 • 信条は「シンプルで使いやすいものを作ること」 ※ ChatGPTに調査してもらい生成 ※ さらに、Apple Intelligenceで要約
自分用のアプリですが、公開もしています Suitoucho シアトマワ
SwiftUIとは
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/ を読んでみてください。
書きやすさにひそむ罠
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)
}
}
}
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 }
課題感 • 宣言的シンタックスで、読みやすく書きやすい反面、 状態・ロジック・見た目が1クラスに混在しがち • UIの見た目や振る舞いをメソッドチェーンで変更するため、 宣言と変更に距離ができる 今日の内容: これらを解消するために試してきたこと
肥大化対策でやっていること 個人的5選
目次 • ViewBuilder: 共通のUI宣言を切り出す • Modi er: 包括的なUIを切り出す • Generics: UIの詳細をあとで決める • Environment: 値や処理を外から注入する fi • Delegate: 処理を委譲する
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の内容が共通
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
}
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"
)
}
}
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̀
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
}
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/
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つの意図が混ざっている
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) } } } } } }
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) } } } } } }
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) } } } } } }
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のナビゲーションと、レイアウトと、詳細が混在
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)
}
}
}
}
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の意図が明確になり、見通しが良くなった👍
}
}
↑レイアウトを再利用できる👍
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へ伝搬
Environment: 値や処理を外から注入する 例: Analytics Before struct CTAButton: View { var body: some View { Button("Subscribe now!") { startSubscription() Analytics.logEvent("subscription_start") } } ... } ← 各所でimportしがちな FirebaseAnalytics 何度も出現するから切り出したい
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) } } ← 処理を切り出す
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ỳである必要もない
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でインスタンスをセットする必要がある
※ パフォーマンスについては、未調査
Environment: 値や処理を外から注入する 例: ログインユーザーの伝搬・変更 • ViewModelからユーザーを取り出すこともよくある • ViewModel経由で取り出す場合の問題 • ユーザーを参照するだけなのにViewModelが必要だったり ̀viewModel.user̀ • Previewするにも、ViewModelのモックが必要になったり • 変更用のメソッドとかもモック…… 本当は、値だけ、セッターだけを使いたい
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を適用する
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を使うように変えた
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")
}
}
}
}
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を使いたい。など
Delegate: 処理を委譲する uses AlbumDetailViewModel AlbumDetailView creates references SongView SongViewがViewModelに直接依存しているから、再利用しづらい
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に直接依存しなくて👍
Delegate: 処理を委譲する シンプルな例 uses AlbumDetailViewModel AlbumDetailView creates action SongView ViewModelに直接依存しなくなった👍
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")
}
}
}
}
コールバックが増えることになったらどうしよう……?
Delegate: 処理を委譲する 結局これに戻る? owns AlbumDetailViewModel AlbumDetailView conforms creates SongView delegates Delegate これなら、コールバックが増えても引数が増えなくて良さそう👍
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") } } } } }
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側でインタフェースを定義できる良さがある👍 ※ パフォーマンスについては、未調査 ※ このパターンは、まだ試してみているところ
まとめ
まとめ 肥大化対策でやっていること個人的5選 • SwiftUIは書きやすい反面、Viewがすぐに肥大化しやすい • 肥大化を解消すべく、これまで試してきた5つの工夫を紹介 • ViewBuilder / Modi er / Generics / Environment / Delegate • 責務を小さく、再利用しやすいViewを実装することを、意識していきたい fi ベストプラクティスは未だわからず 設計の大海原を、引き続きさまよっています……