淺析 Flutter 與 iOS 的檢視橋樑

大頭兄弟技術團隊發表於2020-08-06

PlatformView 提供了在 Flutter 的 Widget 層級中嵌入原生檢視(iOS/Android等), PlatformView 在用來描述 iOS 平臺是檢視用的是 UIKitView,Android 平臺的檢視是 AndoirdView,本文所有描述都是針對 iOS 平臺,按官方的描述該功能還是在釋出預覽階段,並且是非常昂貴的操作;以下是官方 API 文件原文註釋:

Embedding UIViews is still in release preview, to enable the preview for an iOS app add a boolean field with the key 'io.flutter.embedded_views_preview' and the value set to 'YES' to the application's Info.plist file. A list of open issued with embedding UIViews is available on Github. Embedding iOS views is an expensive operation and should be avoided when a Flutter equivalent is possible.

場景

每個技術點的出現必然有它的價值所在,所以即便 PlatfromView 目前存在一些問題,並且 Flutter 本身就是一個 UI 框架,一些業務場景下只能依賴於它完成,例如:地圖、原生廣告、WebView等等;所以 Flutter 開發者還是得點亮 PlatformView 技能樹;

問題

在 Flutter1.12 版本中遇到過在 PageView、ListView 等容器檢視中將 PlatformView 移動到螢幕外,並且 Widget 沒銷燬的場景會引起引擎崩潰,由於問題出在 Flutter 引擎內部,遇到問題的時候可以做這三件事:

  1. Flutter GitHub 倉庫提 issue,等待官方解決;
  2. 定製引擎,編譯 Flutter 引擎找到問題並解決;
  3. 曲線規避問題發生場景;

當然在業務迭代中通常優先選擇第三點曲線規避當前問題,然後給官方提 issue,定製引擎這個選項最好在有足夠把握的時候選擇,不嚴謹的改動可能會引起一系列問題;

使用流程

需求:建立一個可以將黃色的 UIView 顯示到視窗的外掛;

1. 建立 Flutter 外掛

建立外掛可以通過命令列生成外掛模板工程, 工程名只能用小寫:

flutter create --template=plugin -i objc -a java platform_view

這裡建立的是 iOS 端使用 OC 語言 Android 端使用 Java 語言的外掛,建立成功後可以看到這樣的目錄結構:

2.封裝 UIKitView

在 lib 目錄下建立 color_view.dart 存放 UIKitView的一些操作,Flutter 可以利用平臺通道 MethodChannel 與原生平臺進行資料互動,方法呼叫在傳送之前被編碼為二進位制,接收到的二進位制結果被解碼為Dart值。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

const String singleColor = "singleColor";

class ColorView extends StatefulWidget {
  @override
  _ColorViewState createState() => _ColorViewState();
}

class _ColorViewState extends State<ColorView> {
  /// 平臺通道,訊息使用平臺通道在客戶端(UI)和宿主(平臺)之間傳遞
  MethodChannel _channel;

  @override
  Widget build(BuildContext context) {
    return UiKitView(
      // 檢視型別,作為唯一識別符號
      viewType: singleColor,
      // 建立引數:將會傳遞給 iOS 端側, 可以傳遞任意型別引數
      creationParams: "yellow",
      // 用於將creationParams編碼後再傳送到平臺端。
      // 這裡使用Flutter標準二進位制編碼
      creationParamsCodec: StandardMessageCodec(),
      // 原生檢視建立回撥
      onPlatformViewCreated: _onPlatformViewCreated,
    );
  }

  /// 原生檢視建立回撥操作
  /// id 是原生檢視唯一識別符號
  void _onPlatformViewCreated(int id) {
    // 每個 id 對應建立唯一的平臺通道
    _channel = MethodChannel('singleColor_$id');
    // 設定平臺通道的響應函式
    _channel.setMethodCallHandler(_handleMethod);
  }

  /// 平臺通道的響應函式
  Future<void> _handleMethod(MethodCall call) async {
    /// 檢視沒被裝載的情況不響應操作
    if (!mounted) {
      return Future.value();
    }
    switch (call.method) {
      default:
        throw UnsupportedError("Unrecognized method");
    }
  }
}

3.新增 iOS 平臺程式碼

