[Flutter翻译]开始使用Flutter Web之前应该知道的7件事

1,452 阅读15分钟

image.png

照片:Emile Perron on Unsplash

原文地址:medium.com/flutter-com…

原文作者:medium.com/@c.muehle18

发布时间:2021年4月9日-12分钟阅读

入手Flutter Web,抢占先机! 今天我想告诉大家,在开始使用Flutter之前,我希望自己能知道的几点。URL路由、引导、平台相关编译、运行时检查、响应式UI和存储。

在我们开始之前

本文中使用的所有例子都是直接从我在GitHub上的Caladrius仓库中取出的(链接如下)。Caladrius应该是Fauxton的替代品--如果这些都没有印象,别担心。 我是Apache CouchDB的忠实粉丝,而Fauxton是CouchDB附带的一个基于Web的管理工具。

github.com/Dev-Owl/Cal…

github.com/apache/couc…

就像Caladrius的 "读我 "指出的那样,我们的目标是使用Flutter Web构建一个更复杂的例子。当然,它还是应该在手机上运行。

CORS

image.png

照片:Kyle Glenn on Unsplash

第一点其实和Flutter Web没有直接关系,但是在开发过程中可能会遇到这个问题。简单解释一下,CORS是Cross-Origin Resource Sharing的缩写,描述了浏览器用来限制和定义不同域之间资源共享能力的不同技术(CSS、JS、Image、Cookies/Authentication等)。CORS策略是HTTP头部分的一部分(像所有其他头一样,它们是键值对)。

developer.mozilla.org/en-US/docs/…

现在你可能会问自己: "为什么这对我的Flutter网页很重要?" 简单的说,一旦你想共享资源或从不同的域获取资源,你习惯的代码(比如从网站上获取图片)可能就不能用了。

如果你在手机上执行HTTP(S)请求,Flutter代码根本不会在意CORS头。如果你在Flutter web上运行同样的代码,它会抛出一个异常(如果相关的CORS头存在)。

为了确保网站遵循域的CORS配置,你的浏览器会执行检查。你不能改变这一点(这是一件好事),在你能控制其他服务器的情况下,你需要做以下事情。

  • 设置正确的CORS头,以启用你的使用场景。
  • 根据不同的使用情况,您可能需要将域名添加到白名单中。

在你无法控制外部服务器的情况下,你仍然可以选择设置一个CORS代理服务器。因此,请看一下Flutter的指南--它描述了一些重要的内幕。

flutter.dev/docs/develo…

请注意:在Caladrius的开发过程中,我不得不启用我的CouchDB来允许跨起源认证cookies。这需要在 Origin Domains 中设置具体的域,像 * 这样的通配符是不行的!

关于认证题目,还有一个注意点,就是Cookie可以以 "Http Only "的形式传输。如果这是服务器设置的,你的Flutter代码就无法访问cookie信息。请记住你的Flutter代码是页面的JavaScript。

另一个非常重要的学习,关于CORS和Flutter的认证(这次是Flutter相关的),是你必须将BrowserClient(你的HTTP客户端在网络上)的 "withCredentials "属性设置为true。如果你想在Web和App中运行代码,你需要在构建之前将其分开(请参见下面的平台代码,提前部分)。

developer.mozilla.org/en-US/docs/…

路由和深度链接

image.png

图片:JJ Ying on Unsplash

网站的好处是,你可以毫不费力地和别人分享一个链接。如果你指向应用程序里面的东西(直接打开一篇博客文章),这就叫做深度链接。

幸运的是,Flutter web可以让你通过使用 "命名路由 "为用户提供这种服务,这对Flutter web来说并不新鲜。在我们谈论路由和生成链接之前,我想提一下Flutter web的URL策略。

flutter.dev/docs/develo…

上面的链接告诉你如何设置这两种模式。

  • #网址,一切以example.com/#page/id/edit开头。
  • 无#, example.com/page/id/edit。

重要:上面的链接还包含了一个关键点(在底部),如果你计划不在服务器的根目录下托管你的应用程序,你需要在你的项目中生成的index.html文件中配置这个。你需要在你的项目中生成的index.html文件中进行配置。

