1.前言
Flutter 是 Google 这几年大力推广的跨平台 UI 框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。在架构搭建阶段,我们依然需要原生技术的支持。比如说,我们在开发 Android 项目时,会通过在 gradle 文件中配置 Flavor 来实现不同渠道的属性配置,之后通过在编译过程中自动生成 BuildConfig 文件来读取不同 Flavor 下的各种属性。
在 Flutter 项目中,我们如何实现不同 Flavor 下读取相应属性并实现多渠道打包呢?以及 Flutter 的打包过程跟 Android 原生打包有什么不同呢?
本文将以 Flutter 1.22.4 版本为基础,通过 Flutter 项目中对于 Android 工程的构建流程详解,来进行 Flavor 配置说明以及 Apk 构建过程的详细分析。并针对 SDK 的 bug 提出了解决方案及原因梳理。
2.Flavor 配置及打包
2.1 Flavor 配置
在移动开发中,我们通常需要配置三个开发环境,分别为开发环境,测试环境和生产环境。不同的环境配置不同的 Host,及第三方 sdk 的 id,如分享,推送等功能。为了方便测试在同一个手机安装不同环境的 apk,还会为不同环境配置不同包名。配置方式如下: 首先在 gradle 文件中添加 productFlavors,同其他的 Android 工程的配置方式:
productFlavors { // 生产环境 flavoronline { applicationId "com.sohu.flavor" } // 开发环境 flavordev { applicationId "com.sohu.flavor.dev" } // 测试环境 flavortest { applicationId "com.sohu.flavor.test" } }
需要注意的是,所有的 Flavor 名称都需要是小写的,原因之后的章节会进行说明。
2.2 为不同 Flavor 配置入口文件
Flutter 项目并没有类似 BuildConfig 这样自动生成不同 Flavor 下的配置文件。如果读取 Android 层的 BuildConfig 文件需要通过 MethodChannel,异步获取。在现实场景中,冷启动的时候就需要根据 Flavor 上报一些数据,或进行业务处理。所以在不同的 Flavor下,Flutter 建议在 App 初始化时指定不同的入口文件来进行不同配置的实现,实现方式如下: 我们知道默认情况下,Flutter 工程的入口文件是为 lib/main.dart。为了实现多入口,我们需要写三个 Flavor 的入口文件来替代 main.dart。分别为:
1. main_android_dev.dart
import 'package:flutter/material.dart'; import 'package:supermarie/common/config/app_config.dart'; import 'package:supermarie/my_app.dart'; void main() { AppConfig.init(ConfigType.androidDev); runApp(MyAppDev()); } class MyAppDev extends StatelessWidget { @override Widget build(BuildContext context) { return MyApp(); } }
2. main_android_test.dart
import 'package:flutter/material.dart'; import 'package:supermarie/common/config/app_config.dart'; import 'package:supermarie/my_app.dart'; void main() { AppConfig.init(ConfigType.androidTest); runApp(MyAppTest()); } class MyAppTest extends StatelessWidget { @override Widget build(BuildContext context) { return MyApp(); } }
3. main_android_online.dart
import 'package:flutter/material.dart'; import 'package:supermarie/common/config/app_config.dart'; import 'package:supermarie/my_app.dart'; void main() { AppConfig.init(ConfigType.androidOnline); runApp(MyAppOnline()); } class MyAppOnline extends StatelessWidget { @override Widget build(BuildContext context) { return MyApp(); } }
其中 AppConfig.init() 方法为根据不同 Flavor 进行的初始化操作。如: 配置 Host,各种 id,key 等。接着再执行 'runApp()' 方法。而 MyApp() 是走完配置后统一的程序入口,即 App 的首页展示。其结构如图所示:
2.3 不同 Flavor 及不同入口的打包
Flavor 和入口文件配置完成后,我们可以尝试打包了,打包方式如下: flutter build apk --[debug/release] --flavor [flavor] -t [entrance] 参数说明: [debug/release]: 指定 debug 或 release,[flavor]: 指定 Flavor,[entrance]: 指定入口文件名称。所以三个Flavor,总共六个包的打包令分别为:
//开发环境: flutter build apk --debug --flavor flavordev -t lib/main_android_dev.dart flutter build apk --release --flavor flavordev -t lib/main_android_dev.dart //测试环境: flutter build apk --debug --flavor flavortest -t lib/main_android_test.dart flutter build apk --release --flavor flavortest -t lib/main_android_test.dart //生产环境: flutter build apk --debug --flavor flavoronline -t lib/main_android_online.dart flutter build apk --release --flavor flavoronline -t lib/main_android_online.dart
如果一切正常的情况下,我们就会在项目根目录 /build/app/outputs/app/[flavor]/[debug/release] 下看到生成的 apk 文件了。好,Flutter 项目下 Android 工程的 Flavor 设置及打包已经完成了。下一章将通过源码解析来梳理 Flutter 项目 Android 包的打包流程。
3. Flutter Android 构建过程详解
Flutter 项目的构建入口文件位于: fluttersdk/packages/flutter_tools/bin 下的 flutter_tools.dart 文件,这是所有平台的构建入口,代码如下:
import 'package:flutter_tools/executable.dart' as executable; void main(List<String> args) { executable.main(args); }
main() 函数里只有一行代码,执行了 executable.dart 的 main() 方法,我们继续看它的实现:
//skip //... await runner.run(args, () => <FlutterCommand>[ //skip //... BuildCommand(verboseHelp: verboseHelp), //skip //... ], verbose: verbose, muteCommandLogging: muteCommandLogging, verboseHelp: verboseHelp, overrides: <Type, Generator>{ //skip //... }); }
这段代码比较长,会初始化很多 command 指令,然后再依次执行。但由于我们分析的是 apk 的构建流程,所以我们只看 BuildCommand() 的执行过程。
BuildCommand({ bool verboseHelp = false }) { addSubcommand(BuildAarCommand(verboseHelp: verboseHelp)); addSubcommand(BuildApkCommand(verboseHelp: verboseHelp)); addSubcommand(BuildAppBundleCommand(verboseHelp: verboseHelp)); addSubcommand(BuildAotCommand()); addSubcommand(BuildIOSCommand(verboseHelp: verboseHelp)); addSubcommand(BuildIOSFrameworkCommand( buildSystem: globals.buildSystem, verboseHelp: verboseHelp, )); addSubcommand(BuildBundleCommand(verboseHelp: verboseHelp)); addSubcommand(BuildWebCommand(verboseHelp: verboseHelp)); addSubcommand(BuildMacosCommand(verboseHelp: verboseHelp)); addSubcommand(BuildLinuxCommand(verboseHelp: verboseHelp)); addSubcommand(BuildWindowsCommand(verboseHelp: verboseHelp)); addSubcommand(BuildFuchsiaCommand(verboseHelp: verboseHelp)); }
BuildCommand() 中添加了很多子 command 指令,用于不同平台或生成文件的构建。由于我们分析的是 apk 的打包,接下来继续看 BuildApkCommand() 的实现,位于 fluttersdk/packages/flutter_tools/lib/src/commands/build_apk.dart:
class BuildApkCommand extends BuildSubCommand { BuildApkCommand({bool verboseHelp = false}) { addTreeShakeIconsFlag(); usesTargetOption(); //skip //... } //skip //... @override Future<FlutterCommandResult> runCommand() async { //skip //... await androidBuilder.buildApk( project: FlutterProject.current(), target: targetFile, androidBuildInfo: androidBuildInfo, ); return FlutterCommandResult.success(); } }
我们看到 BuildApkCommand() 的构造方法先进行了一些初始化配置。回到之前 executable.main() 方法,如果我们继续跟踪 runner.run() 方法,执行 command 的实现,实际上就是执行各个 command 以及其子 command 的代码,在执行到 BuildApkCommand() 时,调用了其 runCommand() 方法,如上述代码所示。这个方法的核心实现就是 androidBuilder.buildApk(),我们接着看 androidBuilder.buildApk() 的实现:
@override Future<void> buildApk({ @required FlutterProject project, @required AndroidBuildInfo androidBuildInfo, @required String target, }) async { try { await buildGradleApp( project: project, androidBuildInfo: androidBuildInfo, target: target, isBuildingBundle: false, localGradleErrors: gradleErrors, ); } finally { globals.androidSdk?.reinitialize(); } }
其中核心代码 buildGradleApp() 方法的位置在 fluttersdk/packages/flutter_tools/lib/src/android/gradle.dart 下。从文件的命名中即可得知,这个文件跟 Android 的 gradle 文件是密切相关的。我们截取 buildGradleApp() 方法核心部分的实现:
Future<void> buildGradleApp({ @required FlutterProject project, @required AndroidBuildInfo androidBuildInfo, @required String target, @required bool isBuildingBundle, @required List<GradleHandledError> localGradleErrors, bool shouldBuildPluginAsAar = false, int retries = 1, }) async { //skip //... // The default Gradle script reads the version name and number // from the local.properties file. updateLocalProperties(project: project, buildInfo: androidBuildInfo.buildInfo); //skip //... final BuildInfo buildInfo = androidBuildInfo.buildInfo; final String assembleTask = isBuildingBundle ? getBundleTaskFor(buildInfo) : getAssembleTaskFor(buildInfo); final Status status = globals.logger.startProgress( "Running Gradle task '$assembleTask'...", timeout: timeoutConfiguration.slowOperation, multilineOutput: true, ); final List<String> command = <String>[ gradleUtils.getExecutable(project), ]; //skip //... command.add(assembleTask); //skip //... final Stopwatch sw = Stopwatch()..start(); int exitCode = 1; try { exitCode = await processUtils.stream( command, workingDirectory: project.android.hostAppGradleRoot.path, allowReentrantFlutter: true, environment: gradleEnvironment, mapFunction: consumeLog, ); } on ProcessException catch (exception) { consumeLog(exception.toString()); // Rethrow the exception if the error isn't handled by any of the // `localGradleErrors`. if (detectedGradleError == null) { rethrow; } } finally { status.stop(); } //skip //... // Gradle produced an APK. final Iterable<String> apkFilesPaths = project.isModule ? findApkFilesModule(project, androidBuildInfo) : listApkPaths(androidBuildInfo); final Directory apkDirectory = getApkDirectory(project); final File apkFile = apkDirectory.childFile(apkFilesPaths.first); if (!apkFile.existsSync()) { _exitWithExpectedFileNotFound( project: project, fileExtension: '.apk', ); } //skip //...
从代码中我们可得知,在 buildGradleApp() 方法中会读取并修改 local.properties 的属性,local.properties 是根据 Flutter 的 pubspec.yaml 文件等配置生成的本地文件。然后根据 buildInfo 获取 assembleTask,设置当前的编译状态 status,初始化 command 数组,并将 assembleTask 加入其中。接着执行 command 列表,其中一个 command 即调用 gradle 执行 assembleTask。执行完成后,生成 apk 文件,进行构建检查,然后构建结束。既然执行到 gradle 文件了,我们再来看看 Flutter 项目下的 gradle 文件和普通 Android 工程的 gradle 文件有什么不同:
def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterRoot = localProperties.getProperty('flutter.sdk') if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" //skip //...
这是截取项目中的 gradle 文件,不同之处都在头部。首先是读取 localProperties 的属性,主要判断 flutter sdk 是否存在,以及获取 app 的 versionCode 和 versionName。然后在执行项目其他 gradle 配置之前,先执行 sdk 中的 flutter.gradle 文件。位置在: fluttersdk/packages/flutter_tools/gradle/flutter.gradle。其结构如下: flutter.gradle 这个文件代码很长,但从这张图上,我们可以很清晰的看出结构。主要任务就是执行了 FlutterPlugin,在 FlutterPlugin 中定义了 FlutterTask 这个任务,而 FlutterTask 必然承载了 Flutter 的编译过程。FlutterTask 的核心代码如下:
@TaskAction void build() { buildBundle() }
buildBundle() 方法就是根据不同的配置,执行了各种 flutter 的编译指令,比如之前示例代码中,我们通过 -t 指定 flutter 层的程序入口。由于这部分已经出离本文的讨论范围,先不做展开了。大家只要知道这部分执行完成后最终生成了 Flutter 层的编译产物即可。经过了原生层和 Flutter 层的构建,整个 Flutter 项目的 Android apk 构建流程就梳理完毕了。构建思路已经非常清晰,但是在打包过程中,我们发现了新的问题,使我们对构建流程的一些细节进行了梳理,下一章将对打包过程中的 bug 和原因进行说明。
4. Flutter SDK 打包 bug 说明
我们回到 Flavor 设置及打包这个章节的开始的 Flavor 配置示例:
productFlavors { // 生产环境 flavoronline { applicationId "com.sohu.flavor" } // 开发环境 flavordev { applicationId "com.sohu.flavor.dev" } // 测试环境 flavortest { applicationId "com.sohu.flavor.test" } }
在最一开始的配置示例中,我们说明了在 gradle 文件中配置的 Flavor 名称必须是小写的。但实际上一开始我们是用的驼峰命名,即: flavorOnline, flavorDev, flavorTest。这是因为我们开发过程是在 macOS 系统下进行的,打包是在 Ubuntu 系统下通过 jenkins 打包完成的。在开发过程中,我们打各个环境下的包都完全没有问题,但是在 Ubuntu 系统下,会报如下错误:-
Gradle build failed to produce an .apk file. It's likely that this file was generated under XXX(目录), but the tool couldn't find it.
通常情况下,这个问题的出现是因为在多渠道打包时,打包命令没有指定相应的入口文件或渠道名称,但实际上我们的打包命令是没有问题的,而且我们在 XXX 目录下找到了 apk 文件,这些 apk 文件在安装运行过程中也都没有任何问题。那么这个问题是怎么出现的呢?先说结论,经过验证,这个是 flutter sdk(1.22.4) 的一个 bug。再说明原因之前,说明一下针对此问题的三种可行解决方案。
不作任何处理,每次安装 apk 包都通过 adb install 命令去安装。jenkins 上虽然每次都会提示打包失败,但实际上打包文件都在,也都会显示在页面中。 Flavor 名称全小写,也就是我们最终的实现方案。 修改 flutter sdk 中的 gradle.dart 文件第 488 行为:final File apkFile = apkDirectory.childFile(apkFilesPaths.first.toLowerCase());
接下来我们分析一下问题产生的原因: 比如我们打一个 debug flavorDev 的包,打包完成后在 XXX 目录下为: app-flavordev-debug.apk。我们回到 gradle.dart 文件,看看打包完成后的执行内容,主要是做一些检查工作:
// Gradle produced an APK. final Iterable<String> apkFilesPaths = project.isModule ? findApkFilesModule(project, androidBuildInfo) : listApkPaths(androidBuildInfo); final Directory apkDirectory = getApkDirectory(project); final File apkFile = apkDirectory.childFile(apkFilesPaths.first); if (!apkFile.existsSync()) { _exitWithExpectedFileNotFound( project: project, fileExtension: '.apk', ); }
void _exitWithExpectedFileNotFound({ @required FlutterProject project, @required String fileExtension, }) { assert(project != null); assert(fileExtension != null); final String androidGradlePluginVersion = getGradleVersionForAndroidPlugin(project.android.hostAppGradleRoot); BuildEvent('gradle-expected-file-not-found', settings: 'androidGradlePluginVersion: $androidGradlePluginVersion, ' 'fileExtension: $fileExtension', flutterUsage: globals.flutterUsage, ).send(); throwToolExit( 'Gradle build failed to produce an $fileExtension file. ' "It's likely that this file was generated under ${project.android.buildDirectory.path}, " "but the tool couldn't find it." ); }
其中 apkFilesPaths 是需要检查的 apk 路径,通过 apkFilesPaths 拿到 apkFile 文件名称,接着对 apkFile 文件进行检查,如果不存在则报如上所述异常。我们看看 apkFilesPaths 是如何生成的,由于 project.isModule 为 false,所以直接看 listApkPaths() 的实现:
Iterable<String> listApkPaths( AndroidBuildInfo androidBuildInfo, ) { final String buildType = camelCase(androidBuildInfo.buildInfo.modeName); final List<String> apkPartialName = <String>[ if (androidBuildInfo.buildInfo.flavor?.isNotEmpty ?? false) androidBuildInfo.buildInfo.flavor, '$buildType.apk', ]; if (androidBuildInfo.splitPerAbi) { return <String>[ for (AndroidArch androidArch in androidBuildInfo.targetArchs) <String>[ 'app', getNameForAndroidArch(androidArch), ...apkPartialName ].join('-') ]; } return <String>[ <String>[ 'app', ...apkPartialName, ].join('-') ]; }
这段代码说明检查的 apk 的命名方式为: 'app-' + androidBuildInfo.buildInfo.flavor + '$buildType.apk'。所以 apkFilesPaths.first 的结果如下:
app-flavorDev-debug.apk
回过头来看 XXX 目录下的 apk 文件: app-flavordev-debug.apk,发现差了一个大小写。所以猜测是大小写问题导致的。我们修改 gradle.dart 的以下代码:
final File apkFile = apkDirectory.childFile(apkFilesPaths.first);
为:
final File apkFile = apkDirectory.childFile(apkFilesPaths.first.toLowerCase());
即检查的 apkFile 的文件名称强制转成小写,发现 build 成功不会再报错。那为什么在 macOS 就不会报错呢?大概是因为不同 os 在针对检查文件一致性的方式不同。Ubuntu 系统下会针对文件名称大小写进行严格检查。
注意: 为了让 Flutter sdk 修改的内容生效,修改代码后需删除 fluttersdk/flutter/bin/cache 路径下的 flutter_tools.snapshot 和 flutter_tools.stamp 重新编译 sdk。
另外,我们知道在 gradle 文件下可以指定输出的文件名称,这个是没有问题的。但是 flutter sdk 中的 flutter.gradle(fluttersdk/packages/flutter_tools/gradle/flutter.gradle) 依旧会在 XXX 目录下输出相应的小写文件,此处可看 flutter.gradle 的相应实现:
variant.outputs.all { output -> // `assemble` became `assembleProvider` in AGP 3.3.0. def assembleTask = variant.hasProperty("assembleProvider") ? variant.assembleProvider.get() : variant.assemble assembleTask.doLast { //skip //... if (variant.flavorName != null && !variant.flavorName.isEmpty()) { filename += "-${variant.flavorName.toLowerCase()}" } filename += "-${buildModeFor(variant.buildType)}" project.copy { from new File("$outputDirectoryStr/${output.outputFileName}") into new File("${project.buildDir}/outputs/flutter-apk"); rename { return "${filename}.apk" } } } }
所以为了保证打包成功,在不修改 sdk 源码的情况下,我们需将 Flavor 设置成小写即可绕过这个 bug。最后说明一下,目前在 Flutter sdk 的 master 分支上,此 bug 已修复。在 gradle.dart 文件中,会检查小写文件,listApkPaths() 的实现如下:
Iterable<String> listApkPaths( AndroidBuildInfo androidBuildInfo, ) { final String buildType = camelCase(androidBuildInfo.buildInfo.modeName); final List<String> apkPartialName = <String>[ if (androidBuildInfo.buildInfo.flavor?.isNotEmpty ?? false) androidBuildInfo.buildInfo.lowerCasedFlavor, '$buildType.apk', ]; //skip //... }
可以看到 apkPartialName 返回的第 1 位是: androidBuildInfo.buildInfo.lowerCasedFlavor,在取 Flavor 的时候,直接取小写属性。所以大家也可以耐心等待,新的稳定版发布后,这个 bug 应该就 fix 了。
5. 总结
经过实践,最终我们的 Flutter 项目 Android 端,使用以上方式进行了 Flavor 配置与属性读取。并且本文通过源码解析梳理了整个构建流程,希望能够帮助大家更好的理解 Flutter 是如何进行 apk 构建的。另外在实践过程中,发现了 sdk 在打包过程中的一个 bug,且给出了解决方案可以在不修改源码的情况下绕过此问题。
6. 参考文献
https://www.jianshu.com/p/b9e7c00075e1from=timeline&isappinstalled=0查看更多关于干货: 在 Flutter 项目下安卓 flavor 打包配置实践 | 开发者说·DTalk的详细内容...