Chapter 10

App 架构与发布上架

用 MVVM 组织代码架构,通过 TestFlight 测试,最终将 App 提交到 App Store 与全球用户见面

10.1 MVVM 架构详解

MVVM(Model-View-ViewModel)是目前 iOS 开发中最流行的架构模式。它将代码按职责分为三层,使各层独立、可测试、可维护。

Model(模型层) 纯数据结构,描述业务中的实体(用户、文章、订单等)。不了解 UI,不了解如何展示数据,只关注数据是什么。通常是 struct 或 SwiftData 的 @Model class
View(视图层) 负责展示 UI,监听用户操作。只从 ViewModel 获取数据,不直接操作数据或执行业务逻辑。SwiftUI 的 View 天然适合这个角色。
ViewModel(视图模型层) View 和 Model 之间的桥梁。负责从数据层(网络、数据库)获取数据,转换为 View 可直接使用的格式,处理用户操作的业务逻辑。用 @Observable 修饰使 View 能响应其变化。

三层职责对比

关注点示例Swift 类型
Model数据结构ArticleUserOrderstruct / @Model class
ViewModel业务逻辑、数据转换从 API 获取文章列表、处理用户点赞@Observable class
ViewUI 渲染、用户交互ArticleListViewArticleDetailViewstruct: View

为何 SwiftUI 天然适合 MVVM?

SwiftUI 的声明式本质与 MVVM 高度契合:

10.2 @Observable ViewModel 实现 MVVM

@Observable(iOS 17+)是实现 ViewModel 的最佳方式。它比旧版的 ObservableObject + @Published 更简洁高效,只有被 View 实际读取的属性才会触发重渲染。

ℹ️
@Observable vs ObservableObject

旧版:每个需要监听的属性都加 @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 ptPNG,无圆角(系统自动裁切),无透明
iPhone 主屏(@2x)120 × 120 pxXcode 自动从 1024 生成
iPhone 主屏(@3x)180 × 180 pxXcode 自动从 1024 生成
Spotlight 搜索80 × 80 px / 120 × 120 pxXcode 自动生成
设置页面58 × 58 px / 87 × 87 pxXcode 自动生成
💡
现代 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 正在加载,避免空白屏带来的困惑。

LaunchScreen.storyboard 老式方式,通过 Interface Builder 的 Storyboard 文件定义启动画面,支持任意布局。
Info.plist 键值配置(现代方式) 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
NSFaceIDUsageDescriptionFace IDLocalAuthentication
NSBluetoothAlwaysUsageDescription蓝牙CoreBluetooth

10.6 Bundle ID 与 App ID

Bundle Identifier(包标识符) 应用的唯一标识符,通常采用反向域名格式:com.公司名.应用名,例如 com.apple.mobileSafari。在 Xcode 项目的 Target → General → Bundle Identifier 中设置。一旦上架后不能修改,否则视为新 App。
App ID 在 Apple Developer 网站注册的应用标识,包含 Team ID 前缀和 Bundle ID,例如 ABC123XYZ.com.yourcompany.yourapp。用于关联 App 与 Push Notification、App Groups、Game Center 等能力(Capabilities)。
Team ID Apple Developer 账号的唯一标识,10位字母数字组合。在 developer.apple.com 的账号信息中可查看。

10.7 证书与描述文件

iOS 代码签名是 Apple 安全体系的核心,所有运行在 iOS 设备上的代码必须经过签名验证。

Developer Certificate(开发者证书) 证明"这段代码是我写的"的数字证书,由 Apple 颁发。分两种:开发证书(用于在真机调试)和发布证书(用于提交 App Store 或 TestFlight)。存储在 Mac 的 Keychain 中。
Provisioning Profile(描述文件) 将"App + 证书 + 设备"绑定在一起的配置文件,告诉 iOS 系统"哪个 App 可以运行在哪些设备上,由哪个开发者签名"。分为:开发描述文件(指定测试设备 UDID)和发布描述文件(用于 App Store 或企业分发)。
Automatic Signing(自动签名) Xcode 的"Automatically manage signing"选项,让 Xcode 自动处理证书和描述文件的创建、更新和下载,强烈推荐开启,可以避免 90% 的签名问题。
💡
Xcode 自动签名

在 Xcode → 项目设置 → Signing & Capabilities → 勾选 "Automatically manage signing" 并选择你的 Team。只要你的 Apple ID 已加入 Developer Program,Xcode 会自动处理所有证书和描述文件问题。

10.8 TestFlight 测试流程

TestFlight 是 Apple 官方的 Beta 测试平台,可以在正式上架前将 App 分发给最多 10,000 名外部测试用户。

测试流程步骤

步骤操作说明
1Archive(打包)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 构建有效期

每个 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,才是真正的开始!