Flutter混合開發的路由棧管理

xiangzhihong發表於2020-01-27

為了把 Flutter 引入到原生工程,我們需要把 Flutter 工程改造為原生工程的一個元件依賴,並以元件化的方式管理不同平臺的 Flutter 構建產物,即 Android 平臺使用 aar、iOS 平臺使用 pod 進行依賴管理。這樣,我們就可以在 Android 工程中通過 FlutterView,iOS 工程中通過 FlutterViewController,為 Flutter 搭建應用入口,實現 Flutter 與原生的混合開發方式。

對於混合開發的應用而言,通常我們只會將應用的部分模組修改成 Flutter 開發,其他模組繼續保留原生開發,因此應用內除了 Flutter 的頁面之外,還會有原生 Android、iOS 的頁面。在這種情況下,Flutter 頁面有可能會需要跳轉到原生頁面,而原生頁面也可能會需要跳轉到 Flutter 頁面。這就涉及到了一個新的問題:如何統一管理原生頁面和 Flutter 頁面跳轉互動的混合導航棧。

混合導航棧

混合導航棧,指的是在混合開發中原生頁面和Flutter頁面相互摻雜,存在於使用者視角的頁面導航棧檢視,如圖11-12所示。在混合開發的應用中,原生Android、iOS與Flutter各自實現了一套互不相同的頁面對映機制,原生平臺採用的是單容器單頁面,即一個ViewController或Activity對應一個原生頁面;而Flutter採用單容器多頁面的機制,即一個ViewController或Activity對應多個Flutter頁面。Flutter在原生的導航棧之上又自建了一套Flutter導航棧,這使得原生頁面與Flutter頁面與之間進行頁面切換時,需要處理跨引擎的頁面切換問題。

在這裡插入圖片描述
接下來,我們就分別從原生頁面跳轉至 Flutter 頁面,以及從 Flutter 頁面跳轉至原生頁面來看看混合開發的路由管理。

原生頁面跳轉Flutter頁面

從原生頁面跳轉至 Flutter 頁面,實現起來比較簡單。因為 Flutter 本身依託於原生提供的容器,即iOS 使用的是FlutterViewController,Android 使用的是Activity 中的 FlutterView。所以我們通過初始化 Flutter 容器,為其設定初始路由頁面之後,就可以以原生的方式跳轉至 Flutter 頁面了。

對於iOS混合工程來說,可以先初始化一個FlutterViewController例項,然後設定初始化頁面路由,將其加入原生的檢視導航棧中即可完成跳轉,如下所示。

//iOS 跳轉至Flutter頁面
FlutterViewController *vc = [[FlutterViewController alloc] init];
//設定Flutter初始化路由頁面
[vc setInitialRoute:@"defaultPage"];
//完成頁面跳轉
[self.navigationController pushViewController:vc animated:YES]; 
複製程式碼

對於Android混合工程而言,則需要多加一步。因為Flutter頁面的入口並不是原生檢視導航棧的最小單位Activity,而是一個FlutterView,所以我們需要把這個View包裝到Activity的contentView中,然後才能實現跳轉。在Activity內部設定頁面初始化路由之後,在外部就可以採用開啟一個普通的原生檢視的方式來開啟Flutter頁面了,如下所示。

//Android 跳轉至Flutter頁面
//建立一個作為Flutter頁面容器的Activity
public class FlutterHomeActivity extends AppCompatActivity {
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    //設定Flutter初始化路由頁面,傳入路由識別符號
    View FlutterView = Flutter.createView(this, getLifecycle(), "defaultRoute"); 
    //用FlutterView替代Activity的ContentView
    setContentView(FlutterView);
  }
}
//用FlutterPageActivity完成頁面跳轉
Intent intent = new Intent(MainActivity.this, FlutterHomeActivity.class);
startActivity(intent);
複製程式碼

執行專案程式碼,最終的效果下圖所示。

在這裡插入圖片描述
對於Android混合工程來說,Flutter的原生容器就是一個Activity,只需要建立一個FlutterView,然後利用addContentView()方法將當前頁面的layout頁面佈局新增進去即可。如果Flutter的原生容器是一個Fragment,那麼只需要建立一個FlutterFragment,然後在指定的容器中新增Flutter頁面即可。同樣,對於iOS混合工程來說,Flutter的原生容器是一個FlutterViewController。

Flutter 頁面跳轉至原生頁面

