10.1 MVVM 架构详解
MVVM(Model-View-ViewModel)是目前 iOS 开发中最流行的架构模式。它将代码按职责分为三层,使各层独立、可测试、可维护。
struct 或 SwiftData 的 @Model class。
View 天然适合这个角色。
@Observable 修饰使 View 能响应其变化。
三层职责对比
| 层 | 关注点 | 示例 | Swift 类型 |
|---|---|---|---|
| Model | 数据结构 | Article、User、Order | struct / @Model class |
| ViewModel | 业务逻辑、数据转换 | 从 API 获取文章列表、处理用户点赞 | @Observable class |
| View | UI 渲染、用户交互 | ArticleListView、ArticleDetailView | struct: View |
为何 SwiftUI 天然适合 MVVM?
SwiftUI 的声明式本质与 MVVM 高度契合:
- 单向数据流:ViewModel 持有状态 → View 订阅状态变化 → 自动渲染。清晰明确,永远知道数据从哪来。
- @Observable 自动追踪:SwiftUI 自动追踪 View 访问了 ViewModel 的哪些属性,只有这些属性变化时才重新渲染,零样板代码。
- View 是值类型:SwiftUI 的 View(struct)是值类型,副作用少,天然适合作为纯展示层。
10.2 @Observable ViewModel 实现 MVVM
@Observable(iOS 17+)是实现 ViewModel 的最佳方式。它比旧版的 ObservableObject + @Published 更简洁高效,只有被 View 实际读取的属性才会触发重渲染。
旧版:每个需要监听的属性都加 @Published,任一属性变化都会触发整个视图重渲染。新版:@Observable 自动追踪,只有 View 真正用到的属性变化才触发,性能更好,代码更简洁。
// Model
struct Article: Identifiable, Codable {
let id: Int
let title: String
let summary: String
let author: String
let publishedAt: String
let imageURL: String?
var isBookmarked: Bool = false
}
// ViewModel:用 @Observable 标注,负责所有业务逻辑
@Observable
class NewsViewModel {
// 状态属性(View 会订阅这些)
var articles: [Article] = []
var isLoading = false
var errorMessage: String? = nil
var selectedCategory = "科技"
// 计算属性:从 articles 中过滤书签
var bookmarkedArticles: [Article] {
articles.filter { $0.isBookmarked }
}
private let service = NewsService()
// 异步加载新闻
func loadArticles() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
articles = try await service.fetchArticles(category: selectedCategory)
} catch {
errorMessage = error.localizedDescription
}
}
// 业务逻辑:切换书签
func toggleBookmark(for article: Article) {
if let index = articles.firstIndex(where: { $0.id == article.id }) {
articles[index].isBookmarked.toggle()
}
}
// 业务逻辑:切换分类并重新加载
func selectCategory(_ category: String) async {
selectedCategory = category
await loadArticles()
}
}
// View:只负责展示,业务逻辑全部委托给 ViewModel
struct NewsListView: View {
@State private var viewModel = NewsViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("加载新闻...")
} else if let error = viewModel.errorMessage {
ContentUnavailableView(error, systemImage: "wifi.exclamationmark")
} else {
ArticleList(articles: viewModel.articles, viewModel: viewModel)
}
}
.navigationTitle("今日新闻")
.task { await viewModel.loadArticles() }
}
}
}
10.3 App Icon 规格要求
App Icon 是应用在 iPhone 桌面、App Store 搜索结果中的门面,是用户对应用的第一印象,不可忽视。
| 使用场景 | 尺寸 | 格式要求 |
|---|---|---|
| App Store 展示 | 1024 × 1024 pt | PNG,无圆角(系统自动裁切),无透明 |
| iPhone 主屏(@2x) | 120 × 120 px | Xcode 自动从 1024 生成 |
| iPhone 主屏(@3x) | 180 × 180 px | Xcode 自动从 1024 生成 |
| Spotlight 搜索 | 80 × 80 px / 120 × 120 px | Xcode 自动生成 |
| 设置页面 | 58 × 58 px / 87 × 87 px | Xcode 自动生成 |
Xcode 15+ 支持"单图标"模式:你只需提供一张 1024 × 1024 PNG,Xcode 会自动生成所有尺寸变体。在 Assets.xcassets 中的 AppIcon 设置中选择 Single Size 即可。
① 不能包含 Apple Logo 或任何受版权保护的商标;② 背景不能有透明像素;③ 尺寸必须精确(多1像素也会审核失败);④ 图标要在 @1x、@2x、@3x 各尺寸下都清晰可辨。
10.4 Launch Screen / Splash Screen
Launch Screen(启动画面)是 App 启动期间、第一个视图加载完成前显示的过渡画面。它让用户感知到 App 正在加载,避免空白屏带来的困惑。
Info.plist 中通过 UILaunchScreen 字典配置背景色和图片,不需要 Storyboard。
// Info.plist 中配置 Launch Screen(现代方式,无需 Storyboard)
// 在 Xcode 项目的 Info 标签中添加:
UILaunchScreen
├── UIColorName: "LaunchBackground" // Assets 中定义的颜色
└── UIImageName: "LaunchLogo" // Assets 中的图片名
// SwiftUI 的 App 文件中不需要额外代码
// 系统会自动在 @main App 的第一个 View 加载前显示 Launch Screen
10.5 Info.plist 权限声明
iOS 的隐私保护机制要求:凡是需要访问用户隐私数据(相机、位置、麦克风等)的 API,都必须在 Info.plist 中提前声明使用目的说明(Usage Description),且说明必须真实具体,否则审核会被拒绝。
Usage Description 不能写"需要此权限"这类模糊描述,必须写清楚为何需要该权限,例如:"需要访问您的相机以扫描商品条形码"。模糊描述会被 App Store 审核拒绝。
| 权限 Key | 对应功能 | 触发 API |
|---|---|---|
NSCameraUsageDescription | 相机 | AVCaptureDevice |
NSPhotoLibraryUsageDescription | 相册读取 | PhotosUI |
NSPhotoLibraryAddUsageDescription | 相册写入 | PHPhotoLibrary.shared().savePhoto |
NSMicrophoneUsageDescription | 麦克风 | AVAudioSession |
NSLocationWhenInUseUsageDescription | 使用中位置 | CLLocationManager |
NSLocationAlwaysUsageDescription | 始终位置 | 后台定位 |
NSContactsUsageDescription | 通讯录 | CNContactStore |
NSFaceIDUsageDescription | Face ID | LocalAuthentication |
NSBluetoothAlwaysUsageDescription | 蓝牙 | CoreBluetooth |
10.6 Bundle ID 与 App ID
com.公司名.应用名,例如 com.apple.mobileSafari。在 Xcode 项目的 Target → General → Bundle Identifier 中设置。一旦上架后不能修改,否则视为新 App。
ABC123XYZ.com.yourcompany.yourapp。用于关联 App 与 Push Notification、App Groups、Game Center 等能力(Capabilities)。
10.7 证书与描述文件
iOS 代码签名是 Apple 安全体系的核心,所有运行在 iOS 设备上的代码必须经过签名验证。
在 Xcode → 项目设置 → Signing & Capabilities → 勾选 "Automatically manage signing" 并选择你的 Team。只要你的 Apple ID 已加入 Developer Program,Xcode 会自动处理所有证书和描述文件问题。
10.8 TestFlight 测试流程
TestFlight 是 Apple 官方的 Beta 测试平台,可以在正式上架前将 App 分发给最多 10,000 名外部测试用户。
测试流程步骤
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | Archive(打包) | Xcode → Product → Archive,生成 .xcarchive 文件 |
| 2 | 上传到 App Store Connect | 在 Organizer 窗口点击 "Distribute App",选择 App Store Connect |
| 3 | 等待处理 | Apple 需要 15-30 分钟处理构建包(自动扫描违规内容) |
| 4 | 添加测试用户 | 内部测试:最多 25 名 Developer 成员,无需 Beta 审核;外部测试:最多 10,000 人,需通过 Beta 审核 |
| 5 | 发送邀请 | 测试者收到邮件,安装 TestFlight App 后即可安装测试版 |
| 6 | 收集反馈 | 测试者可在 TestFlight 中提交截图反馈,崩溃日志自动上报 |
每个 TestFlight 构建有效期为 90 天,超期后测试者无法再安装该版本。建议在有效期结束前上传新构建。
10.9 App Store Connect 提交步骤
App Store Connect(appstoreconnect.apple.com)是管理 App 提交、元数据、内购、分析数据的控制台。
完整提交流程
| 阶段 | 工作内容 |
|---|---|
| 1. 准备素材 | App 图标(1024×1024)、截图(每个设备规格各2-10张)、App 预览视频(可选) |
| 2. 创建 App 记录 | 在 App Store Connect → My Apps → 新建 App,填写名称、Bundle ID、SKU、主语言 |
| 3. 填写元数据 | App 名称(最多30字)、副标题(最多30字)、关键词(最多100字)、描述(最多4000字)、隐私政策URL |
| 4. 上传截图 | 必须提供 6.5寸(iPhone 14 Pro Max)和 5.5寸(iPhone 8 Plus)截图;iPad App 还需 12.9寸截图 |
| 5. 选择构建 | 从 TestFlight 上传的构建中选择要提交的版本 |
| 6. 内容分级 | 回答问卷,系统自动评定年龄分级(4+、9+、12+、17+) |
| 7. 定价与地区 | 设置售价(免费或付费)、销售地区 |
| 8. 提交审核 | 点击 "Submit for Review",进入等待审核队列 |
正常情况下 App Store 审核在 1-3 个工作日内完成。遇到节假日或重大产品发布期间可能延长到 5-7 天。紧急情况可以申请加急审核(Expedited Review)。
10.10 审核规范要点
App Store 审核指南(App Store Review Guidelines)是 Apple 对所有 App 的规范要求。了解常见拒绝原因可以避免反复修改浪费时间。
常见拒绝原因
| 类别 | 常见问题 | 解决方案 |
|---|---|---|
| 功能不完整 | App 有崩溃、功能无法使用、占位内容未替换 | 充分测试,确保所有功能可用 |
| 权限滥用 | 申请了不必要的权限、Usage Description 不清晰 | 只申请真正需要的权限,描述要具体 |
| 登录要求 | 强制要求登录才能体验基本功能,未提供 Demo 账号 | 提供 Demo 账号给审核员,或允许访客模式 |
| 内购绕过 | 通过第三方支付绕过 Apple 内购(数字商品必须走 IAP) | 数字内容必须使用 StoreKit |
| 隐私政策 | 收集用户数据但未提供隐私政策链接 | 提供有效的隐私政策 URL |
| 元数据问题 | 截图与实际 App 不符、名称含竞品关键词 | 截图必须真实展示 App 功能 |
| 设计质量 | UI 不完善、使用系统应用截图 | 参照 Human Interface Guidelines |
| 违规内容 | 含有赌博、成人、欺骗性内容 | 遵守内容规范 |
① App 是否在所有支持的设备上测试通过?② 是否有崩溃?③ 权限说明是否具体?④ 是否有隐私政策URL?⑤ 截图是否与实际功能一致?⑥ 如有账号登录,是否提供了审核员可用的测试账号?⑦ 描述中是否有错别字?
10.11 实战示例:完整 MVVM 新闻列表 App
将本章所有知识整合,实现一个分层清晰、结构完整的新闻列表应用。
项目结构
NewsApp/
├── NewsApp.swift // @main App 入口
├── Models/
│ └── Article.swift // 数据模型
├── Services/
│ └── NewsService.swift // 网络服务(纯数据获取,无 UI 知识)
├── ViewModels/
│ └── NewsViewModel.swift // 业务逻辑(连接 Service 和 View)
├── Views/
│ ├── NewsListView.swift // 新闻列表
│ ├── NewsDetailView.swift // 新闻详情
│ ├── CategoryBar.swift // 分类选择栏(子视图)
│ └── ArticleCard.swift // 单篇文章卡片(子视图)
└── Assets.xcassets // 图片资源
Model 层
// Models/Article.swift
struct Article: Identifiable, Codable, Hashable {
let id: UUID
let title: String
let summary: String
let author: String
let category: String
let publishedAt: Date
let imageURL: URL?
var isBookmarked: Bool
// 格式化发布时间(视图展示专用)
var formattedDate: String {
publishedAt.formatted(.relative(presentation: .named))
}
}
Service 层
// Services/NewsService.swift
// Service 层只负责网络通信和数据转换,对 UI 一无所知
protocol NewsServiceProtocol {
func fetchArticles(category: String) async throws -> [Article]
}
struct NewsService: NewsServiceProtocol {
private let baseURL = "https://newsapi.org/v2/top-headlines"
private let apiKey = "YOUR_NEWS_API_KEY"
func fetchArticles(category: String) async throws -> [Article] {
var components = URLComponents(string: baseURL)!
components.queryItems = [
URLQueryItem(name: "category", value: category.lowercased()),
URLQueryItem(name: "country", value: "cn"),
URLQueryItem(name: "apiKey", value: apiKey)
]
let (data, response) = try await URLSession.shared.data(from: components.url!)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw NetworkError.invalidResponse
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let result = try decoder.decode(NewsResponse.self, from: data)
return result.articles
}
}
// Mock Service(用于预览和单元测试)
struct MockNewsService: NewsServiceProtocol {
func fetchArticles(category: String) async throws -> [Article] {
try? await Task.sleep(for: .seconds(0.5)) // 模拟网络延迟
return Article.mockData // 返回静态测试数据
}
}
ViewModel 层
// ViewModels/NewsViewModel.swift
@Observable
class NewsViewModel {
var articles: [Article] = []
var isLoading = false
var errorMessage: String? = nil
var selectedCategory = "科技"
var searchText = ""
// 计算属性:根据搜索词过滤
var filteredArticles: [Article] {
guard !searchText.isEmpty else { return articles }
return articles.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.summary.localizedCaseInsensitiveContains(searchText)
}
}
let categories = ["科技", "商业", "娱乐", "健康", "体育"]
private let service: NewsServiceProtocol
init(service: NewsServiceProtocol = NewsService()) {
self.service = service // 依赖注入,方便测试时传入 MockService
}
func loadArticles() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
articles = try await service.fetchArticles(category: selectedCategory)
} catch {
errorMessage = error.localizedDescription
}
}
func selectCategory(_ category: String) async {
selectedCategory = category
await loadArticles()
}
func toggleBookmark(_ article: Article) {
guard let index = articles.firstIndex(of: article) else { return }
articles[index].isBookmarked.toggle()
}
func refresh() async {
await loadArticles()
}
}
View 层
// Views/NewsListView.swift
struct NewsListView: View {
@State private var viewModel = NewsViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// 分类导航栏(子视图)
CategoryBar(
categories: viewModel.categories,
selected: viewModel.selectedCategory
) { category in
Task { await viewModel.selectCategory(category) }
}
// 内容区域(三态)
if viewModel.isLoading {
Spacer()
ProgressView("正在加载...")
Spacer()
} else if let error = viewModel.errorMessage {
Spacer()
ContentUnavailableView {
Label("加载失败", systemImage: "wifi.exclamationmark")
} description: {
Text(error)
} actions: {
Button("重试") { Task { await viewModel.refresh() } }
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.filteredArticles) { article in
NavigationLink(value: article) {
ArticleCard(
article: article,
onBookmark: { viewModel.toggleBookmark(article) }
)
}
.buttonStyle(.plain)
}
}
.padding()
}
.refreshable {
await viewModel.refresh()
}
}
}
.navigationTitle("今日新闻")
.searchable(text: $viewModel.searchText, prompt: "搜索新闻")
.navigationDestination(for: Article.self) { article in
NewsDetailView(article: article)
}
.task {
await viewModel.loadArticles()
}
}
}
}
// Views/ArticleCard.swift(纯 UI 组件,无业务逻辑)
struct ArticleCard: View {
let article: Article
let onBookmark: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let url = article.imageURL {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 180)
.clipShape(.rect(cornerRadius: 8))
}
Text(article.title)
.font(.headline)
.lineLimit(2)
Text(article.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(3)
HStack {
Text(article.author).font(.caption).foregroundStyle(.tertiary)
Spacer()
Text(article.formattedDate).font(.caption).foregroundStyle(.tertiary)
Button(action: onBookmark) {
Image(systemName: article.isBookmarked ? "bookmark.fill" : "bookmark")
.foregroundStyle(article.isBookmarked ? .yellow : .secondary)
}
}
}
.padding()
.background(.background)
.clipShape(.rect(cornerRadius: 12))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
}
}
// App 入口
@main
struct NewsApp: App {
var body: some Scene {
WindowGroup {
NewsListView()
}
}
}
恭喜你完成了整个 iOS/SwiftUI 课程!你已经掌握了:Swift 基础语法、SwiftUI 视图系统、状态管理、导航与列表、网络请求、动画手势、数据持久化,以及 MVVM 架构和上架发布的完整流程。接下来,动手做一个自己的 App,才是真正的开始!