跳到主要内容

用 AI 写 SwiftUI:5 个提示词模板与 SwiftUI 代码 8 个常见坑

用 AI 写 SwiftUI,出错最多的是用了高于最低 iOS 版本的 API、混用 ObservableObject 和 @Observable、在 body 里做副作用。本文给出 5 个可直接复制的提示词模板和 8 个常见坑的错误写法与正确写法。

用 AI 写 SwiftUI,出错最多的不是语法,而是用了高于最低 iOS 版本的 API、把 ObservableObject 和 @Observable 两套写法混在一起、把网络请求这类副作用放进 body。解决办法是在提示词里写清最低 iOS 版本、状态管理方式和不能动的范围,生成后先编译检查,再装到 iPhone 上验证。这篇给出 5 个可以直接复制的提示词模板和 8 个常见坑的错误写法与正确写法。

AI 助手在 iOS 工程里的完整工作流程见在 VS Code 里用 AI 编程助手写 iOS App,仓库里的长期约定写进说明文件,见 AI 编程助手说明文件模板。这里只讲单次对话的提示词和 SwiftUI 代码本身。

SwiftUI 提示词里必须写清哪四件事?

  • 最低 iOS 版本。AI 默认按它见得最多的新写法生成。写明「最低支持 iOS 15.0」,它才会避开 NavigationStack、@Observable。
  • 状态管理方式。写明用 ObservableObject + @Published 还是 @Observable,两套不要混用。
  • 放在哪、不能动什么。给出文件名和类型名,写明「不要改 XxxStore 的存储方式」「不要引入第三方库」。
  • 完成标准。要求「改完能编译通过」,再列出要在手机上验证的操作。

下面的模板把方括号里的内容换成你自己的。工程里已经有说明文件的,模板里和它重复的行可以删掉。

模板一:新页面

1在 [Views/SettingsView.swift] 新建一个 SwiftUI 设置页 SettingsView。
2工程约束:最低支持 iOS [15.0],Swift 5,状态管理用 ObservableObject + @Published,不要用 iOS [16] 及以上才有的 API,确实需要时用 if #available 包起来并给出低版本的写法。
3页面内容:
41. 一个 Form,分「账号」和「通用」两组。
52. 账号组显示昵称(只读)和「退出登录」按钮,点按钮弹出确认框。
63. 通用组有「深色模式」开关,值保存在 UserDefaults 的 key "darkMode"。
7要求:不引入第三方库,不改其他文件。给出 #Preview 时放在 #if DEBUG 里。最后列出我需要在手机上验证的操作。

模板二:列表 + 详情

 1做一个订单列表页和详情页。
 2工程约束:最低支持 iOS [16.0],导航用 NavigationStack + navigationDestination(for:),状态管理用 ObservableObject。
 3数据:Order 结构体已经在 [Models/Order.swift],有 id、title、amount、createdAt,已经遵循 Identifiable,不要修改它的字段。
 4数据来源:OrderService 协议在 [Services/OrderService.swift],有 func fetchOrders() async throws -> [Order],页面通过初始化参数接收它,不要给参数写默认值。
 5列表页:
 61. 进入页面时用 .task 加载,加载中显示 ProgressView,失败显示错误文字和「重试」按钮。
 72. 支持下拉刷新。
 83. ForEach 直接用 Order 的 id,不要用下标或 \.self。
 9详情页:显示全部字段,金额用当前地区货币格式。
10要求:网络请求不要写在 body 里。示例数据和模拟的 OrderService 放在 #if DEBUG 里,只给 #Preview 用。

模板三:表单校验

 1在 [Views/RegisterView.swift] 写一个注册表单。
 2工程约束:最低支持 iOS [15.0],状态管理用 ObservableObject + @Published,ViewModel 标 @MainActor。
 3字段和规则:
 41. 手机号:11 位数字,以 1 开头。
 52. 密码:8 到 20 位,同时包含字母和数字。
 63. 确认密码:和密码一致。
 7交互:
 81. 用户离开某个输入框后才显示这个字段的错误提示,错误文字显示在输入框下方,红色小字。
 92. 所有字段都合法时「注册」按钮才可点,提交时按钮显示加载状态,防止重复点击。
103. 校验逻辑放在 ViewModel 里,写成不依赖 SwiftUI 的纯函数,方便单独测试。
11要求:不引入第三方库,提交接口先留一个 async 函数占位。

