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