Flutter | 通過 ServiceLocator 實現無 context 導航

Vadaski發表於2019-07-05

前言

最近在開發過程中看到很多同學問過這個問題。我想要在網路請求失敗的時候彈出一個統一的處理頁面告訴使用者檢查網路連線。由於這個行為可以發生在任何頁面,我們當然不希望在每一個頁面之中都要重新實現一遍這個邏輯,那樣耦合就太高了,這時候我們的第一反應是在網路請求後某個部分統一處理這部分邏輯。

看上去沒什麼問題,但是如果你做過這個需求話,你就會發現:當我們實現跳轉提示頁面的時候,需要使用到 Navigator 這個元件。回想一下我們一般是如何進行跳轉的。

Navigator.of(context).pushNamed('/errorPage');

我們發現,要實現跳轉到 ErrorPage 這個操作,我們缺少了一個重要的元素 BuildContextNavigator.of(context) 操作其實是在祖先節點中尋找最近的一個 NavigatorState。而這裡的 BuildContext 就是尋找的起點。 所以很多同學都卡在這裡了,那我們就來解決這個問題。

在正式開始本文之前你需要已經理解下面幾個概念:

理解導航原理

什麼是Navigator,MaterialApp做了什麼

我們經常會在應用中開啟許多頁面,當我們返回的時候,它會先後退到上一個開啟的頁面,然後一層一層後退,沒錯這就是一個堆疊。而在Flutter中,則是由Navigator來負責管理維護這些頁面堆疊。

    壓一個新的頁面到螢幕上
    Navigator.of(context).push
    把路由頂層的頁面移除
    Navigator.of(context).pop
複製程式碼

通常我們我們在構建應用的時候並沒有手動去建立一個 Navigator,也能進行頁面導航,這又是為什麼呢。

沒錯,這個 Navigator 正是 MaterialApp 為我們提供的。但是如果 home,routes,onGenerateRoute 和 onUnknownRoute 都為 null,並且 builder 不為 null,MaterialApp 則不會建立任何 Navigator。

既然我們的 Navigator.of(context) 實際上就是在獲取 MaterialApp 提供的 NavigatorState 例項。而 BuildContext 跟當前 Element 有關,要統一控制實際上相當複雜。我們是否可以使用另外一種方式來獲取 Navigator,這樣就可以不再受 BuildContext 的約束了。

獲取 Navigator 例項

要獲取某個 Widget 我們在之前的文章中介紹了可以使用 GlobalKey 來實現。那我們應該如何獲取到 Navigator 呢?

class _AppState extends State<App> {
  GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'navigate');

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: _navigatorKey,
      home: HomeScreen(),
    );
  }
}
複製程式碼

由於 MaterialApp 封裝了 Navigator,並且將 Navigator 的 key 屬性作為 navigatorKey 暴露出來,我們只需要繫結一個 GlobalKey 就行了。

但是現在問題又來了,我們假如想要在外部使用這個 GlobalKey 好像還是不太方便。我們的 Navigator 可能在多處需要使用,假如直接依賴的話每一處都包含了用於建立、定位和管理依賴項的重複程式碼。假如我們現在僅僅只是想進行網路除錯的測試,由於依賴了 Navigator 相關的程式碼,想要進行測試非常困難。

這時候就需要 ServiceLocator 來幫助我們進行解耦。

ServiceLocator

這是一種經典的設計模式,主要目的是將類與依賴解耦,讓類在編譯的時候並知道依賴相的具體實現。從而提升其隔離性和可測試性。

get_it

而今天我們要介紹的是一個來自 Flutter Community 和 Thomas Burkhart 製作的庫 get_it。它是一個輕量級 ServiceLocator 庫,僅僅用到了 99 行程式碼(包括註釋)。建議有時間都去閱讀一下。

簡單上手

get_it 非常簡單,使用就分兩步。

  • 註冊服務
  • 依賴注入

註冊服務

首先建立出一個 GetIt 容器物件。

GetIt getIt = new GetIt();
複製程式碼

然後把需要註冊的服務在容器中註冊。

getIt.registerSingleton<AppModel>(new AppModelImplementation());
getIt.registerLazySingleton<RESTAPI>(() =>new RestAPIImplementation());
複製程式碼

依賴注入

在需要使用到這個依賴的地方我們還是通過這個容器來獲取依賴。

var myAppModel = getIt<AppModel>();

你也可以使用 var myAppModel = getIt.get<AppModel>(); 這個方法,效果是一樣的。

由於 dart 支援全域性變數,我們就把容器直接寫在一個 Dart 檔案中就好了。是不是很簡單呢?

這樣我們的服務就是在容器中建立的,在實際依賴的時候,我們可以只依賴於介面,然後通過容器注入(DI)實現了該介面的實際物件,達到了解耦的效果。

實現 NavigateService

現在我們來看看該如何使用 get_it 實現一個 NavigateService。

新增依賴

Flutter | 通過 ServiceLocator 實現無 context 導航

建立全域性 Locater

我們在專案中新建一個 service_locator.dart 檔案。然後在這個檔案中建立一個全域性 GetIt 例項。

import 'package:get_it/get_it.dart';

    final GetIt getIt = GetIt();
    void setupLocator(){}
複製程式碼

這裡先寫上 setupLocator 方法,之後會在這裡進行服務註冊。

建立 NavigateService

我們把導航相關的功能封裝成 Service,方便之後使用。

import 'package:flutter/material.dart';

