当前位置:   article > 正文

干货:在Flutter项目下安卓flavor打包配置实践

flutter android flavor xm

????????关注后回复 “进群” ,拉你进程序员交流群????????

作者丨狐友技术团队

来源丨搜狐技术产品(ID:sohu-tech)

本文字数:3894 

预计阅读时间:23 分钟

1.前言

Flutter是Google这几年大力推广的跨平台UI框架,可以快速在iOS和Android上构建高质量的原生用户界面。在架构搭建阶段,我们依然需要原生技术的支持。比如说,我们在开发Android项目时,会通过在gradle文件中配置Flavor来实现不同渠道的属性配置,之后通过在编译过程中自动生成BuildConfig文件来读取不同Flavor下的各种属性。

在Flutter项目中,我们如何实现不同Flavor下读取相应属性并实现多渠道打包呢?以及Flutter的打包过程跟Android原生打包有什么不同呢?

本文将以Flutter1.22.4版本为基础,通过Flutter项目中对于Android工程的构建流程详解,来进行Flavor配置说明以及Apk构建过程的详细分析。并针对SDK的bug提出了解决方案及原因梳理。

2.Flavor配置及打包 

2.1Flavor配置 

在移动开发中,我们通常需要配置三个开发环境,分别为开发环境测试环境生产环境。不同的环境配置不同的Host,及第三方sdk的id,如分享,推送等功能。为了方便测试在同一个手机安装不同环境的apk,还会为不同环境配置不同包名。配置方式如下:首先在gradle文件中添加productFlavors,同其他的Android工程的配置方式:

  1. productFlavors {
  2.     // 生产环境
  3.     flavoronline {
  4.         applicationId "com.sohu.flavor"
  5.     }
  6.     // 开发环境
  7.     flavordev {
  8.         applicationId "com.sohu.flavor.dev"
  9.     }
  10.     // 测试环境
  11.     flavortest {
  12.         applicationId "com.sohu.flavor.test"
  13.     }
  14. }

需要注意的是,所有的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

  1. import 'package:flutter/material.dart';
  2. import 'package:supermarie/common/config/app_config.dart';
  3. import 'package:supermarie/my_app.dart';
  4.  
  5. void main() {
  6.   AppConfig.init(ConfigType.androidDev);
  7.   runApp(MyAppDev());
  8. }
  9.  
  10. class MyAppDev extends StatelessWidget {
  11.   @override
  12.   Widget build(BuildContext context) {
  13.     return MyApp();
  14.   }
  15. }

2.main_android_test.dart

  1. import 'package:flutter/material.dart';
  2. import 'package:supermarie/common/config/app_config.dart';
  3. import 'package:supermarie/my_app.dart';
  4.  
  5. void main() {
  6.   AppConfig.init(ConfigType.androidTest);
  7.   runApp(MyAppTest());
  8. }
  9.  
  10. class MyAppTest extends StatelessWidget {
  11.   @override
  12.   Widget build(BuildContext context) {
  13.     return MyApp();
  14.   }
  15. }

3.main_android_online.dart

  1. import 'package:flutter/material.dart';
  2. import 'package:supermarie/common/config/app_config.dart';
  3. import 'package:supermarie/my_app.dart';
  4.  
  5. void main() {
  6.   AppConfig.init(ConfigType.androidOnline);
  7.   runApp(MyAppOnline());
  8. }
  9.  
  10. class MyAppOnline extends StatelessWidget {
  11.  
  12.   @override
  13.   Widget build(BuildContext context) {
  14.     return MyApp();
  15.   }
  16. }

其中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,总共六个包的打包令分别为:

  1. //开发环境:
  2. flutter build apk --debug --flavor flavordev -t lib/main_android_dev.dart
  3. flutter build apk --release --flavor flavordev -t lib/main_android_dev.dart
  4. //测试环境:
  5. flutter build apk --debug --flavor flavortest -t lib/main_android_test.dart
  6. flutter build apk --release --flavor flavortest -t lib/main_android_test.dart
  7. //生产环境:
  8. flutter build apk --debug --flavor flavoronline -t lib/main_android_online.dart
  9. 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文件,这是所有平台的构建入口,代码如下:

  1. import 'package:flutter_tools/executable.dart' as executable;
  2. void main(List<String> args) {
  3.   executable.main(args);
  4. }