相比原生頁面跳轉Flutter頁面,從Flutter頁面跳轉至原生頁面則會相對麻煩些。因為我們需要考慮以下兩種場景,即從Flutter頁面開啟新的原生頁面和從Flutter頁面回退到舊的原生頁面。 由於Flutter並沒有提供對原生頁面的操作方法,所以不能通過直接呼叫原生平臺的方法來實現頁面跳轉,不過可以使用Flutter提供的方法通道來間接實現,即開啟原生頁面使用的是openNativePage()方法,需要關閉Flutter頁面時則呼叫closeFlutterPage()方法。 具體來說,在Flutter和原生兩端各自初始化方法通道,並提供Flutter操作原生頁面的方法,並在原生程式碼中註冊方法通道,當原生端收到Flutter的方法呼叫時就可以開啟新的原生頁面。 在混合開發的應用中,FlutterView與FlutterViewController是Flutter模組的入口,也是Flutter模組初始化的地方。可以看到,在混合開發的應用中接入Flutter與開發一個純Flutter應用在執行機制上並無任何區別,因為對於混合工程來說,原生工程只不過是為Flutter提供了一個容器而已,即Android使用的是FlutterView,iOS使用的是FlutterViewController。接下來,Flutter模組就可以使用自己的導航棧來管理Flutter頁面,並且可以實現多個複雜頁面的渲染和切換。 因為Flutter容器本身屬於原生導航棧的一部分,所以當Flutter容器內的根頁面需要返回時,開發者需要處理Flutter容器的關閉問題,從而實現Flutter根頁面的關閉。由於Flutter並沒有提供操作Flutter容器的方法,因此我們依然需要通過方法通道,在原生程式碼宿主為Flutter提供操作Flutter容器的方法,在頁面返回時關閉Flutter頁面。如圖下圖所示,是Flutter跳轉原生頁面的兩種場景的示意圖。

在這裡插入圖片描述
使用方法通道實現Flutter頁面至原生頁面的跳轉,註冊方法通道最合適的地方是Flutter應用的入口,即在iOS端的FlutterViewController和Android端的是FlutterView初始化Flutter頁面之前。因此,在混合開發的應用中,需要分別繼承iOS的FlutterViewController和Android的AppCompatActivity,然後在iOS的viewDidLoad和Android的onCreate生命週期函式中初始化Flutter容器時,註冊openNativePage和closeFlutterPage兩個方法。 下面是使用方法通道實現Flutter跳轉原生頁面的原生Android端的程式碼,如下所示。

public class FlutterModuleActivity extends AppCompatActivity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
//初始化Flutter容器
        FlutterView fv = Flutter.createView(this, getLifecycle(), "defaultPage");  
        //註冊方法通道  
        new MethodChannel(fv, "com.xzh/navigation").setMethodCallHandler(
            new MethodChannel.MethodCallHandler() {
               @Override
               public void onMethodCall(MethodCall call, Result result) {
               if (call.method.equals("openNativePage")) {
                 Intent intent = new Intent(this, AndroidNativeActivity.class);
                 tartActivity(intent);
                 result.success(0);
               } else if (call.method.equals("closeFlutterPage")) {
                 finish();
                 result.success(0);
           } else {
                result.notImplemented();
              }
           }
      });
    setContentView(fv);
   }
}

複製程式碼

可以發現,在上面的程式碼中,首先使用FlutterView初始化一個Flutter容器,然後在原生程式碼中註冊openNativePage和closeFlutterPage兩個方法,當Flutter頁面通過方法通道呼叫原生方法時即可開啟原生頁面。 與原生Android端的實現原理類似,使用方法通道實現頁面的跳轉頁需要在原生iOS端中註冊openNativePage和closeFlutterPage兩個方法,程式碼如下。

@interface FlutterHomeViewController : FlutterViewController
@end

@implementation FlutterHomeViewController
- (void)viewDidLoad {
[super viewDidLoad];
//宣告方法通道
    FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"com.xzh/navigation" binaryMessenger:self];
    [channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
        if([call.method isEqualToString:@"openNativePage"]) {
//開啟一個新的原生頁面
            iOSNativeViewController *vc = [[iOSNativeViewController alloc] init];
            [self.navigationController pushViewController:vc animated:YES];
            result(@0);
        }else if([call.method isEqualToString:@"closeFlutterPage"]) {
            //關閉Flutter頁面
            [self.navigationController popViewControllerAnimated:YES];
            result(@0);
        }else {
            result(FlutterMethodNotImplemented);
        }
    }];
}
@end

複製程式碼

經過上面的方法註冊後,接下來就可以在Flutter中使用openNativePage()方法來開啟原生頁面了,如下所示。


void main() => runApp(_widgetForRoute(window.defaultRouteName));
//獲取方法通道
const platform = MethodChannel('com.xzh/navigation'); 

//根據路由識別符號返回應用入口檢視
Widget _widgetForRoute(String route) {
  switch (route) {
    default://返回預設檢視
      return MaterialApp(home:DefaultPage());
  }
}

class PageA extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
            body: RaisedButton(
                    child: Text("Go PageB"),
                    onPressed: ()=>platform.invokeMethod('openNativePage')//開啟原生頁面
            ));
  }
}