创建你的路由器

现在你可以选择两种方式:在MaterialApp小组件内部的相关地图中定义命名的路由,或者建立一个路由器类。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Caladrius',
      initialRoute: 'dashboard',
      onGenerateRoute: AppRouter.generateRoute,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
    );
   }
  
  Widget buildNamedRoute(BuildContext context){
     return MaterialApp(
         initialRoute: '/',
         routes: {
            '/': (context) => FirstScreen(),
            '/second': (context) => SecondScreen(),
          },
      );
   }
}

上面的例子展示了如何注册这两种方式--你想做什么是基于你的用例。有一件重要的事情要知道:如果你打算使用命名路由,请注意"/second "作为一个命名路由会在Flutter web请求时做以下事情。

  1. 命名路线被检查,Flutter会把第一个页面推送给注册了/的用户。
  2. 现在,翩翩立即将页面推送到屏幕上进行秒赞
  3. 一旦在用户端,导航栈将有两个元素/和第二个

请记住,用户可以在任何时候点击重载,或者用任何链接打开你的页面--这对你在手机上的Flutter应用来说是一个很大的区别。

这种行为可能并不可取,例如缺乏确保会话存在的选项,以防 "second "是你的应用程序中的保护区。上述缺点可以通过使用onGenerateRoute属性和配置你的路由器来抵消。路由器的工作是理解当前的请求(检查URL),并将正确的页面推送给用户。

import 'package:caladrius/component/bootstrap/bootstrap.dart';
import 'package:caladrius/screens/corsHelp.dart';
import 'package:caladrius/screens/dashboard.dart';
import 'package:caladrius/component/bootstrap/CaladriusBootstrap.dart';
import 'package:caladrius/screens/database.dart';
import 'package:flutter/material.dart';

class AppRouter {
  //Create a root that ensures a login/session
  static PageRoute bootstrapRoute(BootCompleted call, RoutingData data) =>
      _FadeRoute(
        CaladriusBootstrap(call),
        data.fullRoute,
        data,
      );
  //Create a simple route no login before
  static PageRoute pageRoute(
    Widget child,
    RoutingData data,
  ) =>
      _FadeRoute(
        child,
        data.fullRoute,
        data,
      );

  static Route<dynamic> generateRoute(RouteSettings settings) {
    late RoutingData data;
    if (settings.name == null) {
      data = RoutingData.home(); //Default route to dashboard
    } else {
      data = (settings.name ?? '').getRoutingData; //route to url
    }
    //Only the first segment defines the route
    switch (data.route.first) {
      case 'cors':
        {
          return pageRoute(CorsHelp(), data);
        }
      case 'database':
        {
          //If the database part is missing -> Dashboard
          if (data.route.length == 1) {
            return _default(data);
          } else {
            return bootstrapRoute(() => DatabaseView(), data);
          }
        }
      default:
        {
          //Fallback to the dashboard/login
          return _default(data);
        }
    }
  }

  static PageRoute _default(RoutingData data) {
    return bootstrapRoute(() => Dashboard(), data);
  }
}

class RoutingData {
  @override
  int get hashCode => route.hashCode;

  final List<String> route;
  final Map<String, String> _queryParameters;

  String get fullRoute => Uri(
          pathSegments: route,
          queryParameters: _queryParameters.isEmpty ? null : _queryParameters)
      .toString();

  RoutingData(
    this.route,
    Map<String, String> queryParameters,
  ) : _queryParameters = queryParameters;

  //Our fallback to the dashboard
  RoutingData.home([this.route = const ['dashboard']]) : _queryParameters = {};

  String? operator [](String key) => _queryParameters[key];
}

extension StringExtension on String {
  RoutingData get getRoutingData {
    final uri = Uri.parse(this);

    return RoutingData(
      uri.pathSegments,
      uri.queryParameters,
    );
  }
}

class _FadeRoute extends PageRouteBuilder {
  final Widget child;
  final String routeName;
  final RoutingData data;
  _FadeRoute(
    this.child,
    this.routeName,
    this.data,
  ) : super(
          settings: RouteSettings(
            name: routeName,
            arguments: data,
          ),
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) =>
              child,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) =>
              FadeTransition(
            opacity: animation,
            child: child,
          ),
        );
}

