如何在 Flutter 使用 Router 實作 AppLink?
當需要 Flutter 實作 App Link,有兩種途徑可以實現。這篇筆記主要說明如何使用 Router 的方式實作 URL-based 的 App Link。Router 是 Navigator 2.0 所包含的元件之一。
由於行動裝置的路由狀態普遍是採用 push/pop 的線性模型,抽象可理解為 stack 資料結構,因此當遇到我們想把 App 的使用情境延伸到桌面程式或是 Web App 原本的 Navigator 1.0 就顯得有點捉襟見著了,隨著 Flutter 開始支援 Desktop App 的跨平台開發,官方推出 Navigator 2.0 如此大幅度的更新也就不意外。
App 的建立方式
首先我們必須了解建立 MaterialApp 的方式改變了。你需要用 MaterialApp.rouer
替代 MaterialApp
。
@override
Widget build(BuildContext context) {
var routerParser = AppRouteParser();
var router = AppRouterDelegate();
return MaterialApp.router(
title: 'AppName',
theme: MacosThemeData.light(),
darkTheme: MacosThemeData.dark(),
routerDelegate: router,
routeInformationParser: routeParser,
);
}
新的方式要求開發者提供 RouterDelegate
跟 RouterInformationParser
兩個類別, 因此我們接下來的目標,便是實作能夠實現我們路由邏輯的 RouterDelegate
跟 RouterInformationParser
。
目錄架構
為了方便維護,我們會建立一個單獨的目錄,將 Router 相關的三個檔案放入,結構與各檔案用途如下:
- lib/router/
- lib/router/app_link.dart :: 包含一個 AppLink 類別,負責把字串解析成 Dart model。
- lib/router/router_parser.dart :: 包含一個 AppRouteParser 類別,負責做 URL 跟 App 路由狀態的對應。
- lib/router/router.dart 包含一個 AppRouterDelegate 類別,負責處理設定、取得、返回 App 路由狀態。
RouterInformationParser
RouterInformation
是一個資料轉譯的中介層,負責把 URL 轉換成 router 中 setNewRoutePath
需要的 routing configuration 型別,
這邊我們建立一個新的類別 AppRouterPraser
,並指定 routing configuration 的類別是 AppLink
import 'app_link.dart';
/// Converts browser location strings to [AppLink], and vice-versa.
/// This leans on [AppLink] to the actual parsing, so this is largely boilerplate.
class AppRouteParser extends RouteInformationParser<AppLink> {
@override
// Take a url bar location, and create an AppLink from it
Future<AppLink> parseRouteInformation(
RouteInformation routeInformation) async {
AppLink link = AppLink.fromLocation(routeInformation.location);
return link;
}
@override
// Convert an applink into a string used for the browser location
RouteInformation restoreRouteInformation(AppLink configuration) {
// Ask the applink to give us a string
String location = configuration.toLocation();
// Pass that string back to the OS so it can update the url bar
return RouteInformation(location: location);
}
}
完成這部分後,我們來著手實現 AppLink
,讓 AppRouteParser
可以正確的解析 URL。
AppLink
什麼是 AppLink 呢?簡單來說就是對應到 App 上某個頁面的唯一地址,通常會長這樣
exampleapp:/page?paramName=pramValue
或是這樣
exampleapp:/page/${id}
如果是 Web App,則會長這樣:
https://exampple.com/page${id}
但這並沒有強制性規定,App 的開發商可以遵循後端程式使用 Restful API 的習觀,也可以根據自己的業務模型設計,因此我們需要設計一個專門處理這類的資料轉換的類別,該類別需要實作下面的介面供 RouterParser
中使用。
abstract class AppLink {
fromLocation(String location);
String toLocation();
}
這邊提供一個簡單的源碼範例:
class AppLink {
static const String kHomePath = "/";
static const String kStartPath = "/start";
static const String kVaultPath = "/vault";
static const String kNodePath = "/node";
static const String kPortfolioPath = "/portfolio";
static const String kSettingsPath = "/settings";
static const String kIdParam = 'id';
AppLink();
String? id;
String? location;
static AppLink fromLocation(String? location) {
/// default path is home.
location = Uri.decodeFull(location ?? kHomePath);
final uri = Uri.parse(location);
final params = uri.queryParameters;
void trySet(String key, void Function(String) setter) {
String? s = params[key];
if (params.containsKey(key)) setter(s ?? '');
}
final link = AppLink()..location = uri.path;
/// params could be null.
trySet(AppLink.kIdParam, (s) => link.id = s);
return link;
}
String toLocation() {
String addKeyValPair({required String key, String? value}) =>
value == null ? "" : "$key=$value&";
switch (location) {
case (kStartPath):
return kStartPath;
case (kVaultPath):
var loc = '$kVaultPath?';
loc += addKeyValPair(key: kIdParam, value: id);
return Uri.encodeFull(loc);
case (kNodePath):
var loc = '$kNodePath?';
loc += addKeyValPair(key: kIdParam, value: id);
return Uri.encodeFull(loc);
case (kPortfolioPath):
return kPortfolioPath;
case (kSettingsPath):
return kSettingsPath;
default:
return kHomePath;
}
}
}
不單獨將這部分的邏輯從 RouterParser
抽離出來成為一個類別無妨,但抽離出來的好處是更容易做單元測試。
Router
Router 的部分我們需要實作 AppRouterDelegate
的類別,介面如下。
abstract class AppRouterDelegate extends RouterDelegate<AppLink> with ChangeNotifier {
/// The pages are currently opened, similar to browser's history.
final List<AppLink> _stack = [];
Page getPage(AppLink location);
Future<bool> popRoute();
AppLink getCurrentConfiguration();
Future<void> setNewRoutePath(AppLink configuration);
Widget build(BuildContext context);
}
每個方法用途如下:
- getPage :: 回傳 AppLink 對應的 Page
- popRoute :: 要求 App 返回到前一個 URL 對應的狀態。
- getCurrentConfiguration :: 取得當前的 URL,若未實作該 method,瀏覽器的 URL 不會連動更新。
- setNewRoutePath 要求 :: App 切換到該 URL 對應的狀態
- build :: 回傳一個 Navigator,設定當前已開啟的頁面
getPage()
在 Navigator 2.0 中, 新增了一個 Page 的類別,該類別保存了包含路由名称(name,如 “/settings”)和路由参数 (arguments) 等訊息,把 Widget 跟路由的資訊組合起來餵給 Navigator, Page 的主要目的是產生 PageRoute,如同 Widget 與 Element 的關係,我們可以想成 Page 是設定檔,執行中才會產生真正的路由物件。
當前 Flutter 提供的 Page 有兩種,分別是 Android 上的 MaterialPage 跟 iOS 上的 CupertinoPage,如果是 Desktop App 則需要開發者自己客製實作下面介面的 Page 類別。
class MyPage extends Page {
MyPage(this.key);
Route createRoute(BuildContext context) {
return MaterialPageRoute(
settings: this,
builder: (BuildContext context) {
return MyWidget()
},
);
}
}
PageRoute 也是有兩種,分別是針對 iOS 平台轉場動畫的 CupertinoPageRoute
, 另一種是根據 Android, iOS 平台做不同切換風格的轉場動畫 MaterialPageRoute
,開發者若需要自訂自己想要的換頁轉場動畫,便可以在這一層客製化。
我目前沒有發現 Navigator 2.0 是否有 URL 與 Page 對應的設定位置,因此開發者必須自己手動實現這部分的邏輯,所以我們新增加一個 method 叫做 getPage
,範例源碼如下。
getPage(AppLink configuration) {
return CupertinoPage(
name: configuration.location, child: getWidget(configuration));
}
getWidget(AppLink configuration) {
switch (configuration.location) {
case AppLink.kStartPath:
return const NodeScreen();
case (AppLink.kSettingsPath):
return const SettingsScreen();
case (AppLink.kVaultPath):
return const VaultScreen();
case (AppLink.kPortfolioPath):
return const PortfolioScreen();
default:
return const StreamScreen();
}
}
popRoute()
@override
Future<bool> popRoute() async {
// Handle OS level back event (Android mainly)
return tryGoBack();
}
bool _onPopPage(Route<dynamic> route, dynamic result) {
tryGoBack();
return route.didPop(result);
}
bool tryGoBack() {
if (_stack.isNotEmpty) {
_stack.removeLast();
notifyListeners();
return true;
}
return false;
}
getCurrentConfiguration
根據 App 當前的狀態組合成對應 AppLink,源碼大概如下:
AppLink getCurrentConfiguration() {
return AppLink()
..location = '/node/'
..id = currentNodeId;
}
build()
一般回傳一個 Navigator,只是這邊我們會需要兩層 Navigator
,最上層的處理 popup windows 的顯示關閉狀態,內層的 Navigator
才處理換頁的狀態。 Navigator
需要傳進兩個參數 page
跟 onPopPage
。
Widget build(BuildContext context) {
// Wrap in a navigator, so we can show/close pop windows like dialogs.
return Navigator(onPopPage: (_, __) => false, pages: [
MaterialPage(
child: AppScaffold(
pageNavigator: Navigator(
pages: [for (final link in _stack) getPage(link)],
onPopPage: _onPopPage)))
]);
pages 是當前打開的頁面陣列,這裡我們使用 stack
追蹤,onPopPage
是一個用於處理返回上一頁邏輯的函數。其中 AppScaffold
則看開發者需求可自行設計,只是記得要把 pageNavigator
放進 child 中,才能確保 BuildContext
可以拿到 Navigaotr
跟 Router
的狀態,否則在執行 Navigator.of(context)
跟Router.of(context)
會跳出錯誤。
如何更換成新的頁面?
換頁的啟動有兩種,一種是使用者的操作,或是系統要求 App 換頁,前者透過 Router 的 API ,後者通常是從別的 App 點擊 AppLink 跳轉過來,在開發過程中我們可以可透過 xcrun
模擬測試,簡化測試流程。
呼叫 Router API
首先,設置新路徑的 Dart 代碼:
// 使用 Router 的 setNewRoutePath 方法來設置新路徑
Router.of(context).routerDelegate.setNewRoutePath();
// 這裡展示了如何將路徑設置為根位置 '/'
Router.of(context)
.routerDelegate
.setNewRoutePath(AppLink.fromLocation('/'));
接下來,關於在 MacOS 上使用命令行測試 AppLink 的部分:
使用 command 測試 MacOS 上的 AppLink
使用 xcrun simctl 命令在模擬器上打開特定的 URL。這裡的例子是打開 exampleapp 並導航到 home 頁面,並設置 tab 參數為 1。
xcrun simctl openurl booted 'exampleapp://raywenderlich.com/home?tab=1'
如何返回上一頁?
// 調用 routerDelegate 的 popRoute 方法來返回上一頁
Router.of(context).routerDelegate.popRoute();
如何關閉彈出窗口?
// 使用 Navigator 的 pop 方法來關閉當前的彈出窗口或對話框
Navigator.of(context).pop();