android webview flutter (androidsystemwebview安装失败)

如何处理“找不到页面404”错误,手动直接输入URL并避免URL中的哈希字符?

介绍

当我必须在生产中部署我的第一个Flutter Web应用程序时,我必须处理所有与Web Server逻辑相关的常规功能,尤其是:

  • 著名的“ 找不到页面404 ”
  • 从浏览器直接输入URL

我在互联网上进行了大量搜索,但从未找到任何好的解决方案。

本文介绍了我实施的解决方案…

androidwebviewflutter,androidwebview常见问题

背景资料

本文撰写于2020年2月,基于Flutter 1.14.6版(运行Channel Beta)。

看一下Flutter路线图2020,Flutter Web应该在今年正式发布,其结果是这篇文章可能不会很快相关,因为它所解决的问题可能在未来几个月内得到解决。

我也尝试与Service Workers玩耍,但找不到任何解决方案。

在向您提供我已实施的解决方案之前,我想与您分享一些重要的信息…

提醒-Flutter Web应用程序不能在完全可配置的Web服务器后面运行

“ Flutter Web应用程序 不能 在完全可配置的Web服务器后面运行 ”

这句话非常重要,常常被人遗忘……

确实,当您运行Flutter Web应用程序时,您“ 简单地 ”启动了一个基本的Web服务器,该服务器侦听某个“ IP_address:port ”并提供位于“ web ”文件夹中的文件。几乎没有配置/自定义可以添加到该Web服务器的实例。

不同的 网页 文件夹

如果以调试模式运行Flutter Web App,则Web文件夹为“ / web”

如果以发布模式运行,则Web文件夹为“ / build / web”

当您运行Flutter Web应用程序时,一旦激活了基本的Web服务器,就会从相应的“ web ”文件夹中自动调用“ index.html ”页面。

index.html ”页面会自动加载一些资产以及与整个应用程序相对应的“ main.dart.js ”文件。实际上,这对应于Dart代码和其他一些库的Javascript转换。

换句话说...

当您访问“ index.html ”时,您正在 加载整个应用程序

这意味着Flutter Web应用程序是一个单页应用程序,并且在大多数情况下,除了在加载并启动该单页应用程序后检索任何其他资产(字体,图像等)之外,您之间不再会有任何交互Flutter Web应用程序(在浏览器上运行)和Web服务器。

URL中的怪异“#”字符

当您运行Flutter Web应用程序并从一个页面(=路由)导航到另一页面时,我想您已经注意到浏览器URL导航栏级别的更改了……

例如,假设您的应用程序由2个页面组成:“主页”和“登录页面”。主页将在应用程序启动时自动显示,并具有一个导航到LoginPage的按钮。

浏览器的URL栏将包含:

  • http://192.168.1.40:8080/#/ 当您启动应用程序时=>这对应于主页
  • 显示LoginPage时为http://192.168.1.40:8080/#/LoginPage。

主题标签指定URL片段,该片段通常在单页应用程序中用于导航,以替代URL路径。

URL片段最有趣的是

片段不会在HTTP请求消息中发送,因为片段仅由浏览器使用。

在我们的例子中,在Flutter Web中,浏览器使用它们来处理历史记录

(有关片段的更多信息,请点击此链接)

如何在网址中隐藏“#”字符?

我很多次在互联网上看到这个问题,答案很简单。

由于'#'字符通常对应于应用程序中的页面(= Route),因此您需要告诉浏览器更新URL,同时继续在浏览器历史记录中考虑该页面(以便浏览器的后退和前进按钮可以使用正确)。

为此,您需要使页面成为“ StatefulWidget ”,以便利用页面的初始化时间(= initState方法)。

实现此目的的代码如下:

import 'dart:html' as html;
import 'package:flutter/material.dart';

class MyPage extends StatefulWidget {
    @override
    _MyPageState createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
    @override
    void initState(){
        super.initState();

        // this is the trick
        html.window.history.pushState(null, "MyPage", "/mypage");
    }
}

从那时起,当用户将被重定向到“ MyPage”时,而不是在URL中显示“ http://192.168.1.40:8080/#/MyPage”,浏览器将显示“ http://192.168.1.40 :8080 / mypage“,它更加人性化。