不要被上面的代码所淹没,让我为你细细分解。

  • AppRouter - 主类,生成路由数据(来自URL/命名路由的信息),并告诉应用程序打开一个页面。
  • RoutingData - 一个存储当前路由数据的数据类,它包含URL(如果你使用#模式,#后面的所有内容)和查询参数。
  • 字符串扩展方法--简单的从字符串中生成一个URL对象的快捷方式,URL类提供了方便的数据提取功能。
  • FadeRoute - 一个告诉Flutter如何在两条路线之间进行预演过渡的类,在这种情况下,使用不透明度在两个页面之间进行淡化。

AppRouter还包含了引导一个深度链接的代码。在我的用例中,这确保了用户通过CouchDB进行身份验证(请记住我的App应该是一个管理界面)--在下一节会有更多的介绍。

如果我们看一下AppRouter类generateRoute的主要功能,它的流程是这样的。

  1. 将当前的路由数据提取到我们的RoutingData对象中,如果没有(空URL),则使用默认/主页路由的回退。
  2. 多亏了RoutingData的简单性,它现在使用URL的第一部分运行一个switch-case语句来决定我们要显示的内容。

我的AppRouter使用了类似.NET MVC的模式,我给大家看一个示例网址。

example.com/#database/userdb-123/document/helloworld?mode=edit。

URL被分割成单片,在RoutingData类里面,第一部分用来定义Page(或Controller)。在上面的例子中,它应该是 "数据库"。这后面的所有内容都可以在数据库界面里面用来触发进一步的操作(本例中打开数据库 "userdb-123",在编辑模式下查看文档helloworld)。当然,你可以通过调整generateRoute函数轻松改变这种URL处理方式。

这里还有几个重要的内幕。

  • RoutingData对象有一个再生URL的getter(称为fullRoute),这个传递给Flutter是为了在导航后显示URL(否则它就会消失)
  • 路由数据(RoutingData)被传递给路由,当前显示的widget可以读取所有信息并采取相应的行动。
  • 读取RoutingData很简单,在你的构建方法中只需调用。

final routingData = ModalRoute.of(context)!.settings.arguments as RoutingData;

  • 上面的所有内容在手机上的工作方式和在Web上的工作方式100%一样,不需要修改代码,一个代码库就能统治所有的人。

引导和应用程序生命周期

image.png 照片:Gia Oris on Unsplash

网站的工作原理和手机上的App有些不同,但使用Flutter Web,你仍然可以为两者运行相同的代码。让我直接指出一点:如果你会把任何现有的App,让它作为网页运行,很可能不会成功。Flutter是一个工具,不是魔法! 应用的设计必须要支持这种多平台的场景。

生命周期管理是一个很大的区别--在你的App中,你的用户不能在任何时候在任何屏幕上点击神奇的重载按钮,但是在网页上,这很容易实现。如果一个网页重新加载,之前的上下文和运行时信息就会消失。当然,有一些方法可以保存信息(Cookie、indexdb等),但状态已经消失了。

如果你没有从一开始就把这一点内置到应用程序中,这可能是一个挑战。现在你已经意识到了,你可以构建一些东西来让用户体验流畅。让我来介绍一下我的Bootstrapper Widget。

import 'package:caladrius/main.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

typedef BootCompleted = Widget Function();

class BootStrap extends StatefulWidget {
  final List<BootstrapStep> steps;
  final int currentIndex;
  final BootCompleted bootCompleted;

  const BootStrap(
    this.steps,
    this.bootCompleted, {
    Key? key,
    this.currentIndex = 0,
  }) : super(key: key);

  @override
  _BootStrapState createState() => _BootStrapState();
}

class _BootStrapState extends State<BootStrap> implements BootstrapController {
  late int currentIndex;
  bool bootRunning = true;

  @override
  void initState() {
    super.initState();
    currentIndex = widget.currentIndex;
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<Widget>(
        stream: work(),
        builder: (c, snap) {
          if (snap.hasData) {
            return snap.data!;
          }
          return Scaffold(
            body: Center(
              child: CircularProgressIndicator(),
            ),
          );
        });
  }

  Stream<Widget> work() async* {
    while (bootRunning &&
        !(await widget.steps[currentIndex].stepRequired(preferences))) {
      if (currentIndex + 1 < widget.steps.length) {
        currentIndex++;
      } else {
        bootRunning = false;
      }
    }
    if (bootRunning) {
      yield widget.steps[currentIndex].buildStep(this);
    } else {
      yield widget.bootCompleted();
    }
  }

  @override
  void procced() {
    if (currentIndex + 1 < widget.steps.length) {
      setState(() {
        currentIndex++;
      });
    } else {
      setState(() {
        bootRunning = false;
      });
    }
  }

  @override
  void stepback() {
    if (currentIndex > 0) {
      setState(() {
        currentIndex--;
      });
    } else {
      setState(() {
        bootRunning = false;
      });
    }
  }
}

abstract class BootstrapStep {
  const BootstrapStep();
  Future<bool> stepRequired(SharedPreferences prefs);
  Widget buildStep(BootstrapController controller);
}

abstract class BootstrapController {
  void procced();
  void stepback();
}

让我们从上面的列表往上看,它是你开始引导你的应用程序所需要的全部东西(当然,同样任何平台都可以)。

Bootstrap Widget

就像你在Flutter中工作的大部分部件一样,bootstrap组件是一个Widget。"引导 "进度由BootstrapStep对象建立,步骤列表必须传递给Widget。除了这些步骤,你还需要提供一个BootCompleted回调。顾名思义,一旦你所有的步骤完成,这个回调就会被触发,所以你需要提供另一个Widget。

每一步都由一个检查组成--如果这一步是必需的(请注意,我使用的是伟大的SharedPreferences包,我把它交给了stepRequired函数,但它不是必需的依赖,可以删除)和一个返回这一步Widget的函数。

Bootstrap Widget的内部工作由StreamBuilder来完成。万一其中一个stepRequired函数需要一点时间,它只是显示一个加载的spinner。

一旦所有的步骤都完成了(或者不再需要了),BootCompleted回调就会被执行,相关的widget就会显示给用户。 除了上面的方法,你也可以通过调用相关的函数来手动改变步骤,这些函数是通过传递给步骤Widget的BootstrapController来实现的。默认情况下,该过程将从索引0开始(从你的步骤列表),但如果你想的话,也可以更改。

运行中的代码

class CaladriusBootstrap extends StatelessWidget {
  final BootCompleted bootCompleted;

  const CaladriusBootstrap(this.bootCompleted, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BootStrap(
      [
        LoginBootStep(),
      ],
      bootCompleted,
    );
  }
}

以上就是我使用Widget所需要的东西--你在上面看到的LoginBootStep检查了一些与我的应用程序相关的东西。如果我以后需要一些东西,我可以直接添加一个新的步骤,所有的代码都会流过它,而不需要我改变其他东西。

平台代码,提前

一个代码库是很好的,它允许你快速扩展或修复你的应用程序。更少的代码也意味着更少的维护,如果你的应用程序增长,维护的时间也会跟着增加(有人说过,技术债务...)。

虽然如此,但有时还是需要有一个代码部分,只在选定的平台上进行编译。为什么这么说呢?因为如果你试图在 "错误 "的平台上编译,他们会失败。

这听起来很复杂?

实际用Flutter是很直接的。我将向你展示基于Caladrius中使用的一个例子的一切。

import 'package:http/http.dart' as http;

http.Client getClient() {
  throw UnimplementedError('Unsupported');
}

gist.github.com/Dev-Owl/f1d…

import 'package:http/http.dart' as http;

http.Client getClient() {
  return http.Client();
}

gist.github.com/Dev-Owl/f1d…

import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;

http.Client getClient() {
  final client = http.Client();
  (client as BrowserClient).withCredentials = true;
  return client;
}

gist.github.com/Dev-Owl/f1d…

import 'pillowHttp/pillowHttp_stub.dart'
    if (dart.library.io) 'pillowHttp/pillowHttp_app.dart'
    if (dart.library.html) 'pillowHttp/pillowHttp_web.dart';

//Further code ...

gist.github.com/Dev-Owl/f1d…

第一部分向你展示的是stub,空的实现,只是定义了你想编译平台特定的类或函数的结构。

第二和第三部分显示了相关的平台实现。在我的例子中,我想确保在浏览器中启用CORS的认证(是的,你需要在运行请求之前设置这个,默认情况下是OFF)。

最后一节告诉你如何包含这个文件来使用它,你可以用if条件来做导入。对于代码,使用函数getClient,在函数中填写什么平台并不重要。万一你的目标没有被if覆盖,你会在stub部分(第一节)里面遇到UnimplementedError。

这就是你需要做的,根据目标平台编译进代码。每隔一段时间,你还想在运行时做同样的检查,这就是下一节要讲的内容。

平台切换,在运行时

在某些情况下,你想让你的代码有不同的行为(例如渲染不同的widget或当涉及到永久存储时)。我发现的最简单的方法是检查你是否在网络上运行,是通过包含以下一行。

import 'package:flutter/foundation.dart' show kIsWeb;

现在你可以简单的检查一下kIsWeb是否为真,你就知道你目前是在网络上运行。就这样,简单的一句话 :)

