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
这里有四类不同对象:
- 入口文档:通常是
index.html。浏览器直接请求它,或由服务器把未知应用路径回退到它。 - 应用引导代码:例如
flutter_bootstrap.js、主入口 JavaScript 以及 Flutter 引擎相关脚本。 - 构建资源:Dart 编译后的 JavaScript/Wasm、字体、图片、Asset Manifest、渲染器资源等。
- 运行时状态:浏览器 HTTP Cache、CDN Cache、Service Worker Cache,以及应用自身的本地存储。
发布成功的必要条件不是“服务器上存在新文件”,而是:
例如,新入口引用了 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 可以理解为:
它决定了最后一段如何实现。不同 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 的取舍至少涉及四个变量:
其中:
- :浏览器兼容性;
- :图形能力和视觉一致性;
- :首屏加载与带宽成本;
- :辅助功能、文本选择和嵌入 Web 内容的适配性。
例如:
- 一个以数据表格、表单和文本为主的应用,可能更看重兼容性、文本行为和无障碍;
- 一个包含大量动画、自定义绘制、图表或游戏场景的应用,可能更看重图形能力;
- 一个面向受控企业浏览器环境的应用,可以接受更严格的浏览器要求;
- 一个面向未知终端的公共站点,必须先定义不支持的浏览器和降级策略。
不能只根据桌面 Chrome 的一次测试决定 Renderer。至少应在以下维度验证:
- 目标浏览器是否能启动;
- 首次加载是否成功;
- 字体、文本方向和文本输入是否正确;
- Canvas、图片、阴影、裁剪和动画是否正常;
- 浏览器缩放、窗口调整和高 DPI 显示是否正常;
- WebView 或嵌入式浏览器是否满足要求;
- 旧浏览器收到不兼容资源时是否有明确失败信息。
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.html 和 main.dart.js。Renderer 资源、字体、清单和引导文件缺失时,页面可能在请求阶段就失败。
发布前可检查资源引用:
grep -RhoE 'src="[^"]+|href="[^"]+' build/web/index.html | sort
find build/web -type f | sort
对于动态生成的引导文件,还应从浏览器开发者工具的 Network 面板确认实际请求,而不是仅凭目录名称猜测。
3.2 为什么“先删除旧文件再上传新文件”有风险
设版本 的入口引用:
main.dart.js
assets/AssetManifest.json
canvaskit/canvaskit.js
发布 时,如果流程是:
- 删除整个站点目录;
- 上传新目录;
- 修改 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 原子切换的关键条件
一次发布切换可以表示为:
要让切换近似原子,必须先保证:
其中 是新入口引用的全部资源集合。只有资源全部可访问,才能切换入口。
在 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 自己的缓存规则。
对某个请求 ,可以粗略表示为:
“缓存命中”不等于“永久使用旧内容”,但如果响应被标记为长期新鲜,浏览器可能在很长时间内不向服务器询问。
4.2 HTML 与哈希资源的不同生命周期
入口 HTML 通常是:
/index.html
它的 URL 不变,但内容变化频繁。若用户缓存了旧 HTML,发布新版本后仍可能继续引用旧 JavaScript。
静态资源则适合使用内容哈希文件名,例如:
main.8f2a1c.js
assets/logo.31ab90.png
若文件名由内容决定,则:
内容改变时文件名也改变,旧 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;
}
}
这里有两个重要边界:
try_files $uri $uri/ /index.html适合应用路由,但不应让不存在的静态资源也回退成 HTML;- 如果带哈希资源被构建成固定文件名,就不能按扩展名一律长期缓存。
更严格的配置可以要求静态资源必须真实存在:
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 路由:
- Flutter 应用运行后,导航到
/orders/42; - 浏览器地址变成
https://example.com/orders/42; - 用户刷新页面;
- 浏览器向服务器请求
/orders/42; - 服务器发现磁盘上没有名为
orders/42的文件; - 服务器返回
index.html; - Flutter 启动;
- 路由系统读取当前路径
/orders/42; - 应用渲染订单详情页。
第 5 步缺失时,刷新会 404。第 6 步错误时,可能返回服务器默认页面。第 8 步如果路由表不认识该路径,则应用可能显示自己的路由错误页。
5.3 服务器回退必须区分页面和资源
正确的回退条件是:
而不是:
后一个规则会把以下错误隐藏起来:
/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 通常执行:
- 根据 Host、路径、查询字符串和规则选择缓存键;
- 命中缓存时直接返回对象;
- 未命中时从源站获取、根据响应头缓存,再返回给用户。
因此,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 决定网络、缓存或回退
它的生命周期通常包括:
- 浏览器发现新的 Service Worker 脚本;
- 下载并执行安装;
install成功后进入 waiting;- 旧 Worker 仍可能控制当前页面;
- 新 Worker 激活;
- 新 Worker 控制后续页面或请求;
- 页面刷新或重新打开后,用户通常才完整运行新版本。
如果在 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 最容易造成“回滚无效”
假设:
- 用户在版本 打开页面;
- Service Worker 安装并缓存了 ;
- 发布 ;
- 发现错误,服务器回滚到 ;
- 用户仍由控制 的 Service Worker 拦截请求。
此时用户看到的可能仍是 ,因为请求根本没有到达已经回滚的 CDN。
因此,回滚必须同时考虑:
常见处理方式有三种:
- 发布新 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 浏览器验证
使用真实浏览器执行:
- 清除或禁用 Service Worker 后首次打开;
- 直接输入一个深层路径;
- 刷新深层路径;
- 打开 DevTools 的 Network 面板;
- 检查是否有 JS/Wasm/字体
404; - 检查 Console 是否有 MIME、CORS、Mixed Content 或 Wasm 错误;
- 启用离线模式,验证离线策略是否符合预期;
- 发布新版本后观察 Worker 和入口是否更新;
- 在移动浏览器和目标 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 发布后只有部分用户看到旧版本
按层排查:
- 请求
index.html的响应头和Age; - 查看 CDN 的命中状态,如
X-Cache、CF-Cache-Status等供应商字段; - 检查浏览器是否仍有旧 Service Worker;
- 检查入口引用的资源是否属于预期版本;
- 检查不同地域或不同 Host 的 CDN 配置;
- 检查是否存在多个入口,例如
/和/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}
理想状态下:
且:
回滚应将当前指针从 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 回滚成功的判据
回滚不是命令执行成功,而是满足以下条件:
因此需要同时观察:
- 浏览器端 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 发布的核心原则可以归纳为:
其中任何一项缺失,都可能让“上传成功”与“用户运行正确版本”之间产生差异。真正可靠的发布系统,应该让版本关系、缓存边界、路由回退、Service Worker 状态和回滚路径都能够被命令、探针和浏览器测试验证。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 桌面发布:Windows、macOS、Linux 打包、签名和更新
- 下一篇:Flutter Firebase 工程:初始化、认证、消息、Crash 和环境隔离
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论