使用 Nested Navigation 簡化路由設計

閱讀時間約 22 分鐘


Flutter 習慣在最頂層的 MaterialApp 或 CupertinoApp 中統一定義整個 app 的路由管理。當我們把所有頁面的路由管理都放在最頂層時,就會讓它變得很長,不容易維護。或許應該適時思考,是否某些頁面的路由不應該被管理在最頂層。今天就來分享工作上遇到的一個情境,以及它存在什麼問題,而我們又是如何解決的。

舉個例子

假設我們需要實作一個搜尋附近裝置並傳輸檔案到該裝置的需求,一開始我們會搜尋附近裝置,完成之後,畫面會顯示可選擇的裝置清單給使用者進行選擇,當使用者完成選擇之後,畫面會列出檔案清單讓使用者選擇,當使用者選完檔案並按下 Transfer 按鈕後進行傳輸工作。當傳輸工作完成時,回到最一開始的頁面,並顯示 Transfer success 的訊息讓使用者知道。

raw-image

用 Top-Level Navigation 方式實作

我們設計了三個頁面,分別是 SearchDevicesPage、SelectDevicePage 和 SelectFilePage,三個頁面的工作分別是搜尋裝置、讓使用者選擇裝置和 讓使用者選擇檔案,每個頁面都是定義在頂層的路由。為此,我們在路由管理中有了起始頁面 Home 與傳輸檔案所需要的三個頁面。

Route _onGenerateRoute(RouteSettings routeSettings) {
if (routeSettings.name == HomePage.routeName) {
return MaterialPageRoute(builder: (BuildContext context) => const HomePage());
} else if (routeSettings.name == SearchDevicesPage.routeName) {
return MaterialPageRoute<TransferResult>(
builder: (BuildContext context) => const SearchDevicesPage(),
settings: routeSettings,
);
} else if (routeSettings.name == SelectDevicePage.routeName) {
return MaterialPageRoute<TransferResult>(
builder: (BuildContext context) => const SelectDevicePage(),
settings: routeSettings,
);
} else if (routeSettings.name == SelectFilePage.routeName) {
return MaterialPageRoute<TransferResult>(
builder: (BuildContext context) => const SelectFilePage(),
settings: routeSettings,
);
}
throw RouteNotFoundException("Need to implement ${routeSettings.name}");
}

當使用者在 Home 頁面中按下 + 按鈕,想要傳輸檔案時,程式會先打開 SearchDevicesPage 並開始搜尋附近的裝置。當搜尋完成並且使用者按下 Next 按鈕後,程式會開啟下一個頁面:SelectDevicePage,並把搜尋到的裝置清單傳給下一個頁面顯示。

Future _openSelectDevicePage(BuildContext context, List<Device> devices) async {
Navigator.of(context).pushNamed(SelectDevicePage.routeName, arguments: devices);
}

在 SelectDevicePage 中,畫面會顯示裝置清單。當使用者選擇任一裝置後按下 Next,程式就會開啟下一個頁面:SelectFilePage,並且把使用者選擇的裝置傳給它。

Future _openSelectFilePage(BuildContext context) async {
Navigator.of(context).pushNamed(SelectFilePage.routeName, arguments: selectedDevice);
}

當使用者來來到 SelectFilePage 時,畫面會顯示檔案清單。當使用者選擇任一檔案後按下 Next,程式會使用上一個頁面給的裝置與這個頁面選擇的檔案進行傳輸工作。當傳輸工作完成之後,把傳輸結果往回傳遞。

await _transfer(selectedDevice, selectedFile!);
Navigator.pop(context, TransferResult.success);

當 Navigator.pop 執行後,首先回到的是 SelectDevicePage 的 _openSelectDevicePage 方法中。我們需要修改這個方法,讓他可以取得從 SelectFilePage 得到傳輸的結果,並把他回傳給上一頁。

Future _openSelectFilePage(BuildContext context) async {
TransferResult? transferResult = await Navigator.of(context).pushNamed<TransferResult>(SelectFilePage.routeName, arguments: selectedDevice);
Navigator.pop(context, transferResult);
}

同樣的 SearchDevicesPage 也需要進行修改,同樣的讓他可以把結果帶回給 HomePage。

Future _openSelectDevicePage(BuildContext context, List<Device> devices) async {
TransferResult? transferResult = await Navigator.of(context).pushNamed<TransferResult>(SelectDevicePage.routeName, arguments: devices);
Navigator.of(context).pop(transferResult);
}

