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