Flutter 基础体系 · 第 78/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Web 发布:Renderer、缓存、路由、CDN、PWA 和回滚

Flutter Web 的发布不是把 build/web 目录上传到静态服务器这么简单。浏览器首先要下载入口 HTML,然后根据其中的 JavaScript、字体、图片和渲染器资源启动 Flutter 引擎;用户访问的 URL 可能是应用内部路由,也可能是服务器上的真实文件;CDN 可能缓存了旧入口,Service Worker 还可能继续拦截请求。任何一层的版本不一致,都可能表现为白屏、404、旧页面、资源加载失败或“只有部分用户无法更新”。

本文以当前稳定版 Flutter 和 Dart 3 为背景,解释 Flutter Web 发布中几个容易混淆的概念:

  • Renderer 如何影响构建产物、浏览器能力和加载链路;
  • 为什么入口 HTML 与带哈希的静态资源必须采用不同缓存策略;
  • PathUrlStrategy 如何改变服务器路由要求;
  • CDN 如何正确缓存 Flutter Web 资源;
  • PWA、Service Worker 与普通 Web 发布之间的关系;
  • 如何设计可验证、可恢复的发布和回滚流程。

其中,Android、iOS、桌面应用使用原生或桌面渲染路径,不经过浏览器的 URL 回退、CDN 缓存和 Service Worker;本文的发布机制主要针对 Web。


一、先建立 Flutter Web 的发布模型

一次 Flutter Web 页面加载可以抽象为以下数据流:

flowchart TD
    U[用户访问 URL] --> DNS[DNS]
    DNS --> CDN[CDN 或反向代理]
    CDN --> H[入口 HTML]
    H --> JS[Flutter 引导 JavaScript]
    H --> A[资源清单与资源 URL]
    JS --> E[Flutter Engine]
    E --> R[Renderer]
    R --> C[Canvas 或 DOM]
    A --> F[字体、图片、代码、CanvasKit/skwasm 资源]
    SW[Service Worker] -.拦截请求.-> CDN
    API[后端 API] -.XHR/fetch.-> CDN

这里有四类不同对象:

  1. 入口文档:通常是 index.html。浏览器直接请求它,或由服务器把未知应用路径回退到它。
  2. 应用引导代码:例如 flutter_bootstrap.js、主入口 JavaScript 以及 Flutter 引擎相关脚本。
  3. 构建资源:Dart 编译后的 JavaScript/Wasm、字体、图片、Asset Manifest、渲染器资源等。
  4. 运行时状态:浏览器 HTTP Cache、CDN Cache、Service Worker Cache,以及应用自身的本地存储。

发布成功的必要条件不是“服务器上存在新文件”,而是:

入口版本引用资源版本资源可获取运行时兼容\text{入口版本} \rightarrow \text{引用资源版本} \rightarrow \text{资源可获取} \rightarrow \text{运行时兼容}

例如,新入口引用了 main.dart.js 的新内容,但 CDN 仍返回旧的 flutter.js;或者旧入口引用的资源已被删除,都会导致版本链断裂。

因此,部署系统必须同时满足:

  • 一个版本的入口只引用同一个版本的资源;
  • 旧入口引用的资源在回滚窗口内仍然存在;
  • HTML、静态资源和 Service Worker 的更新方式不同;
  • 路由失败不能被错误地转化为静态资源成功响应。

二、Renderer 到底是什么

2.1 Renderer 位于 Dart UI 与浏览器之间

Flutter 应用代码调用的是 Flutter Framework,例如:

return MaterialApp(
  home: Scaffold(
    appBar: AppBar(title: const Text('首页')),
    body: const Center(child: Text('Hello Web')),
  ),
);

这段代码不会直接创建一个浏览器 <button><div>。Flutter Framework 生成绘制与布局指令,再由 Flutter Engine 和 Web Renderer 将其输出到浏览器。

Renderer 可以理解为:

Flutter WidgetRenderObjectEngine 绘制指令浏览器输出\text{Flutter Widget} \rightarrow \text{RenderObject} \rightarrow \text{Engine 绘制指令} \rightarrow \text{浏览器输出}

它决定了最后一段如何实现。不同 Renderer 可能使用:

  • HTML DOM 与 CSS;
  • Canvas 及其图形引擎;
  • WebAssembly 与浏览器 WebAssembly 能力;
  • JavaScript 胶水代码和相应的渲染器资源。

Renderer 不是应用路由器,也不是 CDN 配置;它主要影响绘制实现、构建产物和浏览器兼容条件。

2.2 HTML、CanvasKit 与 skwasm

Flutter Web 历史上提供过不同的渲染器。具体默认值和可用参数会随 Flutter 稳定版变化,因此应以当前版本的 flutter build web --help 和官方文档为准。常见选项包括:

  • HTML Renderer:更多依赖 DOM、CSS 和浏览器 HTML 能力。兼容范围较广,但复杂绘制、文字和效果的行为与 Canvas 路径不同。
  • CanvasKit Renderer:通过 CanvasKit 将 Skia 图形能力带到 Web,通常需要额外的渲染器资源,绘制模型更接近 Flutter 的跨平台图形模型。
  • skwasm Renderer:使用 WebAssembly 版本的 Skia 相关能力。它对浏览器的 WebAssembly 能力有要求,通常需要更谨慎地处理浏览器兼容性和降级策略。

