iOS 开发从入门到精通:环境搭建、Swift 语法、系统特性与 AI 时代的高效开发
- 约 41 分钟读完 - 全文 16353 字iOS 开发从入门到精通:环境搭建、Swift 语法、系统特性与 AI 时代的高效开发
目标读者:会一门编程语言(前端、后端都行),想进入 iOS 开发;或者写过大半年 iOS,但对证书、内存、系统权限、上架总觉得心里没底的同学。
这篇文章是我从「被证书签名折磨到怀疑人生」到「能独立上架一个 App」的完整复盘。照例不讲完所有 API(Apple 官方文档比我权威),只讲五件事,由浅到深排布:
- 环境搭建:新人第一道鬼门关,很多人还没写代码就死在这里。
- Swift 语法主干:真正重要的 20%。
- UI 技术选型:SwiftUI 还是 UIKit,以及那些绕不开的第三方库。
- 系统特性与中级内功:页面写得好只是前台功夫,和系统打交道、把 App 做「上线级」才是后台功夫。
- AI 时代的开发方式:怎么让 AI 帮你写掉 70% 的代码,以及剩下的 30% 为什么恰恰最值钱。
先给一张全文路线图:
flowchart LR
A["环境搭建<br/>Xcode / 证书 / 模拟器"] --> B["Swift 语言<br/>Optional / 闭包 / 协议"]
B --> C["UI 开发<br/>SwiftUI 或 UIKit"]
C --> D["系统特性<br/>定位 存储 网络 通知 唤起"]
D --> E["中级内功<br/>生命周期 架构 测试 发布"]
E --> F["AI 协同开发<br/>更少代码 更快交付"]
第一篇 环境搭建:新人劝退第一关
1.1 你需要什么
iOS 开发有个绕不开的前提:你需要一台 Mac。Apple 的生态是封闭的,Xcode 只跑在 macOS 上。
flowchart TB
A["Mac(macOS 最新版)"] --> B["App Store 下载 Xcode<br/>约 10GB+"]
B --> C["Xcode 内含<br/>编译器 / 模拟器 / 调试器"]
C --> D["注册 Apple ID<br/>免费,可跑真机(7 天签名)"]
D --> E["付费开发者账号<br/>99 美元/年,上架必备"]
预算建议:M 系列芯片的 Mac mini 是性价比之选;硬盘务必 512GB 起步——Xcode 本体加几个模拟器系统镜像,轻松吃掉 100GB。
踩坑故事 1:App Store 下载 Xcode 卡了三天
新人第一天,App Store 下载 Xcode 进度条走到 80% 反复失败。这是 App Store 的祖传毛病。正确姿势:去 Apple Developer 官网下载
.xip安装包,或者用开源的 Xcode 版本管理器xcodes安装。后者还能同时管理多个 Xcode 版本,老项目用旧版、新项目用新版,互不打架。
1.2 证书与签名:iOS 开发的「百慕大三角」
开发阶段你迟早会撞见这个错误:
Code Signing Error: No signing certificate "iOS Development" found
先理解原理,再看图:
flowchart LR
A["你的 Mac<br/>私钥 + CSR"] -->|"请求"| B["Apple 开发者后台<br/>签发证书"]
B --> C["描述文件 Provisioning Profile<br/>绑定:证书 + 设备 + AppID"]
C --> D["Xcode 打包时用私钥签名"]
D --> E["iPhone 验证签名后允许运行"]
三样东西必须对应上:证书(你是谁)、AppID(哪个应用)、设备(哪台手机)。任何一个对不上,真机就跑不起来。
踩坑故事 2:上线前夜,证书被同事 Revoke 了
公司账号多人共用,一位同事在自己电脑上「修复」签名问题时,随手点了 Revoke 旧证书——全团队的 CI 打包瞬间全部失败,第二天就是提审截止日。连夜重新生成证书、更新描述文件、改 CI 配置,凌晨四点才出包。
两条教训:第一,优先用 Xcode 的 Automatically manage signing,别手动折腾证书;第二,团队共用账号时,证书动任何操作前在群里吼一声。有条件的话上
fastlane match,用 Git 仓库统一管理证书,谁也别乱动。
1.3 第一个 App:5 分钟跑起来
Xcode → Create New Project → iOS → App,Interface 选 SwiftUI,Language 选 Swift。然后你就得到了一个能跑的 App:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
Text("Hello, iOS!")
}
.padding()
}
}Cmd + R 跑模拟器。第一次编译会慢(在构建模块缓存),之后增量编译很快。
第二篇 Swift 语法:只吃主干
Swift 是一门现代、严谨、有点「强迫症」的语言。它的设计哲学一句话概括:让错误尽量发生在编译期,而不是用户的手机上。
2.1 可选类型(Optional):Swift 的灵魂
这是 Swift 与其他语言最大的心智差异,也是新人崩溃的第一来源:
var name: String? = "方糖猫" // ? 表示:可能有值,也可能是 nil
// 错误:编译不过
// print(name.count)
// 正确姿势一:if let 解包
if let unwrapped = name {
print(unwrapped.count)
}
// 正确姿势二:guard let(函数里更常用)
func greet(name: String?) {
guard let name = name else {
print("没名字"); return
}
print("你好,\(name)")
}
// 正确姿势三:?? 提供默认值
print(name ?? "匿名")flowchart TD
A["拿到一个 Optional 值"] --> B{"确定它永远有值?"}
B -->|"99% 的情况:不确定"| C["if let / guard let / ??<br/>安全解包"]
B -->|"确定(极少)"| D["! 强制解包"]
D -.->|"万一它是 nil"| E["💥 直接崩溃闪退"]
踩坑故事 3:一个
!毁掉的评分接手过一个老项目,前任开发为了省事到处强制解包:
dict["avatar"]!、array.first!、Int(str)!。某天后端接口调整,一个字段从必有变成可能缺失,线上崩溃率当天飙到 3%,App Store 评分一周掉了 1.2 分。修复原则很简单,执行起来要毅力:全项目搜
!,每一个都要能说出「它为什么不可能为 nil」,说不出的就改成安全解包。Crashlytics 的崩溃报表里,强制解包常年霸榜,这是 iOS 界公开的秘密。
2.2 struct 与 class:值类型和引用类型的分野
// struct:值类型,拷贝的是内容本身
struct Point { var x: Int; var y: Int }
var p1 = Point(x: 1, y: 1)
var p2 = p1
p2.x = 99
print(p1.x) // 还是 1,互不影响
// class:引用类型,拷贝的是「门牌号」
class ViewModel { var title = "" }
let vm1 = ViewModel()
let vm2 = vm1
vm2.title = "改了"
print(vm1.title) // "改了",两个变量指向同一个对象这个区别和 JavaScript 里「基本类型 vs 对象」一模一样,但 Swift 把它变成了显式的设计决策。经验法则:数据模型一律用 struct,需要共享状态和身份标识(ViewModel、Service)才用 class。
2.3 闭包与循环引用:内存管理必修课
Swift 用 ARC 自动管理内存,但有一个经典陷阱:两个 class 互相强引用,谁都释放不掉。
flowchart LR
A["ViewController<br/>强引用"] --> B["Service"]
B -->|"闭包捕获 self<br/>强引用回去"| A
C["打破循环:闭包里写 [weak self]"] -.-> B
class DetailViewController: UIViewController {
let service = DataService()
func load() {
// 错误:service 的闭包强引用 self,self 强引用 service,内存泄漏
// service.fetch { self.updateUI() }
// 正确:[weak self] 让闭包弱引用
service.fetch { [weak self] data in
self?.updateUI(with: data)
}
}
}踩坑故事 4:越用越卡的 App
一个信息流 App 被用户投诉「刷十分钟就卡死」。用 Xcode 的 Memory Graph 一查:每进入一个详情页就泄漏一个 ViewController,退出页面后内存一点没降。罪魁祸首就是详情页里七八个网络回调闭包全都没写
[weak self]。逐页修复后,内存曲线从「阶梯式上升」变成了正常的「上下波动」。感悟:前端开发者转 iOS 最容易在这里翻车——JavaScript 的垃圾回收帮你兜了底,而 Swift 的 ARC 是引用计数,循环引用它不回收。写闭包时肌肉记忆地加上
[weak self],是 iOS 开发者的职业本能。
2.4 异步:async/await 与结构化并发
Swift 的 async/await 和 JavaScript 的很像,但多了「结构化并发」的约束:
struct User: Codable {
let id: Int
let name: String
}
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
// 调用处
Task {
do {
let user = try await fetchUser(id: 1)
print(user.name)
} catch {
print("请求失败:\(error)")
}
}需要并行时,用 async let——它保证所有并发任务在离开作用域前完成,不会像 JS 的 forEach(async...) 那样「放出去就没人管」:
async let user = fetchUser(id: 1)
async let orders = fetchOrders(userId: 1)
let (u, o) = try await (user, orders) // 两个请求并行发出,这里等齐2.5 协议(Protocol):Swift 的接口哲学
protocol Drawable {
func draw()
}
extension Drawable {
func draw() { print("默认实现:画一个方块") } // 协议可以有默认实现!
}
struct Circle: Drawable {
func draw() { print("画一个圆") }
}协议 + 默认实现(POP,面向协议编程)是 Swift 相对 Java/OC 的一大进化。第三方库大量用它做抽象,读源码时满屏都是。
2.6 阶段小结
Swift 入门标准:
- 看到
?和!能条件反射地想「这里会不会是 nil」。 - 能徒手解释 struct 和 class 的三个区别(拷贝语义、继承、ARC)。
- 写闭包时下意识加
[weak self]。 - 会用
Codable一行解析 JSON,不再手写字典取值。
第三篇 UI 开发:SwiftUI 还是 UIKit
3.1 两条路线的正面比较
flowchart TB
subgraph UIKit["UIKit(2008 - )"]
A1["命令式:手动改 UI"]
A2["生态成熟 资料多"]
A3["老项目全是它"]
end
subgraph SwiftUI["SwiftUI(2019 - )"]
B1["声明式:UI = f(状态)"]
B2["代码量少一半"]
B3["iOS 13+ 才有"]
end
Q{"2026 年,学哪个?"}
Q --> UIKit
Q --> SwiftUI
UIKit 是命令式的:你手动创建 label、设 frame、加到视图层级。SwiftUI 是声明式的:你描述「状态是这样时,界面该长这样」,框架负责同步。写前端的同学会发现,SwiftUI 的思想和 Vue/React 一脉相承——数据驱动视图。
我的建议:学 SwiftUI 为主,UIKit 懂个大概。新项目几乎都是 SwiftUI 起手,但存量项目、某些复杂控件(高度自定义的列表、绘图)依然是 UIKit 的天下,两者通过 UIViewRepresentable / UIHostingController 互相嵌套,不是非此即彼。
3.2 SwiftUI 核心三板斧
struct CounterView: View {
@State private var count = 0 // 状态:一变 UI 自动刷新
var body: some View {
VStack(spacing: 16) { // 布局:VStack / HStack / ZStack
Text("点击了 \(count) 次")
.font(.title)
Button("点我 +1") {
count += 1
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}三个概念吃透,SwiftUI 就懂了一半:
- @State:组件私有的状态,改了自动刷新 UI。
- View 协议 + body:每个界面都是「状态的函数」。
- 修饰符链:
.font().padding().background()这种链式调用,顺序不同结果可能不同。
踩坑故事 5:修饰符顺序引发的「灵异布局」
Text("你好") .padding() .background(Color.blue) // 背景包住 padding,蓝底很大 Text("你好") .background(Color.blue) .padding() // 先蓝底再撑开,padding 是透明的新人以为修饰符像 CSS 一样「无顺序」,调了俩小时布局没对上设计稿。记住:SwiftUI 的每个修饰符都返回一个新 View,修饰符是从内往外一层层包裹的。想不通就念口诀:「谁在前,谁靠里」。
3.3 数据流:状态从哪来,往哪去
SwiftUI 的状态管理有一套属性包装器,选错一个就是 bug 温床:
flowchart TB
A["@State<br/>本组件私有的值类型状态"]
B["@StateObject<br/>本组件创建并拥有的 ViewModel"]
C["@ObservedObject<br/>外面传进来的 ViewModel<br/>我不拥有 只是观察"]
D["@EnvironmentObject<br/>全局注入的对象<br/>深层子孙直接取用"]
E["@Binding<br/>父组件状态的引用<br/>子组件可以回写"]
踩坑故事 6:@ObservedObject 导致的状态「复活」
一个表单页用
@ObservedObject var vm = FormViewModel()——看着没问题,但这个 ViewModel 应该在父级创建并持有。结果父视图每次刷新,子视图的 ViewModel 就被重建,用户填到一半的表单数据凭空消失。修法一行:改成
@StateObject(本组件是主人)或让父级用@StateObject创建后传入(子级用@ObservedObject接收)。记忆法:谁创建,谁 StateObject;只接收,就 ObservedObject。这条规则和 Vue 里「状态的唯一权威来源」异曲同工。
3.4 主流组件库与工具库
iOS 的第三方库生态这些年收敛得很厉害,下面是真正高频出现的:
| 库 | 干什么 | 备注 |
|---|---|---|
| SnapKit | UIKit 布局的 DSL | 手写 Auto Layout 必备 |
| Kingfisher | 图片异步加载与缓存 | 图片界的标配 |
| Alamofire | 网络请求封装 | 纯 SwiftUI 新项目可用原生 URLSession |
| Lottie | 播放 AE 导出的动画 JSON | 复杂动效的唯一解 |
| KeychainAccess | 钥匙串存取 | 存 token 见第四篇 |
| SwiftLint | 代码风格检查 | 团队项目标配 |
包管理工具的选择也已经尘埃落定:
flowchart LR
A["CocoaPods<br/>老牌 重 侵入工程文件"] -.->|"逐渐淡出"| C
B["Carthage<br/>去中心化 手动"] -.->|"边缘化"| C
C["SPM Swift Package Manager<br/>Apple 官方 Xcode 内置<br/>新项目首选"]
新项目一律 SPM。CocoaPods 只在维护老项目时才会碰到了。
踩坑故事 7:一个第三方库拖垮整个编译
项目里接了一个功能很全但质量堪忧的统计 SDK,它要求旧版 Alamofire,而我们网络层已经升到新版,版本冲突导致 SPM 解析失败;更糟的是它混编了 OC,桥接头文件改动让全量编译时间从 3 分钟涨到 11 分钟。最后痛下决心自研了那部分功能(实际只有 200 行代码),编译时间回落,还少了一坨黑盒。
感悟:引第三方库的成本不是接入那一天,而是之后每一年。评估标准是:核心功能是否不可替代?维护是否活跃?能不能用 200 行自研替代?苹果的官方 API 每年都在吃掉第三方库的地盘(比如
URLSession已经很好用),优先用官方的。
3.5 Demo:一个完整的 SwiftUI 待办 App
把上面的知识串起来——列表、状态、ViewModel、本地持久化,这个 Demo 可以直接跑:
import SwiftUI
// MARK: - 模型(值类型,Codable 方便持久化)
struct Todo: Identifiable, Codable {
var id = UUID()
var text: String
var done: Bool = false
}
// MARK: - ViewModel
class TodoStore: ObservableObject {
@Published var todos: [Todo] = [] {
didSet { save() } // 数据一变就落盘
}
private let key = "todos_v1"
init() { load() }
func add(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
todos.append(Todo(text: trimmed))
}
func delete(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
private func save() {
if let data = try? JSONEncoder().encode(todos) {
UserDefaults.standard.set(data, forKey: key)
}
}
private func load() {
guard let data = UserDefaults.standard.data(forKey: key),
let saved = try? JSONDecoder().decode([Todo].self, from: data)
else { return }
todos = saved
}
}
// MARK: - 视图
struct TodoListView: View {
@StateObject private var store = TodoStore() // 本视图创建并拥有
@State private var newText = ""
var body: some View {
NavigationStack {
List {
ForEach($store.todos) { $todo in // $ 生成 Binding,可回写
HStack {
Image(systemName: todo.done ? "checkmark.circle.fill"
: "circle")
.foregroundStyle(todo.done ? .green : .gray)
.onTapGesture { todo.done.toggle() }
Text(todo.text)
.strikethrough(todo.done)
}
}
.onDelete(perform: store.delete) // 侧滑删除,白送
}
.navigationTitle("待办(\(store.todos.filter { !$0.done }.count))")
.safeAreaInset(edge: .bottom) {
HStack {
TextField("回车添加", text: $newText)
.textFieldStyle(.roundedBorder)
.onSubmit {
store.add(newText)
newText = ""
}
}
.padding()
.background(.bar)
}
}
}
}不到 80 行,你得到了:增删改查、勾选完成、侧滑删除、重启不丢数据。同等功能用 UIKit 写,代码量至少翻两倍——这就是声明式 UI 的威力。
第四篇 iOS 系统特性:定位、存储、网络、通知与唤起
页面写得好只是「前台功夫」,App 和系统打交道的能力才是「后台功夫」。这一篇讲五个真实 App 绕不开的系统能力。它们的坑有一个共同特征:Demo 里都好好的,一到用户手机上就出事。
4.1 定位:权限分级比 API 本身重要
iOS 定位用 CoreLocation,API 本身不复杂,复杂的是权限体系这些年越收越紧:
flowchart TD
A["请求定位权限"] --> B{"Info.plist 配了用途说明?<br/>NSLocationWhenInUseUsageDescription"}
B -->|"没配"| C["💥 不弹窗甚至直接崩溃<br/>新人第一坑"]
B -->|"配了"| D{"用户怎么选的?"}
D -->|"使用 App 期间允许"| E["前台定位可用"]
D -->|"始终允许"| F["后台也能定位<br/>需先拿前台权限再升级"]
D -->|"拒绝"| G["优雅降级<br/>引导去系统设置"]
E --> H{"iOS 14+ 用户开了<br/>『精确位置』吗?"}
H -->|"开了"| I["米级精度"]
H -->|"关了"| J["⚠️ 只有约 1-3km 精度<br/>代码必须兼容"]
发起定位的 Demo:
import CoreLocation
class LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLLocation?, Never>?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
func currentLocation() async -> CLLocation? {
manager.requestWhenInUseAuthorization() // 触发权限弹窗
return await withCheckedContinuation { cont in
self.continuation = cont
manager.requestLocation() // 请求一次新定位
}
}
func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
continuation?.resume(returning: locs.last)
continuation = nil
}
func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(returning: nil)
continuation = nil
}
}踩坑故事 9:外卖 App 定位把用户送到了两公里外
一个本地生活 App 上线后收到大量投诉:「定位的取货点离我两公里」。排查发现这批用户都在 iOS 14+ 的权限弹窗里关掉了**「精确位置」开关**——这是 Apple 给的模糊定位选项,App 拿到的坐标被人为偏移了。
解法:拿到定位后检查
manager.accuracyAuthorization,如果是.reducedAccuracy,在 UI 上明确提示「当前为模糊定位,可能影响取货点推荐」,并给一键跳系统设置的入口。这个开关在权限弹窗里毫不起眼,但对 LBS 类 App 是生死线。另外提醒做国内地图的同学:iOS 原生定位返回的是 WGS-84 坐标,画到高德地图前要转 GCJ-02,接百度地图要转 BD-09——坐标系天坑在 Android 和 iOS 上是同一口,偏移几百米、用户「站在河里」的事故两边都不少见。
4.2 本地存储:四个层级,各管各的
「存东西」在 iOS 里不是一件事,先想清楚存什么:
flowchart TD
Q{"要存什么?"}
Q -->|"设置项 开关 小数据"| A["UserDefaults<br/>最简单 明文存储"]
Q -->|"token 密码 敏感信息"| B["Keychain<br/>系统级加密<br/>卸载 App 后还在"]
Q -->|"结构化数据<br/>离线缓存 消息列表"| C["SwiftData / Core Data<br/>见 5.3 节"]
Q -->|"图片 日志 大文件"| D["沙盒文件目录<br/>Documents / Caches"]
踩坑故事 10:token 存进 UserDefaults,跟着 iTunes 备份「裸奔」
安全团队做渗透测试时发现:App 的登录 token 存在 UserDefaults 里,用 iTunes 做一个未加密备份,就能在备份文件里直接读到明文 token——拿着它就能以用户身份调通所有接口。
UserDefaults 的 plist 文件明文躺在沙盒里,还会进 iCloud/iTunes 备份。敏感信息唯一的正确归宿是 Keychain:
import KeychainAccess let keychain = Keychain(service: "com.example.myapp") try keychain.set(token, key: "accessToken") let saved = try keychain.get("accessToken")顺带记住 Keychain 的一个反直觉特性:App 卸载重装后,Keychain 里的数据还在。利用它可以做「卸载重装免登录」,但也别拿它存不该留的东西。
沙盒目录的分工也值得一提:
flowchart LR
A["Documents/<br/>用户产生的数据<br/>会被 iCloud 备份"] --> S["沙盒"]
B["Library/Caches/<br/>可重建的缓存<br/>系统空间不足时会清"] --> S
C["tmp/<br/>临时文件<br/>随时可能被清"] --> S
把几个 GB 的缓存视频塞进 Documents,用户的 iCloud 备份会被撑爆,审核也可能找你麻烦——缓存进 Library/Caches,这是规矩。
4.3 网络:URLSession 的完整封装
2.4 节讲了 async/await 的姿势,这里把网络层补完。iOS 新项目的网络层可以完全不用第三方库,URLSession 已经够用:
enum APIError: Error {
case httpError(Int)
case decodingError
}
actor APIClient {
static let shared = APIClient()
var token: String?
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 // 弱网常态,超时必须配
return URLSession(configuration: config)
}()
func get<T: Decodable>(_ path: String) async throws -> T {
var request = URLRequest(url: URL(string: "https://api.example.com\(path)")!)
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.httpError(-1) }
guard (200..<300).contains(http.statusCode) else { throw APIError.httpError(http.statusCode) }
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw APIError.decodingError
}
}
}踩坑故事 11:测试环境一切正常,正式包所有请求都失败
联调时后端给的是
http://测试地址,为了图快在 Info.plist 里加了NSAppTransportSecurity → NSAllowsArbitraryLoads = YES,全局放开了 HTTP。提审时被 Apple 要求说明理由,更糟的是正式环境的某个 CDN 域名忘了配证书,HTTPS 升级漏了它——而全局开关掩盖了问题,直到审核人员用特定网络环境才暴露。ATS(App Transport Security)是 Apple 强制 HTTPS 的机制,默认拦截明文 HTTP。正确姿势不是全局放开,而是用
NSExceptionDomains为个别域名开白名单,并推动后端全量 HTTPS。审核时对任意加载开关是要写书面理由的,别给自己找事。
移动端特有的提醒:手机用户会进电梯、进地铁、切 Wi-Fi 和流量,弱网和断线是常态而不是异常。超时必须配、失败要给重试入口、一次抖动失败别急着弹「网络异常」的全屏错误——静默重试一次,体验天差地别。
4.4 消息通知:iOS 最省心也最「一生一次」的能力
通知分两种:本地通知(App 自己定时触发,如提醒事项)和远程推送(服务器经 APNs 推下来)。
先说一个 iOS 相对 Android 的巨大优势:推送通道统一走 APNs。Apple 的推送服务是系统级的,App 被杀了也能收到通知——不存在 Android 那种「华为小米 OPPO 各接一套厂商通道」的割裂生态。这是 iOS 开发者少有的幸福时刻。
flowchart LR
A["你的服务器"] -->|"携带 deviceToken"| B["APNs<br/>Apple 推送服务"]
B -->|"系统级投递"| C["用户手机<br/>App 被杀也能收到"]
D["App 启动时<br/>registerForRemoteNotifications"] -->|"拿到 deviceToken 上报服务器"| A
本地通知的 Demo(提醒类 App 的核心):
import UserNotifications
// 第 1 步:申请权限(时机见踩坑故事 12)
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
// 第 2 步:排一个 5 秒后的通知
let content = UNMutableNotificationContent()
content.title = "该喝水了"
content.body = "起来活动一下"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(identifier: "drink", content: content, trigger: trigger)
try await center.add(request)踩坑故事 12:通知权限弹窗,一生只有一次机会
新人常在 App 启动第一屏就调
requestAuthorization——用户还没搞懂这 App 是干嘛的,看到弹窗本能地点了「不允许」。这一拒就是永久:之后再调用不会弹任何窗口,想改只能引导用户自己去系统设置里翻,转化率惨不忍睹。一个工具类 App 的推送开启率,因为这个改动从 12% 涨到 47%。正确姿势:先弹一个自己的预引导页,讲清楚「开启通知能及时收到订单状态」,用户点了「好的」再触发系统弹窗。被预引导页拒绝没有任何代价——系统弹窗那张底牌还攥在手里。
另一个高频坑:App 在前台时,推送默认不弹横幅、不响铃,需要实现
UNUserNotificationCenterDelegate的willPresent方法并返回[.banner, .sound],否则用户在 App 里操作时收消息毫无感知。
4.5 App 唤起:Universal Links 与 URL Scheme
「唤起」分两个方向:别人拉起你(短信、网页、二维码里的链接),你拉起别人。
flowchart TD
Q{"从哪拉起你的 App?"}
Q -->|"网页/短信里的 https 链接"| A["Universal Links<br/>需配置 apple-app-site-association<br/>体验最好 不可抢注"]
Q -->|"其他 App / 二维码"| B["URL Scheme<br/>myapp://detail/123<br/>简单但可被其他 App 抢注"]
Universal Links 配置要点:在 Xcode 的 Signing & Capabilities 里加 Associated Domains(applinks:ftmao.com),并在你的网站根路径放一个 apple-app-site-association 文件(无后缀的 JSON)。接收端用 SwiftUI 的 onOpenURL:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// 解析 https://ftmao.com/app/detail/123
if let id = url.pathComponents.last {
router.navigate(to: .detail(id: id))
}
}
}
}
}踩坑故事 13:Universal Link 全配置好了,就是不生效
按出现概率排查:
flowchart TD A["点了链接只开 Safari<br/>不拉起 App"] --> B{"AASA 文件能访问吗?<br/>https://域名/apple-app-site-association"} B -->|"404 或要求登录"| C["文件没放对位置<br/>必须无后缀 纯 JSON"] B -->|"能访问"| D{"中间有重定向吗?"} D -->|"有"| E["⚠️ AASA 文件不允许重定向<br/>包括 http 跳 https"] D -->|"没有"| F{"是在 Safari 地址栏<br/>直接输入的吗?"} F -->|"是"| G["地址栏输入不会触发<br/>必须在网页/短信里『点击』"] F -->|"否"| H{"之前长按选过<br/>『在 Safari 打开』?"} H -->|"是"| I["系统记住了你的选择<br/>长按链接选『在 App 中打开』恢复"]最后一条最隐蔽:用户在链接上长按选过一次「在 Safari 中打开」,iOS 就记住了这个域名不再拉起 App,之后怎么点都没反应。测试同事就栽在这上面,以为功能坏了,其实只是他自己的手机被系统「记住」了。
4.6 平台适配:iOS 的约束比碎片化更值得关注
iOS 没有 Android 那种几十种 ROM 的碎片化,但 Apple 用另一种方式给你立规矩——权限、隐私、后台管得更严。以下是反复用到的适配清单:
深色模式
- 颜色全部用语义色(
Color.primary、Color(.systemBackground))或 Asset Catalog 里配置深色变体,一个写死的Color.white背景都不要有——写死颜色的页面在深色模式下就是一块刺眼白斑,而且这种 bug 只在用户半夜打开 App 时才暴露。
动态字体(Dynamic Type)
- 用
.font(.body)、.font(.title)这类系统文本样式,字号会跟随用户在系统设置里的偏好自动缩放。写死.font(.system(size: 14))的 App,把字体调大的老年用户看到的依然是小字。
安全区
- 刘海屏、灵动岛、底部 Home 指示条都会侵占显示区域。底部操作栏用
safeAreaInset(edge: .bottom)而不是写死 padding,3.5 节的 Demo 里就是这么做的。
后台运行:iOS 是「假后台」,别指望它
flowchart TD
A["App 切到后台"] --> B["几秒内被挂起<br/>代码停止执行"]
B --> C{"想继续干活?"}
C -->|"短时间收尾"| D["beginBackgroundTask<br/>申请约 30 秒"]
C -->|"周期性任务"| E["BGTaskScheduler<br/>系统决定何时执行"]
C -->|"等服务器消息"| F["静默推送唤醒<br/>APNs content-available"]
踩坑故事 14:下载功能切后台就「暂停」,用户骂声一片
一个资料下载功能在 Demo 里完美,上线后用户反馈「切到微信回个消息,下载就停了」。原因正是上图:iOS 挂起后台 App 只需几秒。解法是改用
URLSession的后台配置(URLSessionConfiguration.background)——下载由系统进程接管,App 被杀了都在继续,完成后系统再唤醒你的 App 回调。感悟:iOS 的假后台不是缺陷,是 Apple 对续航和隐私的坚持。适配它的姿势不是对抗(研究各种保活黑科技,审核还会被拒),而是把任务交给系统:下载给后台 URLSession,定时任务给 BGTaskScheduler,消息给 APNs。系统级方案永远比自己保活可靠。
第五篇 中级应知应会:从「能跑」到「上线级」
前四篇能让你写出一个能跑的 App,这一篇补的是不常踩坑但绝不能不知道的硬知识——它们是中级和初级的分水岭。
5.1 App 生命周期:状态恢复是必修课
SwiftUI 时代的生命周期入口在 @main 的 App 结构体和 scenePhase:
stateDiagram-v2
[*] --> active: App 启动,可交互
active --> inactive: 来电话/下拉通知栏/切多任务
inactive --> active: 回到前台
inactive --> background: 完全切入后台
background --> inactive: 从后台回来
background --> [*]: 系统挂起后杀死进程
note right of background
进程死亡时不会有任何回调
重要数据不能指望「退出时再保存」
end note
@main
struct MyApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
// 切后台时保存草稿、暂停播放器
}
}
}
}踩坑故事 15:切出去回了个微信,半小时的表单白填了
用户在表单页填到一半,切出去回了条微信,半小时后切回来,App 从启动页重新加载,填的内容全没了。这不是 bug,是进程死亡——App 在后台久了,系统内存不足时直接杀掉整个进程,ViewModel 跟着一起死。
解法分两层:轻量状态(页面位置、草稿)用
@SceneStorage,它会把数据写入进程死亡也能存活的系统存储;重量级数据(表单草稿)在scenePhase变 background 时落盘。怎么测?App 切后台后,在 Xcode 里点 Stop 再手动从手机桌面点开——就模拟了进程被杀再恢复。不测这一关,你永远不知道状态恢复写对了没有。
5.2 架构:MVVM + 单向数据流
SwiftUI 和 MVVM 是天生的搭档:
flowchart TD
subgraph UI层
A["SwiftUI View<br/>只负责渲染和上报事件"]
end
subgraph 状态层
B["ViewModel<br/>持有 @Published 状态<br/>暴露事件处理函数"]
end
subgraph 数据层
C["Service / Repository<br/>数据的唯一入口<br/>决定走网络还是缓存"]
D["APIClient<br/>远程数据"]
E["SwiftData / UserDefaults<br/>本地数据"]
end
A -->|"用户事件"| B
B -->|"状态驱动刷新"| A
B --> C
C --> D
C --> E
三条铁律,比记住「MVVM」三个字母重要得多:
- View 不直接碰网络和数据库——SwiftUI 视图里出现
URLSession,就是架构腐烂的开始。 - 数据向下流,事件向上流:View 只读状态、只发事件,ViewModel 改状态。
- ViewModel 不 import SwiftUI:不碰 UI 才能被单元测试覆盖(见 5.4),也才能让 AI(第六篇)安全地帮你改逻辑。
5.3 SwiftData:结构化数据的现代答案
第三篇的 Demo 用 UserDefaults 存 Todo,只够玩具级。真实 App 的结构化数据用 SwiftData(iOS 17+,Core Data 的现代封装):
import SwiftData
@Model
final class Expense {
var title: String
var amount: Double
var createdAt: Date
init(title: String, amount: Double) {
self.title = title
self.amount = amount
self.createdAt = .now
}
}
// 视图里直接用 @Query 查询:数据变了 UI 自动更新
struct ExpenseListView: View {
@Query(sort: \Expense.createdAt, order: .reverse) private var expenses: [Expense]
@Environment(\.modelContext) private var context
var body: some View {
List(expenses) { expense in
Text("\(expense.title):\(expense.amount, format: .currency(code: "CNY"))")
}
.onAppear {
context.insert(Expense(title: "咖啡", amount: 32)) // 自动保存
}
}
}@Query 是 SwiftData 最优雅的用法——数据库一变,查询结果自动更新,UI 自动刷新,「增删改 → 界面刷新」这条链路不用写一行通知代码。需要支持 iOS 15/16 的老项目退回 Core Data,纯本地队列需求也可以考虑轻量的 GRDB(直接写 SQL)。
踩坑故事 16:加了个字段,老用户升级就闪退
新版给
Expense加了一个category字段,自己全新安装测试没问题,上线后老用户打开就崩。原因:老用户手机里的数据库还是旧表结构,新代码按新结构读,版本对不上。SwiftData 对只新增带默认值的可选字段这类轻量变更能自动迁移,但改字段类型、加必填字段就必须手写
Schema的迁移计划(VersionedSchema+SchemaMigrationPlan)。教训和 Android 的 Room 一模一样:数据库字段变更是对存量用户的承诺,每次改@Model,先想「老用户的库里有什么」。开发期可以偷懒删库重装,上线前必须真机装旧版、覆盖安装新版测一遍升级路径。
5.4 测试:少但要有
测试金字塔在移动端同样成立,按性价比排:
flowchart LR
A["单元测试 多写<br/>ViewModel / 工具函数<br/>毫秒级 不依赖设备"] --> B["UI 测试 精选<br/>核心页面主流程"]
B --> C["端到端 极少<br/>发版前人工跑一遍"]
单元测试 Demo(Swift Testing,Xcode 16 起的新框架):
import Testing
@testable import MyApp
struct LoginViewModelTests {
@Test func 邮箱格式错误时不发起登录() async {
let vm = LoginViewModel()
vm.email = "not-an-email"
await vm.login()
#expect(vm.emailError == "邮箱格式不对")
#expect(vm.isLoading == false)
}
}务实建议:别追求覆盖率数字。给「钱相关的计算、状态机的转移、数据解析」这三类写单元测试,给「登录、主列表、下单」这三条主链路写 XCUITest,投入产出比最高。UI 层还可以用 SnapshotTesting(截图对比测试)防止 UI 被无意改坏。
5.5 调试三板斧:断点、Memory Graph 与 Instruments
遇到问题时的三板斧,按使用频率排:
- 断点 + LLDB:
po variableName打印对象,条件断点(右键断点设条件)只在特定数据时停下。经验:别用print大法调异步代码,断点比日志可靠得多。 - Memory Graph:调试栏的小图标一键生成内存快照,泄漏的对象、循环引用的环都画成图——踩坑故事 4 就是靠它破的案。
- Instruments:性能问题终极武器。
Time Profiler找卡顿(主线程耗时调用栈一目了然)、Leaks查泄漏、Network看请求瀑布流。发布前拿 Time Profiler 把主流程过一遍,是专业团队的基本功。
日志方面,正经项目用 os.Logger 替代 print——分级、可过滤、能在真机的 Console.app 里回看,排查线上问题时你会感谢自己。
5.6 国际化与无障碍:专业完成度的分水岭
这两件事区分了「能用的 App」和「专业的 App」:
国际化(i18n)
- 所有文案进 String Catalog(
Localizable.xcstrings,Xcode 15+),代码里直接写英文 key,一个硬编码的中文字符串都不要留——今天不国际化,明天产品说「做个英文版」你就要全文搜索替换。 - 复数和变量用 catalog 的复数规则,别用字符串拼接数字。
无障碍(a11y)
- 图标按钮加
.accessibilityLabel("返回")——视障用户的 VoiceOver 靠它朗读界面。 - 可点击区域不小于 44×44 pt,系统组件默认满足,自定义绘制的按钮最容易违规。
- 配合 4.6 节的 Dynamic Type:字号放大五倍后布局不炸,这条顺带也服务了老年用户。
- 最后打开 VoiceOver 把自己的 App 主流程走一遍,十分钟,胜过读十篇规范。
5.7 发布上架:TestFlight、审核与隐私合规
flowchart LR
A["Archive 打包"] --> B["上传到 App Store Connect"]
B --> C["TestFlight 内测<br/>邀请码分发给测试人员"]
C --> D["填写元数据<br/>截图 关键词 隐私清单"]
D --> E["提审"]
E --> F{"审核"}
F -->|"通过"| G["发布"]
F -->|"被拒"| H["看 Resolution Center<br/>改完重提"]
几个中级必懂的点:
- TestFlight 是正式发布前的缓冲区:内部测试 100 人免审核,外部测试 1 万人需过一个简化审核。任何「我测过了」的说法,都应该建立在 TestFlight 包上——它和生产包走的是同一条签名和分发链路。
- 截图和预览视频分尺寸(6.7 寸、6.5 寸、iPad),用模拟器
Cmd+S截,或 fastlane snapshot 批量生成。 - 审核时长通常 24-48 小时,紧急情况可以申请加急审核(Accelerated Review),但一年没几次额度,别浪费在「产品经理想早点上」这种事上。
踩坑故事 8(重提):隐私清单引发的连环拒审
Apple 要求声明 App 用到的「必要理由 API」(文件时间戳、UserDefaults 等)以及所有收集的数据类型。我们第一次提审被打回:用的一个旧版三方统计库偷偷采集了设备 ID,隐私声明里却没写。更新 SDK、补隐私清单(PrivacyInfo.xcprivacy)、重提,前后耗了一周。
教训:隐私合规要在开发期就设计,不是提审前补作业。能不接的统计 SDK 就别接,接了就得为它的一切行为负责——结合踩坑故事 7,第三方库的两笔账(编译成本和合规成本)要一起算。
第六篇 AI 时代:怎么少写代码把 App 做出来
这部分是我最近两年工作方式最大的变化,也是这篇文章最想说的。
6.1 现实:AI 能写多少 iOS 代码
我的实测结论:AI 写 SwiftUI 页面的完成度约 70-90%,写 ViewModel 和业务逻辑约 60-80%,处理证书、签名、上架合规约等于 0。
flowchart TB
A["需求 / 设计稿"] --> B["人:拆成清晰的任务<br/>写 Prompt"]
B --> C["AI:生成 View + ViewModel 初稿"]
C --> D["人:Review + 跑起来验证"]
D --> E{"有问题?"}
E -->|"截图 + 报错喂回去"| C
E -->|"没问题"| F["人:处理 AI 干不了的<br/>签名 / 性能 / 审核"]
F --> G["提审上架"]
关键变化是:你的角色从「写代码的人」变成「拆任务、验收代码、兜底的人」。
6.2 高效 Prompt 的三个套路
套路一:给足上下文,别让 AI 猜。 差的 Prompt 是「帮我写个登录页」;好的 Prompt 是:
用 SwiftUI 写一个登录页。要求:iOS 17 的 NavigationStack;ViewModel 用 @Observable 宏;账号密码字段校验(账号为邮箱格式,密码至少 8 位);登录按钮在请求中显示 ProgressView 并禁用;错误用 alert 展示。Model 层已有
struct AuthResponse: Codable { token: String },网络调用留一个func login(email:password:) async throws -> AuthResponse的协议方法,不要写死实现。
上下文越具体(部署版本、已有类型、项目约定),AI 产出能直接用得越多。
套路二:截图驱动开发。 SwiftUI 的一大红利是「所见即所得」特别适合多模态 AI:把设计稿截图发给 AI 生成初版界面,跑起来后再把模拟器截图发回去说「间距不对,标题字号小了」,来回两轮,像素级还原度能到九成。这在 UIKit 时代是不可想象的。
套路三:让 AI 先写测试和协议,再写实现。 先让它定义 protocol UserService,再生成实现和单元测试。有了测试护栏,AI 后续改代码翻车时你能立刻发现——没有测试的 AI 代码,就像没有保险的跑车。
6.3 Demo:一次真实的 AI 协同开发记录
3.5 节那个 TodoList,我用 AI 辅助重写过一版,真实过程是这样:
- 第一轮(2 分钟):把半成品代码贴给 AI,说「加上本地持久化,用 UserDefaults + Codable」。它给出了
didSet里 save 的方案——直接可用。 - 第二轮(踩坑):AI 自作主张把
Todo.id改成了Int自增,导致重启后 id 冲突、删除错乱。**这是 AI 代码的典型问题:它能跑通 happy path,但对「持久化后 id 唯一性」这种隐含约束没概念。**我让它改回UUID(),并补了一条测试。 - 第三轮:让它加「侧滑删除」和「底部输入栏」,一次通过。
总耗时约 40 分钟,纯手写我估计要 3 小时。省下来的 2 小时 20 分钟去哪了? 一部分花在 Review AI 的代码上——这恰恰是核心价值所在。
6.4 什么 AI 干不了,什么你必须会
flowchart LR
subgraph AI擅长["AI 擅长,放手让它写"]
A1["样板页面 / CRUD 界面"]
A2["Codable 模型 单元测试"]
A3["格式化 转换等工具函数"]
end
subgraph 人必须懂["AI 容易翻车,人必须把关"]
B1["内存与生命周期<br/>weak self 状态所有权"]
B2["系统权限与后台模型<br/>定位 通知 假后台"]
B3["签名 上架 隐私合规"]
B4["性能调优<br/>Instruments 读调用栈"]
end
看出来了吗?AI 干不了的,恰好是这篇文章前面所有「踩坑故事」涉及的东西:循环引用、状态所有权、权限时机、进程死亡、签名、合规。AI 生成的代码照样犯「@ObservedObject 当主人用」「闭包没写 weak self」这些错——能一眼看出它错的人,才能真正驾驭它。
6.5 一些真心话
关于学习路径:「AI 都能写了,还要不要学语法?」——正因为 AI 能写,你才更要懂原理。看不懂的代码你不敢上线,看不懂的 bug 你修不了。AI 时代的学习重心从「记住 API」转向「理解原理 + 会拆任务 + 会验收」。
关于 SwiftUI vs UIKit 的争论:AI 的语料里 SwiftUI 越来越占主流,声明式代码也更适合 AI 生成和修改。这条技术路线的选择,AI 因素已经足以成为加分项。
关于独立开发者:AI 把「一个人做出一个上架 App」的门槛降到了历史最低。设计稿可以 AI 出,代码可以 AI 写大半,连应用图标的文案都能 AI 润色。真正的瓶颈回到了最古老的地方:你想做一个什么东西,它解决谁的问题。技术不再是门槛,判断力才是。
结语
回头看这条路:环境搭建教你敬畏平台规则,Swift 语法教你与编译器合作,UI 选型教你权衡范式,系统特性教你「把任务交给系统」,中级内功教你把 App 做到上线级,而 AI 协同教你把重心放回「想清楚要什么」。
入门的标志是你的 App 能跑在手机上;精通的标志,是审核被拒、线上崩溃、定位漂移、AI 生成了一坨看似能跑的代码时,你都知道问题出在哪、下一步做什么。
祝你的第一个 App 早日上架。