在 HomePage 中,當程式從 SearchDevicesPage 回來時,就可以取得傳輸檔案的結果,並決定畫面如何顯示。以下面程式碼來說,當傳輸成功時,我們會在畫面上顯示一個 Snackbar 訊息提示傳輸成功。

Future _openSearchDevicePage(BuildContext context) async {
TransferResult? transferResult = await Navigator.of(context).pushNamed<TransferResult>(SearchDevicesPage.routeName);
if (transferResult == TransferResult.success) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Transfer success")));
}
}

完整程式碼

作法分析

這種設計可能存在一些問題

  • 頁面會被強迫知道他不需要的資訊

以上面的例子來說,SelectFilePage 本身的工作應該是讓使用者選擇檔案,但是他卻也同時必須儲存使用者選擇的裝置,只為了完成傳輸的工作。或許我們可以把傳輸的工作移到 HomePage,但是這樣一來,我們也會需要把使用者選擇的裝置和檔案往回傳,同樣的造成 SelectDevicePage 也需要知道檔案的資訊,兩種做法都無可避免的導致某些頁面知道他不需要知道的資訊。

Future _openTransferFilePage(BuildContext context) async {
File? file = await Navigator.of(context).pushNamed<File>(SelectFilePage.routeName);
if (file != null) {
Navigator.pop(context, TransferAction(selectedDevice!, file));
}
}
  • 流程路徑上的每個頁面都需要知道如何處理結果

在上面例子中,每個頁面都會需要接回 TransferFileResult,並在 Navigator.pop() 中往回帶,這也表示每個頁面都需要知道 TransferResult 的存在,以及決定如何處理它。但實際上只有 HomePage 是真正需要的人,決定這個結果的是 SelectFilePage,它跟 HomePage 隔了兩個頁面,導致中間的頁面需要幫忙傳遞它們不需要的資訊。

TransferResult? transferResult = await Navigator.of(context).pushNamed<TransferResult>(...);
Navigator.pop(context, transferResult);

這些問題容易導致這些頁面無法重複使用,假設今天多了一種需求:同樣的選擇 Device,同樣的選擇檔案,最後卻是要傳送檔案資訊的文字,而不是傳送檔案本身。如果依照原本的設計修改的話,就會變成需要把最後的行為抽成方法或類別,把他從第一個頁面一路往下一個頁面傳,直到最後一個頁面,但是這個傳遞參數跟中間過程的頁面的工作毫無關係,他只是為了最後一個頁面需要而幫忙傳遞,提高了頁面之間的耦合度。

用 Nested Navigation 實作

為了解決這個問題,我們使用 Nest Navigation 來處理,讓頁面只知道自己需要的訊息,並且輸出他工作後的結果,至於如何使用就讓使用方來決定。

我們使用上面的例子進行修改,先創建一個 TransferFileFlow,並在其中使用 Navigator Widget,給定一個 GlobalKey 放進 Navigator 中,我們需要使用 GlobalKey 來進行 Nested Navigator 的頁面切換。

class TransferFileFlow extends StatefulWidget {
static const String routeName = "transfer-file-flow";

const TransferFileFlow({Key? key}) : super(key: key);

@override
State<TransferFileFlow> createState() => _TransferFileFlowState();
}

class _TransferFileFlowState extends State<TransferFileFlow> {
final GlobalKey<NavigatorState> _nestedNavigatorKey = GlobalKey<NavigatorState>();

@override
Widget build(BuildContext context) {
return Scaffold(
body: Navigator(
key: _nestedNavigatorKey,
initialRoute: SearchDevicesPage.routeName,
onGenerateRoute: _onGenerateRoute,
),
);
}
}

在 Navigator 中傳入 _onGenerateRoute,定義 TransferFileFlow 各個頁面的路由與實際頁面的關係。並把相對應的 callback 傳入實際頁面中,讓每一個頁面完成工作時可以通知 TransferFileFlow,讓 TransferFileFlow 可以切換到下一個頁面。比如說 SelectDevicePage 就傳入了 _onDeviceSelected,當使用者選擇了任一裝置後,就會呼叫 _onDeviceSelected,而 TransferFileFlow 就進行流程的下一步了。