不能将“使用 HTML Renderer”理解为“Flutter 页面变成普通 HTML 页面”。Flutter 控件的语义、可访问性和 DOM 结构仍然不能等同于手写 Web 页面。

构建时可以先查看当前 SDK 支持的参数:

flutter --version
flutter build web --help

在支持这些选项的 SDK 中,典型构建形式可能是:

flutter build web --release --web-renderer canvaskit

或者:

flutter build web --release --web-renderer skwasm

参数名和可用值属于版本敏感内容,不应把某个版本的命令直接复制到所有项目。若命令输出不包含某个 Renderer,应以当前 SDK 能力为准,而不是手工修改构建产物。

2.3 Renderer 的选择不是单一的“性能排序”

Renderer 的取舍至少涉及四个变量:

Q=f(C,G,B,A)Q = f(C, G, B, A)

其中:

  • CC:浏览器兼容性;
  • GG:图形能力和视觉一致性;
  • BB:首屏加载与带宽成本;
  • AA:辅助功能、文本选择和嵌入 Web 内容的适配性。

例如:

  • 一个以数据表格、表单和文本为主的应用,可能更看重兼容性、文本行为和无障碍;
  • 一个包含大量动画、自定义绘制、图表或游戏场景的应用,可能更看重图形能力;
  • 一个面向受控企业浏览器环境的应用,可以接受更严格的浏览器要求;
  • 一个面向未知终端的公共站点,必须先定义不支持的浏览器和降级策略。

不能只根据桌面 Chrome 的一次测试决定 Renderer。至少应在以下维度验证:

  1. 目标浏览器是否能启动;
  2. 首次加载是否成功;
  3. 字体、文本方向和文本输入是否正确;
  4. Canvas、图片、阴影、裁剪和动画是否正常;
  5. 浏览器缩放、窗口调整和高 DPI 显示是否正常;
  6. WebView 或嵌入式浏览器是否满足要求;
  7. 旧浏览器收到不兼容资源时是否有明确失败信息。

2.4 Wasm 构建与 JavaScript 构建不是同一份产物

使用 WebAssembly 相关构建时,通常会改变应用的引导代码、资源集合和浏览器要求。不要把普通 JavaScript 构建目录与 Wasm 构建目录混合发布。

典型验证流程如下:

rm -rf build/web
flutter pub get
flutter build web --release
find build/web -maxdepth 2 -type f | sort | head -50

如果当前 SDK 支持 Wasm 构建,应该使用该 SDK 文档规定的命令生成独立目录,再单独验证。发布系统应把构建参数纳入版本元数据,例如:

{
  "version": "2025.03.08-abc123",
  "flutter": "3.x.y",
  "renderer": "canvaskit",
  "mode": "release"
}

这样在故障排查时,可以回答“用户当前运行的到底是哪一种 Renderer 和哪一次构建”,而不是只知道“这是 Web 版本”。


三、构建产物与版本一致性

3.1 build/web 中哪些文件不能随意删除

不同 Flutter 版本、构建模式和 Renderer 会产生不同文件,但通常包含以下类别:

  • index.html:入口文档;
  • 引导脚本:启动 Flutter 应用;
  • 主 JavaScript 或 Wasm 文件;
  • assets/:字体、图片、Asset Manifest、Flutter 资源;
  • Renderer 相关资源;
  • 图标、Web Manifest 和可能的 Service Worker 文件。

不要只上传一个 index.htmlmain.dart.js。Renderer 资源、字体、清单和引导文件缺失时,页面可能在请求阶段就失败。

发布前可检查资源引用:

grep -RhoE 'src="[^"]+|href="[^"]+' build/web/index.html | sort
find build/web -type f | sort

对于动态生成的引导文件,还应从浏览器开发者工具的 Network 面板确认实际请求,而不是仅凭目录名称猜测。

3.2 为什么“先删除旧文件再上传新文件”有风险

设版本 V1V_1 的入口引用:

main.dart.js
assets/AssetManifest.json
canvaskit/canvaskit.js

发布 V2V_2 时,如果流程是:

  1. 删除整个站点目录;
  2. 上传新目录;
  3. 修改 CDN 指向;

那么在第 1 和第 2 步之间,用户可能拿到:

  • index.html 存在但 main.dart.js 不存在;
  • 新的 HTML 与旧 CDN 缓存的某些资源混用;
  • 部分 CDN 节点已经更新,部分节点仍返回旧内容。

更严重的是,部署期间一次正常请求就可能把错误响应写入缓存。

正确方向是让每个发布版本拥有独立目录:

