likes
comments
collection
share

Flutter和原生混编-两种方案结合使混编更轻松

作者站长头像
站长
· 阅读数 6

最近公司产品想要实践下和flutter混编,也就是基于老的原生APP项目,引入flutter进行混编,这样新的功能就可以使用flutter进行开发,可以节省成本。我负责了该项目,对不同的混编方案进行了了解,最后将自己采取的方案在这里介绍一下[注:此方案我们已进行实际开发并发布],如果大家的项目有混编需求,希望对大家有一定的借鉴意义。

一、混编方案

1.1 三端统一方案

这种方案的项目结构为三端都在都一个文件夹项目中:

-- iOS项目

-- 安卓项目

-- flutter项目

缺点:
  • 三端放在同一个目录,对现有的原生开发项目影响较大,
  • 所有人都需要装上flutter环境且版本要一致,不利于团队开发,
优点:
  • 但在自己开发时候这种可以及时进行flutter attach进行联调,这时候显得非常有必要,所以这种模式适合在开发阶段使用。

1.2 三端分离方案

使用三端分离的模式 三端分离,iOS和安卓原生项目保持不变,创建一个flutter项目用于编写flutter端的代码,然后使用脚本将flutter编译,iOS通过pod引用flutter编译后的framework,然后将生成物放在私有库中供原生调用,安卓端将flutter项目打成aar进行引用。

缺点:
  • 在开发阶段不利于联调,修改或新写一些代码后,需要打包等一系列操作后才能看到效果,效率低。
优点:
  • 这种模式适用于在老项目基础上进行混编引入flutter项目,对老项目侵入性小,
  • 适合团队开发

1.3 采用的方案

综合两种方案的优缺点,最终我们决定采用两种方案结合的方案,即在开发阶段采取三端统一的方案,这样开发中方便进行联调,在发布阶段采用三端分离的方案,利于维护和团队开发。 具体的切换也不麻烦,已iOS为例: 1、创建的flutter端和原生项目放在同一个文件夹; 2、切换不同的方案只需要在podfile中间中切换即可,开发阶段引用本地的flutter端,发布阶段引用私有库的flutter打包生成物。 具体代码可参考2.2中代码。

二、混编实现

这里以iOS端为例详细介绍下混编的具体细节。

2.1 flutter端打包

Flutter项目打包我使用的是脚本,将flutter项目达成framework,然后将这些framework放到公司的私有库中,iOS端就可以通过pod进行引用。

Flutter和原生混编-两种方案结合使混编更轻松

2.1.1 打包脚本

通过图可以看到 build_ios_output.sh 即为打包的脚本,打出来的framework放在 build_for_ios文件夹中。

打包脚本内容:

#前提flutter一定要是app项目: pubspec.yaml里 不要加
#module:
#  androidPackage: com.example.myflutter
#  iosBundleIdentifier: com.example.myFlutter

echo "Clean old build"
find . -d -name "build" | xargs rm -rf
flutter clean
echo "开始获取 packages 插件资源"
flutter packages get

echo "开始构建 build for ios 默认为release,debug需要到脚本改为debug"
#flutter build ios --debug
# release下放开下一行注释,注释掉上一行代码
flutter build ios --release --no-codesign
echo "构建 release 已完成"
echo "开始 处理framework和资源文件"

rm -rf build_for_ios
mkdir build_for_ios

