<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>AI 编程 on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/ai-%E7%BC%96%E7%A8%8B/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Mon, 19 Jan 2026 16:00:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/ai-%E7%BC%96%E7%A8%8B/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>高 bug 率的人该怎么对待：一场项目争论背后的对事、对人与系统</title>
      <link>https://ftmao.com/blog/posts/high-bug-rate-blame-or-system/</link>
      <pubDate>Mon, 19 Jan 2026 16:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/high-bug-rate-blame-or-system/</guid>
      <description>&lt;h1 id=&#34;高-bug-率的人该怎么对待一场项目争论背后的对事对人与系统&#34;&gt;高 bug 率的人该怎么对待：一场项目争论背后的对事、对人与系统&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;引子是一次真实的项目讨论。组里有位同事，bug 率明显比其他人高——高到 QA 看见他的名字心里先咯噔一下。评审会上大家争论怎么处理，冒出来的意见很能代表一个团队的所有本能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;「对事不对人，先别急着怪他，看看是不是模块本身烂。」&lt;/li&gt;
&lt;li&gt;「烂模块别人也在写，怎么就他 bug 多？这就是人的问题，该敲打敲打。」&lt;/li&gt;
&lt;li&gt;「要我说这是架构问题，没测试没 CI，谁写都得出事。」&lt;/li&gt;
&lt;li&gt;「都 2026 年了，他连 AI 辅助 review、AI 生成测试都不用，bug 多不是活该吗？」&lt;/li&gt;
&lt;li&gt;「你们都想简单了，这模块需求天天变、接口文档是过期的，这是设计问题。」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;五种说法，五种药方，互相打架。这场争论当时没有结论，但它逼我把一个问题想透了：&lt;strong&gt;「高 bug 率」根本不是一个问题，而是一组完全不同的问题共用的一个症状。&lt;/strong&gt; 这篇文章就是我后来理出来的完整处理路径：先把症状变成数据，再用方法论找全病根，然后对照行业里的成熟做法，最后才轮到那个最初的问题——对事，还是对人。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一第一步把高-bug-率变成数据&#34;&gt;一、第一步：把「高 bug 率」变成数据&lt;/h2&gt;
&lt;p&gt;「bug 率高」是一个印象，不是事实。争论对错之前，至少要回答三个数据问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;分母是什么？&lt;/strong&gt; 按提交算、按功能点算，还是按代码行算？写核心复杂模块的人和写边角页面的人，天然不在一个 bug 密度量级上。不比单位产出只比绝对数量，是争论里最常见的暗坑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分布长什么样？&lt;/strong&gt; 他的 bug 集中在某个模块，还是均匀散布在他写的所有代码里？&lt;strong&gt;同一个模块换谁写都 bug 多，是系统的问题；同一个人换什么模块都 bug 多，才轮到看人。&lt;/strong&gt; 这一条能砍掉一半的争论。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bug 的类型是什么？&lt;/strong&gt; 是低级错误（空指针、边界、拼写），还是逻辑误解（需求理解错、边界场景没想到），还是集成事故（改了 A 弄坏 B）？三种 bug 指向三种完全不同的病根。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;没有这三组数据，会议上的所有「我觉得」都只是立场。这也引出一个朴素的道理：&lt;strong&gt;对事不对人的第一步，是先把「事」看清楚——多数争论不是因为价值观不同，而是因为大家在争论一个没被测量过的东西。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;二第二步逐条验证五种说法归并成四种病根&#34;&gt;二、第二步：逐条验证，五种说法归并成四种病根&lt;/h2&gt;
&lt;p&gt;拿着数据回到会上的五种说法，逐条对照，会发现它们归并完只剩四种病根：&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;th&gt;正确药方&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;「就是人的问题」&lt;/td&gt;
					&lt;td&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;「架构问题」＋「AI 用得太少」&lt;/td&gt;
					&lt;td&gt;全组在该模块都偏高；逃逸率高（漏到线上才发现）；重复性低级错误占比高、测试覆盖率远低于组均&lt;/td&gt;
					&lt;td&gt;工程系统缺位：无测试、无 CI、高耦合、反馈慢，AI 工具链未流程化&lt;/td&gt;
					&lt;td&gt;补工程系统（含 AI 工具链默认化），不怪任何人&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;「设计问题」&lt;/td&gt;
					&lt;td&gt;bug 集中在「需求理解偏差」类；返工率高&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;/td&gt;
					&lt;td&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;为什么「AI 用得太少」和「架构问题」并成一行？因为用要素维度分解（后文的鱼骨图），AI 工具链属于「机」这一格：团队没把 AI review、AI 生成测试、CI 静态检查做成流程默认项，&lt;strong&gt;这是系统缺位，不是哪个成员的问题&lt;/strong&gt;。只有一种情况它退化回「人」：工具链已经是默认项，而个人拒绝使用——那属于习惯与技能问题，交给后文的 Just Culture 框架处理。&lt;/p&gt;
&lt;p&gt;这个 2026 年的新变量值得多说两句，因为它有正反两面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;正面：「AI 用得不够」首先暴露的是系统问题，不是个人懒惰。&lt;/strong&gt; 如果团队没有默认开启 AI 辅助 review、没有用 AI 生成测试基线，那么「某个成员不用 AI」暴露的是工程系统的缺失——别人不用你也发现不了，只不过他 bug 多先暴露了。正确的动作是把 AI 工具链做成流程默认项，让所有产出都过一遍机器筛子，而不是点名批评谁「不拥抱新技术」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;反面：AI 生成的代码不经 review 直接提交，是 2026 年高 bug 率的新来源。&lt;/strong&gt; 见过不止一个案例，当事人解释「这是 AI 写的，我大意了」——这不能作为免责理由。AI 是放大器，责任心仍是人的。用 AI 放大产出可以，用 AI 稀释 review 不行。规矩要立在团队层面：AI 生成的代码，提交人负全责。&lt;/p&gt;
&lt;p&gt;另外单独提醒「设计问题」：它是最容易被冤枉成「人的问题」的一种。需求一句话、接口靠口头、边界场景靠猜——这种环境下产出的 bug，记在程序员头上是冤案。判断方法很简单：把他的 bug 逐条归因，如果一半以上可以追溯到「需求没说清」，那该改的是需求评审流程，不是他。&lt;/p&gt;
&lt;h2 id=&#34;三第三步病根怎么找全三层归因方法论&#34;&gt;三、第三步：病根怎么找全——三层归因方法论&lt;/h2&gt;
&lt;p&gt;五种说法来自一场会议的即兴发挥，够用吗？不够。它漏掉的病根我在不同团队真实遇到过：排期压垮质量、度量口径本身失真、外部依赖的脏数据、频繁切换任务导致的认知断裂……经验枚举永远有盲区，需要的是一套能&lt;strong&gt;生成&lt;/strong&gt;假设、保证不重不漏的方法论。我用三把刀。&lt;/p&gt;
&lt;h3 id=&#34;第一把刀引入链分解按-bug-的生命周期切&#34;&gt;第一把刀：引入链分解——按 bug 的生命周期切&lt;/h3&gt;
&lt;p&gt;每个 bug 都有完整的生命周期：&lt;strong&gt;需求 → 设计 → 实现 → 验证 → 集成 → 发布 → 运行&lt;/strong&gt;。沿链条逐环问「这一环会不会是病根」，得到一阶的病根空间：&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;/td&gt;
					&lt;td&gt;需求模糊、频繁变更、边界未定义&lt;/td&gt;
					&lt;td&gt;设计问题&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&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;/td&gt;
					&lt;td&gt;人的能力/习惯、AI 生成未审查&lt;/td&gt;
					&lt;td&gt;人的问题&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;验证&lt;/td&gt;
					&lt;td&gt;无测试、review 走过场、AI 工具链缺位&lt;/td&gt;
					&lt;td&gt;架构问题&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&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;/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;会上的说法覆盖了链条的前五环，却漏了最后一环——线上 bug 里有相当比例是环境、数据、依赖造成的，把这类算在程序员头上是彻头彻尾的冤案。&lt;/p&gt;