模板四:从 UIKit 迁移

1把 [ProfileViewController.swift] 改写成 SwiftUI 的 ProfileView,下面是原文件全部内容。
2[粘贴原代码]
3工程约束:最低支持 iOS [15.0]。其他页面还是 UIKit,新页面通过 UIHostingController 嵌入,入口在 [MainCoordinator.swift] 的 showProfile(),只改这一个函数。
4迁移要求:
51. 行为和原页面一致,包括 viewWillAppear 里的刷新、导航栏按钮、空状态。
62. 原来的 delegate 回调改成闭包参数传给 ProfileView。
73. 原来的 ProfileService 不要修改,继续使用。
84. 原文件先保留,不要删除。
9最后列出两个页面行为上可能不一致的地方,让我在手机上逐项对比。

模板五:修编译错误

1下面是编译错误,只给了第一条,请只修这一条引起的问题,不要顺手重构其他代码。
2[粘贴错误原文,例如 MyApp\Views\OrderListView.swift:12:9: error: 'NavigationStack' is only available in iOS 16.0 or newer]
3相关代码:
4[粘贴出错的函数或整个文件]
5工程约束:最低支持 iOS [15.0],不能提高部署目标。
6请说明错误原因,给出修改后的完整代码,并告诉我改动会不会影响其他页面。

一个错误引出一串报错时只给第一条,后面的多半会跟着消失。并发相关的报错原文和改法见 Swift 6 严格并发常见报错。

坑 1:用了高于部署目标的 API

AI 最常写出 NavigationStack、@Observable、SwiftData,而工程最低支持 iOS 15。编译会报下面这类错误。

1error: 'NavigationStack' is only available in iOS 16.0 or newer
2error: 'navigationDestination(for:destination:)' is only available in iOS 16.0 or newer

错误写法。

 1struct ContentView: View {
 2    var body: some View {
 3        NavigationStack {
 4            List(1..<4) { n in
 5                NavigationLink("第 \(n) 项", value: n)
 6            }
 7            .navigationDestination(for: Int.self) { n in
 8                Text("详情 \(n)")
 9            }
10        }
11    }
12}

正确写法。要么提高部署目标,要么用 if #available 给低版本留一条路。

 1struct ContentView: View {
 2    var body: some View {
 3        if #available(iOS 16.0, *) {
 4            NavigationStack {
 5                list
 6            }
 7        } else {
 8            NavigationView {
 9                list
10            }
11        }
12    }
13
14    private var list: some View {
15        List(1..<4) { n in
16            NavigationLink("第 \(n) 项") {
17                Text("详情 \(n)")
18            }
19        }
20    }
21}

常用 API 的最低版本见文末的对照表。

坑 2:ObservableObject 和 @Observable 混用

@Observable 是 iOS 17 的新写法,配套的是 @State、@Bindable、@Environment(类型.self)。AI 经常把它和老写法的 @StateObject、@ObservedObject、@EnvironmentObject 拼在一起。

1error: generic struct 'StateObject' requires that 'CartModel' conform to 'ObservableObject'

错误写法。

 1@Observable
 2final class CartModel {
 3    var items: [String] = []
 4}
 5
 6struct CartView: View {
 7    @StateObject private var model = CartModel()
 8
 9    var body: some View {
10        Text("\(model.items.count) 件")
11    }
12}

最低 iOS 17 的,整套换成新写法。

 1@Observable
 2final class CartModel {
 3    var items: [String] = []
 4}
 5
 6struct CartView: View {
 7    @State private var model = CartModel()
 8
 9    var body: some View {
10        VStack {
11            Text("\(model.items.count) 件")
12            CartEditor(model: model)
13        }
14        .environment(model)
15    }
16}
17
18struct CartEditor: View {
19    @Bindable var model: CartModel
20
21    var body: some View {
22        Button("加一件") {
23            model.items.append("商品")
24        }
25    }
26}
27
28struct CartBadge: View {
29    @Environment(CartModel.self) private var model
30
31    var body: some View {
32        Text("\(model.items.count)")
33    }
34}

最低 iOS 16 及以下的,整套用老写法:类遵循 ObservableObject,属性加 @Published,创建处 @StateObject,传给子视图 @ObservedObject,环境里 .environmentObject(model) 加 @EnvironmentObject。两套的对应关系见 Apple 的 Migrating from the Observable Object protocol to the Observable macro。

