Flutter混合開發—iOS篇

chonglingliu發表於2021-05-14

很多情況下用Flutter來編寫整個專案是不太現實的。例如公司已經有了成熟的App產品了,去用Flutter去重寫整個專案會有很大的工作量和功能上的風險;有時候公司出於謹慎的原因,不可能去冒失的取採用新的技術,可能更願意去用一些次要的功能部分去試水,如果效果不錯才會繼續大面積使用。

我們可以將Flutter打包成模組(module)整合進入原生的iOSAndroid專案中實現上述需求。最開始Flutter只支援單個頁面,最近已經開始支援多個Flutter頁面,但是正如官方所說的其還是不太穩定,有各種莫名其妙的問題。如果不幸採坑,可以試著彎彎繞繞去解決哦,否則只能躺平了。

Note: Support for adding multiple instances of Flutter became available as of Flutter 2.0.0. Use at your own risk since stability or performance issues, and API changes are still possible.

專案介紹

本專案的例子是一個影音App,基於iOS專案搭建,包含三個Tab,每個Tab的內容是一個Flutter模組:

  • 首頁模組

首頁模組

  • 頻道模組

頻道模組

  • 我的模組

我的模組

說明:上面三個Flutter模組都是獨立的,但是首頁和頻道模組能進入影音詳情頁面,播放的時候記錄播放的歷史記錄,能夠點贊,這些播放歷史和點讚的資料在我的模組中顯示,會涉及到獨立的Flutter模組之間的資料共享。此外,看不到播放效果是因為播放器不支援iOS模擬器,真機上是可以播放的。

混合開發的實現過程

ios專案搭建

新建專案的具體過程就不介紹了。

我們基於CocoaPodStoryBoard搭建了一個首頁是UITabbarController的專案。然後新建了三個UIViewController---MainViewController, ChannelViewControllerMineViewController,他們將會分別嵌入對應的Flutter模組。

  • 專案的結構和Podfile內容

專案結構

  • Storyboard預覽

在這裡插入圖片描述

  • UIViewController中都沒有程式碼
class MainViewController: UIViewController {}
class ChannelViewController: UIViewController {}
class MineViewController: UIViewController {}
複製程式碼
  • 最後的效果

效果

Flutter模組的編寫

  • 建立一個Flutter模組---flutter_movie_player
cd /directory
flutter create --template module flutter_movie_player
複製程式碼

注意:Flutter專案和iOS專案最好是放在一個目錄中,並且層級相同。原因是iOS專案需要引用Flutter專案中的檔案和庫。

層級

  • 編寫Flutter程式碼

由於本文只是為了介紹混合開發的實現邏輯,所以不會去詳細介紹每個Flutter頁面是如何實現的,你自己練習時可以不修改任何程式碼,就用預設的那個Flutter計數器也是可以的。

我們接下來會介紹一些重要的入口相關的類:

  1. main.dart
<!-- 首頁模組入口 -->
@pragma('vm:entry-point')
void main() => runApp(MainApp());

<!-- 頻道模組入口 -->
@pragma('vm:entry-point')
void channel() => runApp(ChannelApp());

<!-- 我的模組入口 -->
@pragma('vm:entry-point')
void mine() => runApp(MineApp());

複製程式碼
  1. 我們定義了三個函式main,channelmine, 他們分別載入了MainApp(),ChannelApp()MineApp(),也可以直接理解為三個模組,他們是相互獨立的。
  2. @pragma('vm:entry-point')這個註解是為了避免Dart的搖樹優化(tree-shaking)將這裡定義的函式認定為無用程式碼給優化掉了。main函式可以不加這個註解,統一加上也無妨。
  1. main_page.dart

其實上述3個App()的入口程式碼是類似的,我們只以首頁模組的入口main_page.dart為例做說明。

class MainApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    
    return MaterialApp(
      title: "FBMovie首頁模組",
      theme: FBTheme.normalTheme,
      routes: FBRouter.routes,
      initialRoute: FBRouter.homePageInitialRoute,
      onGenerateRoute: FBRouter.generateRoute,
      debugShowCheckedModeBanner: false,
    );
  }
}

class FBMainPage extends StatefulWidget {
  // 路由的路徑
  static final String routeName = "/main";

  @override
  _FBMainPageState createState() => _FBMainPageState();
}

class _FBMainPageState extends State<FBMainPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: buildAppBar(),
      body: FBHomePage(),
    );
  }

  /// AppBar
  AppBar buildAppBar() {
    return AppBar(
      backgroundColor: Colors.white,
      brightness: Brightness.light,
      leadingWidth: 154.rpx,
      shadowColor: Colors.transparent,
      leading: null,
      actions: buildActions(),
      title: null,
    );
  }

  /// AppBar的actions
  List<Widget> buildActions() {
    return [
      GestureDetector(
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 36.rpx),
          child: Icon(
            Icons.search,
            color: FBTheme.redColor,
            size: 46.rpx,
          ),
        ),
        onTap: searchTapped,
      )
    ];
  }

  /// 搜尋按鈕的點選跳轉
  void searchTapped() {
    Navigator.of(context).pushNamed(FBSearchPage.routerName);
  }
  
}