cp -r build/ios/Release-iphoneos/*/*.framework build_for_ios
cp -r build/ios/Release-iphoneos/App.framework build_for_ios
#cp -r build/ios/Release-iphoneos/Flutter.framework build_for_ios
cp -r .ios/Flutter/engine/Flutter.xcframework build_for_ios
cp -r .ios/Flutter/FlutterPluginRegistrant/Classes/GeneratedPluginRegistrant.* build_for_ios

在打包是,需在终端进入到flutter项目中,然后运行

sh build_ios_output.sh
2.1.2 打包产物

Flutter和原生混编-两种方案结合使混编更轻松

由上图可以看出打出来的为framework,其中App.frameworkDart打包的,其它是使用的插件的framework,运行脚本后将 build_for_ios文件夹中打包物上传到私有库中即可。

2.2 原生端引用flutter

以iOS为例,原生端调用flutter是在Podfile文件中进行调用。

调用如下所示,可以在开发阶段和发布阶段切换不同的方案:
# 联调时候使用该模式 (脚本路径为 .ios->Flutter->podhelper.rb)
# [注:]flutter_debug标志是否是debug 若为release需手动修改为false
flutter_application_path = '../xxxFlutter/xxx_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
$flutter_debug = false

target xxx do

# Flutter端混编(debug联调引用本地,release引用pod私有库中framework)
if $flutter_debug
  install_all_flutter_pods(flutter_application_path)
else
  # 这里在自己联调时可以直接引用打出来的包,测试时命名为 版本号-dev,上线命名规则为 版本号-release
#  pod 'XXXFlutterSDK', :path => '../XXXFlutterSDK'
  pod 'XXXFlutterSDK', '0.0.1-dev'
end

end

到这里整体的混编框架已经很清晰了,安卓端也是类似的,写个脚本将flutter端生成物放到私有库,然后通过aar调用即可。

三、两端调用

3.1 混合栈

两端混编首先要解决的问题就是混合栈问题,两端调用如原生->flutter->flutter->原生等,这中间涉及到原生的导航栈跳转到flutter的导航栈的处理,以及两个导航栈之间页面的跳转和入栈出栈等操作。这里面还有一个问题就是FlutterEngine的问题,如果你只是简单的调用flutterviewcontroller进行页面的调用,这样多次调用会创建多个引擎,而FlutterEngine是很消耗内存的,所以混合栈的问题必须要考虑。

######混合栈主流的有: 1.Google官方FlutterEngineGroup(多引擎方案) 即每次使用一个新的FlutterEngine来渲染Widget树。虽然Flutter 2.0之后的创建FlutterEngine的开销大大降低,但是依然没有解决每个FlutterEngine是一个单独isolate,如果需要Flutter①和Flutter②之间交互数据的话,将会非常麻烦,我们同样无法保证他们之间不会进行数据交互

2.大名鼎鼎的闲鱼flutter_boost(单引擎方案) ######优点:

  • 应用的项目多,经过了验证,可实现较好的效果
  • 最近发布了3.0的bate版本,摒弃了2.0版本对引擎的侵入。 ######缺点:
  • 对项目侵入性较大

3.哈喽单车团队的flutter_thrio(单引擎方案)

该库的优劣作者已经说得很详细了,这里就不再赘述,感兴趣的朋友可以进传送门亲自查看,flutter_thrio的优缺点

4.字节跳动团队的Isolate复用方案和腾讯心悦团队的TRouter方案 很可惜,目前这两个方案并没有开源出来,但很可能字节团队的方案的侵入性相当高。

经过一系列对比后,最终选择了较为成熟和稳定的flutter_boost

3.2 Flutter端实现

在pubspec.yaml中引入 flutter_boost

# flutter_boost
  flutter_boost:
    git:
      url: 'https://github.com/alibaba/flutter_boost.git'
      ref: '3.1.0'
3.2.1 路由

混编主要是原生和flutter端的相互调用,路由的代码如下: 在main.dart中:

 Route<dynamic>? routeFactory(RouteSettings settings, String? uniqueId) {
    // settings.name 首次为 /,  实际是代表首页的意思
    // BoostRoute.routerMap为Boost的路由表
    FlutterBoostRouteFactory? func = BoostRoute.routerMap[settings.name!];
    if (func == null) {
      return null;
    }
    return func(settings, uniqueId);
  }

  /// 然后build
  @override
  Widget build(BuildContext context) {
    return FlutterBoostApp(
      routeFactory,
      appBuilder: appBuilder,
      // initialRoute: RoutePath.storeSignExpress,
    );
  }

其中BoostRoute是项目的路由表,这里给独立为一个类,便于维护,具体代码如下:

import 'package:flutter/material.dart';
import 'package:flutter_boost/flutter_boost.dart';
import 'package:get/get.dart';
import 'package:get/get_core/src/get_main.dart';
import 'package:get/get_navigation/src/extension_navigation.dart';
import 'package:self_driving_flutter/app/config/route/route_path.dart';
import 'package:self_driving_flutter/module/ehi_base_page/view.dart';
import 'package:self_driving_flutter/module/inspect_car_record/view.dart';
import 'package:self_driving_flutter/utils/tools_util.dart';

import '../../../module/feedback_content/view.dart';

/// Boost路由表
class BoostRoute {

  /// 注册的路由表
  static Map<String, FlutterBoostRouteFactory> routerMap = {
    '/': (settings, uniqueId) {
      // 联调时可设置为自己开发的页面(可直接运行AS开发)
      return _buildPage(settings, YTBasePage());
    }
    RoutePath.feedbackContent: (settings, uniqueId) {
      return _buildPage(settings, FeedbackContentPage());
    },
    RoutePath.inspectCarRecord: (settings, uniqueId) {
      Map<dynamic, dynamic> arguments = settings.arguments as Map<dynamic, dynamic>;
      return _buildPage(settings, InspectCarRecordPage(
          orderId: arguments['orderId'],
          userId: arguments['userId'])
      );
    },
  };

  static MaterialPageRoute _buildPage(settings, Widget page) {
    return  MaterialPageRoute(
        settings: settings,
        builder: (_) {
          return page;
        });
  }
}

3.3 原生端实现

3.3.1 导航跳转类

这个类主要是控制原生和flutter页面的pushpop。 具体实现如下:

class YTFlutterBoostDelegate: NSObject, FlutterBoostDelegate {

    ///您用来push的导航栏
    @objc var navigationController:UINavigationController?{
        didSet{
            navigationController?.delegate = self
        }
    }
    
    ///用来存返回flutter侧返回结果的表
    var resultTable:Dictionary<String,([AnyHashable:Any]?)->Void> = [:];
    
    // MARK: 如果框架发现您输入的路由表在flutter里面注册的路由表中找不到,那么就会调用此方法来push一个纯原生页面
    func pushNativeRoute(_ pageName: String!, arguments: [AnyHashable : Any]!) {
        
        //可以用参数来控制是push还是pop
        let isPresent = arguments["isPresent"] as? Bool ?? false
        let isAnimated = arguments["isAnimated"] as? Bool ?? true
        
        //这里根据pageName来判断生成哪个vc
        let targetViewController = dealViewController(with: pageName, arguments: arguments)
        
        // 展示导航,到原生页面使用原生的导航
        self.navigationController?.setNavigationBarHidden(false, animated: false)
        
        if(isPresent) {
            self.navigationController?.present(targetViewController, animated: isAnimated, completion: nil)
        }else{
            self.navigationController?.pushViewController(targetViewController, animated: isAnimated)
        }
    }
    
    // MARK: 当框架的withContainer为true的时候,会调用此方法来做原生的push
    func pushFlutterRoute(_ options: FlutterBoostRouteOptions!) {
        let vc:FBFlutterViewContainer = FBFlutterViewContainer()
        vc.setName(options.pageName, uniqueId: options.uniqueId, params: options.arguments,opaque: options.opaque)
        
        //用参数来控制是push还是pop
        let isPresent = (options.arguments?["isPresent"] as? Bool)  ?? false
        let isAnimated = (options.arguments?["isAnimated"] as? Bool) ?? true
        
        //对这个页面设置结果
        resultTable[options.pageName] = options.onPageFinished
        
        // 隐藏导航,到Flutter页面使用Flutter的导航并禁止右滑手势
        self.navigationController?.setNavigationBarHidden(true, animated: false)
        
        //如果是present模式 ,或者要不透明模式,那么就需要以present模式打开页面
        if(isPresent || !options.opaque){
            self.navigationController?.present(vc, animated: isAnimated, completion: nil)
        }else{
            self.navigationController?.pushViewController(vc, animated: isAnimated)
        }
    }
    
    // MARK: 当pop调用涉及到原生容器的时候,此方法将会被调用
    func popRoute(_ options: FlutterBoostRouteOptions!) {
        //如果当前被present的vc是container,那么就执行dismiss逻辑
        if let vc = self.navigationController?.presentedViewController as? FBFlutterViewContainer,vc.uniqueIDString() == options.uniqueId{
            
            //这里分为两种情况,由于UIModalPresentationOverFullScreen下,生命周期显示会有问题
            //所以需要手动调用的场景,从而使下面底部的vc调用viewAppear相关逻辑
            if vc.modalPresentationStyle == .overFullScreen {
                
                //这里手动beginAppearanceTransition触发页面生命周期
                self.navigationController?.topViewController?.beginAppearanceTransition(true, animated: false)
                
                vc.dismiss(animated: true) {
                    self.navigationController?.topViewController?.endAppearanceTransition()
                }
            }else{
                //正常场景,直接dismiss
                vc.dismiss(animated: true, completion: nil)
            }
        }else{
            self.navigationController?.popViewController(animated: true)
        }
        
        // 展示导航,到原生页面使用原生的导航
        self.navigationController?.setNavigationBarHidden(false, animated: false)
        
        //否则直接执行pop逻辑
        //这里在pop的时候将参数带出,并且从结果表中移除
        if let onPageFinshed = resultTable[options.pageName] {
            onPageFinshed(options.arguments)
            resultTable.removeValue(forKey: options.pageName)
        }
    }
}

private extension YTFlutterBoostDelegate {
    /// 根据pageName来判断生成哪个vc
    func dealViewController(with name: String, arguments: [AnyHashable : Any]) -> UIViewController {
        switch name {
        case storeDetailPage: // 门店详情
            let vc = YTNewStoreDetailViewController()
            if let storeID = arguments["storeID"] as? Int {
                vc.storeID = storeID
            }
            
            YTNavigator.push(vc)
            return vc
        default:
            return UIViewController()
        }
    }
}

extension YTFlutterBoostDelegate : UINavigationControllerDelegate{
    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        // 右滑返回
        viewController.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in
            if context.isCancelled {
                return;
            }
            self.navigationController?.setNavigationBarHidden(false, animated: false)
        })
    }
}

3.3.2 配置FlutterBoost

然后需要在AppDelegate中配置FlutterBoost,在配置中也可以添加两端的交互,用户两端事件的交互,如传值等。 声明属性:

@property (nonatomic, strong) YTFlutterBoostDelegate *boostDelegate;

具体代码如下:

#pragma mark - 配置FlutterBoost及交互
- (void)configFlutterBoost:(UIApplication *)application {
    self.boostDelegate = [[YTFlutterBoostDelegate alloc] init];
    
    __block FlutterEngine *callEngine;
    // 注册FlutterBoost
    [[FlutterBoost instance] setup:application delegate: self.boostDelegate callback:^(FlutterEngine *engine) {
        callEngine = engine;
    }];
    
    // 处理Flutter调用原生事件
    self.methodChannel = [FlutterMethodChannel methodChannelWithName:@"xxx" binaryMessenger:callEngine];
    [self.methodChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult  _Nonnull result) {
        [YTMethodChannelManager methodChannelWith:call result:result];
    }];
}
3.3.3 原生调用flutter

我这里将调用方法单独成一个类,便于维护和扩展,用于在原生代码中打开Flutter页面,具体代码如下:

class YTFlutterUtils: NSObject {

    // MARK: 打开Flutter页面
    // pageRoute: 路由名称
    // arguments: 参数
    // opaque: 这个页面是否透明(默认为true)
    // completion: open方法完成后的回调,仅在原生->flutter页面的时候有用
    // onPageFinished: 参数回传的回调闭包,仅在原生->flutter页面的时候有用
    @objc class func openFlutterPage(with pageName: String = "",
                               arguments: Dictionary<String, Any>? = [:],
                               opaque: Bool = true,
                               completion: ((Bool) -> ())? = nil,
                               onPageFinished: (((Dictionary<AnyHashable, Any>)?) -> ())? = nil
    ) {
        let options = FlutterBoostRouteOptions()
        options.pageName = pageName
        options.arguments = arguments ?? ["animated": true];
        options.opaque = opaque
        options.completion = completion
        options.onPageFinished = onPageFinished
        FlutterBoost.instance().open(options)
    }
}

到这里已经完整的实现了原生段和flutter的混编,包括混编方案的选择及具体实现,希望可以对大家起到一些借鉴作用。