坑 3:为什么不能在 body 里做副作用?

body 每次状态变化都会重新计算,在里面发请求、写文件、改状态,会重复执行很多次。直接在 body 的计算过程中改 @State,运行时还会提示 Modifying state during view update。

错误写法。能编译,但每次刷新界面都会再发一次请求。

 1struct ProfileView: View {
 2    let userID: String
 3    @State private var name = ""
 4
 5    var body: some View {
 6        let _ = Task {
 7            name = await fetchName(userID)
 8        }
 9        Text(name.isEmpty ? "加载中" : name)
10    }
11}

正确写法。用 .task(id:),页面出现时执行一次,userID 变化时自动取消旧任务、重新执行,页面消失时也会取消。

 1struct ProfileView: View {
 2    let userID: String
 3    @State private var name = ""
 4
 5    var body: some View {
 6        Text(name.isEmpty ? "加载中" : name)
 7            .task(id: userID) {
 8                name = await fetchName(userID)
 9            }
10    }
11}
12
13func fetchName(_ id: String) async -> String {
14    // 请求接口
15    "张三"
16}

点击触发的操作写在按钮的 action 里,数据变化后的处理用 .onChange,都不要写在 body 的计算过程中。

坑 4:ForEach 的 id 不稳定会出什么问题?

SwiftUI 靠 id 判断哪一行是哪一行。id 每次都变,动画、输入框焦点、行内的 @State 都会丢失;id 重复,删除或刷新时会显示错行。

错误写法。计算属性每次返回新的 UUID,字符串数组里有重复值时 \.self 也会重复。

 1struct Todo {
 2    var title: String
 3    var id: UUID { UUID() }
 4}
 5
 6struct TodoList: View {
 7    let todos: [Todo]
 8    let tags: [String]
 9
10    var body: some View {
11        List {
12            ForEach(todos, id: \.id) { todo in
13                Text(todo.title)
14            }
15            ForEach(tags, id: \.self) { tag in
16                Text(tag)
17            }
18        }
19    }
20}

正确写法。id 作为存储属性只生成一次,跟着数据一起保存。

 1struct Todo: Identifiable, Codable {
 2    var id = UUID()
 3    var title: String
 4}
 5
 6struct TodoList: View {
 7    @State private var todos: [Todo] = []
 8
 9    var body: some View {
10        List {
11            ForEach(todos) { todo in
12                Text(todo.title)
13            }
14            .onDelete { offsets in
15                todos.remove(atOffsets: offsets)
16            }
17        }
18    }
19}

ForEach(todos.indices, id: \.self) 这种按下标的写法也要避免,删除一行后下标对不上,容易越界崩溃。

坑 5:@State 能用来接收外部传入的值吗?

@State 只在视图第一次创建时取初始值,之后父视图再传新值进来不会生效,子视图里的修改也传不回父视图。

错误写法。

 1struct EditNameView: View {
 2    @State var name: String
 3
 4    var body: some View {
 5        TextField("名字", text: $name)
 6    }
 7}
 8
 9struct SettingsView: View {
10    @State private var userName = "张三"
11
12    var body: some View {
13        VStack {
14            Text("当前:\(userName)")
15            EditNameView(name: userName)
16        }
17    }
18}

正确写法。要和父视图同步的用 @Binding,父视图传 $userName。

 1struct EditNameView: View {
 2    @Binding var name: String
 3
 4    var body: some View {
 5        TextField("名字", text: $name)
 6    }
 7}
 8
 9struct SettingsView: View {
10    @State private var userName = "张三"
11
12    var body: some View {
13        VStack {
14            Text("当前:\(userName)")
15            EditNameView(name: $userName)
16        }
17    }
18}

确实要一份本地草稿、点保存才提交的,@State 写成 private,在 .onAppear 里从传入的值复制一份。

坑 6:后台线程更新界面状态怎么改?

用回调式的网络请求时,回调在后台线程,直接给 @Published 属性赋值。运行时会提示 Publishing changes from background threads is not allowed,界面可能不刷新或出现难以复现的问题。