但是,如果您将该页面加为书签并尝试重新调用它,或者直接在浏览器中键入该URL,则将遇到以下错误页面“ 无法找到此http://192.168.1.40页面 ”,该页面对应到著名的HTTP错误404。

那么如何解决呢?

每次您通过手动输入的URL访问Flutter Web应用程序时,都会运行main()

在解释该解决方案之前,还必须注意,当您通过Web浏览器输入“ 有效 ” URL时,将对Flutter Web服务器进行访问以重新加载应用程序,并在加载后运行main()方法。 。

换句话说,如果您在Web浏览器URL栏级别手动输入“ http://192.168.1.40:8080”或“ http://192.168.1.40:8080/#/page”,则请求将发送到重新加载应用程序并最终运行“ main() ”方法的Web服务器。

当通过应用程序本身从一个页面(=路由)切换到应用程序的另一页面时,情况并非如此,因为代码仅在Web浏览器级别运行!

我的解决方案

第一次尝试...不是解决方案...

下一篇文章过去已经讨论过该问题,并给出了解决方案的一些提示,但是该文章中公开的“ 迄今为止最好的解决方案 ”今天不再起作用(或者我无法使其起作用)。

因此,直接想到的第一个解决方案是基于同一篇文章中所述的“ 第二个解决方案 ” ,其中:

  • 我们在initState()方法中调用pushState时会提到“ .html”扩展名,如下所示:html.window.history.pushState(null,“ MyPage”,“ / mypage .html ”);
  • 我们在每个屏幕上创建一个* .html页面…

但是,这当然很乏味且容易出错,因此我继续进行调查。

解决方案

然后我想:“ 如果我可以拦截URL请求并以正确的格式重定向它,该怎么办?”。