响应式UI

image.png

照片:Harpal Singh on Unsplash

与Flutter web没有直接关系,但仍然很重要! 应用程序的UI应该调整布局和行为,以适应用户的屏幕和平台以及平台的预期。

通常移动应用遵循同样的流程;你有一个列表或菜单,用户在其中选择一个元素。基于这个选择,会显示另一个屏幕,举个例子。

  1. 你打开你的邮件应用,你会看到你的收件箱。
  2. 在邮件列表中,你选择一个邮件来打开它。
  3. 画面切换到邮件的详细信息,您的列表在导航栈中。

在更大的屏幕上(就像我说的,不与网页相关的直接也可以是平板电脑),你可以在旁边呈现一个主部件(邮件列表)和一个细节部分(邮件内容)。现在,一旦用户在主部件中选择了什么,细节部分就会得到更新,而不是切换屏幕。

哪些Widget能派上用场?

首先,你可以简单地运行一个MediaQuery并获得屏幕尺寸。如果你想停止每次都写同样的if块(再次提醒技术债),你可以将代码抽象成一个扩展(或静态方法)。

import 'package:flutter/widgets.dart';

extension ViewMode on Widget {
  bool renderMobileMode(BuildContext context) {
    return MediaQuery.of(context).size.width < 600;
  }
}