&lt;h3 id=&#34;第二把刀鱼骨图-6m按要素维度切&#34;&gt;第二把刀：鱼骨图 6M——按要素维度切&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;graph LR
    R[&amp;#34;高 bug 率&amp;#34;] --&amp;gt; M1[&amp;#34;人&amp;lt;br/&amp;gt;能力 / 习惯 / 态度 / 状态&amp;#34;]
    R --&amp;gt; M2[&amp;#34;机&amp;lt;br/&amp;gt;工具链 / AI / 编译检查 / IDE&amp;#34;]
    R --&amp;gt; M3[&amp;#34;料&amp;lt;br/&amp;gt;需求质量 / 文档 / 输入数据&amp;#34;]
    R --&amp;gt; M4[&amp;#34;法&amp;lt;br/&amp;gt;流程 / 评审 / 排期 / 协作方式&amp;#34;]
    R --&amp;gt; M5[&amp;#34;环&amp;lt;br/&amp;gt;环境 / 依赖服务 / 任务切换频率&amp;#34;]
    R --&amp;gt; M6[&amp;#34;测&amp;lt;br/&amp;gt;测试覆盖 / 度量口径 / 统计方法&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;6M 的价值在于它逼你检查&lt;strong&gt;平时想不到的维度&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;法&lt;/strong&gt;里面藏着排期——连续赶工期的团队，bug 率上升是生理现象，不是态度问题；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;环&lt;/strong&gt;里面藏着任务切换——一个人同时被拉在三个项目里，认知断裂本身就是 bug 发生器；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;测&lt;/strong&gt;里面藏着最容易被忽略的一条：&lt;strong&gt;度量口径本身可能就是病根&lt;/strong&gt;。bug 怎么算、谁录入、严不严格，直接扭曲「谁 bug 率高」的结论。度量一旦成为目标就不再是好的度量（古德哈特定律），拿失真的数据去惩人，是双重错误。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两把刀交叉，病根空间基本不重不漏：链条保证流程维度不漏，6M 保证要素维度不漏。&lt;/p&gt;
&lt;h3 id=&#34;第三把刀控制变量把相关性变成归因&#34;&gt;第三把刀：控制变量——把相关性变成归因&lt;/h3&gt;
&lt;p&gt;枚举出假设只是开始，归因要靠实验设计。核心思想是方差分析：bug 率是人、模块、需求、流程的多元函数，&lt;strong&gt;每次只动一个变量，看结果怎么变&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;换人写同模块&lt;/strong&gt;：bug 率不降 → 模块/需求的问题；降了 → 继续往下查人；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一个人写别的模块&lt;/strong&gt;：还是高 → 人的嫌疑上升；正常了 → 原模块的问题；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一需求给两个人分别实现&lt;/strong&gt;：bug 模式相似 → 需求/设计的问题；差异大 → 实现者的问题；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;横向对照组&lt;/strong&gt;：全组在同类模块上的 bug 基线是多少？没有基线，「偏高」二字本身就不成立。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一套换组实验做完，多数「人的问题」会现出系统问题的原形，剩下真正属于人的部分，才轮到第五节处理。&lt;/p&gt;
&lt;p&gt;最后补一个贯穿全程的视角：&lt;strong&gt;每个线上 bug 都是两个失败的乘积——引入失败 × 拦截失败。&lt;/strong&gt; 只问「谁引入的」是在追责，同时问「为什么六道防线一道都没拦住」才是在建设。前者最多改变一个人，后者改变的是所有人的未来 bug 率。&lt;/p&gt;
&lt;h2 id=&#34;四第四步行业里的成熟方法论与工具地图&#34;&gt;四、第四步：行业里的成熟方法论与工具地图&lt;/h2&gt;
&lt;p&gt;三把刀都不是我的发明：引入链分解借自 IBM 的 ODC，鱼骨图来自石川馨，控制变量是实验设计的基本功。行业里的原版比我的速记法成熟，这一节直接对照。&lt;/p&gt;
&lt;h3 id=&#34;归因层odc正交缺陷分类&#34;&gt;归因层：ODC（正交缺陷分类）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ODC（Orthogonal Defect Classification，Chillarege 等人 1992 年提出）&lt;/strong&gt; 的核心思想比「按环节分类」更深一层：把每个缺陷沿&lt;strong&gt;正交维度&lt;/strong&gt;（缺陷类型、触发条件、影响、来源、发现阶段）打标签，然后看&lt;strong&gt;分布特征&lt;/strong&gt;而不是个案——某一类缺陷在某个阶段的分布异常，直接反推哪个流程环节出了系统性问题。缺陷不是待修的孤案，而是流程的测量数据。这套方法在 IBM、电信和航空软件里跑了三十多年。小团队不必全套上 ODC，但最低限度要做到：&lt;strong&gt;缺陷入库时强制填「引入阶段」和「缺陷类型」两个字段&lt;/strong&gt;——没有这两个字段，第一节要的三组数据永远凑不齐。&lt;/p&gt;
&lt;h3 id=&#34;度量层dora-指标与缺陷逃逸率&#34;&gt;度量层：DORA 指标与缺陷逃逸率&lt;/h3&gt;
&lt;p&gt;「bug 率高」的行业标准度量不是我拍脑袋的三个数据问题，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DORA 四键指标&lt;/strong&gt;（《Accelerate》）：其中的&lt;strong&gt;变更失败率&lt;/strong&gt;就是「高 bug 率」最权威的同行参照系——顶级团队与垫底团队的差距是数量级的，而且它和其他三项（部署频率、变更前置时间、恢复时间）联动，单独压 bug 率而不管交付节奏是缘木求鱼；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缺陷逃逸率&lt;/strong&gt;：bug 在哪一环被发现（开发自测 / CI / 测试 / 线上），逃逸越靠后成本越高——这是「拦截失败」的标准度量；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缺陷聚集热力图&lt;/strong&gt;：Microsoft Research 多年的实证结论——&lt;strong&gt;缺陷不是均匀分布的，少数模块集中了多数缺陷&lt;/strong&gt;（与改动频率和复杂度强相关），CodeScene 这类工具可以直接画出热度图。这直接支撑了「换人写同模块」实验的必要性：热点模块的 bug 率天然高，不控变量就是在冤枉人。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;防线层shift-left-与工具链&#34;&gt;防线层：shift-left 与工具链&lt;/h3&gt;
&lt;p&gt;「拦截失败 × 引入失败」的乘积视角，行业里叫 &lt;strong&gt;shift-left（防线左移）&lt;/strong&gt;，配套工具链已经高度成熟：&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;工具（2026 年现网主流）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;需求/设计&lt;/td&gt;
					&lt;td&gt;设计评审、ADR（架构决策记录）、契约先行&lt;/td&gt;
					&lt;td&gt;设计文档模板 + 评审清单&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;实现&lt;/td&gt;
					&lt;td&gt;测试金字塔（Google 的 small/medium/large 分层）、TDD、小步提交&lt;/td&gt;
					&lt;td&gt;JUnit/pytest + 覆盖率（只做参考，&lt;strong&gt;不做 KPI&lt;/strong&gt;——古德哈特定律）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;静态检查&lt;/td&gt;
					&lt;td&gt;静态分析、安全扫描&lt;/td&gt;
					&lt;td&gt;SonarQube、CodeQL、Semgrep、Snyk&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;测试有效性&lt;/td&gt;
					&lt;td&gt;变异测试（测试的测试）、模糊测试&lt;/td&gt;
					&lt;td&gt;PIT（Java）、fuzzing&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;代码审查&lt;/td&gt;
					&lt;td&gt;AI review 设为 CI 默认门禁&lt;/td&gt;
					&lt;td&gt;CodeRabbit（部署最广）、Greptile（全库上下文检出率最高）、Qodo（审查+测试生成）、GitHub Copilot Code Review、Cursor BugBot&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;测试生成&lt;/td&gt;
					&lt;td&gt;AI 生成单测基线&lt;/td&gt;
					&lt;td&gt;Diffblue Cover（Java）、Qodo&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;集成/发布&lt;/td&gt;
					&lt;td&gt;Trunk-Based Development、小 PR、CI 门禁、灰度&lt;/td&gt;
					&lt;td&gt;GitHub Actions / GitLab CI + 特性开关&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两点强调：其一，2026 年的 AI review 工具已经过了尝鲜期——CodeRabbit 接入仓库数以百万计，Greptile 靠全代码库索引拿到最高检出率——&lt;strong&gt;「不配 AI 审查门禁」在今天的含义，和五年前「没有 CI」一样，属于工程系统缺位&lt;/strong&gt;，这印证了第二节把 AI 折进工程系统的判断。其二，覆盖率、检出数这些指标一旦变成 KPI 就失效，它们只能用来找盲区，不能用来排名。&lt;/p&gt;
&lt;h3 id=&#34;机制层错误预算与安灯宽猛相济的工程制度&#34;&gt;机制层：错误预算与安灯——「宽猛相济」的工程制度&lt;/h3&gt;
&lt;p&gt;第六节要讲的「宽与猛怎么配比」，行业里其实早已制度化了，两个现成机制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Google SRE 的错误预算（error budget）&lt;/strong&gt;：服务的可靠性目标不是 100%，预算内随你折腾（宽），预算耗尽立刻冻结发布、全员还债（猛）。&lt;strong&gt;什么时候该宽、什么时候该猛，不再依赖管理者的手感，而是事先约定的数字&lt;/strong&gt;——这是「小惩大诫」最优雅的工程实现：惩戒自动触发、剂量事先封顶、对事不对人。书架上有我的《&lt;a href=&#34;https://ftmao.com/blog/blog/bookstore/google-sre-workbook-summary/&#34;&gt;Google SRE 工作手册&lt;/a&gt;》笔记，错误预算一章值得细看；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;丰田的安灯（andon）与防错（poka-yoke）&lt;/strong&gt;：流水线上任何人发现异常都可以拉灯停线——「对事不对人」不是口号，是给一线员工的制度性权力；防错设计则直接把「人会犯的错」设计成「犯不了的错」，比任何惩戒都高明。bug 率治理的终极目标不是把人管得更紧，而是让低级错误在机制上不可能发生。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这一节的结论是：&lt;strong&gt;归因用 ODC 的思想，度量用 DORA 与逃逸率，防线用 shift-left 工具链，宽猛用错误预算制度化&lt;/strong&gt;——我的「三把刀」只是这些成熟方法论的穷人版速记，团队有条件就应该直接上原版。&lt;/p&gt;
&lt;h2 id=&#34;五第五步轮到人之后just-culture-三分法&#34;&gt;五、第五步：轮到「人」之后——Just Culture 三分法&lt;/h2&gt;
&lt;p&gt;排除了系统性因素，剩下的才轮到「人」——而处理人，最怕一刀切。安全科学里的 Just Culture（公正文化）把行为分成三类，每一类的正确回应完全不同：&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;无心之失&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;侥幸冒险&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;明知故犯 / 屡教不改&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;用「对事不对人」处理第三类，是在纵容——恶意和惰性会学习你的宽容；用「敲打」处理第一类，是在制造恐惧文化——从此 bug 都被藏起来，团队失去学习的输入。&lt;strong&gt;冲突从来不在两种哲学之间，而在用错了对象。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;六更高处的统一系辞与-dalio&#34;&gt;六、更高处的统一：系辞与 Dalio&lt;/h2&gt;
&lt;p&gt;「对事不对人」和「该敲打敲打」这两种看似对立的姿态，恰好各有一部经典背书，而两部经典在更高处说的是同一件事。&lt;/p&gt;
&lt;p&gt;《系辞传》讲「小惩而大诫，此小人之福也」。注意上下文——它的对象是「不威不惩」的小人，即内在荣辱感已经失灵、只有外部后果能触动的人；注意落点——&lt;strong&gt;「福」&lt;/strong&gt;：惩罚是为受罚者本人的长远利益服务的。《系辞》下传还有一句「无有师保，如临父母」：管教的心是父母之心，不是权力之心。&lt;/p&gt;
&lt;p&gt;Dalio《原则》讲对事不对人、极端求真（radical truth）：当面指出问题比背后留情更仁慈，这是 tough love。&lt;/p&gt;
&lt;p&gt;两者在最高处是同一个标准：&lt;strong&gt;评价一次干预，不看「有没有怪罪人」，而看「它有没有让反馈回路闭合、有没有让这个人变得更好」。&lt;/strong&gt; 惩罚如果是为了发泄、为了立威，就同时背叛了系辞和 Dalio。孔子论政说「宽以济猛，猛以济宽，政是以和」——宽（对事不对人）与猛（小惩大诫）不是二选一，是因人因事因时动态配比的阴阳两端：只宽不猛，组织失去边界；只猛不宽，组织失去真话。&lt;/p&gt;
&lt;p&gt;而两者共同信仰的，是《系辞》最看重的那个字——&lt;strong&gt;几&lt;/strong&gt;：「几者，动之微，吉之先见者也。」反馈必须早、必须真、必须被感觉到。Dalio 用真话送达信号，「小惩」用后果送达信号；信道不同，信源相同。等到 bug 率高到要开会讨论才处理，已经是「不俟终日」的反面教材了。&lt;/p&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;graph TD
    A[&amp;#34;有人 bug 率明显偏高&amp;#34;] --&amp;gt; B{&amp;#34;看分布&amp;lt;br/&amp;gt;同一模块换谁写都高?&amp;#34;}
    B -- &amp;#34;是&amp;#34; --&amp;gt; C[&amp;#34;系统问题&amp;lt;br/&amp;gt;补测试 / CI / 解耦 / AI 工具链&amp;lt;br/&amp;gt;不归咎任何人&amp;#34;]
    B -- &amp;#34;否, 集中在他&amp;#34; --&amp;gt; D{&amp;#34;看 bug 类型&amp;#34;}
    D -- &amp;#34;需求理解偏差类居多&amp;#34; --&amp;gt; E[&amp;#34;设计问题&amp;lt;br/&amp;gt;修需求评审与接口契约流程&amp;#34;]
    D -- &amp;#34;低级重复错误居多&amp;#34; --&amp;gt; F[&amp;#34;工程系统缺失&amp;lt;br/&amp;gt;AI review / 测试基线设为流程默认项&amp;#34;]
    D -- &amp;#34;集成事故类居多&amp;#34; --&amp;gt; G[&amp;#34;架构耦合问题&amp;lt;br/&amp;gt;修边界与回归机制&amp;#34;]
    D -- &amp;#34;跨模块一致的逻辑疏忽&amp;#34; --&amp;gt; H{&amp;#34;看人: 无心之失&amp;lt;br/&amp;gt;还是明知故犯 / 屡教不改?&amp;#34;}
    H -- &amp;#34;无心之失&amp;#34; --&amp;gt; I[&amp;#34;教练 + 结对 + 改系统&amp;lt;br/&amp;gt;绝不罚, 保护上报文化&amp;#34;]
    H -- &amp;#34;侥幸冒险 / 屡教不改&amp;#34; --&amp;gt; J[&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;动手惩戒之前，还有最后一道自检，三个问题按顺序问：&lt;/p&gt;
&lt;ol&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; 这一下是为了回路闭合和他的成长，还是为了我的情绪？答不出前者，就不该动。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;八收尾&#34;&gt;八、收尾&lt;/h2&gt;
&lt;p&gt;回到那场争论。会后我做了归因分析，结论有点扫兴也有点解脱：那位同事 60% 的 bug 出在同一个无测试、需求三天一变的老模块——是系统问题；剩下的低级错误里，AI review 能拦掉一大半——是工程系统问题；真正属于他个人的，是重复犯同类边界错误、提醒后照旧——这部分，一次正式的、明确的、带着「这是为你好」的谈话处理了。三个月后，他的 bug 率回到了组内均值。&lt;/p&gt;
&lt;p&gt;所以「对事还是对人」是个伪问题。完整的答案是：&lt;strong&gt;先对事（数据归因），再对系统（方法论找全病根），最后才对人（Just Culture 分类处理）；对事不对人是道的常态，小惩大诫是道的边界——两者的根，都是让反馈回路不失灵。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;延伸阅读：书架上《&lt;a href=&#34;https://ftmao.com/blog/blog/bookstore/principles-dalio-book-notes/&#34;&gt;原则&lt;/a&gt;》笔记里有 Dalio 的完整决策框架，《&lt;a href=&#34;https://ftmao.com/blog/blog/bookstore/thinking-in-systems-book-notes/&#34;&gt;系统之美&lt;/a&gt;》讲透了为什么「人的行为来自系统结构」，《&lt;a href=&#34;https://ftmao.com/blog/blog/bookstore/yijing-64-hexagrams-index/&#34;&gt;系辞传全文与白话译文&lt;/a&gt;》里有「小惩大诫」的原文语境。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>把 Claude Code 用成工程杠杆：落地心得</title>
      <link>https://ftmao.com/blog/posts/claude-code-engineering-practice/</link>
      <pubDate>Sat, 10 Jan 2026 15:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/claude-code-engineering-practice/</guid>
      <description>&lt;h1 id=&#34;把-claude-code-用成工程杠杆落地心得&#34;&gt;把 Claude Code 用成工程杠杆：落地心得&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;我做软件工程 18 年了，在华为和 IBM 做过电信级系统，近四年又一头扎进 AI 工程化，主导过 AI Agent 短视频生成平台和自动化口播剪辑平台。Claude Code 是我最近半年每天都在用的主力工具。它不是替代工程师的魔法，而是一把需要正确握持的杠杆。这篇文章聊聊我怎么把它落地到工程工作流里。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一先思考再输入&#34;&gt;一、先思考，再输入&lt;/h2&gt;
&lt;p&gt;很多人打开 Claude Code 的第一件事就是开始打字。这往往是最贵的错误。&lt;/p&gt;
&lt;p&gt;我现在的习惯是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;写功能前，先想清楚模块边界、数据流、异常路径；&lt;/li&gt;
&lt;li&gt;重构前，先定义&amp;quot;目标形态&amp;quot;是什么；&lt;/li&gt;
&lt;li&gt;调试前，先列出我已知的事实和假设。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后按 &lt;code&gt;Shift+Tab&lt;/code&gt; 两次进入 &lt;strong&gt;Plan 模式&lt;/strong&gt;，把这些思考丢给 Claude。&lt;/p&gt;
&lt;p&gt;Plan 模式不是让 Claude 替我想，而是让它在我已经梳理好的框架里填充细节、发现盲点、提出反例。输入越清晰，输出越可用。这个道理听起来简单，但真正做到的人不多。&lt;/p&gt;
&lt;p&gt;如果你觉得自己还不具备独立想架构的能力，我的建议是：先从描述问题开始，和 Claude 反复对话，让它帮你把模糊的想法磨成可执行的方案。对话是双向的，不是单向许愿。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;二架构描述决定代码质量&#34;&gt;二、架构描述决定代码质量&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;给我做一个认证系统&amp;rdquo; 和 &amp;ldquo;基于现有 User 模型实现邮箱密码登录，会话存 Redis 24 小时过期，给 /api/protected 加中间件&amp;rdquo;，得到的结果天差地别。&lt;/p&gt;
&lt;p&gt;AI 生成代码的问题，往往不在于模型不够聪明，而在于你给的目标太宽泛。自由度太大，模型就会按照它自己的理解去填补，而这些填补常常和你的工程习惯、团队规范、历史包袱不一致。&lt;/p&gt;
&lt;p&gt;我在方糖猫的项目里深有体会。AI Agent 的渲染流水线涉及 Java API 服务、Python Worker、RocketMQ、Remotion 渲染、FFmpeg 后处理多个环节。如果我只说&amp;quot;帮我实现一个视频生成任务调度&amp;quot;，Claude 可能会写出一个看起来合理但完全不符合我们现有架构的方案。但如果我把状态机、消息格式、失败重试策略、SSE 进度推送方式都描述清楚，它就能写出可以直接接入系统的代码。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;好的 Prompt 不是文学，是需求文档的浓缩版。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三claudemd给-ai-的入职培训&#34;&gt;三、CLAUDE.md：给 AI 的入职培训&lt;/h2&gt;
&lt;p&gt;CLAUDE.md 是 Claude Code 会话启动时自动读取的文件。它的作用很像新员工入职手册，但比那更关键：因为它决定了 Claude 在你项目里的&amp;quot;默认行为&amp;quot;。&lt;/p&gt;
&lt;p&gt;我花了点时间打磨自己的 CLAUDE.md，几条原则：&lt;/p&gt;
&lt;h3 id=&#34;1-保持简短&#34;&gt;1. 保持简短&lt;/h3&gt;
&lt;p&gt;Claude 能稳定遵循的指令数量有限，系统提示已经占了一部分。写太多，它只会随机忽略。我的 CLAUDE.md 一般不超过两屏。&lt;/p&gt;
&lt;h3 id=&#34;2-只写项目特有信息&#34;&gt;2. 只写项目特有信息&lt;/h3&gt;
&lt;p&gt;不要解释&amp;quot;组件目录是什么&amp;quot;这种通用知识。要写真正影响决策的东西：关键 bash 命令、构建脚本、测试命令、提交规范、技术约束。&lt;/p&gt;
&lt;h3 id=&#34;3-写为什么不只是做什么&#34;&gt;3. 写&amp;quot;为什么&amp;quot;，不只是&amp;quot;做什么&amp;quot;&lt;/h3&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;差：&amp;ldquo;使用 Java 17&amp;rdquo;。&lt;/li&gt;
&lt;li&gt;好：&amp;ldquo;使用 Java 17，因为部分依赖在 Java 21 下存在兼容性问题，计划在 Q4 统一升级。&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;原因能让 Claude 在你没想到的地方做出正确判断。&lt;/p&gt;
&lt;h3 id=&#34;4-持续更新&#34;&gt;4. 持续更新&lt;/h3&gt;
&lt;p&gt;工作时长按 &lt;code&gt;#&lt;/code&gt; 键，Claude 会自动把指令写进 CLAUDE.md。我的标准是：当我第二次纠正同一个问题时，就把它固化为规则。&lt;/p&gt;
&lt;p&gt;坏的 CLAUDE.md 像写给新人的文档；好的 CLAUDE.md 像写给&amp;quot;未来失忆的自己&amp;quot;的笔记。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四上下文窗口不是越用越好&#34;&gt;四、上下文窗口不是越用越好&lt;/h2&gt;
&lt;p&gt;Opus 4.5 的上下文窗口很长，但很多人忽略了一个事实：&lt;strong&gt;上下文用到 20%–40% 时，输出质量就开始下降了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;每次消息、每个文件、每段生成代码、每次工具输出都会累积。等会话变得臃肿，即使 &lt;code&gt;/compact&lt;/code&gt; 也救不回来，因为能力已经劣化了。&lt;/p&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;code&gt;SCRATCHPAD.md&lt;/code&gt; 或 &lt;code&gt;plan.md&lt;/code&gt;，把中间结论落地到文件系统。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复制-压缩-清空-粘贴法&lt;/strong&gt;：重要信息复制出来，&lt;code&gt;/compact&lt;/code&gt; 总结，再 &lt;code&gt;/clear&lt;/code&gt;，只粘回关键内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;知道何时清空&lt;/strong&gt;：会话一旦混乱，直接 &lt;code&gt;/clear&lt;/code&gt;。CLAUDE.md 还在，项目规则不会丢。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;正确的心智模型是：&lt;strong&gt;把 Claude 当成无状态的&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;p&gt;有效的提示通常包含三层：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;要做什么&lt;/strong&gt;：明确任务目标；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要做什么&lt;/strong&gt;：Claude 喜欢过度工程化，必须明确限制；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;约束和原因&lt;/strong&gt;：是原型还是生产代码？性能敏感还是可读性优先？为什么？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;比如我会这样写：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;请为 &lt;code&gt;VideoRenderWorker&lt;/code&gt; 添加一个重试机制。要求：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;最多重试 3 次，使用指数退避；&lt;/li&gt;
&lt;li&gt;只对临时性错误重试，业务逻辑错误直接失败；&lt;/li&gt;
&lt;li&gt;不要引入新的依赖，使用现有的 Spring Retry 配置；&lt;/li&gt;
&lt;li&gt;这是生产代码，需要单元测试覆盖。&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;这样的输入，输出基本可以直接进入 Code Review。&lt;/p&gt;
&lt;p&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;Claude 的不同模型有明确的分工：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sonnet&lt;/strong&gt;：更快更便宜，适合执行明确的编码任务；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Opus&lt;/strong&gt;：更慢更贵，适合复杂规划、架构设计、深度推理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我的常用模式是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;用 &lt;strong&gt;Opus&lt;/strong&gt; 做架构规划和复杂方案设计；&lt;/li&gt;
&lt;li&gt;用 &lt;strong&gt;Sonnet&lt;/strong&gt; 执行实现、补全代码、跑测试；&lt;/li&gt;
&lt;li&gt;用 &lt;strong&gt;CLAUDE.md&lt;/strong&gt; 保证模型切换后仍然遵循相同的项目规则。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这和团队里让资深架构师画蓝图、让工程师写代码是一个道理。Claude Code 里切换模型成本很低，但前提是你知道什么任务该用什么模型。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七mcphooksslash-命令与-skills&#34;&gt;七、MCP、Hooks、Slash 命令与 Skills&lt;/h2&gt;
&lt;p&gt;Claude Code 的工具生态值得花时间探索。除了前面提到的 MCP 和 Hooks，&lt;strong&gt;Skills（技能）&lt;/strong&gt; 是我最近用得越来越多的能力，它把常用工作流封装成可复用的指令包。&lt;/p&gt;
&lt;h3 id=&#34;1-mcphooks-和-slash-命令&#34;&gt;1. MCP、Hooks 和 Slash 命令&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MCP&lt;/strong&gt;：连接外部服务。如果你还在手动复制粘贴信息进 Claude，很可能 MCP 能帮你自动化。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hooks&lt;/strong&gt;：在 Claude 修改代码前后执行操作，比如自动格式化、类型检查、跑 lint。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自定义 Slash 命令&lt;/strong&gt;：把常用的提示词模板化，避免每次重复输入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我在项目里配了几个常用 Slash 命令：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/review&lt;/code&gt;：让 Claude 按项目规范做 Code Review；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/test&lt;/code&gt;：为当前修改生成单元测试；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/doc&lt;/code&gt;：为新增接口补全文档。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;2-skills把个人经验变成团队能力&#34;&gt;2. Skills：把个人经验变成团队能力&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Skill 是什么？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Skill 是一个 Markdown 文件（通常是 &lt;code&gt;SKILL.md&lt;/code&gt;），带有 YAML frontmatter。它描述了一个完整的工作流、判断标准或领域知识。Claude 会在合适的时机自动加载它，或者你可以通过 &lt;code&gt;/skill &amp;lt;name&amp;gt;&lt;/code&gt; 手动调用。&lt;/p&gt;
&lt;p&gt;你可以把 Skill 理解为&amp;quot;可插拔的专长&amp;quot;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; 是项目通用的&amp;quot;入职培训&amp;quot;，始终加载；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SKILL.md&lt;/code&gt; 是按需加载的&amp;quot;专项手册&amp;quot;，只在匹配的场景触发。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Skill 放在哪里？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;有两种常见位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;个人级&lt;/strong&gt;：&lt;code&gt;~/.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;，跨项目可用；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;项目级&lt;/strong&gt;：&lt;code&gt;.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;，跟随仓库走，团队成员共享。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我推荐项目级 Skill，因为团队规范应该和代码放在一起，而不是依赖每个人的本地配置。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个最小 SKILL.md 示例：&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-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;name: spring-api-review
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;description: 当用户请求对 Spring Boot API 代码进行 Review 时调用此 skill。
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;# Spring Boot API Review 规范
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#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;2.&lt;/span&gt; 服务层是否处理业务逻辑，而不是直接操作 HTTP 上下文？
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;3.&lt;/span&gt; DTO 是否有清晰的入参/出参边界？
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;4.&lt;/span&gt; 异常是否统一处理，而不是在每个方法里 try-catch？
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;5.&lt;/span&gt; 是否有对应的单元测试覆盖核心分支？
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;-&lt;/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; 问题描述
&lt;/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; 风险等级：高 / 中 / 低
&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;description&lt;/code&gt; 字段非常关键。Claude 会根据它判断何时自动加载这个 Skill。描述越具体，触发越准确。&lt;/p&gt;
&lt;h3 id=&#34;3-有哪些值得尝试的-skill&#34;&gt;3. 有哪些值得尝试的 Skill？&lt;/h3&gt;
&lt;p&gt;Claude Code 本身不内置大量固定 Skill，但社区已经沉淀了不少高质量 Skill 包。以下是我觉得对工程团队最有价值的几个方向：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Skill / Skill 包&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;gstack&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;比较完整的一套工程 Skill 集合，覆盖 review、qa、investigate、ship、canary、retro 等&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/review&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;预着陆 PR 审查，检查 SQL 注入、LLM 信任边界、副作用等结构性问题&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/qa&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;系统化 QA 测试，发现 bug 后自动修复并验证&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/investigate&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;系统性根因排查，要求&amp;quot;无根因不修复&amp;quot;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/ship&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;合并主分支、跑测试、生成 PR 的发布工作流&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/canary&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;部署后监控异常、性能回归、页面失败&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;/retro&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;基于提交历史做工程复盘&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;strong&gt;heliohq/ship&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;更严格的分阶段流水线：spec → dev → E2E → review → QA → refactor → handoff&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;strong&gt;Cavestack&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;强调&amp;quot;Hero Six&amp;quot;核心 Skill，包括 office-hours、investigate、plan-eng-review 等&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;我个人用得比较多的是 &lt;code&gt;/review&lt;/code&gt;、&lt;code&gt;/investigate&lt;/code&gt; 和 &lt;code&gt;/ship&lt;/code&gt;。它们不是替代人的判断，而是把重复性的检查清单和流程自动化，让我把精力放在真正需要决策的地方。&lt;/p&gt;
&lt;h3 id=&#34;4-怎么创建自己的-skill&#34;&gt;4. 怎么创建自己的 Skill？&lt;/h3&gt;
&lt;p&gt;创建 Skill 的步骤很简单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&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-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;mkdir -p .claude/skills/my-skill&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;写 SKILL.md&lt;/strong&gt;：包含 name、description、用途、步骤、输出格式、示例和约束。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;测试触发&lt;/strong&gt;：开一个新会话，描述一个匹配的场景，看 Claude 是否自动加载。如果没加载，调整 description 让它更具体。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;迭代优化&lt;/strong&gt;：使用一段时间后，把新发现的问题和约束补充进去。和 CLAUDE.md 一样，Skill 也是需要持续维护的。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我的建议是：不要一开始就写很多 Skill。先观察团队里哪些工作流是重复的、哪些判断标准是大家口头传颂的，把最常见的两三个固化成 Skill。质量比数量重要。&lt;/p&gt;
&lt;h3 id=&#34;5-skillclaudemdhooks-的分工&#34;&gt;5. Skill、CLAUDE.md、Hooks 的分工&lt;/h3&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;CLAUDE.md&lt;/td&gt;
					&lt;td&gt;项目通用规则，始终加载&lt;/td&gt;
					&lt;td&gt;每次会话启动&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;SKILL.md&lt;/td&gt;
					&lt;td&gt;专项工作流，按需加载&lt;/td&gt;
					&lt;td&gt;匹配描述时自动加载，或 &lt;code&gt;/skill&lt;/code&gt; 手动调用&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Hooks&lt;/td&gt;
					&lt;td&gt;代码修改前后的强制检查&lt;/td&gt;
					&lt;td&gt;触发编辑操作时&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;MCP&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;这几层叠加起来，才能把 Claude Code 从一个&amp;quot;聊天工具&amp;quot;变成符合团队工程规范的&amp;quot;协作系统&amp;quot;。&lt;/p&gt;
&lt;p&gt;这些小投入会不断复利。模型每周都在变好，但你的工具链、Skill 库和流程才是属于自己的护城河。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;八当-claude-卡住时换策略&#34;&gt;八、当 Claude 卡住时，换策略&lt;/h2&gt;
&lt;p&gt;Claude 有时会陷入循环：改了 A 导致 B 错，改了 B 又导致 A 错。这时候继续补充说明往往没用。&lt;/p&gt;
&lt;p&gt;我常用的策略：&lt;/p&gt;
&lt;ol&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;：&amp;ldquo;参考 &lt;code&gt;UserService.java&lt;/code&gt; 里的错误处理模式&amp;rdquo; 比 &amp;ldquo;请优雅地处理异常&amp;rdquo; 有效得多；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;换种表述方式&lt;/strong&gt;：同一个需求用不同结构说一遍，有时能绕过模型的固化模式。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;关键技能不是让 Claude 永远不出错，而是&lt;strong&gt;尽早识别循环，并及时切换策略&lt;/strong&gt;。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九构建系统而不是一次性工具&#34;&gt;九、构建系统，而不是一次性工具&lt;/h2&gt;
&lt;p&gt;Claude Code 最大的价值，不在于帮你完成单次任务，而在于把它嵌入工程系统。&lt;/p&gt;
&lt;p&gt;它支持 headless 模式（&lt;code&gt;-p&lt;/code&gt;），可以脚本化、自动化、串联命令。企业已经在用它做：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动审 PR；&lt;/li&gt;
&lt;li&gt;处理支持请求；&lt;/li&gt;
&lt;li&gt;更新文档；&lt;/li&gt;
&lt;li&gt;批量重构；&lt;/li&gt;
&lt;li&gt;生成测试用例。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们的飞轮是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Claude 出错 → 改进 CLAUDE.md 或工具链 → 下次更好。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;几个月后，即使模型没变，整个系统也会显著提升。这就是复利。&lt;/p&gt;
&lt;p&gt;如果你只把 Claude Code 当成一个交互式聊天工具，你正在浪费它的大部分价值。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十写给老工程师的话&#34;&gt;十、写给老工程师的话&lt;/h2&gt;
&lt;p&gt;用了 Claude Code 一段时间后，我发现一个有意思的现象：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;经验丰富的工程师反而更容易用好它。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因为你知道什么是好的架构，什么是坏的妥协，什么是可以自动化的重复劳动，什么是必须人工把关的关键决策。Claude Code 不会替你承担责任，但它能把你的判断力和工程经验放大很多倍。&lt;/p&gt;
&lt;p&gt;对我来说，它最大的帮助不是写代码更快，而是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把重复性工作交给它，我腾出时间思考架构；&lt;/li&gt;
&lt;li&gt;用它快速验证想法，减少原型阶段的沉没成本；&lt;/li&gt;
&lt;li&gt;通过 CLAUDE.md 和工具链，把个人经验沉淀为团队能力。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;总结&#34;&gt;总结&lt;/h2&gt;
&lt;p&gt;把 Claude Code 用好，核心就几句话：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先思考，再输入；&lt;/li&gt;
&lt;li&gt;架构描述决定代码质量；&lt;/li&gt;
&lt;li&gt;CLAUDE.md 是杠杆点；&lt;/li&gt;
&lt;li&gt;上下文在用到 30% 时就开始劣化；&lt;/li&gt;
&lt;li&gt;提示词是沟通，输出来自输入；&lt;/li&gt;
&lt;li&gt;让 Opus 规划，让 Sonnet 执行；&lt;/li&gt;
&lt;li&gt;善用 MCP、Hooks、Slash 命令，更要善用 Skills；&lt;/li&gt;
&lt;li&gt;卡住时换策略；&lt;/li&gt;
&lt;li&gt;构建系统，而不是只做一次性任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现代 AI 工具已经强得离谱。你是在和工具对抗，还是顺势而行，往往取决于这些细节。&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;我在 ftmao.com/blog 持续记录 AI 工程、Agent 设计与 Claude Code 等工具的落地实践，欢迎关注和交流。&lt;/p&gt;
&lt;/blockquote&gt;
</description>
    </item>
    <item>
      <title>Claude Code 是怎么运行的？给新手的 AI 编程代理入门指南</title>
      <link>https://ftmao.com/blog/posts/claude-code-principles-for-beginners/</link>
      <pubDate>Fri, 09 Jan 2026 10:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/claude-code-principles-for-beginners/</guid>
      <description>&lt;h1 id=&#34;claude-code-是怎么运行的给新手的-ai-编程代理入门指南&#34;&gt;Claude Code 是怎么运行的？给新手的 AI 编程代理入门指南&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;参考：&lt;a href=&#34;https://github.com/shareAI-lab/learn-claude-code&#34;&gt;shareAI-lab/learn-claude-code&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;你有没有想过，为什么你跟 Claude Code 说“帮我改一下这个网页”，它就能自己打开文件、修改代码、运行测试，然后告诉你结果？&lt;/p&gt;
&lt;p&gt;它看起来好聪明，像人一样会“动手”。但其实，它背后的原理并不神秘，甚至可以用几十行简单的代码讲清楚。&lt;/p&gt;
&lt;p&gt;这篇文章是给新手的。不管你有没有学过编程，我都会尽量用生活中的例子来解释。读完之后，你会明白：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Claude Code 最核心的部分是什么；&lt;/li&gt;
&lt;li&gt;它是怎么“看懂”文件、“动手”改代码的；&lt;/li&gt;
&lt;li&gt;它为什么能记住很多事情、做错事能重来；&lt;/li&gt;
&lt;li&gt;以及如果你想自己做一个类似的 AI 助手，应该从哪里开始。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;一一句话讲清楚-claude-code&#34;&gt;一、一句话讲清楚 Claude Code&lt;/h2&gt;
&lt;p&gt;如果把 Claude Code 拆开，你会发现它只有三个东西：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个循环 + 一张工具表 + 一个会说话的 AI&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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;会说话的 AI（大语言模型）&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;/td&gt;
					&lt;td&gt;让 AI 能读写文件、运行命令、搜索代码&lt;/td&gt;
					&lt;td&gt;给大脑装上双手和眼睛&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&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;这个“循环”非常重要。没有它，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;flowchart LR
    subgraph 工具箱[&amp;#34;🧰 工具箱（Harness）&amp;#34;]
        读文件[&amp;#34;读文件&amp;#34;]
        写文件[&amp;#34;写文件&amp;#34;]
        执行命令[&amp;#34;执行命令&amp;#34;]
        搜索[&amp;#34;搜索代码&amp;#34;]
        权限[&amp;#34;权限检查&amp;#34;]
    end

    大脑[&amp;#34;🧠 AI 大脑&amp;lt;br/&amp;gt;会思考、会推理&amp;#34;]
    电脑[&amp;#34;💻 你的电脑&amp;#34;]

    大脑 --&amp;gt;|我想读文件| 读文件
    读文件 --&amp;gt;|文件内容| 大脑
    大脑 --&amp;gt;|我想运行测试| 执行命令
    执行命令 --&amp;gt; 电脑
    电脑 --&amp;gt;|测试结果| 大脑
    权限 --&amp;gt;|这个能做吗？| 执行命令&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;二最核心的-30-行代码&#34;&gt;二、最核心的 30 行代码&lt;/h2&gt;
&lt;p&gt;Claude Code 的心脏，是一段很简单的代码。你可以把它理解成“机器人干活的主流程”：&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;agent_loop&lt;/span&gt;(query):
&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. 先把用户的问题记下来&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    messages &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; [{&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;role&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;content&amp;#34;&lt;/span&gt;: query}]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;while&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;# 2. 把对话历史发给 AI，告诉它有哪些工具可以用&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        response &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;messages&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;create(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            model&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;MODEL,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            system&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;SYSTEM,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            messages&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;messages,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            tools&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;TOOLS,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            max_tokens&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;8000&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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. 把 AI 的回复存进历史&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        messages&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;append({&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;role&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;assistant&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;content&amp;#34;&lt;/span&gt;: response&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;content})
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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. 如果 AI 不再调用工具，就说明它说完了，结束循环&lt;/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; response&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;stop_reason &lt;span style=&#34;color:#f92672&#34;&gt;!=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tool_use&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;        &lt;span style=&#34;color:#75715e&#34;&gt;# 5. 执行 AI 要求的工具，并把结果告诉它&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        results &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;for&lt;/span&gt; block &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; response&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;content:
&lt;/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; block&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;type &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tool_use&amp;#34;&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                output &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; run_tool(block&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;name, block&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;input)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                results&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;append({
&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;type&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;tool_result&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;tool_use_id&amp;#34;&lt;/span&gt;: block&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;id,
&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;content&amp;#34;&lt;/span&gt;: output,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                })
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        messages&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;append({&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;role&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;content&amp;#34;&lt;/span&gt;: results})&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;人话解释&#34;&gt;人话解释&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;你问了一个问题&lt;/strong&gt; → 比如“帮我修一下这个 bug”。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI 看历史和工具表&lt;/strong&gt; → 它想：“我需要先读一下代码文件。”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI 说“我要用读文件工具”&lt;/strong&gt; → 程序真的去打开文件。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把文件内容还给 AI&lt;/strong&gt; → AI 再看，决定下一步。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一直重复，直到 AI 说“我搞定了”&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    开始([你输入任务]) --&amp;gt; 发给AI[把任务+历史发给 AI]
    发给AI --&amp;gt; AI思考[AI 思考]
    AI思考 --&amp;gt; 判断{AI 想调用工具吗？}
    判断 --&amp;gt;|想| 执行工具[程序执行工具]
    判断 --&amp;gt;|不想| 输出答案[输出最终答案]
    执行工具 --&amp;gt; 返回结果[把结果返回给 AI]
    返回结果 --&amp;gt; 发给AI
    输出答案 --&amp;gt; 结束([结束])&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;三20-节课都在讲什么一张总图看懂&#34;&gt;三、20 节课都在讲什么？一张总图看懂&lt;/h2&gt;
&lt;p&gt;Claude Code 的学习资料里有 &lt;strong&gt;20 节课&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 第一阶段[&amp;#34;🌱 第一阶段：让 AI 动起来&amp;#34;]
        s01[&amp;#34;s01 循环&amp;#34;]
        s02[&amp;#34;s02 工具&amp;#34;]
        s03[&amp;#34;s03 权限&amp;#34;]
        s04[&amp;#34;s04 钩子&amp;#34;]
    end

    subgraph 第二阶段[&amp;#34;🛠️ 第二阶段：做复杂任务&amp;#34;]
        s05[&amp;#34;s05 计划&amp;#34;]
        s06[&amp;#34;s06 子代理&amp;#34;]
        s07[&amp;#34;s07 技能&amp;#34;]
        s08[&amp;#34;s08 压缩记忆&amp;#34;]
    end

    subgraph 第三阶段[&amp;#34;🧠 第三阶段：记得住、错得了&amp;#34;]
        s09[&amp;#34;s09 记忆&amp;#34;]
        s10[&amp;#34;s10 系统提示&amp;#34;]
        s11[&amp;#34;s11 错误恢复&amp;#34;]
    end

    subgraph 第四阶段[&amp;#34;⏳ 第四阶段：长时间运行&amp;#34;]
        s12[&amp;#34;s12 任务图&amp;#34;]
        s13[&amp;#34;s13 后台任务&amp;#34;]
        s14[&amp;#34;s14 定时任务&amp;#34;]
    end

    subgraph 第五阶段[&amp;#34;👥 第五阶段：团队协作&amp;#34;]
        s15[&amp;#34;s15 队友&amp;#34;]
        s16[&amp;#34;s16 团队协议&amp;#34;]
        s17[&amp;#34;s17 自治&amp;#34;]
        s18[&amp;#34;s18 目录隔离&amp;#34;]
    end

    subgraph 第六阶段[&amp;#34;🔌 第六阶段：扩展组装&amp;#34;]
        s19[&amp;#34;s19 MCP 插件&amp;#34;]
        s20[&amp;#34;s20 完整系统&amp;#34;]
    end

    第一阶段 --&amp;gt; 第二阶段 --&amp;gt; 第三阶段 --&amp;gt; 第四阶段 --&amp;gt; 第五阶段 --&amp;gt; 第六阶段&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;下面拆解核心部分。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;四第一阶段让-ai-动起来&#34;&gt;四、第一阶段：让 AI 动起来&lt;/h2&gt;
&lt;h3 id=&#34;s01-agent-loop一个循环就够了&#34;&gt;s01 Agent Loop：一个循环就够了&lt;/h3&gt;
&lt;p&gt;这是所有东西的基础。就像你骑自行车，核心是“踩踏板 → 车轮转 → 车前进”这个循环。Claude Code 的核心就是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;AI 思考 → 调用工具 → 看结果 → 再思考 → 再调用工具 → …… → 完成&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;没有循环，AI 只能说一句话。有了循环，它就能连续做很多步。&lt;/p&gt;
&lt;h3 id=&#34;s02-tool-use从一把螺丝刀到整套工具箱&#34;&gt;s02 Tool Use：从一把螺丝刀到整套工具箱&lt;/h3&gt;
&lt;p&gt;最早的 AI 只有一个工具：执行终端命令（bash）。但这样很不方便，就像你修自行车只能用一个螺丝刀。&lt;/p&gt;
&lt;p&gt;后来 Claude Code 给 AI 配了一整套工具：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;read_file&lt;/code&gt;：读文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;write_file&lt;/code&gt;：写文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edit_file&lt;/code&gt;：改文件中的一部分&lt;/li&gt;
&lt;li&gt;&lt;code&gt;glob&lt;/code&gt;：按名字找文件&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grep&lt;/code&gt;：在文件里搜索文字&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些工具是“分发表”管理的。程序不用写一堆 if/else，而是查一个字典：&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;TOOL_HANDLERS &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:#e6db74&#34;&gt;&amp;#34;read_file&amp;#34;&lt;/span&gt;:  read_file_function,
&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;write_file&amp;#34;&lt;/span&gt;: write_file_function,
&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;edit_file&amp;#34;&lt;/span&gt;:  edit_file_function,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;# AI 说想用 read_file，就找到对应的函数执行&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;handler &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; TOOL_HANDLERS[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;read_file&amp;#34;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;result &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; handler(path&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;main.py&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;&lt;strong&gt;好处&lt;/strong&gt;：加新工具就像往工具箱里多放一把扳手，不用改主流程。&lt;/p&gt;
&lt;h3 id=&#34;s03-permission先问能不能做再让它做&#34;&gt;s03 Permission：先问能不能做，再让它做&lt;/h3&gt;
&lt;p&gt;这是 Claude Code 很重要的一课：&lt;strong&gt;自由要有边界&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;AI 能力很强，但它不能想删啥就删啥、想改啥就改啥。权限系统就像家长给孩子定规矩：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读文件可以；&lt;/li&gt;
&lt;li&gt;修改重要文件要问一下；&lt;/li&gt;
&lt;li&gt;删除整个文件夹？不行。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    AI想做事[AI 想执行命令] --&amp;gt; 查权限{权限系统检查}
    查权限 --&amp;gt;|允许| 直接执行
    查权限 --&amp;gt;|需要确认| 问用户[弹窗问用户]
    查权限 --&amp;gt;|禁止| 拒绝[拒绝执行]
    问用户 --&amp;gt;|同意| 直接执行
    问用户 --&amp;gt;|不同意| 拒绝&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;s04-hooks在循环边上挂挂钩&#34;&gt;s04 Hooks：在循环边上挂“挂钩”&lt;/h3&gt;
&lt;p&gt;Hook 中文叫“钩子”。想象你在书包上挂很多小挂件：钥匙扣、饭卡、耳机。主书包不变，但你可以加东西。&lt;/p&gt;
&lt;p&gt;Claude Code 的 Hook 系统也是这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;工具执行前，可以检查一下（PreToolUse）；&lt;/li&gt;
&lt;li&gt;工具执行后，可以记录日志（PostToolUse）；&lt;/li&gt;
&lt;li&gt;会话开始/结束时，可以做初始化或清理。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    AI决定[AI 决定调用工具] --&amp;gt; 前钩子[&amp;#34;PreToolUse 钩子&amp;lt;br/&amp;gt;检查/拦截&amp;#34;]
    前钩子 --&amp;gt; 执行工具
    执行工具 --&amp;gt; 后钩子[&amp;#34;PostToolUse 钩子&amp;lt;br/&amp;gt;记录/通知&amp;#34;]
    后钩子 --&amp;gt; 返回AI&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;核心思想&lt;/strong&gt;：主循环不要改来改去，把扩展功能挂到钩子上。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五第二阶段做复杂任务&#34;&gt;五、第二阶段：做复杂任务&lt;/h2&gt;
&lt;h3 id=&#34;s05-todowrite先做计划再动手&#34;&gt;s05 TodoWrite：先做计划，再动手&lt;/h3&gt;
&lt;p&gt;如果你要写一篇大作文，你会先列提纲，对吧？Claude Code 也是这样。&lt;/p&gt;
&lt;p&gt;做复杂任务前，AI 会先写一个待办清单（Todo）：&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;1. 读 README 了解项目      [待做]
2. 找到 bug 所在的文件     [进行中]
3. 修改代码                [待做]
4. 运行测试确认修好        [待做]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;每完成一步，就更新状态。如果 AI 太久没更新，系统还会“唠叨”它：“喂，你忘了更新待办清单啦！”&lt;/p&gt;
&lt;p&gt;这个机制让 AI 不容易“跑偏”。&lt;/p&gt;
&lt;h3 id=&#34;s06-subagent雇一个小助手&#34;&gt;s06 Subagent：雇一个小助手&lt;/h3&gt;
&lt;p&gt;有时候一个任务需要查很多东西。比如“这个项目的测试框架是什么？”AI 可能要读 10 个文件才能确定答案是 &lt;code&gt;pytest&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;如果让主 AI 自己读 10 个文件，它的“脑子”会塞满东西，又慢又贵。&lt;/p&gt;
&lt;p&gt;解决办法是：&lt;strong&gt;雇一个临时小助手（子代理）&lt;/strong&gt;。小助手用全新的对话去读那 10 个文件，最后只把答案“pytest”告诉主 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;flowchart LR
    主AI[&amp;#34;主 AI&amp;#34;] --&amp;gt;|派任务| 子AI[&amp;#34;子 AI&amp;lt;br/&amp;gt;新开一个对话&amp;#34;]
    子AI --&amp;gt;|读10个文件| 文件[&amp;#34;项目文件&amp;#34;]
    文件 --&amp;gt;|内容| 子AI
    子AI --&amp;gt;|只返回结论：pytest| 主AI&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;主 AI 的脑子很干净，只记住了最重要的结论。&lt;/p&gt;
&lt;h3 id=&#34;s07-skill-loading需要哪本字典就翻哪本&#34;&gt;s07 Skill Loading：需要哪本字典，就翻哪本&lt;/h3&gt;
&lt;p&gt;想象你要学数学、物理、化学三科。你不会每次上课都把三本书全背上，而是上什么课带什么书。&lt;/p&gt;
&lt;p&gt;Claude Code 也是这样。它有很多“技能书”（比如 git 用法、测试规范、API 文档），但不会一次性全塞给 AI。&lt;/p&gt;
&lt;p&gt;它是两层加载：&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;：AI 说“我要查 git 那本书”，系统才把完整内容给它。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样就省了很多“脑容量”。&lt;/p&gt;
&lt;h3 id=&#34;s08-context-compact脑子塞不下时怎么办&#34;&gt;s08 Context Compact：脑子塞不下时怎么办？&lt;/h3&gt;
&lt;p&gt;AI 一次能看的文字是有限的，就像你的作业本只有那么多页。如果写得太多，就要翻页或者撕掉旧的。&lt;/p&gt;
&lt;p&gt;Claude Code 有三种“翻页”方法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;micro_compact&lt;/strong&gt;：把很久以前的工具结果换成一句话摘要，比如“之前用过 read_file”；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;auto_compact&lt;/strong&gt;：当文字太多时，自动让 AI 写个总结，用总结替换原来的长对话；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;compact 工具&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    subgraph 第一层[&amp;#34;第一层：micro_compact&amp;#34;]
        旧结果[&amp;#34;旧工具结果&amp;#34;] --&amp;gt; 占位符[&amp;#34;换成占位符&amp;lt;br/&amp;gt;[之前读过 main.py]&amp;#34;]
    end

    subgraph 第二层[&amp;#34;第二层：auto_compact&amp;#34;]
        太多文字[&amp;#34;对话太长&amp;#34;] --&amp;gt; 写总结[&amp;#34;AI 写摘要&amp;#34;] --&amp;gt; 替换历史[&amp;#34;用摘要替换历史&amp;#34;]
    end

    subgraph 第三层[&amp;#34;第三层：compact 工具&amp;#34;]
        AI判断[&amp;#34;AI 觉得该压缩了&amp;#34;] --&amp;gt; 手动触发[&amp;#34;调用 compact 工具&amp;#34;]
    end

    第一层 --&amp;gt; 第二层 --&amp;gt; 第三层&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;s09-memoryai-也要记笔记&#34;&gt;s09 Memory：AI 也要记笔记&lt;/h3&gt;
&lt;p&gt;AI 没有真正的记忆。每次对话结束，它就忘了。如果想让它长期记住东西（比如你喜欢什么代码风格、项目有什么规矩），就需要一个“记忆系统”。&lt;/p&gt;
&lt;p&gt;记忆系统通常分三步：&lt;/p&gt;
&lt;ol&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;：存到文件或数据库里，下次对话再读出来。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    对话[&amp;#34;一段对话&amp;#34;] --&amp;gt; 选择[&amp;#34;选择重要信息&amp;#34;]
    选择 --&amp;gt; 提取[&amp;#34;提取关键事实&amp;#34;]
    提取 --&amp;gt; 存储[&amp;#34;存到记忆文件&amp;#34;]
    存储 --&amp;gt; 下次对话[&amp;#34;下次启动时读取&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;比如 AI 记住了：“这个用户喜欢用 2 个空格缩进。”下次写代码时它就会自动用 2 个空格。&lt;/p&gt;
&lt;h3 id=&#34;s10-system-prompt提示词不是写死的&#34;&gt;s10 System Prompt：提示词不是写死的&lt;/h3&gt;
&lt;p&gt;System Prompt 就是告诉 AI“你是谁、该怎么做事”的那段话。&lt;/p&gt;
&lt;p&gt;Claude Code 的 System Prompt 不是一次性写死的，而是&lt;strong&gt;运行时拼接&lt;/strong&gt;的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;基础身份部分；&lt;/li&gt;
&lt;li&gt;当前项目规则；&lt;/li&gt;
&lt;li&gt;加载的技能书；&lt;/li&gt;
&lt;li&gt;用户的偏好记忆。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;就像你写作文时，老师给的题目要求 + 你自己的提纲 + 参考资料，组合在一起。&lt;/p&gt;
&lt;h3 id=&#34;s11-error-recovery做错事能重来&#34;&gt;s11 Error Recovery：做错事能重来&lt;/h3&gt;
&lt;p&gt;AI 也会犯错。比如它调用了一个不存在的文件，或者写的代码运行报错。这时候怎么办？&lt;/p&gt;
&lt;p&gt;Claude Code 有几种“重来”策略：&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;清理空间&lt;/strong&gt;：上下文太满导致看不清，先压缩一下再试；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;换个小模型/大模型&lt;/strong&gt;：简单任务用小模型省钱，复杂任务换大模型。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    出错[工具执行出错] --&amp;gt; 重试{重试有用吗？}
    重试 --&amp;gt;|有用| 再试一次
    重试 --&amp;gt;|没用| 换方法[换工具/换思路]
    换方法 --&amp;gt;|还不行| 清空间[压缩上下文]
    清空间 --&amp;gt;|再试| 再试一次&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;重要道理&lt;/strong&gt;：错误不是终点，而是调整的起点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七第四阶段长时间运行&#34;&gt;七、第四阶段：长时间运行&lt;/h2&gt;
&lt;h3 id=&#34;s12-task-system把大目标拆成小任务&#34;&gt;s12 Task System：把大目标拆成小任务&lt;/h3&gt;
&lt;p&gt;复杂项目不能靠一个简单的 Todo 清单。任务之间有依赖关系：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 做完了，B 才能开始；&lt;/li&gt;
&lt;li&gt;C 和 D 可以同时进行；&lt;/li&gt;
&lt;li&gt;E 要等 C 和 D 都做完。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Claude Code 用&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;#34;] --&amp;gt; B[&amp;#34;写前端代码&amp;#34;]
    A --&amp;gt; C[&amp;#34;写后端接口&amp;#34;]
    B --&amp;gt; D[&amp;#34;联调测试&amp;#34;]
    C --&amp;gt; D&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;s13-background-tasks慢活放后台&#34;&gt;s13 Background Tasks：慢活放后台&lt;/h3&gt;
&lt;p&gt;有些命令要跑很久，比如 &lt;code&gt;npm install&lt;/code&gt; 可能要几分钟。如果 AI 停下来等，就太浪费了。&lt;/p&gt;
&lt;p&gt;Claude Code 会开一个新的后台线程去跑这些慢命令，主 AI 继续思考别的事。等后台跑完了，再通知 AI。&lt;/p&gt;
&lt;h3 id=&#34;s14-cron-scheduler定时任务&#34;&gt;s14 Cron Scheduler：定时任务&lt;/h3&gt;
&lt;p&gt;Cron 就是“定时器”。比如你可以让 AI 每天早上 8 点检查一遍代码仓库，看看有没有需要更新的依赖。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“到点了就干活，不用人喊。”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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
    时间到[时间到了] --&amp;gt; Cron[Cron 触发器]
    Cron --&amp;gt; 任务[执行预设任务]
    任务 --&amp;gt; 结果[把结果发给 AI]&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;s15-agent-teams多个-ai-一起干活&#34;&gt;s15 Agent Teams：多个 AI 一起干活&lt;/h3&gt;
&lt;p&gt;一个 AI 干不完的大项目，可以叫一群 AI 一起干。每个 AI 有自己的身份和“收件箱”（mailbox）。&lt;/p&gt;
&lt;p&gt;它们通过 JSONL 文件收发消息：&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:#f92672&#34;&gt;&amp;#34;from&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;leader&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;to&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;alice&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;content&amp;#34;&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:#f92672&#34;&gt;&amp;#34;from&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;alice&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;to&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;leader&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;content&amp;#34;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;每行一条消息，追加写入，像写日记一样简单。&lt;/p&gt;
&lt;h3 id=&#34;s16-team-protocols队友之间要讲规矩&#34;&gt;s16 Team Protocols：队友之间要讲规矩&lt;/h3&gt;
&lt;p&gt;多人协作不能乱说话。Claude Code 的团队协议规定：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;每个重要请求都要带一个 &lt;code&gt;request_id&lt;/code&gt;（像快递单号）；&lt;/li&gt;
&lt;li&gt;回复时要引用同一个 &lt;code&gt;request_id&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;请求状态是 pending（等待中）、approved（同意）或 rejected（拒绝）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样就不会搞混谁同意了什么。&lt;/p&gt;
&lt;h3 id=&#34;s17-autonomous-agentsai-自己会找活干&#34;&gt;s17 Autonomous Agents：AI 自己会找活干&lt;/h3&gt;
&lt;p&gt;更高级的团队里，AI 不需要等领导分配任务。它会自己去看任务板，认领还没人做的、并且前置条件满足的任务。&lt;/p&gt;
&lt;p&gt;如果 60 秒没活干，它就自动“下班关机”。&lt;/p&gt;
&lt;h3 id=&#34;s18-worktree-isolation各干各的不打架&#34;&gt;s18 Worktree Isolation：各干各的，不打架&lt;/h3&gt;
&lt;p&gt;两个 AI 同时改同一个文件，会互相覆盖。Claude Code 用 &lt;strong&gt;git worktree&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&gt;任务 A → 目录 auth-refactor/ → 分支 wt/auth-refactor
任务 B → 目录 ui-login/     → 分支 wt/ui-login&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;大家互不干扰，最后再把代码合并。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九第六阶段扩展组装&#34;&gt;九、第六阶段：扩展组装&lt;/h2&gt;
&lt;h3 id=&#34;s19-mcp-plugin不够用时插更多工具&#34;&gt;s19 MCP Plugin：不够用时，插更多工具&lt;/h3&gt;
&lt;p&gt;MCP 是“模型上下文协议”。你可以把它理解成“USB 接口”：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Claude Code 本身带了一些工具；&lt;/li&gt;
&lt;li&gt;如果你需要连数据库、查浏览器、操作 Slack，可以通过 MCP 插进来；&lt;/li&gt;
&lt;li&gt;这些外部工具和自带工具一样，AI 都能调用。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    Claude[&amp;#34;Claude Code&amp;#34;] --&amp;gt; MCP[&amp;#34;MCP 接口&amp;#34;]
    MCP --&amp;gt; 数据库[&amp;#34;数据库工具&amp;#34;]
    MCP --&amp;gt; 浏览器[&amp;#34;浏览器工具&amp;#34;]
    MCP --&amp;gt; Slack[&amp;#34;Slack 工具&amp;#34;]
    数据库 --&amp;gt;|结果| Claude
    浏览器 --&amp;gt;|结果| Claude
    Slack --&amp;gt;|结果| Claude&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;s20-comprehensive-agent把所有东西拼起来&#34;&gt;s20 Comprehensive Agent：把所有东西拼起来&lt;/h3&gt;
&lt;p&gt;最后一节课，就是把前面 19 节课的所有机制都装进同一个循环里：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个循环 + 工具 + 权限 + 钩子 + 计划 + 子代理 + 技能 + 记忆 + 压缩 + 任务图 + 后台任务 + Cron + 团队 + 自治 + Worktree + MCP = 完整的 Claude Code&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;听起来很多，但你已经一个个看过了。它们都是围绕最初那个简单循环加上的“配件”。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十给新手的-5-个开发小技巧&#34;&gt;十、给新手的 5 个开发小技巧&lt;/h2&gt;
&lt;p&gt;这些技巧来自 Claude Code 的源码，平时写程序也能用到。&lt;/p&gt;
&lt;h3 id=&#34;1-用查表代替一堆-ifelse&#34;&gt;1. 用“查表”代替一堆 if/else&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-python&#34; data-lang=&#34;python&#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;if&lt;/span&gt; name &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;read&amp;#34;&lt;/span&gt;: read_file()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;elif&lt;/span&gt; name &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;write&amp;#34;&lt;/span&gt;: write_file()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;HANDLERS &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; {&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;read&amp;#34;&lt;/span&gt;: read_file, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;write&amp;#34;&lt;/span&gt;: write_file}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;HANDLERS[name]()&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;h3 id=&#34;2-修改列表要小心&#34;&gt;2. 修改列表要小心&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;messages[:] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; new_list  &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;# messages = new_list   # 错误：只是让局部变量指向新列表&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;3-用-kwargs-接受任意参数&#34;&gt;3. 用 &lt;code&gt;**kwargs&lt;/code&gt; 接受任意参数&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;handler&lt;/span&gt;(&lt;span style=&#34;color:#f92672&#34;&gt;**&lt;/span&gt;kw):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    print(kw[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;path&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;handler(path&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;main.py&amp;#34;&lt;/span&gt;, limit&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;10&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;(**kw)&lt;/code&gt; 表示“不管什么参数名，都装进字典 kw 里”。&lt;/p&gt;
&lt;h3 id=&#34;4-守护线程做后台任务&#34;&gt;4. 守护线程做后台任务&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; threading
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;t &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; threading&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;Thread(target&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;download_file, daemon&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;t&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;start()&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;h3 id=&#34;5-jsonl-比-json-更适合追加写入&#34;&gt;5. JSONL 比 JSON 更适合追加写入&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-python&#34; data-lang=&#34;python&#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;with&lt;/span&gt; open(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;inbox.jsonl&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;a&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#66d9ef&#34;&gt;as&lt;/span&gt; f:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    f&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write(json&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;dumps(msg) &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;\n&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 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;for&lt;/span&gt; line &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; open(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;inbox.jsonl&amp;#34;&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    msg &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; json&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;loads(line)&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;hr&gt;
&lt;h2 id=&#34;十一总结&#34;&gt;十一、总结&lt;/h2&gt;
&lt;p&gt;Claude Code 看起来很厉害，但它的核心其实非常朴素：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一个 while 循环 + 一张工具表 + 一个会说话的 AI。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;真正让它变强的，是围绕这个循环一层层加上的“配件”：权限、计划、子代理、技能、记忆、压缩、任务图、后台任务、团队、自治、目录隔离、MCP……&lt;/p&gt;
&lt;p&gt;这些配件都是为了解决一个核心问题：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;怎么让一个只会说话的 AI，真正能在现实世界里连续、安全、高效地干活。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;理解了这个骨架之后，你再回头看 Claude Code，就不会觉得它是黑魔法了。它就是一个被精心设计的“机器人工作台”，而我们看到的聪明，来自那个会思考的大脑，加上一套会执行的 harness。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;大脑负责想，工作台负责做。把工作台造好，大脑就能发挥全部实力。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;本文是学习 &lt;a href=&#34;https://github.com/shareAI-lab/learn-claude-code&#34;&gt;shareAI-lab/learn-claude-code&lt;/a&gt; 的笔记整理。如果你想动手，可以从 &lt;code&gt;s01_agent_loop/code.py&lt;/code&gt; 开始，一课一课跟着做。&lt;/em&gt;&lt;/p&gt;
</description>
    </item>
    <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>
    <item>
      <title>Spark 从入门到精通：架构原理、RDD/DataFrame、调优踩坑与电商实战全指南</title>
      <link>https://ftmao.com/blog/posts/spark-beginner-to-mastery/</link>
      <pubDate>Wed, 26 Oct 2022 09:30:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/spark-beginner-to-mastery/</guid>
      <description>&lt;h1 id=&#34;spark-从入门到精通架构原理rdddataframe调优踩坑与电商实战全指南&#34;&gt;Spark 从入门到精通：架构原理、RDD/DataFrame、调优踩坑与电商实战全指南&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：写过一两年后端（Java/Scala/Python 皆可）、第一次要碰大数据的同学；或者用 PySpark 写过一些脚本、但一遇到性能问题就抓瞎的同学。
写这篇文章时（2022 年 10 月），Spark 3.3 刚发布，DataFrame 早已是绝对主流，Flink 在实时方向上攻城略地。但离线批处理、数仓 ETL、特征工程这些重活，Spark 依然是绕不开的那座山。&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;一 为什么是 Spark&amp;lt;br/&amp;gt;从单机到分布式&amp;#34;] --&amp;gt; B[&amp;#34;二 架构与原理&amp;lt;br/&amp;gt;Driver Executor DAG&amp;#34;]
    B --&amp;gt; C[&amp;#34;三 RDD 与 DataFrame&amp;lt;br/&amp;gt;两代 API 的分水岭&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;倾斜 OOM 小文件&amp;#34;]
    E --&amp;gt; F[&amp;#34;六 AI 时代&amp;lt;br/&amp;gt;少写代码 多写提示词&amp;#34;]
    style F 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;」；第四篇用真实业务把知识串起来；第五篇是血泪史，生产环境和本地 Demo 是两个物种；第六篇聊怎么让 AI 替你干活。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第一篇-为什么是-spark一台机器装不下的时代&#34;&gt;第一篇 为什么是 Spark：一台机器装不下的时代&lt;/h2&gt;
&lt;h3 id=&#34;11-一个电商场景把问题引出来&#34;&gt;1.1 一个电商场景把问题引出来&lt;/h3&gt;
&lt;p&gt;假设你在一家中型电商公司做后端。双十一刚过，老板要看一份报表：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;昨天所有下单用户，按城市分组，统计成交额、下单次数、客单价，再看这批用户里有多少是 30 天内的新客。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;订单表 8 亿行，用户表 4000 万行，行为日志（埋点）一天 20 亿条。MySQL 里跑这个 SQL？&lt;code&gt;GROUP BY&lt;/code&gt; 一个 8 亿行的表，跑一晚上 MySQL 先把你杀了——不，是 DBA 先把你杀了。&lt;/p&gt;
&lt;p&gt;你想到的办法是「把数据导出来，写个 Python 脚本跑」。8 亿行订单数据，CSV 导出大概 400GB。你的开发机内存 32GB。&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 TB
    subgraph 思路一[&amp;#34;思路一：垂直扩展（Stack Overflow 式的天真）&amp;#34;]
        A1[&amp;#34;换个 1TB 内存的机器&amp;#34;] --&amp;gt; A2[&amp;#34;贵得离谱&amp;lt;br/&amp;gt;且有物理上限&amp;#34;]
    end
    subgraph 思路二[&amp;#34;思路二：水平扩展（唯一出路）&amp;#34;]
        B1[&amp;#34;把 400GB 切成 1000 份&amp;#34;] --&amp;gt; B2[&amp;#34;1000 台机器&amp;lt;br/&amp;gt;每台处理 400MB&amp;#34;]
        B2 --&amp;gt; B3[&amp;#34;各自算局部结果&amp;lt;br/&amp;gt;最后合并&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;思路二就是分布式计算。但自己写 1000 台机器的协调代码？分发任务、处理机器挂掉、合并结果、处理网络超时……你会写出第二个 Hadoop。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spark 就是替你把这些脏活全包了&lt;/strong&gt;：你只写「对这批数据做什么变换」，它负责切分、调度、容错、合并。&lt;/p&gt;
&lt;h3 id=&#34;12-spark-在生态里的位置&#34;&gt;1.2 Spark 在生态里的位置&lt;/h3&gt;
&lt;p&gt;新人最容易被一堆名词搞晕：Hadoop、HDFS、YARN、Hive、Spark、Flink。一张图说清楚：&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
    subgraph 存储层
        HDFS[&amp;#34;HDFS&amp;lt;br/&amp;gt;分布式文件系统&amp;lt;br/&amp;gt;（把文件切块存到多台机器）&amp;#34;]
        S3[&amp;#34;S3 / OSS&amp;lt;br/&amp;gt;对象存储&amp;#34;]
    end
    subgraph 资源层
        YARN[&amp;#34;YARN / K8s&amp;lt;br/&amp;gt;集群资源调度&amp;lt;br/&amp;gt;（给任务分配 CPU 和内存）&amp;#34;]
    end
    subgraph 计算层
        MR[&amp;#34;MapReduce&amp;lt;br/&amp;gt;老一辈 磁盘IO 慢&amp;#34;]
        SPARK[&amp;#34;Spark&amp;lt;br/&amp;gt;内存计算 快&amp;lt;br/&amp;gt;本篇主角&amp;#34;]
        FLINK[&amp;#34;Flink&amp;lt;br/&amp;gt;流处理专精&amp;#34;]
    end
    subgraph 应用层
        HIVE[&amp;#34;Hive/Spark SQL&amp;lt;br/&amp;gt;SQL 写报表&amp;#34;]
        DS[&amp;#34;数据科学&amp;lt;br/&amp;gt;MLlib 特征工程&amp;#34;]
    end
    HDFS --&amp;gt; YARN
    S3 --&amp;gt; YARN
    YARN --&amp;gt; MR
    YARN --&amp;gt; SPARK
    YARN --&amp;gt; FLINK
    SPARK --&amp;gt; HIVE
    SPARK --&amp;gt; DS
    style SPARK fill:#ffd699&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;几个关键认知：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Spark 不是存储系统&lt;/strong&gt;，它没有自己的存储，数据放在 HDFS、Hive 表、S3 甚至 MySQL 里，它读进来算完再写回去。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spark 也不是资源调度器&lt;/strong&gt;，它跑在 YARN 或 Kubernetes 上，向它们「申请」机器。&lt;/li&gt;
&lt;li&gt;Spark 相对 MapReduce 的快，核心就一句话：&lt;strong&gt;中间结果放内存，不落地磁盘&lt;/strong&gt;。MapReduce 每步都要写磁盘，迭代式计算（比如机器学习跑 50 轮迭代）就慢到不可用。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑预告&lt;/strong&gt;：很多人学 Spark 的第一步是去装 Hadoop 全家桶，装了一周还没跑起来 Hello World。其实本地学习完全不需要——Spark 有 &lt;code&gt;local&lt;/code&gt; 模式， pip 装个 &lt;code&gt;pyspark&lt;/code&gt; 就能跑。下一篇的 Demo 全是单机可运行的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第二篇-架构与原理代码提交之后到底发生了什么&#34;&gt;第二篇 架构与原理：代码提交之后到底发生了什么&lt;/h2&gt;
&lt;p&gt;这是最重要的一篇。Spark 调优 80% 靠对这套架构的直觉，剩下 20% 才是参数。&lt;/p&gt;
&lt;h3 id=&#34;21-一次-spark-任务的全貌&#34;&gt;2.1 一次 Spark 任务的全貌&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
    CODE[&amp;#34;你的代码&amp;lt;br/&amp;gt;spark.read... .groupBy... .write...&amp;#34;] --&amp;gt; DRV
    subgraph Cluster[&amp;#34;集群（YARN / K8s）&amp;#34;]
        subgraph DRV[&amp;#34;Driver（司机/总指挥）&amp;#34;]
            DAG[&amp;#34;DAG Scheduler&amp;lt;br/&amp;gt;把代码翻译成执行计划&amp;lt;br/&amp;gt;切成若干 Stage&amp;#34;]
            TS[&amp;#34;Task Scheduler&amp;lt;br/&amp;gt;把 Stage 拆成 Task&amp;lt;br/&amp;gt;派发给 Executor&amp;#34;]
        end
        subgraph EXE[&amp;#34;Executor × N（干活的工人）&amp;#34;]
            T1[&amp;#34;Task 1&amp;lt;br/&amp;gt;处理 Partition 1&amp;#34;]
            T2[&amp;#34;Task 2&amp;lt;br/&amp;gt;处理 Partition 2&amp;#34;]
            T3[&amp;#34;Task 3&amp;lt;br/&amp;gt;处理 Partition 3&amp;#34;]
            CACHE[&amp;#34;内存/磁盘&amp;lt;br/&amp;gt;缓存中间数据&amp;#34;]
        end
    end
    TS --&amp;gt; T1
    TS --&amp;gt; T2
    TS --&amp;gt; T3
    T1 -.shuffle 阶段互相交换数据.-&amp;gt; T2&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;关键角色就三个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Driver&lt;/strong&gt;：你的 &lt;code&gt;main&lt;/code&gt; 函数（或 PySpark 脚本）运行的地方。它不干活，它&lt;strong&gt;发号施令&lt;/strong&gt;——分析你的代码、生成执行计划、把任务派发出去、回收结果。Driver 挂了，整个任务就完了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Executor&lt;/strong&gt;：集群里真正干活的 JVM 进程，每个 Executor 有若干核，每个核同时跑一个 Task。数据是**分区（Partition）**存储的，一个 Task 处理一个 Partition。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shuffle&lt;/strong&gt;：当操作需要跨机器汇总数据（&lt;code&gt;groupBy&lt;/code&gt;、&lt;code&gt;join&lt;/code&gt;、&lt;code&gt;distinct&lt;/code&gt;），数据就要在网络上重新分发——这叫 Shuffle。&lt;strong&gt;Shuffle 是 Spark 任务慢的第一大原因&lt;/strong&gt;，后文反复出现。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;22-懒执行lazy-evaluation为什么你的代码没报错却在运行时炸了&#34;&gt;2.2 懒执行（Lazy Evaluation）：为什么你的代码&amp;quot;没报错&amp;quot;却在运行时炸了&lt;/h3&gt;
&lt;p&gt;新手最常踩的认知坑：&lt;strong&gt;Spark 代码写下去并不会立刻执行&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;df &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;read&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;csv(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;orders.csv&amp;#34;&lt;/span&gt;)          &lt;span style=&#34;color:#75715e&#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;df2 &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; df&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(df&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;amount &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;100&lt;/span&gt;)           &lt;span style=&#34;color:#75715e&#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;df3 &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; df2&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;city&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;sum(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;)    &lt;span style=&#34;color:#75715e&#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;df3&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;save(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;output&amp;#34;&lt;/span&gt;)                   &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;Transformation（变换）&lt;/strong&gt;，只是往计划里加节点；&lt;code&gt;write&lt;/code&gt;、&lt;code&gt;count&lt;/code&gt;、&lt;code&gt;collect&lt;/code&gt;、&lt;code&gt;show&lt;/code&gt; 这些叫 &lt;strong&gt;Action（行动）&lt;/strong&gt;，碰到 Action，Driver 才把整条链路翻译成 DAG（有向无环图），切成 Stage，真正派发执行。&lt;/p&gt;
&lt;p&gt;这个设计的好处是 &lt;strong&gt;优化&lt;/strong&gt;：Spark 的 Catalyst 优化器能看到整条链路，比如发现你过滤条件写晚了，它可以自动把 &lt;code&gt;filter&lt;/code&gt; 下推到读数据时就做（Predicate Pushdown），少读一半数据。&lt;/p&gt;
&lt;p&gt;但坑也随之而来：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 1：错误行号的谎言&lt;/strong&gt;
刚转 Spark 时我排查一个任务失败，日志说第 87 行 &lt;code&gt;df.write.save()&lt;/code&gt; 报错 &amp;ldquo;文件不存在&amp;rdquo;。我盯着 87 行看了半小时——write 语法没问题啊！后来才明白：&lt;strong&gt;真正的错误发生在第 12 行的 &lt;code&gt;spark.read&lt;/code&gt;（路径写错了），但因为是懒执行，直到 87 行触发 Action 才爆出来&lt;/strong&gt;。看 Spark 报错，永远要从日志里的执行计划往上找 Transformation 的源头，而不是看报错行号。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;23-stage-是怎么切出来的宽依赖与窄依赖&#34;&gt;2.3 Stage 是怎么切出来的：宽依赖与窄依赖&lt;/h3&gt;
&lt;p&gt;理解 Shuffle 就理解了 Stage 的切分：&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 Stage1[&amp;#34;Stage 1（窄依赖，流水线执行）&amp;#34;]
        P1[&amp;#34;Partition 1&amp;lt;br/&amp;gt;读文件&amp;#34;] --&amp;gt; F1[&amp;#34;filter&amp;lt;br/&amp;gt;过滤&amp;#34;]
        P2[&amp;#34;Partition 2&amp;lt;br/&amp;gt;读文件&amp;#34;] --&amp;gt; F2[&amp;#34;filter&amp;lt;br/&amp;gt;过滤&amp;#34;]
    end
    subgraph SHUFFLE[&amp;#34;⚡ Shuffle（跨网络搬数据）&amp;#34;]
        direction TB
        S[&amp;#34;按 city 重新分区&amp;lt;br/&amp;gt;同 city 的数据&amp;lt;br/&amp;gt;汇聚到同一 Partition&amp;#34;]
    end
    subgraph Stage2[&amp;#34;Stage 2&amp;#34;]
        G1[&amp;#34;Partition A&amp;lt;br/&amp;gt;北京的订单&amp;lt;br/&amp;gt;sum(amount)&amp;#34;]
        G2[&amp;#34;Partition B&amp;lt;br/&amp;gt;上海的订单&amp;lt;br/&amp;gt;sum(amount)&amp;#34;]
    end
    F1 --&amp;gt; SHUFFLE
    F2 --&amp;gt; SHUFFLE
    SHUFFLE --&amp;gt; G1
    SHUFFLE --&amp;gt; G2
    style SHUFFLE fill:#ffcccc&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;窄依赖&lt;/strong&gt;（filter、map）：子 Partition 只依赖父的一个 Partition，可以像流水线一样在同一台机器一口气算完，不分 Stage。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;宽依赖&lt;/strong&gt;（groupBy、join、distinct）：子 Partition 需要父的&lt;strong&gt;所有&lt;/strong&gt; Partition 的数据，必须 Shuffle，Stage 在这里断开。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;一个 Stage 内的失败重试只重跑这个 Stage&lt;/strong&gt;。所以 Spark 的容错单位是 Stage——这也是为什么 Spark 会把 Shuffle 的中间结果落盘（对，嘴上说自己内存计算，Shuffle 还是要写磁盘的），挂了能从这里恢复，而不是从头再来。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第三篇-rdd-与-dataframe两代-api-的分水岭&#34;&gt;第三篇 RDD 与 DataFrame：两代 API 的分水岭&lt;/h2&gt;
&lt;h3 id=&#34;31-rdd能看清每个零件但要自己造发动机&#34;&gt;3.1 RDD：能看清每个零件，但要自己造发动机&lt;/h3&gt;
&lt;p&gt;RDD（弹性分布式数据集）是 Spark 1.x 的核心抽象。用它写上面的需求：&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-scala&#34; data-lang=&#34;scala&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// RDD 风格：你告诉 Spark 每一步&amp;#34;怎么做&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders
&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;.&lt;/span&gt;filter&lt;span style=&#34;color:#f92672&#34;&gt;(&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;_&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;split&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:#f92672&#34;&gt;)(&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;5&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;).&lt;/span&gt;toDouble &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;100&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;)&lt;/span&gt;     &lt;span style=&#34;color:#75715e&#34;&gt;// 自己解析 CSV，自己转类型
&lt;/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;.&lt;/span&gt;map&lt;span style=&#34;color:#f92672&#34;&gt;(&lt;/span&gt;line &lt;span style=&#34;color:#66d9ef&#34;&gt;=&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;(&lt;/span&gt;line&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;split&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:#f92672&#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;),&lt;/span&gt;           &lt;span style=&#34;color:#75715e&#34;&gt;// 自己取城市字段做 key
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                line&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;split&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:#f92672&#34;&gt;)(&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;5&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;).&lt;/span&gt;toDouble&lt;span style=&#34;color:#f92672&#34;&gt;))&lt;/span&gt; &lt;span style=&#34;color:#75715e&#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:#f92672&#34;&gt;.&lt;/span&gt;reduceByKey&lt;span style=&#34;color:#f92672&#34;&gt;(&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;_&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;_&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;)&lt;/span&gt;                          &lt;span style=&#34;color:#75715e&#34;&gt;// 按 key 聚合
&lt;/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;.&lt;/span&gt;saveAsTextFile&lt;span style=&#34;color:#f92672&#34;&gt;(&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;output&amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#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;RDD 的问题是：&lt;strong&gt;Spark 看不懂你在干什么&lt;/strong&gt;。&lt;code&gt;filter(_.split(&amp;quot;,&amp;quot;)(5).toDouble &amp;gt; 100)&lt;/code&gt; 对优化器来说就是一个黑盒 lambda，它不知道你在过滤哪列，没法做谓词下推，没法选更好的 join 策略。而且 JVM 里每一行都是一个 Java 对象，序列化开销大、GC 压力巨大。&lt;/p&gt;
&lt;h3 id=&#34;32-dataframe你只说要什么优化器替你想怎么做&#34;&gt;3.2 DataFrame：你只说&amp;quot;要什么&amp;quot;，优化器替你想&amp;quot;怎么做&amp;quot;&lt;/h3&gt;
&lt;p&gt;DataFrame（Spark 1.3 引入，2.0 后与 Dataset 合并）给数据加了&lt;strong&gt;Schema（表结构）&lt;/strong&gt;，从此 Spark 能看懂你的意图：&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# DataFrame 风格：声明式&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;(spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;read
&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;.&lt;/span&gt;schema(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_id STRING, city STRING, amount DOUBLE&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;.&lt;/span&gt;csv(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;orders.csv&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;.&lt;/span&gt;filter(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount &amp;gt; 100&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;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;city&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;.&lt;/span&gt;sum(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&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;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parquet(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;output&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;两者差距有多大？同样的逻辑，DataFrame 通常比 RDD &lt;strong&gt;快 2~10 倍&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
    subgraph RDD路径[&amp;#34;RDD：信任程序员&amp;#34;]
        R1[&amp;#34;你的 lambda&amp;#34;] --&amp;gt; R2[&amp;#34;逐行 Java 对象&amp;lt;br/&amp;gt;序列化/GC 开销大&amp;lt;br/&amp;gt;优化器爱莫能助&amp;#34;]
    end
    subgraph DF路径[&amp;#34;DataFrame：信任优化器&amp;#34;]
        D1[&amp;#34;声明式表达式&amp;#34;] --&amp;gt; D2[&amp;#34;Catalyst 优化器&amp;lt;br/&amp;gt;逻辑计划优化&amp;lt;br/&amp;gt;谓词下推/列裁剪&amp;#34;]
        D2 --&amp;gt; D3[&amp;#34;Tungsten 执行引擎&amp;lt;br/&amp;gt;二进制内存布局&amp;lt;br/&amp;gt;代码生成 绕开 GC&amp;#34;]
    end
    style DF路径 fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一句话结论（2022 年视角）&lt;/strong&gt;：新业务&lt;strong&gt;永远用 DataFrame/Spark SQL 写&lt;/strong&gt;，RDD 只在两种情况下出现——操作的是非结构化数据（比如解析一堆不规则日志），或者需要逐分区精细控制（&lt;code&gt;mapPartitions&lt;/code&gt; 里建数据库连接池这种）。面试让你手写 RDD wordcount 是考察理解，不是让你生产上这么写。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;33-本地就能跑的完整-demo&#34;&gt;3.3 本地就能跑的完整 Demo&lt;/h3&gt;
&lt;p&gt;别急着上集群。装个 Python 环境就能跑：&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;pip install pyspark&lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt;3.3.0&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;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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# demo01_basic.py&lt;/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;from&lt;/span&gt; pyspark.sql &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; SparkSession
&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;from&lt;/span&gt; pyspark.sql &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; functions &lt;span style=&#34;color:#66d9ef&#34;&gt;as&lt;/span&gt; F
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;spark &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (SparkSession&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;builder
&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;.&lt;/span&gt;appName(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;demo01&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;.&lt;/span&gt;master(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;local[*]&amp;#34;&lt;/span&gt;)      &lt;span style=&#34;color:#75715e&#34;&gt;# 本地模式，用满 CPU 核&lt;/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;.&lt;/span&gt;getOrCreate())
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;# 造数据：100 万行模拟订单&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;range(&lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;, &lt;span style=&#34;color:#ae81ff&#34;&gt;1_000_000&lt;/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;.&lt;/span&gt;select(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;              F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;concat(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ORD&amp;#34;&lt;/span&gt;), F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_id&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;              (F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;%&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;50000&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;              F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;element_at(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                  F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;array([F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(c) &lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; c &lt;span style=&#34;color:#f92672&#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:#e6db74&#34;&gt;&amp;#34;北京&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;上海&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;广州&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;深圳&amp;#34;&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;                  (F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;%&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;5&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:#f92672&#34;&gt;.&lt;/span&gt;cast(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;int&amp;#34;&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;city&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;              (F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;rand() &lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;2000&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;              F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;date_add(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2022-10-25&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;-&lt;/span&gt;(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;%&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;30&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;cast(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;int&amp;#34;&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_date&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;result &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (orders
&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;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;100&lt;/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;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;city&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;.&lt;/span&gt;agg(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_cnt&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;               F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;round(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;sum(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;gmv&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;               F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;round(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;avg(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;avg_price&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;.&lt;/span&gt;orderBy(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;desc(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;gmv&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;result&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;show()          &lt;span style=&#34;color:#75715e&#34;&gt;# Action：真正触发执行&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;result&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;mode(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;overwrite&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parquet(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;output/city_gmv&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;stop()&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;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;python demo01_basic.py&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&gt;+----+---------+-----------+---------+
|city|order_cnt|        gmv|avg_price|
+----+---------+-----------+---------+
|  深圳|   190231|...        |  1050.12|
|  北京|   190087|...        |  1049.87|
...
+----+---------+-----------+---------+&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这个 Demo 麻雀虽小，五脏俱全：&lt;code&gt;spark.range&lt;/code&gt; 造数、&lt;code&gt;element_at&lt;/code&gt; 数组取值、&lt;code&gt;rand&lt;/code&gt; 随机数、聚合多指标、写 Parquet。本地玩熟了，语法迁移到集群上&lt;strong&gt;一行都不用改&lt;/strong&gt;——这就是 Spark 的优雅之处。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 2：Windows 上的 winutils.exe&lt;/strong&gt;
我在 Windows 笔记本上第一次跑 PySpark，直接 &lt;code&gt;winutils.exe&lt;/code&gt; 缺失报错 + 一堆权限异常。解决办法是下载对应 Hadoop 版本的 &lt;code&gt;winutils.exe&lt;/code&gt; 放到 &lt;code&gt;%HADOOP_HOME%\bin&lt;/code&gt; 并设置环境变量。后来学乖了：&lt;strong&gt;学习期直接 WSL2 或者 Docker 起个 Linux 环境&lt;/strong&gt;，少生一肚子气。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第四篇-电商实战把知识串成业务&#34;&gt;第四篇 电商实战：把知识串成业务&lt;/h2&gt;
&lt;p&gt;这一篇用三个电商真实场景，把前面的知识点全部用起来。这三个场景也是面试里最高频的&amp;quot;你做过什么项目&amp;quot;素材。&lt;/p&gt;
&lt;h3 id=&#34;41-场景一订单宽表每日-etl&#34;&gt;4.1 场景一：订单宽表（每日 ETL）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;业务&lt;/strong&gt;：订单数据分散在三张表——订单主表（order_id、user_id、amount、pay_time）、订单明细表（order_id、sku_id、quantity）、商品维表（sku_id、category、brand）。数据仓库要求每天凌晨产出一张&amp;quot;订单明细宽表&amp;quot;，下游 BI 直接查它。&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# demo02_dwd_wide_table.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;table(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ods.orders&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&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;2022-10-25&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;items  &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;table(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ods.order_items&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&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;2022-10-25&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;skus   &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;table(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dim.sku&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&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;2022-10-25&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;wide &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (items
&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;.&lt;/span&gt;join(orders, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_id&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;.&lt;/span&gt;join(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;broadcast(skus), &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&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:#f92672&#34;&gt;.&lt;/span&gt;select(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_id&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;category&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;brand&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;quantity&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pay_time&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;.&lt;/span&gt;withColumn(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&amp;#34;&lt;/span&gt;, F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2022-10-25&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;(wide&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write
&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;.&lt;/span&gt;mode(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;overwrite&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;.&lt;/span&gt;partitionBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&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;.&lt;/span&gt;parquet(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;warehouse/dwd/order_detail_wide&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;&lt;strong&gt;知识点：Broadcast Join&lt;/strong&gt;。普通 join 两张表都要 Shuffle；但如果一张表很小（商品维表几十万行，几十 MB），Spark 可以把它&lt;strong&gt;整个复制到每个 Executor 的内存里&lt;/strong&gt;，大表原地不动就能完成 join——Shuffle 直接消失，速度提升一个数量级。&lt;/p&gt;
&lt;p&gt;判断标准：小表 &amp;lt; &lt;code&gt;spark.sql.autoBroadcastJoinThreshold&lt;/code&gt;（默认 10MB，生产常调到 100MB~500MB）。写 &lt;code&gt;F.broadcast(skus)&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 LR
    subgraph 普通Join[&amp;#34;普通 Sort-Merge Join（两张表都 Shuffle）&amp;#34;]
        A1[&amp;#34;大表 Partition&amp;#34;] -.网络搬家.-&amp;gt; J1[&amp;#34;Join 节点&amp;#34;]
        A2[&amp;#34;维表 Partition&amp;#34;] -.网络搬家.-&amp;gt; J1
    end
    subgraph 广播Join[&amp;#34;Broadcast Join（只有小表复制）&amp;#34;]
        B1[&amp;#34;大表 Partition 1&amp;lt;br/&amp;gt;原地不动&amp;#34;] --&amp;gt; C1[&amp;#34;本地 Join&amp;lt;br/&amp;gt;查内存里的小表&amp;#34;]
        B2[&amp;#34;大表 Partition 2&amp;lt;br/&amp;gt;原地不动&amp;#34;] --&amp;gt; C2[&amp;#34;本地 Join&amp;lt;br/&amp;gt;查内存里的小表&amp;#34;]
        SM[&amp;#34;小表整表复制&amp;lt;br/&amp;gt;→ 每个 Executor&amp;#34;] --&amp;gt; C1
        SM --&amp;gt; C2
    end
    style 广播Join fill:#e8f4e8&lt;/code&gt;&lt;/pre&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;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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# demo03_funnel.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;events &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;table(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ods.user_events&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&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;2022-10-25&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;# events 字段：user_id, event_type(view/click/cart/order/pay), ts&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;first_ts &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (events
&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;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&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;.&lt;/span&gt;agg(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;min(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_type&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;view&amp;#34;&lt;/span&gt;,  F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ts&amp;#34;&lt;/span&gt;)))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_view&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;min(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_type&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;click&amp;#34;&lt;/span&gt;, F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ts&amp;#34;&lt;/span&gt;)))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_click&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;min(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_type&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;cart&amp;#34;&lt;/span&gt;,  F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ts&amp;#34;&lt;/span&gt;)))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_cart&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;min(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_type&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;order&amp;#34;&lt;/span&gt;, F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ts&amp;#34;&lt;/span&gt;)))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_order&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;min(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;event_type&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;pay&amp;#34;&lt;/span&gt;,   F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ts&amp;#34;&lt;/span&gt;)))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_pay&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;# 严格漏斗：必须按时间顺序走到该步才算转化&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;funnel &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; first_ts&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;agg(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_view&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&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;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_click&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_view&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&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;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_cart&amp;#34;&lt;/span&gt;)  &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_click&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&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;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_order&amp;#34;&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_cart&amp;#34;&lt;/span&gt;),  &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&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;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;count(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_pay&amp;#34;&lt;/span&gt;)   &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;t_order&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;))&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&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;funnel&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;show()&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;code&gt;groupBy&lt;/code&gt; + 条件聚合（&lt;code&gt;F.when&lt;/code&gt; inside &lt;code&gt;agg&lt;/code&gt;）是漏斗分析的标准套路&lt;/strong&gt;——一行 SQL 出五步漏斗，不用写五次 count 再 join。这也是经典面试题。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 3：漏斗口径的战争&lt;/strong&gt;
我第一版漏斗上线后，运营说数据和他们对不上。查了半天，发现分歧在口径：我写的是&amp;quot;严格顺序漏斗&amp;quot;（必须 view 之后才 click 才算），运营期望的是&amp;quot;存在性漏斗&amp;quot;（只要当天两个行为都发生过就算）。&lt;strong&gt;大数据开发一半的返工不是技术问题，是口径没对齐&lt;/strong&gt;。现在的习惯：开工前把每一步的定义写成一行白话发群里，确认了再写代码。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;43-场景三推荐特征工程用户画像&#34;&gt;4.3 场景三：推荐特征工程（用户画像）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;业务&lt;/strong&gt;：算法团队要用户特征——每个用户近 30 天的购买次数、客单价、最偏爱类目、最近一次购买距今天数（RFM 模型的雏形）。&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# demo04_user_features.py&lt;/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;from&lt;/span&gt; pyspark.sql.window &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; Window
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders_30d &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;table(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dwd.order_detail_wide&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;between(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2022-09-26&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2022-10-25&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;# 基础聚合特征&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;agg_feat &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (orders_30d
&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;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&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;.&lt;/span&gt;agg(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;countDistinct(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_id&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;order_cnt_30d&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;round(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;avg(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#ae81ff&#34;&gt;2&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;avg_amount_30d&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                 F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;max(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pay_time&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;last_pay_time&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;# 最偏爱类目：窗口函数按类目的购买金额排序取第一&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;w &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; Window&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;partitionBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;orderBy(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;desc(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;cat_amount&amp;#34;&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;top_cat &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (orders_30d
&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;.&lt;/span&gt;groupBy(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;category&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;.&lt;/span&gt;agg(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;sum(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;amount&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;cat_amount&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;.&lt;/span&gt;withColumn(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;rn&amp;#34;&lt;/span&gt;, F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;row_number()&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;over(w))
&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;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;rn&amp;#34;&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:#f92672&#34;&gt;.&lt;/span&gt;select(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;, F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;category&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;alias(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;top_category&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;features &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (agg_feat
&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;.&lt;/span&gt;join(top_cat, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&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;.&lt;/span&gt;withColumn(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;days_since_last_pay&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;datediff(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2022-10-26&amp;#34;&lt;/span&gt;), F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;last_pay_time&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;features&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;mode(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;overwrite&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parquet(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;features/user_rfm/dt=2022-10-25&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;&lt;strong&gt;知识点：窗口函数&lt;/strong&gt;（&lt;code&gt;Window.partitionBy(...).orderBy(...)&lt;/code&gt; + &lt;code&gt;row_number&lt;/code&gt;）是&amp;quot;每组取 TopN&amp;quot;的标准解法，Hive SQL 里也是同一句，大数据面试必考。&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 LR
    subgraph 数据源
        DB[&amp;#34;业务 MySQL&amp;lt;br/&amp;gt;订单/明细/商品&amp;#34;]
        LOG[&amp;#34;埋点日志&amp;lt;br/&amp;gt;曝光/点击/加购&amp;#34;]
    end
    DB --&amp;gt;|Sqoop/DataX 同步| ODS
    LOG --&amp;gt;|Flume/直接落盘| ODS
    subgraph 数仓[&amp;#34;数仓分层（Hive on Spark）&amp;#34;]
        ODS[&amp;#34;ODS 贴源层&amp;lt;br/&amp;gt;原样搬运&amp;#34;] --&amp;gt; DWD[&amp;#34;DWD 明细层&amp;lt;br/&amp;gt;清洗 关联 宽表&amp;lt;br/&amp;gt;★ 场景一&amp;#34;]
        DWD --&amp;gt; DWS[&amp;#34;DWS 汇总层&amp;lt;br/&amp;gt;按主题聚合&amp;lt;br/&amp;gt;★ 场景二漏斗&amp;#34;]
        DWS --&amp;gt; ADS[&amp;#34;ADS 应用层&amp;lt;br/&amp;gt;直接服务业务&amp;lt;br/&amp;gt;★ 场景三特征&amp;#34;]
    end
    ADS --&amp;gt; BI[&amp;#34;BI 报表&amp;#34;]
    ADS --&amp;gt; ALG[&amp;#34;推荐算法&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;第五篇-调优与踩坑生产环境才是真的战场&#34;&gt;第五篇 调优与踩坑：生产环境才是真的战场&lt;/h2&gt;
&lt;p&gt;本地 Demo 岁月静好，上了生产环境才知道什么叫残酷。下面三个坑，每一个我都在凌晨两点处理过。&lt;/p&gt;
&lt;h3 id=&#34;51-天坑一数据倾斜一个-task-跑三小时其他三分钟&#34;&gt;5.1 天坑一：数据倾斜（一个 Task 跑三小时，其他三分钟）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：任务 99% 的 Stage 秒过，最后一个 Stage 的 200 个 Task 里，199 个 5 分钟跑完，&lt;strong&gt;1 个跑了 3 小时还在爬&lt;/strong&gt;。Spark UI 上看，那个 Task 的 Shuffle Read 是别人的几百倍。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：某个 key 的数据量碾压其他 key。电商里最典型的就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;大促时某个爆款商品（iPhone 发售当天）的订单明细占全站 30%；&lt;/li&gt;
&lt;li&gt;维表 join 时空值/默认值 key——脏数据的 &lt;code&gt;user_id = null&lt;/code&gt; 或 &lt;code&gt;&#39;-1&#39;&lt;/code&gt; 有上千万条，全挤到一个 Partition。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;排查&lt;/strong&gt;：Spark UI → Stages → 看 Task 的 Summary Metrics，Max 时间远大于 Median（中位数），就是倾斜。&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 招式1：过滤异常 key（脏数据导致的倾斜，90% 的情况）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;df &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; df&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;filter(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;isNotNull() &lt;span style=&#34;color:#f92672&#34;&gt;&amp;amp;&lt;/span&gt; (F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user_id&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;-1&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：热点 key 加盐打散（真热点，比如爆款商品）&lt;/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;# 给大表的热点 key 加随机后缀 0~9，小表对应 key 复制 10 份&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;salted &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; big&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;withColumn(
&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;join_key&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&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;爆款SKU&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;           F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;concat(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&amp;#34;&lt;/span&gt;), F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_&amp;#34;&lt;/span&gt;), (F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;rand()&lt;span style=&#34;color:#f92672&#34;&gt;*&lt;/span&gt;&lt;span style=&#34;color:#ae81ff&#34;&gt;10&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;cast(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;int&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;.&lt;/span&gt;otherwise(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&amp;#34;&lt;/span&gt;)))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;small_exploded &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; small&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;withColumn(
&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;join_key&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;when(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&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;爆款SKU&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;           F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;concat(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&amp;#34;&lt;/span&gt;), F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(&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;                    F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;explode(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;array([F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lit(i) &lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; i &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; range(&lt;span style=&#34;color:#ae81ff&#34;&gt;10&lt;/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;.&lt;/span&gt;otherwise(F&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;col(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_id&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：开启 AQE 自动处理（Spark 3.x 的救星）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;conf&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;set(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;spark.sql.adaptive.enabled&amp;#34;&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;spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;conf&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;set(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;spark.sql.adaptive.skewJoin.enabled&amp;#34;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 4：AQE 救了我一个通宵&lt;/strong&gt;
2022 年上半年迁移 Spark 3.x 后开了 AQE（Adaptive Query Execution），它能&lt;strong&gt;在运行时&lt;/strong&gt;发现倾斜的 Partition 并自动拆分。以前要手工调参数 &lt;code&gt;spark.sql.shuffle.partitions&lt;/code&gt; 反复试的任务，AQE 直接自己搞定了。所以第一建议永远是：&lt;strong&gt;先升级到 Spark 3.x 并开启 AQE，再用脑子去调&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&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 倾斜前[&amp;#34;倾斜：一个 Partition 吞掉 40% 数据&amp;#34;]
        K1[&amp;#34;Partition 1: 50MB&amp;#34;] 
        K2[&amp;#34;Partition 2: 8GB 😱&amp;#34;] 
        K3[&amp;#34;Partition 3: 50MB&amp;#34;]
    end
    subgraph 倾斜后[&amp;#34;AQE 拆分后：化整为零&amp;#34;]
        L1[&amp;#34;Partition 1: 50MB&amp;#34;]
        L2a[&amp;#34;Partition 2a: 200MB&amp;#34;]
        L2b[&amp;#34;Partition 2b: 200MB&amp;#34;]
        L2c[&amp;#34;Partition 2c: 200MB&amp;lt;br/&amp;gt;...共拆 40 份&amp;#34;]
        L3[&amp;#34;Partition 3: 50MB&amp;#34;]
    end
    倾斜前 --&amp;gt;|&amp;#34;AQE 运行时检测拆分&amp;#34;| 倾斜后
    style K2 fill:#ffcccc&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;52-天坑二oom内存不够但到底谁不够&#34;&gt;5.2 天坑二：OOM（内存不够，但到底谁不够）&lt;/h3&gt;
&lt;p&gt;Spark 的 OOM 分两种，报错长得像，病因完全不同：&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;code&gt;ExecutorLostFailure&lt;/code&gt; / 容器被 kill / &lt;code&gt;java.lang.OutOfMemoryError&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;Executor 内存不够&lt;/strong&gt;：数据太多、缓存太大&lt;/td&gt;
					&lt;td&gt;加 &lt;code&gt;executor-memory&lt;/code&gt;、增加分区数摊薄数据、减少 &lt;code&gt;cache&lt;/code&gt; 滥用&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Driver 端 OOM&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;Driver 内存不够&lt;/strong&gt;：几乎永远是 &lt;code&gt;collect()&lt;/code&gt; / &lt;code&gt;toPandas()&lt;/code&gt; 把全量数据拉回 Driver&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;不要用 collect 收大数据！&lt;/strong&gt; 用 &lt;code&gt;take(100)&lt;/code&gt; 抽样看，用 &lt;code&gt;write&lt;/code&gt; 落盘&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 5：一个 collect 拖垮了整个集群调度&lt;/strong&gt;
同事为了&amp;quot;快速验证&amp;quot;，在生产任务里写了 &lt;code&gt;df.collect()&lt;/code&gt; 然后 len() 一下——那张表 2 亿行。Driver 内存从 4G 一路撑爆，任务挂掉不说，还因为是调度系统的重要任务，报警电话打到了总监那里。&lt;strong&gt;从此组里 Code Review 第一条红线：搜索代码里有没有 &lt;code&gt;collect()&lt;/code&gt;、&lt;code&gt;toPandas()&lt;/code&gt;，有就必须说明数据量上限&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;53-天坑三小文件灾难hdfs-的慢性病&#34;&gt;5.3 天坑三：小文件灾难（HDFS 的慢性病）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：任务很快跑完，但写出的结果目录里是 &lt;strong&gt;20000 个 100KB 的小文件&lt;/strong&gt;。然后：Hive 查这张表慢得离谱，NameNode 内存报警，下游任务读它要启动几万个 Task。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：200 个分区 × 每次写 100 个文件 = 每天积累，几个月后就是几百万小文件。HDFS 的 NameNode 把每个文件的元数据放内存，小文件多是集群的头号慢性病。&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-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 写出前合并分区（注意：coalesce 不 Shuffle，repartition 会 Shuffle）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;df&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;coalesce(&lt;span style=&#34;color:#ae81ff&#34;&gt;20&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parquet(&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;df&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;repartition(&lt;span style=&#34;color:#ae81ff&#34;&gt;20&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;dt&amp;#34;&lt;/span&gt;)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;write&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parquet(&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&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# 根治：开启 AQE 的 Shuffle 分区自动合并，从源头减少小文件&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;spark&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;conf&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;set(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;spark.sql.adaptive.coalescePartitions.enabled&amp;#34;&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&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 TD
    Q1{&amp;#34;任务挂了还是慢？&amp;#34;}
    Q1 --&amp;gt;|挂了| Q2{&amp;#34;Driver 日志里&amp;lt;br/&amp;gt;OOM 关键字？&amp;#34;}
    Q2 --&amp;gt;|Executor 端| F1[&amp;#34;加 executor-memory&amp;lt;br/&amp;gt;或增大分区数&amp;#34;]
    Q2 --&amp;gt;|Driver 端| F2[&amp;#34;查 collect/toPandas&amp;lt;br/&amp;gt;换成 write 或 take&amp;#34;]
    Q2 --&amp;gt;|文件不存在等| F3[&amp;#34;懒执行报错错位&amp;lt;br/&amp;gt;从执行计划往上找源头&amp;#34;]
    Q1 --&amp;gt;|慢| Q3{&amp;#34;Spark UI 看 Stage：&amp;lt;br/&amp;gt;个别 Task 特别慢？&amp;#34;}
    Q3 --&amp;gt;|是 Max远大于Median| F4[&amp;#34;数据倾斜&amp;lt;br/&amp;gt;过滤脏key/加盐/AQE&amp;#34;]
    Q3 --&amp;gt;|否 全都慢| Q4{&amp;#34;Shuffle Read/Write&amp;lt;br/&amp;gt;数据量巨大？&amp;#34;}
    Q4 --&amp;gt;|是| F5[&amp;#34;能广播的小表广播&amp;lt;br/&amp;gt;列裁剪 过滤下推&amp;lt;br/&amp;gt;提前聚合减少数据量&amp;#34;]
    Q4 --&amp;gt;|否| F6[&amp;#34;查上游：小文件太多？&amp;lt;br/&amp;gt;查集群：资源不够排队？&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;第六篇-ai-时代少写代码完成大数据开发&#34;&gt;第六篇 AI 时代：少写代码，完成大数据开发&lt;/h2&gt;
&lt;p&gt;时间快进到 AI 编程工具普及之后。回头看，Spark 开发是&lt;strong&gt;最适合被 AI 改造&lt;/strong&gt;的领域之一，因为它的代码模式极其规整：读、变换、聚合、写，套路就那么多。&lt;/p&gt;
&lt;h3 id=&#34;61-ai-能替你做什么不能替你做什么&#34;&gt;6.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 AI擅长[&amp;#34;AI 做得比人好&amp;#34;]
        A1[&amp;#34;自然语言 → PySpark/SQL 代码&amp;lt;br/&amp;gt;按用户30天行为算RFM特征 一句话出码&amp;#34;]
        A2[&amp;#34;样板代码生成&amp;lt;br/&amp;gt;Schema 定义 造数脚本 单元测试&amp;#34;]
        A3[&amp;#34;报错诊断&amp;lt;br/&amp;gt;贴堆栈 直接定位到懒执行错位/倾斜&amp;#34;]
        A4[&amp;#34;口径文档化&amp;lt;br/&amp;gt;把代码翻译成业务能看懂的白话&amp;#34;]
    end
    subgraph AI不擅长[&amp;#34;人必须自己把关&amp;#34;]
        B1[&amp;#34;业务口径&amp;lt;br/&amp;gt;漏斗是严格顺序还是存在性&amp;lt;br/&amp;gt;AI 不知道你老板要什么&amp;#34;]
        B2[&amp;#34;性能直觉&amp;lt;br/&amp;gt;哪里该广播 哪里会倾斜&amp;lt;br/&amp;gt;要看真实数据分布&amp;#34;]
        B3[&amp;#34;生产决策&amp;lt;br/&amp;gt;资源参数 重跑策略 数据回补&amp;#34;]
    end&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;：写「用户漏斗分析」= 查文档 30 分钟 + 写代码 40 分钟 + 调试 30 分钟。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;现在&lt;/strong&gt;：给 AI 描述清楚——表名、字段、漏斗定义（严格按时间顺序）、输出格式——10 秒出代码，我花 20 分钟 review 口径和边界条件。&lt;strong&gt;总耗时砍到三分之一&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但注意：&lt;strong&gt;省下来的时间全部应该砸在口径确认和数据校验上&lt;/strong&gt;。AI 写的代码语法几乎不会错，但它不知道你们的 &lt;code&gt;user_id = &#39;-1&#39;&lt;/code&gt; 是脏数据、不知道运营要的是存在性漏斗。这正是第五篇那些坑的价值——&lt;strong&gt;AI 时代，踩坑经验不是贬值了，而是成了你最贵的资产&lt;/strong&gt;，因为代码人人都能生成，能判断代码对不对的人变稀缺了。&lt;/p&gt;
&lt;h3 id=&#34;62-具体怎么干三个提效姿势&#34;&gt;6.2 具体怎么干：三个提效姿势&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;姿势一：需求描述模板化。&lt;/strong&gt; 给 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&gt;背景：表 ods.user_events，字段 user_id/event_type/ts，按天分区 dt
任务：计算 2022-10-25 的转化漏斗 曝光→点击→加购→下单→支付
口径：严格顺序漏斗，后一步的最早时间必须大于前一步
输出：一行五列，列名用中文&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;第四篇场景三的完整代码，用这样的提示词一次就能生成得八九不离十。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;姿势二：报错即提示词。&lt;/strong&gt; Spark 报错堆栈又长又唬人，直接整段贴给 AI，附上你的 Transformation 代码，它对「懒执行错位」「ClassNotFound（依赖没打包）」「分区越界」这类经典错误的诊断准确率很高。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;姿势三：让 AI 写测试和校验脚本。&lt;/strong&gt; 大数据开发最缺的就是测试——数据对不对以前靠人肉抽查。现在让 AI 顺手生成校验 SQL（行数对比、NULL 比例、金额总和与上游对账），质量反而比手工时代更好了。&lt;/p&gt;
&lt;h3 id=&#34;63-冷静的一句话&#34;&gt;6.3 冷静的一句话&lt;/h3&gt;
&lt;p&gt;AI 不会让你&amp;quot;少学 Spark&amp;quot;，它让&lt;strong&gt;学习曲线的重心变了&lt;/strong&gt;：以前背 API 占 60% 的时间，现在这部分 AI 补上了；剩下 40%——架构直觉、业务口径、排障经验——变成了 100% 的核心竞争力。&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;p&gt;&lt;strong&gt;1. 学 Spark 的本质是学&amp;quot;分布式的世界观&amp;quot;。&lt;/strong&gt; 单机时代的直觉——数据随手可得、循环遍历随意、内存无限——全都要扔掉。你要习惯一种新的思维方式：数据是流动的、分区是平行的、Shuffle 是昂贵的、失败是必然的（所以容错设计是一等公民）。这套世界观建立起来之后，学 Flink、学 Kafka、学 ClickHouse 都会很快，因为它们要解决的是同一族问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 调优经验 = 疼痛的记忆。&lt;/strong&gt; 没有人看文档能学会处理数据倾斜，都是被凌晨两点的报警电话教会的。所以别怕踩坑，踩了记下来——我这篇第五篇的每个坑，当年都值一个通宵。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 离业务近一点，离技术远一点。&lt;/strong&gt; 做了几年大数据，最大的感悟是：把 ETL 跑得快不算本事，&lt;strong&gt;知道这张表给谁用、口径对不对、错了谁会亏钱&lt;/strong&gt;才是本事。电商场景里，GMV 算错 1% 可能就是一个部门的年终奖。技术只是手段，数据是业务的镜像。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. AI 时代最值得押注的能力是&amp;quot;判断力&amp;quot;。&lt;/strong&gt; 生成代码会越来越便宜，判断&amp;quot;这段代码对不对、这个口径该不该这么定、这个架构扛不扛得住双十一&amp;quot;会越来越贵。把 AI 当成一个能力极强但没有常识的实习生：活让它干，锅你来背，所以你必须看得懂它干的活。&lt;/p&gt;
&lt;p&gt;与君共勉。&lt;/p&gt;
</description>
    </item>
    <item>
      <title>React 从入门到精通：环境搭建、核心语法、组件库选型与 AI 时代的开发方式</title>
      <link>https://ftmao.com/blog/posts/react-beginner-to-mastery/</link>
      <pubDate>Fri, 26 Aug 2022 10:00:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/react-beginner-to-mastery/</guid>
      <description>&lt;h1 id=&#34;react-从入门到精通&#34;&gt;React 从入门到精通&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：会 HTML/CSS/JS 基础，想系统学前端框架的同学；或写过 Vue/jQuery 想转 React 的工程师。
阅读目标：把环境搭起来、把核心语法学会、把坑提前踩掉、知道组件库怎么选，最后聊聊 AI 时代写前端的新姿势。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;React 的入门门槛不高，但「会写」和「写对」之间隔着一批经典的坑。这篇按真实学习顺序组织：环境 → 语法 → 心智模型 → 踩坑 → 组件库 → Demo → 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;JS 基础&amp;lt;br/&amp;gt;ES6 语法&amp;#34;] --&amp;gt; B[&amp;#34;环境搭建&amp;lt;br/&amp;gt;Node + Vite&amp;#34;]
    B --&amp;gt; C[&amp;#34;核心语法&amp;lt;br/&amp;gt;JSX / 组件 / Props / State&amp;#34;]
    C --&amp;gt; D[&amp;#34;Hooks&amp;lt;br/&amp;gt;useState / useEffect / 自定义 Hook&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;AI 协同开发&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;二开发环境搭建2022-年的正确姿势&#34;&gt;二、开发环境搭建（2022 年的正确姿势）&lt;/h2&gt;
&lt;h3 id=&#34;21-装-node用-nvm不要官网下载安装包&#34;&gt;2.1 装 Node：用 nvm，不要官网下载安装包&lt;/h3&gt;
&lt;p&gt;第一个坑往往出在环境上：老教程让你去 nodejs.org 下安装包，半年后项目要求换个 Node 版本，你就得重装。&lt;strong&gt;用 nvm 管理多版本&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-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 用 nvm-windows，Mac/Linux 用 nvm&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;nvm install &lt;span style=&#34;color:#ae81ff&#34;&gt;16&lt;/span&gt;        &lt;span style=&#34;color:#75715e&#34;&gt;# 2022 年的 LTS 是 16/18&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;nvm use &lt;span style=&#34;color:#ae81ff&#34;&gt;16&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;node -v               &lt;span style=&#34;color:#75715e&#34;&gt;# v16.x.x&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;npm config set registry https://registry.npmmirror.com&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&#34;22-创建项目用-vite不要-create-react-app&#34;&gt;2.2 创建项目：用 Vite，不要 create-react-app&lt;/h3&gt;
&lt;p&gt;2022 年的第二个坑：老教程还在教 &lt;code&gt;create-react-app&lt;/code&gt;（CRA），它启动慢、打包慢、官方已基本停止维护。&lt;strong&gt;用 Vite&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-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm create vite@latest my-app -- --template react
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;cd my-app
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm install
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm run dev           &lt;span style=&#34;color:#75715e&#34;&gt;# 打开 http://localhost:5173&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;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-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;my-app/
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;├── index.html          # 入口 HTML
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;├── package.json
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;├── vite.config.js
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;└── src/
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ├── main.jsx        # 挂载根组件
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ├── App.jsx         # 根组件
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    └── components/     # 自己的组件放这里&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;23-编辑器与插件&#34;&gt;2.3 编辑器与插件&lt;/h3&gt;
&lt;p&gt;VS Code + 两个必装插件：&lt;strong&gt;ES7+ React Snippets&lt;/strong&gt;（输入 &lt;code&gt;rafce&lt;/code&gt; 一键生成组件骨架）、&lt;strong&gt;ESLint&lt;/strong&gt;（边写边提示 Hooks 依赖遗漏等问题）。再开 VS Code 的保存自动格式化（Prettier），团队风格从此不打架。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;三核心语法记住一个公式就够了&#34;&gt;三、核心语法：记住一个公式就够了&lt;/h2&gt;
&lt;h3 id=&#34;31-心智模型ui--fstate&#34;&gt;3.1 心智模型：UI = f(state)&lt;/h3&gt;
&lt;p&gt;React 的全部思想一句话：&lt;strong&gt;界面是状态的函数&lt;/strong&gt;。你不直接操作 DOM，只改数据（state），React 负责把界面更新到和数据一致。&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
    S[&amp;#34;state 变化&amp;lt;br/&amp;gt;setState&amp;#34;] --&amp;gt; R[&amp;#34;React 重新执行组件函数&amp;lt;br/&amp;gt;得到新的虚拟 DOM&amp;#34;]
    R --&amp;gt; D[&amp;#34;Diff：对比新旧虚拟 DOM&amp;lt;br/&amp;gt;找出最小差异&amp;#34;]
    D --&amp;gt; DOM[&amp;#34;只把差异 patch 到真实 DOM&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这就是为什么 React 代码里&lt;strong&gt;永远不要 &lt;code&gt;document.getElementById&lt;/code&gt; 去改 DOM&lt;/strong&gt;——绕过状态直接改界面，两边就对不上了。&lt;/p&gt;
&lt;h3 id=&#34;32-组件与-jsx&#34;&gt;3.2 组件与 JSX&lt;/h3&gt;
&lt;p&gt;组件就是一个返回 JSX 的函数。JSX 是「长得像 HTML 的 JS」，注意三条规则：标签必须闭合、属性用驼峰（&lt;code&gt;className&lt;/code&gt; 不是 &lt;code&gt;class&lt;/code&gt;）、JS 表达式放 &lt;code&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-jsx&#34; data-lang=&#34;jsx&#34;&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;Welcome&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt; }) {          &lt;span style=&#34;color:#75715e&#34;&gt;// props 是父组件传进来的参数
&lt;/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; &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;h1&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;className&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;&lt;span style=&#34;color:#a6e22e&#34;&gt;你好&lt;/span&gt;, {&lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt;}&lt;span style=&#34;color:#f92672&#34;&gt;!&lt;/span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;h1&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;
&lt;/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;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;return&lt;/span&gt; &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;Welcome&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;React&amp;#34;&lt;/span&gt; /&amp;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;h3 id=&#34;33-state-与事件让界面动起来&#34;&gt;3.3 state 与事件：让界面动起来&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-jsx&#34; data-lang=&#34;jsx&#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;useState&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;react&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;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;Counter&lt;/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;count&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;setCount&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;useState&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:#66d9ef&#34;&gt;return&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;onClick&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{() =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;setCount&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;)}&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;点了&lt;/span&gt; {&lt;span style=&#34;color:#a6e22e&#34;&gt;count&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;    &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&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;props 是父组件给的（只读），state 是自己的（用 set 函数改）&lt;/strong&gt;。数据流向永远是父 → 子，这叫单向数据流，是 React 排查问题比双向绑定框架容易的根本原因。&lt;/p&gt;
&lt;h3 id=&#34;34-列表渲染与-key&#34;&gt;3.4 列表渲染与 key&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-jsx&#34; data-lang=&#34;jsx&#34;&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;TodoList&lt;/span&gt;({ &lt;span style=&#34;color:#a6e22e&#34;&gt;items&lt;/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;    &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;ul&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;items&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;map&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;item&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;li&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;key&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;}&amp;gt;{&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;text&lt;/span&gt;}&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;li&lt;/span&gt;&amp;gt;   &lt;span style=&#34;color:#75715e&#34;&gt;// key 用稳定 id，不要用 index！
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;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;ul&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;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;h3 id=&#34;35-useeffect处理副作用&#34;&gt;3.5 useEffect：处理副作用&lt;/h3&gt;
&lt;p&gt;数据请求、定时器、订阅这类「和渲染无关的事」放 &lt;code&gt;useEffect&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;useEffect&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;fetch&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;/api/todos&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;then&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;r&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;r&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;json&lt;/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;then&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;setTodos&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}, []);   &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;// 依赖数组为空 = 只在组件首次渲染后执行一次&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;四踩坑故事六个我真实掉进去过的坑&#34;&gt;四、踩坑故事：六个我真实掉进去过的坑&lt;/h2&gt;
&lt;h3 id=&#34;坑-1setstate-是异步的连续-1-只加了一次&#34;&gt;坑 1：setState 是「异步」的，连续 +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;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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 想点一下加 3，结果只加了 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:#a6e22e&#34;&gt;setCount&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:#a6e22e&#34;&gt;setCount&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:#a6e22e&#34;&gt;setCount&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;原因：三次 &lt;code&gt;count&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;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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;setCount&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;c&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;c&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:#a6e22e&#34;&gt;setCount&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;c&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;c&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:#a6e22e&#34;&gt;setCount&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;c&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;c&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:#960050;background-color:#1e0010&#34;&gt;// 这次真的 +3&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;新 state 依赖旧 state 时，永远用函数形式&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;坑-2useeffect-里-setstate死循环刷爆页面&#34;&gt;坑 2：useEffect 里 setState，死循环刷爆页面&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;useEffect&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;setList&lt;/span&gt;([...&lt;span style=&#34;color:#a6e22e&#34;&gt;list&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;newItem&lt;/span&gt;]);   &lt;span style=&#34;color:#75715e&#34;&gt;// list 变了 → 重新渲染 → effect 又执行 → 死循环
&lt;/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;list&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;effect 的依赖里有没有它自己改的东西&lt;/strong&gt;」。解法通常是改用函数式更新把依赖摘掉：&lt;code&gt;setList((l) =&amp;gt; [...l, newItem])&lt;/code&gt;，依赖数组就能清空。&lt;/p&gt;
&lt;h3 id=&#34;坑-3列表-key-用-index勾选状态全乱了&#34;&gt;坑 3：列表 key 用 index，勾选状态全乱了&lt;/h3&gt;
&lt;p&gt;Todo 列表勾了第 1 项，删掉它之后，第 2 项变成「已勾选」——因为 key 用 index 时，React 按位置复用 DOM，删除一项后后面所有项位置错位，复用错了组件内部状态。&lt;strong&gt;key 必须用数据的稳定 id&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;坑-4直接改-state-对象界面死活不更新&#34;&gt;坑 4：直接改 state 对象，界面死活不更新&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;user&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;name&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:#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;setUser&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;user&lt;/span&gt;);           &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;// 还是没反应——引用没变，React 认为没变化&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;React 靠&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;setUser&lt;/span&gt;({ ...&lt;span style=&#34;color:#a6e22e&#34;&gt;user&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;name&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;数组同理：用 &lt;code&gt;filter/map/concat&lt;/code&gt; 造新数组，不要 &lt;code&gt;push/splice&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id=&#34;坑-5定时器里的-stale-closure闭包旧值&#34;&gt;坑 5：定时器里的 stale closure（闭包旧值）&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;useEffect&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;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:#a6e22e&#34;&gt;console&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;log&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;count&lt;/span&gt;);        &lt;span style=&#34;color:#75715e&#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:#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:#66d9ef&#34;&gt;return&lt;/span&gt; () =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;clearInterval&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;effect 只在首次渲染执行，回调里捕获的 &lt;code&gt;count&lt;/code&gt; 永远是首次渲染的旧值。解法：把 &lt;code&gt;count&lt;/code&gt; 加进依赖数组，或用 &lt;code&gt;useRef&lt;/code&gt; 保存最新值。这类 bug 不报错、只是数据不新鲜，最难查——&lt;strong&gt;ESLint 的 exhaustive-deps 规则能提前拦住大半，别随手关掉&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;坑-6在组件里定义组件输入框每敲一个字就失焦&#34;&gt;坑 6：在组件里定义组件，输入框每敲一个字就失焦&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-jsx&#34; data-lang=&#34;jsx&#34;&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;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;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;Input&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; () =&amp;gt; &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;input&lt;/span&gt; /&amp;gt;;   &lt;span style=&#34;color:#75715e&#34;&gt;// 每次渲染都是全新组件，DOM 被销毁重建
&lt;/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; &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;Input&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;组件定义必须放模块顶层。这个坑的典型症状是「input 输入一个字符就丢焦点」，看到这个词直接想到它。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;五底层原理为什么-react-是这样设计的&#34;&gt;五、底层原理：为什么 React 是这样设计的&lt;/h2&gt;
&lt;p&gt;前面六个坑，背规则可以避开，但理解原理才能「预判」。这一章讲透四件事：虚拟 DOM 与 Diff、Fiber 架构、渲染两阶段、Hooks 的实现。学完你会发现：&lt;strong&gt;第四节所有的坑，都是这四个原理的直接推论&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id=&#34;51-虚拟-dom-与-diff-算法用-js-对象换性能&#34;&gt;5.1 虚拟 DOM 与 Diff 算法：用 JS 对象换性能&lt;/h3&gt;
&lt;p&gt;虚拟 DOM 就是用普通 JS 对象描述界面长什么样（&lt;code&gt;{ type: &#39;div&#39;, props: {...}, children: [...] }&lt;/code&gt;）。每次 state 变化，React 重新生成一棵新的虚拟 DOM 树，和旧的对比（Diff），只把差异更新到真实 DOM——直接操作 DOM 慢，操作 JS 对象快，这就是虚拟 DOM 的收益。&lt;/p&gt;
&lt;p&gt;全量对比两棵树的算法复杂度是 O(n³)，一万个节点就是十亿次比较，不可用。React 用三条「妥协」策略把它降到 O(n)：&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
    subgraph 策略[&amp;#34;Diff 的三条妥协策略&amp;#34;]
        T[&amp;#34;tree diff：只同层比较&amp;lt;br/&amp;gt;不跨层移动节点&amp;#34;]
        C[&amp;#34;component diff：组件类型变了&amp;lt;br/&amp;gt;整棵子树直接销毁重建&amp;#34;]
        E[&amp;#34;element diff：同层列表&amp;lt;br/&amp;gt;靠 key 识别 谁移动/新增/删除&amp;#34;]
    end
    T --&amp;gt; R[&amp;#34;结果：O(n) 完成对比&amp;#34;]
    C --&amp;gt; R
    E --&amp;gt; R&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;三条策略各自对应一条实践纪律：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;tree diff&lt;/strong&gt; → 跨层级移动 DOM 节点代价极大，设计组件树时保持层级稳定；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;component diff&lt;/strong&gt; → 同一个位置不要渲染「类型忽变」的组件（&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; 和 &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; 来回切换），类型一变整棵重建；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;element diff&lt;/strong&gt; → 这就是&lt;strong&gt;坑 3 的原理&lt;/strong&gt;：key 是 React 识别「哪个节点是哪个」的唯一依据，用 index 当 key，删除中间项后后面所有 key 全变，React 就把节点张冠李戴了。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;52-fiber-架构把渲染拆成可中断的小任务&#34;&gt;5.2 Fiber 架构：把渲染拆成可中断的小任务&lt;/h3&gt;
&lt;p&gt;React 15 之前，更新是递归遍历组件树，一旦开始就不能停——组件树大了，一帧 16ms 里算不完，页面就掉帧卡顿。React 16 重写架构，引入 &lt;strong&gt;Fiber&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fiber 是什么&lt;/strong&gt;：每个组件对应一个 Fiber 节点，渲染工作被拆成一个个以 Fiber 为单位的小任务；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;怎么连起来&lt;/strong&gt;：节点之间不用递归，用链表（&lt;code&gt;child&lt;/code&gt; 第一个子节点 / &lt;code&gt;sibling&lt;/code&gt; 下一个兄弟 / &lt;code&gt;return&lt;/code&gt; 父节点）——&lt;strong&gt;用指针代替调用栈，所以随时可以暂停、恢复、放弃&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;双缓存&lt;/strong&gt;：内存里同时存在两棵树——屏幕上正在显示的 &lt;code&gt;current&lt;/code&gt; 树，和后台正在构建的 &lt;code&gt;workInProgress&lt;/code&gt; 树。新树在后台慢慢算，算完一次性把指针切过去，用户永远看不到「渲染到一半」的界面。&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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 旧架构[&amp;#34;React 15 栈调和&amp;#34;]
        A1[&amp;#34;递归遍历整棵树&amp;lt;br/&amp;gt;不可中断&amp;#34;] --&amp;gt; A2[&amp;#34;大树直接卡顿掉帧&amp;#34;]
    end
    subgraph 新架构[&amp;#34;React 16+ Fiber&amp;#34;]
        B1[&amp;#34;每个 Fiber 一个小任务&amp;lt;br/&amp;gt;可随时暂停让出主线程&amp;#34;] --&amp;gt; B2[&amp;#34;高优先级任务插队&amp;lt;br/&amp;gt;（用户输入 &amp;gt; 列表渲染）&amp;#34;]
        B2 --&amp;gt; B3[&amp;#34;workInProgress 树算完&amp;lt;br/&amp;gt;一次性切换&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;时间切片&lt;/strong&gt;带来的直接体验：输入框输入（高优先级）可以打断长列表渲染（低优先级），输入再也不卡。&lt;code&gt;useTransition&lt;/code&gt; 就是把这个优先级能力开放给开发者——把「不紧急的更新」包进 &lt;code&gt;startTransition&lt;/code&gt;，界面保持响应。&lt;/p&gt;
&lt;h3 id=&#34;53-渲染两阶段render-可中断commit-不可中断&#34;&gt;5.3 渲染两阶段：render 可中断，commit 不可中断&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
    subgraph R[&amp;#34;render 阶段（可中断）&amp;#34;]
        R1[&amp;#34;调用组件函数&amp;lt;br/&amp;gt;生成新虚拟 DOM&amp;#34;]
        R2[&amp;#34;Diff，算出变更清单&amp;#34;]
    end
    subgraph C[&amp;#34;commit 阶段（不可中断）&amp;#34;]
        C1[&amp;#34;把变更一次性&amp;lt;br/&amp;gt;写入真实 DOM&amp;#34;]
        C2[&amp;#34;执行 useLayoutEffect&amp;lt;br/&amp;gt;再异步执行 useEffect&amp;#34;]
    end
    R --&amp;gt; C&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;（发请求、改全局变量）：render 阶段可中断、可重试、&lt;strong&gt;可能被调用多次&lt;/strong&gt;，副作用会重复执行。所以副作用必须放 &lt;code&gt;useEffect&lt;/code&gt;——它在 commit 之后执行，每次提交只跑一次。&lt;strong&gt;坑 2 的死循环本质也是没搞清这个边界&lt;/strong&gt;：effect 里改 state 触发新一轮 render→commit→effect，链条首尾相接就成了循环。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;为什么 setState 看起来是异步的&lt;/strong&gt;（坑 1）：setState 只是「登记一个更新任务」，组件函数要等 React 调度到 render 阶段才重新执行。你在 &lt;code&gt;setCount&lt;/code&gt; 之后立刻读 &lt;code&gt;count&lt;/code&gt;，读到的当然是本次渲染的旧值。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&#34;54-hooks-的实现一条链表--调用顺序&#34;&gt;5.4 Hooks 的实现：一条链表 + 调用顺序&lt;/h3&gt;
&lt;p&gt;Hooks 没有魔法。每个 Fiber 节点上挂着一条&lt;strong&gt;链表&lt;/strong&gt;，组件每次调用一个 Hook（useState/useEffect/&amp;hellip;），React 就顺着链表取下一个节点存状态：&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
    F[&amp;#34;Fiber 节点&amp;lt;br/&amp;gt;memoizedState&amp;#34;] --&amp;gt; H1[&amp;#34;Hook 节点 1&amp;lt;br/&amp;gt;useState count&amp;#34;]
    H1 --&amp;gt; H2[&amp;#34;Hook 节点 2&amp;lt;br/&amp;gt;useState text&amp;#34;]
    H2 --&amp;gt; H3[&amp;#34;Hook 节点 3&amp;lt;br/&amp;gt;useEffect&amp;#34;]
    H3 --&amp;gt; N[&amp;#34;null&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;关键：&lt;strong&gt;状态不是按变量名存的，是按调用顺序存的&lt;/strong&gt;。第 n 次调用 useState，就拿链表上第 n 个节点。这就是「&lt;strong&gt;Hook 不能写在 if/循环里&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-jsx&#34; data-lang=&#34;jsx&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// ❌ 条件调用：show 为 false 时少调一次，后面所有 Hook 状态错位
&lt;/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;show&lt;/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;a&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;setA&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;useState&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&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;b&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;setB&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;useState&lt;/span&gt;(&lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;用一个 20 行的迷你实现体会一下原理：&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;// 极简版 useState：一个数组 + 一个游标
&lt;/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;hookStates&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; &lt;span style=&#34;color:#a6e22e&#34;&gt;cursor&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&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;useState&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;initial&lt;/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;i&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;cursor&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;++&lt;/span&gt;;                    &lt;span style=&#34;color:#75715e&#34;&gt;// 本次调用占第 i 个槽位
&lt;/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;hookStates&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;hookStates&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;??&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;initial&lt;/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;setState&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;v&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;hookStates&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;typeof&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;v&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;===&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;function&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;?&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;v&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;hookStates&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;]) &lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;v&lt;/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;cursor&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;render&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:#66d9ef&#34;&gt;return&lt;/span&gt; [&lt;span style=&#34;color:#a6e22e&#34;&gt;hookStates&lt;/span&gt;[&lt;span style=&#34;color:#a6e22e&#34;&gt;i&lt;/span&gt;], &lt;span style=&#34;color:#a6e22e&#34;&gt;setState&lt;/span&gt;];
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;// 每次 render 前 cursor 归零，Hook 按调用顺序对号入座
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;真实 React 里这个数组换成 Fiber 上的链表、render 换成调度器触发，思想一模一样。也顺带解释了&lt;strong&gt;坑 5（stale closure）&lt;/strong&gt;：effect 回调捕获的是「它创建那次渲染」的作用域，而 Hook 槽位里的状态早已更新——两者不是一份数据，于是读到旧值。&lt;/p&gt;
&lt;h3 id=&#34;55-批量更新多次-setstate-合并成一次渲染&#34;&gt;5.5 批量更新：多次 setState 合并成一次渲染&lt;/h3&gt;
&lt;p&gt;React 会把同一个事件循环里的多次 setState &lt;strong&gt;合并（batching）成一次渲染&lt;/strong&gt;，避免白白渲染三次。React 18 起连 Promise、setTimeout 里的更新也自动批量。坑 1 的「连续 +1 只加一次」正是批量更新的副产品：三次调用都基于同一个旧值计算，合并后只生效最后一次；函数式更新 &lt;code&gt;setCount(c =&amp;gt; c + 1)&lt;/code&gt; 之所以能正确累加，是因为每次计算都基于「上一个更新任务的结果」而不是旧快照。&lt;/p&gt;
&lt;h3 id=&#34;56-一张图总结从-setstate-到屏幕更新&#34;&gt;5.6 一张图总结：从 setState 到屏幕更新&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
    A[&amp;#34;setState 登记更新任务&amp;#34;] --&amp;gt; B[&amp;#34;Scheduler 调度&amp;lt;br/&amp;gt;按优先级插队/合并&amp;#34;]
    B --&amp;gt; C[&amp;#34;render 阶段（可中断）&amp;lt;br/&amp;gt;沿 Fiber 链表逐个执行组件函数&amp;lt;br/&amp;gt;Hooks 按调用顺序取状态&amp;#34;]
    C --&amp;gt; D[&amp;#34;生成 workInProgress 树&amp;lt;br/&amp;gt;Diff 出变更清单&amp;#34;]
    D --&amp;gt; E[&amp;#34;commit 阶段（不可中断）&amp;lt;br/&amp;gt;一次性写入真实 DOM&amp;#34;]
    E --&amp;gt; F[&amp;#34;执行 useEffect&amp;#34;]
    F --&amp;gt;|&amp;#34;effect 里又 setState&amp;#34;| A&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;回头看第四节的坑：&lt;strong&gt;坑 1 是批量更新、坑 2 是 render/commit 边界、坑 3 是 element diff、坑 4 是引用比较（Diff 的判断依据）、坑 5 是 Hooks 快照机制、坑 6 是 component diff（类型变=重建）&lt;/strong&gt;。规则会过时，原理不会——这就是先学原理的价值。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;六主流-ui-组件库选型对比&#34;&gt;六、主流 UI 组件库：选型对比&lt;/h2&gt;
&lt;p&gt;别从零写按钮和表格，组件库选一个用熟：&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;th&gt;备注&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;strong&gt;Ant Design (antd)&lt;/strong&gt;&lt;/td&gt;
					&lt;td&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;Material UI (MUI)&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;Google Material 风格&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;Ant Design Mobile&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;移动端&lt;/td&gt;
					&lt;td&gt;H5/混合 APP&lt;/td&gt;
					&lt;td&gt;与 antd 同门&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;strong&gt;Tailwind + Headless UI&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;原子化 CSS&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;Chakra UI&lt;/strong&gt;&lt;/td&gt;
					&lt;td&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;中后台选 antd 基本不会错。三分钟搭出一个像样的页面：&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-jsx&#34; data-lang=&#34;jsx&#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;Table&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;Button&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;Form&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;Input&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;antd&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;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;UserPage&lt;/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;columns&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;#34;姓名&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;dataIndex&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;name&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;title&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;dataIndex&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;email&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;return&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &amp;lt;&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;Form&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;layout&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;inline&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;onFinish&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{(&lt;span style=&#34;color:#a6e22e&#34;&gt;v&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;console&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;log&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;v&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;Form.Item&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;name&amp;#34;&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;&amp;gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;Input&lt;/span&gt; /&amp;gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;Form.Item&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;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;htmlType&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;submit&amp;#34;&lt;/span&gt;&amp;gt;&lt;span style=&#34;color:#a6e22e&#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;      &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;Form&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;Table&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;columns&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{&lt;span style=&#34;color:#a6e22e&#34;&gt;columns&lt;/span&gt;} &lt;span style=&#34;color:#a6e22e&#34;&gt;dataSource&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{[]} &lt;span style=&#34;color:#a6e22e&#34;&gt;rowKey&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; /&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &amp;lt;/&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;组件库避坑&lt;/strong&gt;：不要混用两个重型组件库（打包体积爆炸）；版本升级先看迁移指南（antd 4→5 改了样式方案）；表单校验规则写在 &lt;code&gt;rules&lt;/code&gt; 里而不是手写 if-else。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;七完整-demo一个-todo-app&#34;&gt;七、完整 Demo：一个 Todo App&lt;/h2&gt;
&lt;p&gt;把前面的知识拼起来——增删改查、状态提升、组件拆分，都在这 60 行里：&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-jsx&#34; data-lang=&#34;jsx&#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;useState&lt;/span&gt; } &lt;span style=&#34;color:#a6e22e&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;react&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;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;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;const&lt;/span&gt; [&lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;setTodos&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;useState&lt;/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;text&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;setText&lt;/span&gt;] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;useState&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 style=&#34;color:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;add&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&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;if&lt;/span&gt; (&lt;span style=&#34;color:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;text&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;trim&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;setTodos&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;t&lt;/span&gt;) =&amp;gt; [...&lt;span style=&#34;color:#a6e22e&#34;&gt;t&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 style=&#34;color:#a6e22e&#34;&gt;text&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:#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;setText&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:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;toggle&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;id&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;setTodos&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;t&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;t&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;map&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;x&lt;/span&gt;) =&amp;gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;x&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:#a6e22e&#34;&gt;id&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;?&lt;/span&gt; { ...&lt;span style=&#34;color:#a6e22e&#34;&gt;x&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:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;x&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:#a6e22e&#34;&gt;x&lt;/span&gt;)));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;remove&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;setTodos&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;t&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;t&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;filter&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;x&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;x&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:#a6e22e&#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&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;    &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;div&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;style&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{{ &lt;span style=&#34;color:#a6e22e&#34;&gt;maxWidth&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;400&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;margin&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;40px auto&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;h1&lt;/span&gt;&amp;gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;Todo&lt;/span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;h1&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;input&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:#a6e22e&#34;&gt;text&lt;/span&gt;} &lt;span style=&#34;color:#a6e22e&#34;&gt;onChange&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;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;setText&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;target&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;value&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;onClick&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{&lt;span style=&#34;color:#a6e22e&#34;&gt;add&lt;/span&gt;}&amp;gt;&lt;span style=&#34;color:#a6e22e&#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;      &amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;ul&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;todos&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;map&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;item&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;li&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;key&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;id&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;span&lt;/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;onClick&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{() =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;toggle&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#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:#a6e22e&#34;&gt;style&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{{ &lt;span style=&#34;color:#a6e22e&#34;&gt;textDecoration&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;item&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:#e6db74&#34;&gt;&amp;#34;line-through&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;none&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;              {&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;text&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;span&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;onClick&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;{() =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;remove&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;item&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;)}&amp;gt;&lt;span style=&#34;color:#a6e22e&#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;          &amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;li&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;ul&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;p&lt;/span&gt;&amp;gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;共&lt;/span&gt; {&lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt;} &lt;span style=&#34;color:#a6e22e&#34;&gt;项&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;完成&lt;/span&gt; {&lt;span style=&#34;color:#a6e22e&#34;&gt;todos&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;filter&lt;/span&gt;((&lt;span style=&#34;color:#a6e22e&#34;&gt;x&lt;/span&gt;) =&amp;gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;x&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;done&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt;} &lt;span style=&#34;color:#a6e22e&#34;&gt;项&lt;/span&gt;&amp;lt;/&lt;span style=&#34;color:#f92672&#34;&gt;p&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;div&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;}
&lt;/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;default&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;App&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;setTodos&lt;/code&gt; 全部函数式更新、key 用 &lt;code&gt;id&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;这篇博客里 80% 的代码，AI 现在都能直接生成&lt;/strong&gt;。Todo、表单、表格页这类「模式明确」的代码，人的价值已经不在敲键盘上。我现在的前端开发流程变成了这样：&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;AI：生成组件代码&amp;lt;br/&amp;gt;一次给完整页面而非碎片&amp;#34;]
    B --&amp;gt; C[&amp;#34;人：Review + 跑起来验证&amp;lt;br/&amp;gt;重点看 state 设计和依赖数组&amp;#34;]
    C --&amp;gt; D[&amp;#34;AI：按反馈迭代&amp;lt;br/&amp;gt;改样式 / 加校验 / 拆组件&amp;#34;]
    D --&amp;gt; E[&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;ol&gt;
&lt;li&gt;&lt;strong&gt;描述越具体，代码越能直接用&lt;/strong&gt;。「写个用户管理页」和「用 antd Table 写用户管理页，列：姓名/邮箱/状态，顶部一个内联查询表单，分页 pageSize 20，数据从 GET /api/users 拿」得到的代码质量天差地别——这和给实习生派活是一个道理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;让 AI 一次生成完整页面，而不是一次次要碎片&lt;/strong&gt;。碎片代码的集成成本比生成成本高；整页生成后你再删改，快得多。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review 的重点变了&lt;/strong&gt;：语法 AI 很少错，要盯的是架构层面的东西——state 放哪层合不合理、useEffect 依赖有没有坑（本文第四节的坑 AI 也会犯）、组件拆分粒度、key 用没用 index。&lt;strong&gt;基本功越扎实，Review 越快越准&lt;/strong&gt;——AI 淘汰的是只会写不会看的人。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工具形态&lt;/strong&gt;：代码补全（Copilot）管「写着顺」；对话式 IDE（Cursor、Claude Code）管「整个功能」；v0.dev 这类管「从截图/描述直接出页面」。三者是不同粒度，按任务大小选。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;APP 整体路径&lt;/strong&gt;：需求描述 → AI 出页面原型 → 选定组件库统一风格 → AI 按接口文档接数据 → 人工处理鉴权、错误处理、边界情况。一个中后台 CRUD 类 APP，这个流程能把工期压到原来的三分之一。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;九延伸话题为什么国内流行-vue海外是-react-统治&#34;&gt;九、延伸话题：为什么国内流行 Vue，海外是 React 统治&lt;/h2&gt;
&lt;p&gt;先校准这个说法：不是非黑即白——国内大厂核心产品用 React 的不少（蚂蚁的 Ant Design 就是 React 生态），海外 Laravel 社区也带起了一批 Vue 用户。但总体格局确实存在：&lt;strong&gt;国内中小厂、外包、移动端 H5 里 Vue 占比明显高，海外从创业公司到大厂 React 一家独大&lt;/strong&gt;（npm 全球周下载量 React 常年是 Vue 的数倍）。原因是多层的：&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
    subgraph 历史时机[&amp;#34;历史时机（2014-2016）&amp;#34;]
        H1[&amp;#34;Vue 2014 年发布&amp;lt;br/&amp;gt;作者尤雨溪是华人&amp;lt;br/&amp;gt;中文文档从第一天就是一流的&amp;#34;]
        H2[&amp;#34;恰逢国内移动互联网爆发&amp;lt;br/&amp;gt;海量新人入行&amp;lt;br/&amp;gt;需要低门槛框架&amp;#34;]
        H3[&amp;#34;AngularJS 在国内已流行&amp;lt;br/&amp;gt;Vue 继承模板+双向绑定心智&amp;lt;br/&amp;gt;平滑承接&amp;#34;]
    end
    subgraph 生态锁定[&amp;#34;生态锁定&amp;#34;]
        E1[&amp;#34;饿了么 Element UI（2016）&amp;lt;br/&amp;gt;阿里系采用 Vue&amp;#34;]
        E2[&amp;#34;Element / Ant Design Vue / Vant&amp;lt;br/&amp;gt;中后台组件库成套&amp;#34;]
        E3[&amp;#34;uni-app 用 Vue 语法写小程序&amp;lt;br/&amp;gt;吃透国内小程序红利&amp;#34;]
    end
    subgraph 正循环[&amp;#34;正循环&amp;#34;]
        L1[&amp;#34;Vue 门槛低 → 培训机构主教 Vue&amp;#34;]
        L2[&amp;#34;会 Vue 的人多 → 企业招 Vue 便宜快&amp;#34;]
        L3[&amp;#34;企业用 Vue → 岗位多 → 新人继续学 Vue&amp;#34;]
    end
    subgraph 海外对照[&amp;#34;海外对照&amp;#34;]
        O1[&amp;#34;Facebook 背书 + 自家产品验证&amp;#34;]
        O2[&amp;#34;React Native 吃下跨端&amp;#34;]
        O3[&amp;#34;Next.js 等元框架强化生态&amp;#34;]
        O4[&amp;#34;大厂招聘需求锁定人才供给&amp;#34;]
    end
    H1 &amp;amp; H2 &amp;amp; H3 --&amp;gt; 国内[&amp;#34;国内 Vue 流行&amp;#34;]
    E1 &amp;amp; E2 &amp;amp; E3 --&amp;gt; 国内
    L1 --&amp;gt; L2 --&amp;gt; L3 --&amp;gt; 国内
    O1 &amp;amp; O2 &amp;amp; O3 &amp;amp; O4 --&amp;gt; 海外[&amp;#34;海外 React 统治&amp;#34;]&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;。Vue 的中文文档从发布第一天就是母语级质量，而 React 中文文档长期靠社区翻译、滞后且质量参差。对自学者，这个差异是决定性的——学什么框架，往往就是「哪份文档看得懂」决定的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生态位被谁先占住&lt;/strong&gt;。2016 年饿了么开源 Element UI、阿里系大量采用 Vue，中后台组件库（Element/Ant Design Vue/Vant）成套出现，「Vue + Element」成了国内中后台的标准答案。海外对应位置被 React 的 Ant Design（蚂蚁国际版反哺）、MUI、Next.js 占住。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;培训机构的放大器作用&lt;/strong&gt;。Vue 模板语法接近 HTML、双向绑定写表单快、SFC 单文件组件直观——对从后端/jQuery 转前端的人极其友好，培训周期短、就业反馈快，于是机构主教 Vue，供给端被锁定。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;移动端的分野&lt;/strong&gt;。海外跨端 = React Native，进一步巩固 React；国内跨端的真正战场是&lt;strong&gt;小程序&lt;/strong&gt;，而 uni-app 这类「用 Vue 语法写小程序」的框架吃掉了这块红利，又反过来强化了 Vue 心智。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React 的门槛是真实的&lt;/strong&gt;。JSX、函数式思维、不可变数据、Hooks 的心智负担（本文四、五章的坑就是证据），对英语文档和工程化基础都有要求——这在海外是标配，在国内快速扩张期的团队里是成本。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;对学习者的启示&lt;/strong&gt;：两个框架的核心思想（组件化、状态驱动 UI、虚拟 DOM）完全相通，Vue3 的组合式 API 和 React Hooks 已经趋同。学会一个，另一个一周能上手——&lt;strong&gt;别把宝押在框架上，押在原理上&lt;/strong&gt;。而且 AI 时代框架差异进一步被抹平：AI 两个都写得很好，你需要的只是读懂它写的是什么的判断力。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十思考与感悟&#34;&gt;十、思考与感悟&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. React 的本质不是语法，是「UI = f(state)」这个心智模型。&lt;/strong&gt; Hooks 语法三天能学会，但「什么该进 state、state 放哪层、如何从 state 推导 UI」要想清楚需要几个月。判断一个人 React 水平，不要问语法，问他「这个状态你会放在哪个组件」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 坑大多是「引用」和「时机」两类。&lt;/strong&gt; 本文六个坑归个类：坑 1/3/4 是没理解引用比较（key 复用、state 不可变），坑 2/5/6 是没理解渲染时机（effect 何时跑、闭包捕获哪次渲染的值）。把「每次渲染都是一次全新函数调用」这句话想透，大半坑自动消失。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 组件库是杠杆，不是拐杖。&lt;/strong&gt; antd 让中后台效率翻倍，但只会在组件里塞业务逻辑的人，换个库就废了。组件库之上是交互设计，之下是 React 基本功，两头都得懂一点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. AI 时代，「读代码」比「写代码」更值钱。&lt;/strong&gt; 生成不再稀缺，判断力才稀缺。能一眼看出 AI 生成的代码里 useEffect 依赖少了、state 设计反了的人，会把 AI 用得越来越顺手；只会复制粘贴的人，会被 AI 生成的坑埋掉。学基础不是为了手写，是为了 Review。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;十一学习路线建议&#34;&gt;十一、学习路线建议&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;第一周&lt;/strong&gt;：搭好 Vite 环境，跟着官方教程（react.dev 的互动教程质量很高）过完 JSX/组件/state。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二周&lt;/strong&gt;：手写 Todo App，故意踩一遍第四节的坑（比如把 key 换成 index 看看怎么乱），踩过才算学会。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第三周&lt;/strong&gt;：加路由（React Router）和组件库（antd），做一个有列表页+表单页的两页应用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第四周&lt;/strong&gt;：接一个真实后端 API，处理 loading/错误/空数据三种状态——真实项目和 Demo 的差距全在这里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进阶&lt;/strong&gt;：自定义 Hook 抽逻辑、Context/Zustand 管全局状态、性能优化（memo/useMemo）、Next.js 服务端渲染。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;参考资料：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://react.dev/&#34;&gt;React 官方文档（react.dev）&lt;/a&gt;——新版文档质量极高，教程可交互&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://vitejs.dev/&#34;&gt;Vite 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ant.design/&#34;&gt;Ant Design 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://reactrouter.com/&#34;&gt;React Router 文档&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
</description>
    </item>
    <item>
      <title>MongoDB 从入门到精通：存储引擎、建模、索引、副本集、分片与事务实战</title>
      <link>https://ftmao.com/blog/posts/mongodb-beginner-to-mastery/</link>
      <pubDate>Tue, 26 Oct 2021 10:30:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/mongodb-beginner-to-mastery/</guid>
      <description>&lt;h1 id=&#34;mongodb-从入门到精通存储引擎建模索引副本集分片与事务实战&#34;&gt;MongoDB 从入门到精通：存储引擎、建模、索引、副本集、分片与事务实战&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：用过 MySQL 之类的关系型数据库，现在要接 MongoDB 的同学——可能是接手存量系统，可能是新业务选型，也可能是想给自己的技术栈补上 NoSQL 这一块。&lt;br&gt;
本文不浪费篇幅在「什么是文档」这种名词解释上，直接从真正影响系统生死的东西讲起：存储引擎、一致性、高可用、性能。&lt;br&gt;
写这篇的契机：2021 年我所在团队的电商中台把部分业务从 MySQL 迁到 MongoDB，整个过程脱了一层皮。这篇是我希望迁移前有人能拍在桌上的笔记。&lt;br&gt;
文内版本基线：MongoDB 5.0（2021 年 7 月发布）、官方 Java 同步驱动 4.3、JDK 11。&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;Mongo 适合什么&amp;#34;] --&amp;gt; B[&amp;#34;二 存储引擎&amp;lt;br/&amp;gt;WiredTiger 底牌&amp;#34;]
    B --&amp;gt; C[&amp;#34;三 数据建模&amp;lt;br/&amp;gt;核心战场&amp;#34;]
    C --&amp;gt; D[&amp;#34;四 索引与 explain&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;监控备份 少写代码&amp;#34;]
    style H fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;学习 MongoDB 要始终围绕三个问题：&lt;strong&gt;数据安不安全、查询快不快、流量扛不扛得住&lt;/strong&gt;。这篇的每一节，都是对这三问的某一次正面回答。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第一篇-选型认知什么时候该用-mongodb什么时候别逞强&#34;&gt;第一篇 选型认知：什么时候该用 MongoDB，什么时候别逞强&lt;/h2&gt;
&lt;p&gt;第一课不是怎么用，是&lt;strong&gt;什么时候用&lt;/strong&gt;。用错场景的 MongoDB，比不用更可怕。&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 适合[&amp;#34;MongoDB 的主场&amp;#34;]
        P[&amp;#34;商品中心&amp;lt;br/&amp;gt;类目属性千差万别&amp;lt;br/&amp;gt;文档模型天然契合&amp;#34;]
        C[&amp;#34;购物车/会话&amp;lt;br/&amp;gt;读多写多 结构灵活&amp;#34;]
        L[&amp;#34;行为日志/埋点&amp;lt;br/&amp;gt;写入量大 schema 常变&amp;lt;br/&amp;gt;TTL 索引自动过期&amp;#34;]
        CM[&amp;#34;CMS/配置中心&amp;lt;br/&amp;gt;页面配置 JSON 化&amp;#34;]
    end
    subgraph 谨慎[&amp;#34;需要精心设计的边界&amp;#34;]
        O[&amp;#34;订单/库存&amp;lt;br/&amp;gt;可用 但要事务+对账设计&amp;#34;]
        M[&amp;#34;消息/Feed 流&amp;lt;br/&amp;gt;可用 注意分片键设计&amp;#34;]
    end
    subgraph 别上[&amp;#34;别硬上 Mongo&amp;#34;]
        F[&amp;#34;账务/清结算&amp;lt;br/&amp;gt;强事务 强审计&amp;lt;br/&amp;gt;交给关系型&amp;#34;]
        S[&amp;#34;复杂搜索&amp;lt;br/&amp;gt;交给 Elasticsearch&amp;#34;]
        J[&amp;#34;重度多表关联分析&amp;lt;br/&amp;gt;交给 OLAP/数仓&amp;#34;]
    end
    style 适合 fill:#e8f4e8
    style 别上 fill:#fde8e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;一句话原则：&lt;strong&gt;MongoDB 适合「单实体自成一体、结构多变、读多」的在线业务；不适合「钱」「搜索」「重度关联分析」。&lt;/strong&gt; 判断依据不是性能 benchmark，而是数据访问模式——先把系统里 80% 的查询列出来，看它们能不能被「单集合 + 少量 $lookup」覆盖。覆盖不了，选型本身就有问题。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;思考与感悟之一&lt;/strong&gt;：数据库选型和框架选型完全不同。框架选错了可以重构，数据库选错了是「数据搬家」级别的灾难——停机迁移、双写对账、灰度切流，每一步都在钢丝上走。所以对「不做什么」的清单，要比「做什么」的清单更长。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第二篇-wiredtiger理解存储引擎才配谈性能&#34;&gt;第二篇 WiredTiger：理解存储引擎，才配谈性能&lt;/h2&gt;
&lt;p&gt;很多人用 MongoDB 三年，不知道底下是 WiredTiger。这就像开了一辈子车不懂发动机——平时没事，出事时只能干瞪眼。这一篇先把「数据到底怎么存的」讲透，再回答那个面试必问的问题：&lt;strong&gt;MongoDB 为什么快，为什么扛得住大量读写。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&#34;21-底层数据模型bson磁盘上到底存了什么&#34;&gt;2.1 底层数据模型：BSON，磁盘上到底存了什么&lt;/h3&gt;
&lt;p&gt;你在应用层操作的是「文档」（可以理解成 JSON），但磁盘上存的是 &lt;strong&gt;BSON（Binary JSON）&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 BSON[&amp;#34;一个 BSON 文档的字节布局&amp;#34;]
        L[&amp;#34;int32 总长度&amp;lt;br/&amp;gt;开头 4 字节&amp;#34;] --&amp;gt; E1[&amp;#34;元素1&amp;lt;br/&amp;gt;类型字节 + 字段名 + 值&amp;#34;]
        E1 --&amp;gt; E2[&amp;#34;元素2&amp;lt;br/&amp;gt;类型字节 + 字段名 + 值&amp;#34;]
        E2 --&amp;gt; EN[&amp;#34;...&amp;#34;]
        EN --&amp;gt; Z[&amp;#34;0x00 结束符&amp;#34;]
    end
    subgraph TYPES[&amp;#34;BSON 比 JSON 多出的类型&amp;#34;]
        T1[&amp;#34;ObjectId / Date / 正则&amp;#34;]
        T2[&amp;#34;int32 / int64 / double&amp;#34;]
        T3[&amp;#34;Decimal128 高精度十进制&amp;#34;]
        T4[&amp;#34;BinData 二进制 / 数组 / 内嵌文档&amp;#34;]
    end
    BSON -.-&amp;gt; TYPES&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;BSON 的设计直接回答了两个性能问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;长度前缀 + 类型字节 → 遍历时可以「跳过」字段。&lt;/strong&gt; 查询只取 &lt;code&gt;name&lt;/code&gt; 字段时，引擎不需要解析整个文档，读到字段长度直接指针后移——这就是为什么投影（projection）能省时间。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;类型丰富 → 不需要序列化转换。&lt;/strong&gt; JSON 只有 string/number/boolean，日期只能存字符串再解析；BSON 原生支持 Date、ObjectId、Decimal128（钱的精确计算就靠它）、二进制，省去了应用层和存储层之间大量的类型转换开销。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;更关键的是&lt;strong&gt;文档在磁盘上是连续存放的&lt;/strong&gt;：一个商品文档（含内嵌的 SKU 数组、图片列表）在磁盘上是&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
    subgraph MYSQL[&amp;#34;MySQL 读一个完整商品&amp;#34;]
        A1[&amp;#34;product 表的 B+ 树页&amp;#34;] --&amp;gt; J[&amp;#34;JOIN&amp;#34;]
        A2[&amp;#34;sku 表的 B+ 树页&amp;lt;br/&amp;gt;磁盘另一处&amp;#34;] --&amp;gt; J
        A3[&amp;#34;image 表的 B+ 树页&amp;lt;br/&amp;gt;磁盘又一处&amp;#34;] --&amp;gt; J
        J --&amp;gt; R[&amp;#34;多次随机 I/O&amp;lt;br/&amp;gt;才能拼出一个商品&amp;#34;]
    end
    subgraph MONGO[&amp;#34;MongoDB 读一个完整商品&amp;#34;]
        B[&amp;#34;一次磁盘寻道&amp;lt;br/&amp;gt;读出连续的一整块 BSON&amp;#34;]
    end
    style MONGO fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这就是 MongoDB 设计哲学里最重要的词：&lt;strong&gt;数据局部性（data locality）&lt;/strong&gt;。「一起读的数据，就存在一起」。嵌入建模之所以是 Mongo 的灵魂，底层原因就在这里——它不是风格偏好，是磁盘 I/O 决定的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;追问：文档是定长连续存放的，那修改、变大之后怎么办？磁盘会不连续吗？&lt;/strong&gt; 这要分两个时代说。MMAPv1 时代（3.0 之前）文档存在定长槽位里，变大就得整体搬家（document move），旧槽位变空洞，磁盘碎片是当年最臭名昭著的问题。WiredTiger 时代换了个思路：&lt;strong&gt;存储单位是 B 树的页而不是文档&lt;/strong&gt;，文档是叶页里的一条 record；修改&lt;strong&gt;从不在原地写&lt;/strong&gt;——checkpoint 时用 copy-on-write 把改后的整页写到磁盘&lt;strong&gt;新位置&lt;/strong&gt;，旧块进空闲列表复用；文档长大导致页放不下就触发&lt;strong&gt;页分裂&lt;/strong&gt;，超大文档走 overflow 页。所以「连续」的准确含义是「逻辑上一条 record、一次 I/O 读取」，物理位置本来就会搬。代价有两个：长期高频更新会积累碎片（用 &lt;code&gt;db.collection.compact()&lt;/code&gt; 定期回收），以及&lt;strong&gt;频繁长大的文档会引发频繁页分裂&lt;/strong&gt;——这是第三篇「增长边界」判断的底层原因，时序类数据的标准解法是按时间桶预分配文档的 bucket 模式，让单文档尺寸保持稳定。&lt;/p&gt;
&lt;h3 id=&#34;22-为什么快读快与写快的底层原因&#34;&gt;2.2 为什么快：读快与写快的底层原因&lt;/h3&gt;
&lt;p&gt;「MongoDB 快」不能当口号喊，要能拆成具体机制。先写后读。&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
    APP[&amp;#34;应用&amp;lt;br/&amp;gt;Java 驱动&amp;#34;] --&amp;gt; M[&amp;#34;mongod&amp;lt;br/&amp;gt;内存操作&amp;#34;]
    M --&amp;gt; C[&amp;#34;WiredTiger cache&amp;lt;br/&amp;gt;修改页标记为 dirty&amp;lt;br/&amp;gt;写操作到此即完成&amp;#34;]
    M --&amp;gt; J[&amp;#34;journal 日志&amp;lt;br/&amp;gt;顺序追加写&amp;lt;br/&amp;gt;磁盘顺序 I/O 极快&amp;#34;]
    C -.-&amp;gt;|&amp;#34;checkpoint&amp;lt;br/&amp;gt;默认每 60 秒&amp;lt;br/&amp;gt;批量刷盘&amp;#34;| D[&amp;#34;数据文件&amp;lt;br/&amp;gt;压缩后的 B 树页&amp;#34;]
    style C fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;四个叠加的机制：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;写内存即返回&lt;/strong&gt;。写操作修改的是 cache 里的页，标记 dirty 就完事，数据文件由 checkpoint 线程&lt;strong&gt;每 60 秒批量刷一次&lt;/strong&gt;——随机写被攒成了顺序批量写。这是「写快」的第一来源。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;journal 用顺序写保证持久性&lt;/strong&gt;。怕宕机丢内存里的数据？journal（WAL 日志）是&lt;strong&gt;顺序追加写&lt;/strong&gt;，磁盘的顺序写比随机写快几个数量级。&lt;code&gt;j: true&lt;/code&gt; 的写确认就是等 journal 落盘，而不是等数据文件落盘。&lt;/p&gt;
&lt;p&gt;这里必须正面回答一个所有人都会问的问题：&lt;strong&gt;「写入内存就返回，服务器挂了怎么办？」&lt;/strong&gt; 不会变成脏数据——cache 里丢掉的只是 dirty 页的内存副本，每次修改都已先追加进 journal；mongod 重启时以最后一次 checkpoint 为基线&lt;strong&gt;重放 journal&lt;/strong&gt;，把丢失的写补回数据文件。真正的风险窗口在确认时机：默认 &lt;code&gt;w:1&lt;/code&gt; 下 journal 每 ~100ms 才批量提交，断电最多丢「最近约 100ms 且未同步到从节点」的写（第六篇丢的 23 笔订单就是这么没的）；&lt;code&gt;j: true&lt;/code&gt; 等日志落盘，单节点宕机不丢；&lt;code&gt;w: majority&lt;/code&gt; 等多数派确认，机器物理损毁也不丢。&lt;strong&gt;丢不丢数据不是 MongoDB 决定的，是你选 writeConcern 档位时决定的&lt;/strong&gt;——这套「内存脏页异步刷盘 + 日志重放」和 MySQL InnoDB 的 redo log 是同一套思想。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;文档级并发&lt;/strong&gt;。更新只锁单个文档，100 个线程写 100 个不同文档完全不互斥。对比早期 MySQL MyISAM 的表锁、乃至行锁时代的间隙锁开销，文档锁的冲突概率天然更低。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;没有跨行约束的包袱&lt;/strong&gt;。无外键检查、无触发器、单文档原子性不需要 undo log（改一个文档就是改一整块，要么全改要么不改），写入路径短。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;读取为什么快——四道防线：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;局部性&lt;/strong&gt;：如上，嵌入建模让「读一个完整实体」= 一次磁盘寻道，没有 JOIN 的多次随机 I/O。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存&lt;/strong&gt;：热数据常驻 WiredTiger cache（默认一半内存），命中内存的读在微秒级。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B 树索引&lt;/strong&gt;：索引项 O(log n) 定位，千万级数据也就 4 层左右，加覆盖索引（第四篇）连文档都不用读。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;副本集读扩展&lt;/strong&gt;：&lt;code&gt;readPreference: secondary&lt;/code&gt; 把展示类读流量分摊到从节点，读吞吐随节点数线性扩展。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;大量读 + 大量写同时扛住的原因&lt;/strong&gt;：读写分离（从节点扛读）+ MVCC（读不阻塞写、写不阻塞读）+ 写内存化。这三件事同时成立，才撑得起电商大促「几十万读 QPS 的商品页 + 几万写 QPS 的库存扣减」同框的场景。还不够？分片把写入水平切到多个副本集，写吞吐同样线性扩展（第七篇）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;快的代价（必须说清楚，否则就是忽悠）&lt;/strong&gt;：写内存即返回意味着默认 &lt;code&gt;w:1&lt;/code&gt; 下宕机可能丢最后一小段数据（第六篇的丢单事故就是这么来的）；热数据依赖内存，内存小于工作集时性能断崖；没有 JOIN 意味着关联查询要自己扛。&lt;strong&gt;MongoDB 的快是「用设计取舍换来的快」，不是魔法。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&#34;23-wiredtiger-你必须知道的四件事&#34;&gt;2.3 WiredTiger 你必须知道的四件事&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
    W[&amp;#34;WiredTiger 存储引擎&amp;lt;br/&amp;gt;MongoDB 3.2+ 默认&amp;#34;] --&amp;gt; A[&amp;#34;文档级并发控制&amp;lt;br/&amp;gt;多线程写同一集合不互斥&amp;lt;br/&amp;gt;只有同一文档冲突才重试&amp;#34;]
    W --&amp;gt; B[&amp;#34;压缩存储&amp;lt;br/&amp;gt;snappy 默认&amp;lt;br/&amp;gt;磁盘占用约为数据量的 30%-70%&amp;#34;]
    W --&amp;gt; C[&amp;#34;缓存机制&amp;lt;br/&amp;gt;cacheSizeGB 默认 = 50% × (内存 - 1GB)&amp;lt;br/&amp;gt;热数据全在内存里&amp;#34;]
    W --&amp;gt; D[&amp;#34;MVCC 多版本&amp;lt;br/&amp;gt;读不阻塞写 写不阻塞读&amp;lt;br/&amp;gt;snapshot 隔离&amp;#34;]&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;strong&gt;并发改同一个文档会串行重试&lt;/strong&gt;。这直接决定建模——秒杀库存如果把 100 个 SKU 的库存都嵌在一个商品文档里，大促时这个文档就是全站最热的锁热点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;缓存即命&lt;/strong&gt;：WiredTiger cache 默认吃掉一半内存。生产上「Mongo 内存打满」往往不是泄漏，是 cache 在正常工作——看 &lt;code&gt;wiredTiger.cache&lt;/code&gt; 指标而不是看进程 RSS 下结论。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;压缩是把双刃剑&lt;/strong&gt;：省磁盘，费 CPU。写密集型日志库可换 zlib 更高压缩比，CPU 紧张时别乱调。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MVCC 的代价&lt;/strong&gt;：老版本数据要清理，长时间不提交的事务会压住清理线程，导致 cache 中 dirty data 堆积、写入被拖慢。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 #1（cache 被大事务压垮）&lt;/strong&gt;：我们有个定时任务一次性更新 200 万条商品，跑的时候整个库延迟飙升。原因：巨型事务导致 WiredTiger 无法清理旧版本，cache 中 dirty bytes 超过阈值后应用线程被迫参与淘汰（eviction），写入集体变慢。解法：&lt;strong&gt;批量操作必须拆批&lt;/strong&gt;（每批 1000 条 + 短暂让出窗口），生产环境永远不开「不设限的大批量更新」。这条后来成了团队《MongoDB 使用红线》第一条。&lt;/p&gt;
&lt;h3 id=&#34;24-本地环境直接上副本集别玩单机&#34;&gt;2.4 本地环境：直接上副本集，别玩单机&lt;/h3&gt;
&lt;p&gt;单节点 MongoDB 不支持事务，也不具备生产形态。本地练手就用 Docker Compose 起一套三节点副本集：&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-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# docker-compose.yml&lt;/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;version&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;3.8&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:#f92672&#34;&gt;services&lt;/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;mongo1&lt;/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;image&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;mongo:5.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:#f92672&#34;&gt;command&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--replSet&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;rs0&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--bind_ip_all&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;ports&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;27017:27017&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;mongo2&lt;/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;image&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;mongo:5.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:#f92672&#34;&gt;command&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--replSet&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;rs0&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--bind_ip_all&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;mongo3&lt;/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;image&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;mongo:5.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:#f92672&#34;&gt;command&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--replSet&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;rs0&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--bind_ip_all&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;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;# 初始化副本集（执行一次）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;mongosh --eval &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;rs.initiate({
&lt;/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;  _id: &amp;#34;rs0&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;  members: [
&lt;/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;    { _id: 0, host: &amp;#34;mongo1:27017&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;    { _id: 1, host: &amp;#34;mongo2:27017&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;    { _id: 2, host: &amp;#34;mongo3:27017&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;  ]
&lt;/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;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;hr&gt;
&lt;h2 id=&#34;第三篇-数据建模核心战场没有之一&#34;&gt;第三篇 数据建模：核心战场，没有之一&lt;/h2&gt;
&lt;p&gt;MongoDB 精通与否，就看建模。SQL 建模有范式护体，Mongo 建模全靠经验与取舍——这也是 AI 最帮不上忙的部分（第八篇详谈）。&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;lt;br/&amp;gt;还是独立成集合？&amp;#34;] --&amp;gt; A{&amp;#34;三个判断&amp;#34;}
    A --&amp;gt; B[&amp;#34;读写边界&amp;lt;br/&amp;gt;总是一起读写吗？&amp;#34;]
    A --&amp;gt; C[&amp;#34;增长边界&amp;lt;br/&amp;gt;数量有上限吗？&amp;#34;]
    A --&amp;gt; D[&amp;#34;并发边界&amp;lt;br/&amp;gt;会被高并发独立更新吗？&amp;#34;]
    B --&amp;gt;|&amp;#34;是，且&amp;#34;| E[&amp;#34;嵌入&amp;lt;br/&amp;gt;如：订单内嵌 items 快照&amp;#34;]
    C --&amp;gt;|&amp;#34;否，无限增长&amp;#34;| R[&amp;#34;引用&amp;lt;br/&amp;gt;如：商品引用评论&amp;#34;]
    D --&amp;gt;|&amp;#34;是，热点&amp;#34;| R2[&amp;#34;引用&amp;lt;br/&amp;gt;如：SKU 库存独立成文档&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;code&gt;items&lt;/code&gt; 嵌入（下单时一次写入、之后整体读取）；但「收货地址」独立成集合——地址在下单、物流、售后多个流程被独立读写，嵌在用户文档里每次都要加载整个用户。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;增长边界&lt;/strong&gt;：单文档 16MB 硬上限。商品的「问大家」嵌进商品文档，爆款商品一年撑爆，连夜迁移。教训：&lt;strong&gt;文档不是仓库，是货架。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;并发边界&lt;/strong&gt;：文档级锁决定了——&lt;strong&gt;凡是会被高并发写的数据，从父文档里拆出去&lt;/strong&gt;。秒杀库存必须一个 SKU 一个文档，绝不能嵌在商品里。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;32-电商核心建模订单快照与库存拆分&#34;&gt;3.2 电商核心建模：订单快照与库存拆分&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;erDiagram
    USER ||--o{ ORDER : &amp;#34;引用&amp;#34;
    PRODUCT ||--o{ SKU_STOCK : &amp;#34;拆出 独立高并发写&amp;#34;
    ORDER ||--|{ ORDER_ITEM : &amp;#34;内嵌 商品快照&amp;#34;
    PRODUCT ||--o{ COMMENT : &amp;#34;引用 无界增长&amp;#34;
    PRODUCT {
        ObjectId _id
        string name
        string categoryId
        array attrs &amp;#34;类目属性 灵活嵌入&amp;#34;
        string detailUrl &amp;#34;富文本放对象存储&amp;#34;
    }
    SKU_STOCK {
        ObjectId _id
        ObjectId productId
        string spec
        int stock &amp;#34;独立文档 原子扣减&amp;#34;
        int version &amp;#34;乐观锁&amp;#34;
    }
    ORDER {
        ObjectId _id
        ObjectId userId
        array items &amp;#34;名称/单价/图片快照&amp;#34;
        long totalAmount &amp;#34;单位: 分&amp;#34;
        string status
        date createdAt
    }&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;code&gt;items&lt;/code&gt;。商品会改名改价下架，三个月后客服处理售后，看的必须是「成交时」的样子。快照冗余不是脏数据，是业务真相。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额用「分」，long 存&lt;/strong&gt;。浮点的 &lt;code&gt;199.00&lt;/code&gt; 元在 IEEE 754 世界里是 &lt;code&gt;198.999...&lt;/code&gt;，所有和钱有关的字段一律整数存分。这不是 Mongo 的问题，但 Mongo 的自由度会让人更容易犯懒。&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;思考与感悟之二&lt;/strong&gt;：SQL 建模回答「数据之间是什么关系」，Mongo 建模回答「数据以什么节奏被读写」。前者是静态的学问，后者是动态的学问——必须预测流量形状（谁是热点、谁会暴涨、谁一起出现）才能建好模。这就是为什么 Mongo 建模更依赖业务理解，也更难被自动化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第四篇-索引与-explain调优基本功没有捷径&#34;&gt;第四篇 索引与 explain：调优基本功，没有捷径&lt;/h2&gt;
&lt;h3 id=&#34;41-explain-是唯一标准答案&#34;&gt;4.1 explain 是唯一标准答案&lt;/h3&gt;
&lt;p&gt;任何「这条查询为什么慢」的讨论，不贴 explain 输出就是耍流氓。Java 里看执行计划：&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 在 Java 驱动里用 explain 需要走 Document 命令（或先在 mongosh 里验证）&lt;/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;// mongosh 中的写法：&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;db.&lt;span style=&#34;color:#a6e22e&#34;&gt;products&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;find&lt;/span&gt;({ categoryId: cid, status: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;on&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;sort&lt;/span&gt;({ price: &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt;1 })
&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;explain&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;executionStats&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;输出只盯四处：&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;winningPlan.stage&amp;#34;] --&amp;gt; B{&amp;#34;COLLSCAN?&amp;#34;}
    B --&amp;gt;|&amp;#34;是&amp;#34;| C[&amp;#34;全表扫描&amp;lt;br/&amp;gt;立刻建索引&amp;#34;]
    B --&amp;gt;|&amp;#34;IXSCAN&amp;#34;| D[&amp;#34;看三个数字&amp;#34;]
    D --&amp;gt; E[&amp;#34;nReturned&amp;lt;br/&amp;gt;返回多少条&amp;#34;]
    D --&amp;gt; F[&amp;#34;totalDocsExamined&amp;lt;br/&amp;gt;读了多少文档&amp;#34;]
    D --&amp;gt; G[&amp;#34;totalKeysExamined&amp;lt;br/&amp;gt;扫了多少索引键&amp;#34;]
    E --&amp;gt; H{&amp;#34;理想: 三者接近&amp;lt;br/&amp;gt;危险: Examined 远大于 Returned&amp;#34;}&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;h3 id=&#34;42-esr-法则与-java-建索引&#34;&gt;4.2 ESR 法则与 Java 建索引&lt;/h3&gt;
&lt;p&gt;复合索引字段顺序：&lt;strong&gt;Equality（等值）→ Sort（排序）→ Range（范围）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;商品列表页查询：&lt;code&gt;{ categoryId: 等值, status: 等值 }&lt;/code&gt; + 按价格倒序，用 Java 驱动建索引：&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// 依赖: org.mongodb:mongodb-driver-sync:4.3.x&lt;/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;import&lt;/span&gt; com.mongodb.client.MongoClient;
&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;import&lt;/span&gt; com.mongodb.client.MongoClients;
&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;import&lt;/span&gt; com.mongodb.client.MongoCollection;
&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;import&lt;/span&gt; com.mongodb.client.model.Indexes;
&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;import&lt;/span&gt; org.bson.Document;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;IndexDemo&lt;/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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;static&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;main&lt;/span&gt;(String&lt;span style=&#34;color:#f92672&#34;&gt;[]&lt;/span&gt; args) {
&lt;/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; (MongoClient client &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; MongoClients.&lt;span style=&#34;color:#a6e22e&#34;&gt;create&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;mongodb://localhost:27017/?replicaSet=rs0&amp;#34;&lt;/span&gt;)) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            MongoCollection&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Document&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; products &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDatabase&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;shop&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;products&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;// ESR：categoryId/status 等值(E) 在前，price 排序(S) 在后&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            products.&lt;span style=&#34;color:#a6e22e&#34;&gt;createIndex&lt;/span&gt;(Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;compoundIndex&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;ascending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;categoryId&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;ascending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;status&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;descending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;price&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;            products.&lt;span style=&#34;color:#a6e22e&#34;&gt;createIndex&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;ascending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;price&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;new&lt;/span&gt; com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;IndexOptions&lt;/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;partialFilterExpression&lt;/span&gt;(com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;status&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;on&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;// TTL 索引：埋点日志 7 天自动删除，省掉清理脚本&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            client.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDatabase&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;shop&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;access_logs&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;createIndex&lt;/span&gt;(Indexes.&lt;span style=&#34;color:#a6e22e&#34;&gt;ascending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;createdAt&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;new&lt;/span&gt; com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;IndexOptions&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;expireAfter&lt;/span&gt;(7L, java.&lt;span style=&#34;color:#a6e22e&#34;&gt;util&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;concurrent&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;TimeUnit&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;DAYS&lt;/span&gt;));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;price&lt;/code&gt; 就从 Sort 变成 Range，一个索引满足不了两个需求——&lt;strong&gt;索引设计永远是针对具体查询集合的权衡，没有万能索引&lt;/strong&gt;。每个索引都拖慢写入，写多读少的日志库尤其敏感。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 #2（32MB 排序内存墙）&lt;/strong&gt;：运营后台「全量商品按上架时间倒序」接口，商品过 10 万后直接报 &lt;code&gt;Sort exceeded memory limit of 33554432 bytes&lt;/code&gt;。Mongo 内存排序上限 32MB，超了就炸。解法不是调参数，是建索引让排序走索引（索引天然有序）。&lt;strong&gt;上线红线第二条：凡带 sort 的查询，必须 explain 验证走索引排序。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 #3（正则索引进退两难）&lt;/strong&gt;：模糊搜索 &lt;code&gt;find({ name: /卫衣/ })&lt;/code&gt;，非前缀正则无法走索引，数据量上来后 P99 三秒起步。前缀匹配 &lt;code&gt;/^卫衣/&lt;/code&gt; 可以走索引，但中文搜索很少是前缀形态。2021 年的正经答案：&lt;strong&gt;Mongo 管数据，ES 管搜索&lt;/strong&gt;，用 oplog/canal 同步，别指望一个库包打天下。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第五篇-聚合管道把计算下推给服务端&#34;&gt;第五篇 聚合管道：把计算下推给服务端&lt;/h2&gt;
&lt;p&gt;核心思维：「能在库里算完的，绝不拉回应用层」。拉 10 万条数据到 JVM 里 for 循环求和，是最常见的性能自杀。&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;$match&amp;lt;br/&amp;gt;筛: 本月已支付&amp;#34;] --&amp;gt; B[&amp;#34;$unwind&amp;lt;br/&amp;gt;拆: items 展开&amp;#34;]
    B --&amp;gt; C[&amp;#34;$group&amp;lt;br/&amp;gt;按商品汇总&amp;#34;]
    C --&amp;gt; D[&amp;#34;$sort&amp;lt;br/&amp;gt;销量倒序&amp;#34;]
    D --&amp;gt; E[&amp;#34;$limit&amp;lt;br/&amp;gt;Top 10&amp;#34;]
    E --&amp;gt; F[&amp;#34;热销榜&amp;lt;br/&amp;gt;一次出结果&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;实战 Demo：本月热销商品 Top 10（Java 驱动写法）：&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// demo01-top-sales：本月热销榜&lt;/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;import&lt;/span&gt; com.mongodb.client.MongoClient;
&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;import&lt;/span&gt; com.mongodb.client.MongoClients;
&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;import&lt;/span&gt; com.mongodb.client.MongoCollection;
&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;import&lt;/span&gt; com.mongodb.client.model.Accumulators;
&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;import&lt;/span&gt; com.mongodb.client.model.Aggregates;
&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;import&lt;/span&gt; com.mongodb.client.model.Filters;
&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;import&lt;/span&gt; com.mongodb.client.model.Sorts;
&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;import&lt;/span&gt; org.bson.Document;
&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;import&lt;/span&gt; org.bson.conversions.Bson;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;import&lt;/span&gt; java.util.ArrayList;
&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;import&lt;/span&gt; java.util.Arrays;
&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;import&lt;/span&gt; java.util.Date;
&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;import&lt;/span&gt; java.util.List;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;TopSalesDemo&lt;/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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;static&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;main&lt;/span&gt;(String&lt;span style=&#34;color:#f92672&#34;&gt;[]&lt;/span&gt; args) {
&lt;/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; (MongoClient client &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; MongoClients.&lt;span style=&#34;color:#a6e22e&#34;&gt;create&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;mongodb://localhost:27017/?replicaSet=rs0&amp;#34;&lt;/span&gt;)) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            MongoCollection&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Document&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; orders &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDatabase&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;shop&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;orders&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;            Date monthStart &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Date(); &lt;span style=&#34;color:#75715e&#34;&gt;// 实际取本月 1 号 0 点，此处从简&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            Date monthEnd &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; 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;            List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Bson&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; pipeline &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; Arrays.&lt;span style=&#34;color:#a6e22e&#34;&gt;asList&lt;/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;// 第一道永远是 $match：能用索引，砍掉 99% 的数据&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Aggregates.&lt;span style=&#34;color:#a6e22e&#34;&gt;match&lt;/span&gt;(Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;and&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;status&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;paid&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;gte&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;createdAt&amp;#34;&lt;/span&gt;, monthStart),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;lt&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;createdAt&amp;#34;&lt;/span&gt;, monthEnd))),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Aggregates.&lt;span style=&#34;color:#a6e22e&#34;&gt;unwind&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Aggregates.&lt;span style=&#34;color:#a6e22e&#34;&gt;group&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items.productId&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Accumulators.&lt;span style=&#34;color:#a6e22e&#34;&gt;first&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;productName&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items.productName&amp;#34;&lt;/span&gt;), &lt;span style=&#34;color:#75715e&#34;&gt;// 快照红利：不用 $lookup&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Accumulators.&lt;span style=&#34;color:#a6e22e&#34;&gt;sum&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;totalQty&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items.qty&amp;#34;&lt;/span&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                            Accumulators.&lt;span style=&#34;color:#a6e22e&#34;&gt;sum&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;totalAmount&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;new&lt;/span&gt; Document(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$multiply&amp;#34;&lt;/span&gt;, Arrays.&lt;span style=&#34;color:#a6e22e&#34;&gt;asList&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items.qty&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;$items.price&amp;#34;&lt;/span&gt;)))),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Aggregates.&lt;span style=&#34;color:#a6e22e&#34;&gt;sort&lt;/span&gt;(Sorts.&lt;span style=&#34;color:#a6e22e&#34;&gt;descending&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;totalQty&amp;#34;&lt;/span&gt;)),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                    Aggregates.&lt;span style=&#34;color:#a6e22e&#34;&gt;limit&lt;/span&gt;(10)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;            List&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Document&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; hotList &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; ArrayList&lt;span style=&#34;color:#f92672&#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;            orders.&lt;span style=&#34;color:#a6e22e&#34;&gt;aggregate&lt;/span&gt;(pipeline)
&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;allowDiskUse&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;)   &lt;span style=&#34;color:#75715e&#34;&gt;// 中间结果超 100MB 允许落盘，报表查询记得开&lt;/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;into&lt;/span&gt;(hotList);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            hotList.&lt;span style=&#34;color:#a6e22e&#34;&gt;forEach&lt;/span&gt;(doc &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; System.&lt;span style=&#34;color:#a6e22e&#34;&gt;out&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;println&lt;/span&gt;(doc.&lt;span style=&#34;color:#a6e22e&#34;&gt;toJson&lt;/span&gt;()));
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;$match&lt;/code&gt; 必须放第一&lt;/strong&gt;，放 &lt;code&gt;$unwind&lt;/code&gt; 后面等于先把 500 万订单拆成 3000 万条再筛——有人这么写过，直接把一个节点的内存打爆。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;allowDiskUse(true)&lt;/code&gt;&lt;/strong&gt;：聚合中间结果超 100MB 默认报错，报表类查询记得开。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;能不用 &lt;code&gt;$lookup&lt;/code&gt; 就不用&lt;/strong&gt;。它是应急手段，性能和多表 join 一样是噩梦。需要大量 &lt;code&gt;$lookup&lt;/code&gt; 才能完成的查询，说明建模（第三篇）出了问题——回头改模型，别在查询层硬扛。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第六篇-副本集高可用与读写语义的分水岭&#34;&gt;第六篇 副本集：高可用与读写语义的分水岭&lt;/h2&gt;
&lt;p&gt;单节点 MongoDB 是玩具，副本集才是起点。这篇是「会用 Mongo」和「敢把核心业务放 Mongo」之间的分水岭。&lt;/p&gt;
&lt;h3 id=&#34;61-架构与选主&#34;&gt;6.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
    subgraph RS[&amp;#34;副本集 rs0（3 节点是最小生产配置）&amp;#34;]
        P[&amp;#34;Primary&amp;lt;br/&amp;gt;唯一可写&amp;#34;]
        S1[&amp;#34;Secondary&amp;lt;br/&amp;gt;异步复制 oplog&amp;#34;]
        S2[&amp;#34;Secondary&amp;lt;br/&amp;gt;异步复制 oplog&amp;#34;]
        P -- &amp;#34;oplog 同步&amp;#34; --&amp;gt; S1
        P -- &amp;#34;oplog 同步&amp;#34; --&amp;gt; S2
    end
    P -.-&amp;gt;|&amp;#34;心跳超时 10s&amp;lt;br/&amp;gt;自动选主&amp;#34;| S1
    APP[&amp;#34;应用（驱动）&amp;#34;] --&amp;gt;|&amp;#34;自动感知拓扑变化&amp;lt;br/&amp;gt;故障转移对代码透明&amp;#34;| RS&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;必须理解的机制：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;oplog&lt;/strong&gt;：主节点的操作日志，从节点靠它追赶。oplog 是有界集合，从节点宕机太久会追不上，只能全量同步（initial sync）——大库的全量同步以小时计，这就是「从节点宕机要当事故处理」的原因。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自动选主&lt;/strong&gt;：主挂了，10 秒左右选出新主。&lt;strong&gt;驱动自动重连新主，应用无感&lt;/strong&gt;——前提是连接串写对了（多节点 + &lt;code&gt;replicaSet=rs0&lt;/code&gt;），而不是只写了一个 IP。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;连接池&lt;/strong&gt;：&lt;code&gt;MongoClient&lt;/code&gt; 是线程安全的重对象，&lt;strong&gt;全应用一个单例&lt;/strong&gt;，连接池大小用 &lt;code&gt;maxPoolSize&lt;/code&gt; 调。每请求 new 一个 &lt;code&gt;MongoClient&lt;/code&gt; 等于每请求建一批 TCP 连接，压测时会把数据库连接数打爆——这和「每请求连一次 MySQL」是同一种自杀。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;62-读写语义writeconcern-与-readconcern&#34;&gt;6.2 读写语义：writeConcern 与 readConcern&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// demo02-write-concern：不同档位，不同的安全与延迟权衡&lt;/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;import&lt;/span&gt; com.mongodb.WriteConcern;
&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;import&lt;/span&gt; com.mongodb.client.MongoCollection;
&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;import&lt;/span&gt; org.bson.Document;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;MongoCollection&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Document&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; orders &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDatabase&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;shop&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;orders&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;// 快但可能丢：主节点内存确认就返回（埋点日志可用）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders.&lt;span style=&#34;color:#a6e22e&#34;&gt;withWriteConcern&lt;/span&gt;(WriteConcern.&lt;span style=&#34;color:#a6e22e&#34;&gt;W1&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;insertOne&lt;/span&gt;(logDoc);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;// 安全：多数派落盘 + journal 落盘才返回（订单必须这档）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;orders.&lt;span style=&#34;color:#a6e22e&#34;&gt;withWriteConcern&lt;/span&gt;(WriteConcern.&lt;span style=&#34;color:#a6e22e&#34;&gt;MAJORITY&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;withJournal&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;)).&lt;span style=&#34;color:#a6e22e&#34;&gt;insertOne&lt;/span&gt;(orderDoc);&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;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;业务&lt;/th&gt;
					&lt;th&gt;writeConcern&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;/td&gt;
					&lt;td&gt;&lt;code&gt;majority&lt;/code&gt; + journal&lt;/td&gt;
					&lt;td&gt;主从切换时绝不能丢单&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;库存扣减&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;majority&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;丢扣减记录不可忍&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;行为埋点&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;w: 1&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;丢几条日志无所谓，别拖累写入&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;购物车&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;majority&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;用户加购后刷新没了，投诉就来了&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;readConcern&lt;/code&gt; 同理：读「已提交到多数派」的数据用 &lt;code&gt;ReadConcern.MAJORITY&lt;/code&gt;，避免读到主从切换后被回滚的数据。&lt;strong&gt;钱相关的读，一律 majority；展示类的读，local 即可。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 #4（丢单惊魂夜）&lt;/strong&gt;：上线初期订单写入用默认 &lt;code&gt;w: 1&lt;/code&gt;。某天主节点宕机选主，新主上没有最后 8 秒的 23 笔订单——用户付了钱（支付回调已记录），订单却不存在，客服电话被打爆。复盘结论：&lt;strong&gt;支付回调链路和订单链路的 writeConcern 必须对齐 majority&lt;/strong&gt;，宁慢勿丢。这 8 秒人工补单补了一整夜。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;思考与感悟之三&lt;/strong&gt;：分布式系统里「默认配置」几乎都是「性能优先、安全靠边」的，MongoDB 的 &lt;code&gt;w:1&lt;/code&gt;、MySQL 的异步复制、Kafka 的 &lt;code&gt;acks=1&lt;/code&gt; 皆如此。成熟的标志之一：&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;h3 id=&#34;71-能不用事务就不用原子操作的威力&#34;&gt;7.1 能不用事务就不用：原子操作的威力&lt;/h3&gt;
&lt;p&gt;MongoDB 单文档操作天然原子。电商防超卖，一条条件更新就够，根本不需要事务：&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// demo03-deduct-stock：条件原子扣减，1000 并发抢 1 件也只有 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:#f92672&#34;&gt;import&lt;/span&gt; com.mongodb.client.MongoCollection;
&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;import&lt;/span&gt; com.mongodb.client.model.Filters;
&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;import&lt;/span&gt; com.mongodb.client.model.Updates;
&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;import&lt;/span&gt; com.mongodb.client.result.UpdateResult;
&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;import&lt;/span&gt; org.bson.Document;
&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;import&lt;/span&gt; org.bson.types.ObjectId;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;DeductStockDemo&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;deduct&lt;/span&gt;(MongoCollection&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;&lt;/span&gt;Document&lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; skuStocks, ObjectId skuId, &lt;span style=&#34;color:#66d9ef&#34;&gt;int&lt;/span&gt; qty) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        UpdateResult r &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; skuStocks.&lt;span style=&#34;color:#a6e22e&#34;&gt;updateOne&lt;/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;                Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;and&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_id&amp;#34;&lt;/span&gt;, skuId),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        Filters.&lt;span style=&#34;color:#a6e22e&#34;&gt;gte&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;stock&amp;#34;&lt;/span&gt;, qty)),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                Updates.&lt;span style=&#34;color:#a6e22e&#34;&gt;combine&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        Updates.&lt;span style=&#34;color:#a6e22e&#34;&gt;inc&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;stock&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt;qty),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        Updates.&lt;span style=&#34;color:#a6e22e&#34;&gt;inc&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sold&amp;#34;&lt;/span&gt;, qty),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        Updates.&lt;span style=&#34;color:#a6e22e&#34;&gt;currentDate&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;updatedAt&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;if&lt;/span&gt; (r.&lt;span style=&#34;color:#a6e22e&#34;&gt;getModifiedCount&lt;/span&gt;() &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; 0) {
&lt;/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; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; IllegalStateException(&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
  &lt;/div&gt;&lt;p&gt;精髓：判断（&lt;code&gt;stock &amp;gt;= qty&lt;/code&gt;）和修改（&lt;code&gt;$inc&lt;/code&gt;）合并成&lt;strong&gt;一次原子操作&lt;/strong&gt;，不存在「查出来够、扣的时候不够」的竞态。这比任何分布式锁都干净。&lt;/p&gt;
&lt;h3 id=&#34;72-真正需要事务的场景&#34;&gt;7.2 真正需要事务的场景&lt;/h3&gt;
&lt;p&gt;跨集合的一致性——下单要同时「扣库存 + 建订单 + 核销券」，三个动作三个集合，这时上多文档事务（4.0+，需副本集）：&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-java&#34; data-lang=&#34;java&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// demo04-place-order-tx：多文档事务下单&lt;/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;import&lt;/span&gt; com.mongodb.ClientSessionOptions;
&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;import&lt;/span&gt; com.mongodb.ReadConcern;
&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;import&lt;/span&gt; com.mongodb.TransactionOptions;
&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;import&lt;/span&gt; com.mongodb.WriteConcern;
&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;import&lt;/span&gt; com.mongodb.client.ClientSession;
&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;import&lt;/span&gt; com.mongodb.client.MongoClient;
&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;import&lt;/span&gt; org.bson.Document;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;PlaceOrderTxDemo&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;void&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;placeOrder&lt;/span&gt;(MongoClient client, Document orderDoc, ObjectId skuId, &lt;span style=&#34;color:#66d9ef&#34;&gt;int&lt;/span&gt; qty, ObjectId couponId) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        TransactionOptions txnOptions &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; TransactionOptions.&lt;span style=&#34;color:#a6e22e&#34;&gt;builder&lt;/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;readConcern&lt;/span&gt;(ReadConcern.&lt;span style=&#34;color:#a6e22e&#34;&gt;SNAPSHOT&lt;/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;writeConcern&lt;/span&gt;(WriteConcern.&lt;span style=&#34;color:#a6e22e&#34;&gt;MAJORITY&lt;/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;build&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;try&lt;/span&gt; (ClientSession session &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client.&lt;span style=&#34;color:#a6e22e&#34;&gt;startSession&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                ClientSessionOptions.&lt;span style=&#34;color:#a6e22e&#34;&gt;builder&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;defaultTransactionOptions&lt;/span&gt;(txnOptions).&lt;span style=&#34;color:#a6e22e&#34;&gt;build&lt;/span&gt;())) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            session.&lt;span style=&#34;color:#a6e22e&#34;&gt;withTransaction&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:#66d9ef&#34;&gt;var&lt;/span&gt; db &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; client.&lt;span style=&#34;color:#a6e22e&#34;&gt;getDatabase&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;shop&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;// 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; r &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; db.&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;sku_stocks&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;updateOne&lt;/span&gt;(session,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;and&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                                com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_id&amp;#34;&lt;/span&gt;, skuId),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                                com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;gte&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;stock&amp;#34;&lt;/span&gt;, qty)),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Updates&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;inc&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;stock&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;-&lt;/span&gt;qty));
&lt;/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; (r.&lt;span style=&#34;color:#a6e22e&#34;&gt;getModifiedCount&lt;/span&gt;() &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; 0) {
&lt;/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; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; IllegalStateException(&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;// 2. 插入订单（含商品快照）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                db.&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;orders&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;insertOne&lt;/span&gt;(session, orderDoc);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#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;                db.&lt;span style=&#34;color:#a6e22e&#34;&gt;getCollection&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;coupons&amp;#34;&lt;/span&gt;).&lt;span style=&#34;color:#a6e22e&#34;&gt;updateOne&lt;/span&gt;(session,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                        com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;and&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                                com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_id&amp;#34;&lt;/span&gt;, couponId),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;                                com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Filters&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;eq&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;used&amp;#34;&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;                        com.&lt;span style=&#34;color:#a6e22e&#34;&gt;mongodb&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;client&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;model&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;Updates&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;set&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;used&amp;#34;&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&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;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 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;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;sequenceDiagram
    participant A as 下单服务
    participant M as MongoDB 副本集
    A-&amp;gt;&amp;gt;M: withTransaction 开启
    A-&amp;gt;&amp;gt;M: 1. 条件原子扣库存
    alt 库存不足
        M--&amp;gt;&amp;gt;A: modifiedCount = 0
        A-&amp;gt;&amp;gt;M: 自动 abortTransaction&amp;lt;br/&amp;gt;三步全部回滚
        A--&amp;gt;&amp;gt;A: 返回售罄
    else 成功
        A-&amp;gt;&amp;gt;M: 2. 插入订单(含快照)
        A-&amp;gt;&amp;gt;M: 3. 核销优惠券
        A-&amp;gt;&amp;gt;M: commit (w:majority)
        M--&amp;gt;&amp;gt;A: 提交成功
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事 #5（事务的 QPS 税）&lt;/strong&gt;：压测发现全事务下单链路 QPS 比「原子扣减 + 消息队列异步落单」低约 40%，且长事务会压住 WiredTiger 的旧版本清理（第二篇的坑）。大促链路的最终方案：&lt;strong&gt;核心扣减用单文档原子操作（快），订单与券的一致性用 MQ + 定时对账任务兜底（最终一致）&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;p&gt;数据量单副本集放不下（经验值：单集合过 2TB 或写入 QPS 过万再考虑），才上分片：&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
    APP[&amp;#34;应用&amp;#34;] --&amp;gt; MONGOS[&amp;#34;mongos 路由层&amp;#34;]
    MONGOS --&amp;gt; S1[&amp;#34;Shard 1&amp;lt;br/&amp;gt;副本集&amp;#34;]
    MONGOS --&amp;gt; S2[&amp;#34;Shard 2&amp;lt;br/&amp;gt;副本集&amp;#34;]
    MONGOS --&amp;gt; S3[&amp;#34;Shard 3&amp;lt;br/&amp;gt;副本集&amp;#34;]
    MONGOS -.-&amp;gt; CFG[&amp;#34;Config Server&amp;lt;br/&amp;gt;元数据副本集&amp;#34;]
    S1 -.-&amp;gt;|&amp;#34;均衡器&amp;lt;br/&amp;gt;chunk 迁移&amp;#34;| S2&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;分片键（shard key）是 Mongo 最「一选定终身」的决策，选错无法修改（5.0 之前）。三条铁律：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;高基数&lt;/strong&gt;：用 &lt;code&gt;status&lt;/code&gt;（只有几个枚举值）做分片键，所有数据挤在少数 chunk 里，分了个寂寞。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;避免单调递增&lt;/strong&gt;：用 &lt;code&gt;createdAt&lt;/code&gt; 或自增 ObjectId 做分片键，所有新写入都打到&lt;strong&gt;最后一个分片&lt;/strong&gt;——热点分片被写爆，其他分片围观。日志类业务要么 hash 分片键，要么接受这个形态。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;匹配查询模式&lt;/strong&gt;：订单按 &lt;code&gt;userId&lt;/code&gt; hash 分片，「查我的订单」路由到单分片，飞快；「全平台订单报表」变成广播查询（scatter-gather），慢——所以报表本来就不该打在分片集群上，该走数仓。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 #6（jumbo chunk 卡死均衡）&lt;/strong&gt;：早期一张集合按 &lt;code&gt;categoryId&lt;/code&gt; 分片，头部类目一个 chunk 涨到 500MB 还无法拆分（jumbo chunk），均衡器彻底停摆，分片间数据倾斜到 3:1。最后导出数据重建集合换 hash 分片键才解决，停机 4 小时。&lt;strong&gt;分片键评审必须过架构组，这是红线第三条。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;第八篇-运维底线与-ai-时代把系统做稳把代码写少&#34;&gt;第八篇 运维底线与 AI 时代：把系统做稳，把代码写少&lt;/h2&gt;
&lt;h3 id=&#34;81-生产运维的最低纲领&#34;&gt;8.1 生产运维的最低纲领&lt;/h3&gt;
&lt;p&gt;不展开讲 DBA 手册，只列必须亲自盯的五件事：&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
    M[&amp;#34;监控&amp;lt;br/&amp;gt;slow query / cache 压力 / oplog 窗口 / 复制延迟&amp;#34;] --&amp;gt; A[&amp;#34;告警&amp;lt;br/&amp;gt;复制延迟 &amp;gt; 30s 即报警&amp;lt;br/&amp;gt;别等追不上才发现&amp;#34;]
    B[&amp;#34;备份&amp;lt;br/&amp;gt; mongodump 逻辑备份&amp;lt;br/&amp;gt;+ 快照物理备份&amp;lt;br/&amp;gt;恢复演练每季度一次&amp;#34;] --&amp;gt; A
    C[&amp;#34;慢查询&amp;lt;br/&amp;gt;profiler 只开采样&amp;lt;br/&amp;gt;每周过 top 慢查询&amp;#34;] --&amp;gt; A
    D[&amp;#34;容量&amp;lt;br/&amp;gt;磁盘水位 70% 预警&amp;lt;br/&amp;gt;oplog 大小按写入峰值调&amp;#34;] --&amp;gt; A
    E[&amp;#34;权限&amp;lt;br/&amp;gt;最小化授权 禁止裸奔&amp;lt;br/&amp;gt;生产禁 dropDatabase&amp;#34;] --&amp;gt; A&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;踩坑故事 #7（恢复演练救了我们一命）&lt;/strong&gt;：团队坚持每季度做一次「备份恢复演练」，被吐槽形式主义。直到有人误删集合（&lt;code&gt;drop()&lt;/code&gt; 当 &lt;code&gt;deleteMany()&lt;/code&gt; 用了），靠备份 40 分钟恢复，业务无损。&lt;strong&gt;没演练过的备份等于没有备份&lt;/strong&gt;——这句话值一个 P0 事故。&lt;/p&gt;
&lt;h3 id=&#34;82-ai-时代如何少写代码把系统做稳&#34;&gt;8.2 AI 时代：如何少写代码把系统做稳&lt;/h3&gt;
&lt;p&gt;写这篇博客的 2021 年，GitHub Copilot 刚发布。站在工程视角，做几个判断，留给未来验证：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;AI 会吃掉「模式化代码」，吃不掉「模式化决策」。&lt;/strong&gt; CRUD、Schema 定义、聚合管道骨架、连接池配置——这些高度模式化的代码 AI 生成得又快又准（你甚至可以现在就试试让 Copilot 补全上面的 &lt;code&gt;DeductStockDemo&lt;/code&gt;，完成度惊人）。但「嵌入还是引用」「分片键选什么」「writeConcern 定哪档」——这些&lt;strong&gt;依赖业务流量预测的决策&lt;/strong&gt;，AI 给不出答案。核心竞争力从「会写」转向「会判断」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;「少写代码」的正确姿势是把决策做在数据层。&lt;/strong&gt; 用快照冗余消灭 &lt;code&gt;$lookup&lt;/code&gt;，用 TTL 索引消灭清理脚本，用原子扣减消灭分布式锁，用条件更新消灭事务——本篇通篇的「少写代码」讲的都是这个：&lt;strong&gt;好的建模让代码自然变少&lt;/strong&gt;。AI 是放大器，放大的既可以是好设计，也可以是坏设计。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个人撑起一个 App 的门槛在崩塌。&lt;/strong&gt; MongoDB Atlas（托管副本集/分片）+ Serverless 函数 + AI 生成 CRUD 层，一个小电商的服务端几天就能搭起来。2021 年这还只是苗头，我确信它会成常态。但反过来：&lt;strong&gt;门槛越低，线上事故越便宜&lt;/strong&gt;——不懂 writeConcern 的人也能上线了，丢单的事故只会更多。AI 时代的工程教育，重点应该从「怎么实现」转向「哪里会炸」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI 生成代码的第一评审项是资源语义。&lt;/strong&gt; 连接谁关、事务多长、批量多大、一致性哪档——AI 生成的代码功能往往是对的，资源语义常常是错的（比如给你 &lt;code&gt;w: 1&lt;/code&gt; 的默认写入）。Code Review 的重心会整体迁移到这四个问题上。&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;思考与感悟之四（收尾）&lt;/strong&gt;：工程的本质，是「替业务把不确定性挡在系统之外」。MongoDB 给了你自由度，也把范式的保护伞收走了——锁粒度、一致性档位、分片拓扑，每一个都要自己选。AI 会帮你把代码写得越来越快，但「选什么」的责任永远在人身上。这既是这份工作的辛苦之处，也是它的护城河。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&#34;附录demo-与检查清单&#34;&gt;附录：Demo 与检查清单&lt;/h2&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;Demo&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;/td&gt;
					&lt;td&gt;&lt;code&gt;IndexDemo&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;ESR 复合索引、部分索引、TTL 索引&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;热销榜聚合&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;TopSalesDemo&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;$match&lt;/code&gt; 前置、&lt;code&gt;$unwind&lt;/code&gt;/&lt;code&gt;$group&lt;/code&gt;、&lt;code&gt;allowDiskUse&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;写入档位&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;demo02-write-concern&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;w:1&lt;/code&gt; / &lt;code&gt;majority&lt;/code&gt; / journal 的取舍&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;原子扣库存&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;DeductStockDemo&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;条件原子更新防超卖&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;事务下单&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;PlaceOrderTxDemo&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;多文档事务、回滚、事务读写关注级别&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;MongoDB 生产红线（本文沉淀，可直接抄进团队规范）：&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;生产环境永远不开不设限的大批量更新，批量操作必拆批 + 让出窗口。&lt;/li&gt;
&lt;li&gt;凡带 sort 的查询，必须 explain 验证走索引排序。&lt;/li&gt;
&lt;li&gt;分片键评审必须过架构组；能用副本集解决的绝不上分片。&lt;/li&gt;
&lt;li&gt;钱相关的读写一律 &lt;code&gt;majority&lt;/code&gt;；默认配置永远不可信。&lt;/li&gt;
&lt;li&gt;备份必须每季度做恢复演练，没演练过的备份等于没有备份。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;文中代码基于 MongoDB 5.0 + 官方 Java 同步驱动 4.3 实测可编译运行。如果你正站在「要不要接 MongoDB」的门口，建议按八篇顺序在本地副本集上把每个坑亲手复现一遍——看别人踩坑是故事，自己踩一遍才是经验。&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