使用 Xcode 編輯 iOS 平臺程式碼之前,首先確保程式碼至少被構建過一次,即從 IDE/編輯器執行示例程式,或在終端中執行以下命令:

cd platform_view/example; flutter build ios --debug --no-codesign

開啟 Platform_view/example/ios/Runner.xcworkspace iOS 工程,外掛的 iOS 平臺程式碼位於專案導航中的這個位置:

Pods/Development Pods/platform_view/../../example/ios/.symlinks/plugins/platform_view/ios/Classes

PlatformViewPlugin

此檔案建立外掛工程時生成的,在程式啟動的時候會將 AppDeleage 註冊進來, 這裡的 AppDeleage 繼承自 FlutterAppDelegate 遵守了 FlutterPluginRegistry, FlutterAppLifeCycleProvider 協議,前者為了提供應用程式上下文和註冊回撥的方法,後者為了方便後續在外掛中獲取應用生命週期事件;

#import "PlatformViewPlugin.h"
#import "PlatfromViewFactory.h"

@implementation PlatformViewPlugin

/// 註冊外掛
/// @param registrar 提供應用程式上下文和註冊回撥的方法
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
    // 註冊檢視工廠
    // 繫結工廠唯一識別符號這裡與 Flutter UIKitView 所使用 viewType 一致
    [registrar registerViewFactory:[[PlatfromViewFactory alloc] initWithMessenger:[registrar messenger]]
                            withId:@"singleColor"];
}

@end

PlatfromViewFactory

#import <Foundation/Foundation.h>
#import <Flutter/Flutter.h>

NS_ASSUME_NONNULL_BEGIN

@interface PlatfromViewFactory : NSObject<FlutterPlatformViewFactory>

/// 初始化檢視工廠
/// @param messager 用於與 Flutter 傳輸二進位制訊息通訊
- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager;

@end

NS_ASSUME_NONNULL_END
#import "PlatfromViewFactory.h"
#import "PlatformView.h"

@interface PlatfromViewFactory ()

/// 用於與 Flutter 傳輸二進位制訊息通訊
@property (nonatomic, strong) NSObject<FlutterBinaryMessenger> *messenger;

@end

@implementation PlatfromViewFactory

- (instancetype)initWithMessenger:(NSObject<FlutterBinaryMessenger> *)messager {
    self = [super init];
    if (self) {
        self.messenger = messager;
    }
    return self;
}

#pragma mark - FlutterPlatformViewFactory

/// 建立一個“FlutterPlatformView”
/// 由iOS程式碼實現,該程式碼公開了一個用於嵌入Flutter應用程式的“UIView”。
/// 這個方法的實現應該建立一個新的“UIView”並返回它。
/// @param frame Flutter通過其佈局widget來計算得來
/// @param viewId 檢視的唯一識別符號,建立一個 UIKitView 該值會+1
/// @param args 對應Flutter 端UIKitView的creationParams引數
- (nonnull NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame
                                            viewIdentifier:(int64_t)viewId
                                                 arguments:(id _Nullable)args {
    PlatformView *platformView = [[PlatformView alloc] initWithWithFrame:frame
                                                          viewIdentifier:viewId
                                                               arguments:args
                                                         binaryMessenger:self.messenger];
    return platformView;
}

/// 使用Flutter標準二進位制編碼
- (NSObject<FlutterMessageCodec> *)createArgsCodec {
    return [FlutterStandardMessageCodec sharedInstance];
}

@end

Flutter 端 UIKitView 的 viewType 與 工廠 ID 相同才能建立關聯,工廠的核心方法 createWithFrame,這裡三個引數都是由 Flutter 端傳遞過來的,UIKitView 的大小是由父 Widget 決定的,frame也就是 Flutter 通過其佈局 widget 來計算得來, viewId 是建立一個 UIKitView 該值會+1,並且是唯一的,args 對應 Flutter端 UIKitView 的 creationParams 引數;

PlatformView

PlatformView 繼承自 FlutterPlatformView 協議,工廠呼叫 PlatformView 物件來建立真正的 view 例項:

#import <Foundation/Foundation.h>
#import <Flutter/Flutter.h>

NS_ASSUME_NONNULL_BEGIN

@interface PlatformView : NSObject<FlutterPlatformView>

- (instancetype)initWithWithFrame:(CGRect)frame
                   viewIdentifier:(int64_t)viewId
                        arguments:(id _Nullable)args
                  binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger;

@end

NS_ASSUME_NONNULL_END
#import "PlatformView.h"

@interface PlatformView ()

/// 檢視
@property (nonatomic, strong) UIView *yellowView;

/// 平臺通道
@property (nonatomic, strong) FlutterMethodChannel *channel;

@end

@implementation PlatformView

- (instancetype)initWithWithFrame:(CGRect)frame
                   viewIdentifier:(int64_t)viewId
                        arguments:(id _Nullable)args
                  binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger {
    if ([super init]) {
        /// 初始化檢視
        self.yellowView = [[UIView alloc] init];
        self.yellowView.backgroundColor = UIColor.yellowColor;

        /// 這裡的channelName是和Flutter 建立MethodChannel時的名字保持一致的,保證一個原生檢視有一個平臺通道傳遞訊息
        NSString *channelName = [NSString stringWithFormat:@"singleColor_%lld", viewId];
        self.channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger];
        // 處理 Flutter 傳送的訊息事件
        [self.channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
            if ([call.method isEqualToString:@""]) {

            }
        }];
    }
    return self;
}