Route _onGenerateRoute(RouteSettings routeSettings) {
if (routeSettings.name == SearchDevicesPage.routeName) {
return MaterialPageRoute(
builder: (BuildContext context) => SearchDevicesPage(
onBack: _onSearchDevicesPageClose,
onDevicesSearched: _onDevicesSearched,
),
);
} else if (routeSettings.name == SelectDevicePage.routeName) {
return MaterialPageRoute(
builder: (BuildContext context) => SelectDevicePage(
searchedDevices: searchedDevices,
onDeviceSelected: _onDeviceSelected,
),
);
} else if (routeSettings.name == SelectFilePage.routeName) {
return MaterialPageRoute(
builder: (BuildContext context) => SelectFilePage(
onFileSelected: _onFileSelected,
),
);
}
throw RouteNotFoundException("Need to implement ${routeSettings.name}");
}

在每個 callback 中,TransferFileFlow 接收每一個頁面的輸出,並暫存在自己身上。在最後一個頁面完成之後,就進行傳輸檔案的動作。

void _onDevicesSearched(List<Device> devices) {
searchedDevices = devices;
_nestedNavigatorKey.currentState?.pushNamed(SelectDevicePage.routeName);
}

void _onDeviceSelected(Device device) {
selectedDevice = device;
_nestedNavigatorKey.currentState?.pushNamed(SelectFilePage.routeName);
}

Future _onFileSelected(File file) async {
selectedFile = file;
await _transfer(selectedDevice, selectedFile);
Navigator.of(context).pop(TransferResult.success);
}

當整個 TransferFileFlow 的流程完成之後,TransferFileFlow 就會回傳 TransferResult 給 HomePage。中間也不存在任何頁面幫忙傳遞結果,而是 TransferFileFlow 送出結果,HomePage 下一秒收到後就馬上使用。

Future _openTransferFileFlow(BuildContext context) async {
TransferResult? transferResult = await Navigator.of(context).pushNamed<TransferResult>(TransferFileFlow.routeName);
if (transferResult != null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Transfer success")));
}
}

至於原本頂層路由管理就會只剩下 HomePage 與 TransferFlow,也會變得簡單一些。

Route _onGenerateRoute(RouteSettings routeSettings) {
if (routeSettings.name == HomePage.routeName) {
return MaterialPageRoute(builder: (BuildContext context) => const HomePage());
} else if (routeSettings.name == TransferFileFlow.routeName) {
return MaterialPageRoute<TransferResult>(builder: (BuildContext context) => const TransferFileFlow());
}
throw RouteNotFoundException("Need to implement ${routeSettings.name}");
}

完整程式碼

作法分析

  • 傳遞給頁面的參數恰好是頁面所需要的

使用 Nested Navigation,把控制流程的的工作轉交給 TransferFileFlow,由 TransferFileFlow 把頁面需要的參數直接傳給它,頁面再也不需要幫忙傳遞任何參數。以上面的例子來說,我們再也不需要把使用者選擇的 Device 傳給 SelectFilePage 了,讓 SelectDevicePage 處理完之後傳給 TransferFileFlow,SelectFilePage 只要專注地處理使用者選擇的 File,並把使用者選擇的 File 傳給 TransferFileFlow,最後由 TransferFileFlow 組合資訊並完成工作。

  • 頁面無須處理與流程相關的邏輯

頁面做完工作之後,也只需要專注的輸出結果給 Flow,由 Flow 來蒐集必要資訊,用以完成傳輸工作,並決定傳輸結果。當今天多了另一個類似需求,我們就可以創建另一個 Flow,該 Flow 可以根據需求來組合需要的頁面,最後再進行不同的操作,讓頁面不會因為與前後頁面之間的耦合導致難以重複使用。

結論

Nested Navigation 十分適合使用在這種固定流程的工作上,如果頁面沒辦法單獨提供功能,而是需要多個頁面共同組合出一個功能的話,就很適合使用 Nested Navigation 這種做法。當一個頁面能提供完整的功能,例如顯示比賽資訊,顯示裝置詳細資訊,或者是更新使用暱稱這種單一頁面就可以完成的,或許就不太需要特別使用 Nested Navigation。

參考