class NavigateService {
  final GlobalKey<NavigatorState> key = GlobalKey(debugLabel: 'navigate_key');

  NavigatorState get navigator => key.currentState;

  get pushNamed => navigator.pushNamed;
  get push => navigator.push;
}
複製程式碼

通過 key.currentState 獲取到 NavigatorState 例項。

我這裡簡單暴露了導航的 push 和 pushName 功能,你可以根據自己的功能來進行擴充套件。

註冊服務

現在就需要在容器中註冊這個服務,回到 service_locator.dart。

void setupLocator(){
  getIt.registerSingleton(NavigateService());
}
複製程式碼

通過呼叫 registerSingleton,我們在容器中註冊了一個單例模式使用的 NavigateService。之後我們所有需要註冊的 Service 都在這裡註冊一遍即可。

容器初始化

剛剛已經寫好了註冊函式,現在就需要在我們的 Flutter 應用執行時初始化一次,main 函式是一個不錯的選擇。

void main() {
  setupLocator();
  runApp(App());
}
複製程式碼

這樣在我們程式執行的時候就能夠把服務都初始化到容器中。

依賴注入

剛才我們說了,要想獲得 Navigator 需要在 MaterialApp 的 navigatorKey 繫結一個 GlobalKey。所以我們現在通過容器注入服務,來繫結這個 GlobalKey。

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: getIt<NavigateService>().key,
      routes: {'/ErrorScreen': (_) => ErrorScreen()},
      home: HomeScreen(),
    );
  }
}
複製程式碼

上面通過 getIt() 注入了 NavigateService 的依賴。這個 getIt 就是我們的全域性例項。

然後新增了一個命名路由。這裡我把 HomeScreen 和 ErrorScreen 的程式碼放在下面。

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(onPressed: () {
        getIt<NavigateService>().pushNamed('/ErrorScreen');
      }),
    );
  }
}

class ErrorScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      color: Colors.red,
      child: Text('Error'),
    );
  }
}
複製程式碼

在 HomeScreen 中點選一下 FloatingActionButton 就會通過注入的 NavigateService 跳轉到 ErrorScreen。

在進行跳轉時,我們可以看到並沒有使用 context。

getIt<NavigateService>().pushNamed('/ErrorScreen');

這樣你就可以在你想要的地方恰當的處理一些全域性導航操作了。它的一個巨大的好處在於你不僅可以在 Widget 中使用,而且可以在任何地方使用容器中的服務。

get_it 詳解

不同的註冊方式

GetIt 提供了多種註冊方式,這將會影響這些物件的生命週期。目前有三種:

  • 工廠模式:void registerFactory<T>(FactoryFunc<T> func) 每次都會返回新的例項。
  • 單例模式:void registerSingleton<T>(T instance) 每次返回同一例項。 這種模式需要手動初始化,就像我們上面例子中那樣。
  • 單例模式(懶載入): void registerLazySingleton<T>(FactoryFunc<T> func) 這種方式只有第一次注入依賴的時候,才會初始化服務,並且每次返回相同例項。

覆蓋註冊

如果你在容器中註冊了兩次同一服務的話,預設情況下會在除錯模式中得到一個斷言,就像下面這樣。

void setupLocator(){
  getIt.registerSingleton(NavigateService());
  getIt.registerSingleton(NavigateService());
}
複製程式碼

Failed assertion: line 53 pos 12: 'allowReassignment || !_factories.containsKey(T)': Type NavigateService is already registered

get_it 會認為你可能是寫錯了,所以提醒你這裡註冊了兩次相同服務。如果你真的必須覆蓋註冊,那麼你可以通過設定屬性 allowReassignment == true 來關閉此斷言。

重置容器

如果你想要重置所有容器,可以呼叫 reset() 方法。一般在做測試的時候會用到。

Q&A

ServiceLocator 與 Dependency Injection & Inversion of Control 的關係

我們在上面看到,當我們使用 ServiceLocator 之後,實現了控制反轉(Ioc)。服務不再由使用者建立,而是通過容器注入。這樣我們可以不再依賴於具體的實現,而是依賴於一層薄薄的的介面。這樣呼叫者不再知道服務具體實現細節,可以很輕鬆的使用 mock 資料進行替換。ServiceLocator 其實就是一種特殊的控制反轉。

Dependency Injection 實際上和 ServiceLocator 解決的是同樣的問題。但是它又與DI的實現原理上有所不同。由於 Flutter 為了減少打包後應用體積禁用了 dart 的反射包,所以你不知道神奇注入物件的來源,這樣一來大多數依賴於反射的 DI 包也就沒法用了。

獲取服務的效能

我們可以從 get_it 的原始碼中看到,這個 ServiceLocator 就是用一個 map 在儲存資料。

final _factories = new Map<Type, _ServiceFactory<dynamic>>();
複製程式碼

所以獲取服務的效能是 O(1)。

寫在最後

本文參考了以下資料:

感興趣的同學可以去閱讀一下大師的文章。

這次介紹的庫非常輕量,你可以很快速的上手它。這裡你可能會覺得它與 InheritWidget 有些相似。雖然都在解決模型依賴問題,get_it 不僅能夠在 Widget tree 中進行使用,而且能夠解決模型間的依賴問題。大家可以根據自己專案的情況來選擇使用。

如果文章中還存在任何問題還請老師指正!歡迎在下方評論區以及我的郵箱1652219550a@gmail.com 一起討論,我會及時回覆!

題外話,我的個人部落格也在同步連載中!歡迎各位光顧鴨 xinlei.dev/

相關文章