错误写法。

 1final class FeedModel: ObservableObject {
 2    @Published var items: [String] = []
 3
 4    func load() {
 5        let url = URL(string: "https://api.example.com/feed")!
 6        URLSession.shared.dataTask(with: url) { data, _, _ in
 7            let lines = String(decoding: data ?? Data(), as: UTF8.self)
 8                .split(separator: "\n").map(String.init)
 9            self.items = lines
10        }.resume()
11    }
12}

正确写法。ViewModel 标 @MainActor,用 async/await,await 回来以后仍在主线程。

 1@MainActor
 2final class FeedModel: ObservableObject {
 3    @Published var items: [String] = []
 4
 5    func load() async {
 6        let url = URL(string: "https://api.example.com/feed")!
 7        do {
 8            let (data, _) = try await URLSession.shared.data(from: url)
 9            items = String(decoding: data, as: UTF8.self)
10                .split(separator: "\n").map(String.init)
11        } catch {
12            items = []
13        }
14    }
15}
16
17struct FeedView: View {
18    @StateObject private var model = FeedModel()
19
20    var body: some View {
21        List(model.items, id: \.self) { Text($0) }
22            .task { await model.load() }
23    }
24}

坑 7:忘加权限用途说明会怎样?

AI 写了定位、相机、麦克风、通讯录的代码,却不会去改 Info.plist。缺少用途说明时,App 一访问这类数据就会闪退,上架审核也会被拒。

错误写法。代码本身没问题,但 Info.plist 里没有对应的键。

 1import CoreLocation
 2
 3@MainActor
 4final class LocationPermission: ObservableObject {
 5    private let manager = CLLocationManager()
 6
 7    func request() {
 8        manager.requestWhenInUseAuthorization()
 9    }
10}

正确写法。在 Info.plist 加上用途说明。

1<key>NSLocationWhenInUseUsageDescription</key>
2<string>用于显示你附近的门店</string>

自动生成 Info.plist 的工程,改构建设置 INFOPLIST_KEY_NSLocationWhenInUseUsageDescription。每种权限对应的键见 Info.plist 权限用途说明大全。只让用户挑几张照片的,用 iOS 16 起的 PhotosPicker,系统选择器不需要相册权限。在提示词里加一句「用到需要用户授权的功能时,列出要加的 Info.plist 键和中文文案」。

坑 8:预览代码进了正式包

AI 为了让 #Preview 能跑,会写示例数据和模拟服务,有时还把模拟服务写成参数默认值。这些代码不在 #if DEBUG 里,就会编进正式包,最坏的情况是用户看到的是测试数据。

错误写法。

 1struct MockOrderService: OrderService {
 2    func fetchOrders() async throws -> [Order] {
 3        [Order(id: 1, title: "测试订单")]
 4    }
 5}
 6
 7struct OrderListView: View {
 8    var service: any OrderService = MockOrderService()
 9    @State private var orders: [Order] = []
10
11    var body: some View {
12        List(orders) { Text($0.title) }
13            .task { orders = (try? await service.fetchOrders()) ?? [] }
14    }
15}
16
17#Preview {
18    OrderListView()
19}

正确写法。正式代码不给默认值,由调用方传入真实服务。模拟服务和预览一起放进 #if DEBUG。

 1struct Order: Identifiable, Decodable {
 2    var id: Int
 3    var title: String
 4}
 5
 6protocol OrderService: Sendable {
 7    func fetchOrders() async throws -> [Order]
 8}
 9
10struct LiveOrderService: OrderService {
11    func fetchOrders() async throws -> [Order] {
12        let url = URL(string: "https://api.example.com/orders")!
13        let (data, _) = try await URLSession.shared.data(from: url)
14        return try JSONDecoder().decode([Order].self, from: data)
15    }
16}
17
18struct OrderListView: View {
19    let service: any OrderService
20    @State private var orders: [Order] = []
21
22    var body: some View {
23        List(orders) { Text($0.title) }
24            .task { orders = (try? await service.fetchOrders()) ?? [] }
25    }
26}
27
28#if DEBUG
29struct MockOrderService: OrderService {
30    func fetchOrders() async throws -> [Order] {
31        [Order(id: 1, title: "测试订单")]
32    }
33}
34
35#Preview {
36    OrderListView(service: MockOrderService())
37}
38#endif

改完要用 Release 配置编译一次,确认没有正式代码引用了 #if DEBUG 里的类型。