分享各種 Flutter 開發技巧
留言0
查看全部
發表第一個留言支持創作者!
本文探討如何使用 Flutter 的 Widget 測試來驗證應用程式的 Routing 功能,確保重構後仍然正常運作。我們將通過具體的範例,從設定 MockNavigatorObserver 到驗證 Routing 參數,提供清晰步驟與建議,以提高測試的可讀性和效能,是開發人員必備的測試技巧。
這篇文章介紹如何在多臺 MacBook 上同步開發工具與設定,以提高開發效率。文章重點在於如何同步 IntelliJ、IdeaVim 和 Alfred 配置,並解決因設定不同而影響開發效率的問題。透過簡單的步驟,開發者可以在不同設備上無縫運作,持續專注於開發工作,而不必因為熱鍵或工具失效而浪費時間。
本文探討在客戶端程式開發中,如何有效處理根據後端不同資料型態變化的畫面顯示。透過列舉 Shortgun Surgery 問題及其對代碼維護的影響,分析各種設計模式,包括轉接器模式和策略模式,來改善資料的處理方式。最終提出根據具體情況選擇合適解法的重要性,以確保開發效率與代碼可維護性。
本文探討如何使用 Flutter 的 Widget 測試來驗證應用程式的 Routing 功能,確保重構後仍然正常運作。我們將通過具體的範例,從設定 MockNavigatorObserver 到驗證 Routing 參數,提供清晰步驟與建議,以提高測試的可讀性和效能,是開發人員必備的測試技巧。
這篇文章介紹如何在多臺 MacBook 上同步開發工具與設定,以提高開發效率。文章重點在於如何同步 IntelliJ、IdeaVim 和 Alfred 配置,並解決因設定不同而影響開發效率的問題。透過簡單的步驟,開發者可以在不同設備上無縫運作,持續專注於開發工作,而不必因為熱鍵或工具失效而浪費時間。
本文探討在客戶端程式開發中,如何有效處理根據後端不同資料型態變化的畫面顯示。透過列舉 Shortgun Surgery 問題及其對代碼維護的影響,分析各種設計模式,包括轉接器模式和策略模式,來改善資料的處理方式。最終提出根據具體情況選擇合適解法的重要性,以確保開發效率與代碼可維護性。
你可能也想看
Google News 追蹤
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
不論是 Astra、Blocksy 還是 Kadence 佈景主題,都有內建頁首與頁尾編輯器,你可以在外觀自訂器中以所見即所得的方式新增各種元素,像是選單、按鈕及社群圖示。
Thumbnail
CSS 是控制網頁外觀的語言,應用於網頁設計、UI/UX 設計、電子商務和移動應用開發。主要使用者包括前端開發者、UI/UX 設計師和網頁設計師。CSS 的特性有樣式控制、層疊優先級、響應式設計及分離內容與樣式。
Thumbnail
※ 什麼是路由? 當我們說「路由」時,可能是在談論路由器(實體設備),也可能是在談論路由(選擇路徑的過程),或者是在談論路徑(資料封包的傳輸路徑)。 路由器 (Router):這是一種實體設備,負責將資料封包 (Packet) 從一個網路傳送到另一個網路。它的工作方式類似於交通指揮,確保資料封包
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
需求情境: 在設計畫面時,資料來源是後台的 api,每一次畫面細節的修修改改,都會觸發 Xcode Preview 程序,導致不斷呼叫後台。此時若資料結構和大小都具有一定規模,就會導致效率低落,不斷等待,且消耗伺服器資源甚鉅。 解決方案: 將後台傳回的資料以檔案形式暫存在本地端,每次 pr
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
不論是 Astra、Blocksy 還是 Kadence 佈景主題,都有內建頁首與頁尾編輯器,你可以在外觀自訂器中以所見即所得的方式新增各種元素,像是選單、按鈕及社群圖示。
Thumbnail
CSS 是控制網頁外觀的語言,應用於網頁設計、UI/UX 設計、電子商務和移動應用開發。主要使用者包括前端開發者、UI/UX 設計師和網頁設計師。CSS 的特性有樣式控制、層疊優先級、響應式設計及分離內容與樣式。
Thumbnail
※ 什麼是路由? 當我們說「路由」時,可能是在談論路由器(實體設備),也可能是在談論路由(選擇路徑的過程),或者是在談論路徑(資料封包的傳輸路徑)。 路由器 (Router):這是一種實體設備,負責將資料封包 (Packet) 從一個網路傳送到另一個網路。它的工作方式類似於交通指揮,確保資料封包
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
需求情境: 在設計畫面時,資料來源是後台的 api,每一次畫面細節的修修改改,都會觸發 Xcode Preview 程序,導致不斷呼叫後台。此時若資料結構和大小都具有一定規模,就會導致效率低落,不斷等待,且消耗伺服器資源甚鉅。 解決方案: 將後台傳回的資料以檔案形式暫存在本地端,每次 pr
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。