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

flutter混编

最近公司产品想要实践下和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端打包.png

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打包产物.png

由上图可以看出打出来的为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的混编,包括混编方案的选择及具体实现,希望可以对大家起到一些借鉴作用。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,340评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,762评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,329评论 0 329
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,678评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,583评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,995评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,493评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,145评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,293评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,250评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,267评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,973评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,556评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,648评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,873评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,257评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,809评论 2 339

推荐阅读更多精彩内容