/releases/
  2025-03-08-abc123/
    index.html
    main.dart.js
    assets/
  2025-03-01-def456/
    index.html
    main.dart.js
    assets/
current -> /releases/2025-03-08-abc123

或者在对象存储中使用:

/releases/2025-03-08-abc123/index.html
/releases/2025-03-08-abc123/main.dart.js
/releases/2025-03-08-abc123/assets/...

再由反向代理或 CDN 的版本路由将 / 指向当前版本。

3.3 原子切换的关键条件

一次发布切换可以表示为:

P:VoldVnewP: V_{\text{old}} \rightarrow V_{\text{new}}

要让切换近似原子,必须先保证:

rRnew,exists(r)=true\forall r \in R_{\text{new}},\quad \operatorname{exists}(r)=\text{true}

其中 RnewR_{\text{new}} 是新入口引用的全部资源集合。只有资源全部可访问,才能切换入口。

在 Linux 文件系统上,可以使用符号链接切换:

ln -sfn /srv/releases/2025-03-08-abc123 /srv/current

ln -sfn 的具体原子性和并发行为仍取决于文件系统与操作方式;生产环境更常见的是让 Nginx、对象存储版本前缀或负载均衡配置切换一个版本标识,并在切换前完成探测。


四、缓存:为什么入口和资源必须区别对待

4.1 HTTP Cache 的基本判断

浏览器或 CDN 是否使用缓存,通常会综合以下信息:

  • URL 和查询字符串;
  • Cache-Control
  • ETag
  • Last-Modified
  • Expires
  • 响应状态码;
  • CDN 自己的缓存规则。

对某个请求 xx,可以粗略表示为:

serve from cache    fresh(x)cacheable(x)\text{serve from cache} \iff \text{fresh}(x) \land \text{cacheable}(x)

“缓存命中”不等于“永久使用旧内容”,但如果响应被标记为长期新鲜,浏览器可能在很长时间内不向服务器询问。

4.2 HTML 与哈希资源的不同生命周期

入口 HTML 通常是:

/index.html

它的 URL 不变,但内容变化频繁。若用户缓存了旧 HTML,发布新版本后仍可能继续引用旧 JavaScript。

静态资源则适合使用内容哈希文件名,例如:

main.8f2a1c.js
assets/logo.31ab90.png

若文件名由内容决定,则:

filename=hash(content)\text{filename} = \operatorname{hash}(\text{content})

内容改变时文件名也改变,旧 URL 不会“悄悄变成新内容”。这允许对资源设置长期缓存:

Cache-Control: public, max-age=31536000, immutable

但是,不能对所有 Flutter 构建产物盲目添加 immutable。如果某个文件名固定而内容会覆盖,例如 index.html、某些引导脚本或固定名称的清单,那么长期缓存会造成更新滞后。

一个常见策略是:

对象 典型策略 原因
index.html no-cache,或短 TTL 并要求重新验证 入口必须尽快发现新版本
带内容哈希的 JS/CSS/图片 长 TTL + immutable 文件名变化代表内容变化
固定名称的引导脚本 短 TTL 或 no-cache 内容可能随构建变化
Web Manifest 短 TTL 图标、名称和安装信息可能变化
Service Worker 脚本 必须允许重新验证 它控制后续缓存行为
版本探针 JSON no-cache 用于健康检查和版本判断

no-cache 的含义是“使用前必须向服务器重新验证”,不是“不允许存储”;如果需要禁止存储,才使用 no-store。生产中通常希望浏览器保留 HTML,但每次发布检查其是否变化,因此 no-cache 更符合入口文档的语义。

4.3 Nginx 示例

下面是一个按路径设置缓存的示例。实际文件名应根据当前 Flutter 版本的构建产物调整:

