很早之前就碰到了这个问题,当时 Flutter Web 尚在预览阶段,项目中又使用了 GetX,内部代码繁杂分析困难,故将大锅甩至后者,没有细看。

如今再次遇到,一路排查才发现另有原因,特此记录。

TLDR;一句话概括结论请看「总结」部分。

问题和上下文

场景是某个 OAuth2 的身份鉴权,作为第三方服务,通过网页授权的方式让用户登录。这里的需求做了简化:用户带着验证参数访问网页应用,应用据此构造认证链接后跳转过去,认证接口在验证通过后带着 userId 重定向跳回应用。

这个应用采用了 Flutter Web 来实现,编译部署为一个单页 Web 应用(SPA)。

问题出现在:认证接口重定向跳回应用时,携带的 userID 参数消失了。

排查定位

整个流程中可能出错的点比较多,因此做了如下尝试。

  • 手动拼凑绝对正确的认证链接。
  • 在每一个跳转前后打点,弹框当前的 URL。
  • 手动模拟重定向跳转。

均无进展,查看一个验证流程用的 demo 网页,发现参数是有的,确定了是应用自身的问题。但不管如何模拟操作,本地就是复现不了。

没办法,只能访问线上环境,终于在部署页面的调试模式中发现了问题:

这认证接口在重定向返回的时候,把链接里的 /#/ 给截取掉了。。。

默认情况下,Flutter Web 的 URL 是 Hash 形式的:Hostname:port/#/pageA,即比常见的网页多了一层 /#/。经过查询,可能是因为 # 的作用默认为标记网页中的锚点,因此认证服务端认为已经定位到了相关页面,就把后面的内容都抛弃了。

如果丢失了这一层 /#/,网页就会跳回带有它的初始页面。如果没有在 MaterialApp 中设置命名路由的 routes ,直接访问对应的链接,也是无法跳转到相应页面的。

这好像跟预期的网页行为不太一致,为啥呢?

路由和 URL 策略

原因就是 Flutter 默认的路由,与其网页的 URL 并非完全相同。

Flutter 起初是以移动端跨平台框架作为目标来设计的,在 App 上页面的管理相对简单很多,不需要严格遵循网页 URL 的层级结构。很多路由的 API 都是后续添加的,这也就导致了默认行为的一些差异。

用 Hash 来作为默认的 URL 形式,也确实更符合其单页应用的类型,毕竟对应的网页文件是没有变化的。

为了实现需求,必须改回来。根据 官方文档 Flutter Web 的 URL 策略配置 中的简要说明,用以下方法将其 URL 策略切换为常见的 Path 模式,即没有那一层井号。

1
2
3
4
5
6
import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy();
  runApp(App());
}

解决了 URL 路径的问题,这时候再测试,发现跟普通的网站地址就一致了。

URL参数的获取

获取 URL 中的参数也有多种方法,如果想全部使用 Flutter 的通用方法来处理(即可以跨平台使用),甚至解析诸如 /details/:id 这样的形式,可以通过 MaterialApp 中的 onGenerateRoute 方法设置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
onGenerateRoute: (settings) {
  // Handle '/'
  if (settings.name == '/') {
    return MaterialPageRoute(builder: (context) => HomeScreen());
  }
  
  // Handle '/details/:id'
  var uri = Uri.parse(settings.name);
  if (uri.pathSegments.length == 2 &&
      uri.pathSegments.first == 'details') {
    var id = uri.pathSegments[1];
    return MaterialPageRoute(builder: (context) => DetailScreen(id: id));
  }
  
  return MaterialPageRoute(builder: (context) => UnknownScreen());
},

或者,如果你确认项目只会以 Web 形式使用,那就可以大胆放心的引入 ‘dart:js’ 和 ‘dart:html’,直接通过与宿主 HTML 文件交互来实现:

1
2
3
4
5
// 获取网页 URL
final rawUrl = html.window.location.href;

// 在宿主 HTML 中执行 js 方法,如弹框
js.context.callMethod('alert', ['Oops']);

调试和部署的差异

解决了上述问题,测试阶段的各个功能都正常。然而部署上线之后,直接访问子路径下的某个页面,居然返回了 404 错误。

命名路由的 routes 是有的,或者如果使用 GetX,设置了 getPages 是一样的。在调试模式下,这个问题是不存在的,仅仅出现在部署之后,一度让我大为困惑。

对照之前的改动,把 URL 策略切换回 Hash 模式,这个问题就消失了。

难道是 Flutter Web 编译的 bug 嘛?

一通搜索后总算有了结论,原来是托管应用页面的 web 服务(例如 Nginx),默认会把多级路径的 URL 按目录去解析,访问最后一个目录下的 index.html 文件。而对于单页应用来说,显然后续的路径都是作为参数来使用的。而 Hash 模式由于使用一个井号把后续路径隔开了,自然就没有这个问题了。

通过在 Nginx 的配置文件中添加如下配置:

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

意为:先尝试访问 URL 和其路径下的文件,如果不存在,则直接访问根目录的 index.html。

这样就能在线上部署环境顺利解析并传递参数了。

为什么开发的时候没有出现这个问题呢?原因就是在调试模式下,Flutter 优化了本地开发服务器,会优雅的处理各类 URL 策略并指向根目录的 Html 文件,也就是替我们做了上面这一步。

总结

实际上看官方文档能够解决至少一半的问题。如果问题定位的足够准确,相关知识了解的足够全面,文档结构也足够的熟悉,那么甚至能解决超过 80% 的问题……

相关的知识点其实都在文档中有所说明,总结一下就是:

Flutter Web 的 Path URL 策略使用 History API,需要托管的 Web 服务使用 SPA 配置。

Flutter Web 可以自身控制 URL,也可以通过与宿主 HTML 文件交互来实现。

参考