複製程式碼

這個邏輯也很簡單,和普通的Flutter project 的程式碼沒有任何差別。MaterialApp -> Scaffold -> appBar + body(FBHomePage) -> 輪播圖+列表 -> ....

iOS專案引入Flutter模組

  • 修改podfile檔案
// 1. 找到flutter module 的目錄
flutter_application_path = '../../flutter_movie_player'
// 2. 找到flutter module 的目錄中的/.ios/Flutter/podhelper.rb檔案
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'FBMoviePlayer' do
  use_frameworks!
  // 3. 執行podhelper.rb中的install_all_flutter_pods方法
  install_all_flutter_pods(flutter_application_path)

end
複製程式碼

加的每行程式碼的邏輯意義在註釋中有說明。注意一點是flutter_application_path這個路徑別整錯了,否則就沒法繼續了。

  • 執行pod install

執行這個命令能將Flutter SDKFlutter 程式碼 引入到iOS專案中。

  • Appdelegate中定義一個FlutterEngineGroup物件
var engineGroup = FlutterEngineGroup(name: "fb-movie-player", project: nil)
複製程式碼

如果專案中有多個Flutter模組就需要使用FlutterEngineGroup, 它能管理多個FlutterEngine, 讓他們共享資源等功能。

繼續接下來的工作之前,我先介紹下實現思路:

我們這裡的設計思路是將三個載入不同Flutter APPFlutterViewControllerView放在MainViewController, ChannelViewControllerMineViewControllerView上。

這裡有的小夥伴可能會有疑問:為什麼不將MainViewController, ChannelViewControllerMineViewController直接定義為FlutterViewController的子類。

這裡我解釋下:UITabbarController的子ViewController幾乎是同時初始化的,如果他們都是FlutterViewController那麼會造成對FlutterEngineGroup共享資源的爭奪,這樣顯示會出現異常。這也是目前使用多個Flutter module會出現的一個問題。所以需要改變下思路,在需要使用的時候再進行FlutterViewController的初始化。其實這也有點問題,就是進行預載入比較難控制,每個Flutter module第一次載入的時候會有點慢。

介紹完實現方法後,繼續敲程式碼啦。

  • 定義一個FlutterViewController子類
class FBFlutterViewController: FlutterViewController {
    
    init(withEntrypoint entryPoint: String?) {
        let appDelegate: AppDelegate = UIApplication.shared.delegate as! AppDelegate
        // 1. 用Appdelegate中的FlutterEngineGroup生成一個FlutterEngine,引擎載入入口是main.dart的entrypoint函式
        let newEngine = appDelegate.engineGroup.makeEngine(withEntrypoint: entryPoint, libraryURI: nil)
        // 2. 用這個FlutterEngine初始化FlutterViewController
        super.init(engine: newEngine, nibName: nil, bundle: nil)
    }
    
    required convenience init(coder aDecoder: NSCoder) {
        self.init(withEntrypoint: nil)
    }
    
}
複製程式碼

自定義了一個FlutterViewController子類,這個子類會根據傳過來的entryPoint初始化一個FlutterEngine, 這個FlutterEngine的載入入口是main.dart檔案中的entrypoint函式,然後FlutterViewController子類持有這個FlutterEngine;

  • UIViewController載入FBFlutterViewController
class MainViewController: UIViewController {
    // 1. 懶載入 main.dart 中的main入口函式對應的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: nil)
    
    override func viewDidLoad() {
        // 2. 新增FlutterViewController
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
    
}
複製程式碼
  1. MainViewController載入main.dart 中的main入口函式對應的Flutter App, 對應的void main() => runApp(MainApp());的內容;
  2. 懶載入也是為了解決資源競爭的問題。
class ChannelViewController: UIViewController {
    // 懶載入 main.dart 中的channel入口函式對應的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: "channel")
    
    override func viewDidLoad() {
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
    
}
複製程式碼

對應的void channel() => runApp(ChannelApp());的內容

class MineViewController: UIViewController {
    // 懶載入 main.dart 中的mine入口函式對應的Flutter App
    private lazy var subFlutterVC: FBFlutterViewController = FBFlutterViewController(withEntrypoint: "mine")
    
    override func viewDidLoad() {
        addChild(subFlutterVC)
        let safeFrame = self.view.safeAreaLayoutGuide.layoutFrame
        subFlutterVC.view.frame = safeFrame
        self.view.addSubview(subFlutterVC.view)
        subFlutterVC.didMove(toParent: self)
    }
}
複製程式碼

對應的void mine() => runApp(MineApp()); 的內容

目前位置,三個Flutter module已經被整合到了我們的iOS專案中了,每個模組基本上能正常顯示。但是這個專案還有兩個問題需要我們來解決。

註冊外掛

我前面提到過,首頁模組頻道模組 中的播放歷史和點贊記錄是需要在 我的模組 中展示的。但是現在他們是獨立的,這就涉及到模組資料同步的問題。

這個同步的邏輯有一些通用的方式:

