<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Kotlin on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/kotlin/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Thu, 10 Aug 2023 09:10:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/kotlin/index.xml" rel="self" type="application/rss+xml" /><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>
    
  </channel>
</rss>