#pragma mark - FlutterPlatformView
/// 返回真正的檢視
- (UIView *)view {
    return self.yellowView;
}

@end

4.使用

在 example工程中的 lib/main.dart 中使用封裝好的 ColorView:

import 'package:flutter/material.dart';
import 'package:platform_view/color_view.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('PlatformView Plugin'),
        ),
        body: Center(
          // 由於原生檢視的大小由父 Widget 決定,
          // 這裡新增 Container 作為父 Widget 並設定寬高為 100
          child: Container(
            width: 100.0,
            height: 100.0,
            child: ColorView(),
          ),
        ),
      ),
    );
  }
}

5.開啟嵌入原生檢視功能

由於嵌入 UIViews 仍在版本預覽中,預設此功能是關閉的,需要在 info.pilst 進行配置,開啟嵌入原生檢視:

<key>io.flutter.embedded_views_preview</key>
<true/>

6.執行結果

寬高各 100 的黃色 UIView 就顯示出來了,這裡只是舉了個最簡單的場景,可以根據業務需求定製和原生平臺的互動。

原始碼解析

1.原生檢視功能開關

剛剛我們執行應用前在 info.plist 配置了開啟原生檢視預覽,可以看到原始碼中獲取了開啟狀態,在沒開啟的時候返回 nullptr ,嵌入式檢視要求 GPU 和平臺檢視的執行緒相同,即主執行緒;不開啟則是由 GPU 執行緒繪製畫布上的 UI;

// The name of the Info.plist flag to enable the embedded iOS views preview.
const char* const kEmbeddedViewsPreview = "io.flutter.embedded_views_preview";

bool IsIosEmbeddedViewsPreviewEnabled() {
  return [[[NSBundle mainBundle] objectForInfoDictionaryKey:@(kEmbeddedViewsPreview)] boolValue];
}
ExternalViewEmbedder* IOSSurfaceSoftware::GetExternalViewEmbedder() {
  if (IsIosEmbeddedViewsPreviewEnabled()) {
    return this;
  } else {
    return nullptr;
  }
}
if (flutter::IsIosEmbeddedViewsPreviewEnabled()) {
    // Embedded views requires the gpu and the platform views to be the same.
    // The plan is to eventually dynamically merge the threads when there's a
    // platform view in the layer tree.
    // For now we use a fixed thread configuration with the same thread used as the
    // gpu and platform task runner.
    // TODO(amirh/chinmaygarde): remove this, and dynamically change the thread configuration.
    // https://github.com/flutter/flutter/issues/23975

    flutter::TaskRunners task_runners(threadLabel.UTF8String,                          // label
                                      fml::MessageLoop::GetCurrent().GetTaskRunner(),  // platform
                                      fml::MessageLoop::GetCurrent().GetTaskRunner(),  // gpu
                                      _threadHost.ui_thread->GetTaskRunner(),          // ui
                                      _threadHost.io_thread->GetTaskRunner()           // io
    );
    // Create the shell. This is a blocking operation.
    _shell = flutter::Shell::Create(std::move(task_runners),  // task runners
                                    std::move(settings),      // settings
                                    on_create_platform_view,  // platform view creation
                                    on_create_rasterizer      // rasterzier creation
    );
  } else {
    flutter::TaskRunners task_runners(threadLabel.UTF8String,                          // label
                                      fml::MessageLoop::GetCurrent().GetTaskRunner(),  // platform
                                      _threadHost.gpu_thread->GetTaskRunner(),         // gpu
                                      _threadHost.ui_thread->GetTaskRunner(),          // ui
                                      _threadHost.io_thread->GetTaskRunner()           // io
    );
    // Create the shell. This is a blocking operation.
    _shell = flutter::Shell::Create(std::move(task_runners),  // task runners
                                    std::move(settings),      // settings
                                    on_create_platform_view,  // platform view creation
                                    on_create_rasterizer      // rasterzier creation
    );
  }