  1. 通過伺服器網路請求的方式;
  2. App進行記憶體儲存;
  3. APP進行檔案儲存;
  4. App進行資料庫儲存;

我們這裡用的是資料庫的儲存方式,但是Flutter的資料庫儲存是通過外掛來實現的,我們上面是沒有實現外掛的註冊,所以需要進行這方面的工作。

dependencies:
  flutter:
    sdk: flutter
  ...
  fijkplayer: ^0.8.7
  shared_preferences: 0.5.12+4
  sqflite: ^1.3.0
  url_launcher: ^5.7.10
複製程式碼

其實我們的Flutter專案中用到了這些外掛,都需要統一註冊Flutter Engine中。

  • 問題之一

問題

未註冊外掛,看不到觀看歷史和點贊

  • 解決方案:
// 1. 引入庫
import FlutterPluginRegistrant

class FBFlutterViewController: FlutterViewController {
    
    /// ...

    override func viewDidLoad() {
        super.viewDidLoad()
        // 2. 註冊外掛到FlutterEngine中
        GeneratedPluginRegistrant.register(with: self.pluginRegistry())
    }
    
}
複製程式碼
  • 實現效果

效果

註冊外掛,能看到觀看歷史和點贊

編寫外掛

當整合到專案中後肯定會遇到各種問題,這時候編寫外掛就是很常見的需求了。我們來看一下下面這個圖:

二級介面

我們看到從首頁進入到二級頁面,底下的TabBar沒有隱藏,這是不符合一般的設計邏輯的。但是最開始Flutter開發者可能並不瞭解這個問題,這時候就需要進行改程式碼了。

我們需要實現的邏輯就是當二級甚至更深層級的介面的時候需要隱藏TabBar,只有一級介面顯示TabBar

Flutter端修改

  • 封裝一個TabBar功能相關的外掛類TabBarController
class TabBarController {
  // 定義一個MethodChannel
  static final channel = const MethodChannel("fbmovie.com/tab_switch");

  /// 顯示tabbar
  static Future<int> showTab() async {
    final result = await channel.invokeMethod("showTab");
    return result ?? 0;
  }

  /// 隱藏tabbar
  static Future<int> hideTab() async {
    final result = await channel.invokeMethod("hideTab");
    return result ?? 0;
  }

}
複製程式碼

定義一個MethodChannel,然後定義了一個showTab和一個hideTab方法去呼叫原生程式碼。

  • 初始化一個路由監聽器
// 路由監聽器
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
複製程式碼
  • 監聽路由的變化
class MainApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      // ...省略
      // 1. MaterialApp 加上路由監聽器
      navigatorObservers: [routeObserver],
    );
    
  }
}

class FBMainPage extends StatefulWidget {
  
  @override
  _FBMainPageState createState() => _FBMainPageState();
}

// 2. 混入 RouteAware
class _FBMainPageState extends State<FBMainPage> with RouteAware {
  // ...省略
  
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 3. 訂閱路由監聽器
    routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    // 4. 取消訂閱路由監聽器
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  void didPopNext() {
    // 5. 返回到當前頁面
    TabBarController.showTab();
  }

  void didPushNext() {
    // 6. 跳轉到下一個頁面
    TabBarController.hideTab();
  }
  
}
複製程式碼

當訂閱路由監聽器後,FBMainPage跳轉到其他頁面時會呼叫didPushNext,此時通知Native程式碼隱藏TabBar,當其他頁面跳轉回FBMainPage時,此時通知Native程式碼顯示TabBar

iOS端修改

class FBFlutterViewController: FlutterViewController {

    private var channel: FlutterMethodChannel?
    
    override func viewDidLoad() {
        // ...省略
        // 1. 生成FlutterMethodChannel
        channel = FlutterMethodChannel(
            name: "fbmovie.com/tab_switch", binaryMessenger: self.engine!.binaryMessenger)
        // 2. 註冊回撥方法    
        channel!.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
          if call.method == "showTab" {
            // 3. 顯示TabBar
            self?.showTab()
          } else if call.method == "hideTab" {
          // 3. 隱藏TabBar
            self?.hideTab()
          } else {
            result(FlutterMethodNotImplemented)
          }
        }
    }
    
    /// 顯示TabBar 
    func showTab() {
        self.parent?.tabBarController?.tabBar.isHidden = false
    }
    /// 隱藏TabBar
    func hideTab() {
        self.parent?.tabBarController?.tabBar.isHidden = true
    }
    
}
複製程式碼

iOS 端主要就是在FlutterViewController中初始化FlutterMethodChannel,監聽Flutter端的呼叫,然後去控制UITabbarController

效果如下:

效果圖

總結

Flutter多模組整合還有一些待完善的地方,但是整體上來說Flutter混入原生還是很不錯的一個方式。由於自己的渲染閉環效率,做出來的效果還是不錯的。

本文介紹了Flutter混合iOS專案的實現方式,下節我們將繼續來介紹Flutter混入Android專案。

相關文章