class DefaultPage extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: Text("DefaultPage Page"),
            leading: IconButton(icon:Icon(Icons.arrow_back), onPressed:() => platform.invokeMethod('closeFlutterPage')//關閉Flutter頁面
        )),
        body: RaisedButton(
                  child: Text("Go PageA"),
                  onPressed: ()=>Navigator.push(context, MaterialPageRoute(builder: (context) => PageA())),//開啟Flutter頁面 PageA
        ));
  }
}

複製程式碼

在上面的例子中,Flutter 容器的根檢視 DefaultPage 包含有兩個按鈕。點選左上角的按鈕後,可以通過 closeFlutterPage 返回原生頁面;點選中間的按鈕後,會開啟一個新的 Flutter 頁面 PageA。PageA 中也有一個按鈕,點選這個按鈕之後會呼叫 openNativePage 來開啟一個新的原生頁面。 整個混合導航棧示例的程式碼流程,如下圖所示。通過這張圖,你就可以把這個示例的整個程式碼流程串起來了。

在這裡插入圖片描述
在混合應用工程中,RootViewController 與 MainActivity 分別是 iOS 和 Android 應用的原生頁面入口,可以初始化為 Flutter 容器的 FlutterHomeViewController(iOS 端)與 FlutterHomeActivity(Android 端)。

在為其設定初始路由頁面 DefaultPage 之後,就可以以原生的方式跳轉至 Flutter 頁面。但是,Flutter 並未提供介面,來支援從 Flutter 的 DefaultPage 頁面返回到原生頁面,因此我們需要利用方法通道來註冊關閉 Flutter 容器的方法,即 closeFlutterPage,讓 Flutter 容器接收到這個方法呼叫時關閉自身。

在 Flutter 容器內部,我們可以使用 Flutter 內部的頁面路由機制,通過 Navigator.push 方法,完成從 DefaultPage 到 PageA 的頁面跳轉;而當我們想從 Flutter 的 PageA 頁面跳轉到原生頁面時,因為涉及到跨引擎的頁面路由,所以我們仍然需要利用方法通道來註冊開啟原生頁面的方法,即 openNativePage,讓 Flutter 容器接收到這個方法呼叫時,在原生程式碼宿主完成原生頁面 SomeOtherNativeViewController(iOS 端)與 SomeNativePageActivity(Android 端)的初始化,並最終完成頁面跳轉。

總結

對於原生 Android、iOS 工程混編 Flutter 開發,由於應用中會同時存在 Android、iOS 和 Flutter 頁面,所以我們需要妥善處理跨渲染引擎的頁面跳轉,解決原生頁面如何切換 Flutter 頁面,以及 Flutter 頁面如何切換到原生頁面的問題。

在原生頁面切換到 Flutter 頁面時,我們通常會將 Flutter 容器封裝成一個獨立的 ViewController(iOS 端)或 Activity(Android 端),在為其設定好 Flutter 容器的頁面初始化路由(即根檢視)後,原生的程式碼就可以按照開啟一個普通的原生頁面的方式來開啟 Flutter 頁面了。

而如果我們想在 Flutter 頁面跳轉到原生頁面,則需要同時處理好開啟新的原生頁面,以及關閉自身回退到老的原生頁面兩種場景。在這兩種場景下,我們都需要利用方法通道來註冊相應的處理方法,從而在原生程式碼宿主實現新頁面的開啟和 Flutter 容器的關閉。

需要注意的是,與純 Flutter 應用不同,原生應用混編 Flutter 由於涉及到原生頁面與 Flutter 頁面之間切換,因此導航棧內可能會出現多個 Flutter 容器的情況,即多個 Flutter 例項。Flutter 例項的初始化成本非常高昂,每啟動一個 Flutter 例項,就會建立一套新的渲染機制,即 Flutter Engine,以及底層的 Isolate。而這些例項之間的記憶體是不互相共享的,會帶來較大的系統資源消耗。

為了解決混編工程中 Flutter 多例項的問題,業界有兩種解決方案:

  • 以今日頭條為代表的修改 Flutter Engine 原始碼,使多 FlutterView 例項對應的多 Flutter Engine 能夠在底層共享 Isolate;
  • 以閒魚為代表的共享 FlutterView,即由原生層驅動 Flutter 層渲染內容的方案。

不過,目前這兩種解決方案都不夠完美。所以,在 Flutter 官方支援多例項單引擎之前,應該儘量使用Flutter去開發一些閉環業務,減少原生頁面與Flutter頁面之間的互動,儘量避免Flutter頁面跳轉到原生頁面,原生頁面又啟動一個新的Flutter例項的情況,並且保證應用內不要出現多個 Flutter 容器例項的情況。

相關文章