main()函数里只有一行代码,执行了executable.dart的main()方法,我们继续看它的实现:

  1. //skip 
  2. //...
  3.   await runner.run(args, () => <FlutterCommand>[
  4. //skip 
  5. //...
  6.     BuildCommand(verboseHelp: verboseHelp),
  7. //skip 
  8. //...
  9.   ], verbose: verbose,
  10.      muteCommandLogging: muteCommandLogging,
  11.      verboseHelp: verboseHelp,
  12.      overrides: <Type, Generator>{
  13. //skip 
  14. //...
  15.      });
  16. }

这段代码比较长,会初始化很多command指令,然后再依次执行。但由于我们分析的是apk的构建流程,所以我们只看BuildCommand()的执行过程。

  1.   BuildCommand({ bool verboseHelp = false }) {
  2.     addSubcommand(BuildAarCommand(verboseHelp: verboseHelp));
  3.     addSubcommand(BuildApkCommand(verboseHelp: verboseHelp));
  4.     addSubcommand(BuildAppBundleCommand(verboseHelp: verboseHelp));
  5.     addSubcommand(BuildAotCommand());
  6.     addSubcommand(BuildIOSCommand(verboseHelp: verboseHelp));
  7.     addSubcommand(BuildIOSFrameworkCommand(
  8.       buildSystem: globals.buildSystem,
  9.       verboseHelp: verboseHelp,
  10.     ));
  11.     addSubcommand(BuildBundleCommand(verboseHelp: verboseHelp));
  12.     addSubcommand(BuildWebCommand(verboseHelp: verboseHelp));
  13.     addSubcommand(BuildMacosCommand(verboseHelp: verboseHelp));
  14.     addSubcommand(BuildLinuxCommand(verboseHelp: verboseHelp));
  15.     addSubcommand(BuildWindowsCommand(verboseHelp: verboseHelp));
  16.     addSubcommand(BuildFuchsiaCommand(verboseHelp: verboseHelp));
  17.   }

BuildCommand()中添加了很多子command指令,用于不同平台或生成文件的构建。由于我们分析的是apk的打包,接下来继续看BuildApkCommand()的实现,位于fluttersdk/packages/flutter_tools/lib/src/commands/build_apk.dart

  1. class BuildApkCommand extends BuildSubCommand {
  2.   BuildApkCommand({bool verboseHelp = false}) {
  3.     addTreeShakeIconsFlag();
  4.     usesTargetOption();
  5. //skip
  6. //...
  7.   }
  8. //skip
  9. //...
  10.   @override
  11.   Future<FlutterCommandResult> runCommand() async {
  12. //skip
  13. //...
  14.     await androidBuilder.buildApk(
  15.       project: FlutterProject.current(),
  16.       target: targetFile,
  17.       androidBuildInfo: androidBuildInfo,
  18.     );
  19.     return FlutterCommandResult.success();
  20.   }
  21. }

我们看到BuildApkCommand()的构造方法先进行了一些初始化配置。回到之前executable.main()方法,如果我们继续跟踪runner.run()方法,执行command的实现,实际上就是执行各个command以及其子command的代码,在执行到BuildApkCommand()时,调用了其runCommand()方法,如上述代码所示。这个方法的核心实现就是androidBuilder.buildApk(),我们接着看androidBuilder.buildApk()的实现:

  1.   @override
  2.   Future<void> buildApk({
  3.     @required FlutterProject project,
  4.     @required AndroidBuildInfo androidBuildInfo,
  5.     @required String target,
  6.   }) async {
  7.     try {
  8.       await buildGradleApp(
  9.         project: project,
  10.         androidBuildInfo: androidBuildInfo,
  11.         target: target,
  12.         isBuildingBundle: false,
  13.         localGradleErrors: gradleErrors,
  14.       );
  15.     } finally {
  16.       globals.androidSdk?.reinitialize();
  17.     }
  18.   }

