谷歌在2018年发布了Flutter 1.0版本,对于Flutter开发方式,官方提供了两种解决方案,一种是纯的Flutter开发,开发者只需要关心dart代码部分的开发,另外一种是混合开发方式,也就是将Fluter接入既有工程。
对于贝壳而言,我们内部有很多既有工程, 没办法采用第一种方式。因为不能将原有的native代码全部替换成Dart代码,也不能让所有的开发人员同时转向Flutter。所以我们就不得不选择混合开发方式。但是官方对混合开发支持稍微欠缺一点, 比如在集成方面就有很多问题, 接下来本文将介绍官方的混合集成方案以及贝壳的在集成方面的实践。
1、官方方案
1.1 工程模版
我们先来了解一下官方的方案,Flutter 官方给出了四种工程模板,分别是:
app
module
package
plugin
app
App模板是Flutter官方推荐的方式,是纯Flutter工程,但并不意味着没有任何IOS和安卓的元素。这个工程模板中仍然包括iOS和安卓的工程,只不过是自动生成的。普通开发人员不需要关心而已。
module
module模板是官方提供给集成既有iOS和安卓工程的模板。该模板包含了一些辅助脚本,这些脚本主要是帮助原生iOS工程以pod方式集成Flutter,原生安卓工程以Gradle方式集成Flutter,这个模板相对符合我们的需求
package
package这种模板是提供给业务开发使用的,它能包含dart代码。
plugin
plugin这种模板主要是帮助开发者来补充Flutter不能满足的功能,比如定位,蓝牙,权限。它包含两部分代码,一部分是dart代码,一部分是native代码。同时这种模板包含一个example,example工程和App模板的样式一致。
根据上述模板的情况,我们简单总结一下:
flutter 存在两种模板帮助开发者做组件或者模块化开发,一种是package,一种是plugin
flutter 为既有工程准备了专门的模板:module
1.2 Flutter集成过程到底需要集成什么?
我们以2.2.2为例,看看官方集成既有工程的模板(module模板)
├── .android
├── .flutter-plugins
├── .flutter-plugins-dependencies
├── .ios
├── .packages
├── demo.iml
├── demo_android.iml
├── lib
├── pubspec.lock
├── pubspec.yaml
└── test
上面目录有三个关键文件:
.ios
是一个目录,它内部包含了集成既有iOS工程核心内容
.android
是一个目录,他内部包含了集成既有安卓工程核心内容
pubspec.yaml 这个类似iOS podspec,同时也具有podfile的作用。用来描述组件或者模块本身的构成,比如描述代码资源等等。其次可以指定自己的依赖,比如依赖某某业务,某个基础功能等等。这些组件采用的模板就是我们上面说的package和plugin。最后配合flutter pub相关命令,完成了Dart业务代码的集成。
我们以.ios目录为例,看一下详细的内容:
├── Config
│ ├── Debug.xcconfig
│ ├── Flutter.xcconfig
│ └── Release.xcconfig
├── Flutter
│ ├── AppFrameworkInfo.plist
│ ├── FlutterPluginRegistrant
│ ├── Generated.xcconfig
│ ├── README.md
│ ├── demo.podspec
│ ├── engine
│ │ ├── Flutter.podspec
│ │ └── Flutter.xcframework
│ │ ├── Info.plist
│ │ ├── ios-armv7_arm64
│ │ │ └── Flutter.framework
│ │ └── ios-x86_64-simulator
│ │ └── Flutter.framework
│ ├── flutter_export_environment.sh
│ └── podhelper.rb
├── Podfile
├── Runner
│ ├── AppDelegate.h
│ ├── AppDelegate.m
│ ├── Assets.xcassets
│ ├── Base.lproj
│ ├── Info.plist
│ └── main.m
├── Runner.xcodeproj
└── Runner.xcworkspace
同时官方给出的集成说明如下:
1.Add the following lines to your Podfile:
flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
2.For each Podfile target that needs to embed Flutter, call install_all_flutter_pods(flutter_application_path).
target 'MyApp' do
install_all_flutter_pods(flutter_application_path)
end
3.Run pod install.
根据官方描述,要集成到既有工程,需要在podfile中增加两部分代码,一部分是flutter工程目录的变量声明,一部分是在target中调用集成函数install_all_flutter_pods,这个函数在上面目录的podhelper.rb中,我们看一下它的内容是什么
def install_all_flutter_pods(flutter_application_path = nil)
flutter_application_path ||= File.join('..', '..')
install_flutter_engine_pod
install_flutter_plugin_pods(flutter_application_path)
install_flutter_application_pod(flutter_application_path)
end
根据install_all_flutter_pods函数内容,整个集成过程分为三个部分:
集成Flutter的运行引擎
使用 install_flutter_engine_pod 集成,官方已经打好包的,根据config和平台有不同的版本
集成Flutter Plugin
使用install_flutter_plugin_pods函数集成Flutter工程中依赖的plugin,plugin中包含一部分native代码,官方将每个plugin以单独的pod集成进入iOS的target
集成Flutter工程的dart代码和资源文件
这部分比较微妙一点,对于资源来讲iOS中有直接copy bundle,而对于dart代码如何集成进入呢?这块Flutter的做法是给iOS既有壳工程先增加一个build phase,内容为:
\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/xcode_backend.sh build
这个命令先将dart代码进行编译,编译产物是一个名为App.framework的动态库,然后将对应的资源文件copy放入App.framework的内部目录中。
针对官方整个过程,我们遇到的问题如下:
官方方案需要对podfile增加一些代码,有一定的侵入性。
官方方案需要将Flutter工程存放在某个本地目录下,而多人开发时,需要来回交付,肯定不能是某个人的本地目录,就只能是壳工程,这样对壳工程带来二次污染。
官方方案中集成dart代码时需要Flutter SDK进行编译Flutter代码,这意味需要全体开发者配置Flutter SDK,大大增加了非Flutter开发人员的成本。
2、 贝壳Flutter iOS集成方案
根据上面的问题,新的方案必须既能完成Flutter三个核心内容的集成,又不能污染壳工程,更重要的不能影响非Flutter开发人员。为了兼顾Flutter开发和非Flutter开发,我们将整个集成分为两种模式,对于Flutter开发使用源码集成,对于非Flutter开发使用二进制交付。方案如下图:
源码模式
二进制模式
2.1 集成Flutter工程的dart代码和资源文件以及辅助文件
官方在集成这部分内容的时候使用了两个pod,并且给壳工程还增加了build phash,我们的方案是将他简化成一个podspec来描述,并且支持两种模式。
1. 源码模式
该模式是为Flutter开发人员服务,首先给podspec增加一个build phash,然后再增加s.framewwork='App.framework', 在编译的时候,xcode会调用build phash内容,链接完成后,cocoapods就会将App.framework copy进入ipa包内。这样就完成了集成。并且在本地开发时Fluter开发人员只需要这样引用即可
pod 'demo',:path=>'../demo'
2. 二进制模式
该模式是针对非Flutter开发人员,只有产物,没有源码,podspec中直接描述了App.framework,非Flutter开发人员使用时这样引入
pod 'demo', '0.0.1'
两种模式下iOS的辅助文件都使用s.source方式引入,以一个pod为核心将Flutter产物以及辅助文件进行绑定。解决掉多处引入的问题,可插拔,不污染壳工程。
2.2 集成Flutter Plugin
为了保持对podfile最小影响以及可维护性,并且能够支持Flutter整体的可插拔,我们希望能在不增加多余的引入的情况下,利用上一步在podfile中的引入demo的这一行来自动引入Flutter Plugin。为了完成这个任务,首先我们考虑的是在demo的podspec做文章,但是在podspec中没有办法做到自动导入pod的,幸运的是cocoapods的plugin可以在pre_install阶段调用store_pod进行pod的自动导入。方案有了,剩下的就是plugin信息了。plugin信息在两种模式下获取方式有所不同。
源码模式:
在Flutter工程使用Flutter pub get/upgrade后,会在根目录生成两个文件,.flutter-plugins和.flutter-plugins-dependencies文件。.flutter-plugins每行使用key=value形式记录了plugin的名称和存放path信息,而.flutter-plugins-dependencies则一个json,记录了组件的名称,在1.22.x版本之后这个信息里面保存了平台,pluign名称,plugin路径等等,源码模式下,可以读取这个文件内容进行plugin信息收集
二进制模式:
将源码打包成二进制时,我们生成了一套自己的目录格式,如上图所示,目录中有一个config文件,config中包含了plugin的信息。但是当我们使用二进制模式加载Flutter产物时,我们发现在pre_install阶段Flutter的二进制产物还没有下载到本地,根本没有办法获取config信息,所以我们必须进行预下载,可以使用 cocoapos的Downloader.download_source进行下载,但是在pre_install阶段,我们不能将所有的pod都进行下载,我们需要识别到那个pod是Flutter产物,所以我们在pod后面增加一个关键字:Flutter,引入pod的格式如下:
pod 'demo', '0.0.1', :isFlutter => true
根据 :isFlutter => true,识别Flutter工程对应的pod。
2.3 集成Flutter的运行引擎
Flutter 库引入和Plugin的引入类似,Flutter.framework本身是随Flutter工程所使用的Flutter SDK进行动态变化的,这里依然分为两种模式。
源码模式:
我们在使用1.22.4及以前,每次编译时,Flutter SDK会将Flutter.framework 复制到 Flutter工程iOS/Flutter的目录下面,在2.2.2时,Flutter直接使用了Flutter SDK内部的目录,为了保持一致,我们对xcode_backend.sh进行了修改,始终复制一份Flutter.framework到Flutter工程iOS/Flutter目录下。这样我们就能以pod形式轻松引入。
二进制模式下:
在构建二进制 产物时,我们会将Flutter.framework的Debug和Rlease的多个架构进行合并,最终形成Debug和Release两个SDK,然后在Flutter的podspec中增加build phase, 代码如下:
parentPath=$PODS_TARGET_SRCROOT
rm -rf $parentPath/Real/Flutter.framework
if [[ $ACTION == "install" ]]; then
export CONFIGURATION='Release'
fi
cp-rf $parentPath/$CONFIGURATION/Flutter.framework $parentPath/Real/
使用上面代码在编译时根据CONFIGURATION行动态Copy。
这三部分的集成改造,解决了官方方案对既有壳工程污染问题,并且podfile中的接入写法贴近标准写法,更重要的是兼顾了Flutter开发和非Flutter开发同学的使用。
2.4 依然存在问题
上面接入方案看上去干净清爽,但是在效率上仍然存在很大问题:
1. 打包时间长,影响开发效率
虽然我们有源码模式,但是源码模式依赖Flutter SDK,但是对于打包机而言,它并不知道Flutter SDK的版本,所以打包机没办法使用源码打包,只能先通过固定的Flutter SDK环境打好二进制产物,然后再进行app打包,这样就变成了串行,并且打包二进制产物时需要打包多个架构,而打包app时只需要真机架构,这样一来,效率很低
2. 对于团队成员都是Flutter开发者的情况下,协同效率受有部分影响
项目全体成员都是Flutter开发的情况下,我们希望除了path形式和版本形式外,能够支持git形式,这样能够及时同步代码。
3. Flutter SDK版本配置差异导致的协同问题多,FlutterSDK的配置成本高
Flutter开发人员的Flutter SDK版本差异可能会导致编译结果和运行结果的差异,同时Flutter开发人员配置Flutter SDK环境。
4. flavor支持
同一个工程,我们需要出多个版本,这些版本差异主要是主题颜色和资源文件的差异,我们使用flavor来支持这个事情,但是上面的集成方案中没有说明如何支持
2.5 Flutter SDK自动化部署
要解决上述前三个问题,就需要让打包机和开发者自动化使用正确Flutter SDK版本,而不用关心Flutter SDK的具体的版本以及如何配置Flutter SDK。基于此,我们开发了Flutter SDK自动化部署工具,工具的核心原理是在Flutter 工程目录下增加一个配置文件,注明Flutter SDK的渠道和版本,然后当开发人员使用整个使用过程如下图:
git方式支持
有了自动化部署工具,git方式的支持就方便了,我们在Cocoapods插件上增加对git方式的支持,引入方式如下:
pod 'demo', :git=>'http://xxx.git', :isFlutter => true
工作流程如下图:
除此之外我们还支持环境变量的方式传入git库和git分支,使用功能如下:
FLUTTER_COMPONENT=xxx FLUTTER_COMPONENT_SOURCE_URL=http://xxx.git FLUTTER_COMPONENT_BRANCH=xxxb pod install
对于这两种模式,我们的cocoapods插件能够提取出Flutter工程对应的git地址和分支,然后使用cocoapods的api进行下载,下载完copy到一个独立目录,最后调用flutter自动化部署脚本进行pub get或者pub upgrade,pub get和pub upgrade的区分是根据pod的命令使用,pod install 对应pub get,pod update 对应pub upgrade。整体完成之后,进行Flutter plugin和Flutter.framework动态引入
flavor的支持
对于flavor支持,iOS采用subspec的形式,对于每个flavor不同的配置,主要体现在每个subspec的独立配置不同。
我们来总结一下这套方案,如下图:
优化前:
优化后:
在打包方面,QA在打包时,只需要选择输入Flutter工程的分支和壳工程的分支,打包机只需要使用pod update就能将需要打包的所有代码下载好。在编译的时,Flutter代码的编译对于xcode来说仅仅是一个普通的pod target的编译,能够进行并发操作,并且只需要编译真机架构。我们还在打包的时关闭了bitcode支持,这样大大提高了编译效率,整个出包的时间缩短了80%。
3、贝壳Flutter Android集成方案
对于Flutter接入到Android现有工程官方提供了两种方式:
Gradle 子项目源码方式嵌入到现有的项目
AAR 库嵌入到现有的项目
考虑到子项目源码依赖方式对非Flutter业务开发人员不够友好,因此我们选择AAR嵌入到现有APP方式。但是对官方的方式进行部分补充以满足现有工程体系。后面我们将详细介绍。
3.1 集成方案概览
首先看下整体结构图:
原生App接入Flutter是以原生为主, Flutter以产物库(library aar)的方式引入。这个aar对应的上图我们称之为“Flutter壳工程”。与官方Flutter Module不同的是我们将所依赖的Flutter Plugin的java/kotlin代码一起打包进aar。如下图Plugin本身的代码已包含在aar中。
其中Flutter Plugin依赖的原生基础库都以compileOnly的方式出现在各自的工程代码中(与插件工程引用基础库类似)。而后在Android壳工程通过implementation引入其基础库。同样对FlutterPlugin第三方插件中递归依赖库我们需要在Android壳工程额外引入。接下来我们将介绍如何创建Flutter壳工程(以下简称Portal工程)。
3.2 如何创建Flutter Portal工程
Portal工程与Flutter Application一样,可以通过Android Studio IDE创建(或者命令行创建),而后我们将其进行个性化定制。
3.2.1 工程创建
在 Android Studio 打开现有的 Android 项目并点击菜单按钮 File > New > New Flutter Project > Flutter Application > ...
3.2.2 工程定制
修改module名称,android/settings.gradle
/// FILE: android/settings.gradle
//include ':app'
include ':app_flutter'
project(':app_flutter').projectDir=new File(rootProject.projectDir,'app')
android/build.gradle
///FILE:android/build.gradle
buildscript{
repositories {
google()
//[ADD]:flutter-gradle plugindownload
maven { url "http://your.artifactory/url"}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.3'
//[ADD]:flutter-gradleplugin
classpath 'com.lianjia.common.android:flutter-gradle:1.0'
}
}
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
//project.evaluationDependsOn(':app')
project.evaluationDependsOn(':app_flutter')
}
android/app/build.gradle
///FILE:android/app/build.gradle
//apply plugin: 'com.android.application'
apply plugin: 'com.android.library'
//apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
//上面flutter.gradle用flutter-gradleplugin替换,有定制内容
apply plugin: 'flutter-plugin'
apply from: '../gradle-mvn-push.gradle'//AAR上传mavenTask
flutter {
source '../..'
//配置调试
packageName 'com.package.name'
launcherActivity 'com.package.name.YourMainActivity'
}
3.2.3 flutter gradle定制
前面我们将flutter.gradle从创建的工程中注释掉
//apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
使用apply plugin: 'flutter-plugin'替代。
主要有以下几个方面:
① 开发人员调试效率:与现有插件化开发模式一致,将编译产物推到手机,重启生效
// 是否是从flutter package project执行run
String pluginRun = Utils.resolveProperty(project, "flutter.pluginRun", "false")
if ("true" == pluginRun) {
PackageRunTask packageRunTask = project.tasks.create(name: "flutterRunPackage${variant.name.capitalize()}", type: PackageRunTask) {
flutterExecutable this.flutterExecutable
sourceDir project.file(project.flutter.source)
flutterProject project
packageName project.flutter.packageName
launchActivityName project.flutter.launcherActivity
mergeAssetsOutputDir mergeAssetsTask.outputDir.asFile.get().absolutePath
}
if (packageRunTask) {
zip.finalizedBy packageRunTask
}
}
② Plugin classes合并:将
def zip = Utils.beyondAndroidGradlePlugin330 ? variant.getPackageLibraryProvider().get() : variant.packageLibrary
zip.into("libs") {
pluginProjects.each { pluginProject->
// plugin project's classes
from ("${pluginProject.buildDir}/${AndroidProject.FD_INTERMEDIATES}/packaged-classes/${flutterBuildMode}/classes.jar") {
rename { String filename ->
if (filename == "classes.jar") {
return "${pluginProject.name}.jar"
} else return filename
}
}
}
}
③ 配合flutter命令上传aar
String flutterUpload = Utils.resolveProperty(project, "flutter.upload", "false")
if ("true" == flutterUpload) {
def uploadArchive = project.tasks.findByName("uploadArchives")
if (uploadArchive) {
zip.finalizedBy uploadArchive
}
}
3.2.4 flutter_tools定制
前面在配置工程中的android/app/build.gradle文件时,我们将
apply plugin: 'com.android.application' ==> apply plugin: 'com.android.library'
在官方模式下, 无法用flutter module的flutter build aar的方式生成aar。这里我们就涉及到对flutter_tools定制,主要包括以下几点:
① flutter assets资源
除了Android 自身flavor外,flutter package资源flutter_assets也可按照自定义的规则裁剪资源,以减少包体积。
在研究flutter_tools编译过程时发现flutter处理各package yaml文件中定义的assets内容,过滤掉特定规则的路径。如
///FILE:flutter_tools/lib/src/asset.dart
Map<_Asset, List> _parseAssets(
Strinflavor,//[ADD]
PackageMap packageMap,
FlutterManifest flutterManifest,
List wildcardDirectories,
String assetBase, {
List excludeDirs = const [],
String packageName,
}) {
final Map<_Asset, List> result = <_Asset, List>{};
final _AssetDirectoryCache cache = _AssetDirectoryCache(excludeDirs);
for (Uri assetUri in flutterManifest.assets) {
//[ADD]:过滤flutter_assets中给定匹配flavor的资源,达到flavor资源裁剪
if(FLAVOR_CONDITION){
continue;
}
//
if (assetUri.toString().endsWith('/')) {
wildcardDirectories.add(assetUri);
_parseAssetsFromFolder(packageMap, flutterManifest, assetBase,
cache, result, assetUri,
excludeDirs: excludeDirs, packageName: packageName);
} else {
_parseAssetFromFile(packageMap, flutterManifest, assetBase,
cache, result, assetUri,
excludeDirs: excludeDirs, packageName: packageName);
}
}
///.....
}
② IDE Run 运行调试
///FILE:flutter_tools/lib/src/runner/flutter_command_runner.dart
ArgResults parse(Iterable args) {
try {
// Run from business package project,we change command from run comman 2 build apk command,
// [ADD]兼容AndroidStudioIDE配置--plugin,转换成buildapk
if (args.contains('run') && args.contains('--plugin')) {
// change 2 a growable List
args = List.from(args);
// we know that args is List
if (args is List) {
final int index = args.indexOf('run');
args[index] = 'build';
args.insertAll(index + 1, ['apk', '--debug']);
args.remove('--machine');
// We also remove 'lib/main.dart' to support flavor specified entry pointer
args.remove('lib/main.dart');
}
}
// This is where the CommandRunner would call argParser.parse(args). We
// override this function so we can call tryArgsCompletion instead, so the
// completion package can interrogate the argParser, and as part of that,
// it calls argParser.parse(args) itself and returns the result.
return tryArgsCompletion(args.toList(), argParser);
}
///....
}
3.2.5 Flutter壳工程编译
编译命令与官方Flutter Application基本一致:如
flutter build apk --flavor flavor_name --release
与官方命令不一样的是这里生成了一个flutter工程对应aar。同时可以追加 --upload 命令行参数完成flutter-aar上传到maven的工作。
3.2.6 Android壳工程接入Flutter
按照aar接入方式即可:
implementation 'com.android.flutter:flutter-aar:1.0.0@aar'
3.2.7 开发工程调试
因为该集成方案与Flutter官方存在差异,所以为方便开发人员在业务开发阶段便捷的debug,在官方flutter attach调试之外,我们提供了Flutter壳工程、业务package工程两种方式来hotreload dart侧代码变更,如下Android Studio中 Run 图标。这里是我们对flutter_tools本身的修改(如上4、② IDE Run 运行调试)。
对于业务package我们也可以通过增加 --portal-root 来指定所属的Flutter壳工程,以便在package工程debug。
对于Flutter Plugin Java代码调试,IDE 打开example/android,待gradle同步完成后, debug即可。
4、总结
至此贝壳内部Flutter的集成方案就介绍完了。整个过程包括了对官方集成主体的缩减,使用源码模式和二进制模式来兼顾Flutte人开发和非Flutter开发,自研Flutter SDK自动化部署工具来降低Flutter开发人员的配置成本和打包机自动化横向扩展,提高整体协同效率,后期我们会对Flutter 自动化部署工具的实现原理进行单独的讲解。