最初に正直なところを書きます。Rork Max の SwiftUI 生成は本当に速いです。プロンプトを書いて、数秒後には動くアプリのスケルトンが手元にあります。私もこの体験に最初は感動しました。ただ、そのまま App Store Connect にアップロードした 1 本目のアプリは、Apple の審査でリジェクトされました。原因はクラッシュ、メモリリーク、そしてアクセシビリティ違反でした。
そこから 30 本以上のアプリを Rork Max で作って公開してきた中で、生成直後に必ずチェック・修正するパターンが固まってきました。ここではその 10 パターンを、実際に詰まったコードのビフォー/アフター付きで共有します。読み終えたとき、あなたの手元にある「動くけど不安」なコードが「動いて、しかも安心して提出できる」コードに変わるはずです。
なぜ生成コードはそのままでは通らないのか
Rork Max の AI は「動くコードを最短で出す」方向に最適化されています。これは初期スケルトン作成では最高の特性ですが、トレードオフとして本番運用で必要な要素が落ちることがあります。具体的には次の 3 つです。
- エッジケースの省略 — 空配列・通信失敗・キャンセル時の挙動
- iOS バージョン分岐の単純化 — 最新 API を使うが古い OS でクラッシュ
- アクセシビリティとローカライズの未対応 — VoiceOver や RTL 言語
これらを生成プロンプトで完全にカバーするのは難しいため、生成後の手動チェックが品質の最後の砦になります。Rork Max の生成プロンプト改善については Rork Max SwiftUI: AI プロンプト設計と生成品質の最大化 で詳しく書いていますが、本記事はその次のステップ、つまり「生成後の磨き上げ」に焦点を当てます。
パターン 1: @StateObject と @ObservedObject の取り違え
これは個人的に一番よく遭遇するパターンです。Rork Max は親ビューでも子ビューでも @ObservedObject を使うことがあり、結果としてビュー再描画のたびに ViewModel が再生成されてしまいます。
// ❌ Before — Rork Max がよく出すパターン
struct TaskListView: View {
@ObservedObject var viewModel = TaskViewModel() // 再生成される
var body: some View {
List(viewModel.tasks) { task in
TaskRow(task: task)
}
}
}このコードは初期表示は正しく動きますが、親ビューが再描画されるたびに TaskViewModel() が新しく作られ、API リクエストが何度も走ったり、@Published プロパティが初期化されたりします。
// ✅ After — ライフサイクルが明確
struct TaskListView: View {
@StateObject private var viewModel = TaskViewModel() // ビューと同じ寿命
var body: some View {
List(viewModel.tasks) { task in
TaskRow(task: task)
}
}
}
struct TaskRow: View {
@ObservedObject var task: Task // 親から渡される — @ObservedObject が正しい
var body: some View {
HStack {
Text(task.title)
Spacer()
if task.isCompleted { Image(systemName: "checkmark") }
}
}
}判断基準はシンプルで、そのビューが ViewModel を「所有」するなら @StateObject、外から「渡される」なら @ObservedObject です。生成コードの全 ViewModel プロパティに対してこの基準で見直すと、不要な再生成バグの 8 割は消えます。
パターン 2: Task の二重起動とキャンセル漏れ
非同期処理を .task ではなく .onAppear の中で Task { ... } として書いている生成コードを頻繁に見ます。これはビューが消えても処理が継続し、ネットワーク帯域とバッテリーを浪費する原因になります。
// ❌ Before — キャンセルされない非同期処理
struct FeedView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { Text($0.title) }
.onAppear {
Task {
posts = try await API.fetchPosts() // ビュー破棄後も走る
}
}
}
}ビューが画面から消えてもこの Task は継続し、最悪のケースでは画面復帰時に古いレスポンスで上書きされ、ユーザーが見ている内容が突然切り替わるバグになります。
// ✅ After — .task はビュー破棄時に自動キャンセル
struct FeedView: View {
@State private var posts: [Post] = []
@State private var loadError: Error?
var body: some View {
List(posts) { Text($0.title) }
.task {
do {
posts = try await API.fetchPosts()
} catch is CancellationError {
return // 静かにキャンセル
} catch {
loadError = error
}
}
.alert("読み込みに失敗しました", isPresented: .constant(loadError != nil)) {
Button("OK") { loadError = nil }
}
}
}.task は SwiftUI のビューライフサイクルと連動するため、画面遷移時に自動でキャンセルされます。CancellationError を握りつぶすのを忘れると、戻るボタンを押しただけでエラーアラートが出るアプリになります。
パターン 3: List のセル再利用が効いていない
Rork Max は時々、List の代わりに ScrollView { LazyVStack } を使う生成をしますが、行数が多くなるとパフォーマンスが落ちます。
// ❌ Before — メモリを食い続ける
ScrollView {
LazyVStack {
ForEach(items) { item in
ComplexRow(item: item) // 全行が一度生成されたら破棄されない
}
}
}私が試したケースでは、1,000 件のリストで 200MB 近くメモリを使い、低スペック端末でクラッシュしました。
// ✅ After — List はセル再利用される
List(items) { item in
ComplexRow(item: item)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.listStyle(.plain)「List だとカスタマイズしにくい」というのは古い iOS 14 時代の常識です。iOS 17 以降の List は listRowSeparator listRowInsets listSectionSpacing で大半のカスタマイズができますし、メモリ効率は段違いです。
パターン 4: Identifiable 違反による表示崩れ
ForEach に id: \.self を指定したコードは、要素が重複した瞬間に SwiftUI が混乱します。これは生成コードでよく見るパターンです。
// ❌ Before — タイトルが同じだと表示が崩れる
ForEach(comments, id: \.text) { comment in
CommentRow(text: comment.text)
}ユーザーが「ありがとう」というコメントを 2 つ投稿した瞬間、片方のコメントが表示されない、削除すると別のコメントが消える、といった怪奇現象が起きます。
// ✅ After — 一意な ID を使う
struct Comment: Identifiable {
let id: UUID // または UUID().uuidString
let text: String
let author: String
let createdAt: Date
}
ForEach(comments) { comment in // Identifiable なら id 指定不要
CommentRow(comment: comment)
}サーバから取得するデータには必ず一意な id を含めること、UUID() で生成するなら生成タイミングをイニシャライザにする点が肝心です。var id: UUID { UUID() } のような computed property は毎回別の値になり、id の意味を成しません。
パターン 5: メインスレッド外の UI 更新
Swift Concurrency に慣れていない開発者向けに Rork Max が生成するコードは、@MainActor の付与が抜けていることがあります。
// ❌ Before — Xcode 16 の警告 + 古い iOS でクラッシュ
class FeedViewModel: ObservableObject {
@Published var posts: [Post] = []
func load() async {
let result = try? await API.fetchPosts()
self.posts = result ?? [] // バックグラウンドスレッドから @Published 更新
}
}「Publishing changes from background threads is not allowed」という警告が出ますし、たまにクラッシュします。
// ✅ After — @MainActor で全メソッドをメインスレッド固定
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var posts: [Post] = []
@Published private(set) var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await API.fetchPosts()
} catch {
// エラーは別の @Published プロパティで保持
}
}
}クラスレベルで @MainActor を付けると、メソッドはすべてメインスレッドで実行されます。バックグラウンドで走らせたい重い処理だけ nonisolated func processInBackground() async のように明示的に外すパターンが本番運用に向いています。
パターン 6: NavigationStack のパス管理
iOS 16 で導入された NavigationStack を Rork Max が使う際、@State private var path = NavigationPath() が抜けていると、深い階層からの戻り方が制御できません。
// ❌ Before — プログラム的に戻れない
NavigationStack {
HomeView()
}決済完了後に「ホームまで戻る」を実装したいのに、popToRoot が SwiftUI に存在しないため詰みます。
// ✅ After — パスを保持して制御可能に
@MainActor
final class AppRouter: ObservableObject {
@Published var homePath = NavigationPath()
func popToHomeRoot() { homePath.removeLast(homePath.count) }
}
struct ContentView: View {
@StateObject private var router = AppRouter()
var body: some View {
NavigationStack(path: $router.homePath) {
HomeView()
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
.navigationDestination(for: CheckoutStep.self) { step in
CheckoutView(step: step)
}
}
.environmentObject(router)
}
}決済完了画面から router.popToHomeRoot() を呼べば、SwiftUI は瞬時にルートまで戻します。プッシュ通知タップでアプリ内特定画面を開く実装も、このパスを使えばエレガントに書けます。詳しくは Rork Max のディープリンクとユニバーサルリンク完全ガイド も参考にしてください。
パターン 7: Image の非同期ロードでメモリリーク
AsyncImage をそのまま使うと、スクロールが激しいリストでメモリ使用量が跳ね上がります。
// ❌ Before — 同じ URL を何度もロードする
List(items) { item in
AsyncImage(url: URL(string: item.imageURL)) // キャッシュなし
.frame(width: 80, height: 80)
}スクロールで戻るたびに同じ画像を再ダウンロードし、データ通信量とメモリを浪費します。
// ✅ After — Kingfisher 等のキャッシュライブラリを使う
import Kingfisher
List(items) { item in
KFImage(URL(string: item.imageURL))
.placeholder { Color.gray.opacity(0.2) }
.fade(duration: 0.25)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
}Apple 純正にこだわるなら、URLSession の URLCache.shared を活用しつつ独自キャッシュレイヤーを書くこともできますが、本番アプリでは Kingfisher か Nuke のどちらかを入れる方が合理的です。私は手の届く範囲のメンテナンスコストで考えて、Kingfisher を選んでいます。
パターン 8: 文字列ハードコードのまま提出
Rork Max は日本語プロンプトに対して日本語、英語プロンプトに対して英語のラベルを生成します。これは便利ですが、ローカライズ対応のコードにはなっていません。
// ❌ Before — 日本語ベタ書き
Button("購読する") { subscribe() }
Text("月額¥980で全機能利用可能")App Store の海外展開を後から決めると、全画面の文字列を 1 つずつ String(localized:) に置き換える地獄が待っています。
// ✅ After — Localizable.xcstrings 経由
Button(String(localized: "subscribe.button.title")) { subscribe() }
Text(String(localized: "subscribe.price.monthly"))String(localized:) は iOS 15+ 対応で、Xcode 15 から導入された .xcstrings フォーマットと組み合わせると、翻訳の管理が劇的に楽になります。.strings ファイルの時代と比べて、未翻訳キーの検出や複数形対応が組み込まれているのが利点です。
パターン 9: VoiceOver で読み上げられない UI
アクセシビリティ対応の漏れは、App Store 審査で指摘されることもあるリジェクト要因です。Rork Max の生成コードは accessibilityLabel を付け忘れることが多いので、提出前に必ず通しチェックします。
// ❌ Before — VoiceOver で「ボタン」としか読まれない
Button(action: addToFavorites) {
Image(systemName: "heart")
}
// ❌ Before — 装飾画像が「画像」と読まれて邪魔
Image("background-pattern")// ✅ After — 意味のあるラベルとトレイト
Button(action: addToFavorites) {
Image(systemName: "heart")
}
.accessibilityLabel(isFavorite ? "お気に入りから削除" : "お気に入りに追加")
.accessibilityHint("ダブルタップで状態が切り替わります")
Image("background-pattern")
.accessibilityHidden(true) // 装飾画像は読み上げ対象外に私の運用では、Xcode の Accessibility Inspector を起動して「Audit」を全画面で実行し、警告ゼロを提出条件にしています。この手間を惜しむと、視覚障害のあるユーザーに使えないアプリを世に出すことになります。
パターン 10: 本番環境の API キーがコードに埋め込まれている
これは技術的というより運用の問題ですが、Rork Max は API 連携を生成するときに、プロンプトに含まれていたキーを Swift ファイルにそのまま埋め込むことがあります。
// ❌ Before — GitHub に push した瞬間に流出
struct API {
static let openAIKey = "YOUR_API_KEY_PLACEHOLDER"
static let stripeKey = "YOUR_STRIPE_PUBLISHABLE_KEY"
}GitHub Secret Scanning が反応する程度ならまだ良い方で、気付かずに公開リポジトリに上げると数分以内に不正利用されます。私の知人は、間違って公開した OpenAI のキーで一晩で 8 万円の請求が来ました。
// ✅ After — Info.plist + 環境別 xcconfig で分離
// API.swift
struct API {
static var openAIKey: String {
Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String ?? ""
}
}
// Debug.xcconfig (gitignore)
OPENAI_API_KEY = sk-debug-xxxxxxxxxxxx
// Release.xcconfig (gitignore)
OPENAI_API_KEY = sk-prod-xxxxxxxxxxxx
// Info.plist
// <key>OPENAI_API_KEY</key>
// <string>$(OPENAI_API_KEY)</string>さらに本番では、クライアントから直接サードパーティ API を呼ばず、自前のバックエンド経由にすることをおすすめします。Cloudflare Workers なら無料枠で十分な量を捌けますし、キーをサーバ側に閉じ込めればクライアント露出を完全に防げます。バックエンド構築については Rork Max の Cloudflare Workers + tRPC で型安全バックエンドを構築する が参考になります。
リファクタリング後にやるべきこと
10 パターンのチェックを通したら、最後に次の 3 つを必ず実行します。
最初に Xcode の Static Analyzer を Product → Analyze で走らせます。Swift Concurrency 関連の警告は放置するとリリース後に Crashlytics に大量のクラッシュレポートとして返ってきます。次に Instruments の Allocations と Leaks で 5 分間アプリを操作し、メモリリークと過剰なヒープ確保がないか確認します。最後に TestFlight 内部テスト に上げて、自分以外の端末(特に低スペック iPhone SE 第 3 世代など)で 30 分以上触ります。シミュレータでは出ない発熱・バッテリー問題が、ここで見つかります。
改善した内容を生成プロンプトに戻す
ここが本当に大事なポイントです。リファクタリングした内容を Rork Max の次回生成プロンプトのテンプレートに反映させると、同じバグを毎回直す手間がなくなります。
私は次のような「品質要件プロンプト」を全プロジェクトの先頭に貼っています。
## 必須要件
- ViewModel は @StateObject で所有し、子ビューには @ObservedObject で渡す
- 非同期処理は .task を使い、CancellationError を catch して握りつぶす
- ObservableObject クラスは @MainActor を付与する
- ForEach は Identifiable な型に対してのみ使い、id: \.self は使わない
- ボタンと画像には accessibilityLabel を必ず付ける
- 文字列リテラルは String(localized:) でラップする
- API キーは Info.plist + xcconfig 経由で読み込む
このプロンプトを使うようになってから、リファクタリングに費やす時間が当初の 1/3 程度に減りました。AI コード生成と人間のレビューを組み合わせる、というのはこういう意味だと思います。
次のアクション
まずは今手元にある Rork Max 生成コードを開き、上の 10 パターンのうち 1 つだけチェックしてみてください。私の経験上、ほとんどのプロジェクトでパターン 1(@StateObject 取り違え)かパターン 5(@MainActor 抜け)のどちらかは必ず見つかります。1 つ直すだけでも体感できる安定性向上があるはずです。そのうえで残り 9 パターンを順に潰していけば、App Store 審査に出せる品質まで持っていけます。