其中核心代码buildGradleApp()方法的位置在fluttersdk/packages/flutter_tools/lib/src/android/gradle.dart下。从文件的命名中即可得知,这个文件跟Android的gradle文件是密切相关的。我们截取buildGradleApp()方法核心部分的实现:

  1. Future<void> buildGradleApp({
  2.   @required FlutterProject project,
  3.   @required AndroidBuildInfo androidBuildInfo,
  4.   @required String target,
  5.   @required bool isBuildingBundle,
  6.   @required List<GradleHandledError> localGradleErrors,
  7.   bool shouldBuildPluginAsAar = false,
  8.   int retries = 1,
  9. }) async {
  10. //skip
  11. //...
  12.   // The default Gradle script reads the version name and number
  13.   // from the local.properties file.
  14.   updateLocalProperties(project: project, buildInfo: androidBuildInfo.buildInfo);
  15. //skip
  16. //...
  17.   final BuildInfo buildInfo = androidBuildInfo.buildInfo;
  18.   final String assembleTask = isBuildingBundle
  19.     ? getBundleTaskFor(buildInfo)
  20.     : getAssembleTaskFor(buildInfo);
  21.   final Status status = globals.logger.startProgress(
  22.     "Running Gradle task '$assembleTask'...",
  23.     timeout: timeoutConfiguration.slowOperation,
  24.     multilineOutput: true,
  25.   );
  26.   final List<String> command = <String>[
  27.     gradleUtils.getExecutable(project),
  28.   ];
  29. //skip
  30. //...
  31.   command.add(assembleTask);
  32. //skip
  33. //...
  34.   final Stopwatch sw = Stopwatch()..start();
  35.   int exitCode = 1;
  36.   try {
  37.     exitCode = await processUtils.stream(
  38.       command,
  39.       workingDirectory: project.android.hostAppGradleRoot.path,
  40.       allowReentrantFlutter: true,
  41.       environment: gradleEnvironment,
  42.       mapFunction: consumeLog,
  43.     );
  44.   } on ProcessException catch (exception) {
  45.     consumeLog(exception.toString());
  46.     // Rethrow the exception if the error isn't handled by any of the
  47.     // `localGradleErrors`.
  48.     if (detectedGradleError == null) {
  49.       rethrow;
  50.     }
  51.   } finally {
  52.     status.stop();
  53.   }
  54. //skip
  55. //...
  56.   // Gradle produced an APK.
  57.   final Iterable<String> apkFilesPaths = project.isModule
  58.     ? findApkFilesModule(project, androidBuildInfo)
  59.     : listApkPaths(androidBuildInfo);
  60.   final Directory apkDirectory = getApkDirectory(project);
  61.   final File apkFile = apkDirectory.childFile(apkFilesPaths.first);
  62.   if (!apkFile.existsSync()) {
  63.     _exitWithExpectedFileNotFound(
  64.       project: project,
  65.       fileExtension: '.apk',
  66.     );
  67.   }
  68. //skip
  69. //...

从代码中我们可得知,在buildGradleApp()方法中会读取并修改local.properties的属性,local.properties是根据Flutter的pubspec.yaml文件等配置生成的本地文件。然后根据buildInfo获取assembleTask,设置当前的编译状态status,初始化command数组,并将assembleTask加入其中。接着执行command列表,其中一个command即调用gradle执行assembleTask。执行完成后,生成apk文件,进行构建检查,然后构建结束。既然执行到gradle文件了,我们再来看看Flutter项目下的gradle文件和普通Android工程的gradle文件有什么不同:

  1. def localProperties = new Properties()
  2. def localPropertiesFile = rootProject.file('local.properties')
  3. if (localPropertiesFile.exists()) {
  4.     localPropertiesFile.withReader('UTF-8') { reader ->
  5.         localProperties.load(reader)
  6.     }
  7. }
  8. def flutterRoot = localProperties.getProperty('flutter.sdk')
  9. if (flutterRoot == null) {
  10.     throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
  11. }
  12. def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
  13. if (flutterVersionCode == null) {
  14.     flutterVersionCode = '1'
  15. }
  16. def flutterVersionName = localProperties.getProperty('flutter.versionName')
  17. if (flutterVersionName == null) {
  18.     flutterVersionName = '1.0'
  19. }
  20. apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
  21. //skip
  22. //...

这是截取项目中的gradle文件,不同之处都在头部。首先是读取localProperties的属性,主要判断flutter sdk是否存在,以及获取app的versionCodeversionName。然后在执行项目其他gradle配置之前,先执行sdk中的flutter.gradle文件。位置在:fluttersdk/packages/flutter_tools/gradle/flutter.gradle。其结构如下:


flutter.gradle这个文件代码很长,但从这张图上,我们可以很清晰的看出结构。主要任务就是执行了FlutterPlugin,在FlutterPlugin中定义了FlutterTask这个任务,而FlutterTask必然承载了Flutter的编译过程。FlutterTask的核心代码如下:

  1.     @TaskAction
  2.     void build() {
  3.         buildBundle()
  4.     }

buildBundle()方法就是根据不同的配置,执行了各种flutter的编译指令,比如之前示例代码中,我们通过-t指定flutter层的程序入口。由于这部分已经出离本文的讨论范围,先不做展开了。大家只要知道这部分执行完成后最终生成了Flutter层的编译产物即可。经过了原生层和Flutter层的构建,整个Flutter项目的Android apk构建流程就梳理完毕了。构建思路已经非常清晰,但是在打包过程中,我们发现了新的问题,使我们对构建流程的一些细节进行了梳理,下一章将对打包过程中的bug和原因进行说明。

4.Flutter SDK打包bug说明 

我们回到Flavor设置及打包这个章节的开始的Flavor配置示例:

  1. productFlavors {
  2.     // 生产环境
  3.     flavoronline {
  4.         applicationId "com.sohu.flavor"
  5.     }
  6.     // 开发环境
  7.     flavordev {
  8.         applicationId "com.sohu.flavor.dev"
  9.     }
  10.     // 测试环境
  11.     flavortest {
  12.         applicationId "com.sohu.flavor.test"
  13.     }
  14. }

在最一开始的配置示例中,我们说明了在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。再说明原因之前,说明一下针对此问题的三种可行解决方案。

1.不作任何处理,每次安装apk包都通过adb install命令去安装。jenkins上虽然每次都会提示打包失败,但实际上打包文件都在,也都会显示在页面中。

2.Flavor名称全小写,也就是我们最终的实现方案。

3.修改flutter sdk中的gradle.dart文件第488行为:

final File apkFile = apkDirectory.childFile(apkFilesPaths.first.toLowerCase());

接下来我们分析一下问题产生的原因:比如我们打一个debug flavorDev的包,打包完成后在XXX目录下为:app-flavordev-debug.apk。我们回到gradle.dart文件,看看打包完成后的执行内容,主要是做一些检查工作:

  1.   // Gradle produced an APK.
  2.   final Iterable<String> apkFilesPaths = project.isModule
  3.     ? findApkFilesModule(project, androidBuildInfo)
  4.     : listApkPaths(androidBuildInfo);
  5.   final Directory apkDirectory = getApkDirectory(project);
  6.   final File apkFile = apkDirectory.childFile(apkFilesPaths.first);
  7.   if (!apkFile.existsSync()) {
  8.     _exitWithExpectedFileNotFound(
  9.       project: project,
  10.       fileExtension: '.apk',
  11.     );
  12.   }
  1. void _exitWithExpectedFileNotFound({
  2.   @required FlutterProject project,
  3.   @required String fileExtension,
  4. }) {
  5.   assert(project != null);
  6.   assert(fileExtension != null);
  7.  
  8.   final String androidGradlePluginVersion =
  9.   getGradleVersionForAndroidPlugin(project.android.hostAppGradleRoot);
  10.   BuildEvent('gradle-expected-file-not-found',
  11.     settings:
  12.     'androidGradlePluginVersion: $androidGradlePluginVersion, '
  13.       'fileExtension: $fileExtension',
  14.     flutterUsage: globals.flutterUsage,
  15.   ).send();
  16.   throwToolExit(
  17.     'Gradle build failed to produce an $fileExtension file. '
  18.     "It's likely that this file was generated under ${project.android.buildDirectory.path}, "
  19.     "but the tool couldn't find it."
  20.   );
  21. }

其中apkFilesPaths是需要检查的apk路径,通过apkFilesPaths拿到apkFile文件名称,接着对apkFile文件进行检查,如果不存在则报如上所述异常。我们看看apkFilesPaths是如何生成的,由于project.isModulefalse,所以直接看listApkPaths()的实现:

  1. Iterable<String> listApkPaths(
  2.   AndroidBuildInfo androidBuildInfo,
  3. ) {
  4.   final String buildType = camelCase(androidBuildInfo.buildInfo.modeName);
  5.   final List<String> apkPartialName = <String>[
  6.     if (androidBuildInfo.buildInfo.flavor?.isNotEmpty ?? false)
  7.       androidBuildInfo.buildInfo.flavor,
  8.     '$buildType.apk',
  9.   ];
  10.   if (androidBuildInfo.splitPerAbi) {
  11.     return <String>[
  12.       for (AndroidArch androidArch in androidBuildInfo.targetArchs)
  13.         <String>[
  14.           'app',
  15.           getNameForAndroidArch(androidArch),
  16.           ...apkPartialName
  17.         ].join('-')
  18.     ];
  19.   }
  20.   return <String>[
  21.     <String>[
  22.       'app',
  23.       ...apkPartialName,
  24.     ].join('-')
  25.   ];
  26. }

这段代码说明检查的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.snapshotflutter_tools.stamp重新编译sdk。

另外,我们知道在gradle文件下可以指定输出的文件名称,这个是没有问题的。但是flutter sdk中的flutter.gradle(fluttersdk/packages/flutter_tools/gradle/flutter.gradle)依旧会在XXX目录下输出相应的小写文件,此处可看flutter.gradle的相应实现:

  1. variant.outputs.all { output ->
  2.     // `assemble` became `assembleProvider` in AGP 3.3.0.
  3.     def assembleTask = variant.hasProperty("assembleProvider")
  4.         ? variant.assembleProvider.get()
  5.         : variant.assemble
  6.     assembleTask.doLast {
  7. //skip
  8. //...
  9.         if (variant.flavorName != null && !variant.flavorName.isEmpty()) {
  10.             filename += "-${variant.flavorName.toLowerCase()}"
  11.         }
  12.         filename += "-${buildModeFor(variant.buildType)}"
  13.         project.copy {
  14.             from new File("$outputDirectoryStr/${output.outputFileName}")
  15.             into new File("${project.buildDir}/outputs/flutter-apk");
  16.             rename {
  17.                 return "${filename}.apk"
  18.             }
  19.         }
  20.     }
  21. }

所以为了保证打包成功,在不修改sdk源码的情况下,我们需将Flavor设置成小写即可绕过这个bug。最后说明一下,目前在Flutter sdk的master分支上,此bug已修复。在gradle.dart文件中,会检查小写文件,listApkPaths()的实现如下:

  1. Iterable<String> listApkPaths(
  2.   AndroidBuildInfo androidBuildInfo,
  3. ) {
  4.   final String buildType = camelCase(androidBuildInfo.buildInfo.modeName);
  5.   final List<String> apkPartialName = <String>[
  6.     if (androidBuildInfo.buildInfo.flavor?.isNotEmpty ?? false)
  7.       androidBuildInfo.buildInfo.lowerCasedFlavor,
  8.     '$buildType.apk',
  9.   ];
  10. //skip
  11. //...
  12. }

可以看到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

-End-

最近有一些小伙伴,让我帮忙找一些 面试题 资料,于是我翻遍了收藏的 5T 资料后,汇总整理出来,可以说是程序员面试必备!所有资料都整理到网盘了,欢迎下载!

点击????卡片,关注后回复【面试题】即可获取

在看点这里好文分享给更多人↓↓

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/515288
推荐阅读
相关标签
  

闽ICP备14008679号