轉載至 我的blog
最近項目中越來越多的頁面開始使用 Flutter 進行開發,這時一個棘手的問題就暴露出來,我們項目中對 Flutter 頁面橫豎屏的控制非常的僵硬,基本上就是在被跳轉的頁面的 init 方法中進行了橫豎屏的設置,然后在 dispose 的時候再改回去,當初這樣寫的原因在于任務緊張并且團隊初次使用了 Flutter 來開發,對 Flutter 中的各種機制還不是特別的了解,并且就幾個頁面,跳轉的邏輯十分的簡單,就將就了一下。
現在頁面多了,這樣寫的缺點就很大了。嚴重依賴頁面間的跳轉順序,一切的橫豎屏設置都是依賴于固定路徑的跳轉,否者在 dispose 的時候就無法設置正確的屏幕方向,在 Flutter 中最理想的是使用 route 來進行任意的跳轉,我們這種寫法對此而言簡直就是災難。
實現方案
正好最近有時間就研究了一下這個問題,并找到了一個相對可靠的解決方案,根據我的經驗,我想實現一種類似于 iOS 當中橫豎屏控制的機制。在 iOS 當中,每個 ViewController 都能重寫三個只讀屬性,來設置自己的屏幕方向。這樣每個頁面只需要聲明自己的屏幕方向,就能在顯示自身時獲得正確的顯示,無須關心導航棧中的其他頁面,屏幕方向的控制都交給 Navigator 來管理。
監聽導航棧
要實現上述功能,最重要的一點就是,我們要監聽導航棧的變動,在 Flutter 中,當我們 push 一個頁面和 pop 一個頁面的時候,我們需要監聽到兩個數據,一個是 push 時要去的頁面,一個是 pop 時所要返回的頁面。這樣我們就能通過讀取頁面的配置信息來修改屏幕方向,實現類似的效果。
在 Flutter 中我們創建一個 MaterialApp
時,有一個屬性是 final List<NavigatorObserver> navigatorObservers;
, 其中的 NavigatorObserver
是一個類,里面定義了兩個重要的方法 void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { }
和 void didPop(Route<dynamic> route, Route<dynamic> previousRoute) { }
。當 Flutter 頁面中的導航棧發生變化時,會通知所有的 navigatorObservers,并調用上面的兩個方法。
這里我們需要實現一個自己的 navigatorObserver 來監聽導航棧,這里我們定義一個:
class OrientationNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route previousRoute) {
debugPrint(
'didPush route: $route previousRoute: $previousRoute');
}
@override
void didPop(Route route, Route previousRoute) {
debugPrint(
'didPop route: $route previousRoute: $previousRoute');
}
}
將 OrientationNavigatorObserver
設置到 navigatorObservers 中以后,我們在兩個方法里面都打印出 route
和 previousRoute
,運行項目進行一些跳轉,觀察打印的信息,這里可能會出現多種情況:
首先,如果你在項目中使用了是這樣的跳轉方法:
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) {
return VipTaskRegular();
}));
直接使用 push 方法進行跳轉,打印出的信息中 settings
的值會是 (null, null),而這個 route
不會攜帶和你的 widget (這里是 VipTaskRegular)相關的信息,我們就無法確定跳轉的頁面,也就無法進行相關的設置,settings 中的兩個值,一個是 name(路由),還有 arguments (攜帶的參數),所以使用 push 直接進行跳轉的頁面我們是無能為力的。
其實使用 push 進行頁面跳轉不是最優方法,在現在的開發模式中,無論是原生應用還是 Flutter 都開始流行使用路由地址來進行頁面跳轉,就像 web 那樣。通過一個確定的 uri
我們在任何頁面都能跳轉到任何頁面。Flutter 也是推薦先在 MaterialApp 中的 routes
注冊路由,然后我們就能通過 pushNamed(context, routeName)
直接進行頁面跳轉,這種方案代碼更加簡潔,結構上也清晰。
與此同時,我們就能在 didPush
和 didPop
的 route 中捕獲到 route.settings.name
。因為路由地址和頁面是一一對應的,通過這個 routeName 我們就能知道對應的頁面是什么了。
didPush 中將要顯示的頁面是 route, 而在 didPop 中將要返回的頁面是 previousRoute
頁面配置
解決了監聽的問題,接下來就是如何讀取頁面的相關設置。最初的想法還是依賴于 iOS 中的機制(方案一):
- 我想要定義一個抽象類,里面定義上讀取橫豎屏方向的靜態屬性或方法(因為無法獲取 widget 實例,實例方法和屬性也就無從獲取了)
- 各個頁面實現抽象類中的屬性和方法,返回配置信息
- 在導航監聽中,通過 routeName 獲取才頁面的類型,并讀取配置
- 通過配置信息設置橫豎屏方向
但是,上面的設想中,第一條就無法實現,Dart 中的靜態方法是無法向下傳遞給子類的,所以無論是 extends 還是 implements,都是無效的。其次,存儲頁面的 Type 類型到 Map 中,再讀取出來時,也只能通過 switch 進行類型判斷。這都是因為我們無法實現一個可以定義靜態屬性和方法的抽象類。因為無法獲取到 widget 的實例,我們也無法像 iOS 那樣,通過讀取實例的屬性來獲取配置信息。
既然上面的行不通,其次也實現不了像 iOS 那樣的效果,畢竟如果讀取不到實例,也就無法在運行時讓實例通過屬性來自由控制屏幕的方向,我們就索性設置一個配置表,通過 routeName 和 config 的對應,我們讀取預設值進行相關的設置。這樣我們就只需要在一個集中的地方,統一注冊相關的設置就 ok 了
通過配置表統一設置,和通過實現協議各自配置,孰優孰劣就見仁見智了,iOS 中更推崇自己的事情自己干。
這里我直接把相關的代碼粘貼上來:
// Flutter page orientation config.
abstract class OrientationSupport {
bool get available;
List<DeviceOrientation> get preferredOrientations;
List<SystemUiOverlay> get overlays;
}
// Default horizontal config.
class OrientationSupportHorizontal implements OrientationSupport {
@override
bool get available => true;
@override
List<DeviceOrientation> get preferredOrientations => Platform.isIOS
? [DeviceOrientation.landscapeRight]
: [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight];
@override
List<SystemUiOverlay> get overlays =>
[SystemUiOverlay.top, SystemUiOverlay.bottom];
}
// Default vertical config.
class OrientationSupportVertical implements OrientationSupport {
@override
bool get available => true;
@override
List<DeviceOrientation> get preferredOrientations =>
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown];
@override
List<SystemUiOverlay> get overlays => [];
}
class OrientationSupportStay implements OrientationSupport {
@override
bool get available => false;
@override
List<DeviceOrientation> get preferredOrientations => [];
@override
List<SystemUiOverlay> get overlays => [];
}
OrientationSupportVertical _vertical() => OrientationSupportVertical();
class OrientationSupportManager {
static Map<String, OrientationSupport> _list = {
WordMainPage.name: _vertical(),
};
// Leo default page orientation is horizontal.
static OrientationSupport get defaultOrientationSupport =>
OrientationSupportHorizontal();
static void register(String route, OrientationSupport orientationSupport) =>
_list[route] = orientationSupport;
static void registerVertical(String route) =>
register(route, OrientationSupportVertical());
static OrientationSupport retrieve(String route) => _list[route];
}
OrientationSupport
是配置類,里面定義了屏幕方向和狀態欄等顯示狀態,這些都是在屏幕轉換時需要改變的設定。下面就分別定義了橫屏和豎屏的常用配置,和一個不觸發任何轉屏操作的類(它的作用主要是給那些橫豎屏都兼容的頁面使用的,比如彈窗)OrientationSupportManager
中用來注冊各自頁面的橫豎屏設置。
為了方便使用,在我們的項目中,因為大部分的屏幕是橫屏的,所以我會設定一個 defaultOrientationSupport
為橫屏,這樣在后續處理的時候,我就只用注冊豎屏的界面,而不用把所有的頁面都設置一遍。
接下來的操作就是在 NavigatorObserver
中根據監聽來修改屏幕的方向了,代碼如下:
class OrientationNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route previousRoute) {
debugPrint('didPush route: $route previousRoute: $previousRoute');
_setupOrientation(route.settings.name);
}
@override
void didPop(Route route, Route previousRoute) {
debugPrint('didPop route: $route previousRoute: $previousRoute');
_setupOrientation(previousRoute.settings.name);
}
void _setupOrientation(String route) {
// assert(route != null, 'The page have not register route');
if (route != null) {
var orientationSupport = OrientationSupportManager.retrieve(route);
orientationSupport ??=
OrientationSupportManager.defaultOrientationSupport;
if (!orientationSupport.available) return;
// assert(orientationSupport != null,
// '$route have not register orientation support');
SystemChrome.setPreferredOrientations(
orientationSupport.preferredOrientations);
SystemChrome.setEnabledSystemUIOverlays(orientationSupport.overlays);
}
}
}
如果路由為空的話,就不做任何響應,正常的情況下任何頁面的跳轉都必須通過 pushNamed 來進行,這樣的 routeName 是不會為空的。不過也不排除像 alert 彈窗這樣的頁面,在 Flutter 中也是用 push 顯示,但是因為沒有特殊的意義且生命周期短暫就沒有用 pushNamed 來跳轉的事例。然后就是通過 routeName 進行檢索,拿到配置,如果沒有獲取到,就使用默認配置。最后通過 SystemChrome
來設置屏幕方向。
flutter_boost 中如何使用
我們的項目屬于原生中嵌入 Flutter 頁面,在處理 Native 和 Flutter 的導航關系時,使用了 alibaba/flutter_boost。這里也分為兩個場景的使用:
場景一: flutter 內部跳轉也使用 flutter_boost
在 Flutter 內部的跳轉也使用了 flutter_boost
來進行處理的話,通過設置 MaterialApp 的 navigatorObservers
是沒有用的,所有的導航跳轉都被 flutter_boost 接管了,我們需要通過 FlutterBoost.singleton.addContainerObserver(BoostContainerObserver observer)
進行監聽設置。 BoostContainerObserver 就是一個 block 函數,它提供的信息和原來的 NavigatorObserver 差不多。
typedef BoostContainerObserver = void Function(
ContainerOperation operation, BoostContainerSettings settings);
enum ContainerOperation { Push, Onstage, Pop, Remove }
class BoostContainerSettings {
const BoostContainerSettings({
this.uniqueId = 'default',
this.name = 'default',
this.params,
this.builder,
});
final String uniqueId;
final String name;
final Map<String, dynamic> params;
final WidgetBuilder builder;
}
和原來的方法一樣,我們需要監聽的是 Push
和 Pop
操作,而 settings 里的 name
就是 rotueName。
場景二: flutter 內部跳轉不使用 flutter_boost
還有一種情況就是,在 flutter -> native 和 native -> flutter 中使用 flutter_boost。但是在 flutter -> flutter 時,還是使用 flutter 內部的 push
方法進行跳轉。
在這種情況下,我們使用 FlutterBoost.singleton.addBoostNavigatorObserver
來添加一個 NavigatorObserver
, 然后在項目中運行,flutter 內部相互之間的跳轉是沒有問題的,但是當你從 native 跳轉到 flutter 頁面的時候,你會發現進入的 flutter 頁面橫豎屏設置沒有生效,但是再后續的 flutter 內部的跳轉,橫豎屏的設置是生效的。
進一步研究,我們發現從 native A 跳轉 flutter B 的時候,無論這個 B 頁面的 routeName 在 flutter_boost
和 MaterialApp
中的注冊路由為何值,在我們的監聽中,routeName 都會變為 /
。這就導致對 B 頁面的橫豎屏設置失效了。要解決這個問題,我們就需要同時監聽 FlutterBoost.singleton.addContainerObserver
。
當從 native -> flutter 的時候,在 Push
方法中,我們能監聽到 B 頁面正確的 routeName。這里我們就能讀取到相對應的配置信息,然后把配置信息更新到配置表中,key 不是 routeName 而是 /
。通過覆蓋 /
的默認配置,我們就能修復這個問題。在 OrientationNavigatorObserver
添加這個監聽:
NavigatorObserver 的調用時機晚于 ContainerObserver
static void flutterContainerObserver(
ContainerOperation operation, BoostContainerSettings settings) {
debugPrint(
'operation: $operation, settings: name -> ${settings.name} params -> ${settings.params}');
if (operation == ContainerOperation.Push) {
var routeName = settings.name;
OrientationSupport orientationSupport =
OrientationSupportManager.retrieve(routeName) ?? OrientationSupportManager.defaultOrientationSupport;
OrientationSupportManager.register('/', orientationSupport);
}
}
總結
通過使用集中的配置將橫豎屏的設置代碼從各個頁面中抽離出來,各個頁面無感知的實現了頁面橫豎屏的設定。因為依賴使用 pushNamed
來進行頁面跳轉,也使得團隊的代碼風格更加統一,所有的頁面都能通過 routeName 來進行跳轉,方便了以后的開發和維護。現階段的局限是,因為無法獲取到頁面實例,也就無法通過頁面實例內部的屬性,來動態的調整橫豎屏。不過因為配置項是一個接口,通過自定義某個頁面的特殊配置類,然后再在頁面中修改這個配置類的靜態屬性的話,應該也能實現動態的修改,不過這樣的場景較為少見。