<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>移动开发 on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/%E7%A7%BB%E5%8A%A8%E5%BC%80%E5%8F%91/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Wed, 16 Aug 2023 09:40:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/%E7%A7%BB%E5%8A%A8%E5%BC%80%E5%8F%91/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>微信小程序开发从入门到精通：环境搭建、WXML/WXSS、组件库、架构、平台能力与发布全指南</title>
      <link>https://ftmao.com/blog/posts/wechat-miniprogram-beginner-to-mastery/</link>
      <pubDate>Wed, 16 Aug 2023 09:40:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/wechat-miniprogram-beginner-to-mastery/</guid>
      <description>&lt;h1 id=&#34;微信小程序开发从入门到精通环境搭建wxmlwxss组件库架构平台能力与发布全指南&#34;&gt;微信小程序开发从入门到精通：环境搭建、WXML/WXSS、组件库、架构、平台能力与发布全指南&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：想进入小程序开发的前端同学，或者写过 Vue/React、想搞清楚「小程序和网页开发到底差在哪」的同学。&lt;br&gt;
知识骨架与编排方式参考姊妹篇《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/android-beginner-to-mastery/&#34;&gt;Android 开发从入门到精通&lt;/a&gt;》——同样是「由浅入深、踩坑驱动」。写 UI 的心法还可以看《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/frontend-ui-vue-mastery/&#34;&gt;前端 UI 开发从入门到精通（Vue 技术栈）&lt;/a&gt;》，小程序的组件化思想和 Vue 一脉相承。&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;一 环境搭建&amp;lt;br/&amp;gt;注册 AppID 跑起 Hello World&amp;#34;] --&amp;gt; B[&amp;#34;二 语法基础&amp;lt;br/&amp;gt;WXML/WXSS/JS/JSON 四件套&amp;#34;]
    B --&amp;gt; C[&amp;#34;三 UI 组件库&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;lt;br/&amp;gt;数据从哪来 存到哪去&amp;#34;]
    E --&amp;gt; F[&amp;#34;六 平台能力&amp;lt;br/&amp;gt;登录 订阅消息 分享 支付&amp;#34;]
    F --&amp;gt; G[&amp;#34;七 质量与发布&amp;lt;br/&amp;gt;性能 体验版 审核上线&amp;#34;]
    G --&amp;gt; H[&amp;#34;八 AI 时代&amp;lt;br/&amp;gt;让 AI 写 80% 的代码&amp;#34;]
    style H fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;为什么是这个顺序？前三篇解决「&lt;strong&gt;能不能写出来&lt;/strong&gt;」；第四篇解决「&lt;strong&gt;页面多了以后会不会散架&lt;/strong&gt;」；第五、六篇解决「&lt;strong&gt;能不能和后端、和微信这个平台打交道&lt;/strong&gt;」；第七篇解决「&lt;strong&gt;敢不敢提交审核发给真实用户&lt;/strong&gt;」；第八篇解决「&lt;strong&gt;如何持续快下去&lt;/strong&gt;」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第一篇-环境搭建比-android-友好但坑藏在账号里&#34;&gt;第一篇 环境搭建：比 Android 友好，但坑藏在「账号」里&lt;/h2&gt;
&lt;p&gt;先说好消息：小程序的环境搭建是整个客户端开发里最友好的——不用装 JDK，不用配 SDK，不用 Gradle 转圈。坏消息是：&lt;strong&gt;门槛从「装软件」转移到了「注册账号」&lt;/strong&gt;，而账号相关的坑往往要在提交审核那天才爆出来。&lt;/p&gt;
&lt;h3 id=&#34;11-你需要准备的三样东西&#34;&gt;1.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 TD
    A[&amp;#34;小程序账号 (AppID)&amp;lt;br/&amp;gt;mp.weixin.qq.com 注册&amp;#34;] --&amp;gt; D[&amp;#34;微信开发者工具&amp;#34;]
    B[&amp;#34;微信开发者工具&amp;lt;br/&amp;gt;官方 IDE 写代码 + 模拟器 + 调试&amp;#34;] --&amp;gt; E[&amp;#34;能跑起 Hello World&amp;lt;br/&amp;gt;= 入门完成一半&amp;#34;]
    C[&amp;#34;微信扫码登录&amp;lt;br/&amp;gt;一个微信号即可&amp;#34;] --&amp;gt; D
    D --&amp;gt; E&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;动手清单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;注册小程序账号&lt;/strong&gt;：到 &lt;a href=&#34;https://mp.weixin.qq.com&#34;&gt;mp.weixin.qq.com&lt;/a&gt; 选择「小程序」注册，拿到 &lt;strong&gt;AppID&lt;/strong&gt;（在「开发管理 → 开发设置」里）。注意一个邮箱只能绑定一个微信公众产品账号。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;下载微信开发者工具&lt;/strong&gt;：认准&lt;a href=&#34;https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html&#34;&gt;官方页面&lt;/a&gt;，选稳定版（Stable Build）。它是「编辑器 + 模拟器 + 调试器 + 发布工具」四合一，写小程序不需要再装别的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新建项目&lt;/strong&gt;：填入 AppID，模板选「不使用云服务」的最小模板，后端服务选「不使用模板」。模拟器里看到 Hello World 即通关。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果只是学习不打算发布，也可以在新建项目时选「测试号」，跳过注册——但测试号很多能力（支付、订阅消息、发布）用不了，建议还是注册一个正式的个人账号。&lt;/p&gt;
&lt;h3 id=&#34;12-踩坑故事一类目选错审核排队两周白排&#34;&gt;1.2 踩坑故事一：类目选错，审核排队两周白排&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：朋友的小程序功能全部做完，信心满满提交审核，第二天被拒：「所选类目与小程序实际内容不符」。改类目需要提供《增值电信业务经营许可证》——他没有。推倒重来，换了个人主体账号重新注册，前面所有配置全部作废。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：注册时选择的&lt;strong&gt;服务类目&lt;/strong&gt;和&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
    Q{&amp;#34;小程序要做什么？&amp;#34;}
    Q --&amp;gt;|&amp;#34;内容展示 / 工具&amp;lt;br/&amp;gt;不涉及交易&amp;#34;| A[&amp;#34;个人主体即可&amp;lt;br/&amp;gt;注册免费 门槛低&amp;#34;]
    Q --&amp;gt;|&amp;#34;电商 / 需要微信支付&amp;#34;| B[&amp;#34;必须企业主体&amp;lt;br/&amp;gt;+ 对应经营类目&amp;#34;]
    Q --&amp;gt;|&amp;#34;医疗 / 金融 / 社交&amp;#34;| C[&amp;#34;企业主体 + 行业资质&amp;lt;br/&amp;gt;提前查资质清单再动手&amp;#34;]
    style C fill:#ffe4e1&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：&lt;strong&gt;先查类目，再写代码&lt;/strong&gt;。微信官方有《小程序开放的服务类目》文档，写清楚每个类目需要什么资质。个人主体不能碰电商交易、不能做陌生人社交——无数个人开发者做到一半才发现「个人号不支持」，这是小程序生态最贵的一个坑，因为它爆在最后一步。&lt;/p&gt;
&lt;h3 id=&#34;13-踩坑故事二不校验合法域名的虚假安全感&#34;&gt;1.3 踩坑故事二：「不校验合法域名」的虚假安全感&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：开发期接口调得好好的，一到真机预览就白屏，控制台报 &lt;code&gt;request:fail url not in domain list&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：小程序的 &lt;code&gt;wx.request&lt;/code&gt; 只能请求在 mp 后台&lt;strong&gt;预先配置过的 HTTPS 域名&lt;/strong&gt;（request 合法域名）。开发者工具里有个「详情 → 本地设置 → 不校验合法域名」的开关，很多人开发期一直勾着它，勾到忘记它的存在——而真机上这个开关不存在（除非打开调试模式）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;去 mp 后台「开发管理 → 开发设置 → 服务器域名」配置 request 合法域名，&lt;strong&gt;必须 HTTPS，且域名要 ICP 备案&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;开发期就把「不校验合法域名」关掉，让问题暴露在写代码的时候，而不是发给产品经理体验的时候。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：小程序的「域名校验」和 Android 的「明文流量拦截」是同一类东西——平台用白名单逼你上 HTTPS。这类安全约束的特点是「先放行以后再收」基本等于永远不收，第一天就把域名配好。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第二篇-语法基础四个文件就是一个页面&#34;&gt;第二篇 语法基础：四个文件就是一个页面&lt;/h2&gt;
&lt;p&gt;环境通了，接下来是语言。小程序的语法对前端来说几乎没有学习成本——它就是「受限版的 Vue」：模板 + 数据绑定 + 指令 + 组件化。这一篇的重点不是语法本身，而是&lt;strong&gt;它和自由 Web 的差异&lt;/strong&gt;，坑全在差异里。&lt;/p&gt;
&lt;h3 id=&#34;21-一个页面--四个文件&#34;&gt;2.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 TD
    A[&amp;#34;index.wxml&amp;lt;br/&amp;gt;模板 ≈ HTML&amp;lt;br/&amp;gt;写结构&amp;#34;] --- P[&amp;#34;一个页面&amp;#34;]
    B[&amp;#34;index.wxss&amp;lt;br/&amp;gt;样式 ≈ CSS&amp;lt;br/&amp;gt;多了 rpx 单位&amp;#34;] --- P
    C[&amp;#34;index.js&amp;lt;br/&amp;gt;逻辑：Page({...})&amp;lt;br/&amp;gt;data + 生命周期 + 事件处理&amp;#34;] --- P
    D[&amp;#34;index.json&amp;lt;br/&amp;gt;配置：导航栏标题&amp;lt;br/&amp;gt;引入哪些组件&amp;#34;] --- P
    P --&amp;gt; E[&amp;#34;在 app.json 的 pages 数组里&amp;lt;br/&amp;gt;注册路径才算存在&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;全局还有一组 &lt;code&gt;app.js / app.json / app.wxss&lt;/code&gt;：App 的生命周期、全局配置（页面注册、tabBar、窗口样式）、全局样式。&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;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-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- pages/index/index.wxml --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;container&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;text&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;title&amp;#34;&lt;/span&gt;&amp;gt;{{title}}&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;text&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;wx:for&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{todos}}&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;wx:key&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;todo&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;text&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{item.done ? &amp;#39;done&amp;#39; : &amp;#39;&amp;#39;}}&amp;#34;&lt;/span&gt;&amp;gt;{{item.text}}&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;text&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;button&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;bindtap&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onAdd&amp;#34;&lt;/span&gt;&amp;gt;添加&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;button&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt;&amp;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;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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// pages/index/index.js
&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:#a6e22e&#34;&gt;Page&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:#a6e22e&#34;&gt;data&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:#a6e22e&#34;&gt;title&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;我的待办&amp;#39;&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:#a6e22e&#34;&gt;todos&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:#a6e22e&#34;&gt;id&lt;/span&gt;&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 style=&#34;color:#a6e22e&#34;&gt;text&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;学会 WXML&amp;#39;&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;done&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;true&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:#a6e22e&#34;&gt;id&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;text&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;学会 setData&amp;#39;&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;done&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;onAdd&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;concat&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:#a6e22e&#34;&gt;id&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; Date.&lt;span style=&#34;color:#a6e22e&#34;&gt;now&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:#a6e22e&#34;&gt;text&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;新任务&amp;#39;&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:#a6e22e&#34;&gt;done&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &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 style=&#34;color:#75715e&#34;&gt;// ⚠️ 改数据必须走 setData，直接 this.data.todos = todos 界面不会刷新
&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&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;h3 id=&#34;22-setdata小程序开发的第一性原理&#34;&gt;2.2 setData：小程序开发的第一性原理&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;小程序的视图和逻辑跑在两个不同的线程&lt;/strong&gt;：视图层是 WebView，逻辑层是 JSCore。你在 JS 里改 &lt;code&gt;data&lt;/code&gt;，要通过 &lt;code&gt;setData&lt;/code&gt; 把数据&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;sequenceDiagram
    participant L as 逻辑层 (JSCore)
    participant B as 原生桥 (Native)
    participant V as 视图层 (WebView)
    L-&amp;gt;&amp;gt;B: this.setData({ todos })
    Note over B: 数据序列化传输&amp;lt;br/&amp;gt;大对象 / 高频调用都在这里变慢
    B-&amp;gt;&amp;gt;V: 更新 data
    V-&amp;gt;&amp;gt;V: 重新渲染 WXML&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这一个架构事实解释了小程序里 80% 的性能问题和 80% 的新手困惑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;直接赋值 &lt;code&gt;this.data.x = 1&lt;/code&gt; 数据变了但界面不刷新——因为没有经过那座桥。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setData&lt;/code&gt; 传几十 KB 的大对象会卡——桥上的运输成本。&lt;/li&gt;
&lt;li&gt;在 &lt;code&gt;onPageScroll&lt;/code&gt; 里每次滚动都 &lt;code&gt;setData&lt;/code&gt; 会卡成 PPT——桥上堵车。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;现在只需要记住结论：setData 是有成本的，数据要小、频率要低。&lt;/strong&gt; 第七篇会专门讲怎么优化。&lt;/p&gt;
&lt;h3 id=&#34;23-十分钟速览和-vue-的核心差异&#34;&gt;2.3 十分钟速览：和 Vue 的核心差异&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;Page&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. 数据：必须先声明在 data 里，模板才能用
&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:#a6e22e&#34;&gt;data&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;count&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;list&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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#75715e&#34;&gt;// 2. 改数据：setData，支持路径写法
&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:#a6e22e&#34;&gt;onInc&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;count&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;count&lt;/span&gt; &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:#75715e&#34;&gt;// 深层路径：this.setData({ &amp;#39;list[0].done&amp;#39;: true })
&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;// 3. 没有计算属性，官方方案是 WXS 或 setData 时算好
&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;//    （新语法支持 computed 需引第三方库，见第四篇）
&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;// 4. 生命周期：onLoad / onShow / onReady / onHide / onUnload
&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:#a6e22e&#34;&gt;onLoad&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;options&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;// options 里是页面跳转带来的参数：/pages/detail?id=1 → options.id === &amp;#39;1&amp;#39;
&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:#a6e22e&#34;&gt;onShow&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&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;WXML 指令对照表（会 Vue 的看一眼就够）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Vue&lt;/th&gt;
					&lt;th&gt;小程序 WXML&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;v-if&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;wx:if&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;v-else&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;wx:else&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;v-for&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;wx:for&lt;/code&gt;，配合 &lt;code&gt;wx:key&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;@click&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;bindtap&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;:src=&amp;quot;url&amp;quot;&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;src=&amp;quot;{{url}}&amp;quot;&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;v-model&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;没有&lt;/strong&gt;，手写 &lt;code&gt;value=&amp;quot;{{x}}&amp;quot; bindinput=&amp;quot;onInput&amp;quot;&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;感悟&lt;/strong&gt;：小程序没有 &lt;code&gt;v-model&lt;/code&gt; 是刻意的——双向绑定在「两线程架构」下是个性能陷阱，官方逼着你显式处理每一次输入。理解这一点，你就理解了小程序 API 设计的整体气质：&lt;strong&gt;宁可啰嗦，不可模糊&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;24-rpx一个单位解决屏幕适配&#34;&gt;2.4 rpx：一个单位解决屏幕适配&lt;/h3&gt;
&lt;p&gt;小程序做了件 Android 和 Web 都很羡慕的事：定义了 &lt;strong&gt;rpx&lt;/strong&gt;（responsive pixel），屏幕宽度恒为 750rpx。设计稿按 750 宽出图，iPhone 6/7/8 上 1rpx = 0.5px，在更宽的机型上等比放大。样式里写 &lt;code&gt;width: 375rpx&lt;/code&gt; 就是「半屏宽」，不用算任何比例。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事三：rpx 用在不该用的地方&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：海报生成页用了 Canvas 手绘，按设计稿把字号写成 28rpx，导出图片在小屏手机上文字糊成一团。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Canvas 2D 接口、以及 &lt;code&gt;wx.createSelectorQuery&lt;/code&gt; 拿到的尺寸单位都是&lt;strong&gt;物理 px&lt;/strong&gt;，不是 rpx。rpx 只活在 WXSS 里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：涉及 Canvas / JS 计算尺寸的地方，先换算：&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;const&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;pixelRatio&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;windowWidth&lt;/span&gt; } &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;getWindowInfo&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;rpxToPx&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;rpx&lt;/span&gt;) =&amp;gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;rpx&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;windowWidth&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;750&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;：记住 rpx 的管辖范围只有 WXSS。凡是在 JS 里处理尺寸，第一件事就是确认单位。&lt;/p&gt;
&lt;h3 id=&#34;25-踩坑故事四回调里的-this-指向丢了&#34;&gt;2.5 踩坑故事四：回调里的 this 指向丢了&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;wx.request&lt;/code&gt; 的 success 回调里 &lt;code&gt;this.setData(...)&lt;/code&gt; 报错 &lt;code&gt;Cannot read property &#39;setData&#39; of undefined&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// ❌ 经典错误：普通函数的 this 不是页面实例
&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;request&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:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;https://api.example.com/todos&amp;#39;&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:#a6e22e&#34;&gt;success&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;function&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;res&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt; });  &lt;span style=&#34;color:#75715e&#34;&gt;// this 是 undefined
&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:#75715e&#34;&gt;// ✅ 解法一：箭头函数（词法 this），今天的推荐写法
&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;request&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:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;https://api.example.com/todos&amp;#39;&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:#a6e22e&#34;&gt;success&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;) =&amp;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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&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;：这是 JavaScript 的老问题，但它是每个小程序新手的「成人礼」。&lt;strong&gt;统一用箭头函数写回调&lt;/strong&gt;，这个问题就绝迹了。顺手把 &lt;code&gt;var that = this&lt;/code&gt; 这种上古写法从代码里清除出去。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第三篇-ui-组件库别从零画按钮&#34;&gt;第三篇 UI 组件库：别从零画按钮&lt;/h2&gt;
&lt;p&gt;语法有了，马上写界面。小程序自带一套基础组件（view、text、button、image、scroll-view、swiper……），但它们是「零件」不是「成品」——没有现成的日历、下拉刷新、商品卡片。这一篇解决两个问题：选什么组件库，以及怎么用它写出一个像样的页面。&lt;/p&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 TD
    Q{&amp;#34;做什么样的小程序？&amp;#34;}
    Q --&amp;gt;|&amp;#34;电商 / 通用业务&amp;lt;br/&amp;gt;组件要全&amp;#34;| A[&amp;#34;vant-weapp (有赞)&amp;lt;br/&amp;gt;生态最大 文档最细&amp;#34;]
    Q --&amp;gt;|&amp;#34;要腾讯系设计风格&amp;lt;br/&amp;gt;长期维护&amp;#34;| B[&amp;#34;TDesign (腾讯官方)&amp;lt;br/&amp;gt;设计规范完整&amp;#34;]
    Q --&amp;gt;|&amp;#34;京东系 / 多端&amp;#34;| C[&amp;#34;NutUI (京东)&amp;#34;]
    Q --&amp;gt;|&amp;#34;一套代码同时出&amp;lt;br/&amp;gt;小程序 + H5 + App&amp;#34;| D[&amp;#34;跨端框架&amp;lt;br/&amp;gt;Taro / uni-app&amp;lt;br/&amp;gt;⚠️ 见 3.4 节的权衡&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;vant-weapp&lt;/strong&gt;：有赞出品，事实上的生态老大。组件全（60+），电商场景（商品、优惠券、地址编辑）开箱即用，社区问题几乎都能搜到答案。&lt;strong&gt;不知道选什么就选它&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TDesign&lt;/strong&gt;：腾讯官方，设计体系完整，维护稳定。和 vant-weapp 二选一主要看设计稿更接近哪个风格。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NutUI&lt;/strong&gt;：京东出品，风格轻量，多端版本（Taro）做得好。&lt;/li&gt;
&lt;li&gt;基础组件之外值得一提的：&lt;code&gt;wxml-to-canvas&lt;/code&gt;（海报生成）、&lt;code&gt;echarts-for-weixin&lt;/code&gt;（图表）、&lt;code&gt;computed&lt;/code&gt; / &lt;code&gt;mobx-miniprogram&lt;/code&gt;（下一篇的主角）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;引入方式&lt;/strong&gt;（以 vant-weapp 为例）：小程序从 2019 年起支持 npm，但和 Web 的 npm 用法不一样，多一步「构建」：&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-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm i @vant/weapp -S&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;然后在开发者工具里点「&lt;strong&gt;工具 → 构建 npm&lt;/strong&gt;」，再在页面的 json 里声明：&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-json&#34; data-lang=&#34;json&#34;&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:#f92672&#34;&gt;&amp;#34;usingComponents&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:#f92672&#34;&gt;&amp;#34;van-button&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;@vant/weapp/button/index&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:#f92672&#34;&gt;&amp;#34;van-field&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;@vant/weapp/field/index&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;h3 id=&#34;32-踩坑故事五npm-装了组件死活找不到&#34;&gt;3.2 踩坑故事五：npm 装了，组件死活找不到&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;npm install&lt;/code&gt; 成功，&lt;code&gt;usingComponents&lt;/code&gt; 也写了，模拟器报 &lt;code&gt;Component is not found in path &amp;quot;@vant/weapp/button/index&amp;quot;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：小程序构建工具&lt;strong&gt;不直接读 &lt;code&gt;node_modules&lt;/code&gt;&lt;/strong&gt;，需要「工具 → 构建 npm」把它编译成 &lt;code&gt;miniprogram_npm&lt;/code&gt; 目录。装完包没构建、或者 &lt;code&gt;app.json&lt;/code&gt; 同级目录结构不对（&lt;code&gt;project.config.json&lt;/code&gt; 里 &lt;code&gt;miniprogramRoot&lt;/code&gt; 配错），都会撞上这个报错。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：构建 npm 后重启编译；团队项目把 &lt;code&gt;miniprogram_npm&lt;/code&gt; 加入 git 忽略，但要在 README 里写明「拉代码后先构建 npm」——新人入职第一天栽在这上面的比例高得离谱。&lt;/p&gt;
&lt;h3 id=&#34;33-demo一个五脏俱全的登录页&#34;&gt;3.3 Demo：一个五脏俱全的登录页&lt;/h3&gt;
&lt;p&gt;把数据绑定、事件、vant-weapp 组件串起来——每个小程序都有的页面：&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-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- pages/login/login.wxml --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;class&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;page&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;van-field&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:#a6e22e&#34;&gt;value&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{form.phone}}&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:#a6e22e&#34;&gt;label&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&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:#a6e22e&#34;&gt;placeholder&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&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:#a6e22e&#34;&gt;error-message&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{errors.phone}}&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:#a6e22e&#34;&gt;bind:change&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onPhoneChange&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  /&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;van-field&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:#a6e22e&#34;&gt;value&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{form.code}}&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:#a6e22e&#34;&gt;label&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&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:#a6e22e&#34;&gt;placeholder&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&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:#a6e22e&#34;&gt;bind:change&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onCodeChange&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;van-button&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;slot&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;button&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;size&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;small&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;type&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;primary&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:#a6e22e&#34;&gt;disabled&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{countdown &amp;gt; 0}}&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:#a6e22e&#34;&gt;bindtap&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onSendCode&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      {{countdown &amp;gt; 0 ? countdown + &amp;#39;s 后重发&amp;#39; : &amp;#39;发送验证码&amp;#39;}}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;van-button&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;van-field&lt;/span&gt;&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;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;van-button&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;type&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;primary&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;block&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;round&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:#a6e22e&#34;&gt;loading&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{loading}}&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:#a6e22e&#34;&gt;bindtap&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onLogin&amp;#34;&lt;/span&gt;&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;  &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;van-button&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;view&lt;/span&gt;&amp;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;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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// pages/login/login.js
&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:#a6e22e&#34;&gt;Page&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:#a6e22e&#34;&gt;data&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:#a6e22e&#34;&gt;form&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;phone&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;&amp;#39;&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;code&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;&amp;#39;&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:#a6e22e&#34;&gt;errors&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;phone&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;&amp;#39;&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:#a6e22e&#34;&gt;countdown&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&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:#a6e22e&#34;&gt;loading&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &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:#a6e22e&#34;&gt;onPhoneChange&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;e&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;// van-field 的值在 e.detail 里
&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&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;#39;form.phone&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;detail&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;#39;errors.phone&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;&amp;#39;&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:#a6e22e&#34;&gt;onCodeChange&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;e&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;form.code&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;detail&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:#a6e22e&#34;&gt;onSendCode&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:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;/^1\d{10}$/&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;test&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;form&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;phone&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;errors.phone&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;手机号格式不对&amp;#39;&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&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;countdown&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;60&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;timer&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;setInterval&lt;/span&gt;(() =&amp;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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;next&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;countdown&lt;/span&gt; &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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;countdown&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;next&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:#a6e22e&#34;&gt;next&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;=&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;) &lt;span style=&#34;color:#a6e22e&#34;&gt;clearInterval&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;timer&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:#ae81ff&#34;&gt;1000&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;// api.sendSmsCode(this.data.form.phone)
&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:#a6e22e&#34;&gt;onLogin&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;const&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;phone&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;code&lt;/span&gt; } &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;form&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:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;/^1\d{10}$/&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;test&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;phone&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;errors.phone&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;手机号格式不对&amp;#39;&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&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;loading&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;true&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:#a6e22e&#34;&gt;setTimeout&lt;/span&gt;(() =&amp;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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;loading&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;switchTab&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/pages/home/home&amp;#39;&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:#ae81ff&#34;&gt;800&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:#a6e22e&#34;&gt;onUnload&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:#a6e22e&#34;&gt;clearInterval&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;timer&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;设计要点&lt;/strong&gt;（这些比语法值钱）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;表单状态装进一个 &lt;code&gt;form&lt;/code&gt; 对象&lt;/strong&gt;，更新用 setData 的路径语法（&lt;code&gt;&#39;form.phone&#39;: value&lt;/code&gt;），只传变化的那一小条数据过线程桥——2.2 节的第一性原理在这里第一次兑现。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按钮的「正常 / 加载中 / 禁用」形态&lt;/strong&gt;由状态驱动，UI 本质是状态机，这在 Vue、Android、小程序里是同一句话。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定时器在 &lt;code&gt;onUnload&lt;/code&gt; 里清掉&lt;/strong&gt;——页面销毁了定时器还在跑，轻则无效 setData 报错刷屏，重则内存泄漏。这是第四篇生命周期的预演。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;34-原生还是跨端框架一个绕不开的决策&#34;&gt;3.4 原生还是跨端框架？一个绕不开的决策&lt;/h3&gt;
&lt;p&gt;只要做小程序，早晚有人问你：为什么不用 Taro / uni-app，一套代码出五端？我的建议是&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
    Q{&amp;#34;业务分布在哪？&amp;#34;}
    Q --&amp;gt;|&amp;#34;只发微信小程序&amp;lt;br/&amp;gt;且追求极致体验&amp;#34;| A[&amp;#34;原生开发&amp;lt;br/&amp;gt;能力最全 坑最少&amp;lt;br/&amp;gt;新 API 第一天就能用&amp;#34;]
    Q --&amp;gt;|&amp;#34;微信 + 支付宝/抖音多平台&amp;lt;br/&amp;gt;或同时要 H5&amp;#34;| B[&amp;#34;uni-app / Taro&amp;lt;br/&amp;gt;用平台差异最小的子集&amp;#34;]
    Q --&amp;gt;|&amp;#34;团队全是 React 技术栈&amp;#34;| C[&amp;#34;Taro&amp;#34;]
    Q --&amp;gt;|&amp;#34;团队全是 Vue 技术栈&amp;#34;| D[&amp;#34;uni-app&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事六：跨端框架里用了「全平台都支持」的组件，抖音端直接白屏&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：uni-app 项目用了一个官方组件库的级联选择器，微信端、H5 端正常，发布到抖音小程序直接白屏，排查三天发现是该组件内部用了抖音端不支持的一个 API。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;感悟&lt;/strong&gt;：跨端框架解决的是「语法转换」，解决不了「&lt;strong&gt;平台能力差异&lt;/strong&gt;」。微信小程序的能力更新最快（微信自家的新能力当然先给自己），跨端框架追平永远有时间差。选跨端，就要接受「用所有平台能力的最小公约数」这个约束。&lt;strong&gt;小团队单平台起步，先原生，等真的要多端了再迁移，往往比第一天就上跨端框架更划算。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第四篇-架构与生命周期从写页面到写应用&#34;&gt;第四篇 架构与生命周期：从「写页面」到「写应用」&lt;/h2&gt;
&lt;p&gt;前三篇你能写出一个页面了。但真实小程序是几十个页面、被用户以你想象不到的顺序跳转、随时被微信切到后台。这一篇建立组织代码的地基。&lt;/p&gt;
&lt;h3 id=&#34;41-生命周期apppagecomponent-三层&#34;&gt;4.1 生命周期：App、Page、Component 三层&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;stateDiagram-v2
    [*] --&amp;gt; App_onLaunch: 冷启动(整个小程序只此一次)
    App_onLaunch --&amp;gt; Page_onLoad: 打开第一个页面
    Page_onLoad --&amp;gt; Page_onShow: 页面显示
    Page_onShow --&amp;gt; Page_onReady: 首次渲染完成
    Page_onReady --&amp;gt; Page_onHide: 跳转下一页 / 切后台
    Page_onHide --&amp;gt; Page_onShow: 返回本页 / 回到前台
    Page_onShow --&amp;gt; Page_onUnload: 页面被销毁(redirect/navigateBack)
    Page_onUnload --&amp;gt; [*]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;真正值钱的是这三条规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;onLoad&lt;/code&gt; 一生只来一次，&lt;code&gt;onShow&lt;/code&gt; 每次显示都来&lt;/strong&gt;：&lt;code&gt;onLoad&lt;/code&gt; 只在页面创建时执行一次，拿着跳转参数；&lt;code&gt;onShow&lt;/code&gt; 每次显示都来。&lt;strong&gt;「返回时要不要刷新列表」的答案就是把刷新逻辑放 &lt;code&gt;onShow&lt;/code&gt;，而不是假设 &lt;code&gt;onLoad&lt;/code&gt; 会再来&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;onReady&lt;/code&gt; 之前别碰节点&lt;/strong&gt;：&lt;code&gt;wx.createSelectorQuery()&lt;/code&gt; 查 DOM 要等首次渲染完成，放 &lt;code&gt;onLoad&lt;/code&gt; 里查高度会拿到 0。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小程序没有可靠的「退出回调」&lt;/strong&gt;：用户按胶囊按钮关掉小程序，&lt;code&gt;onUnload&lt;/code&gt; 之后 App 可能还活着，也可能随时被系统回收。重要数据不能指望「退出时再保存」，和 Android 的 &lt;code&gt;onDestroy&lt;/code&gt; 不保证调用是同一个道理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事七：编辑页保存返回，列表还是旧数据&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：从列表页进编辑页，改完保存返回，列表纹丝不动。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：返回上一页走的是 &lt;code&gt;navigateBack&lt;/code&gt;，上一页的实例&lt;strong&gt;没有被销毁&lt;/strong&gt;，&lt;code&gt;onLoad&lt;/code&gt; 不会再执行。&lt;/p&gt;
&lt;p&gt;&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;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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 解法一：列表页把刷新放 onShow
&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:#a6e22e&#34;&gt;onShow&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;loadList&lt;/span&gt;();   &lt;span style=&#34;color:#75715e&#34;&gt;// 返回时自然刷新，缺点是首次会重复加载，加 flag 控制
&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;// 解法二：编辑页保存后主动通知上一页（事件总线 / getCurrentPages）
&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;pages&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;getCurrentPages&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;prevPage&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;pages&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;pages&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;2&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:#a6e22e&#34;&gt;prevPage&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;onNeedRefresh&lt;/span&gt;&lt;span style=&#34;color:#f92672&#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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;navigateBack&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;42-页面栈小程序导航的宪法&#34;&gt;4.2 页面栈：小程序导航的「宪法」&lt;/h3&gt;
&lt;p&gt;小程序的页面不是平铺的，是&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 LR
    subgraph stack[&amp;#34;页面栈（最多 10 层）&amp;#34;]
        A[&amp;#34;首页&amp;lt;br/&amp;gt;栈底&amp;#34;] --&amp;gt; B[&amp;#34;列表页&amp;#34;] --&amp;gt; C[&amp;#34;详情页&amp;#34;] --&amp;gt; D[&amp;#34;编辑页&amp;lt;br/&amp;gt;栈顶&amp;#34;]
    end
    E[&amp;#34;navigateTo&amp;lt;br/&amp;gt;压栈 保持当前页&amp;#34;] -.-&amp;gt;|&amp;#34;+1 层&amp;#34;| stack
    F[&amp;#34;navigateBack&amp;lt;br/&amp;gt;出栈返回&amp;#34;] -.-&amp;gt;|&amp;#34;-1 层&amp;#34;| stack
    G[&amp;#34;redirectTo&amp;lt;br/&amp;gt;替换栈顶&amp;#34;] -.-&amp;gt;|&amp;#34;层数不变&amp;#34;| stack
    H[&amp;#34;reLaunch&amp;lt;br/&amp;gt;清空重开&amp;#34;] -.-&amp;gt;|&amp;#34;栈重置&amp;#34;| stack
    I[&amp;#34;switchTab&amp;lt;br/&amp;gt;跳 tabBar 页&amp;#34;] -.-&amp;gt;|&amp;#34;非 tabBar 页全部出栈&amp;#34;| stack&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事八：跳转到第十一层，按钮点了没反应&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：一个「猜你喜欢 → 详情 → 相关推荐 → 详情 → …」的商品浏览链路，用户连点十次之后，再点卡片毫无反应，既不跳转也不报错（老基础库静默失败）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code&gt;navigateTo&lt;/code&gt; 的页面栈上限是 &lt;strong&gt;10 层&lt;/strong&gt;，到了第 11 次压栈直接失败。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：自循环的详情链路用 &lt;code&gt;redirectTo&lt;/code&gt;（替换栈顶而不是压栈），或者监测栈深度，满了就 &lt;code&gt;reLaunch&lt;/code&gt; 回首页。这也是「返回键要按十下才能退出」这种体验事故的根源——&lt;strong&gt;栈越深，用户的返回路径越长&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事九：navigateTo 跳 tabBar 页，永远不生效&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;wx.navigateTo({ url: &#39;/pages/home/home&#39; })&lt;/code&gt; 调用了，纹丝不动。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：tabBar 页面不在普通页面栈体系里，必须用 &lt;code&gt;wx.switchTab&lt;/code&gt;；反过来，非 tabBar 页不能用 &lt;code&gt;switchTab&lt;/code&gt;。两类页面的 URL 在 &lt;code&gt;app.json&lt;/code&gt; 里就决定了（&lt;code&gt;tabBar.list&lt;/code&gt; 里的那几个），API 选错是唯一可能的原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：这条错不报错、静默失败，是新人调试时间最长的坑之一。记住口诀：&lt;strong&gt;tabBar 用 switchTab，栈满用 redirectTo，重启用 reLaunch，其他 navigateTo&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;43-组件化与通信从页面到-component&#34;&gt;4.3 组件化与通信：从页面到 Component&lt;/h3&gt;
&lt;p&gt;页面大了要拆组件。自定义组件也是四文件结构，js 里用 &lt;code&gt;Component({...})&lt;/code&gt; 代替 &lt;code&gt;Page({...})&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// components/price-card/index.js
&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:#a6e22e&#34;&gt;Component&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:#a6e22e&#34;&gt;options&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:#a6e22e&#34;&gt;styleIsolation&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;isolated&amp;#39;&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 style=&#34;color:#a6e22e&#34;&gt;properties&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:#a6e22e&#34;&gt;price&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;type&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; Number, &lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&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 style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;&lt;span style=&#34;color:#f92672&#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:#a6e22e&#34;&gt;methods&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:#a6e22e&#34;&gt;onBuy&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;triggerEvent&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;buy&amp;#39;&lt;/span&gt;, { &lt;span style=&#34;color:#a6e22e&#34;&gt;price&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;price&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;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-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- 父页面使用 --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;price-card&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;price&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{item.price}}&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;bind:buy&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onBuy&amp;#34;&lt;/span&gt; /&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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 TD
    A[&amp;#34;父 → 子&amp;lt;br/&amp;gt;properties&amp;#34;] --- S[&amp;#34;组件通信&amp;#34;]
    B[&amp;#34;子 → 父&amp;lt;br/&amp;gt;triggerEvent 派事件&amp;#34;] --- S
    C[&amp;#34;任意组件 ↔ 全局&amp;lt;br/&amp;gt;mobx-miniprogram 绑定&amp;#34;] --- S
    D[&amp;#34;页面间&amp;lt;br/&amp;gt;EventChannel(navigateTo 时建立)&amp;lt;br/&amp;gt;或事件总线&amp;#34;] --- S&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事十：改组件库样式改不动，自己页面的样式却互相污染&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：给 &lt;code&gt;van-button&lt;/code&gt; 写了个覆盖样式死活不生效；反过来，A 页面给 &lt;code&gt;.title&lt;/code&gt; 写的红色，B 页面的 &lt;code&gt;.title&lt;/code&gt; 也变红了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：小程序的样式隔离规则和直觉相反：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;页面之间&lt;/strong&gt;：样式是全局的！A 页面 WXSS 里的类名会污染 B 页面（同名类）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;组件内部&lt;/strong&gt;：默认有隔离（&lt;code&gt;isolated&lt;/code&gt;），外面的样式进不去，里面的样式出不来——所以改不动组件库。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;页面样式一律加页面级命名空间：&lt;code&gt;.page-order .title { ... }&lt;/code&gt;，或者干脆多用组件（组件天然隔离）。&lt;/li&gt;
&lt;li&gt;组件需要接收外部样式时，在 &lt;code&gt;options&lt;/code&gt; 里调 &lt;code&gt;styleIsolation: &#39;apply-shared&#39;&lt;/code&gt;（外能进内不出）或 &lt;code&gt;shared&lt;/code&gt;（双向），改组件库主题时经常要用到。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;44-全局状态mobx-miniprogram-就够了&#34;&gt;4.4 全局状态：mobx-miniprogram 就够了&lt;/h3&gt;
&lt;p&gt;页面多了之后，「用户信息、购物车数量」这类全局状态在几十个页面间传来传去会迅速失控。小程序生态没有 Vuex/Pinia 那样的官方答案，事实标准是 &lt;strong&gt;mobx-miniprogram + mobx-miniprogram-bindings&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// stores/user.js
&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;import&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;observable&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;action&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;mobx-miniprogram&amp;#39;&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;export&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;userStore&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;observable&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:#a6e22e&#34;&gt;userInfo&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;null&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:#a6e22e&#34;&gt;get&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;isLoggedIn&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:#f92672&#34;&gt;!!&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;userInfo&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:#a6e22e&#34;&gt;setUserInfo&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;action&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;function&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;info&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;userInfo&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;info&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;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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 页面绑定：store 变了，页面 data 自动同步
&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;import&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;createStoreBindings&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;mobx-miniprogram-bindings&amp;#39;&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;import&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;userStore&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;../../stores/user&amp;#39;&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:#a6e22e&#34;&gt;Page&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:#a6e22e&#34;&gt;onLoad&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;storeBindings&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;createStoreBindings&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;this&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:#a6e22e&#34;&gt;store&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;userStore&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:#a6e22e&#34;&gt;fields&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;userInfo&amp;#39;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;isLoggedIn&amp;#39;&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:#a6e22e&#34;&gt;actions&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;setUserInfo&amp;#39;&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:#a6e22e&#34;&gt;onUnload&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;storeBindings&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;destroyStoreBindings&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;思考&lt;/strong&gt;：这套写法的心智模型和 Vue 的 Pinia + computed 完全一致——「store 是唯一数据源，页面只是绑定」。同时别忘了给 &lt;code&gt;Component&lt;/code&gt; 配上官方扩展库 &lt;code&gt;computed&lt;/code&gt; 和 &lt;code&gt;watch&lt;/code&gt;（&lt;code&gt;mobx-miniprogram&lt;/code&gt; 同一作者出品），小程序原生组件没有计算属性，手写派生状态是代码腐烂的起点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第五篇-网络与存储数据从哪来存到哪去&#34;&gt;第五篇 网络与存储：数据从哪来，存到哪去&lt;/h2&gt;
&lt;p&gt;架构定了，现在填数据的肉。这一篇学完，你就能写出「登录 + 联网 CRUD + 本地缓存」的完整小程序了。&lt;/p&gt;
&lt;h3 id=&#34;51-wxrequest先把回调包成-promise&#34;&gt;5.1 wx.request：先把回调包成 Promise&lt;/h3&gt;
&lt;p&gt;小程序的联网 API 是回调风格的 &lt;code&gt;wx.request&lt;/code&gt;，直接用会很快陷入回调地狱（2.5 节那种嵌套套嵌套）。&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;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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// utils/request.js
&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;BASE_URL&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;https://api.example.com&amp;#39;&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;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;request&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;url&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;method&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;GET&amp;#39;&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;needAuth&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;true&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;new&lt;/span&gt; Promise((&lt;span style=&#34;color:#a6e22e&#34;&gt;resolve&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;reject&lt;/span&gt;) =&amp;gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;request&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:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;BASE_URL&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;url&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:#a6e22e&#34;&gt;method&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:#a6e22e&#34;&gt;data&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:#a6e22e&#34;&gt;timeout&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;10000&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:#a6e22e&#34;&gt;header&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:#75715e&#34;&gt;// 统一带 token：小程序没有 Cookie 自动管理，登录态自己维护
&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:#a6e22e&#34;&gt;Authorization&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;needAuth&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;?&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;`Bearer &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;${&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;getStorageSync&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;token&amp;#39;&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; &lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;&amp;#39;&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:#a6e22e&#34;&gt;success&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;res&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:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;statusCode&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;=&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;200&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;statusCode&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;300&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:#a6e22e&#34;&gt;resolve&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&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;else&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;statusCode&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;===&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;401&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;// token 过期：清登录态，跳登录页
&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;removeStorageSync&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;token&amp;#39;&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;reLaunch&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/pages/login/login&amp;#39;&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:#a6e22e&#34;&gt;reject&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;登录已过期&amp;#39;&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;else&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:#a6e22e&#34;&gt;reject&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;?&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;message&lt;/span&gt; &lt;span style=&#34;color:#f92672&#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;&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;statusCode&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;));
&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:#a6e22e&#34;&gt;fail&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;err&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:#a6e22e&#34;&gt;reject&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;网络异常，请稍后重试&amp;#39;&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;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;// 使用：async/await，和前端写 axios 一个手感
&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;async&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onLoad&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; {
&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;request&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/todos&amp;#39;&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;todos&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;catch&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;e&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;showToast&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;title&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;message&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;icon&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;none&amp;#39;&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;：拦截逻辑（带 token、401 处理、错误归一化）写在这一层，业务代码就只剩一行 &lt;code&gt;await&lt;/code&gt;——这和 Android 的 OkHttp 拦截器、前端的 axios 拦截器是同一种分层思想。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十一：401 了，但页面还停在原地&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：token 过期后接口返回 401，封装的 request 里做了 &lt;code&gt;reLaunch&lt;/code&gt; 跳登录页，但用户反馈「偶尔跳不过去」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code&gt;wx.reLaunch&lt;/code&gt; 跳转登录页时，如果当前页面栈里有 tabBar 页，&lt;code&gt;reLaunch&lt;/code&gt; 的目标 URL 带了 query 参数在部分基础库上被丢弃；更隐蔽的是——&lt;strong&gt;401 时多个并发请求会触发多次 reLaunch&lt;/strong&gt;，第二次 reLaunch 打断了第一次。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：401 处理加防抖锁：&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;let&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;isRedirectingToLogin&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &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;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;handleUnauthorized&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:#a6e22e&#34;&gt;isRedirectingToLogin&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 style=&#34;color:#a6e22e&#34;&gt;isRedirectingToLogin&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;true&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;removeStorageSync&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;token&amp;#39;&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:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;reLaunch&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:#a6e22e&#34;&gt;url&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/pages/login/login&amp;#39;&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:#a6e22e&#34;&gt;complete&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; () =&amp;gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;isRedirectingToLogin&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &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;（小程序同理）：微信用户会进电梯、切 Wi-Fi 和流量，弱网和断线是&lt;strong&gt;常态而不是异常&lt;/strong&gt;。&lt;code&gt;timeout&lt;/code&gt; 必须配、失败要给重试入口、&lt;code&gt;wx.showToast&lt;/code&gt; 报错别弹全屏错误页——静默重试一次，体验天差地别。&lt;/p&gt;
&lt;h3 id=&#34;52-本地存储wxsetstorage-的分寸&#34;&gt;5.2 本地存储：wx.setStorage 的分寸&lt;/h3&gt;
&lt;p&gt;小程序的存储比 Android 简单得多，没有 Room 没有 DataStore，就一个 &lt;code&gt;wx.setStorage / wx.getStorage&lt;/code&gt;（异步）和 &lt;code&gt;wx.setStorageSync / wx.getStorageSync&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;flowchart TD
    Q{&amp;#34;要存什么？&amp;#34;}
    Q --&amp;gt;|&amp;#34;token / 用户设置&amp;lt;br/&amp;gt;小键值对&amp;#34;| A[&amp;#34;wx.setStorageSync&amp;lt;br/&amp;gt;同步 简单&amp;#34;]
    Q --&amp;gt;|&amp;#34;列表缓存 / 草稿&amp;lt;br/&amp;gt;几 KB 以上的数据&amp;#34;| B[&amp;#34;wx.setStorage (异步版)&amp;lt;br/&amp;gt;⚠️ 同步版大数据会卡 UI&amp;#34;]
    Q --&amp;gt;|&amp;#34;要跟随用户换设备&amp;lt;br/&amp;gt;或免后端&amp;#34;| C[&amp;#34;微信云开发&amp;lt;br/&amp;gt;云数据库 + 云函数&amp;#34;]
    Q --&amp;gt;|&amp;#34;敏感数据(密码之类)&amp;#34;| D[&amp;#34;❌ 别存本地&amp;lt;br/&amp;gt;存 token 不存密码&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;两个数字要记住：单个 key 上限 1MB，总上限 10MB。超出直接写入失败。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十二：storage 同步读写把首页卡死了&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：首页加载越来越慢，Profiling 发现 &lt;code&gt;getStorageSync&lt;/code&gt; 一个调用占了 200ms。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：运营活动的弹窗配置、AB 实验分组、用户信息……十几个模块都往 storage 里塞，有个模块把整份商品列表（几百 KB）也存了进去，首页启动时同步读，主线程原地罚站。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大对象读写一律用异步版（&lt;code&gt;wx.getStorage&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;启动时只读「渲染首屏必需」的字段，其余懒加载。&lt;/li&gt;
&lt;li&gt;token 这种小且高频的用同步版没问题——&lt;strong&gt;按数据大小和频率选 API，不是按顺手程度&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;53-云开发没有后端时的另一条路&#34;&gt;5.3 云开发：没有后端时的另一条路&lt;/h3&gt;
&lt;p&gt;如果你是个人开发者、没有自己的服务器，&lt;strong&gt;微信云开发&lt;/strong&gt;（CloudBase）值得单独了解：云数据库（类 MongoDB）、云函数（Node.js）、云存储、云调用（直接调微信 API 免鉴权），在开发者工具里一站式完成，不用买服务器、不用配域名、不用过 ICP 备案——对，&lt;strong&gt;云开发的请求不受 1.3 节域名白名单限制&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-js&#34; data-lang=&#34;js&#34;&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:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;db&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;wx&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;cloud&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;database&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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;db&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;collection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;todos&amp;#39;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;where&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;done&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;false&lt;/span&gt; }).&lt;span style=&#34;color:#a6e22e&#34;&gt;get&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;工具类、内容类、Demo 项目用云开发；交易核心、复杂业务自建后端。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第六篇-平台能力登录订阅消息分享支付与分包&#34;&gt;第六篇 平台能力：登录、订阅消息、分享、支付与分包&lt;/h2&gt;
&lt;p&gt;到这里，你的小程序已经「自成一体」了。这一篇让它和微信这个平台打交道。平台能力的坑有一个共同特征：&lt;strong&gt;文档看着简单，但 API 的生命周期变迁极快，照着两年前的教程写必踩坑&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;61-登录code-换-openid-的三段握手&#34;&gt;6.1 登录：code 换 openid 的三段握手&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;sequenceDiagram
    participant MP as 小程序前端
    participant WX as 微信服务器
    participant S as 你的后端
    MP-&amp;gt;&amp;gt;WX: wx.login() 拿 code(5分钟有效)
    MP-&amp;gt;&amp;gt;S: 把 code 发给自己的后端
    S-&amp;gt;&amp;gt;WX: code + appid + secret 换&amp;lt;br/&amp;gt;openid + session_key
    WX--&amp;gt;&amp;gt;S: openid(用户唯一标识) + session_key
    S--&amp;gt;&amp;gt;MP: 签发自家 token(如 JWT)
    Note over MP: 之后所有请求带 token&amp;lt;br/&amp;gt;session_key 永远不出后端&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;三条铁律&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;session_key&lt;/code&gt; 绝不能下发到前端&lt;/strong&gt;——它能解密用户敏感数据，泄漏等于把用户数据大门敞开。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;code 只能用一次且 5 分钟过期&lt;/strong&gt;，换取动作必须在后端做（secret 也不能进前端代码）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前端维护的是自家 token&lt;/strong&gt;，不是微信的 code。5.1 节 request 封装里带的 &lt;code&gt;Authorization&lt;/code&gt; 就是它。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十三：跟着老教程写 getUserProfile，提交审核被拒&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：照着 2021 年的教程用 &lt;code&gt;wx.getUserProfile&lt;/code&gt; 拿用户头像昵称，真机一调返回 &lt;code&gt;getUserProfile:fail can only be invoked by user TAP gesture&lt;/code&gt; 或直接被拒审。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：微信在 2022 年&lt;strong&gt;回收了 &lt;code&gt;getUserProfile&lt;/code&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;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-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- 头像：button open-type=&amp;#34;chooseAvatar&amp;#34; --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;button&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;open-type&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;chooseAvatar&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;bind:chooseavatar&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onChooseAvatar&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;image&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;src&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{avatarUrl || &amp;#39;/images/default-avatar.png&amp;#39;}}&amp;#34;&lt;/span&gt; /&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;button&lt;/span&gt;&amp;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;&amp;lt;!-- 昵称：input type=&amp;#34;nickname&amp;#34;，拉起微信输入法半屏 --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;input&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;type&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;nickname&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;placeholder&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;请输入昵称&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;bind:blur&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;onNicknameBlur&amp;#34;&lt;/span&gt; /&amp;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;：这是小程序开发最重要的生存技能——&lt;strong&gt;微信 API 的废弃和变更极快，任何「照着教程写」的代码都要去官方文档核对一遍最新版本&lt;/strong&gt;。关注「微信小程序开发者文档」的更新日志，比关注任何公众号都有用。&lt;/p&gt;
&lt;h3 id=&#34;62-订阅消息推送的正确打开方式&#34;&gt;6.2 订阅消息：推送的正确打开方式&lt;/h3&gt;
&lt;p&gt;小程序没有 Android 那种「随便推」的通知系统，触达用户的正道是&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 LR
    A[&amp;#34;用户在小程序里&amp;lt;br/&amp;gt;主动点击按钮&amp;#34;] --&amp;gt; B[&amp;#34;wx.requestSubscribeMessage&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;后端在服务通知里&amp;lt;br/&amp;gt;下发一次消息&amp;#34;]
    C --&amp;gt;|&amp;#34;拒绝&amp;#34;| E[&amp;#34;⚠️ 同一个模板&amp;lt;br/&amp;gt;短期内不能再弹&amp;#34;]
    C --&amp;gt;|&amp;#34;勾选『总是保持以上选择』&amp;#34;| F[&amp;#34;以后静默按上次选择执行&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;关键认知&lt;/strong&gt;：订阅消息默认是&lt;strong&gt;一次性的&lt;/strong&gt;——用户点一次允许，你只能推一次。长期订阅仅对政务、医疗等特定类目开放。所以正确的产品设计是「&lt;strong&gt;在最关键的动作上请求订阅&lt;/strong&gt;」（下单、预约、打卡），而不是见缝插针地弹。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十四：订阅消息发出去了，用户没收到&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;按出现概率排查：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模板 ID 不对&lt;/strong&gt;：模板在 mp 后台「订阅消息」里申请，&lt;code&gt;tmplIds&lt;/code&gt; 写错或用了测试模板，发送直接报错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;关键词格式不匹配&lt;/strong&gt;：模板里的 &lt;code&gt;{{thing1.DATA}}&lt;/code&gt;（字符串）、&lt;code&gt;{{amount2.DATA}}&lt;/code&gt;（金额）每种类型有严格格式限制，金额必须是数字、字符数超限都会发送失败——&lt;strong&gt;失败原因要去查发送接口的返回码，客户端无感知&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用户没订阅 / 订阅次数用完了&lt;/strong&gt;：一次订阅只够推一次，推完额度清零。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跳转页面未发布&lt;/strong&gt;：消息配置的 &lt;code&gt;page&lt;/code&gt; 路径必须是&lt;strong&gt;已发布版本里存在的页面&lt;/strong&gt;，配了还在开发中的页面，用户点消息会跳到错误页。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;63-分享与裂变小程序的增长引擎&#34;&gt;6.3 分享与裂变：小程序的增长引擎&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;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-js&#34; data-lang=&#34;js&#34;&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:#a6e22e&#34;&gt;Page&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:#a6e22e&#34;&gt;onShareAppMessage&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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#a6e22e&#34;&gt;title&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;这个记账小程序太好用了&amp;#39;&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:#a6e22e&#34;&gt;path&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/pages/home/home?from=share&amp;amp;uid=&amp;#39;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;uid&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:#a6e22e&#34;&gt;imageUrl&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;/images/share-cover.png&amp;#39;&lt;/span&gt;,  &lt;span style=&#34;color:#75715e&#34;&gt;// 5:4 比例，自定义封面
&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;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;分享无法拿到「是否分享成功」的回调了&lt;/strong&gt;（早年有，已回收）——「分享成功就送优惠券」这种强制裂变做不了，这也是微信的刻意设计。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;朋友圈&lt;/strong&gt;：小程序不能直接分享到朋友圈（除了特定的「分享到朋友圈」Beta 能力且仅 Android），正道是生成海报图让用户自己发——3.1 节的 &lt;code&gt;wxml-to-canvas&lt;/code&gt; 就是干这个的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;裂变归因全靠 path 参数&lt;/strong&gt;：谁分享的、从哪进来的，都编码在 &lt;code&gt;onShareAppMessage&lt;/code&gt; 的 path 里，落地页在 &lt;code&gt;onLoad(options)&lt;/code&gt; 里解析。这套机制就是小程序增长黑客的全部基础。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;64-支付五步流程前后端各管一半&#34;&gt;6.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;sequenceDiagram
    participant MP as 小程序
    participant S as 你的后端
    participant WX as 微信支付
    MP-&amp;gt;&amp;gt;S: 1. 创建订单(商品 金额)
    S-&amp;gt;&amp;gt;WX: 2. 统一下单(用商户号密钥签名)
    WX--&amp;gt;&amp;gt;S: prepay_id
    S--&amp;gt;&amp;gt;MP: 3. 返回支付参数(二次签名)
    MP-&amp;gt;&amp;gt;WX: 4. wx.requestPayment 拉起收银台
    WX--&amp;gt;&amp;gt;MP: 用户输密码完成
    WX--&amp;gt;&amp;gt;S: 5. ⚠️ 支付结果异步回调(这才是到账依据)&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事十五：以前端回调为准发货，被刷单薅了羊毛&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;wx.requestPayment&lt;/code&gt; 的 &lt;code&gt;success&lt;/code&gt; 回调里直接通知后端「支付成功，发货」，上线一个月被恶意用户用篡改的客户端刷出大量未付款订单。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：前端的一切回调都不可信。唯一可信的到账凭证是&lt;strong&gt;微信支付服务器推送给你后端的异步回调（pay 通知）&lt;/strong&gt;，验签通过才算数。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：这条规则在 Android 内购、Stripe 支付里一模一样——&lt;strong&gt;客户端回调只负责 UI 表现，服务端回调才负责业务状态&lt;/strong&gt;。凡是涉及钱的状态变更，永远以服务端到服务端的通知为准。&lt;/p&gt;
&lt;p&gt;另外记住 1.2 节的连锁反应：虚拟商品（课程、会员）在 &lt;strong&gt;iOS 端不能走微信支付&lt;/strong&gt;（苹果规则），要么引导安卓/H5 支付，要么走小程序虚拟支付通道并接受抽成——类目和主体在注册那天就决定了你有没有资格碰这块。&lt;/p&gt;
&lt;h3 id=&#34;65-分包加载2mb-限制的解法&#34;&gt;6.5 分包加载：2MB 限制的解法&lt;/h3&gt;
&lt;p&gt;小程序主包上限 &lt;strong&gt;2MB&lt;/strong&gt;（总包 20MB），所有页面塞一个包很快就会撞线。答案是分包：&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-json&#34; data-lang=&#34;json&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// app.json
&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:#f92672&#34;&gt;&amp;#34;pages&amp;#34;&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/home/home&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/login/login&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:#f92672&#34;&gt;&amp;#34;subPackages&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:#f92672&#34;&gt;&amp;#34;root&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;packageOrder&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:#f92672&#34;&gt;&amp;#34;pages&amp;#34;&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/list/list&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/detail/detail&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:#f92672&#34;&gt;&amp;#34;root&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;packageUser&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:#f92672&#34;&gt;&amp;#34;pages&amp;#34;&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/profile/profile&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pages/setting/setting&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:#f92672&#34;&gt;&amp;#34;preloadRule&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:#f92672&#34;&gt;&amp;#34;pages/home/home&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:#f92672&#34;&gt;&amp;#34;network&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;wifi&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:#f92672&#34;&gt;&amp;#34;packages&amp;#34;&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;packageUser&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&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;主包 ≤ 2MB&amp;lt;br/&amp;gt;首页 + tabBar 页 + 公共代码&amp;#34;] --&amp;gt;|&amp;#34;用户点进订单模块&amp;#34;| B[&amp;#34;订单分包&amp;lt;br/&amp;gt;按需下载&amp;#34;]
    A --&amp;gt;|&amp;#34;Wi-Fi 下预加载&amp;#34;| C[&amp;#34;个人中心分包&amp;#34;]
    style A fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;设计要点&lt;/strong&gt;：首页和 tabBar 页必须在主包（启动就加载）；低频功能（设置、关于、客服）果断拆分包；&lt;code&gt;preloadRule&lt;/code&gt; 让「首页加载完、Wi-Fi 下静默下载个人中心包」，用户点过去秒开。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第七篇-质量与发布从能跑到敢上线&#34;&gt;第七篇 质量与发布：从「能跑」到「敢上线」&lt;/h2&gt;
&lt;p&gt;功能做完只是上半场。小程序的发布比 Android 多了一道「平台审核」，这是它最独特的一关。&lt;/p&gt;
&lt;h3 id=&#34;71-性能三板斧setdata分包图片&#34;&gt;7.1 性能三板斧：setData、分包、图片&lt;/h3&gt;
&lt;p&gt;小程序性能问题的 90% 集中在这三件事上：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一板斧：setData 瘦身&lt;/strong&gt;（2.2 节第一性原理的兑现）&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;setData 传的什么？&amp;#34;}
    B --&amp;gt;|&amp;#34;整个大列表&amp;#34;| C[&amp;#34;改成路径更新&amp;lt;br/&amp;gt;&amp;#39;list[3].done&amp;#39;: true&amp;#34;]
    B --&amp;gt;|&amp;#34;高频调用&amp;lt;br/&amp;gt;onPageScroll / 倒计时&amp;#34;| D[&amp;#34;合并 + 节流&amp;lt;br/&amp;gt;攒一批一次发&amp;#34;]
    B --&amp;gt;|&amp;#34;传了视图用不到的字段&amp;#34;| E[&amp;#34;data 里只放渲染需要的数据&amp;lt;br/&amp;gt;其余存 this 普通属性&amp;#34;]&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// ❌ 每秒 setData 整个对象
&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;form&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;form&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:#66d9ef&#34;&gt;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;({ &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;form.phone&amp;#39;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;value&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;// ✅ 不参与渲染的数据根本别进 data
&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:#a6e22e&#34;&gt;Page&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:#a6e22e&#34;&gt;data&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;list&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:#a6e22e&#34;&gt;onLoad&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;_rawResponse&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;null&lt;/span&gt;;  &lt;span style=&#34;color:#75715e&#34;&gt;// 内部缓存，挂 this 上不挂 data
&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;——6.5 节已经讲过，启动速度的第一优化项。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三板斧：图片&lt;/strong&gt;——列表图一律压缩到展示尺寸的 2 倍以内、用 &lt;code&gt;image&lt;/code&gt; 组件的 &lt;code&gt;lazy-load&lt;/code&gt;、CDN 域名配进 downloadFile 白名单。首屏图太多时，骨架屏比 loading 转圈的感知快一倍。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十六：倒计时把列表页卡成 PPT&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：团购列表每个卡片都有「距结束 xx:xx」倒计时，页面滑动掉帧严重。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：20 个卡片各自开了 &lt;code&gt;setInterval&lt;/code&gt;，每秒 20 次 &lt;code&gt;setData&lt;/code&gt; 跨线程传输——桥堵了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;strong&gt;一个全局定时器&lt;/strong&gt;，每秒算一次所有卡片的剩余时间，合并成一次 &lt;code&gt;setData&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-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 一个 timer 驱动全部卡片
&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;timer&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;setInterval&lt;/span&gt;(() =&amp;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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;updates&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;list&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;forEach&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;) =&amp;gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;updates&lt;/span&gt;[&lt;span style=&#34;color:#e6db74&#34;&gt;`list[&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;${&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;}&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;].remainText`&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;formatRemain&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;endTime&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;this&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;setData&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;updates&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:#ae81ff&#34;&gt;1000&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;72-调试三板斧vconsole真机调试与体验版&#34;&gt;7.2 调试三板斧：vConsole、真机调试与体验版&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;开发者工具&lt;/strong&gt;：模拟器 + Wxml 面板（看最终渲染树）+ AppData 面板（实时看每个页面的 data，排查 setData 问题的第一现场）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;真机调试&lt;/strong&gt;：开发版小程序右上角胶囊菜单里打开「开发调试」，vConsole 会浮在页面上——手机上看到 console.log、网络请求、storage 内容。&lt;strong&gt;真机和模拟器的差异比你想的大&lt;/strong&gt;：字体渲染、安全区、性能、个别 API 行为都不一样，发布前必须真机过一遍主流程。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;体验版&lt;/strong&gt;：上传代码后在 mp 后台设为体验版，添加体验成员（微信号），扫码即用。它是「发给产品和测试同学」的标准姿势，比 Android 装 APK 优雅得多。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十七：体验版打不开，群里一片「白屏了」&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&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;/li&gt;
&lt;li&gt;你上传后&lt;strong&gt;设为体验版&lt;/strong&gt;了吗？（上传 ≠ 自动成为体验版，要在版本管理里手动选）&lt;/li&gt;
&lt;li&gt;对方是不是打开了「调试模式」导致走了别的版本？（体验版打开调试后会加载开发版代码，版本就乱了）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：小程序有「开发版 / 体验版 / 线上版」三个版本共存，版本切换的开关藏在各处。发体验版给别人之前，用一个不在体验成员名单里的微信号扫一次——&lt;strong&gt;模拟用户的真实路径&lt;/strong&gt;，这一分钟能省一小时的群聊排查。&lt;/p&gt;
&lt;h3 id=&#34;73-审核与发布小程序独有的最后一关&#34;&gt;7.3 审核与发布：小程序独有的最后一关&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;开发者工具&amp;lt;br/&amp;gt;上传代码&amp;#34;] --&amp;gt; B[&amp;#34;mp 后台&amp;lt;br/&amp;gt;提交审核&amp;#34;]
    B --&amp;gt; C{&amp;#34;审核结果&amp;lt;br/&amp;gt;几小时到几天&amp;#34;}
    C --&amp;gt;|&amp;#34;通过&amp;#34;| D[&amp;#34;手动点『发布』&amp;lt;br/&amp;gt;全量上线&amp;#34;]
    C --&amp;gt;|&amp;#34;拒绝&amp;#34;| E[&amp;#34;按驳回原因改&amp;lt;br/&amp;gt;重新排队&amp;#34;]
    D --&amp;gt; F[&amp;#34;灰度发布&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;类目和内容对齐&lt;/strong&gt;（1.2 节）：电商功能挂企业类目，个人号别碰交易。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给用户「不登录也能逛」的路径&lt;/strong&gt;：审核员会拒绝「打开就强制授权手机号」的小程序。登录应该发生在「需要身份的那个动作」上（下单、收藏），而不是启动时。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测试账号&lt;/strong&gt;：审核表单里提供测试账号密码或免密通道，审核员进不去你的核心流程必拒。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;敏感功能自审&lt;/strong&gt;：UGC 内容要有举报/审核机制说明；涉及医疗、金融的回答要有资质上传。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;感悟&lt;/strong&gt;：审核这道关常被骂，但它塑造了小程序生态和 App 生态的根本不同——&lt;strong&gt;小程序是「寄居」在微信里的，平台既是分发渠道也是监管者&lt;/strong&gt;。接受这个设定，把审核标准当作产品设计的一部分（登录时机、隐私协议、类目选择），而不是发布前的拦路虎，你的过审率会高得多。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第八篇-ai-时代怎么少写一半代码把小程序做出来&#34;&gt;第八篇 AI 时代：怎么少写一半代码把小程序做出来&lt;/h2&gt;
&lt;p&gt;前七篇是「内功」，这一篇是「兵器」。我的结论和 Android 篇一致：&lt;strong&gt;AI 没有让小程序开发变简单，它让「懂行的人」快了 3-5 倍，让「不懂行的人」更快地写出能跑但烂尾的代码&lt;/strong&gt;。区别在于你怎么用它。&lt;/p&gt;
&lt;h3 id=&#34;81-ai-在小程序开发各环节的胜任度&#34;&gt;8.1 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 good[&amp;#34;AI 做得极好 放心交出去&amp;#34;]
        A1[&amp;#34;单页面骨架&amp;lt;br/&amp;gt;wxml + wxss + Page({...})&amp;#34;]
        A2[&amp;#34;表单页 / 列表页&amp;lt;br/&amp;gt;配 vant-weapp 组件&amp;#34;]
        A3[&amp;#34;request 封装 / 工具函数&amp;#34;]
        A4[&amp;#34;单元测试与类型定义&amp;#34;]
    end
    subgraph mid[&amp;#34;AI 能做 但你要盯着&amp;#34;]
        B1[&amp;#34;跨页面状态设计&amp;#34;]
        B2[&amp;#34;性能优化(setData/分包)&amp;#34;]
        B3[&amp;#34;组件库主题定制&amp;#34;]
    end
    subgraph bad[&amp;#34;AI 做不好 还得靠人&amp;#34;]
        C1[&amp;#34;类目/资质/审核问题&amp;lt;br/&amp;gt;(政策天天变)&amp;#34;]
        C2[&amp;#34;真机才出现的 bug&amp;lt;br/&amp;gt;安全区 键盘 滚动&amp;#34;]
        C3[&amp;#34;微信 API 的最新变更&amp;lt;br/&amp;gt;(训练数据滞后)&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;第三格要特别说：微信 API 的变更速度（6.1 节 getUserProfile 的故事）恰好打在 AI 的软肋上——训练数据里全是旧 API 的示例。AI 给你写 &lt;code&gt;wx.getUserInfo&lt;/code&gt; 时你要能一眼认出「这是 2021 年的写法」。&lt;/p&gt;
&lt;h3 id=&#34;82-实战工作流需求--小程序的-ai-协作五步法&#34;&gt;8.2 实战工作流：需求 → 小程序的 AI 协作五步法&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;第一步：先写「项目宪法」，再让 AI 写代码。&lt;/strong&gt; 在项目里放一个 &lt;code&gt;AGENTS.md&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-markdown&#34; data-lang=&#34;markdown&#34;&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;-&lt;/span&gt; 原生小程序（不用跨端框架），组件库 @vant/weapp，less 写样式
&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;-&lt;/span&gt; 页面四文件结构：wxml/wxss/js/json，页面样式加命名空间防污染
&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;-&lt;/span&gt; 全局状态用 mobx-miniprogram + mobx-miniprogram-bindings，组件配 computed 扩展
&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;-&lt;/span&gt; 网络统一走 utils/request.js 封装，async/await，业务代码禁止裸 wx.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;-&lt;/span&gt; setData 只传变化字段的路径写法；不参与渲染的数据挂 this 普通属性
&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;-&lt;/span&gt; 跳转口诀：tabBar 用 switchTab，自循环链路用 redirectTo，其他 navigateTo
&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;-&lt;/span&gt; 回调一律箭头函数；定时器/绑定在 onUnload 里清理
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- 登录态：wx.login 拿 code 给后端换 token，前端只存 token，禁存 session_key&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;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：页面级生成，而不是小程序级生成。&lt;/strong&gt; 不要对 AI 说「给我写一个记账小程序」，要说「按项目约定写一个记账首页：顶部本月支出汇总卡片，下面是账单列表，用 van-cell，下拉刷新，每条显示分类图标、备注、金额」。一次一个页面，AI 的输出质量和你的可控性都最高。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：让它先写数据结构和状态，再写 UI。&lt;/strong&gt; 先生成页面的 &lt;code&gt;data&lt;/code&gt; 结构、接口契约、状态流转，review 通过后再写 wxml——这正是第四、五篇的架构顺序。状态设计错了，UI 写得再漂亮也是白写。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四步：报错直接喂回去，但分清两类问题。&lt;/strong&gt; 模拟器里的 JS 报错、组件找不到这类&lt;strong&gt;代码级问题&lt;/strong&gt;，贴给 AI 循环两三次基本能解；但 1.2 节那种&lt;strong&gt;账号类目问题&lt;/strong&gt;、6.1 节那种 &lt;strong&gt;API 已废弃问题&lt;/strong&gt;、真机上才出现的白屏——AI 帮不了，它没有你的 mp 后台权限，也没有真机。这就是为什么 AI 篇必须排在最后：&lt;strong&gt;它是放大器，放大的是你前七篇的水平&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第五步：人工 review 的五个必查点。&lt;/strong&gt; AI 生成的小程序代码，我每次都只细查五处：① &lt;code&gt;setData&lt;/code&gt; 是不是整个大对象往里塞（性能重灾区）；② 定时器和 store 绑定有没有在 &lt;code&gt;onUnload&lt;/code&gt; 里清理（泄漏来源）；③ 跳转 API 用对没有（tabBar 页 navigateTo 的静默失败 AI 经常犯）；④ 请求有没有走统一封装、token 处理对不对；⑤ 用的微信 API 是不是已经废弃的版本。其他代码粗看即可——&lt;strong&gt;review 的精力要花在 AI 的已知短板上&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;83-一个真实对比demo-小程序的工时变化&#34;&gt;8.3 一个真实对比：Demo 小程序的工时变化&lt;/h3&gt;
&lt;p&gt;第三篇那个登录页 + 一个待办列表页的小程序 Demo，三年前我要写一整天（查 API、调样式、处理状态）。2026 年用 AI 的实际耗时：&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;pie title 一个双页面 Demo 的 2 小时去哪了
    &amp;#34;写 AGENTS.md 和提示词&amp;#34; : 20
    &amp;#34;AI 生成 + 我 review 修改&amp;#34; : 55
    &amp;#34;AppID/类目/域名配置(AI帮不上)&amp;#34; : 25
    &amp;#34;真机测试与细节打磨&amp;#34; : 20&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;注意那张饼图里最大的启示：&lt;strong&gt;省下来的时间没有消失，它转移到了「描述需求」和「审查代码」上&lt;/strong&gt;，还有一部分转移到了 AI 永远碰不到的「平台事务」上——注册、类目、域名、审核。AI 时代的核心能力从「打字速度」变成了三样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把模糊需求拆成精确技术任务的能力&lt;/strong&gt;——你对第四篇架构的理解越深，给 AI 的指令就越准。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;快速判断代码对错的能力&lt;/strong&gt;——看不懂 setData 成本的人，review 不了 AI 写的小程序代码。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;搞定平台事务的能力&lt;/strong&gt;——类目、资质、审核、API 变更，这些「和微信打交道」的事，一件都外包不出去。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;84-思考ai-是放大器不是替代品&#34;&gt;8.4 思考：AI 是放大器，不是替代品&lt;/h3&gt;
&lt;p&gt;小程序生态和 AI 有一个有趣的共同点：它们都在「消灭记忆型知识的价值」。小程序当年消灭了「会写 Objective-C/Java 才能做移动端」的门槛；AI 今天消灭了「背得出 API 参数才配写代码」的门槛。留下来的价值是同一种东西——&lt;strong&gt;判断型知识&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;这个状态该放 &lt;code&gt;data&lt;/code&gt; 还是挂 &lt;code&gt;this&lt;/code&gt; 上？&lt;/li&gt;
&lt;li&gt;这个跳转该用 &lt;code&gt;redirectTo&lt;/code&gt; 还是 &lt;code&gt;navigateTo&lt;/code&gt;？&lt;/li&gt;
&lt;li&gt;这个需求个人主体能不能做，会不会死在审核？&lt;/li&gt;
&lt;li&gt;AI 写的这段代码用的 API，是不是去年就被回收了？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这篇文章前七篇的内容，在 AI 时代不但没有过时，反而更重要了——它们是你在 AI 写完代码后，能说「对」或者「不对」的全部依据。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;结语学习路线收个尾&#34;&gt;结语：学习路线收个尾&lt;/h2&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;第 1 周&amp;lt;br/&amp;gt;注册 AppID + 跑通模板&amp;lt;br/&amp;gt;+ 四件套语法&amp;#34;] --&amp;gt; B[&amp;#34;第 2-3 周&amp;lt;br/&amp;gt;vant-weapp 组件库&amp;lt;br/&amp;gt;写 5 个静态页面&amp;#34;]
    B --&amp;gt; C[&amp;#34;第 1-2 个月&amp;lt;br/&amp;gt;生命周期 + request 封装&amp;lt;br/&amp;gt;+ 登录流程&amp;lt;br/&amp;gt;写一个联网 CRUD 小程序&amp;#34;]
    C --&amp;gt; D[&amp;#34;第 2-4 个月&amp;lt;br/&amp;gt;订阅消息/分享/支付/分包&amp;lt;br/&amp;gt;体验版 审核 发布上线&amp;#34;]
    D --&amp;gt; E[&amp;#34;持续&amp;lt;br/&amp;gt;把 AI 变成&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;第一周死磕账号不丢人&lt;/strong&gt;。类目、主体、域名、备案，这些「和微信打交道」的事才是真正的门槛，写代码反而是最简单的部分。这不是你笨，是这个生态的真实成本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第一天就用真机预览&lt;/strong&gt;。模拟器掩盖了安全区、键盘、性能和版本差异问题——你的用户分布在几千种机型的微信里，而小程序的真机预览只要扫个码，这是整个客户端开发里成本最低的真机测试，没有借口不用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让 AI 写代码，让自己写笔记&lt;/strong&gt;。每踩一个坑记下「现象、根因、下次怎么一眼认出」，这些笔记是 AI 时代你不可被替代的资产——因为 AI 知道所有答案，但它不知道&lt;strong&gt;微信上个月又改了哪个 API，以及你的审核上次为什么被拒&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;祝你的 setData 永远轻盈，审核永远一次过，小程序在 iPhone SE 和折叠屏上都一样流畅。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>Android 开发从入门到精通：环境搭建、Kotlin、Compose、架构、系统能力与发布全指南</title>
      <link>https://ftmao.com/blog/posts/android-beginner-to-mastery/</link>
      <pubDate>Thu, 10 Aug 2023 09:10:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/android-beginner-to-mastery/</guid>
      <description>&lt;h1 id=&#34;android-开发从入门到精通环境搭建kotlincompose架构系统能力与发布全指南&#34;&gt;Android 开发从入门到精通：环境搭建、Kotlin、Compose、架构、系统能力与发布全指南&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：想进入 Android 开发的新人，或者写过一阵 Java/旧版 XML 布局、想切换到现代技术栈（Kotlin + Compose）的同学。&lt;br&gt;
知识骨架参考《Android 编程权威指南》（Big Nerd Ranch Guide），结合 2026 年的技术现状做了取舍，按「由浅入深」重新编排。&lt;br&gt;
姊妹篇：《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/frontend-ui-vue-mastery/&#34;&gt;前端 UI 开发从入门到精通（Vue 技术栈）&lt;/a&gt;》——写 UI 的心法，两端是相通的。&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;一 环境搭建&amp;lt;br/&amp;gt;能跑起 Hello World&amp;#34;] --&amp;gt; B[&amp;#34;二 Kotlin 基础&amp;lt;br/&amp;gt;语法 + 协程&amp;#34;]
    B --&amp;gt; C[&amp;#34;三 Compose 界面&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;lt;br/&amp;gt;数据从哪来 存到哪去&amp;#34;]
    E --&amp;gt; F[&amp;#34;六 系统能力&amp;lt;br/&amp;gt;定位 通知 唤醒 适配&amp;#34;]
    F --&amp;gt; G[&amp;#34;七 质量与发布&amp;lt;br/&amp;gt;测试 调试 签名上线&amp;#34;]
    G --&amp;gt; H[&amp;#34;八 AI 时代&amp;lt;br/&amp;gt;让 AI 写 80% 的代码&amp;#34;]
    style H fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;为什么是这个顺序？前三篇解决「&lt;strong&gt;能不能写出来&lt;/strong&gt;」；第四篇解决「&lt;strong&gt;代码多了以后会不会散架&lt;/strong&gt;」；第五、六篇解决「&lt;strong&gt;能不能和服务器、和操作系统打交道&lt;/strong&gt;」；第七篇解决「&lt;strong&gt;敢不敢发给真实用户&lt;/strong&gt;」；第八篇解决「&lt;strong&gt;如何持续快下去&lt;/strong&gt;」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第一篇-环境搭建90-的新人倒在这一关&#34;&gt;第一篇 环境搭建：90% 的新人倒在这一关&lt;/h2&gt;
&lt;p&gt;先泼一盆冷水再给你信心：&lt;strong&gt;Android 开发的门槛不在写代码，而在环境&lt;/strong&gt;。新人放弃 Android 的第一大原因不是学不会语法，而是装了三天 Android Studio 跑不起来一个 Hello World。所以环境放在最前面，并且写得足够细。&lt;/p&gt;
&lt;h3 id=&#34;11-你需要安装的三样东西&#34;&gt;1.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 TD
    A[&amp;#34;JDK 17+&amp;lt;br/&amp;gt;编译 Kotlin/Java 的底座&amp;#34;] --&amp;gt; D[&amp;#34;Android Studio&amp;#34;]
    B[&amp;#34;Android SDK&amp;lt;br/&amp;gt;系统 API + 平台工具 adb&amp;#34;] --&amp;gt; D
    C[&amp;#34;Android Emulator&amp;lt;br/&amp;gt;或一台真机&amp;#34;] --&amp;gt; D
    D --&amp;gt; E[&amp;#34;能跑起 Hello World&amp;lt;br/&amp;gt;= 入门完成一半&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;好消息是 2026 年的 Android Studio 已经是「全家桶」安装：下载安装包一路 Next，JDK（内嵌 JBR）、SDK、模拟器全都有了。真正需要你动手的只有三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;下载地址认准官方&lt;/strong&gt;：&lt;a href=&#34;https://developer.android.google.cn/studio&#34;&gt;developer.android.google.cn&lt;/a&gt;（国内可直连）。别去下载站，装到捆绑了全家桶的旧版本是新人常见悲剧。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第一次启动时让它下载 SDK&lt;/strong&gt;，默认勾选 Android SDK Platform、SDK Build-Tools、Android Emulator 即可。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配环境变量&lt;/strong&gt;（命令行构建和 AI 工具都会用到）：&lt;/li&gt;
&lt;/ol&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-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# Windows: 系统环境变量里添加&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ANDROID_HOME&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;C:&lt;span style=&#34;color:#ae81ff&#34;&gt;\U&lt;/span&gt;sers&lt;span style=&#34;color:#ae81ff&#34;&gt;\你&lt;/span&gt;的用户名&lt;span style=&#34;color:#ae81ff&#34;&gt;\A&lt;/span&gt;ppData&lt;span style=&#34;color:#ae81ff&#34;&gt;\L&lt;/span&gt;ocal&lt;span style=&#34;color:#ae81ff&#34;&gt;\A&lt;/span&gt;ndroid&lt;span style=&#34;color:#ae81ff&#34;&gt;\S&lt;/span&gt;dk
&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;# Path 里追加&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;%ANDROID_HOME%&lt;span style=&#34;color:#ae81ff&#34;&gt;\p&lt;/span&gt;latform-tools   &lt;span style=&#34;color:#75715e&#34;&gt;# adb 在这里&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;%ANDROID_HOME%&lt;span style=&#34;color:#ae81ff&#34;&gt;\c&lt;/span&gt;mdline-tools&lt;span style=&#34;color:#ae81ff&#34;&gt;\l&lt;/span&gt;atest&lt;span style=&#34;color:#ae81ff&#34;&gt;\b&lt;/span&gt;in&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;验证：新开一个终端跑 &lt;code&gt;adb version&lt;/code&gt;，有输出即成功。&lt;/p&gt;
&lt;h3 id=&#34;12-第一个项目别折腾用模板&#34;&gt;1.2 第一个项目：别折腾，用模板&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;New Project → Empty Activity&lt;/code&gt;（注意选 &lt;strong&gt;Empty Activity&lt;/strong&gt;，这是 Compose 模板；不要选带 &amp;ldquo;Views&amp;rdquo; 后缀的，那是旧 XML 布局）。点 Run，等模拟器起来，看到 &amp;ldquo;Hello Android&amp;rdquo; 就算通关。&lt;/p&gt;
&lt;h3 id=&#34;13-踩坑故事一gradle-同步转了一小时的圈&#34;&gt;1.3 踩坑故事一：Gradle 同步转了一小时的圈&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：新建项目后底部状态栏 &amp;ldquo;Gradle Sync&amp;rdquo; 转圈一小时不动，或者报 &lt;code&gt;Could not resolve com.android.tools.build:gradle:x.x.x&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Gradle 要从国外 Maven 仓库下载几百 MB 的构建工具和依赖，国内直连极慢。这是国内 Android 新人的「成人礼」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：给项目根目录的 &lt;code&gt;settings.gradle.kts&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// settings.gradle.kts
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;pluginManagement {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    repositories {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        maven(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://maven.aliyun.com/repository/gradle-plugin&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        maven(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://maven.aliyun.com/repository/google&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        google()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        mavenCentral()
&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;dependencyResolutionManagement {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    repositories {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        maven(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://maven.aliyun.com/repository/google&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        maven(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://maven.aliyun.com/repository/central&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        google()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        mavenCentral()
&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;：镜像要放在 &lt;code&gt;google()&lt;/code&gt; &lt;strong&gt;前面&lt;/strong&gt;，Gradle 按顺序找，先命中镜像就不用走官方源了。同样的思路适用于一切包管理器（npm、pip、Maven）——「换源」是国内开发者的通用生存技能。&lt;/p&gt;
&lt;h3 id=&#34;14-踩坑故事二agpgradlejdk-的三角恋情&#34;&gt;1.4 踩坑故事二：AGP、Gradle、JDK 的三角恋情&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：拉下同事的项目，构建报错 &lt;code&gt;Unsupported class file major version 65&lt;/code&gt; 或 &lt;code&gt;Minimum supported Gradle version is x.x&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Android 开发有三个版本必须互相匹配，这是新人最懵的地方：&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;Android Gradle Plugin (AGP)&amp;lt;br/&amp;gt;项目级 build.gradle 里声明&amp;#34;] --&amp;gt;|&amp;#34;要求&amp;#34;| B[&amp;#34;Gradle 版本&amp;lt;br/&amp;gt;gradle-wrapper.properties&amp;#34;]
    A --&amp;gt;|&amp;#34;要求&amp;#34;| C[&amp;#34;JDK 版本&amp;lt;br/&amp;gt;Android Studio 内嵌或系统&amp;#34;]
    B -.-&amp;gt;|&amp;#34;版本对照表&amp;lt;br/&amp;gt;查官方文档&amp;#34;| C
    style A fill:#fff3cd&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;AGP 8.x 要求 Gradle 8.x + JDK 17。&lt;/li&gt;
&lt;li&gt;报错时&lt;strong&gt;不要瞎改&lt;/strong&gt;，打开 Google 官方的「Android Gradle 插件版本说明」对照表，三者对齐。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：项目里 &lt;code&gt;gradle-wrapper.properties&lt;/code&gt; 锁定了 Gradle 版本是有深意的——它保证任何同事、任何 CI 机器拉下代码构建结果一致。新人最容易犯的错就是全局升级 Gradle 然后所有老项目全炸。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第二篇-kotlin-基础写给会别的语言的人&#34;&gt;第二篇 Kotlin 基础：写给会别的语言的人&lt;/h2&gt;
&lt;p&gt;环境通了，接下来是语言。如果你会 Java 或 JavaScript，这一篇很快；如果是零基础，建议搭配《Kotlin 核心编程》或官方教程把例子都敲一遍。&lt;/p&gt;
&lt;h3 id=&#34;21-十分钟速览和-javajs-的核心差异&#34;&gt;2.1 十分钟速览：和 Java/JS 的核心差异&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 1. val / var：不可变优先
&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;val&lt;/span&gt; name = &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;方糖猫&amp;#34;&lt;/span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;// 不可变，类似 const
&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; age = &lt;span style=&#34;color:#ae81ff&#34;&gt;3&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;// age = &amp;#34;三&amp;#34;             // ❌ 类型推导为 Int，改 String 编译报错
&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;// 2. 空安全：类型系统层面消灭 NPE
&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; nickname: String? = &lt;span style=&#34;color:#66d9ef&#34;&gt;null&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;// nickname.length        // ❌ 编译不过，必须处理 null
&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;val&lt;/span&gt; len = nickname&lt;span style=&#34;color:#f92672&#34;&gt;?.&lt;/span&gt;length &lt;span style=&#34;color:#f92672&#34;&gt;?:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&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 style=&#34;color:#75715e&#34;&gt;// 3. when 表达式：加强版 switch
&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;val&lt;/span&gt; desc = &lt;span style=&#34;color:#66d9ef&#34;&gt;when&lt;/span&gt; (age) {
&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;in&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;..&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &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:#ae81ff&#34;&gt;3&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &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;else&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &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;&lt;span style=&#34;color:#75715e&#34;&gt;// 4. data class：一行顶 Java 五十行
&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;data&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;User&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; id: Long, &lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; name: String, &lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; email: String = &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:#75715e&#34;&gt;// 自动生成 equals/hashCode/toString/copy
&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;// 5. 函数式：lambda 和集合操作
&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;val&lt;/span&gt; names = users.filter { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.name.isNotBlank() }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 .map { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.name }
&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;// 6. 扩展函数：给别人的类加方法
&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;String&lt;/span&gt;.isEmail(): Boolean = &lt;span style=&#34;color:#66d9ef&#34;&gt;this&lt;/span&gt;.contains(&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:#e6db74&#34;&gt;&amp;#34;ftmao@example.com&amp;#34;&lt;/span&gt;.isEmail()   &lt;span style=&#34;color:#75715e&#34;&gt;// true&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;空安全是 Kotlin 最值得吹的设计。Java 程序员的一生之敌 &lt;code&gt;NullPointerException&lt;/code&gt;，在 Kotlin 里被类型系统提前拦下——&lt;code&gt;String?&lt;/code&gt; 和 &lt;code&gt;String&lt;/code&gt; 是两个不同的类型，编译器强迫你处理 null。&lt;/p&gt;
&lt;h3 id=&#34;22-协程异步代码同步写&#34;&gt;2.2 协程：异步代码同步写&lt;/h3&gt;
&lt;p&gt;App 开发的核心动作是「发起网络请求 → 更新界面」，而且&lt;strong&gt;网络请求绝不能跑在主线程&lt;/strong&gt;（否则界面卡死，系统直接弹 ANR 杀掉你的 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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// viewModelScope 是 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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;loadUsers&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    viewModelScope.launch {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        _uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt; = &lt;span style=&#34;color:#a6e22e&#34;&gt;UiState&lt;/span&gt;.Loading
&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; {
&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;// withContext(Dispatchers.IO) 切到 IO 线程执行耗时操作
&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;val&lt;/span&gt; users = withContext(&lt;span style=&#34;color:#a6e22e&#34;&gt;Dispatchers&lt;/span&gt;.IO) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                api.getUsers()        &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;            _uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt; = &lt;span style=&#34;color:#a6e22e&#34;&gt;UiState&lt;/span&gt;.Success(users)   &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 style=&#34;color:#66d9ef&#34;&gt;catch&lt;/span&gt; (e: Exception) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            _uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt; = &lt;span style=&#34;color:#a6e22e&#34;&gt;UiState&lt;/span&gt;.Error(e.message &lt;span style=&#34;color:#f92672&#34;&gt;?:&lt;/span&gt; &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;}&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;sequenceDiagram
    participant UI as 主线程(UI)
    participant CO as 协程
    participant IO as IO 线程池
    UI-&amp;gt;&amp;gt;CO: viewModelScope.launch
    CO-&amp;gt;&amp;gt;IO: withContext(IO) 发起网络请求
    Note over UI: 主线程不阻塞&amp;lt;br/&amp;gt;界面照常响应触摸
    IO--&amp;gt;&amp;gt;CO: 请求返回
    CO--&amp;gt;&amp;gt;UI: 切回主线程更新状态
    UI-&amp;gt;&amp;gt;UI: Compose 自动重组界面&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;思考&lt;/strong&gt;：协程最难的不是语法，是「作用域」的概念——协程挂在谁的 scope 上，决定了它什么时候被取消。用 &lt;code&gt;viewModelScope&lt;/code&gt; 而不是裸 &lt;code&gt;GlobalScope.launch&lt;/code&gt;，你的协程就会随页面销毁自动取消，不会内存泄漏。这是面试高频题，更是线上事故高频源。现在只需要记住结论，第四篇讲完生命周期后你会真正理解为什么。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第三篇-用-compose-写出第一个界面&#34;&gt;第三篇 用 Compose 写出第一个界面&lt;/h2&gt;
&lt;p&gt;语言有了，马上写界面——学习正反馈来得越早越好。&lt;/p&gt;
&lt;h3 id=&#34;31-从-xml-到-compose一次范式革命&#34;&gt;3.1 从 XML 到 Compose：一次范式革命&lt;/h3&gt;
&lt;p&gt;旧时代的 Android UI 是「XML 写布局 + Java/Kotlin 找控件 + 手动 setText」，命令式的，状态和视图靠人肉同步。Compose 是声明式的：&lt;strong&gt;UI 是状态的函数&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
    subgraph old[&amp;#34;旧时代：命令式&amp;#34;]
        A1[&amp;#34;XML 布局&amp;#34;] --&amp;gt; B1[&amp;#34;findViewById&amp;#34;] --&amp;gt; C1[&amp;#34;手动 setText/setVisibility&amp;#34;]
        C1 --&amp;gt; D1[&amp;#34;状态变了？&amp;lt;br/&amp;gt;记得手动再 set 一遍&amp;lt;br/&amp;gt;(漏了就出 bug)&amp;#34;]
    end
    subgraph new[&amp;#34;Compose：声明式&amp;#34;]
        A2[&amp;#34;状态 State&amp;#34;] --&amp;gt;|&amp;#34;状态变化自动触发重组&amp;#34;| B2[&amp;#34;@Composable 函数&amp;lt;br/&amp;gt;UI = f(state)&amp;#34;]
    end
    style new fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;写过 Vue/React 的人会秒懂——这和前端框架的响应式是同一种思想，只是语法换成了 Kotlin。&lt;/p&gt;
&lt;h3 id=&#34;32-主流-ui-组件库答案比前端简单&#34;&gt;3.2 主流 UI 组件「库」：答案比前端简单&lt;/h3&gt;
&lt;p&gt;前端有 Element Plus / Ant Design / Naive UI 群雄割据，Android 的答案简单得多：&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;绝大多数情况&amp;#34;| A[&amp;#34;Material 3 (material3)&amp;lt;br/&amp;gt;官方出品 随 SDK 维护&amp;#34;]
    Q --&amp;gt;|&amp;#34;要高度自定义设计&amp;#34;| B[&amp;#34;Compose Foundation 无样式组件&amp;lt;br/&amp;gt;+ 自建 Design System&amp;#34;]
    Q --&amp;gt;|&amp;#34;跨端 iOS + Android&amp;#34;| C[&amp;#34;Compose Multiplatform&amp;lt;br/&amp;gt;或 Flutter(另一条技术栈)&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Material 3（&lt;code&gt;androidx.compose.material3&lt;/code&gt;）&lt;/strong&gt;：官方组件库，Button、Card、TextField、Dialog、Scaffold、NavigationBar 一应俱全，且深度适配系统特性（深色主题、动态取色）。&lt;strong&gt;先用它，用到不够用再谈别的&lt;/strong&gt;。这和前端「别和组件库对着干」是同一个道理——对抗官方设计体系的自定义，维护成本极高。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;accompanist 已成历史&lt;/strong&gt;：早年 Compose 功能不全，靠 accompanist 库补（下拉刷新、权限等），2026 年这些能力已全部并入官方库，新项目不要再引入。&lt;/li&gt;
&lt;li&gt;第三方生态里值得一提的：&lt;a href=&#34;https://coil-kt.github.io/coil/&#34;&gt;Coil&lt;/a&gt;（图片加载，事实标准）、&lt;a href=&#34;https://composedestinations.rafaelcosta.xyz/&#34;&gt;Compose Destinations&lt;/a&gt;（类型安全导航）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;33-demo一个五脏俱全的登录页&#34;&gt;3.3 Demo：一个五脏俱全的登录页&lt;/h3&gt;
&lt;p&gt;把 Kotlin、协程、Compose、Material 3 串起来——这是每个 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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;@Composable&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;LoginScreen&lt;/span&gt;(viewModel: LoginViewModel = 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;val&lt;/span&gt; state &lt;span style=&#34;color:#66d9ef&#34;&gt;by&lt;/span&gt; viewModel.uiState.collectAsState()
&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;    Scaffold { padding &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        Column(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            modifier = Modifier
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .padding(padding)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .fillMaxSize()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                .padding(&lt;span style=&#34;color:#ae81ff&#34;&gt;24&lt;/span&gt;.dp),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            verticalArrangement = &lt;span style=&#34;color:#a6e22e&#34;&gt;Arrangement&lt;/span&gt;.Center,
&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;            OutlinedTextField(
&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;value&lt;/span&gt; = state.email,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                onValueChange = viewModel&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;onEmailChange,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                label = { 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;                isError = state.emailError &lt;span style=&#34;color:#f92672&#34;&gt;!=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;null&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                supportingText = { state.emailError&lt;span style=&#34;color:#f92672&#34;&gt;?.&lt;/span&gt;let { Text(&lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;) } },
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                modifier = &lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.fillMaxWidth(),
&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;            Spacer(&lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.height(&lt;span style=&#34;color:#ae81ff&#34;&gt;16&lt;/span&gt;.dp))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            OutlinedTextField(
&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;value&lt;/span&gt; = state.password,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                onValueChange = viewModel&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;onPasswordChange,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                label = { 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;                visualTransformation = PasswordVisualTransformation(),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                modifier = &lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.fillMaxWidth(),
&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;            Spacer(&lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.height(&lt;span style=&#34;color:#ae81ff&#34;&gt;24&lt;/span&gt;.dp))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            Button(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                onClick = viewModel&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;login,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                enabled = !state.loading,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                modifier = &lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.fillMaxWidth(),
&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;if&lt;/span&gt; (state.loading) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    CircularProgressIndicator(&lt;span style=&#34;color:#a6e22e&#34;&gt;Modifier&lt;/span&gt;.size(&lt;span style=&#34;color:#ae81ff&#34;&gt;20&lt;/span&gt;.dp))
&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&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;                }
&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;对应的 ViewModel，注意状态管理的写法：&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;data&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;LoginUiState&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;val&lt;/span&gt; email: String = &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;val&lt;/span&gt; password: String = &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;val&lt;/span&gt; emailError: String? = &lt;span style=&#34;color:#66d9ef&#34;&gt;null&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;val&lt;/span&gt; loading: Boolean = &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:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;LoginViewModel&lt;/span&gt; : 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;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; _uiState = MutableStateFlow(LoginUiState())
&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;val&lt;/span&gt; uiState: StateFlow&amp;lt;LoginUiState&amp;gt; = _uiState.asStateFlow()
&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onEmailChange&lt;/span&gt;(v: String) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        _uiState.update { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.copy(email = v, emailError = &lt;span style=&#34;color:#66d9ef&#34;&gt;null&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;login&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;val&lt;/span&gt; s = _uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&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; (!s.email.contains(&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;            _uiState.update { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.copy(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;            &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;        viewModelScope.launch {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            _uiState.update { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.copy(loading = &lt;span style=&#34;color:#66d9ef&#34;&gt;true&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; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                delay(&lt;span style=&#34;color:#ae81ff&#34;&gt;1500&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;// api.login(s.email, s.password)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                _uiState.update { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.copy(loading = &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;catch&lt;/span&gt; (e: Exception) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                _uiState.update { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;.copy(loading = &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&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;（这些比语法值钱）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;单一数据源&lt;/strong&gt;：整个页面的所有状态装进一个 &lt;code&gt;UiState&lt;/code&gt; data class，UI 只读它。改状态用 &lt;code&gt;update { it.copy(...) }&lt;/code&gt; 不可变更新，和前端 Redux/Pinia 一个思想。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UI 状态的状态机&lt;/strong&gt;：登录按钮有「正常 / 加载中 / 禁用」形态，错误有「无错 / 有错」形态——「UI 本质是状态机」，在任何平台都成立。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;34-踩坑故事三列表滑动卡成-ppt&#34;&gt;3.4 踩坑故事三：列表滑动卡成 PPT&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;LazyColumn&lt;/code&gt; 展示 500 条消息，滑动掉帧明显。&lt;/p&gt;
&lt;p&gt;&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;LazyColumn 卡顿&amp;#34;] --&amp;gt; B{&amp;#34;Composable 里&amp;lt;br/&amp;gt;有耗时计算吗？&amp;#34;}
    B --&amp;gt;|&amp;#34;有，最常见&amp;#34;| C[&amp;#34;用 remember 缓存计算结果&amp;lt;br/&amp;gt;remember(list) { expensive(list) }&amp;#34;]
    B --&amp;gt;|&amp;#34;没有&amp;#34;| D{&amp;#34;item 有稳定的 key 吗？&amp;#34;}
    D --&amp;gt;|&amp;#34;没写 key&amp;#34;| E[&amp;#34;items(list, key = { it.id })&amp;lt;br/&amp;gt;没 key 时位置变动全量重组&amp;#34;]
    D --&amp;gt;|&amp;#34;写了&amp;#34;| F{&amp;#34;在 Debug 包测的？&amp;#34;}
    F --&amp;gt;|&amp;#34;是，冤枉了&amp;#34;| G[&amp;#34;换 Release 包测！&amp;lt;br/&amp;gt;Debug 包 Compose 性能差数倍&amp;#34;]
    style G fill:#ffe4e1&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：第三条最坑——&lt;strong&gt;Compose 的 Debug 包性能不代表线上&lt;/strong&gt;。无数新人在 Debug 包测出「卡顿」，花两天优化，打完 Release 包发现根本不卡。性能问题永远以 Release 包为准。&lt;/p&gt;
&lt;h3 id=&#34;35-踩坑故事四旋转屏幕数据全没了&#34;&gt;3.5 踩坑故事四：旋转屏幕，数据全没了&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：页面填了一半表单，用户一转屏幕，输入全没了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：旋转屏幕默认会&lt;strong&gt;销毁并重建 Activity&lt;/strong&gt;。存在 Composable 局部变量里的状态随重建灰飞烟灭。&lt;/p&gt;
&lt;p&gt;&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;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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 1. remember：只管重组不丢，不管重建 —— 最弱
&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; text &lt;span style=&#34;color:#66d9ef&#34;&gt;by&lt;/span&gt; remember { mutableStateOf(&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:#75715e&#34;&gt;// 2. rememberSaveable：重建也能恢复（进 Bundle，有大小限制）
&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; text &lt;span style=&#34;color:#66d9ef&#34;&gt;by&lt;/span&gt; rememberSaveable { mutableStateOf(&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:#75715e&#34;&gt;// 3. ViewModel + StateFlow：配置变更（旋转、深浅色切换）都不丢 —— 正道
&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;//    状态放 ViewModel 里，Activity 重建 ViewModel 不重建&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;：Android 的「生命周期地狱」本质上是系统在告诉你：&lt;strong&gt;UI 是廉价的，随时会被销毁；状态是珍贵的，要放在比 UI 活得久的地方&lt;/strong&gt;。这个坑也是下一篇的入口——旋转只是「销毁」的一种，更狠的还在后面。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第四篇-架构与生命周期从写页面到写应用&#34;&gt;第四篇 架构与生命周期：从「写页面」到「写应用」&lt;/h2&gt;
&lt;p&gt;前三篇你能写出一个页面了。但真实 App 是几十个页面、在几亿台不可控的设备上被随意切换、随时被杀。这一篇建立组织代码的地基——《Android 编程权威指南》全书真正想教的就是这一篇的东西。&lt;/p&gt;
&lt;h3 id=&#34;41-activity-生命周期不是八股是保命符&#34;&gt;4.1 Activity 生命周期：不是八股，是保命符&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;stateDiagram-v2
    [*] --&amp;gt; onCreate: 启动
    onCreate --&amp;gt; onStart
    onStart --&amp;gt; onResume: 获得焦点，可交互
    onResume --&amp;gt; onPause: 被半透明遮挡(弹窗)
    onPause --&amp;gt; onResume: 回到前台
    onPause --&amp;gt; onStop: 完全不可见(跳别的页面/按Home)
    onStop --&amp;gt; onRestart: 从后台回来
    onRestart --&amp;gt; onStart
    onStop --&amp;gt; onDestroy: 用户退出或系统回收
    onDestroy --&amp;gt; [*]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;真正值钱的是这两条规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可见性配对&lt;/strong&gt;：&lt;code&gt;onStart/onStop&lt;/code&gt; 管「看不看得见」（注册/注销广播监听放这里），&lt;code&gt;onResume/onPause&lt;/code&gt; 管「能不能交互」（相机、传感器这类独占资源放这里）。放错层级是资源泄漏的常见来源。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;onDestroy&lt;/code&gt; 不保证被调用&lt;/strong&gt;：进程被系统直接杀死时，什么回调都不会来。重要数据不能指望「退出时再保存」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事五：切后台半小时，回来从头再来&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：用户在表单页填到一半，切出去回了个微信，半小时后切回来，App 重新从启动页加载，填的内容全没了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：这不是 3.5 节旋转屏幕那种&lt;strong&gt;配置变更&lt;/strong&gt;，而是&lt;strong&gt;进程死亡（Process Death）&lt;/strong&gt;——App 在后台久了，系统内存不足时直接杀掉整个进程。ViewModel 也跟着进程一起死了，3.5 节的三层方案全部失效。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：ViewModel 里的关键状态用 &lt;strong&gt;SavedStateHandle&lt;/strong&gt;，它会把数据写入进程死亡也能存活的 Bundle：&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-kotlin&#34; data-lang=&#34;kotlin&#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;FormViewModel&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;private&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; state: SavedStateHandle) : ViewModel() {
&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;// 进程被杀再恢复，draft 还在
&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; draft: 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;get&lt;/span&gt;() = state[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;draft&amp;#34;&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;?:&lt;/span&gt; &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;set&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt;) { state[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;draft&amp;#34;&lt;/span&gt;] = &lt;span style=&#34;color:#66d9ef&#34;&gt;value&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;：开发者选项里打开「不保留活动（Don&amp;rsquo;t keep activities）」，或者在 App 切后台后执行 &lt;code&gt;adb shell am kill &amp;lt;包名&amp;gt;&lt;/code&gt; 模拟进程死亡。不测这一关，你永远不知道自己的状态恢复写对了没有——这是权威指南反复强调而多数教程略过的点。&lt;/p&gt;
&lt;h3 id=&#34;42-四大组件与-intentandroid-的宪法&#34;&gt;4.2 四大组件与 Intent：Android 的「宪法」&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 TD
    A[&amp;#34;Activity&amp;lt;br/&amp;gt;一页界面&amp;#34;] --- E[&amp;#34;Intent 把它们串起来&amp;#34;]
    B[&amp;#34;Service&amp;lt;br/&amp;gt;无界面的后台工作&amp;lt;br/&amp;gt;(今天多由 WorkManager/前台服务承担)&amp;#34;] --- E
    C[&amp;#34;BroadcastReceiver&amp;lt;br/&amp;gt;监听系统/应用事件&amp;lt;br/&amp;gt;开机完成 网络变化&amp;#34;] --- E
    D[&amp;#34;ContentProvider&amp;lt;br/&amp;gt;跨进程共享结构化数据&amp;lt;br/&amp;gt;联系人 媒体库&amp;#34;] --- E&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;应知应会的点不在概念，而在分寸：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Intent 分显式/隐式&lt;/strong&gt;：显式指定类名跳自己 App 的页面；隐式声明「我想看一张图片 / 打开一个网页」，系统找能干的 App 来接。隐式 Intent 配合 intent-filter，就是 Android 应用间协作的全部基础——第六篇的 App 唤醒全靠它。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Task 与返回栈&lt;/strong&gt;：启动的 Activity 层层叠在一个任务栈里，返回键出栈。第六篇的 &lt;code&gt;launchMode&lt;/code&gt; 就是在控制这个栈的行为。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;今天的比重&lt;/strong&gt;：新项目里 Service 大多被 WorkManager 和协程替代，BroadcastReceiver 的系统广播被限制得越来越严，ContentProvider 多数时候你只是「使用者」（读联系人、读相册）。&lt;strong&gt;概念要懂，但别再像十年前的教程那样上来先写三个 Service&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;43-fragmentcompose-时代怎么看它&#34;&gt;4.3 Fragment：Compose 时代怎么看它&lt;/h3&gt;
&lt;p&gt;权威指南的老版本用了大量篇幅讲 Fragment——它是 View 时代组织页面的核心。2026 年的答案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;新项目&lt;/strong&gt;：单 Activity + Compose + Navigation 库，页面就是 Composable，&lt;strong&gt;不需要 Fragment&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;绕不开的场景&lt;/strong&gt;：维护老项目、集成只有 View 版本的第三方 SDK（某些地图、支付 SDK），以及系统级 UI（如 &lt;code&gt;PreferenceFragmentCompat&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以对待 Fragment 的正确姿势是：&lt;strong&gt;看懂生命周期比 Activity 多一层（view 的生命周期和 Fragment 本身分离），够用即可，不必深入&lt;/strong&gt;。把省下的时间投给 Compose。&lt;/p&gt;
&lt;h3 id=&#34;44-架构mvvm--repository--单向数据流&#34;&gt;4.4 架构：MVVM + Repository + 单向数据流&lt;/h3&gt;
&lt;p&gt;权威指南从 MVC 讲到 MVVM 的演进，核心思想收敛成今天 Google 官方推荐的这张图：&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;Composable&amp;lt;br/&amp;gt;只负责渲染和上报事件&amp;#34;]
    end
    subgraph 状态层
        B[&amp;#34;ViewModel&amp;lt;br/&amp;gt;持有 UiState(StateFlow)&amp;lt;br/&amp;gt;暴露事件处理函数&amp;#34;]
    end
    subgraph 数据层
        C[&amp;#34;Repository&amp;lt;br/&amp;gt;数据的唯一入口&amp;lt;br/&amp;gt;决定走网络还是缓存&amp;#34;]
        D[&amp;#34;Retrofit&amp;lt;br/&amp;gt;远程数据&amp;#34;]
        E[&amp;#34;Room / DataStore&amp;lt;br/&amp;gt;本地数据&amp;#34;]
    end
    A --&amp;gt;|&amp;#34;用户事件&amp;#34;| B
    B --&amp;gt;|&amp;#34;StateFlow 数据流&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;UI 不直接碰网络和数据库&lt;/strong&gt;——Composable 里出现 Retrofit 调用，就是架构腐烂的开始。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据向下流，事件向上流（UDF）&lt;/strong&gt;：UI 只读状态、只发事件，ViewModel 改状态。3.3 节的登录 Demo 就是这个模式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repository 是数据的唯一入口&lt;/strong&gt;：ViewModel 不关心数据来自网络还是 Room 缓存，这让「离线优先」的 App 成为可能——先展示 Room 里的旧数据，网络回来再刷新。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;注意这张图里数据层的两个角色——Retrofit 和 Room。它们就是下一篇的主角。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第五篇-网络与存储数据从哪来存到哪去&#34;&gt;第五篇 网络与存储：数据从哪来，存到哪去&lt;/h2&gt;
&lt;p&gt;架构定了，现在填数据层的肉。这一篇学完，你就能写出「联网 CRUD + 离线缓存」的完整 App 了。&lt;/p&gt;
&lt;h3 id=&#34;51-联网的第一步权限与明文拦截&#34;&gt;5.1 联网的第一步：权限与明文拦截&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事六：请求发出去了，App 里却报 CLEARTEXT 错误&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：Retrofit 请求发出去了，模拟器浏览器也能打开接口地址，App 里却报 &lt;code&gt;CLEARTEXT communication not permitted&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;，两个新手必踩的组合拳：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;AndroidManifest.xml&lt;/code&gt; 忘了声明网络权限（Android 不像前端，联网要申请）：&lt;/li&gt;
&lt;/ol&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-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;manifest&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;...&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;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:#f92672&#34;&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;android.permission.INTERNET&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;/manifest&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;ol start=&#34;2&#34;&gt;
&lt;li&gt;Android 9+ 默认禁止 HTTP 明文请求，测试环境是 &lt;code&gt;http://&lt;/code&gt; 就会撞上。开发期放行：&lt;/li&gt;
&lt;/ol&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-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;application&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:#a6e22e&#34;&gt;android:usesCleartextTraffic=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;true&amp;#34;&lt;/span&gt;   &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;&amp;lt;!--&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;仅限&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;debug&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;包这么干&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;--&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ...&amp;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;：上线前把明文流量关掉，让后端上 HTTPS。安全这类事情，「先放行以后再收」基本等于永远不收。&lt;/p&gt;
&lt;h3 id=&#34;52-retrofit网络层的事实标准&#34;&gt;5.2 Retrofit：网络层的事实标准&lt;/h3&gt;
&lt;p&gt;移动端网络层的标准组合：&lt;strong&gt;Retrofit（定义接口）+ OkHttp（发请求）+ 协程（写异步）&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 1. 接口定义：挂起函数 + 注解，Kotlin 协程原生支持
&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;interface&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;ApiService&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:#a6e22e&#34;&gt;@GET&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;users&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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;getUsers&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;@Query&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;page&amp;#34;&lt;/span&gt;) page: Int): ApiResponse&amp;lt;List&amp;lt;User&amp;gt;&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:#a6e22e&#34;&gt;@POST&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;login&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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;login&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;@Body&lt;/span&gt; body: LoginRequest): ApiResponse&amp;lt;LoginData&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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 2. OkHttp：拦截器统一带 token、打日志、配超时
&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;val&lt;/span&gt; okHttp = &lt;span style=&#34;color:#a6e22e&#34;&gt;OkHttpClient&lt;/span&gt;.Builder()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .connectTimeout(&lt;span style=&#34;color:#ae81ff&#34;&gt;10&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;TimeUnit&lt;/span&gt;.SECONDS)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .readTimeout(&lt;span style=&#34;color:#ae81ff&#34;&gt;15&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;TimeUnit&lt;/span&gt;.SECONDS)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .addInterceptor { chain &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;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;val&lt;/span&gt; req = chain.request().newBuilder()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            .addHeader(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Authorization&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Bearer &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;${TokenHolder.token}&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;            .build()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        chain.proceed(req)
&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;    .addInterceptor(HttpLoggingInterceptor().setLevel(&lt;span style=&#34;color:#a6e22e&#34;&gt;HttpLoggingInterceptor&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Level&lt;/span&gt;.BODY))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .build()
&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;// 3. Retrofit 组装
&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;val&lt;/span&gt; api = &lt;span style=&#34;color:#a6e22e&#34;&gt;Retrofit&lt;/span&gt;.Builder()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .baseUrl(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://api.example.com/&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;    .client(okHttp)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .addConverterFactory(&lt;span style=&#34;color:#a6e22e&#34;&gt;GsonConverterFactory&lt;/span&gt;.create())
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .build()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .create(ApiService&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt;.java)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事七：baseUrl 结尾少了一个斜杠&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：Retrofit 初始化直接 crash：&lt;code&gt;IllegalArgumentException: baseUrl must end in /&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：几乎每个 Android 开发者都撞过一次。顺手记住另一个相关坑：&lt;code&gt;@GET&lt;/code&gt; 里的相对路径如果以 &lt;code&gt;/&lt;/code&gt; 开头，会丢弃 baseUrl 的路径段——&lt;code&gt;baseUrl(&amp;quot;https://x.com/api/&amp;quot;)&lt;/code&gt; 配 &lt;code&gt;@GET(&amp;quot;/users&amp;quot;)&lt;/code&gt; 实际请求的是 &lt;code&gt;https://x.com/users&lt;/code&gt;，&lt;code&gt;/api&lt;/code&gt; 没了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事八：拦截器里打印了 body，业务层就拿不到数据了&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：在自定义拦截器里写了 &lt;code&gt;response.body()!!.string()&lt;/code&gt; 打日志，结果业务层解析永远失败。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：OkHttp 的响应体是&lt;strong&gt;流，只能消费一次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：打日志用官方 &lt;code&gt;HttpLoggingInterceptor&lt;/code&gt;（上面 Demo 里已带）；自定义拦截器里要读内容，用 &lt;code&gt;peekBody&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; bodyText = response.peekBody(&lt;span style=&#34;color:#ae81ff&#34;&gt;1024&lt;/span&gt; * &lt;span style=&#34;color:#ae81ff&#34;&gt;1024&lt;/span&gt;).string()  &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;p&gt;&lt;strong&gt;移动端特有的提醒&lt;/strong&gt;：手机用户会进电梯、进地铁、切 Wi-Fi 和流量，弱网和断线是&lt;strong&gt;常态而不是异常&lt;/strong&gt;。超时必须配、失败要给重试入口、一次抖动失败别急着弹「网络异常」的全屏错误——静默重试一次，体验天差地别。&lt;/p&gt;
&lt;h3 id=&#34;53-本地存储四个层级各管各的&#34;&gt;5.3 本地存储：四个层级，各管各的&lt;/h3&gt;
&lt;p&gt;「存东西」在 Android 里不是一件事，先想清楚存什么：&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;lt;br/&amp;gt;设置项 / token&amp;#34;| A[&amp;#34;DataStore&amp;lt;br/&amp;gt;(SharedPreferences 的接班人)&amp;#34;]
    Q --&amp;gt;|&amp;#34;结构化数据&amp;lt;br/&amp;gt;离线缓存 / 消息列表&amp;#34;| B[&amp;#34;Room 数据库&amp;lt;br/&amp;gt;(下一节详解)&amp;#34;]
    Q --&amp;gt;|&amp;#34;图片 / 日志 / 大文件&amp;#34;| C[&amp;#34;App 私有目录&amp;lt;br/&amp;gt;context.filesDir&amp;#34;]
    Q --&amp;gt;|&amp;#34;要存进相册 / 共享给用户&amp;#34;| D[&amp;#34;MediaStore API&amp;lt;br/&amp;gt;(不要直接写 /sdcard)&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;键值存储的 Demo，协程化的 DataStore 用法：&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; Context.dataStore &lt;span style=&#34;color:#66d9ef&#34;&gt;by&lt;/span&gt; preferencesDataStore(name = &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;settings&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;val&lt;/span&gt; KEY_TOKEN = stringPreferencesKey(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;token&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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;saveToken&lt;/span&gt;(context: Context, token: String) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    context.dataStore.edit { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;[KEY_TOKEN] = token }
&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;// 读取是 Flow，可以在 ViewModel 里直接 collect
&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;val&lt;/span&gt; tokenFlow: Flow&amp;lt;String?&amp;gt; = context.dataStore.&lt;span style=&#34;color:#66d9ef&#34;&gt;data&lt;/span&gt;.map { &lt;span style=&#34;color:#66d9ef&#34;&gt;it&lt;/span&gt;[KEY_TOKEN] }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事九：SharedPreferences 引发的 ANR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：老项目用户反馈「切换页面偶尔卡死」，ANR 日志里堆栈停在 &lt;code&gt;QueuedWork.waitToFinish()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code&gt;SharedPreferences.apply()&lt;/code&gt; 号称异步，但系统会在 Activity 停顿时把磁盘写入队列 flush 完才放行——数据多了，主线程就在这等着。另外首次 &lt;code&gt;getSharedPreferences()&lt;/code&gt; 的读盘也发生在主线程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：新项目一律 DataStore（协程挂起，天然不阻塞主线程）；老项目遇到这类 ANR，迁移 DataStore 是治本方案。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十：targetSdk 升到 30，读不到文件了&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：原来「把文件存到 &lt;code&gt;/sdcard/Download&lt;/code&gt;」的代码，升级 targetSdk 后全部失败。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Android 10 引入&lt;strong&gt;分区存储（Scoped Storage）&lt;/strong&gt;，App 再也不能随意读写公共目录了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法分级&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;App 自己的文件 → 用 &lt;code&gt;context.filesDir&lt;/code&gt; / &lt;code&gt;cacheDir&lt;/code&gt;，不需要任何权限，卸载自动清理。&lt;/li&gt;
&lt;li&gt;图片视频要进相册 → 走 &lt;code&gt;MediaStore&lt;/code&gt; API。&lt;/li&gt;
&lt;li&gt;让用户选文档 → 走 SAF（系统文件选择器）。&lt;/li&gt;
&lt;li&gt;想申请 &lt;code&gt;MANAGE_EXTERNAL_STORAGE&lt;/code&gt; 一把梭？Google Play 会拒审，除非你是文件管理器类 App。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;感悟&lt;/strong&gt;：分区存储当年被骂得很惨，但回头看是对的——它终结了「每个 App 都在 SD 卡根目录拉一坨文件夹」的蛮荒时代。适配它的正确姿势不是对抗（到处找绕过方案），而是&lt;strong&gt;按数据的归属选 API&lt;/strong&gt;：属于自己的放私有目录，属于用户的走 MediaStore。&lt;/p&gt;
&lt;h3 id=&#34;54-room编译期检查-sql-的数据库&#34;&gt;5.4 Room：编译期检查 SQL 的数据库&lt;/h3&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 1. Entity：一张表
&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:#a6e22e&#34;&gt;@Entity&lt;/span&gt;(tableName = &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;expenses&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;data&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:#a6e22e&#34;&gt;@PrimaryKey&lt;/span&gt;(autoGenerate = &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;) &lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; id: Long = &lt;span style=&#34;color:#ae81ff&#34;&gt;0&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;val&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;val&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;val&lt;/span&gt; createdAt: Long,
&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;// 2. DAO：操作表的方法，SQL 在编译期校验
&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:#a6e22e&#34;&gt;@Dao&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;interface&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;ExpenseDao&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:#a6e22e&#34;&gt;@Query&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;SELECT * FROM expenses ORDER BY createdAt DESC&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;observeAll&lt;/span&gt;(): Flow&amp;lt;List&amp;lt;Expense&amp;gt;&amp;gt;   &lt;span style=&#34;color:#75715e&#34;&gt;// 返回 Flow：数据变了 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:#a6e22e&#34;&gt;@Insert&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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;insert&lt;/span&gt;(expense: Expense)
&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:#a6e22e&#34;&gt;@Delete&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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;delete&lt;/span&gt;(expense: Expense)
&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;// 3. Database：单例持有
&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:#a6e22e&#34;&gt;@Database&lt;/span&gt;(entities = [Expense&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt;], version = &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;abstract&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;AppDatabase&lt;/span&gt; : RoomDatabase() {
&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;abstract&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;expenseDao&lt;/span&gt;(): ExpenseDao
&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;val&lt;/span&gt; db = &lt;span style=&#34;color:#a6e22e&#34;&gt;Room&lt;/span&gt;.databaseBuilder(context, AppDatabase&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt;.java, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;app.db&amp;#34;&lt;/span&gt;).build()&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; 返回 &lt;code&gt;Flow&lt;/code&gt; 是 Room 最优雅的用法——数据库一变，Flow 发射新值，Compose 自动重组，「增删改 → 界面刷新」这条链路不用写一行通知代码。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十一：加了个字段，老用户升级就闪退&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：新版给 &lt;code&gt;Expense&lt;/code&gt; 加了一个 &lt;code&gt;category&lt;/code&gt; 字段，自己测没问题，上线后老用户打开就崩：&lt;code&gt;IllegalStateException: A migration from 1 to 2 was required&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Room 用 &lt;code&gt;version&lt;/code&gt; 管理表结构。老用户手机里的库还是 version 1 的结构，新代码按 version 2 读，Room 要求你提供 Migration（怎么从 1 迁移到 2），你没有提供。&lt;/p&gt;
&lt;p&gt;&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;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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; MIGRATION_1_2 = &lt;span style=&#34;color:#66d9ef&#34;&gt;object&lt;/span&gt; &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;: &lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;Migration&lt;/span&gt;(&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;, &lt;span style=&#34;color:#ae81ff&#34;&gt;2&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;migrate&lt;/span&gt;(db: SupportSQLiteDatabase) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        db.execSQL(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ALTER TABLE expenses ADD COLUMN category TEXT NOT NULL DEFAULT &amp;#39;&amp;#39;&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:#a6e22e&#34;&gt;Room&lt;/span&gt;.databaseBuilder(&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;    .addMigrations(MIGRATION_1_2)
&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;// 开发期偷懒可以用 .fallbackToDestructiveMigration()（清库重建）
&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;    .build()&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;：数据库字段变更和接口变更一样，是&lt;strong&gt;对存量用户的承诺&lt;/strong&gt;。每次改 Entity，先想「老用户的库里有什么」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第六篇-系统能力定位通知唤醒与平台适配&#34;&gt;第六篇 系统能力：定位、通知、唤醒与平台适配&lt;/h2&gt;
&lt;p&gt;到这里，你的 App 已经「自成一体」了。这一篇让它和操作系统、和其他 App、和真实世界的几亿台设备打交道。系统能力的坑有一个共同特征：&lt;strong&gt;Demo 里都好好的，一到用户手机上就出事&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;61-定位别再用-locationmanager-了&#34;&gt;6.1 定位：别再用 LocationManager 了&lt;/h3&gt;
&lt;p&gt;官方推荐的是 Google Play 服务里的 &lt;code&gt;FusedLocationProviderClient&lt;/code&gt;——它自动在 GPS、基站、Wi-Fi 之间选最优来源，比你手写 &lt;code&gt;LocationManager&lt;/code&gt; 监听省心得多。&lt;/p&gt;
&lt;p&gt;权限申请的关键流程（Android 12 之后有变化）：&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;lt;br/&amp;gt;ACCESS_FINE + ACCESS_COARSE&amp;#34;] --&amp;gt; B{&amp;#34;用户怎么选的？&amp;#34;}
    B --&amp;gt;|&amp;#34;精确授权&amp;#34;| C[&amp;#34;高精度定位&amp;lt;br/&amp;gt;米级&amp;#34;]
    B --&amp;gt;|&amp;#34;Android 12+ 只给『大致位置』&amp;#34;| D[&amp;#34;只有约 3km 精度&amp;lt;br/&amp;gt;⚠️ 代码必须兼容这种情况&amp;#34;]
    B --&amp;gt;|&amp;#34;拒绝&amp;#34;| E[&amp;#34;优雅降级&amp;lt;br/&amp;gt;引导手动选择城市&amp;#34;]
    B --&amp;gt;|&amp;#34;需要后台定位&amp;#34;| F[&amp;#34;必须再单独申请&amp;lt;br/&amp;gt;ACCESS_BACKGROUND_LOCATION&amp;lt;br/&amp;gt;且只能跳设置页让用户手动开&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;获取定位的 Demo（配合 &lt;code&gt;kotlinx-coroutines-play-services&lt;/code&gt; 的 &lt;code&gt;await()&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;val&lt;/span&gt; fused = &lt;span style=&#34;color:#a6e22e&#34;&gt;LocationServices&lt;/span&gt;.getFusedLocationProviderClient(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;suspend&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;getCurrentLocation&lt;/span&gt;(): Location? {
&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; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        fused.getCurrentLocation(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#a6e22e&#34;&gt;Priority&lt;/span&gt;.PRIORITY_HIGH_ACCURACY,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            CancellationTokenSource().token,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        ).await()
&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; (e: SecurityException) {
&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;null&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事十二：定位永远是 null，或者位置是三天前的&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：&lt;code&gt;getLastLocation()&lt;/code&gt; 一会儿返回 null，一会儿返回几个小时前的旧坐标。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：这个方法只是&lt;strong&gt;读系统缓存&lt;/strong&gt;——最近有别的 App 定位过才有值，它自己不会发起定位。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：像上面 Demo 那样用 &lt;code&gt;getCurrentLocation()&lt;/code&gt; 主动请求一次新定位。需要持续追踪（跑步轨迹这类）再用 &lt;code&gt;requestLocationUpdates()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十三：坐标偏了五百米，人都站到河里了&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：GPS 拿到的经纬度画到高德地图上，位置偏移了几百米，用户「站在」马路对面的河里。&lt;/p&gt;
&lt;p&gt;&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 LR
    A[&amp;#34;GPS 芯片原始坐标&amp;lt;br/&amp;gt;WGS-84&amp;#34;] --&amp;gt;|&amp;#34;国测局加密算法&amp;#34;| B[&amp;#34;GCJ-02 火星坐标&amp;lt;br/&amp;gt;高德/腾讯/Google 国内用&amp;#34;]
    B --&amp;gt;|&amp;#34;百度再加密一次&amp;#34;| C[&amp;#34;BD-09&amp;lt;br/&amp;gt;百度地图专用&amp;#34;]
    A -.-&amp;gt;|&amp;#34;直接画到高德地图&amp;lt;br/&amp;gt;❌ 偏移 300-600 米&amp;#34;| D[&amp;#34;线上事故&amp;#34;]
    style D fill:#ffe4e1&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;教训&lt;/strong&gt;：用什么地图 SDK 就转成什么坐标。&lt;code&gt;FusedLocationProviderClient&lt;/code&gt; 返回的是 WGS-84，上高德地图前先用高德 SDK 的 &lt;code&gt;CoordinateConverter&lt;/code&gt; 转 GCJ-02；接百度地图就转 BD-09。&lt;strong&gt;永远不要自己写转换算法&lt;/strong&gt;，加密参数不公开，网上的「破解版公式」精度堪忧。&lt;/p&gt;
&lt;h3 id=&#34;62-消息通知两段历史决定了今天的写法&#34;&gt;6.2 消息通知：两段历史决定了今天的写法&lt;/h3&gt;
&lt;p&gt;通知 API 看着简单，但它是 Android 版本行为变更的重灾区。先记住这两个分水岭：&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;Android 8 (API 26)&amp;lt;br/&amp;gt;通知必须归属&amp;lt;br/&amp;gt;NotificationChannel&amp;#34;] --&amp;gt; B[&amp;#34;Android 13 (API 33)&amp;lt;br/&amp;gt;通知变成运行时权限&amp;lt;br/&amp;gt;POST_NOTIFICATIONS&amp;#34;]
    B --&amp;gt; C[&amp;#34;今天的完整姿势&amp;lt;br/&amp;gt;建渠道 + 申请权限&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 第 1 步：建渠道（App 启动时建，重复调用无副作用）
&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;val&lt;/span&gt; channel = NotificationChannel(
&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;order&amp;#34;&lt;/span&gt;,                          &lt;span style=&#34;color:#75715e&#34;&gt;// 渠道 id
&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;订单消息&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 style=&#34;color:#a6e22e&#34;&gt;NotificationManager&lt;/span&gt;.IMPORTANCE_HIGH,
&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;context.getSystemService(NotificationManager&lt;span style=&#34;color:#f92672&#34;&gt;::&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt;.java)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .createNotificationChannel(channel)
&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 步：Android 13+ 运行时申请权限
&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;val&lt;/span&gt; launcher = rememberLauncherForActivityResult(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;ActivityResultContracts&lt;/span&gt;.RequestPermission()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;) { granted &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;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;launcher.launch(&lt;span style=&#34;color:#a6e22e&#34;&gt;Manifest&lt;/span&gt;.permission.POST_NOTIFICATIONS)
&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;// 第 3 步：发通知
&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;val&lt;/span&gt; notification = &lt;span style=&#34;color:#a6e22e&#34;&gt;NotificationCompat&lt;/span&gt;.Builder(context, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    .setSmallIcon(&lt;span style=&#34;color:#a6e22e&#34;&gt;R&lt;/span&gt;.drawable.ic_notification)   &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;    .setContentTitle(&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;    .setContentText(&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;    .setAutoCancel(&lt;span style=&#34;color:#66d9ef&#34;&gt;true&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;    .build()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;NotificationManagerCompat&lt;/span&gt;.from(context).notify(&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;, notification)&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;&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;通知不显示&amp;#34;] --&amp;gt; B{&amp;#34;建 Channel 了吗？&amp;#34;}
    B --&amp;gt;|&amp;#34;没建（Android 8+ 直接静默丢弃）&amp;#34;| C[&amp;#34;补上 createNotificationChannel&amp;#34;]
    B --&amp;gt;|&amp;#34;建了&amp;#34;| D{&amp;#34;Channel 的 importance&amp;lt;br/&amp;gt;是不是 LOW/MIN？&amp;#34;}
    D --&amp;gt;|&amp;#34;是&amp;#34;| E[&amp;#34;⚠️ 渠道等级一旦创建&amp;lt;br/&amp;gt;代码里改不了！&amp;lt;br/&amp;gt;只能换渠道 id 或引导用户去设置&amp;#34;]
    D --&amp;gt;|&amp;#34;否&amp;#34;| F{&amp;#34;Android 13+ 申请&amp;lt;br/&amp;gt;POST_NOTIFICATIONS 了吗？&amp;#34;}
    F --&amp;gt;|&amp;#34;没申请&amp;#34;| G[&amp;#34;补运行时权限申请&amp;#34;]
    style E fill:#ffe4e1&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;第二个分支最阴：渠道等级创建后&lt;strong&gt;被系统记住&lt;/strong&gt;，你在代码里把 &lt;code&gt;IMPORTANCE_LOW&lt;/code&gt; 改成 &lt;code&gt;IMPORTANCE_HIGH&lt;/code&gt; 毫无作用，要么换一个新的渠道 id，要么引导用户去系统设置里改。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关于推送（Push）多说两句&lt;/strong&gt;：本地通知只是通知的一半，另一半是服务器推消息下来。这里国内生态是割裂的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FCM（Google 推送）在国内不可用。&lt;/li&gt;
&lt;li&gt;国内正道是&lt;strong&gt;厂商通道&lt;/strong&gt;：华为、小米、OPPO、vivo、荣耀各自的推送 SDK。它们是系统级通道，&lt;strong&gt;App 被杀也能收到&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;第三方聚合（个推、极光）可以一套代码接多家厂商通道，小团队首选。&lt;/li&gt;
&lt;li&gt;只靠自己的长连接保活推消息？国产 ROM 杀后台从不手软，重要消息（订单、支付）务必走厂商通道。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;63-app-唤醒deep-link拉起与被拉起&#34;&gt;6.3 App 唤醒：Deep Link、拉起与被拉起&lt;/h3&gt;
&lt;p&gt;4.2 节讲了隐式 Intent 和 intent-filter 的原理，这一节把它用起来。「唤醒」分两个方向：别人拉起你，你拉起别人。&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;App Links&amp;lt;br/&amp;gt;需域名资产验证 体验最好&amp;#34;]
    Q --&amp;gt;|&amp;#34;其他 App / 二维码&amp;#34;| B[&amp;#34;自定义 Scheme&amp;lt;br/&amp;gt;myapp://detail/123&amp;lt;br/&amp;gt;简单但可被抢注&amp;#34;]
    Q --&amp;gt;|&amp;#34;通知点击&amp;#34;| C[&amp;#34;PendingIntent&amp;lt;br/&amp;gt;配 FLAG_IMMUTABLE&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;Manifest 里声明可唤起入口（Android 12+ 必须显式 &lt;code&gt;android:exported&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-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;activity&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:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;.DetailActivity&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:#a6e22e&#34;&gt;android:exported=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;true&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:#a6e22e&#34;&gt;android:launchMode=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;singleTask&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt;   &lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- ⚠️ 见踩坑故事十五 --&amp;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:#f92672&#34;&gt;&amp;lt;intent-filter&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:autoVerify=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;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:#f92672&#34;&gt;&amp;lt;action&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;android.intent.action.VIEW&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;category&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;android.intent.category.DEFAULT&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;category&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;android.intent.category.BROWSABLE&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;data&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:#a6e22e&#34;&gt;android:scheme=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https&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:#a6e22e&#34;&gt;android:host=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ftmao.com&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:#a6e22e&#34;&gt;android:pathPrefix=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;/app&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;/intent-filter&amp;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:#f92672&#34;&gt;&amp;lt;/activity&amp;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;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：App 已在后台时点击链接，打开的还是上次的旧内容；多点几次链接，返回键要按四下才退出去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：默认 &lt;code&gt;launchMode=&amp;quot;standard&amp;quot;&lt;/code&gt; 每次唤醒都新建一个 Activity 实例，叠在任务栈顶（4.2 节的返回栈）；而且热启动时新参数走 &lt;code&gt;onNewIntent()&lt;/code&gt; 回调，&lt;strong&gt;不走 &lt;code&gt;onCreate()&lt;/code&gt;&lt;/strong&gt;——只在 &lt;code&gt;onCreate&lt;/code&gt; 里读 intent 的代码自然漏掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;code&gt;singleTask&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-kotlin&#34; data-lang=&#34;kotlin&#34;&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onCreate&lt;/span&gt;(savedInstanceState: Bundle?) {
&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;.onCreate(savedInstanceState)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    handleDeepLink(intent)
&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;override&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onNewIntent&lt;/span&gt;(intent: Intent) {
&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;.onNewIntent(intent)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    handleDeepLink(intent)   &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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;handleDeepLink&lt;/span&gt;(intent: Intent?) {
&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;val&lt;/span&gt; id = intent&lt;span style=&#34;color:#f92672&#34;&gt;?.&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;data&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;?.&lt;/span&gt;lastPathSegment &lt;span style=&#34;color:#f92672&#34;&gt;?:&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 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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;反方向（你拉起别的 App）的版本坑&lt;/strong&gt;：Android 11 引入&lt;strong&gt;包可见性&lt;/strong&gt;限制，&lt;code&gt;packageManager.getPackageInfo(&amp;quot;com.tencent.mm&amp;quot;)&lt;/code&gt; 会抛 &lt;code&gt;NameNotFoundException&lt;/code&gt;——不是你代码错了，是系统假装微信不存在。需要在 Manifest 里声明 &lt;code&gt;&amp;lt;queries&amp;gt;&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-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;queries&amp;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:#f92672&#34;&gt;&amp;lt;package&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;android:name=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;com.tencent.mm&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&amp;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:#f92672&#34;&gt;&amp;lt;/queries&amp;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;64-平台适配-tips碎片化才是-android-的本体&#34;&gt;6.4 平台适配 tips：碎片化才是 Android 的本体&lt;/h3&gt;
&lt;p&gt;前端适配的是几个浏览器，Android 适配的是「几十种 ROM × 十几个活跃系统版本 × 无数种屏幕」。以下是我项目里反复用到的适配清单：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;屏幕与手势&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;尺寸单位永远 &lt;code&gt;dp&lt;/code&gt;/&lt;code&gt;sp&lt;/code&gt;，别碰 &lt;code&gt;px&lt;/code&gt;；字号用 &lt;code&gt;sp&lt;/code&gt; 才能跟随用户的系统字体设置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android 15 起强制 edge-to-edge&lt;/strong&gt;：内容会画到状态栏和手势导航条下面。用 Compose 的 &lt;code&gt;Scaffold&lt;/code&gt; + &lt;code&gt;Modifier.padding(paddingValues)&lt;/code&gt; 或 &lt;code&gt;WindowInsets&lt;/code&gt; 处理，别自己猜状态栏高度。&lt;/li&gt;
&lt;li&gt;折叠屏和平板：用 &lt;code&gt;WindowSizeClass&lt;/code&gt; 判断屏幕档位切换单栏/双栏布局，别写死「宽度大于 600dp 就是平板」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;深色模式&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;颜色全部走 &lt;code&gt;MaterialTheme.colorScheme&lt;/code&gt;，&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;版本策略&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;minSdk 26&amp;lt;br/&amp;gt;覆盖 95%+ 活跃设备&amp;#34;] --&amp;gt; B[&amp;#34;targetSdk 跟着&amp;lt;br/&amp;gt;Google Play 年度要求走&amp;#34;]
    B --&amp;gt; C[&amp;#34;行为变更按版本处理&amp;lt;br/&amp;gt;Build.VERSION.SDK_INT 分支&amp;#34;]
    C --&amp;gt; D[&amp;#34;能力检测优于版本判断&amp;lt;br/&amp;gt;能查权限就查权限&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事十六：小米上好好的，华为上定时任务就是不执行&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：提醒功能在小米、Pixel 上正常，部分华为、OPPO 用户反馈「到点不提醒」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：国产 ROM 的后台管控比原生 Android 激进得多——自启动权限、电池优化白名单、后台弹出界面权限，&lt;strong&gt;每家厂商的名称、入口、默认策略都不一样&lt;/strong&gt;。原生 API 层的 AlarmManager、前台服务，到了这些 ROM 上随时可能被「省电策略」干掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解法&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;周期性、可延迟的任务用 &lt;strong&gt;WorkManager&lt;/strong&gt;——它是 Google 专门为解决各厂商后台差异做的兼容层，会根据系统能力自动选底层实现。&lt;/li&gt;
&lt;li&gt;必须精确到点的（闹钟类），检测并引导用户跳转对应设置页（&lt;code&gt;Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt; 等），文案讲清楚「不开会怎样」。&lt;/li&gt;
&lt;li&gt;重要消息走 6.2 节说的厂商推送通道，别指望自己保活。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;感悟&lt;/strong&gt;：适配的终极心法只有一句——&lt;strong&gt;不要假设环境，永远做能力检测&lt;/strong&gt;。&lt;code&gt;if (Build.VERSION.SDK_INT &amp;gt;= 33)&lt;/code&gt;、&lt;code&gt;if (checkSelfPermission(...) == GRANTED)&lt;/code&gt;、&lt;code&gt;if (packageManager.resolveActivity(intent) != null)&lt;/code&gt; 这些防御性代码写多了，你就从「在我手机上能跑」毕业到「在所有人手机上能跑」。这也是 Android 开发和前端开发气质上最大的不同：前端的环境是相对可控的浏览器，Android 的环境是几亿台你从没见过的手机。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第七篇-质量与发布从能跑到敢上线&#34;&gt;第七篇 质量与发布：从「能跑」到「敢上线」&lt;/h2&gt;
&lt;p&gt;功能做完只是上半场。这一篇是把 App 交给真实用户之前的最后一道关。&lt;/p&gt;
&lt;h3 id=&#34;71-测试少但要有&#34;&gt;7.1 测试：少但要有&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;Compose 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（ViewModel + 协程，&lt;code&gt;kotlinx-coroutines-test&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-kotlin&#34; data-lang=&#34;kotlin&#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;LoginViewModelTest&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:#a6e22e&#34;&gt;@Test&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;`邮箱格式错误时不发起登录`&lt;/span&gt;() = runTest {
&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;val&lt;/span&gt; vm = LoginViewModel()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        vm.onEmailChange(&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;        vm.login()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        assertEquals(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;邮箱格式不对&amp;#34;&lt;/span&gt;, vm.uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt;.emailError)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        assertFalse(vm.uiState.&lt;span style=&#34;color:#66d9ef&#34;&gt;value&lt;/span&gt;.loading)
&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;Compose UI 测试 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-kotlin&#34; data-lang=&#34;kotlin&#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;LoginScreenTest&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:#a6e22e&#34;&gt;@get&lt;/span&gt;:Rule
&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;val&lt;/span&gt; rule = createComposeRule()
&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:#a6e22e&#34;&gt;@Test&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;fun&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;点击登录按钮触发校验提示&lt;/span&gt;() {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        rule.setContent { LoginScreen() }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        rule.onNodeWithText(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;登录&amp;#34;&lt;/span&gt;).performClick()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        rule.onNodeWithText(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;邮箱格式不对&amp;#34;&lt;/span&gt;).assertIsDisplayed()
&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;：别追求覆盖率数字。给「钱相关的计算、状态机的转移、数据解析」这三类写单元测试，给「登录、主列表、下单」这三条主链路写 UI 测试，投入产出比最高。&lt;/p&gt;
&lt;h3 id=&#34;72-调试三板斧logcatadb-与-layout-inspector&#34;&gt;7.2 调试三板斧：Logcat、adb 与 Layout Inspector&lt;/h3&gt;
&lt;p&gt;遇到问题时的三板斧，按使用频率排：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Logcat&lt;/strong&gt;：分级日志 &lt;code&gt;Log.d/e/w&lt;/code&gt;，配上明确的 TAG。经验：TAG 用类名，日志里带上关键 id（订单号、用户 id），半年后排查线上问题你会感谢自己。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;adb 命令行&lt;/strong&gt;，高频就这几条：&lt;/li&gt;
&lt;/ol&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-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;adb devices                          &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;adb install app-debug.apk            &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;adb shell am start -n 包名/.MainActivity   &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;adb shell am kill 包名                &lt;span style=&#34;color:#75715e&#34;&gt;# 模拟进程死亡（4.1 节测试用）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;adb logcat -s 你的TAG                 &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;ol start=&#34;3&#34;&gt;
&lt;li&gt;&lt;strong&gt;Layout Inspector&lt;/strong&gt;：Compose 时代的「布局透视镜」，实时看每个 Composable 的重组次数——重组次数异常飙升，就是 3.4 节性能问题的第一现场。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;73-国际化与无障碍专业完成度的分水岭&#34;&gt;7.3 国际化与无障碍：专业完成度的分水岭&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;code&gt;res/values/strings.xml&lt;/code&gt;，代码里用 &lt;code&gt;stringResource(R.string.xxx)&lt;/code&gt;，&lt;strong&gt;一个中文字符串都不要硬编码&lt;/strong&gt;——今天不国际化，明天产品说「做个英文版」你就要全文搜索替换。&lt;/li&gt;
&lt;li&gt;复数用 &lt;code&gt;&amp;lt;plurals&amp;gt;&lt;/code&gt;（英文的 1 item / 2 items 规则不同），别用字符串拼接数字。&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;contentDescription&lt;/code&gt;（视障用户的屏幕阅读器 TalkBack 靠它朗读）：&lt;/li&gt;
&lt;/ul&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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;IconButton(onClick = onBack) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    Icon(&lt;span style=&#34;color:#a6e22e&#34;&gt;Icons&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;AutoMirrored&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filled&lt;/span&gt;.ArrowBack, contentDescription = &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;ul&gt;
&lt;li&gt;可点击区域不小于 48dp——Material 组件默认满足，自定义 Canvas 绘制按钮时最容易违规。&lt;/li&gt;
&lt;li&gt;最后打开 TalkBack 把自己的 App 主流程走一遍，十分钟，胜过读十篇规范。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;74-发布签名混淆与-aab&#34;&gt;7.4 发布：签名、混淆与 AAB&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. 签名（Keystore）&lt;/strong&gt;：Android 用签名识别「同一开发者」。生成 release keystore 后&lt;strong&gt;立刻备份到至少两个安全的地方&lt;/strong&gt;——丢了它，你的 App 永远无法更新，只能换包名重新上架，用户和评分清零。这不是理论风险，是每个公司都流传过的真实事故。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 混淆（R8）&lt;/strong&gt;：Release 包默认开启，压缩、优化、混淆代码。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事十七：Debug 包好好的，Release 包登录一直失败&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：Debug 一切正常，打完 Release 包，接口返回的 JSON 解析出来所有字段都是 null。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：R8 把 data class 的字段名混淆成了 &lt;code&gt;a&lt;/code&gt;、&lt;code&gt;b&lt;/code&gt;、&lt;code&gt;c&lt;/code&gt;，而 Gson 默认靠&lt;strong&gt;字段名反射&lt;/strong&gt;匹配 JSON 的 key——名字没了，匹配全落空。&lt;/p&gt;
&lt;p&gt;&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;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-kotlin&#34; data-lang=&#34;kotlin&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 方案 A：给序列化模型加 keep 规则（proguard-rules.pro）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;-keep &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;com&lt;/span&gt;.example.app.&lt;span style=&#34;color:#66d9ef&#34;&gt;data&lt;/span&gt;.model.** { *; }
&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;// 方案 B：换 Moshi/kotlinx-serialization，编译期生成适配器，不靠反射&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;：&lt;strong&gt;所有验证都必须用 Release 包做最后一遍&lt;/strong&gt;。Debug 和 Release 的差异（混淆、压缩、日志）大到足以让「我测过了」变成一句空话——3.4 节的性能测量如此，7.4 节的功能验证同样如此。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 打包 AAB&lt;/strong&gt;：Google Play 要求上传 Android App Bundle（.aab）而非 APK，由 Play 按用户设备分发精简后的安装包。&lt;code&gt;versionCode&lt;/code&gt; 单调递增（系统靠它判断更新），&lt;code&gt;versionName&lt;/code&gt; 给人看。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第八篇-ai-时代怎么少写一半代码把-app-做出来&#34;&gt;第八篇 AI 时代：怎么少写一半代码把 App 做出来&lt;/h2&gt;
&lt;p&gt;前七篇是「内功」，这一篇是「兵器」。我的结论先放在这：&lt;strong&gt;AI 没有让 Android 开发变简单，它让「懂行的人」快了 3-5 倍，让「不懂行的人」更快地写出能跑但烂尾的代码&lt;/strong&gt;。区别在于你怎么用它。&lt;/p&gt;
&lt;h3 id=&#34;81-ai-在-android-开发各环节的胜任度&#34;&gt;8.1 AI 在 Android 开发各环节的胜任度&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 good[&amp;#34;AI 做得极好 放心交出去&amp;#34;]
        A1[&amp;#34;样板代码&amp;lt;br/&amp;gt;ViewModel / data class / Repository&amp;#34;]
        A2[&amp;#34;单页面 UI&amp;lt;br/&amp;gt;给我画一个设置页&amp;#34;]
        A3[&amp;#34;单元测试&amp;#34;]
        A4[&amp;#34;解释报错信息&amp;#34;]
    end
    subgraph mid[&amp;#34;AI 能做 但你要盯着&amp;#34;]
        B1[&amp;#34;跨页面架构改造&amp;#34;]
        B2[&amp;#34;性能优化&amp;#34;]
        B3[&amp;#34;依赖升级适配&amp;#34;]
    end
    subgraph bad[&amp;#34;AI 做不好 还得靠人&amp;#34;]
        C1[&amp;#34;环境/构建问题&amp;lt;br/&amp;gt;(它看不到你的机器)&amp;#34;]
        C2[&amp;#34;真机/特定 ROM 才出现的 bug&amp;#34;]
        C3[&amp;#34;产品取舍&amp;lt;br/&amp;gt;这个交互要不要做&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;82-实战工作流需求--app-的-ai-协作五步法&#34;&gt;8.2 实战工作流：需求 → App 的 AI 协作五步法&lt;/h3&gt;
&lt;p&gt;这是我自己做一个新 App 时的真实流程：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一步：先写「项目宪法」，再让 AI 写代码。&lt;/strong&gt; 在项目里放一个 &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;.cursorrules&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-markdown&#34; data-lang=&#34;markdown&#34;&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;-&lt;/span&gt; Kotlin + Jetpack Compose + Material 3，不用 XML 布局
&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;-&lt;/span&gt; 架构：MVVM + Repository，单向数据流，状态全部走 ViewModel + StateFlow + UiState
&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;-&lt;/span&gt; 网络：Retrofit + OkHttp + 协程，挂起函数返回 ApiResponse&amp;lt;T&amp;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;-&lt;/span&gt; 本地：Room 存结构化数据，DataStore 存键值，定时/后台任务用 WorkManager
&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;-&lt;/span&gt; 图片加载用 Coil，导航用 Navigation Compose
&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;-&lt;/span&gt; 禁止 GlobalScope；禁止硬编码颜色和文案，一律走 MaterialTheme 和 strings.xml
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- Room 改 Entity 必须同步提供 Migration 并升 version&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;没有这份文件，AI 每次生成代码的风格都随机漂移；有了它，AI 的输出稳定得像团队里最守规矩的那个工程师。注意这份清单里的每一条，都对应着前面某一篇的某个坑——&lt;strong&gt;项目宪法本质上是踩坑笔记的浓缩&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：页面级生成，而不是 App 级生成。&lt;/strong&gt; 不要对 AI 说「给我写一个记账 App」，要说「按项目约定写一个记账首页：顶部本月支出汇总卡片，下面是 LazyColumn 账单列表，每条显示分类图标、备注、金额」。一次一个页面、一个模块，AI 的输出质量和你的可控性都最高。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：让它先写数据层和状态，再写 UI。&lt;/strong&gt; 先生成 Entity/DAO/Repository + &lt;code&gt;UiState&lt;/code&gt; + ViewModel，review 通过后再让它写 Composable——这正是第四、五篇的架构顺序。状态设计错了，UI 写得再漂亮也是白写。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四步：编译错误直接喂回去。&lt;/strong&gt; Android Studio 现在集成了 Gemini，编译报错选中 → 问 AI → 应用修复，循环两三次大部分构建问题都能解决。但 1.3、1.4 那种&lt;strong&gt;环境级问题 AI 帮不了你&lt;/strong&gt;——它看不到你本地的 SDK 装在哪个盘；6.4 那种「只在华为手机上复现」的问题它更帮不了——它没有真机。这就是为什么 AI 篇必须排在最后：&lt;strong&gt;它是放大器，放大的是你前七篇的水平&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第五步：人工 review 的四个必查点。&lt;/strong&gt; AI 生成的 Android 代码，我每次都只细查四处：① 协程作用域是否用了 &lt;code&gt;viewModelScope&lt;/code&gt;（内存泄漏重灾区）；② 状态是否放进了 ViewModel 而不是 &lt;code&gt;remember&lt;/code&gt; 在深层 Composable 里（旋转和进程死亡丢数据）；③ 网络/IO 是否切了 &lt;code&gt;Dispatchers.IO&lt;/code&gt;（ANR 来源）；④ 权限和版本分支有没有做能力检测（AI 爱写「假设权限已授予」的代码）。其他代码粗看即可——&lt;strong&gt;review 的精力要花在 AI 的已知短板上&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;83-一个真实对比demo-app-的工时变化&#34;&gt;8.3 一个真实对比：Demo App 的工时变化&lt;/h3&gt;
&lt;p&gt;3.3 节那个登录页 + 一个用户列表页的小 Demo，三年前我要写一整天（查 API、调布局、处理状态）。2026 年用 AI 的实际耗时：&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;pie title 一个双页面 Demo 的 2 小时去哪了
    &amp;#34;写 AGENTS.md 和提示词&amp;#34; : 20
    &amp;#34;AI 生成 + 我 review 修改&amp;#34; : 60
    &amp;#34;环境构建问题排查(AI帮不上)&amp;#34; : 25
    &amp;#34;真机测试与细节打磨&amp;#34; : 15&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;注意那张饼图里最大的启示：&lt;strong&gt;省下来的时间没有消失，它转移到了「描述需求」和「审查代码」上&lt;/strong&gt;。AI 时代的核心能力从「打字速度」变成了两样：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把模糊需求拆成精确技术任务的能力&lt;/strong&gt;——你对第四篇架构的理解越深，给 AI 的指令就越准。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;快速判断代码对错的能力&lt;/strong&gt;——这恰恰要求你懂前七篇讲的所有基本功。看不懂协程作用域的人，review 不了 AI 写的 Android 代码。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;84-思考ai-是放大器不是替代品&#34;&gt;8.4 思考：AI 是放大器，不是替代品&lt;/h3&gt;
&lt;p&gt;每次技术圈出现「AI 能不能取代程序员」的争论，我都想起一个类比：计算器没有取代会计，它淘汰了「只会打算盘的会计」，成就了「用计算器做财务分析的会计」。AI 对 Android 开发者的影响一样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它消灭了&lt;strong&gt;记忆型知识&lt;/strong&gt;的价值（某个 API 的参数顺序，查得再快也不如 AI 直接生成）。&lt;/li&gt;
&lt;li&gt;它放大了&lt;strong&gt;判断型知识&lt;/strong&gt;的价值（这个状态该放 ViewModel 还是 Composable？这个版本冲突该升 AGP 还是降 Gradle？这个定时任务该用 WorkManager 还是 AlarmManager？这次 Room 改表要不要写 Migration？）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这篇文章前七篇的内容，在 AI 时代不但没有过时，反而更重要了——它们是你在 AI 写完代码后，能说「对」或者「不对」的全部依据。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;结语学习路线收个尾&#34;&gt;结语：学习路线收个尾&lt;/h2&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;第 1 周&amp;lt;br/&amp;gt;环境搭建 + 跑通模板&amp;lt;br/&amp;gt;+ Kotlin 语法&amp;#34;] --&amp;gt; B[&amp;#34;第 2-4 周&amp;lt;br/&amp;gt;Compose 基础&amp;lt;br/&amp;gt;写 5 个静态页面&amp;#34;]
    B --&amp;gt; C[&amp;#34;第 2-3 个月&amp;lt;br/&amp;gt;架构 + 网络 + Room&amp;lt;br/&amp;gt;写一个完整的&amp;lt;br/&amp;gt;联网 CRUD App&amp;#34;]
    C --&amp;gt; D[&amp;#34;第 3-6 个月&amp;lt;br/&amp;gt;定位/通知/唤醒/适配&amp;lt;br/&amp;gt;测试 签名 发布上架&amp;#34;]
    D --&amp;gt; E[&amp;#34;持续&amp;lt;br/&amp;gt;把 AI 变成&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;第一周死磕环境不丢人&lt;/strong&gt;。每个 Android 老兵都经历过 Gradle 转圈的下午，这不是你笨，是这个生态的真实成本。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;用真机开发，而且要用不止一台&lt;/strong&gt;。模拟器掩盖了低端机的性能问题，单品牌真机掩盖了 ROM 碎片化问题——你的用户分布在十几种厂商的手机上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让 AI 写代码，让自己写笔记&lt;/strong&gt;。每踩一个坑记下「现象、根因、下次怎么一眼认出」，这些笔记是 AI 时代你不可被替代的资产——因为 AI 知道所有答案，但它不知道&lt;strong&gt;你的项目昨天为什么炸&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;祝你的 Gradle 永远秒同步，keystore 永远有备份，App 在华为和小米上都一样坚挺。&lt;/p&gt;
</description>
    </item>
    <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>
