用 Flutter 开发 Android 项目的兼容性血泪史

 

这几天摸鱼的时候打算升级一下 flutter_nfc_kitNFSee 的各种依赖,包括 Flutter SDK、第三方包、Android SDK、兼容工具等。不折腾不要紧,一搞就是两整天。仅以本文记录这两天中我踩过的种种大坑,以及借此总结出的一些经验。

背景:Flutter Android 项目结构与编译方式

Flutter 虽然是跨平台开发工具,但必然不可避免地涉及到各个平台的原生代码。一个支持 Android 平台的 Flutter 库通常会有如此结构:

- android
  - settings.gradle + build.gradle
  - src/main
    - AndroidManifest.xml
    - [Kotlin / Java code]
- lib
  - [Dart code]
- example
  - [example app as below]
- pubspec.yaml

而一个可以编译到 Android 的 Flutter 程序的结构类似:

- lib
  - [Dart code]
- android
    - app
    - build.gradle
    - src/main
        - AndroidManifest.xml
        - [Kotlin / Java code]
    - settings.gradle + build.gradle
- pubspec.yaml

通常库也会带一个示例程序,所以大部分库的 example 目录下也有上面的结构。为了清晰起见,我们把库和程序分开考虑,否则层级就太多了。

与传统 Android 开发(或者广泛地说,传统 Java 开发)的第三方库都以编译后的 JAR 的形式(即使包含代码,也只是给开发者参考的)发布不同的是,Dart 采用了时下比较流行(说的就是 Rust)的源码发布模式,开发者需要将自己的库的所有代码上传到 。在编译应用时,Flutter 会自动将所有依赖的代码组合起来。在 Android 端,就是将所有包含原生代码的库生成为 Gradle 子项目(通常名为 `:library_name`),并使得最终编译任务(如熟悉的 `:app:assembleDebug`)依赖所有的子项目。

为什么会踩坑

这还用问吗?当然是因为我什么都想升级到最新的。

坑 1:Gradle 版本不兼容

目前最新的 Gradle 是 8.3。Gradle 的 8 和 7 之间有比较大的差异,尤其是移除了很多旧 API。因此如果依赖的任何项目的构建脚本中用到了这些 API,那就必然只能用 Gradle 7。此外还有一些具体的语法更改,这些虽然总是可以迁移的;但在不方便修改子项目构建脚本的前提下,实施起来显得就比较困难了。

此外,还需要参阅 兼容矩阵,确定系统的 Java 版本对应的最低 Gradle 版本。如 Java 17 LTS 可以使用的最低版本是 7.3。

我的解决方法:使用到 Gradle 7.6.2(最新的 7.x 版本)。此外还需要控制项目引入的第三方库版本,使得它们不依赖过新的 Gradle(例子如 sqlite3_flutter_libs 从 0.5.14 开始依赖 AGP 8,因此传递依赖 Gradle 8,无法使用)。

坑 2:Gradle 插件多版本不兼容

构建 Android 项目时,需要使用 Android Gradle 插件(AGP),它分为库(library)和应用(app)两个版本。如果项目中使用了 Kotlin,那么还需要引入 kotlin-android 插件。这些插件会创建和修改一系列构建任务(如打包 APK、处理资源文件等),以满足实际的构建需要。

原本项目 Gradle 插件以构建脚本依赖(buildscript.dependency)的形式声明,以 apply plugin 的方式引入,但官方现在更建议使用 plugin 语句来声明和引入。这样当然更清晰,但会产生一个问题:在整个 Gradle 构建中,每个插件的版本必须只能声明一次(相比而言,引入的依赖通常会取兼容的最高版本),任何第二次声明都会导致构建错误(哪怕和首次一致)。具体的报错类似:

Error resolving plugin [id: 'com.android.library', version: '8.1.0', apply: false]
Plugin request for plugin already on the classpath must not include a version

我的解决方法:只在应用项目的根构建脚本(build.gradle)中指定所有需要用到的插件版本(但不实际应用插件),而在应用/库的实际构建脚本中只声明插件 ID,不声明版本。这样就可以避免重复声明版本的问题。如:

// android/build.gradle
buildscript {
    ext.kotlin_version = '1.9.10' // used in other dependencies
}
plugins {
    id 'com.android.application' version '7.0.4' apply false
    id 'com.android.library' version '7.0.4' apply false
    id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
}
// android/app/build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}
// some_library/android/build.gradle
plugins {
    id 'com.android.library'
    id 'org.jetbrains.kotlin.android'
}

坑 3:新版 Android Gradle 插件的“兼容性检查”

AGP 在 2021 年底引入了“兼容性检查”的特性(相关 commit)。具体来说,在构建脚本生成所有构建任务后,开始实际构建前,会检查根项目使用的 AGP 版本是否与所有子项目使用的版本是“兼容”的,如果不兼容则会报错。但根据代码,事实上项目中的所有 AGP 只能是相同的版本。

这个要求是可以理解的,因为 Gradle 对插件的上述版本检查可以保证以插件形式引入的 AGP 版本相同,但以传统依赖方式引入的 AGP 不会经过检查。而不一致的版本确实可能导致潜在的互操作问题。然而对于 Flutter 项目来说,第三方库用了什么版本的 AGP 是无法控制的,因此这意味着整个项目基本不可能通过检查

很遗憾的是,简单的代码阅读告诉我检查一旦加进去,是无法再用公开 API 移除的。我尝试使用反射进行了一些修改(吐槽一下 Groovy 的动态装饰类带来了不少麻烦),但还是放弃了。大致原因是不只有来自于 AGP 的检查会导致构建失败,还有 Gradle 自带的属性(Attr)检查,整体魔改起来确实比较复杂。

我的解决方法:换一个没有引入此检查的版本,也就是上面提到的 AGP 7.0.4。

其他说明

我没有尝试用 Kotlin DSL 写构建脚本,或许此后可以尝试一下,但看起来这些坑依旧是存在的。