2.建立流程

接著來看看 UIKitView 建立後是怎麼到 iOS 端側的:

  • 點進 UIKitView 原始碼可以看到時一個 StafulWidget,接著看看它的 State 裡面實現;

getNextPlatformViewId實際上的操作是內部記錄了 viewId 的值,每次呼叫後+1;int getNextPlatformViewId() => _nextPlatformViewId++;後面的 UiKitViewController 看起來就是核心控制層了;

  • 可以看到 Flutter 封裝了內部使用的 platform_views 平臺通道,傳送了 create 事件;Flutter 的 framwork 層, 在原生檢視的事件響應中呼叫了 OnCreate 方法;

  • 最後我們來看下 OnCreate 方法,程式碼中擷取了部分主要流程:
void FlutterPlatformViewsController::OnCreate(FlutterMethodCall* call, FlutterResult& result) {
  ...
  NSDictionary<NSString*, id>* args = [call arguments];
  // 獲取 viewid
  long viewId = [args[@"id"] longValue];
  // 獲取 viewType
  std::string viewType([args[@"viewType"] UTF8String]);
  ...
  // 通過 viewType 獲取檢視工廠
  NSObject<FlutterPlatformViewFactory>* factory = factories_[viewType].get();
  ...
  id params = nil;
  // 解碼引數
  if ([factory respondsToSelector:@selector(createArgsCodec)]) {
    NSObject<FlutterMessageCodec>* codec = [factory createArgsCodec];
    if (codec != nil && args[@"params"] != nil) {
      FlutterStandardTypedData* paramsData = args[@"params"];
      params = [codec decode:paramsData.data];
    }
  }
  // 通過檢視工廠建立嵌入檢視
  NSObject<FlutterPlatformView>* embedded_view = [factory createWithFrame:CGRectZero
                                                           viewIdentifier:viewId
                                                                arguments:params];
  views_[viewId] = fml::scoped_nsobject<NSObject<FlutterPlatformView>>([embedded_view retain]);

  // 將嵌入檢視新增到FlutterTouchInterceptingView中,
  // FlutterTouchInterceptingView主要負責處理手勢轉發和拒絕部分手勢,
  FlutterTouchInterceptingView* touch_interceptor = [[[FlutterTouchInterceptingView alloc]
       initWithEmbeddedView:embedded_view.view
      flutterViewController:flutter_view_controller_.get()] autorelease];

  // 儲存檢視
  touch_interceptors_[viewId] =
      fml::scoped_nsobject<FlutterTouchInterceptingView>([touch_interceptor retain]);
  root_views_[viewId] = fml::scoped_nsobject<UIView>([touch_interceptor retain]);

  result(nil);
}

3. 檢視分析

在建立檢視流程中引擎還預設新增了 FlutterOverlayView,目的是防止原生檢視遮擋 Flutter 檢視,原生檢視層級之上 Flutter 檢視都會繪製在 FlutterOverlayView 上,同一層級的檢視還是繪製在 FlutterView 上面,這裡 FlutterView 和 FlutterOverlayView 都是 CAEAGLLayer,用於渲染 Flutter 檢視。

參考連結

  1. Flutter Packages 的開發和提交
  2. 撰寫雙端平臺程式碼(外掛編寫實現)
  3. UiKitView api 文件
  4. Github Flutter Engine

相關文章