换句话说,类似……(但这不起作用)不幸的是,正如我之前所说,不可能在HTTP请求中中继片段(带有#字符)的概念。

因此,我需要找到其他东西。

如果我可以使应用程序“ 认为 ” URL不一样怎么办?

然后,我找到了Shelf Dart软件包,这是Dart的Web服务器中间件,它允许定义请求处理程序

解决方案非常简单:

  • 我们运行机架式 Web服务器的实例,侦听所有传入的请求
  • 我们在本地主机上运行Flutter Web
  • 我们检查请求是否指向页面
  • 对于所有这些请求,我们将它们重定向到标称index.html保持Request URL不变,以便可以由main()方法拦截,然后显示请求的页面…

当然,与资产相关的请求(图片,JavaScript等)不应属于重定向的一部分……

架子再次提供了一个称为shelf_proxy的代理处理程序,该代理处理程序对外部服务器的请求。正是我需要的!

但是,此代理处理程序不提供任何功能来插入重新路由逻辑……太糟糕了。

因此,由于其源代码已获得BSD许可,因此我克隆了该代理处理程序的源代码,以插入自己的重新路由逻辑,该逻辑简单地包含在内(但当然可以扩展到需求):

  • 如果URL不包含对扩展名的任何引用(例如“ .js”,“。json”,“。png”…),并且在路径中仅包含1个块(例如“ http://192.168.1.40:8080 / mypage”,而不是 “ http://192.168.1.40:8080 / assets / package / ...”),然后我将请求重定向到Flutter Web服务器实例的页面“ index.html ”,
  • 否则,我只需将请求重定向到Flutter Web服务器实例,而无需提及“ index.html”页面。

这意味着要运行2台Web服务器!”,你能告诉我吗

是的,它确实。

代理 Web服务器(在这里,利用现有的),听着真正的 IP地址和端口该颤振Web应用程序,听本地主机

实作

1.创建Flutter Web应用程序

照常创建Flutter Web应用程序。

2.修改您的“ main.dart”文件(在/ lib中)

这个想法是直接捕获浏览器URL中提供的路径

import 'dart:html' as html;
import 'package:flutter/material.dart';

void main(){
    //
    // Retrieve the path that was sent
    //
    final String pathName = html.window.location.pathname;

    //
    // Tell the Application to take it into consideration
    //
    runApp(
        Application(pathName: html),
    );
}

class Application extends StatelessWidget {
    const Application({
        this.pathName,
    });

    final String pathName;

    @override
    Widget build(BuildContext context){
        return MaterialApp(
            onUnknownRoute: (_) => UnknownPage.route(),
            onGenerateRoute: Routes.onGenerateRoute,
            initialRoute: pathName,
        );
    }
}

class Routes {
    static Route<dynamic> onGenerateRoute(RouteSettings settings){
        switch (settings.name.toLowerCase()){
            case "/": return HomePage.route();
            case "/page1": return Page1.route();
            case "/page2": return Page2.route();
            default:
                return UnknownPage.route();
        }
    }
}

class HomePage extends StatefulWidget {
    @override
    _HomePageState createState() => _HomePageState();

    //
    // Static Routing
    //
    static Route<dynamic> route() 
        => MaterialPageRoute(
                builder: (BuildContext context) => HomePage(),
            );

}

class _HomePageState extends State<HomePage>{
    @override
    void initState(){
        super.initState();

        //
        // Push this page in the Browser history
        //
        html.window.history.pushState(null, "Home", "/");
    }

    @override
    Widget build(BuildContext context){
        return Scaffold(
            appBar: AppBar(title: Text('Home Page')),
            body: Column(
                children: <Widget>[
                    RaisedButton(
                        child: Text('page1'),
                        onPressed: () => Navigator.of(context).pushNamed('/page1'),
                    ),
                    RaisedButton(
                        child: Text('page2'),
                        onPressed: () => Navigator.of(context).pushNamed('/page2'),
                    ),
                    //
                    // Intentionally redirect to an Unknown page
                    //
                    RaisedButton(
                        child: Text('page3'),
                        onPressed: () => Navigator.of(context).pushNamed('/page3'),
                    ),
                ],
            ),
        );
    }
}

// Similar code as HomePage, for Page1, Page2 and UnknownPage

说明:

  • main()方法级别,我们捕获提交的路径(第8行)并将其提供给Application
  • 应用认为路径作为“ 初始一个 ” =>“ initialRoute: 路径”(行#30)
  • 所述Routes.onGenerateRoute(...)则方法被调用并返回的路线,其对应于所提供的路径
  • 如果路由不存在,它将重定向到UnknownPage()

3.创建代理服务器

1 – 在项目的根目录中创建一个bin文件夹2 – 在/ bin文件夹中创建一个名为“ proxy_server.dart ”的文件 3 –将以下代码放入该“ proxy_server.dart ”文件中:

import 'dart:async';
import 'package:self/self_io.dart' as shelf_io;
import './proxy_handler.dart';

void main() async {
    var server;

    try {
        server = await shelf_io.serve(
            proxyHandler("http://localhost:8081"), // redirection to
            "localhost",    // listening to hostname
            8080,           // listening to port
        );
    } catch(e){
        print('Proxy error: $e');
    }
}

说明:

主()方法简单地初始化的一个实例货架 web服务器,其

  • 在端口8080上侦听“ localhost”
  • 将所有传入的HTTP请求发送到proxyHandler()方法,该方法被指示重定向到“ localhost:8081”

4 –将以下文件“ proxy_handler.dart ”从该要点复制到您的/ bin文件夹中。

import 'dart:async';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:path/path.dart' as p;
import 'package:pedantic/pedantic.dart';

// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file [https://github.com/dart-lang/shelf_proxy].

/// A handler that proxies requests to [url].
///
/// To generate the proxy request, this concatenates [url] and [Request.url].
/// This means that if the handler mounted under `/documentation` and [url] is
/// `http://example.com/docs`, a request to `/documentation/tutorials`
/// will be proxied to `http://example.com/docs/tutorials`.
///
/// [url] must be a [String] or [Uri].
///
/// [client] is used internally to make HTTP requests. It defaults to a
/// `dart:io`-based client.
///
/// [proxyName] is used in headers to identify this proxy. It should be a valid
/// HTTP token or a hostname. It defaults to `shelf_proxy`.
Handler proxyHandler(url, {http.Client client, String proxyName}) {
  Uri uri;
  if (url is String) {
    uri = Uri.parse(url);
  } else if (url is Uri) {
    uri = url;
  } else {
    throw ArgumentError.value(url, 'url', 'url must be a String or Uri.');
  }
  client ??= http.Client();
  proxyName ??= 'shelf_proxy';

  return (serverRequest) async {
    var requestUrl = uri.resolve(serverRequest.url.toString());

    //
    // Insertion of the business logic
    //
    if (_needsRedirection(requestUrl.path)){
      requestUrl = Uri.parse(url + "/index.html");
    }

    var clientRequest = http.StreamedRequest(serverRequest.method, requestUrl);
    clientRequest.followRedirects = false;
    clientRequest.headers.addAll(serverRequest.headers);
    clientRequest.headers['Host'] = uri.authority;

    // Add a Via header. See
    // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.45
    _addHeader(clientRequest.headers, 'via',
        '${serverRequest.protocolVersion} $proxyName');

    unawaited(store(serverRequest.read(), clientRequest.sink));
    var clientResponse = await client.send(clientRequest);
    // Add a Via header. See
    // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.45
    _addHeader(clientResponse.headers, 'via', '1.1 $proxyName');

    // Remove the transfer-encoding since the body has already been decoded by
    // [client].
    clientResponse.headers.remove('transfer-encoding');

    // If the original response was gzipped, it will be decoded by [client]
    // and we'll have no way of knowing its actual content-length.
    if (clientResponse.headers['content-encoding'] == 'gzip') {
      clientResponse.headers.remove('content-encoding');
      clientResponse.headers.remove('content-length');

      // Add a Warning header. See
      // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.2
      _addHeader(
          clientResponse.headers, 'warning', '214 $proxyName "GZIP decoded"');
    }

    // Make sure the Location header is pointing to the proxy server rather
    // than the destination server, if possible.
    if (clientResponse.isRedirect &&
        clientResponse.headers.containsKey('location')) {
      var location =
          requestUrl.resolve(clientResponse.headers['location']).toString();
      if (p.url.isWithin(uri.toString(), location)) {
        clientResponse.headers['location'] =
            '/' + p.url.relative(location, from: uri.toString());
      } else {
        clientResponse.headers['location'] = location;
      }
    }

    return Response(clientResponse.statusCode,
        body: clientResponse.stream, headers: clientResponse.headers);
  };

}

/// Use [proxyHandler] instead.
@deprecated
Handler createProxyHandler(Uri rootUri) => proxyHandler(rootUri);

/// Add a header with [name] and [value] to [headers], handling existing headers
/// gracefully.
void _addHeader(Map<String, String> headers, String name, String value) {
  if (headers.containsKey(name)) {
    headers[name] += ', $value';
  } else {
    headers[name] = value;
  }
}

/// Pipes all data and errors from [stream] into [sink].
///
/// When [stream] is done, the returned [Future] is completed and [sink] is
/// closed if [closeSink] is true.
///
/// When an error occurs on [stream], that error is passed to [sink]. If
/// [cancelOnError] is true, [Future] will be completed successfully and no
/// more data or errors will be piped from [stream] to [sink]. If
/// [cancelOnError] and [closeSink] are both true, [sink] will then be
/// closed.
Future store(Stream stream, EventSink sink,
    {bool cancelOnError = true, bool closeSink = true}) {
  var completer = Completer();
  stream.listen(sink.add, onError: (e, StackTrace stackTrace) {
    sink.addError(e, stackTrace);
    if (cancelOnError) {
      completer.complete();
      if (closeSink) sink.close();
    }
  }, onDone: () {
    if (closeSink) sink.close();
    completer.complete();
  }, cancelOnError: cancelOnError);
  return completer.future;
}

///
/// Checks if the path requires to a redirection
///
bool _needsRedirection(String path){
  if (!path.startsWith("/")){
    return false;
  }

  final List<String> pathParts = path.substring(1).split('/');

  ///
  /// We only consider a path which is only made up of 1 part
  ///
  if (pathParts.isNotEmpty && pathParts.length == 1){
    final bool hasExtension = pathParts[0].split('.').length > 1;
    if (!hasExtension){
      return true;
    }
  }
  return false;
}

总结

当我需要在生产中发布Flutter Web应用程序时,我必须找到一个能够处理以下问题的解决方案:

  • URL异常(例如“ 未找到页面-错误404 ”);
  • 友好的网址(不包含#个字符)

在地方,我把该解决方案(本文的主题),工程但这只能被看作是一个解决办法

我想应该还有其他一些解决方案,更多的是“ 官方的 ”,但迄今为止我还没有发现其他解决方案。

我真的希望Flutter团队能够尽快解决此问题,以便在已有解决方案的情况下提供“ 干净的 ”解决方案或记录该解决方案。