當需要 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,
    );
  }

新的方式要求開發者提供 RouterDelegateRouterInformationParser 兩個類別, 因此我們接下來的目標,便是實作能夠實現我們路由邏輯的 RouterDelegateRouterInformationParser

目錄架構

為了方便維護,我們會建立一個單獨的目錄,將 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 需要傳進兩個參數 pageonPopPage

  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 可以拿到 NavigaotrRouter 的狀態,否則在執行 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();

延伸閱讀