照片:Emile Perron on Unsplash
釋出時間:2021年4月9日-12分鐘閱讀
入手Flutter Web,搶佔先機! 今天我想告訴大家,在開始使用Flutter之前,我希望自己能知道的幾點。URL路由、引導、平臺相關編譯、執行時檢查、響應式UI和儲存。
在我們開始之前
本文中使用的所有例子都是直接從我在GitHub上的Caladrius倉庫中取出的(連結如下)。Caladrius應該是Fauxton的替代品--如果這些都沒有印象,別擔心。 我是Apache CouchDB的忠實粉絲,而Fauxton是CouchDB附帶的一個基於Web的管理工具。
就像Caladrius的 "讀我 "指出的那樣,我們的目標是使用Flutter Web構建一個更復雜的例子。當然,它還是應該在手機上執行。
CORS
照片: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的指南--它描述了一些重要的內幕。
請注意:在Caladrius的開發過程中,我不得不啟用我的CouchDB來允許跨起源認證cookies。這需要在 Origin Domains 中設定具體的域,像 * 這樣的萬用字元是不行的!
關於認證題目,還有一個注意點,就是Cookie可以以 "Http Only "的形式傳輸。如果這是伺服器設定的,你的Flutter程式碼就無法訪問cookie資訊。請記住你的Flutter程式碼是頁面的JavaScript。
另一個非常重要的學習,關於CORS和Flutter的認證(這次是Flutter相關的),是你必須將BrowserClient(你的HTTP客戶端在網路上)的 "withCredentials "屬性設定為true。如果你想在Web和App中執行程式碼,你需要在構建之前將其分開(請參見下面的平臺程式碼,提前部分)。
路由和深度連結
圖片:JJ Ying on Unsplash
網站的好處是,你可以毫不費力地和別人分享一個連結。如果你指向應用程式裡面的東西(直接開啟一篇部落格文章),這就叫做深度連結。
幸運的是,Flutter web可以讓你通過使用 "命名路由 "為使用者提供這種服務,這對Flutter web來說並不新鮮。在我們談論路由和生成連結之前,我想提一下Flutter web的URL策略。
上面的連結告訴你如何設定這兩種模式。
- #網址,一切以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請求時做以下事情。
- 命名路線被檢查,Flutter會把第一個頁面推送給註冊了/的使用者。
- 現在,翩翩立即將頁面推送到螢幕上進行秒贊
- 一旦在使用者端,導航棧將有兩個元素/和第二個
請記住,使用者可以在任何時候點選過載,或者用任何連結開啟你的頁面--這對你在手機上的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的主要功能,它的流程是這樣的。
- 將當前的路由資料提取到我們的RoutingData物件中,如果沒有(空URL),則使用預設/主頁路由的回退。
- 多虧了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%一樣,不需要修改程式碼,一個程式碼庫就能統治所有的人。
引導和應用程式生命週期
照片: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');
}
複製程式碼
import 'package:http/http.dart' as http;
http.Client getClient() {
return http.Client();
}
複製程式碼
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;
}
複製程式碼
import 'pillowHttp/pillowHttp_stub.dart'
if (dart.library.io) 'pillowHttp/pillowHttp_app.dart'
if (dart.library.html) 'pillowHttp/pillowHttp_web.dart';
//Further code ...
複製程式碼
第一部分向你展示的是stub,空的實現,只是定義了你想編譯平臺特定的類或函式的結構。
第二和第三部分顯示了相關的平臺實現。在我的例子中,我想確保在瀏覽器中啟用CORS的認證(是的,你需要在執行請求之前設定這個,預設情況下是OFF)。
最後一節告訴你如何包含這個檔案來使用它,你可以用if條件來做匯入。對於程式碼,使用函式getClient,在函式中填寫什麼平臺並不重要。萬一你的目標沒有被if覆蓋,你會在stub部分(第一節)裡面遇到UnimplementedError。
這就是你需要做的,根據目標平臺編譯進程式碼。每隔一段時間,你還想在執行時做同樣的檢查,這就是下一節要講的內容。
平臺切換,在執行時
在某些情況下,你想讓你的程式碼有不同的行為(例如渲染不同的widget或當涉及到永久儲存時)。我發現的最簡單的方法是檢查你是否在網路上執行,是通過包含以下一行。
import 'package:flutter/foundation.dart' show kIsWeb;
現在你可以簡單的檢查一下kIsWeb是否為真,你就知道你目前是在網路上執行。就這樣,簡單的一句話 :)
響應式UI
照片:Harpal Singh on Unsplash
與Flutter web沒有直接關係,但仍然很重要! 應用程式的UI應該調整佈局和行為,以適應使用者的螢幕和平臺以及平臺的預期。
通常移動應用遵循同樣的流程;你有一個列表或選單,使用者在其中選擇一個元素。基於這個選擇,會顯示另一個螢幕,舉個例子。
- 你開啟你的郵件應用,你會看到你的收件箱。
- 在郵件列表中,你選擇一個郵件來開啟它。
- 畫面切換到郵件的詳細資訊,您的列表在導航棧中。
在更大的螢幕上(就像我說的,不與網頁相關的直接也可以是平板電腦),你可以在旁邊呈現一個主部件(郵件列表)和一個細節部分(郵件內容)。現在,一旦使用者在主部件中選擇了什麼,細節部分就會得到更新,而不是切換螢幕。
哪些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樹中工作。
在pub.dev上也有一些預製包,讓你去做更復雜的事情,就像我上面的小例子一樣(還沒有試過,因為我現在對我的簡單檢查很滿意)。
永久儲存
照片:Steve Johnson on Unsplash
一個網頁的儲存空間是有限的,你不能儲存多少資料。你沒有檔案系統,不能隨心所欲的寫和讀。不過,你還是可以儲存資料,你甚至可以用100%相同的程式碼在兩個平臺上進行儲存。
使用者設定與偏好
就用下面的包吧,很死簡單,根據你的平臺,把資料儲存在SQLite資料庫或者本地儲存(鍵值儲存)。
更多的資料?
如果你需要更多的資料或者想要執行查詢,我會推薦你去看看Moor。
網路相關的文件可以在這裡找到。
結論
Flutter Web是讓你用同樣的程式碼做更多事情的重要一步。不過,它還是需要在前期進行一些紮實的思考。在開發過程中,你總是要問自己,在特定的目標平臺上可能會發生什麼,你是否需要構建一些東西來考慮這個問題。
從我的角度來看,同時在多個平臺上使用Flutter工作,一開始是很粗糙的,但有了這裡提供的學習,我就有信心繼續進行我的小專案了。誰知道呢,也許我還要做第二輪的 "Flutter入門前要知道的事情"。