探討Flutter的導覽與路由時,若相關資料讀來有違和感,別懷疑,Flutter是相對年輕的平臺,社群經驗還在建立,透過設計概念、隱喻、API架構,甚至是原始碼來探討,才是王道。

以紙為隱喻的Navigator

對於有著一定功能的App來說,不會只有一個功能頁面,開發者使用Flutter涉及多個頁面切換時,若無特別想法,就會使用Navigator與MaterialPageRoute來設計,或者透過具名路由(Named route)設定路由表,單純地遵守框架規則,將路由置入(push)Navigator管理的堆疊,或者從堆疊中彈出(pop)。

但看過文件後我有疑問:「嗯?為什麼Navigator要使用堆疊?」這邊指的並不是資料結構上的堆疊,而是頁面切換為何是疊起來的?Flutter提倡並支援Material Design,來看看Material Design官網怎麼說好了,在〈Understanding navigation〉(https://bit.ly/2AI3Tpj)談到「使用者透過導覽通行app」。

若只是要通行App的話,就算不透過Navigator,開發者應該也有些直覺的作法吧!像是將Widget樹中代表各頁面的子樹置換掉,或者是用Stack之類的排版元件來實現;然而在毫無規範的情況下,這類作法在頁面複雜時,很快地就會令頁面間的跳轉一團亂,摸不清現在是在App的哪個位置,Android開發者在過去就面臨過這類問題,在Google 2018 I/O年推出的AndroidJetpack中,包含了新的Navigation元件,就是想解決這類問題。

Flutter吸取了過去的經驗,直接在框架中內建Navigator,讓開發者透過Navigator來規範,將導覽的設計約束在可控制的範圍,所謂的約束,具體來說,就是Navigator API文件上第一句就談到的:「用堆疊來管理子Widget」。

也就是說,只有在導覽設計打算採用堆疊的情況下,使用Navigator才會開心,框架才能幫你處理頁面切換狀態、訊息溝通、轉場動畫等細節,設計時可用紙來隱喻:App首頁是一張紙,若進入另一頁,就是把另一張紙疊在上頭;若想離開頁面,就將最上層的紙拿掉,下方頁面自然就顯現出來。而這就是Navigator.push/pushNamed與pop方法的作用。

你也可以把最底下的紙,直接用另一張紙來取代,這可能發生在使用者登入後,直接將登入頁面移去,以會員首頁取代,以簡化堆疊管理的情況,例如,pushReplacement/pushReplacementNamed的作用就是如此;在疊了很多張紙後,有時,我們可能會想直接取閱底層某張紙,這時,可以用popUntil設定條件,一次性地彈出不符合條件的頁面。

路由不是頁面!

在技術面上,Navigator堆疊中管理的不是Widget,而是Route實例。許多開發者經常誤會「路由就是頁面(或螢幕)」,在文件上示範程式碼時,繼承StatelessWidget/StatefulWidget的類別,常被命名為XXXRoute。這或許不該怪開發者,因為Flutter官方文件在許多地方,也都會有「In Flutter, screens and pages are called routes」這類的說明出現,例如:〈Navigate to a new screen and back〉(https://bit.ly/2ZkhCwJ)。

確實!建構Route實例時,經常會指定頁面的建立方式,而將一個Route置入Navigator的堆疊後,最終目的是顯示被建立的頁面,然而Flutter中頁面不等於路由,最多就只能說「路由是畫面/螢幕資源的抽象」,而我會傾向於使用「路由代表某個資源的銜接」這種說法。

舉個例子好了,當MaterialPageRoute被置入Navigator的堆疊,最後雖然會透過builder建構的Widget實例,build傳回的Widget來做全螢幕呈現,不過,MaterialPageRoute銜接的資源,除了最後得到的Widget之外,還包含了原生平臺相應的轉場動畫效果。

MaterialApp有個routes特性,可以設置路由表,對象是Map<String, WidgetBuilder>實例,也就是,可藉由名稱關聯至最後要建立的Widget頁面,進一步隱藏了Route實例的存在,這更令人容易誤認為路由就是頁面,然而,在MaterialApp的底層,其實會透過routes設定來建立對應的MaterialPageRoute。

Fluuter中的路由並不是頁面,而是代表某個資源的銜接,這個銜接也並非只有單向,將Route置入Navigator堆疊的操作,其實含有對路由進行非同步請求的隱喻;將路由從堆疊中彈出時,可以傳回請求結果,也就是技術上來說,Navigator的push等方法,是可以await的,使用pushNamed方法時,也可以附加arguments。

路由名稱、引數、非同步回應,感覺就類似Ajax請求的概念,這就有想像空間了,能否不透過arguments,而是透過'/products/1'這類路由名稱,將1當成是給/product的參數呢?透過MaterialApp的onGenerateRoute特性,在取得RouteSettings後,對name進行剖析,並設定給Route物件,就可以實現這點,實際上,MaterialApp預設的Navigator,就只是預設產生MaterialPageRoute罷了。

Navigator是個Widget

在MaterialPageRoute的API文件上談到,它(嚴格說來,是其關聯的頁面)會取代整個螢幕,不過,只有透過MaterialApp預設的Navigator才會有此行為,在MaterialApp的API文件中就談到,只有在home、routes、onGenerateRoute與onUnknownRoute為null,而builder不為null的情況下,才不會生成頂層的Navigator。

其實,Navigator是StatefulWidget的子類別,Navigator.pushNamed之類的靜態方法,實際上,是透過Navigator.of取得Widget樹中,最接近的Navigator父裔節點對應的NavigatorState,進一步操作對應的pushNamed等方法。

這意謂著,Navigator可以被安排在Widget樹的任何地方,透過路由管理,於該節點進行更有彈性的頁面切換效果。例如,可以設定MaterialApp的body為Navigator,在該Navigator的onGenerateRoute設定路由切換規則,這麼一來,該Navigator路由設定下切換過去的頁面元件,會成為該節點的Widget子樹,也就是說,被切過去的頁面,就不一定佔滿整個螢幕。

也就是說,如果Flutter的Scaffold在appBar、bottomNavigationBar等設定,無法滿足導覽設計需求時,透過巢狀的Navigator設計,也可以自行實現頁籤導覽之類的元件。

值得注意的是,onGenerateRoute特性的使用,MaterialApp的onGenerateRoute,其實,是委託給預設Navigator的onGenerateRoute,有開發者聲稱可以將它當成攔截器來使用,但這做法不正確,就MaterialApp來說,routes沒有對應名稱時才會運行onGenerateRoute,它其實是彈性自定路由的場合。別誤會它是找不到路由名稱時執行,那應該是onKnownRoute的工作。

保持適當懷疑與思考

會有這一連串的探索,主要是來自於閱讀Flutter相關文件時,總覺得有些令人狐疑之處,或許是因為相對於其他App平臺或框架來說,Flutter是比較年輕的,社群方面的經驗也還在建立當中。

釐清這類疑問的方式有很多,像是透過設計概念與隱喻來理解(例如透過Material Design的閱讀),或者是API架構甚至原始碼的探索;無論如何,不要只是流於元件組合,對文件保有適當懷疑並持續思考與探討,過程中就會有令人意外不到的發現。

作者簡介


Advertisement

更多 iThome相關內容