server {
    listen 443 ssl;
    server_name example.com;

    root /srv/current;

    location = /index.html {
        add_header Cache-Control "no-cache" always;
        try_files $uri =404;
    }

    location = /flutter_service_worker.js {
        add_header Cache-Control "no-cache" always;
        try_files $uri =404;
    }

    location = /flutter_bootstrap.js {
        add_header Cache-Control "no-cache" always;
        try_files $uri =404;
    }

    location ~* \.(js|wasm|json|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

这里有两个重要边界:

  1. try_files $uri $uri/ /index.html 适合应用路由,但不应让不存在的静态资源也回退成 HTML;
  2. 如果带哈希资源被构建成固定文件名,就不能按扩展名一律长期缓存。

更严格的配置可以要求静态资源必须真实存在:

location ~* ^/(assets|canvaskit|skwasm)/ {
    try_files $uri =404;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

服务器返回错误状态时,也要检查是否错误地附带了长期缓存头。一个被 CDN 缓存的 404 可能在文件已经上传后仍持续失败。

4.4 用命令验证响应头

发布后不要只打开页面,应检查入口和资源:

curl -I https://example.com/
curl -I https://example.com/index.html
curl -I https://example.com/assets/AssetManifest.json

预期关注点包括:

HTTP/2 200
cache-control: no-cache
etag: "..."
content-type: text/html

资源则应类似:

HTTP/2 200
cache-control: public, max-age=31536000, immutable
content-type: application/javascript

如果 index.html 返回 text/html 没问题,但请求 /main.dart.js 也返回 text/html,通常说明路由回退配置过宽,资源文件缺失时被错误地回退到了入口页面。浏览器随后会报类似“Unexpected token <”,因为它把 HTML 当成 JavaScript 解析。


五、路由:Hash URL 和 Path URL 的根本差异

5.1 两种 URL 结构

Flutter Web 常见两种 URL 策略。

Hash URL:

https://example.com/#/orders/42

# 后面的部分不会作为 HTTP 请求路径发送给服务器。服务器只看到 /,因此静态服务器不需要理解 Flutter 的应用路由。

Path URL:

https://example.com/orders/42

用户直接打开该地址时,浏览器会请求:

GET /orders/42

服务器必须把这个“应用内部路径”返回给 index.html,否则会返回真实的 404

Flutter 中可显式使用:

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy();
  runApp(const MyApp());
}

usePathUrlStrategy() 使用路径形式替代哈希形式。它影响浏览器地址和服务器配置,但不会自动配置 Nginx、Apache、CDN 或对象存储。

5.2 Path URL 的完整请求过程

假设应用中存在 /orders/42 路由:

  1. Flutter 应用运行后,导航到 /orders/42
  2. 浏览器地址变成 https://example.com/orders/42
  3. 用户刷新页面;
  4. 浏览器向服务器请求 /orders/42
  5. 服务器发现磁盘上没有名为 orders/42 的文件;
  6. 服务器返回 index.html
  7. Flutter 启动;
  8. 路由系统读取当前路径 /orders/42
  9. 应用渲染订单详情页。

第 5 步缺失时,刷新会 404。第 6 步错误时,可能返回服务器默认页面。第 8 步如果路由表不认识该路径,则应用可能显示自己的路由错误页。

5.3 服务器回退必须区分页面和资源

正确的回退条件是:

request is application route¬file existsreturn index.html\text{request is application route} \land \neg \text{file exists} \Rightarrow \text{return index.html}

而不是:

¬file existsreturn index.html\neg \text{file exists} \Rightarrow \text{return index.html}

后一个规则会把以下错误隐藏起来:

/assets/missing.png  -> index.html
/main.dart.js        -> index.html

这会使真正的资源缺失变成浏览器解析错误,诊断更加困难。

在 CDN 中也应实现类似规则:

  • /assets/*/*.js/*.wasm、字体和图片缺失时返回 404
  • 应用页面路径在资源不存在时回退到入口;
  • API 路径绝不能回退到入口,否则前端会收到 HTML 而不是 JSON。

例如:

/api/*        -> 交给 API 源站,不回退
/assets/*     -> 静态资源源站,404 就是 404
/orders/42     -> 回退到 index.html

5.4 路由参数与 Web 服务器

应用路由中的参数可能包含:

/orders/42
/search?q=flutter

路径部分通常由 Flutter 路由解析;查询参数则仍由浏览器 URL 提供。服务器回退只负责返回入口,不应试图替代应用路由解析。

如果前端路由依赖认证状态,服务器仍然需要先返回入口,然后由 Flutter 启动后读取登录态并决定显示页面。不要把 Flutter 的客户端路由误当作服务器端权限控制。真正敏感的数据必须由后端 API 鉴权,隐藏页面或修改 URL 不能构成安全边界。


六、CDN:缓存的不是“网站”,而是不同类型的请求

6.1 CDN 的三个关键动作

CDN 通常执行:

  1. 根据 Host、路径、查询字符串和规则选择缓存键;
  2. 命中缓存时直接返回对象;
  3. 未命中时从源站获取、根据响应头缓存,再返回给用户。

因此,CDN 配置必须回答三个问题:

  • 哪些 URL 可以长时间缓存;
  • 哪些 URL 必须回源验证;
  • 哪些请求不能被静态站点规则处理。

如果 CDN 忽略 Cache-Control: no-cache,入口 HTML 仍可能长时间不更新。若 CDN 把查询字符串纳入缓存键,而应用又使用无意义的跟踪参数,就会造成大量低命中率缓存对象。若 CDN 不区分不同 Host,预览环境的内容可能被生产域名命中。

6.2 推荐的版本化源站结构

一种稳定结构如下:

s3://bucket/releases/2025-03-08-abc123/index.html
s3://bucket/releases/2025-03-08-abc123/main.dart.js
s3://bucket/releases/2025-03-08-abc123/assets/...
s3://bucket/releases/2025-03-01-def456/...

CDN 对外暴露:

https://example.com/index.html
https://example.com/main.dart.js

边缘层把当前版本映射到某个 release 前缀。切换时只改变映射,不删除旧版本目录。

另一种简单方式是每个构建直接包含哈希资源,但保留固定入口。此时必须保证:

  • 新入口引用的资源已上传;
  • 旧入口引用的资源不被删除;
  • CDN 能同时服务新旧资源;
  • 回滚只切换入口或版本映射。

6.3 发布顺序

一个可验证的顺序是:

构建
  ↓
生成版本目录
  ↓
上传全部资源
  ↓
从版本目录读取并检查入口
  ↓
请求入口和关键资源
  ↓
切换 current
  ↓
刷新或失效入口缓存
  ↓
执行真实浏览器冒烟测试

上传阶段使用版本前缀:

set -euo pipefail

VERSION="$(git rev-parse --short HEAD)"
RELEASE_DIR="build/releases/$VERSION"

flutter build web --release
mkdir -p "$RELEASE_DIR"
cp -a build/web/. "$RELEASE_DIR/"

test -f "$RELEASE_DIR/index.html"
test -f "$RELEASE_DIR/main.dart.js" || true
find "$RELEASE_DIR" -type f | wc -l

这里的 || true 只是为了说明不同 Flutter 版本的入口文件名可能变化;实际 CI 不应无条件忽略主产物,而应根据当前构建结果明确检查。例如先执行:

find "$RELEASE_DIR" -maxdepth 1 -type f -print

再将项目实际需要的引导文件纳入检查。

如果使用对象存储:

aws s3 sync "$RELEASE_DIR/" \
  "s3://example-bucket/releases/$VERSION/" \
  --delete

--delete 只能用于独立的版本前缀,不能用于整个共享生产目录,否则可能删除其他版本仍需引用的资源。

6.4 CDN 失效不是回滚的唯一手段

可以对 /index.html 做精准失效:

aws cloudfront create-invalidation \
  --distribution-id "$DISTRIBUTION_ID" \
  --paths "/index.html" "/"

但要注意:

  • 失效操作可能是异步的;
  • 不同边缘节点完成时间可能不同;
  • 浏览器本地 Cache 和 Service Worker 不受 CDN 失效直接控制;
  • 如果旧资源已删除,失效入口也无法修复旧客户端。

因此,CDN 失效用于加速入口更新,而不是替代版本保留和原子发布。


七、PWA 与 Service Worker

7.1 PWA 是一组浏览器能力,不只是一个图标

PWA(Progressive Web App)通常由以下能力组合而成:

  • 可安装性;
  • Web App Manifest;
  • Service Worker;
  • 离线或弱网缓存;
  • 安全上下文,通常要求 HTTPS;
  • 合适的图标、启动显示模式和应用元数据。

“页面能在手机浏览器打开”不等于“它是 PWA”。同样,添加一个 manifest.json 也不等于已经实现离线能力。

7.2 Service Worker 位于网络请求前面

Service Worker 是浏览器控制的后台脚本。它可以在页面发起请求前拦截:

页面请求
  ↓
Service Worker 是否控制当前页面?
  ├─ 否:浏览器 Cache/CDN/源站
  └─ 是:Service Worker 决定网络、缓存或回退

它的生命周期通常包括:

  1. 浏览器发现新的 Service Worker 脚本;
  2. 下载并执行安装;
  3. install 成功后进入 waiting;
  4. 旧 Worker 仍可能控制当前页面;
  5. 新 Worker 激活;
  6. 新 Worker 控制后续页面或请求;
  7. 页面刷新或重新打开后,用户通常才完整运行新版本。

如果在 install 阶段预缓存资源,某个关键资源失败可能导致安装失败;如果激活阶段删除旧缓存过早,仍由旧页面使用的资源可能突然不可用。

7.3 Flutter 自动生成 Service Worker 的版本边界

Flutter 曾经可以在 Web 构建中生成 Flutter Service Worker,用于缓存构建资源。但该行为和 PWA 支持策略属于版本敏感内容,近年的 Flutter 文档已提示相关自动生成能力可能被弃用或调整。不能假设所有当前稳定版都以相同方式生成 flutter_service_worker.js

发布前应检查:

find build/web -maxdepth 1 -type f -print
grep -R "serviceWorker" build/web/index.html build/web/*.js 2>/dev/null || true

如果构建产物包含自动生成的 Service Worker,必须验证它的缓存范围、缓存命名和更新行为。如果项目需要明确的离线策略,通常应将 Service Worker 当作独立前端基础设施管理,而不是依赖某个版本的默认生成结果。

7.4 Service Worker 最容易造成“回滚无效”

假设:

  1. 用户在版本 V1V_1 打开页面;
  2. Service Worker 安装并缓存了 V1V_1
  3. 发布 V2V_2
  4. 发现错误,服务器回滚到 V1V_1
  5. 用户仍由控制 V2V_2 的 Service Worker 拦截请求。

此时用户看到的可能仍是 V2V_2,因为请求根本没有到达已经回滚的 CDN。

因此,回滚必须同时考虑:

回滚生效=服务器入口回滚CDN 入口回源Service Worker 状态可控\text{回滚生效} = \text{服务器入口回滚} \land \text{CDN 入口回源} \land \text{Service Worker 状态可控}

常见处理方式有三种:

  • 发布新 Service Worker,使其切换到已知正常版本;
  • 让 Service Worker 的资源缓存键包含构建版本,并保留旧资源;
  • 对紧急故障设计“网络优先”或可远程关闭离线缓存的策略。

不能只删除服务器上的 Service Worker 文件来“强制卸载”。浏览器可能继续使用已注册的 Worker;而且删除脚本后的更新行为还取决于浏览器何时重新检查该 URL。

7.5 选择缓存策略

Service Worker 中常见策略包括:

  • Cache First:先缓存,适合带哈希的静态资源;
  • Network First:先网络,失败再使用缓存,适合入口 HTML 或需要及时更新的内容;
  • Stale While Revalidate:先返回缓存,同时后台更新;
  • Network Only:适合登录、支付、实时 API 等不能离线伪造的请求。

对 Flutter Web,入口 HTML 通常不适合永久 Cache First。否则新版本可能永远无法被发现。带内容哈希的静态资源适合长期缓存,但需要保留旧版本,避免旧入口或旧 Worker 请求到 404

API 请求也不应与应用静态资源使用同一套离线策略。例如:

GET /assets/main.xxx.js  -> Cache First
GET /index.html          -> Network First
GET /api/orders/42       -> Network Only 或明确的业务缓存
POST /api/orders         -> Network Only

POST、登录响应或包含用户数据的 API 结果写入通用静态缓存,可能造成数据泄露或状态错乱。


八、PWA 更新与用户可见版本

仅仅更新文件,并不能保证用户立刻看到新版本。因为存在多个缓存层:

Service Worker Cache
    ↓
浏览器 HTTP Cache
    ↓
CDN Cache
    ↓
源站文件

如果要显示当前版本,可以在构建时生成一个固定格式的版本文件:

{
  "version": "2025-03-08-abc123",
  "builtAt": "2025-03-08T10:30:00Z"
}

部署后从入口或 API 暴露:

/version.json

version.json 必须设置为 no-cache,否则它本身也可能是旧的。

前端可以在启动后请求版本探针:

import 'dart:convert';
import 'dart:html' as html;

Future<String?> fetchWebVersion() async {
  final request = await html.HttpRequest.request(
    '/version.json',
    requestHeaders: const {'Cache-Control': 'no-cache'},
  );

  if (request.status != 200 || request.responseText == null) {
    return null;
  }

  final data = jsonDecode(request.responseText!) as Map<String, dynamic>;
  return data['version'] as String?;
}

这段代码只适用于 Web;dart:html 不能用于 Android、iOS、桌面等非 Web 目标。若项目需要跨平台代码,应通过条件导入或平台抽象隔离。

版本探针不能替代 Service Worker 更新机制。它的用途是诊断、显示“发现新版本”、触发温和刷新,而不是直接删除浏览器缓存。


九、发布前的端到端验证

9.1 构建验证

在 CI 中至少记录:

flutter --version
dart --version
flutter pub get
flutter analyze
flutter test
flutter build web --release

对于使用特定 Renderer 的项目,还应把 Renderer 参数显式写入构建脚本,而不是依赖开发者机器的默认值。

构建完成后检查:

test -f build/web/index.html
find build/web -type f -size 0 -print
grep -R "http://" build/web 2>/dev/null || true

空文件检查可以发现上传或生成异常;混入明文 HTTP 地址则可能在 HTTPS 站点中触发 Mixed Content。

9.2 静态请求验证

定义关键 URL 集合:

/
某个公开路由,例如 /about
某个带参数路由,例如 /orders/42
一个 JS 主资源
一个字体或图片资源
一个不存在的静态资源
一个 API 路径

逐项验证:

curl -i https://example.com/
curl -i https://example.com/about
curl -i https://example.com/orders/42
curl -i https://example.com/assets/not-found.png
curl -i https://example.com/api/health

预期关系是:

  • /:返回入口 HTML;
  • /about:Path URL 下返回入口 HTML;
  • /orders/42:返回入口 HTML;
  • 不存在的图片:返回真正的 404,不是入口 HTML;
  • /api/health:由 API 服务处理;
  • JS、Wasm、字体:返回对应 MIME 类型和 200

9.3 浏览器验证

使用真实浏览器执行:

  1. 清除或禁用 Service Worker 后首次打开;
  2. 直接输入一个深层路径;
  3. 刷新深层路径;
  4. 打开 DevTools 的 Network 面板;
  5. 检查是否有 JS/Wasm/字体 404
  6. 检查 Console 是否有 MIME、CORS、Mixed Content 或 Wasm 错误;
  7. 启用离线模式,验证离线策略是否符合预期;
  8. 发布新版本后观察 Worker 和入口是否更新;
  9. 在移动浏览器和目标 WebView 中重复核心路径。

“首页可以打开”只覆盖了最短路径,不能证明路由、Renderer、缓存和 PWA 都正确。


十、常见失败表现与诊断路径

10.1 白屏但服务器返回 200

先看浏览器 Console 和 Network:

  • 主 JS 是否实际返回 HTML;
  • Renderer 资源是否 404
  • Wasm 是否被服务器以正确 MIME 类型返回;
  • 是否存在 CSP 阻止脚本或 Worker;
  • 是否有跨域请求;
  • Service Worker 是否返回旧资源;
  • 是否由于浏览器不支持当前构建路径而启动失败。

如果 main.dart.js 响应体开头是:

<!doctype html>

说明服务器将 JS 请求错误回退到了入口页面。

10.2 刷新子路由出现 404

这通常是 Path URL 使用了,但服务器没有配置入口回退。检查:

curl -i https://example.com/some/deep/path

如果返回服务器原生 404,需要增加页面路由回退;如果返回了入口 HTML,而 Flutter 内部仍显示错误页,则服务器已经正确回退,问题转移到应用路由解析。

10.3 发布后只有部分用户看到旧版本

按层排查:

  1. 请求 index.html 的响应头和 Age
  2. 查看 CDN 的命中状态,如 X-CacheCF-Cache-Status 等供应商字段;
  3. 检查浏览器是否仍有旧 Service Worker;
  4. 检查入口引用的资源是否属于预期版本;
  5. 检查不同地域或不同 Host 的 CDN 配置;
  6. 检查是否存在多个入口,例如 //index.html 配置不同。

不要首先让用户“强制刷新”。强制刷新只能绕过部分浏览器缓存,不能可靠清除 Service Worker 控制,也不能修复源站版本混用。

10.4 回滚后仍然报资源 404

典型原因是回滚只切换了入口,但删除了旧版本资源:

旧入口 V1 -> main.aaa.js
当前源站只保留 main.bbb.js

修复方式是:

  • 保留至少覆盖回滚窗口的版本资源;
  • 按版本前缀存储资源;
  • 回滚入口和资源映射一起切换;
  • 对 Service Worker 缓存中的旧版本进行验证。

10.5 CDN 返回旧的错误页面

如果发布期间资源曾经短暂缺失,CDN 可能缓存了 404。检查错误响应的缓存头和 CDN 错误缓存 TTL。清除错误对象后重新请求,并确认源站文件已经存在,再执行精准失效。


十一、回滚设计:回滚的是版本指针,不是“重新上传旧文件”

11.1 版本、入口和资源

设发布版本为:

V1 = {index-v1, assets-v1}
V2 = {index-v2, assets-v2}

理想状态下:

refs(index_v1)assets_v1\operatorname{refs}(index\_v1) \subseteq assets\_v1

且:

refs(index_v2)assets_v2\operatorname{refs}(index\_v2) \subseteq assets\_v2

回滚应将当前指针从 V2 改回 V1

current = V2  →  current = V1

而不是把 V1 的文件逐个覆盖到 V2 目录。后者会产生混合目录:

index.html      来自 V1
main.dart.js    来自 V2
assets/font     来自旧目录

混合目录无法通过文件名直观识别,故障也更难复现。

11.2 一个简单的发布状态模型

stateDiagram-v2
    [*] --> Built
    Built --> Uploaded: 资源上传完成
    Uploaded --> Verified: 入口和资源探测通过
    Verified --> Active: 切换 current
    Active --> RolledBack: 发现严重故障
    RolledBack --> Active: 切换到已验证版本
    Uploaded --> Aborted: 探测失败
    Aborted --> [*]

每个状态应有可验证条件:

  • Built:构建命令成功,版本信息已记录;
  • Uploaded:版本目录中的文件数量和校验信息完整;
  • Verified:入口、资源、深层路由和浏览器冒烟通过;
  • Active:线上入口和版本探针返回预期版本;
  • RolledBack:旧版本入口、资源和运行时仍可访问。

11.3 回滚操作

如果当前版本映射由 Nginx 符号链接控制:

ln -sfn /srv/releases/2025-03-01-def456 /srv/current
nginx -t
systemctl reload nginx

然后验证:

curl -sS https://example.com/version.json
curl -I https://example.com/

如果使用 CDN,应先改变源站版本映射,再对入口路径做精准失效。不要先删除新版本目录,因为仍然打开新入口的用户可能继续请求其中的资源。

11.4 回滚成功的判据

回滚不是命令执行成功,而是满足以下条件:

入口返回目标旧版本 目标版本所有关键资源返回 200 深层路由可启动 当前 Service Worker 不再强制返回坏版本 错误率恢复\begin{aligned} &\text{入口返回目标旧版本} \\ &\land\ \text{目标版本所有关键资源返回 200} \\ &\land\ \text{深层路由可启动} \\ &\land\ \text{当前 Service Worker 不再强制返回坏版本} \\ &\land\ \text{错误率恢复} \end{aligned}

因此需要同时观察:

  • 浏览器端 JavaScript 错误率;
  • 资源 404
  • 页面加载失败;
  • API 错误率;
  • 不同浏览器和地域的真实探针;
  • CDN 命中与回源情况。

十二、一个可落地的发布方案

下面是一种不依赖特定云厂商的目录和流程:

release-store/
  2025-03-01-def456/
    index.html
    flutter_bootstrap.js
    main.dart.js
    assets/
  2025-03-08-abc123/
    index.html
    flutter_bootstrap.js
    main.dart.js
    assets/

active-version.json

发布脚本的逻辑:

set -euo pipefail

VERSION="${GIT_COMMIT:?missing GIT_COMMIT}"
RELEASE="build/releases/$VERSION"

flutter pub get
flutter analyze
flutter test
flutter build web --release

mkdir -p "$RELEASE"
cp -a build/web/. "$RELEASE/"

test -f "$RELEASE/index.html"
test -d "$RELEASE/assets"

# 上传到不可变版本目录
aws s3 sync "$RELEASE/" \
  "s3://example-bucket/releases/$VERSION/"

# 上传后验证对象存在
aws s3api head-object \
  --bucket example-bucket \
  --key "releases/$VERSION/index.html"

# 最后再切换活动版本
printf '{"version":"%s"}\n' "$VERSION" > active-version.json
aws s3 cp active-version.json \
  "s3://example-bucket/active-version.json" \
  --cache-control 'no-cache'

这段流程的核心不是命令本身,而是顺序:

  • 版本目录不可变;
  • 资源先上传;
  • 上传后验证;
  • 最后改变活动版本;
  • 活动版本探针禁止长期缓存;
  • 旧版本不立即删除。

如果 CDN 通过边缘函数把 / 映射到版本目录,则切换边缘配置也要先完成版本目录验证。若 CDN 无法做到可靠的映射切换,可以保留固定入口,但仍要先上传新资源,再更新入口,最后精准刷新入口缓存。


十三、生产取舍与边界

13.1 不能把 Web 当作移动端安装包

Android、iOS 和桌面应用通常通过应用包管理版本,资源随安装包一起分发;Flutter Web 则由浏览器、HTTP Cache、CDN 和可选的 Service Worker 共同决定运行内容。

因此 Web 发布必须处理:

  • 用户可能长时间不刷新;
  • 用户可能直接访问任意深层 URL;
  • 用户可能处于旧 Service Worker 控制下;
  • 用户可能从不同 CDN 节点获取内容;
  • 服务器可能同时服务多个历史客户端。

13.2 不能用缓存掩盖版本管理问题

长期缓存带哈希资源可以降低带宽和加载时间,但前提是资源真正不可变。如果每次发布都覆盖同名文件,再添加:

Cache-Control: immutable

就会把错误永久化到缓存生命周期内。

同样,Cache-Control: no-store 不能解决 Service Worker 已经缓存的内容;CDN 失效也不能删除用户设备上的应用缓存。必须明确每个缓存层的所有权和清理方式。

13.3 PWA 离线能力会改变故障模型

没有 Service Worker 时,回滚主要涉及浏览器 HTTP Cache、CDN 和源站。启用 Service Worker 后,应用可能在网络正常时仍使用本地旧缓存,也可能在网络异常时返回旧页面。

这不是绝对的好或坏,而是一个业务选择:

  • 内容型站点可能希望尽快展示缓存,再后台更新;
  • 管理后台可能更看重版本一致性;
  • 交易页面通常不能依赖过期数据;
  • 离线表单需要明确冲突解决和提交失败处理。

缓存策略必须按资源和业务语义设置,而不能仅按“静态文件”统一处理。


十四、发布检查清单

发布前可用以下条件进行自动化自查:

[ ] 已记录 Flutter、Dart、Renderer 和构建参数
[ ] build/web 中的入口、引导文件和资源均已生成
[ ] 新版本资源先上传,入口最后切换
[ ] 入口 HTML 使用 no-cache 或短 TTL
[ ] 哈希资源不会被覆盖,并使用长期缓存
[ ] 固定名称脚本没有错误地设置 immutable
[ ] Path URL 的深层路由刷新可返回入口
[ ] 不存在的 JS、Wasm、图片不会回退成 index.html
[ ] API 路径不会被静态站点入口回退规则吞掉
[ ] CDN 已验证入口和资源的缓存头
[ ] Service Worker 的生成、注册、更新和注销策略已确认
[ ] 离线策略没有缓存敏感 API 或写请求
[ ] 旧版本资源在回滚窗口内仍然保留
[ ] 已执行真实浏览器首次打开、刷新、更新和回滚测试

Flutter Web 发布的核心原则可以归纳为:

Renderer 明确+入口可更新+资源不可变+路由可回退+PWA 可控+版本可切换\boxed{ \text{Renderer 明确} + \text{入口可更新} + \text{资源不可变} + \text{路由可回退} + \text{PWA 可控} + \text{版本可切换} }

其中任何一项缺失,都可能让“上传成功”与“用户运行正确版本”之间产生差异。真正可靠的发布系统,应该让版本关系、缓存边界、路由回退、Service Worker 状态和回滚路径都能够被命令、探针和浏览器测试验证。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。