干货: 在 Flutter 项目下安卓 flavor 打包配置实践 | 开发者说·DTalk

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。再说明原因之前,说明一下针对此问题的三种可行解决方案。

  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 文件,看看打包完成后的执行内容,主要是做一些检查工作:

// 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

手机扫码阅读