如果屏幕低于600dp,我的widgets将遵循移动路径,并向你展示不同的布局。

使用这种方法也迫使你分开你的widget;否则你将需要写很多重复的代码。你也可以使用LayoutBuilder来处理你的父Widget的大小,并随时在widget树中工作。

api.flutter.dev/flutter/wid…

在pub.dev上也有一些预制包,让你去做更复杂的事情,就像我上面的小例子一样(还没有试过,因为我现在对我的简单检查很满意)。

pub.dev/packages/re…

永久储存

image.png

照片:Steve Johnson on Unsplash

一个网页的存储空间是有限的,你不能存储多少数据。你没有文件系统,不能随心所欲的写和读。不过,你还是可以存储数据,你甚至可以用100%相同的代码在两个平台上进行存储。

用户设置与偏好

就用下面的包吧,很死简单,根据你的平台,把数据存储在SQLite数据库或者本地存储(键值存储)。

更多的数据?

如果你需要更多的数据或者想要运行查询,我会推荐你去看看Moor。

pub.dev/packages/mo…

网络相关的文档可以在这里找到。

moor.simonbinder.eu/web/

结论

Flutter Web是让你用同样的代码做更多事情的重要一步。不过,它还是需要在前期进行一些扎实的思考。在开发过程中,你总是要问自己,在特定的目标平台上可能会发生什么,你是否需要构建一些东西来考虑这个问题。

从我的角度来看,同时在多个平台上使用Flutter工作,一开始是很粗糙的,但有了这里提供的学习,我就有信心继续进行我的小项目了。谁知道呢,也许我还要做第二轮的 "Flutter入门前要知道的事情"。


www.deepl.com 翻译