美文网首页
Flutter混编方案

Flutter混编方案

作者: 楚丶liu香 | 来源:发表于2020-07-13 20:44 被阅读0次

    前言

    为什么会有Flutter混编方案?其实这是一个很现实的问题。比如我们想要新写一个App,直接选用Flutter作为移动端开发的跨平台方案是非常好的一个选择。但是现实中是我们的App可能是已经开发了很多年的一个巨型工程,完全放弃原有的代码而使用Flutter重写App是不现实的。

    开发过程中,我们最想要的是原生代码开发和Flutter共存:既不影响项目工程的原生开发,又能使用Flutter去统一iOS/Android技术栈。

    混编方案

    一般来说混编方案有以下两种:

    flutter_01.png
    1. 统一管理方案:将iOS工程和Android工程作为Flutter工程的子工程,由Flutter统一管理。
    2. 三端分离方案:iOS工程、Android工程、Flutter工程是三个单独的项目工程,将Flutter工程的编译产物作为iOS工程和Android工程的依赖模块,原有工程的管理模式不变,对原生工程没有侵入性,无需额外配置工作。

    1. 统一管理方案

    统一管理方案是只有一个项目工程,这样的好处是代码集中,可以很方便的进行项目开发,每个开发同学都可以进行iOS、Android和Flutter的开发。当然缺点也非常明显:

    • 对原有项目的侵入性太大,项目对外部环境的依赖程度增加。
    • 每个人本地都要装有自己端的开发环境(iOS/Android)和Flutter的开发环境,并且Flutter SDK版本要保持一致。
    • 耦合度会越来越高。当项目越来越复杂后,整个项目的代码耦合度会越来越高,相关工具链耗时也会越来越长,导致开发效率降低。

    2. 三端分离方案

    三端分离方案是iOS、Android和Flutter分别作为三个独立项目存在,在远端各自有各自的代码仓库。这种方案需要单独创建Flutter项目,然后通过iOS(CocoaPods)和安卓的依赖管理工具将Flutter项目build出来的framework、资源包等放入Native工程以供使用。这种方式可以将iOS、Android和Flutter项目放在一个目录下面作为一个项目来管理,也可以不在同一目录下,关键是设置Flutter模块依赖时相对路径一定要设置正确,如下:

    some/path/
      demo_android/
      demo_ios/
      demo_flutter/
    

    以iOS端为例。

    2.1 创建Flutter工程

    要将Flutter集成到现有项目中,首先创建Flutter模块,命令行运行:

    cd yourproject/path/
    flutter create --template module my_flutter
    

    执行完后,在yourproject/path/my_flutter中生成了Flutter模块项目。在该目录中,你可以运行一些flutter命令,比如flutter run --debugflutter build ios

    注意:

    path可以自己定,但是一定要和后边Podfile文件的路径一致。

    my_flutter目录结构如下:

    .
    ├── .android                // Android部分工程文件
    ├── .gitignore              // 忽略项配置文件
    ├── .ios                    // iOS部分工程文件
    │   └── Runner.xcworkspace  // iOS工程工作区
    ├── lib                     // 项目Dart源文件    
    │   └── main.dart           // Flutter项目代码入口文件,类似iOS的main.m,RN的index.js文件
    ├── pubspec.yaml            // 项目依赖配置文件,类似于iOS的Podfile,RN的package.json文件
    └── test                    // 项目测试文件
    
    • 在lib目录下放置自己的Dart代码
    • Flutter依赖项添加到my_flutter/pubspec.yaml,包括Flutter软件包和插件。
    • .ios包含一个Xcode工作区,自己的iOS代码不要添加到这里,这里的更改不会显示到已有的iOS项目中,并且可能会被Flutter覆盖。
    • .ios/.android/目录是自动生成的,不要对其进行源码控制。
    • 首次拉取到集成Flutter模块的项目代码后,构建项目之前,要现在my_flutter目录运行flutter pub get以生成对应的.ios/.android/目录。

    使用Android Studio运行Flutter工程无误后,就可以将Flutter推到远端仓库,为后边的混编做好准备工作。

    2.2 将Flutter工程集成到已有应用程序中

    官方推荐使用CocoaPods依赖管理工具来安装Flutter SDK,这种方式要求当前项目的每个开发人员本地都必须安装Flutter SDK版本。

    如果你的项目还没有使用CocoaPods,可以参考CocoaPods官网或者CocoaPods入门来给项目添加CocoaPods依赖管理工具。

    ① Podfile文件

    我们要通过CocoaPods管理Flutter SDK,需要再Podfile文件中增加以下内容:

    flutter_application_path = '../my_flutter'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    // 对于每个需要集成Flutter的Podfile target,添加如下内容
    install_all_flutter_pods(flutter_application_path)
    

    这里需要注意的是,如果你的Flutter模块目录结构与官方文档推荐的不一致,需要自己调整相对路径,以保证安装正确。Podfile详情案例如下:

    source 'https://github.com/CocoaPods/Specs.git'
    platform :ios, '8.0'
    inhibit_all_warnings!
    
    # path修改为调整后的相对路径
    flutter_application_path = './Demo/Vendors/demo_flutter'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    target 'MyApp' do
    
    install_all_flutter_pods(flutter_application_path)
    
    end
    
    ② 运行pod install

    pod install主要做了以下事情:

    • 解析Generated.xcconfig文件,获取 Flutter工程配置信息,文件在my_flutter/.ios/Flutter/目录下,文件中包含了Flutter SDK路径、Flutter工程路径、Flutter工程入口、编译目录等。
    • 将Flutter SDK中的Flutter.framework通过pod添加到Native工程。
    • 使用post_install这个pod hooks来关闭Native工程的bitcode,并将Generated.xcconfig文件加入Native工程。

    现在虽然进行了三端分离,但是项目之间是直接依赖的,仍然存在一些问题:

    • 对原有项目仍然有侵入性,需要在项目中配置Podfile文件和执行flutter命令。
    • 每个人本地仍然需要自己端的开发环境(iOS/Android)和Flutter的开发环境,并且Flutter SDK版本要保持一致。
    2.3 构建Flutter模块

    三端分离方案的关键是抽离Flutter工程,将Flutter项目的构建产物按照某种规则提供给原生工程使用,比如Android使用aar,iOS使用CocoaPods。

    这种方案是将Flutter项目的构建产物作为原生工程的子模块,原有工程不需要本地安装Flutter开发环境,只需要关注原生开发即可。当我们的Flutter项目有了新功能或改动后,将其构建产物通过拖入或改造为依赖库的方式提供给原生项目使用。下面我们来一步步实现三端分离方案。

    原生工程对Flutter的依赖主要分为两部分:

    1. Flutter库和引擎,也就是Flutter的framework库和引擎库。
    2. Flutter工程,也就是我们自己实现的Flutter模块功能,主要包含Flutter模块lib目录下的Dart代码和各种资源。
    ① 构建

    iOS集成Flutter模块要稍微比Android麻烦一点。iOS项目工程对Flutter的依赖分别是:

    1. Flutter库和引擎,即Flutter.framework;
    2. Flutter项目产物,即App.framework。

    iOS项目的Flutter模块依赖,实际上就是这两个产物,可以直接拖入项目工程,或者封装成一个CocoaPods私有库供原生项目引用。

    如何构建Flutter产物呢,Flutter项目根目录下执行build命令:

    flutter build ios --debug
    
    flutter_02.png

    这条命令执行完后,会生成上面说的构建产物:Flutter.frameworkApp.framework。如果想要release的产物,把--debug换成--release即可。

    flutter_03.png
    ② 依赖使用

    如果想和Android一样搞成依赖库,需要单独将构建产物封装成CocoaPods私有库,通过pod的方式给项目引入使用。如何构建私有库,这里就不介绍了,如果有兴趣可以参考CocoaPods入门

    如果不想这么麻烦,也可以直接将产物拖进项目的某个目录下,直接引入使用。

    原生iOS项目中,导入头文件#import <Flutter/Flutter.h>,直接使用FlutterViewController创建视图控制器即可,代码如下:

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
        FlutterViewController *vc = [[FlutterViewController alloc]init];
        self.window.rootViewController = vc;
        [self.window makeKeyAndVisible];
        return YES;
    }
    
    2.4. iOS端集成方案
    ① 开发模式

    开发模式最重要要求就是便于调试,这时可以采用上面1.2.2方案,仍然是三端分离,但是不使用Flutter构建产物。

    首先在iOS指定目录下clone Flutter项目代码。之前的iOS项目也集成过React Native模块,为了便于统一管理,我们将Flutter模块放在React Native模块同一目录下。切到指定目录下执行clone命令:

    git clone flutter项目地址
    
    flutter_04.png

    然后进入到Flutter模块根目录下,执行:

    flutter pub get
    

    这是管理Flutter packages的命令,会将项目依赖的Flutter package拉取到本地供项目使用。

    flutter_05.png

    此时Flutter模块的准备工作已经完成,下边需要将Flutter模块配置给iOS项目工程使用。Podfile文件中增加以下内容:

    source 'https://github.com/CocoaPods/Specs.git'
    
    platform :ios, '8.0'
    inhibit_all_warnings!
    
    # flutter模块路径配置,路径为你缩放至目录的相对路径
    flutter_application_path = './Demo/Vendors/demo_flutter'
    load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
    
    target 'Demo' do
    
    # iOS依赖库
    pod 'AFNetworking', '3.2.1'
    
    # flutter
    install_all_flutter_pods(flutter_application_path)
    
    end
    
    

    修改完毕后,执行:

    pod update
    
    flutter_06.png

    执行完毕后,Flutter.framework会通过Pod添加到iOS项目中,还有一些工程配置也会在这个命令中得以完成。

    此时配置完毕,iOS项目添加如2.3所示native代码,运行项目,即可看到Flutter页面。

    ② 发布模式

    开发模式主要是为了调试,所以开发同学本地必须有Flutter开发环境,但是当开发测试完需要打Release包发布或者代码提交的服务端使用打包服务机打包的时候,Flutter工程的代码是什么我们已经不关心了,只需要提供Flutter工程的编译产物给原生工程依赖使用即可。

    所以此时进入到项目中的Flutter工程目录下执行flutter build ios --debugflutter build ios --release构建编译产物,待编译完成后有两种方式提供给原生工程使用:

    • 直接拖入原生工程,做相应工程配置后即可依赖使用。
    flutter_07.png
    • 将构建产物使用CocoaPods制作成私有库供原生项目依赖使用,这也是目前比较推崇的方式。

    具体关系图:

    flutter_08.png

    目前iOS端是直接将Flutter编译产物直接拖入项目使用,后续会将Flutter编译产物构建成上图中私有库的形式,使用Cocoapods做Flutter模块的依赖管理。

    3. Native与Flutter通信

    就像Native与H5交互相仿,Native与Flutter通信也是通过一个中间通信工具对象(Platform Channel)来完成的,有三种类型:

    • MethodChannel,最常用的传递对象,现在项目中使用的通信方式也是基于MethodChannel完成的。
    • BasicMessageChannel,用户数据信息的传递。
    • EventChannel,用于时间监听传递等场景。
    3.1 Native模块

    下面我们来看下iOS端代码实现,首先定义MethodChannel的name,初始化过程中会使用这些name创建通信对象。

    static NSString *const kChannelFlutterToNative = @"com.demo.flutter/native";
    static NSString *const kChannelNativeToFlutter = @"com.demo.flutter/flutter";
    
    ① Native模块传递信息给Flutter模块
    // native to flutter
    FlutterMethodChannel *flutterChannel = [FlutterMethodChannel methodChannelWithName:kChannelNativeToFlutter binaryMessenger:flutterVC.binaryMessenger];
    NSString *serviceToken = @"token";
    if (serviceToken.length > 0) {
        [flutterChannel invokeMethod:@"onActivetyResult" arguments:@{@"cookie" : serviceToken}
        ];
    }
    
    ② Native模块接收Flutter模块传递信息
    // flutter to native
    FlutterMethodChannel *nativeChannel = [FlutterMethodChannel methodChannelWithName:kChannelFlutterToNative binaryMessenger:flutterVC.binaryMessenger];
    [nativeChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult  _Nonnull result) {
        if ([call.method isEqualToString:@"openWebViewPage"]) { 
            //打开webView
            NSString *url = [call.arguments[@"message"] description];
            self.loadWebView(url);
        } else if ([call.method isEqualToString:@"handleTrackingCrash"]) { 
            //触发native埋点
            [self trackingFlutterErrorWithData:call.arguments];
        }
    }];
    
    3.2 Flutter模块

    Flutter工程代码实现,首先也要初始化MethodChannel对象,初始化用的字符串要与上面iOS Native工程使用的保持一致。

    static final flutterToNativeChannel = const MethodChannel('com.demo.flutter/native');
    static final nativeToFlutterChannel = const MethodChannel('com.demo.flutter/flutter');
    
    ① Flutter模块接收Native模块传递信息
    Future<dynamic> handle(MethodCall call) async {
      switch(call.method) {
        case 'onActivetyResult':
          onDataChange(call.arguments);
          break;
      }
    }
    ConstantsUtil.nativeToFlutterChannel.setMethodCallHandler(handle);
    
    ② Flutter传递信息给Native模块
    Widget renderBottomRow(i) {
      return GestureDetector(
        onTap: () => openWebView(ConstantsUtil.flutterToNativeChannel, listData[i]['url']),
        child: Container(
            ...
        ),
      );
    }
    
    Future<Null> openWebView(MethodChannel channel, String url) async {
      Map<String,String> param = {'message':url};
      await channel.invokeMethod('openWebViewPage', param);
    }
    

    4. 调试

    作为软件开发,调试过程必不可少,那么在混编模式下有什么调试方案和技巧呢?

    在2.4所提到的开发模式下,我们本地既有Native工程代码,又有Flutter工程代码,想要同时调试Native代码和Flutter代码,一般有两种方案:

    4.1 iOS和Flutter同时调试,不支持断点方案

    ① Xcode打开iOS项目,运行项目并打开Flutter项目页面。

    ② 终端命令行输入:flutter devices,打印出已连接到计算机的设备。

    flutter_09.png

    ③ Android Studio打开嵌在iOS项目中的Flutter项目,控制台选择Terminal选项卡,输入:

    flutter attach -d 894DADC8-A12B-47FC-B8A7-EE29F0D2B086
    

    flutter attach的作用是将当前Flutter项目连接到某个正在运行的应用程序上。回车后,控制台会输出:

    Syncing files to device iPhone 11...                                    
     6,247ms (!) 
    

    这表示连接成功,具体Android Studio项目详情截图如下:

    flutter_10.png

    Flutter项目代码修改后:

    • r是热加载,局部刷新,刷新所有改动的Flutter代码文件,此时就可以看到代码改动后的结果;
    • R是热重启,全部刷新,刷新所有的Flutter文件。如过Hot reload刷新无效,可以尝试使用Hot restart
    • dq都是终止连接,结束调试。

    Hot reloadHot restart区别:

    • Hot reload,将所有代码更改加载到VM中,并重新构建Widget树,但是不会重新运行main()initState()
    • Hot restart,同样将所有代码更改加载到VM中,然后重新启动Flutter应用,从而丢失应用状态。
    4.2 iOS和Flutter同时调试,支持断点方案

    ① Android Studio打开嵌在iOS项目中的Flutter项目,工具栏点击Flutter Attach

    flutter_11.png

    此时控制台Debug选项卡log输出:

    Waiting for a connection from Flutter on iPhone 11...
    

    ② Xcode打开iOS项目,运行项目并打开Flutter项目页面。控制台Debug选项卡log如下输出代表连接完成,可以进行断点调试。

    Debug service listening on ws://127.0.0.1:54615/cDjoWoEjEok=/ws
    Syncing files to device iPhone 11...
    
    flutter_12.png

    同样在控制台上边也可以通过点击Hot reloadHot restart按钮来实现代码修改的更新操作。

    5. 遇到的问题

    5.1 找不到GeneratedPluginRegistrant文件

    如果原生代码中使用了GeneratedPluginRegistrant类,还要从Flutter项目中将这个类文件拿出来和Flutter项目产物放在一块提供给原生项目使用,不然会报错找不到这个类。

    5.2 启动崩溃,Library not loaded: @rpath/Flutter.framework/Flutter

    项目启动崩溃,控制台log日志如下:

    dyld: Library not loaded: @rpath/Flutter.framework/Flutter
      Referenced from: /Users/zzz/Library/Developer/CoreSimulator/Devices/F5A071EC-2F1A-47E8-9C71-8E1269E01568/data/Containers/Bundle/Application/72BC9387-1FFB-467F-97FE-21767A5861B0/Demo.app/Demo
      Reason: image not found
    

    根据提示Flutter.framework没有被加载,我们点击Xocde的TARGETS -> General,找到 Frameworks,Libraries,and Embedded Content选项的Flutter.framework,将Embed值由Do Not Embed改为Embed Without Signing,重新运行项目即可。

    另外App.framework也需要将Embed值由Do Not Embed改为Embed Without Signing,不然项目运行后进入flutter页面是看到你写的功能页面的。

    5.3 项目嵌入Flutter编译产物后,使用模拟器运行项目报错
    Building for iOS Simulator, but the linked and embedded framework 'App.framework' was built for iOS.
    
    flutter_13.png

    这是因为Xcode 11.4更改了框架的链接和嵌入方式,导致了在iOS设备和模拟器之间切换的问题。想要避免这个启动错误最简单的操作就是更改Workspace Settings。点击Xocde菜单栏File --> Workspace Settings...,在弹出对话框中将Build System值改为Legacy Build System即可。重新运行项目,即可正常在模拟器启动应用。

    flutter_14.png
    5.4 Android Studio运行Flutter项目,提示Waiting for another flutter command to release the startup lock...

    项目异常关闭或者使用任务管理器强制关闭后一般会出现这个问题,原因是在Flutter编译运行过程中会创建一个文件锁lockfile,而异常关闭或者强制关闭或导致这个锁没有释放而一直存在,导致启动过程中出现waiting问题。

    解决方案也很简单,找到这个文件删除即可。

    rm ./flutter/bin/cache/lockfile
    
    5.5 应用程序提交App Store时报错
    Unsupported Architecture. Your executable contains unsupported architecture '[x86_64, i386]
    

    意思比较明白,打包的应用程序包含了不被支持的模拟器架构(x86_64和 i386)。解决方案当然就是删掉这两个模拟器架构。

    选择Xcode的Targets --> Build Phases,点击+号按钮选择New Run Script Phase,在输入框中填入以下代码:

    APP_PATH="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
    
    # This script loops through the frameworks embedded in the application and
    # removes unused architectures.
    
    find "$APP_PATH" -name '*.framework' -type d | while read -r FRAMEWORK
    do
        FRAMEWORK_EXECUTABLE_NAME=$(defaults read "$FRAMEWORK/Info.plist" CFBundleExecutable)
        FRAMEWORK_EXECUTABLE_PATH="$FRAMEWORK/$FRAMEWORK_EXECUTABLE_NAME"
        echo "Executable is $FRAMEWORK_EXECUTABLE_PATH"
    
        EXTRACTED_ARCHS=()
        for ARCH in $ARCHS
        do
            echo "Extracting $ARCH from $FRAMEWORK_EXECUTABLE_NAME"
            lipo -extract "$ARCH" "$FRAMEWORK_EXECUTABLE_PATH" -o "$FRAMEWORK_EXECUTABLE_PATH-$ARCH"
            EXTRACTED_ARCHS+=("$FRAMEWORK_EXECUTABLE_PATH-$ARCH")
        done
    
        echo "Merging extracted architectures: ${ARCHS}"
        lipo -o "$FRAMEWORK_EXECUTABLE_PATH-merged" -create "${EXTRACTED_ARCHS[@]}"
        rm "${EXTRACTED_ARCHS[@]}"
    
        echo "Replacing original executable with thinned version"
        rm "$FRAMEWORK_EXECUTABLE_PATH"
        mv "$FRAMEWORK_EXECUTABLE_PATH-merged" "$FRAMEWORK_EXECUTABLE_PATH"
    done
    

    这段脚本将会在打包过程中执行,删除掉Archive包中不被支持的模拟器架构(x86_64和 i386)。

    5.6 配置Flutter环境变量后执行Flutter命令仍然报zsh: command not found: flutter问题

    配置Flutter环境变量后执行Flutter命令正常,退出终端工具ZSH,再次打开终端工具ZSH执行Flutter命令,会提示zsh: command not found: flutter问题。

    原因是如果你使用的是ZSH,终端启动时 ~/.bash_profile 将不会被加载,解决办法就是修改 ~/.zshrc ,在其中添加:source ~/.bash_profile

    6. 异常监控

    要想知道Flutter模块在原生应用中是否正常使用,异常监控绝对少不了。下面我们来看下Flutter异常如何收集和上报。

    trackError方法是回调Native异常上报代码,在Native中上传。目前iOS端使用的是埋点上传的方式记录当前Flutter模块的异常,后边还会做进一步优化,上传到统一异常收集平台展示。

    Future<Null> trackError (MethodChannel channel, String exception, String stack) async {
      Map<String,String> param = {'exception' : exception,
                                  'stack' : stack};
      await channel.invokeMethod('handleTrackingCrash', param);
    }
    
    6.1 Dart异常

    对于Dart异常,我们可以使用全局onError函数去捕获:

    runZoned(() {
      runApp(MyApp());
      if (Platform.isAndroid) {
        SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
      }
    }, onError: (error, stackTrace) {
      // This is a pure Dart error
      trackError(ConstantsUtil.flutterToNativeChannel, error.toString(), stackTrace.toString());
    });
    

    这里只要Dart代码有Error就会触发onError回调方法。

    6.2 Flutter异常

    除了Dart异常外,Flutter也能抛出其他异常,比如调用原生代码发生的平台异常,这种类型的异常也同样是需要上报的。

    为了捕获 Flutter 异常,需要重写FlutterError.onError属性。在开发环境下,可以将异常格式化输出到控制台。在生产环境下,可以把异常信息传递Native模块做异常上报。

    FlutterError.onError = (FlutterErrorDetails errorDetails) {
      trackError(ConstantsUtil.flutterToNativeChannel, errorDetails.exception.toString(), errorDetails.stack.toString());
    };
    

    如果想要了对异常上报做进一步了解,请点击实用教程查看。

    相关文章

      网友评论

          本文标题:Flutter混编方案

          本文链接:https://www.haomeiwen.com/subject/aevzcktx.html