[iOS] SwiftUI で続けたこと・やめたこと(2026年版)
SwiftUI でアプリの開発を続けていく中で、ずっと使い続けているコードパターンやテクニックもあれば逆にやめたものもあります。
前回の記事では開発プロセスや設計などのエンジニアリングに焦点を当てましたが、この記事ではコードパターンに焦点をあて、
- 現在どのようなプラクティスやコードパターンを採用しているか
- それはどのような理由・哲学に基づいているか
を紹介していきたいと思います。
ただ、記事タイトルのような Before / After に限定すると恣意的な書き方も出てきてしまうと思うので、そのあたりは柔軟に書いていきたいと思います。
なお、iOS 16+ なので全体を通して ObservableObject を利用したコード例になっていますが、iOS 17+ から利用可能な Observable マクロに置き換えて読んでいただいても問題ないかと思います。
設計
Single Source of Truth がすべて
Single Source of Truth(1つの事実は1箇所で持つ)は SwiftUI における根幹的な考え方ですが、これは現在でもまったく変わらない重要な考え方で、むしろ SwiftUI における設計とは「Single Source of Truth の状態をどのように持つか」 に集約されるとさえ感じています。
パフォーマンスのために Single Source of Truth を崩さなくてはならない場面もありますが、少なくとも私が過去のプロジェクトで経験したのは1回だけしかありません。(その1回も本当に必要だったのかは現在の知識で考え直してみる必要があると思います)
私が SwiftUI で開発しているなかで上位に食い込むバグとして「ViewModel 側でフォーカス制御をしようとしてうまく動かないケースがある」というものがあります。
enum LoginField: Hashable {
case email
case password
}
@MainActor
final class SampleScreenModel: ObservableObject {
@Published var focus: LoginField?
@Published var email = ""
@Published var password = ""
func focusToPassword() {
// ✅ 1. ViewModel 側で自身のプロパティを更新(🚫 確実に動作するものではない)
focus = .password
}
}
struct SampleScreen: View {
@StateObject var viewModel = SampleScreenModel()
@FocusState var focused: LoginField?
var body: some View {
VStack {
TextField("Email", text: $viewModel.email)
.focused($focused, equals: .email)
SecureField("Password", text: $viewModel.password)
.focused($focused, equals: .password)
Button("パスワードにフォーカス") {
viewModel.focusToPassword()
}
}
.padding()
// ✅ 2. ViewModel 側の変更を検知して自身の`@FocusState`を更新
.onChange(of: viewModel.focus) { _, newValue in
focused = newValue
}
}
}
これは今の ChatGPT に聞いても回答として返ってくるものですがバグが含まれており、
- 「パスワードにフォーカス」ボタンを押す
- 自分で Email にフォーカスする
- 再び「パスワードにフォーカス」ボタンを押す
という手順を行った際に、3. のタイミングで期待どおりにフォーカスしないことが分かります。
原因は ViewModel 側では 1. のタイミングで .password に切り替わっており、3. のタイミングで .password を設定しても値が変わらないため onChange がトリガーされないためです。
これを修正するもっとも簡単なアイディアの1つは、事前にクリアして次のフレームで期待する値に更新する方法です。
func focusToPassword() {
// 1. クリア
focus = nil
// 2. 次のフレームで期待する値に更新
Task {
focus = .password
}
}
この修正方法は手軽で私も(手抜きで)繰り返し使用してきましたが、本質的に何がこの問題を引き起こしているかと言うと Single Source of Truth ではない という点かと思います。
現在では以下のコードパターンを採用しています。
@MainActor
final class SampleScreenModel: ObservableObject {
// ✅ 1. ViewModel 側では状態を持たず Combine でシグナルを送る
let focus = PassthroughSubject<LoginField, Never>()
...
func focusToPassword() {
focus.send(.password)
}
}
struct SampleScreen: View {
@StateObject var viewModel = SampleScreenModel()
@FocusState var focused: LoginField?
var body: some View {
VStack {
...
}
.padding()
// ✅ 2. onChange の代わりに onReceive で変更を検知して自身を更新
.onReceive(viewModel.focus) {
focused = $0
}
}
}
これは View のみに状態を持った Single Source of Turth な設計になっており、(おそらく)前述したようなバグは起こらない作りかと思います。
Combine という見方によってはレガシーな API を使用していますが、私にとっては直感的かつ実用的なコードパターンだと感じており、(別に Combine でなくても良いと思いますが)Single Source of Truth を崩すよりはずっと良いと感じています。
なお、これに限った話ではありませんが すべてを SwiftUI で統一することが最も優れているわけではない と経験則上で感じており、後述しますが状況によっては UIKit のようなレガシーな API を好んで使う場面も私の中では増えてきています。
View / ViewModel のシンプル構成
これは前回の記事でも触れた内容なので重複する部分もありますが、1画面あたりは View / ViewModel という基本構成があります。
struct Article: Identifiable {
var id: String
var title: String
}
@MainActor
class ArticleListScreenModel: ObservableObject {
@Published var articles: [Article] = []
init() {
Task {
articles = ...
}
}
}
struct ArticleListScreen: View {
@StateObject var viewModel: ArticleListScreenModel = .init()
var body: some View {
List {
ForEach(viewModel.articles) { article in
Text(article.title)
}
}
}
}
私は過去に、
- 共通の Model を用意して ViewModel をなくし
- View に画面固有の状態と処理を書けば良いのではないか
と考えて試したことがあるのですが、私にとっては開発・保守性の両面において View / ViewModel の構成を上回るという直感は得られませんでした。
「見た目の View」 と「裏側の処理」は少なくとも私の中では分離して処理されていると感じており、ここ数年はこの View / ViewModel の構成を脳死で使用しています。
なお、これも前回の記事で触れたことですが ViewModel が登場すると古典的には「MVVM」に感じられ、
- View と ViewModel を明確に分離
- View には見た目の定義のみ
- ViewModel にすべての状態と処理を持たせる
という厳格な設計が要求されることが多いと思うのですが、私は厳格に分離することはせずに 「View と ViewModel で1つの仕事」 という捉え方をしており、
- View に状態を持っても OK(とくに遷移など View からのみ参照すれば良いもの)
- View にちょっとした処理を書いても OK(とくに遷移・ログなど)
- ViewModel に処理っぽいものを書いておく(API アクセスなど)
という緩いスタイルを取っています。
これは 厳格な責務分割をすると逆にコードを行ったり来たりしてより複雑になることもあり、保守性の良さよりも責務分割されたコードを書くことが目的になってしまうケースがある という経験則から来ています。
チーム開発においてルールを自由にしすぎると混乱につながるのも事実ですが、これくらいの範囲であれば各人のクラフトマンシップに任せてしまって良いと直感しています。
SwiftUI 系の補助ライブラリはなし
これも前回の記事で触れていますが、SwiftUI 系の補助ライブラリは使用しておらず、SwiftUI に関わるライブラリとしては SwiftUI Introspect のみ(前回の記事で触れるのを忘れていました…)となっています。
SwiftUI Introspect
少なくとも iOS 16+ や iOS 17+ においては SwiftUI Introspect は必須レベルだと感じています。
後述するようにアラートは UIKit で表示する方法を採用しており、そのために表示先となる UIViewController を取得する必要があるのですが、その際は以下のコードパターンを採用しています。
@_spi(Advanced) import SwiftUIIntrospect
@MainActor
final class SampleScreenModel: ObservableObject {
// ✅ 1. UIViewController に限らず weak でキャプチャ
weak var viewController: UIViewController?
}
struct SampleScreenScreen: View {
@StateObject private var viewModel = SampleScreenModel()
var body: some View {
ZStack {
...
}
// ✅ 2. iOS 16+ を対象にして新しい OS が出たときの対応を不要に
.introspect(.viewController, on: .iOS(.v16...), scope: .ancestor) {
viewModel.viewController = $0
}
}
}
以前は循環参照によるメモリリークが発生しなければ、View の @State でキャプチャしていたこともあったのですが、ある日うっかり親である UIViewController をキャプチャして循環参照によるメモリリークを起こしてしまったことがあり、これは注意力に頼るよりもパターンで固定するのが良いと判断し、現在では ViewModel 側で weak キャプチャするようにしています。
@_spi(Advanced) を利用した特定のバージョン以上(.iOS(.v16...))という条件指定にはトレードオフがあり、OS のアップデートによって SwiftUI の裏側のコンポーネントが変わった場合でもコンパイルエラーにならないというデメリットもありますが、過去の OS アップデートによってそれが起こった箇所は非常に限定的であり、そもそも OS アップデート時はフル QA をすることになるので、この書き方をトレードオフとして受け入れています。
Swift Navigation
過去には Swift Navigation を導入したほうが良いだろうと考えたことは過去に何度もあったのですが、私の中でそれを入れるべき強い理由には今のところ遭遇していません。
チームによっては導入するのもありだと今でも感じますが、SwiftUI 標準の API でもシンプルで十分だとも感じており、入れるべき「強い理由」が開発チーム内で見つかったときに導入する任意ライブラリという位置づけがよいかな、と感じています。
ちなみに、私は遷移系の変数名は showXxx のスタイルで統一して実装時の判断コストをなくし、かつ検索しやすいよう(Greppability)にしています。
Swift Algorithms
SwiftUI 系の補助ライブラリではありませんが、 Swift Algorithms について過去に私はデフォルトライブラリ(最初から脳死で入れてしまう)という位置づけをしていましたが、現在では任意ライブラリの位置づけに落としています。
- SwiftUI によるアプリ開発で欲しくなる API がある
- リスト操作(キーによるグルーピングからのソートなど)で結局は欲しくなる
というのがデフォルトライブラリに位置づけていた理由ですが、過去のプロジェクトも含めて振り返ってみると使用している API は、
indexedchunks(ofCount:)
などの一部の頻出 API に偏っており、AI で簡単にコードを生成できるようになったことも含め、これくらいであれば自前実装したほうが良いと現在では判断しています。(といっても現在のプロジェクトではリスト操作で欲しい場面が出てきたので結局使っている点は補足しておきます)
ちなみに Swift Algorithms はデフォルトで遅延処理されるのですが、(パフォーマンス的に良くないのでそもそもやるべきではありませんが誤って)遅延シーケンスを SwiftUI の ForEach に与えるとクラッシュすることもあり、そういった点でも罠に引っかかりやすいと感じています。
ところで Haskell を愛用する人には「Haskell が遅延処理じゃなければ良かった」という方もいるそうですが、私も Swift Algorithms のデフォルト挙動が遅延処理でなければ、あるいは 「正格版の Swift Algorithms が欲しい」 とずっと感じています。
標準の API で不足を感じたら検討する
これは SwiftUI に限った話ではありませんが、開発においては標準の API で明らかに不足を感じて導入するべき強い理由が出たタイミングで検討するのが良いかと感じています。
早めに高級なライブラリ・フレームワークを入れてしまうと、
- そのライブラリを使いこなすことにエネルギーを使う(実装時により良い API を探したり、PR で「こう書けますよ」「こう書けるみたいです」といったコメントが飛びかったり)
- 新規参画したメンバーが知らなかった場合に学習コストが掛かる
- AI で生成するコードと合わなくなる(手直しが必要になる)
- 単純に複雑さが増す(ケースが多い)
- (とくにフレームワーク系の場合)あとから剥がそうとしてもコストが掛かる
といったものを経験則上(あるいは直感的に)では感じており、ライブラリやフレームワークを導入するのは慎重になったほうが良いと感じています。
一方、新規サービスを立ち上げる際は初速が最重要なこともあり、将来的にコストになるリスクを加味したうえで最初からライブラリを採用するのもありだと考えています。(結局はケース・バイ・ケースということにはなると思います)
UIKit の活用
SwiftUI 登場から初期にかけて、私は SwiftUI で統一するのが開発・保守の両面からもっとも優れているのだろうと考えていました。
しかし、実際に一定以上の規模のアプリを開発したり、複雑な機能を実装する機会を経て 「すべてを SwiftUI で書けば楽というわけではない」 と感じるようになりました。
アラート
私の中でもっとも使いづらく感じる API としてアラート・アクションシートがあります。
Button("アラート表示") {
showAlert = true
}
.alert(
"タイトル",
isPresented: $showAlert,
actions: {
Button("アクション1") { ... }
Button("アクション2") { ... }
Button("アクション3") { ... }
},
message: { Text("メッセージ") }
)
これは典型的なアラートのコード例で、これだけ見ると宣言的でスッキリしていて何も不足を感じません。
しかし、アラートのボタンをタップしたときの処理は直前のコンテキストに依存することが多く、クロージャ内で値をキャプチャして処理したいケースが頻出しますが、この宣言的な書き方でそれを直感的かつシンプルなコードで表現するのは難しく感じられます。
過去にはこの不満点を補うために、プロジェクトに合わせたリッチなアラート API を設計したこともあるのですが、明らかにオーバースペック(オーバーエンジニアリング)に感じられましたし、そもそも SwiftUI で宣言的に書くメリットも無いと感じました。(アラートのタイトルやボタンを状態に応じてリアクティブに切り替えたいと考えたことはありませんし、そもそも機能するのかも私は知りません)
結果、現在では Builder パターンを採用した UIKit ベースのアラートを使用しています。
AlertManager("タイトル", "メッセージ")
.action("アクション1") { ... }
.action("アクション2") { ... }
.action("アクション3") { ... }
.show(on: viewController)
標準の UIAlertController をそのまま使う形だとコード量的にシンタックスノイズに感じるので、個人的にはそのまま利用するよりも(Builder パターンでなくても良いと思いますが)簡易な API でラップしたほうが使いやすいと感じています。
枯れた技術
毎年 WWDC で発表される SwiftUI の機能は先進的で 「こんなに簡単にできるようになるの最高すぎる!」 と感じるのですが、実際に使ってみると不足を感じることが多々あります。
例えば、
- iOS 14 で導入された LazyXGrid はパフォーマンスの問題を抱えていた(List の代替にはならない)
- iOS 16 で刷新された Navigation API は明らかに不足しており、それは iOS 17 で追加された
- iOS 17 のカルーセルは基本的に動作するものの一部は iOS 18 でしか正しく動作しない
と挙げ始めればキリがありません。(やや言いすぎでしょうか?)
これはライブラリやフレームワークの開発において一般的な流れとして理解できるものの、Xcode などの開発ツールも含めて近年の Apple のソフトウェア品質は明らかに低下しており、私の中では最新の API・ツールはベータ版として見るようになっています。
そのため最近では SwiftUI で書けそうなものでも、枯れた UIKit の技術があればそちらを使う という意思決定をする機会が多くなっています。(これは AI でコード生成できるようになった点も大きいと感じています)
また、UIKit の話からは少しそれますが直近のプロジェクトでの iOS サポートバージョンは 16+ とかなり古いバージョンもサポートしていながら開発において明らかに不利な点は見つけられておらず、iOS アプリ開発においても「枯れた技術」が使える時代になったと感じています。(厳密には iOS 16.0 には致命的なバグが含まれているのですが、利用率はかなり低いので妥協範囲と感じています)
実装パターン
ここからは1画面あたりの基本的な構成について触れていきます。
1画面あたりの実装パターン
私が採用している典型的な1画面あたりのコードは以下のようになっています。
// ---- API レスポンス構造 ----
struct ArticleResponse: Codable {
var id: String
var title: String
}
// ---- View 用の構造体 ----
/// 記事
struct ArticleData: Identifiable {
var id: String
var title: String
}
// MARK: API
extension ArticleData {
init(from data: ArticleResponse) {
self.init(
id: data.id,
title: data.title
)
}
}
// MARK: Example
extension ArticleData {
static let examples: [ArticleData] = .examples
}
extension [ArticleData] {
static let examples: [ArticleData] = [
.init(id: "1", title: "XXX"),
.init(id: "2", title: "YYY"),
.init(id: "3", title: "ZZZ"),
]
}
// ---- ViewModel ----
@MainActor
class ArticleListScreenModel: ObservableObject {
@Published var articles: [ArticleData] = []
init() {
Task {
do {
articles = ...
} catch {
...
}
}
}
}
// ---- Screen ----
/// xxx画面
struct ArticleListScreen: View {
@StateObject var viewModel: ArticleListScreenModel = .init()
var body: some View {
ArticleListScreenContent(articles: viewModel.articles)
}
}
private struct ArticleListScreenContent: View {
var articles: [ArticleData]
var body: some View {
ZStack {
if articles.isEmpty {
empty()
} else {
list()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("記事一覧")
}
// 0件
func empty() -> some View {
Text("データがありません")
}
// 一覧
func list() -> some View {
List {
ForEach(articles) { article in
Text(article.title)
}
}
}
}
// MARK: Preview
#Preview("一覧") {
NavigationStack {
ArticleListScreenContent(articles: .examples)
}
}
#Preview("0件") {
NavigationStack {
ArticleListScreenContent(articles: [])
}
}
API からの取得・エラー・読み込み中などの考慮を省略していますが、これは普段私が直近で書いているコードほぼそのままで、MARK を含めたコメントや関数名までほぼ一緒です。
順に詳しく見ていきたいと思います。
View 用の構造体は用意する
以前、API レスポンス構造を View でそのまま参照していた時期もあったのですが、メンテコスト的にトレードオフが釣り合わないと感じており、ここ数年では View 用の構造体を用意する方針を続けています。
// ---- API レスポンス構造 ----
struct ArticleResponse: Codable {
var id: String
var title: String
}
// ---- View 用の構造体 ----
/// 記事
struct ArticleData: Identifiable {
var id: String
var title: String
}
// MARK: API
extension ArticleData {
init(from data: ArticleResponse) {
self.init(
id: data.id,
title: data.title
)
}
}
これは、
- Previews でのサンプルデータが作りやすく・メンテしやすい(これが最大の要因)
- サンプルデータがあれば Previews 以外でも活用しやすい(一時的に画面を見たいとか)
- View から参照されているデータがすぐに分かる
といった点に起因しています。
通常、View から参照されるのは API レスポンスの一部だけであることが多く、それをそのままサンプルデータとして利用すると不要なプロパティもブランク値(空文字列など)で埋める必要が出てきたり、かつ API レスポンスの変更によってメンテするコストも都度掛かってくるため、最初の実装時だけならともかくトータルではペイしづらいと感じています。
逆に Previews を作らないと割り切る場合は API レスポンス構造をそのまま View で使うのもありかな、とも考えています。
Previews 用のサンプルデータ
Previews 用のサンプルデータは以下のように定義しており、特筆すべき点として型 ArticleData と型 [ArticleData] の両方からサンプルデータにアクセスできるようにしています。
// MARK: Example
extension ArticleData {
static let examples: [ArticleData] = .examples
}
extension [ArticleData] {
static let examples: [ArticleData] = [
.init(id: "1", title: "XXX"),
.init(id: "2", title: "YYY"),
.init(id: "3", title: "ZZZ"),
]
}
これは開発時のコード補完(DX)を考慮したもので、単体の ArticleData を要求される場面でも .examples[0] といったようにアクセスできるようにして開発時のプチストレスを減らしています。
これは比較的最近の試みですが、個人的には開発体験の良いプラクティスだと感じています。
XxxScreen が ViewModel を持つ
コードのとおりですが XxxScreen が ViewModel を持ち、Previews 用に切り出した XxxScreenContent を内部で利用する形にしています。
/// xxx画面
struct ArticleListScreen: View {
@StateObject var viewModel: ArticleListScreenModel = .init()
var body: some View {
ArticleListScreenContent(articles: viewModel.articles)
}
}
これも前回の記事で触れていますが、実際に API を叩いて簡易的にプレビューできる場合は Previews 用の XxxScreenContent は切らずに済ますこともあります。
ちなみに ScreenModel という名前なのに変数が viewModel というのは違和感があるかもしれませんが、正直なところこの変数はなんでもいい(パターンの一部なので変数名自体に大きな意味をもたせなくても良い)と感じており、screenModel でも vm でも視認性が高く大きな違和感がない変数名を採用すれば良いと考えています。
シンプルな命名・コメント
XxxScreenContent について特筆すべき点はあまりありませんが、シンプルな命名・コメントに寄せるようにしています。
private struct ArticleListScreenContent: View {
var articles: [ArticleData]
var body: some View {
ZStack {
if articles.isEmpty {
empty()
} else {
list()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("記事一覧")
}
// 0件
func empty() -> some View {
Text("データがありません")
}
// 一覧
func list() -> some View {
List {
ForEach(articles) { article in
Text(article.title)
}
}
}
}
例えば、empty() や list() といったシンプルな関数名は実際に採用しているものですし、コメントの 0件 や 一覧 といったものもほぼそのままです。
Swift は Objective-C から続く流れで「正しいネーミング」が重視されることが多い印象ですが、限定されたスコープにおいて正確なネーミングを意識すると却って理解速度を妨げるケースもあると感じており、私自身は極力シンプルで短いネーミングを採用するようにしています。
仕様説明としての Previews
前回の記事でも触れたように、Previews はほぼ必ず用意しています。
// MARK: Preview
#Preview("一覧") {
NavigationStack {
ArticleListScreenContent(articles: .examples)
}
}
#Preview("0件") {
NavigationStack {
ArticleListScreenContent(articles: [])
}
}
厳格な表示パターンを網羅すると言うよりは、読み手に対して 仕様の Overview を素早く提供することを意識しています。(ただ結果としてパターン網羅には近くなります)
ちなみに、Preview マクロ内で @Previewable を使って状態を保持する方法もありますが、私は使用せずに iOS 16 以前の Preview 用の構造体を切り出すパターンを使用しています。
// iOS 17+ で用意された @Previewable を使った実装
#Preview {
@Previewable @State var isOn = true
Toggle("", isOn: $isOn)
}
// iOS 16 以前から使用できる実装
#Preview {
Preview()
}
private struct Preview: View {
@State private var isOn = false
var body: some View {
Toggle("", isOn: $isOn)
}
}
これはマクロ内でコンパイルエラーが発生したときに原因が分かりづらくなりやすいという問題を防ぐ目的で、コードの読み書きコストも含めたトータルのトレードオフとしてこちらを選択しています。
これに限った話でもありませんが、マクロ・Result Builder に代表されるように近年の Swift はコンパイルエラーの原因が(とくに初心者には)即座に分からないことが多く、そのあたりのトレードオフを読んで総合的に判断していく必要があると感じています。
コーディングスタイル
構造体を Identifiable に準拠させる
これはおそらく一般的なテクニックとして定着していると思うので軽くですが、構造体を Identifiable に準拠させForEach における一時的な id 指定はできるだけ避けるほうがよいと感じています。
// 🚫 こうではなく...
struct Article {
var id: String
var title: String
}
ForEach(articles, id: \.id) { article in
Text(article.title)
}
// ✅ こう
struct Article: Identifiable {
var id: String
var title: String
}
ForEach(articles) { article in
Text(article.title)
}
比較的 SwiftUI 初期に関わったプロジェクトにおいて前者のパターンが多用されていたのですが、画面によって id となるキー指定が違っていたりして、忘れたころにあとからバグとして見つかることが多くありました。(特定のデータパターン・操作の時だけ表示が変になる)
今でも UI 上では絶対に重複しない(検索履歴の文字列など)場合は手抜きをしてしまうこともあるのですが、将来的な保守性を考えると必ず中間構造を用意して一時的な id 指定は避けたほうが良いだろうと直感しています。
View のプロパティ・関数
View を定義するうえで以下の2つのルールを適用しています。
- プロパティは
var固定 - アクセス修飾子(
private)はつけない
前者は「通常の構造体を定義する際のパターン」にあわせることで、思考の切り替えコストを減らしているものです。(var vs let の議論はここでは割愛します)
後者は「人間が読み書きするうえで意識したいケースはない」(そもそも View の関数を呼び出したいケースはほぼ無いはず)という考えに起因しており、ビルド時のコストがそれを上回らない限りは省略して良いと考えています。
ビルド時のシンボルテーブルが少し大きくなったところでコンパイル時間に大きな差は与えないだろう(それよりは型推論のコストを1つ減らすほうがはるかに大きいだろう)という直感にもとづいていますが、もし影響することが分かった場合は AI を使って一気に付けたいと思っています。
これに限った話でもありませんが、Swift はそもそも強力な言語として作られているので「逆に楽ができるところは楽に済ませてしまう」 というのが私の中で哲学としてあるように感じます。
if-else を好む
SwiftUI を学習していると View ツリーや構造的 Identity といったものが出てきます。
以下のコードは基本的に等価ですが、前者は View ツリーが変化するもので、後者は変化しないものです。
// `false`になると Text は View ツリーから消える
if showText {
Text("Hello, World!")
}
// `false`になっても可視性が変わるだけで View ツリーは変化なし
Text("Hello, World!")
.opacity(showText ? 1 : 0)
これらは
- 画面の仕様(デザイン・ステート保持)
- アニメーション
- パフォーマンスが重要な箇所
といった場面で意識する必要が出てきますが、私の経験則上では大半が if-else の分岐で問題なく、そしてシンタックスハイライトやコードの形状によってコードの意味をより素早く理解できるのは if-else だと感じています。
一時期、全部 .opacity (あるいは .visible のようなラッパー)で統一したほうが読みやすいだろうかと実験的に書いてみたこともあるのですが、View の定義に埋もれてしまって瞬時に判断できず仕様の理解を妨げてしまうデメリットのほうが上回ると感じました。
そのため現在ではシンプルな if-else を好み、必要な場合のみ .opacity などを利用するようにしています。(これはパフォーマンス最適化における金言「必要になってから最適化しろ(まだ最適化するな)」に通ずるものがあると感じています)
独自モディファイアは控えめに
私は過去に(View の extension に定義する関数も含めた)モディファイアを積極的に作る方針を取っていました。
これは元を辿ると DDD に影響を受けており、
- 脳の思考パターンと同じようにコードを読み書きできる
- SwiftUI 黎明期において知識をコードに蓄積する(「知識をエンコードする」と表現しています)
といったことを目的としたものでした。
しかし近年では AI によるコード生成・レビューが一般化してきており、そういった中で独自モディファイアを多く定義していると、生成されたコードを修正するために多くの時間を取られると予想しており、現在では独自モディファイアをできるだけ控えています。
私自身はまだ AI をそこまで利用していませんが、プロジェクト独自の凝った実装が増えるほど生成 AI とは相性が悪くなるのではないかと想像しています。(が、それも正直どこまで続くかは私には読めません…)
文字列リテラルと Color
これは細かい話ですが、
- 文字列を指定するときは文字列リテラル
- Color を指定するときは型(つまり
Color)を省略せず
というコードスタイルを採用しています。
// 🚫 こうではなく
Text(article.title)
.foregroundStyle(.red)
// ✅ こう
Text("\(article.title)")
.foregroundStyle(Color.red)
両者ともアプリケーションハンガリアン(ご存知の方はいるでしょうか?)の考え方から影響を受けており、シンタックスハイライトやコードの字面から認識しやすいコードスタイルを採用しています。
ただ、ローカライズ対応が必要なアプリにおいては前者のテクニックは使えないかと思います。
Space(height:) はあったほうが便利
SwiftUI で最初に関わった業務プロジェクトにおいて、中間的なスペースを表現する Space を定義しました。(Android 側で同様のユーティリティがあったのも当時定義した理由の1つです)
List {
Text("xxx")
Space(height: 10)
Text("yyy")
}
これはとくに List 内の余白を調整するのに便利だったのですが「これは果たして本当にあったほうが有益なのだろうかと考え、それを検証するためにここ1年半ほどは .padding あるいは Color.clear.frame(height:) だけで実装してみました。
その結論として、やはり中立的なスペースを表現するユーティリティ的な View はあったほうが良いというのが私の所感で、List に限らず VStack も space を 0 にしてフラットな階層にして中間的なスペースを配置したほうが可読性が高いケースもあると感じています。
一方、無くて本当に困るかと言うと当然そんなことはないので、.padding や Color.clear.frame(height:) で済ましてしまうのも選択の1つだと思います。
return に複雑な式を書くのを避ける
SwiftUI とは直接関係ありませんが、return に複雑な式を書くのを避けるようにしています。
// 🚫 こうではなく
return (1 + 2) * (3 + 4)
// ✅ こう
let result = (1 + 2) * (3 + 4)
return result
これはデバッガビリティ(デバッグ容易性)を考慮したもので、ブレークポイントを張って途中結果を素早く確認できるコードスタイルにしています。
これは iOS や Swift に限らず、私が長年にわたって迷ってきたトピック(必要ならデバッグ時にコードを足せば十分では?など)ですが、現在はこちらのほうがトータルでは優れていると結論を出しています。
短い変数名を許容する
Go 言語において、一般的な変数には非常に短い決まった名前をつける慣習があります。
go
// コンテキスト
func handler(ctx context.Context) { ... }
// リクエスト・レスポンス
func handler(w http.ResponseWriter, r *http.Request) { ... }
// チャネル
ch := make(chan int)
上記は ChatGPT で出てきたものをコピペしただけなので間違っていたら恐縮ですが、ともかく ctx や w、 rといった非常に短い変数名が多用されていることが分かります。
私はプログラマが変数を読む時、
- 文章的解釈
- シンボル的解釈(記号的解釈)
の2つのモードを使い分けている(これらが一般的な概念として存在するかは知りません)と考えており、意味が明白になる場合(ループ変数の i など)は短い変数名を採用して「シンボル的解釈」にしたほうが読みやすいケースも多いと感じています。
一例として、現在関わっているプロジェクトでは以下のような短い変数名を利用しています。
// リクエスト・レスポンス
let r = XxxRequest()
let res = Session.shared.send(r)
// async let による並列取得
async let t1 = fetchStatus()
async let t2 = fetchArticles()
async let t3 = fetchFavorites()
let (status, articles, favorites) = try await (t1, t2, t3)
Swift はその前身である Objective-C の時代から文章的な命名が好まれており、実際のところ Swift にはネーミング規約もあったと思いますが、プロジェクトやチームで合意形成できるなら「守破離」における「破」や「離」を部分的に適用してみても良いかと感じています。
型は省略しない
これは最近というよりも直近の話なのですが、String や Bool といった初期値のリテラルだけで自明なケースでも型を省略しないようにしました。
// 🚫 こうではなく
@MainActor
class XxxScreenModel: ObservableObject {
@Published var text = ""
@Published var showXxx = false
}
// ✅ こう
@MainActor
class XxxScreenModel: ObservableObject {
@Published var text: String = ""
@Published var showXxx: Bool = false
}
ここ2〜3か月ほどは可読性の観点から前者の書き方を採用していたのですが、Xcode 26 にアップデートした際に簡単な CGFloat の計算式の型推論が遅すぎてコンパイルエラーになるという自体に遭遇しました。
そして、たまたまですが X(Twitter)上でもそういった式の型推論が遅いといった内容を目にしたこともあり、こういった書き方も型推論が遅くなるケースは全然あるだろうと考え直し、現在では後者の書き方に戻しています。(実際、Swift のリテラルは型を持たないので参照箇所すべてをチェックして推論しているはずです)
ただ、実際に計測したわけではないので、無視できるコストだった場合は再び後者の書き方に戻す可能性もあると考えています。
コメント
前回の記事でも触れていますが、私が過去に関わった iOS プロジェクトでは 「コメントの少なさ」が必ずと言っていいほど課題になって おり、キレイなコード・設計よりもコメントの丁寧さのほうが保守性に大きく影響すると経験則上では感じています。
私は『リーダブルコード』(名著ですが近代版が欲しいところです)の 『読みやすいコードというのは、それを理解するための時間が最小なコードである』 という定義を現在でも採用しており、少しでも早く読み解くことができるならコメントは処理の内容と重複しようと書くようにしています。
ドキュメンテーションコメント・処理の流れ
ドキュメンテーションコメントは private も含めてほぼ必ず記述しています。
/// 記事
struct ArticleData: Identifiable {
/// ID
var id: String
/// タイトル
var title: String
}
/// xxx画面
struct ArticleListScreen: View {
...
/// ...
private func xxx() { ... }
}
また順序的な処理についてもコメントを書いて、全体の処理の流れを Overview として読み手に素早く伝えるようにしています。
/// 記事一覧を取得
func fetchArticles(page: Int, query: String) async throws -> [ArticleData] {
// 1. ユーザステータスを取得
let userStatus = try await fetchUserStatus()
// 2. 記事一覧を取得
let articles = try await fetchArticles(
userStatus: userStatus,
query: query,
page: page
)
// 3. xxx
...
}
なお、上記の関数 fetchArticles を Xcode で自動生成(Cmd + Option + /)されたドキュメンテーションコメントのテンプレートで埋めようとすると以下のようになりますが、
/// 記事一覧を取得
/// - Parameters:
/// - page: ページ
/// - query: 検索文字列
/// - Throws: エラー発生時
/// - Returns: 記事一覧
func fetchArticles(page: Int, query: String) async throws -> [ArticleData] {
...
}
この形式を強制すると(とくに Throws や Returns が顕著ですが)冗長な記述が多くなることもあるので、すべて任意という位置づけにしてドキュメンテーションコメントを書くこと自体が目的になるのを防いでいます。(これに限った話でもありませんが「完璧」を目指したくなる心理が人間にはあると感じます)
なお、前述の例ではコンテキスト的に明白だと思われた page や query といった引数の説明も省略していますが、query が「部分一致」か「全体一致」かといったコンテキストが重要と思われた場合はそれを追記することもあります。
うまく動作しなかったコードと説明を残す
実装していると、正しい API の使い方をしている(はず)のに期待どおり動作しないケースに遭遇することがあります。
- 特定の iOS バージョンのみで発生する不具合
- 自身の理解不足から間違ったコードを書いている
など理由は様々ですが、こういったナレッジはコメントとして残しておくと役に立つケースが多いと感じています。
手元のプロジェクトを探してみると以下のようなコメントがありました。
// NOTE:
// `@Environment(\.scenePhase)`ではうまく検出できなかったので Notification で処理。
// (アプリのルートが SwiftUI にならないと動作しない?)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
...
}
このコメントからは、
- 最初に試した(おそらく SwiftUI において理想的な)
scenePhaseでは動かなかった - アプリのルートが SwiftUI になってないと動作しないのではないかという仮説
の2点が書かれており、この実装が古いコードパターンで書かれているからと言って安易に最新の書き方にはできなそうということが読み取れ、あとからコードを読んだ人がリファクタリングを試みてムダな時間を浪費するのを防ぐのにつながると予想しています。(PR のディスカッションに閉じているとコードを読んだだけでは判断できない)
特定の OS バージョンのみで発生する不具合の場合は、遭遇したバージョンも明記するようにしています。
// NOTE:
// 〇〇の問題があって以下のコードを採用している。(iOS 18.2 実機で確認済み)
不具合が発生する対象バージョンがある程度絞り込めれば、将来的な判断もしやすくなると直感しています。
既存コードにコメントを追加する
既存コードを読み解く際に「分かりづらい」と感じることは、エンジニアとして仕事をしていると何度もあるかと思います。
私の経験上そういったコードは、
- 名前のわかりづらさ
- 責務分割の不足
- 過剰な設計・責務分割
- 読み手を意識しないコード(「動けばいい」で書かれたコード)
といったものに分類されることが多い印象ですが、多くのケースにおいてその場ですぐには直せないと感じています。(名前は IDE のリファクタリング機能を使えば簡単なこともありますが、その変更後の名前が妥当なものか判断するには時間を置く必要があると感じます)
しかし、コメントを追記するのは手軽で処理に影響もなく、次回以降の読み手により多くのヒントを与えてプロジェクト全体の時間を節約することに繋げられます。(余談ですが、私はコードを読み解く際にコメントを足しながら進めることが多いです)
私はこういった「ついでにコメントを書く」ことが多く、自分が過去に書いたコードについても内容の理解が少しでも遅れると感じた場合はコメントを追記するようにしており、「呼吸するようにリファクタリングする」に習って 「呼吸するようにコメントを書く」 と表現しています。
仕様・ドメイン理解への援助
前回の記事でも触れたようにコードを読み進めれば仕様・ドメインについて理解が深まるという状態を1つの理想形として現在では考えています。
これは私の所感的な話なので横道にそれますが、いつからかエンジニア業界全体が 「コメントが無くても分かるコードを書く」 から 「コメントを書くのは初心者のコードだ」 といった空気にすり替わってしまったように感じています。
実際「コメントが不足している」というのがプロジェクト課題に上がってさえ、メンバーの誰1人としてコメントを書こうとしないという場面も過去には遭遇していて、これはコメントを書くのが面倒というよりもコメントを書くのが怖いという心理状態に陥っているように直感され、これは心理的安全性に近い社会心理学的な問題になっているようにも感じます。
コメントを書くのは面倒ですし、正解の無さや表記揺れ の問題 (これはまた別の深い話につながるのですが、ここでは「妥協することが大切」だと考えていることを付記しておきます)もありますが、それでもなお保守性のためにコメントをたくさん書くのは非常に有用だと経験則上感じています。
テクニック
Environment
前回の記事でも触れたように EnvironmentObject は原則利用しない方針にしていますが、Environment は以下のケースで限定的に利用しています。
- View の深い階層から気軽にアクセスしたい
- 横断的に一気に値を適用したい
直近のプロジェクトでは以下の2つのみを利用していました。
extension EnvironmentValues {
/// シートを閉じる
@Entry var dismissSheet: () -> Void = {}
/// フォームのテーマ
@Entry var formStyle: FormStyle = .default
}
シートを閉じる
私が SwiftUI で実装していて(おそらく)もっとも多く遭遇する実装ミスは 「シートを閉じようとして dismiss して前の画面に戻ってしまう」 というものです。(ほとんどの方が経験あるのではないでしょうか)
// 親画面
struct SampleScreen: View {
@State private var showSheet = false
var body: some View {
Button("シート表示") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetScreen()
}
}
}
// シート画面1
struct SheetScreen: View {
@State private var showNext = false
var body: some View {
NavigationStack {
Button("次の画面へ") {
showNext = true
}
.navigationDestination(isPresented: $showNext) {
SheetChildScreen()
}
}
}
}
// シート画面2
struct SheetChildScreen: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("閉じる") {
dismiss() // 🚫 シートを閉じるつもりだが前の画面へ
}
}
}
遷移元からシートを閉じるのに必要な情報を Binding で渡す($showSheet)のも手ですが、シート上の画面階層が深くなるにつれて引き回すコードが増えていくので、私はシートを閉じる用の Environment を用意するようにしています。
extension EnvironmentValues {
/// シートを閉じる
@Entry var dismissSheet: () -> Void = {}
}
struct SampleScreen: View {
@State private var showSheet = false
var body: some View {
Button("シート表示") {
showSheet = true
}
.sheet(isPresented: $showSheet) {
SheetScreen()
// ✅ 1. シートを閉じるときの処理を記述
.environment(\.dismissSheet, { showSheet = false })
}
}
}
...
struct SheetChildScreen: View {
@Environment(\.dismissSheet) var dismissSheet
var body: some View {
Button("閉じる") {
dismissSheet() // ✅ シートを閉じる
}
}
}
これはもっともシンプルな実装なので、プロジェクトによっては適宜ラッパーを用意するのもありだと考えています。
// Modifier でラップしたり
.sheet(isPresented: $showSheet) {
SheetScreen()
.dismissSheet({ showSheet = false })
}
// 独自のシートモディファイア内で吸収したり
.xxxSheet(isPresented: $showSheet) {
SheetScreen()
}
フォームスタイル
こちらについては特筆すべき点も無いのですが、横断的に一気に値を適用したいケースに該当する例になっています。
// ✅ 1. フォーム部品に一気に適用
AddressForm {
...
}
.formStyle(.translucent)
...
// ✅ 2. 各フォーム部品で参照してスタイルを適用
struct FlowerTextField: View {
@Environment(\.formStyle) var formStyle
}
EnvironmentObject にも共通しますが、階層をまたいで一気にアクセスが可能な一方で実装ミスをコンパイルエラーとして検出できないため、値の引き渡しが面倒になってきたら導入を検討するのが良いかもしれません。
ロギング
アプリを開発しているとポーリングなど定期的に実行する処理が登場することがあります。
// 1秒間隔で3回までトライ
for i in 1 ... 3 {
do {
let status = try await fetchStatus()
// ステータスに応じた処理...
} catch {
CrashlyticsManager.shared.send(error)
try await Task.sleep(for: .seconds(1))
continue
}
}
コード例がぱっと思い浮かばず適当な感じになってしまったのですが、こういう処理はどこかしらで登場することが多いと思います。
私はこういったワンショットで終わらないコードを書く際には Logger(OSLog)を利用してログを入れるようにしています。
import OSLog
extension Logger {
static let pooling = Logger(category: "Pooling")
}
...
// 1秒間隔で3回までトライ
Logger.pooling.info("開始")
for i in 1 ... 3 {
Logger.pooling.info("\(i)回目")
do {
let status = try await fetchStatus()
Logger.pooling.info("\(i)回目: status - \(status)")
// ステータスに応じた処理...
} catch {
Logger.pooling.info("\(i)回目: エラー - \(error)")
CrashlyticsManager.shared.send(error)
try await Task.sleep(for: .seconds(1))
continue
}
}
こういった通常のイベントドリブンよりも生存期間が長いコードにおいては、
- うまくいかなかった場合の調査
- 修正後の動作確認(と得られる安心感)
などにおいてログがあるかどうかは雲泥の差だと感じています。
ツール
この記事の本題とは少しズレますが、開発ツールについても少し補足したいと思います。
SwiftFormat はビルドフェーズに組み込まない
これは前回の記事の内容とも重複しますが、SwiftFormat はビルドフェーズに組み込まず手動で実行するようにしています。
shell
$ swiftformat .
以前は Xcode のビルドフェーズに組み込んでいたのですが、稀に Xcode の undo スタックを壊して Cmd + Z で戻せなくなるストレスがあったためですが、これはチームの好みもあるかもしれません。
手動は実行し忘れのデメリットもありますが、常に完璧な状態を維持しなくても問題ないという考え方をしています。(どこかのタイミングで最終的にフォーマットされれば良い)
コードスニペットは外部ツール
以前は Xcode にスニペットを登録して利用していましたが、現在では Raycast に移行しています。
Xcode のスニペットだと (あらためて言語化しようとすると難しく感じるのですが…) 補完でシュッと出したい時に出ないことが多く使い勝手がイマイチだと以前から感じており、そんな中で Raycast を試してみたら良かったので移行しました。
一例を挙げると以下のようなスニペットを登録しています。(コメントに記載したものがトリガー)
// :state
@State private var
// :preview
private struct Preview: View {
var body: some View {
}
}
// :button
Button {
<#code#>
} label: {
<#code#>
}
// :sleep
try await Task.sleep(for: .seconds(1))
// :ns
let error = NSError(domain: "X", code: 42)
throw error
// :dismiss
@Environment(\.dismiss) private var dismiss
dismiss()
// :introv
weak var viewController: UIViewController?
@_spi(Advanced) import SwiftUIIntrospect
.introspect(.viewController, on: .iOS(.v16...), scope: .ancestor) {
viewModel.viewController = $0
}
書いていて思い出しましたが、最初のキッカケは label つきの Button のコード補完がうまくいないことがあった(↑ のコードのように出力して欲しいがならないこともあった)ことだったかもしれません。
この Raycast を使ったコードスニペットの体験はたいへん良く、地味に日々の生産性を上げてくれていると感じています。(もっとも多用しているのは :sleep かもしれません)
哲学
最後にこれまで書いてきた内容全体に通じている、私自身がどのような考え方にもとづいて意思決定をしているの書いてみたいと思います。(ややポエムよりの内容になります)
シンプルで最小を保つ
『直感的でシンプルな最小の状態を保つ』 というのはソフトウェア開発において昔から存在する格言で、多くの人が一度は耳にしたことがある、あるいはこの考えを受け入れていると思います。
「最小な状態を保つ」は将来を見越した共通化や抽象化を避け、現在の事業やプロダクトにとって必要最低限なコードしか書かないようにすることで、こちらは字面から非常に分かりやすいものです。
個人的に難しいと感じるのは「直感的でシンプルな」の方で、誰しもがシンプルさについての感覚を持っていると思いますが、シンプルと複雑の境界線を引くのはかなり困難かと思います。
ここでシンプルさの定義をするのはやはり困難だと感じるので避けますが、私自身は 「思考の段数」と「積み重ね順序」が1つのヒントになるのだろうと感じています。
前者の例を挙げると、
- 否定の否定(条件式など)
- ポインタのポインタ
といったもので、思考の段数を踏むものを考えるのは人間の脳にとって苦手と言われており、とくに後者については『Joel on Software』で直感的に理解できるかどうかでプログラマとしての素養があるか分かるとまで書かれています。(どうやら私にプログラマとしての素質は無いようです…)
後者の例を挙げると、
- 説明が前後していて分かりづらい
- 技術書で「これはあとから説明します」が多いと読みづらい
といったもので、(これも『Joel on Software』で書かれている内容ですが)人間の脳はランダムアクセスではなくシーケンシャルアクセスしかできず、それは私たちの脳がストーリーを理解するように進化してきたのが理由だと言われています。
両者ともそれに実際に遭遇している時の自分の思考の動きを観察すると「負荷がかかっている」ことが実感できるもので、逆に言えばこういった負荷がかかるものを避けたパターン、もっと言えば子供でも理解できるような負荷がほとんどないパターンは直感的でシンプルなのではないか、と個人的には直感しています。
私自身、シンプルな設計・コードが良いと理解するまでにそもそも多くの時間がかかりましたが、シンプルな設計・コードを選択できるようになったと自覚するまでにはそれよりさらに多くの時間がかかったように感じています。(し、今後も切磋琢磨して感覚を磨いていくことになると思います)
このあたりはたくさんの保守作業(仕様変更・バグ修正)の経験をとおして、少しずつ感覚を磨いていくしかないのだろうとも感じています。
仮説検証と探求
この記事の内容からも読み取れるように、私は多くの仮説検証を繰り返しながらよりよいプラクティスやコードパターンを探求しています。
プロのエンジニアとして最初から完璧で非の無いコードを書きたい気持ちも生まれますが、それよりは最初はシンプルで愚直なアプローチを取って事業やプロダクトにコミットし、あとから見つかった課題から次にどうするべきかの一手を考えることが大切だと私は感じています。
というのは事業やプロダクトにとって必要なものは異なり、その点を外してしまうと「技術のためのコード」になって簡単に手段の目的化につながり、そういったものをあとから捨てるのは心理的に困難であるという経験則があるからです。
日常的なヒントとしては、「事業貢献」と「研究」を切り分けるのは良いプラクティスだと感じています。
事業貢献はいわゆる純粋なエンジニアリングの仕事で、与えられた課題を解決することだけに全力を尽くすことで、大雑把に言えば給与という対価のための労働作業です。
一方の研究はよりよい方法がないか探求するもので、
- 最新のライブラリ・フレームワークの検証
- 実装パターンの検証
- ツールの検証(今なら AI など)
- 自分の学習のためにコードを書く(自己成長への投資)
などにあたるもので、いわば企業における研究投資のようなものです。
難しいのは (エンジニアにとってはとくに)「どちらも仕事の一部」である ことで、これを明確に意識して切り分けないと、そのときの気分によって好きなほうに時間を投資してしまうケースがあることです。(私自身も何度も経験しています)
実際のところ、どれくらいのバランスでやれば良いか判断するのはもはや経営にちかいと考えており、少なくとも組織や事業がどういう方向に進みたいと考えているのかを深く理解する必要があると感じます。
そして、おそらく多くの人が感じているようにこれは本当に難しく正解のないもので、状況に合わせて何度も調整していく必要があるものだと思います。
ただ、そこまで行かなくても自分が「事業貢献」と「研究」のどちらのモードで作業しているのか自覚するのは、エンジニアとして成長するうえでのヒントになると感じています。
事業にコミットする
ここまで書き進めてみると、エンジニアリングという仕事はやはり簡単なようで難しく感じます。(多くの人がこれの繰り返しだと思います)
ただ、事業にコミットするという点さえ忘れなければ多くの場合はうまくいくようにも感じており、知識・経験の不足や間違いを恐れずにどうしたら貢献できるかを考えて仕事を進めていれば、結果として良いエンジニアリングにたどり着くのではないか、と個人的には直感しています。
『嫌われる勇気』の言葉を借りれば、「真剣になる必要はあっても、深刻になる必要はない」と私自身は感じています。
おわりに
そんなわけで SwiftUI でアプリを開発する際に、現在どのようなコードパターン・コードスタイルを採用しているか思い出したものを書いてみました。
前回の記事とも重複しますが、
- シンプルで最小限のアプローチから始める
- 完璧でない状態を受け入れる
というのが(とくにチームにおける)ソフトウェア開発においては重要だと感じています。
なお、この記事で言及した内容をベストプラクティスとは考えておらず(何かを唯一のベストプラクティスと捉えることがアンチパターンの1つだと私は考えています)、結局は組織・チームにとって必要な最適解を求めていく作業は必要だと思います。
この記事がそれを進める上での何かしらのヒントに繋がるものがあれば、執筆者冥利に尽きます。