[Flutter翻譯]開始使用Flutter Web之前應該知道的7件事

Sunbreak發表於2021-04-09

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 翻譯

相關文章