AI 生成的 SwiftUI 代码怎么验证?

  1. 看编辑器。KXBuild 的代码提示正常时,AI 改完的代码哪里有问题会直接画红线,见代码提示与索引。
  2. 编译检查。在工程目录执行下面的命令,只检查能否编译,不签名。可以让能执行终端命令的 AI 助手自己跑,有 error 就接着改。
1kxbuild build .\MyApp.xcodeproj -D CODE_SIGNING_ALLOWED=NO
  1. Release 再编一次。确认 #if DEBUG 之外没有引用预览代码。
1kxbuild build .\MyApp.xcodeproj -c Release --no-ipa -D CODE_SIGNING_ALLOWED=NO
  1. 真机按 F5。KXBuild 构建、签名、安装并启动 App,print 输出在「输出 → KXBuild Console」。按 AI 列出的操作逐项点一遍,再试一遍旋转屏幕、切到后台再回来、弱网下的加载,这些场景 AI 最容易漏。
  2. 低版本系统。手头有最低支持版本的 iPhone,装上去再看一次,if #available 的另一条分支只有在那里才会执行。

断点和变量查看见运行与调试。

常用 SwiftUI API 最低支持哪个 iOS 版本?

下表的版本取自 Apple 开发者文档各 API 页面,截至 2026 年 10 月。

API 最低 iOS 低版本替代
@StateObject 14.0 @ObservedObject 由外部持有
AsyncImage 15.0 自己用 URLSession 加载
.refreshable 15.0 无
foregroundStyle(_:) 15.0 foregroundColor(_:)
NavigationStack、navigationDestination(for:) 16.0 NavigationView + NavigationLink(destination:)
NavigationSplitView 16.0 NavigationView
PhotosPicker 16.0 PHPickerViewController 包一层
ShareLink 16.0 UIActivityViewController 包一层
presentationDetents(_:) 16.0 全屏 .sheet
scrollContentBackground(_:) 16.0 无
@Observable、@Bindable 17.0 ObservableObject + @Published
environment(_:) 传入 Observable 对象 17.0 environmentObject(_:)
SwiftData 17.0 Core Data 或自己存文件
ContentUnavailableView 17.0 自己拼空状态视图
onChange(of:initial:_:) 17.0 onChange(of:perform:),iOS 17 起标为弃用
scrollPosition(id:anchor:) 17.0 ScrollViewReader
sensoryFeedback(_:trigger:) 17.0 UIImpactFeedbackGenerator
Tab(新的 TabView 写法) 18.0 .tabItem

让 AI 写 SwiftUI 之前先确认工程的最低版本:在 KXBuild 视图中右键 target 选「KXBuild: 编辑目标设置」,General 页的 iOS Deployment Target 就是,修改方法见工程设置。

常见问题

AI 写的 SwiftUI 代码报 is only available in iOS 16.0 or newer 怎么办?

说明用了高于最低 iOS 版本的 API。把错误原文和最低版本一起发给 AI,要求它换成低版本写法或用 if #available 包起来。也可以确认用户情况后提高部署目标,二选一,不要两边都改。

SwiftUI 里 @Observable 和 ObservableObject 该用哪个?

最低支持 iOS 17 及以上的新工程用 @Observable,配套 @State、@Bindable、@Environment。要支持 iOS 16 及以下的用 ObservableObject 加 @Published,配套 @StateObject、@ObservedObject、@EnvironmentObject。同一个类型只用一套。

怎么让 AI 不用高版本的 SwiftUI API?

在提示词或仓库说明文件里写明最低 iOS 版本,并点名不能用的 API,例如 NavigationStack、@Observable、SwiftData。再要求它改完执行编译检查,出现版本错误时它能自己改回来。

在 Windows 上能检查 AI 生成的 SwiftUI 代码能不能编译吗?

能。在工程目录执行 kxbuild build .\MyApp.xcodeproj -D CODE_SIGNING_ALLOWED=NO,错误按「文件:行:列: error: 说明」输出,可以直接交给 AI 修。界面效果和交互要按 F5 装到 iPhone 上看。

#Preview 里的示例数据会被打进正式包吗?

写在 #if DEBUG 外面的示例数据、模拟服务都会编进正式包。把它们和 #Preview 一起放进 #if DEBUG,正式代码的参数不要用模拟服务做默认值,改完用 Release 配置编译一次确认。