大规模项目完全 gradle 化的一次落地总结

642 阅读9分钟

工匠若水可能会迟到,但是从来不会缺席,最终还是觉得将自己的云笔记分享出来吧 ~

背景

项目规模航母级庞大,年代久远,导致很多模块迟迟没有完全 gradle 化,所以浪费了一定的开发效率。由于构建采用了类似 c 语言的宏定义概念控制,加上自定义的拆 dex 构建,对部分大型模块不熟悉,所以整个构建无比复杂,从而对完全 gradle 化带来了一些坑。本文仅以总结记录自己的落地经验,以便后续回忆分析。

落地过程中的坑与解决方案

整体迁移的思路是先依据各种 ant 构建自定义的 build.xml 翻译成对应规则的 build.gradle 脚本,接着进行依赖调整,接着进行糖宏检查适配,接着进行各 dex 打入内容与 ant 对比确认,最后对 apk 文件对比分析进行增删处理,全量全渠道构建,然后进行测试与灰度。

整个过程的原则是保证原目录结构不动,尽可能不修改任何源码,做到仅构建脚本的迁移,所以小问题不断。

ant 硬伤带来的问题

原来采用 eclipse 的目录结构,现在通过 sourceSets 来重新指向目录,所以产生了新build_common.gradle文件,如下:

apply plugin: 'com.android.library'

android {
    useLibrary 'org.apache.http.legacy'
    compileSdkVersion rootProject.ext.compileSdkVersion
    buildToolsVersion rootProject.ext.buildToolsVersion
    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 27
    }
    sourceSets {
        main {
            manifest.srcFile 'AndroidManifest.xml'
            java.srcDirs = ['src']
            resources.srcDirs = ['src']
            aidl.srcDirs = ['src']
            renderscript.srcDirs = ['src']
            res.srcDirs = ['res']
            assets.srcDirs = ['assets']
        }

        instrumentTest.setRoot('tests')
        debug.setRoot('build-types/debug')
        release.setRoot('build-types/release')
    }
    compileOptions {
        sourceCompatibility rootProject.ext.JAVA_SOURCE_VERSION
        targetCompatibility rootProject.ext.JAVA_TARGET_VERSION
    }
}

通过上面操作解决了大部分通用模块,其余特殊的模块通过 apply 这个共用 gradle 文件并重写相关目录实现。

R 文件合并时的问题

老项目大都是 eclipse 迁移过来,包同名的 R 文件比较多,以前 ant 构建没有 aar 的概念,构建包含android.jar的 jar 包都是主动 classpath 追加的,现在迁移到 gradle 后有十几个原来的 jar module 需要迁移成 aar 的模块,这样就能回归到标准构建的 android 代码模块。所以需要新加AndroidManifest.xml清单文件,而清单文件中的package="cn.company.app.base"值必须要特别注意,因为 android 构建多模块在最后合并打包时会先进行资源合并,所以如果 package 的值和别的模块的清单文件值相同就会导致 gradle 构建失败,提示合并清单文件重复冲突。

出现上面问题的原因就是 aar 模块项目构建中间产物gradleBuild/intermediates/classes/debug/下的R.javaBuildConfig.java文件的包名就是其AndroidManifest.xml清单文件中的 package 值。

aar 包丢失 aidl 文件问题

原来通过 ant 构建时指定源码目录下的 aidl 文件也会被自动打入 jar 包中,而换成 gradle 的 aar 后默认 gradle 打包 aar 不会包含 aidl 文件,所以当这些 aar 应用给别的 module 时就会构建提示 aidl 文件缺失,因此需要让 aidl 文件打包进 aar 包,做法如下:

android {
    aidlPackageWhiteList 'cn/app/xx/Behavior.aidl', \
            'cn/app/xx/aa/ApplyTo.aidl'
    sourceSets {
        //...
    }
}

so 库构建问题

这个问题比较坑,做过大型项目的小伙伴应该都能理解。项目超级大,里面一些 so 是使用第三方现成的,一些 so 是我们自己构建生成的,而我们自己构建生成的 so 都比较复杂,尤其是构建参数,并且不同的 so 是不同的业务团队负责的。在整体迁移 ant 到 gradle 构建时强制其他团队进行配合整改是基本不现实的,因为这些 so 进行迁移构建对测试团队的压力很大,所以评估后我选择了 so 构建继续保持原来的 ndk-build 方式,不过这里也有坑。

一开始我想将我们 ant 里的 ndk-build 构建直接迁移到 gradle 的externalNativeBuild.ndkBuild形式,但是比较坑的是我们 gradle 是定制版的(dex分包),而我们这个版本的externalNativeBuild.ndkBuild恰巧只支持 path 配置,不支持参数配置,所以挺坑,升级 gradle 插件代价太大,所以这种方式就走不通了,只能将来 gradle 升级后使用。

接着想着要不将现有的 mk 构建迁移到 cmake,评估发现对测试影响太大,最好能先保持现状,所以我就走了一条歪路,也有坑,那就是自己写个 gradle 脚本通过 ndk-build 传递参数构建,结果上网搜了一下,随便复制一个 doLast 那种挂载在构建 task 上,以为万事大吉了,结果在构建机上的包老是出现一会儿 so 有编译进去,一会儿没有,然后排查发现项目根目录的gradle.properties文件中有如下配置:

org.gradle.daemon=true  
org.gradle.parallel=false //开启并行构建
org.gradle.configureondemand=true
org.gradle.caching=true

由此怀疑是并行构建的问题,所以看了下网上思路的实现,一想确实有锅,不能只 doLast,还得挂在在一些 task(ndk合并)前面,只有这样才能保证并行构建每次取到正常结束的 so,所以我为了更加完美,就写了如下ndk_cmd_build.gradle脚本:

/**
 * 由于 gradle plugin 版本不够新,external native build 暂时用不了 Application.mk,
 * 所以实现一个通用的 ndk-build cmd 构建。
 * 支持并行构建、flavour、buildtype。
 * task 名为 compile${targetName}NdkBuildCmd,其中 ${targetName} = "${productFlavorName}${buildTypeName}"。
 */

def runBefore(String dependentTaskName, Task task) {
    Task dependentTask = tasks.findByPath(dependentTaskName)
    if (dependentTask != null) {
        dependentTask.dependsOn task
    }
}

def mustRunAfter(String dependentTaskName, Task task) {
    Task dependentTask = tasks.findByPath(dependentTaskName)
    if (dependentTask != null) {
        dependentTask.mustRunAfter(task)
    }
}

project.afterEvaluate {
    def ndkBuildCmdArgs = project.hasProperty("ndkBuildCmdArgs") ? project.ndkBuildCmdArgs : []
    def buildTypes = android.buildTypes.collect { type -> type.name }
    def productFlavors = android.productFlavors.collect { flavor -> flavor.name }

    if (!productFlavors) {
        productFlavors.add('')
    }

    productFlavors.each { productFlavorName ->
        buildTypes.each { buildTypeName ->
            def flavorNameCapitalized = "${productFlavorName.capitalize()}"
            def buildNameCapitalized = "${buildTypeName.capitalize()}"
            def targetName = "${flavorNameCapitalized}${buildNameCapitalized}"

            def ndkCmdBuildTaskName = "compile${targetName}NdkBuildCmd"

            def ndkCmdBuildTask = tasks.create(name: ndkCmdBuildTaskName, type: Exec) {
                group = "other"
                description = "ndk build with cmd style for ${targetName}."

                doFirst {
                    println("${project.name} start ndk build use cmd...")
                    def ndkDir = System.getenv("ANDROID_NDK_HOME")
                    if (ndkDir == null) {
                        Properties properties = new Properties()
                        properties.load(new FileReader("$rootDir/local.properties"))
                        ndkDir = properties.getProperty("ndk.dir")
                    }
                    println("#command is# ${ndkDir}/ndk-build ${ndkBuildCmdArgs}")
                    if (ndkDir != null) {
                        if (org.gradle.internal.os.OperatingSystem.current().isWindows()) {
                            commandLine("${ndkDir}\\ndk-build.cmd", *ndkBuildCmdArgs)
                        } else {
                            commandLine("${ndkDir}/ndk-build", *ndkBuildCmdArgs)
                        }
                    } else {
                        commandLine "echo", "ndk path is null, please check you environment(example: export ANDROID_NDK_HOME=/opt/android-ndk-r10d, the key must named as ANDROID_NDK_HOME)!"
                    }
                }

                if (ndkBuildCmdArgs.size() == 0) {
                    enabled = false
                }
            }
            //runBefore("pre${targetName}Build", ndkCmdBuildTask)
            runBefore("compile${targetName}Ndk", ndkCmdBuildTask)
            //按照源码里几个 task 都处理下!
            mustRunAfter("compile${targetName}Renderscript", ndkCmdBuildTask)
            mustRunAfter("merge${targetName}JniLibFolders", ndkCmdBuildTask)
            mustRunAfter("transformNativeLibsWithIntermediateJniLibsFor${targetName}", ndkCmdBuildTask)
            mustRunAfter("transformNativeLibsWithMergeJniLibsFor${targetName}AndroidTest", ndkCmdBuildTask)
            mustRunAfter("transformNativeLibsWithMergeJniLibsFor${targetName}", ndkCmdBuildTask)
            mustRunAfter("transformNativeLibsWithStripDebugSymbolFor${targetName}", ndkCmdBuildTask)
            mustRunAfter("transformNativeLibsWithSyncJniLibsFor${targetName}", ndkCmdBuildTask)
        }
    }

    def ndkCmdBuildCleanTask = tasks.create(name: "ndkCmdBuildClean", type: Delete) {
        group = "build"
        description = "clean the build of ndk build with cmd."
        def ndkBuildCmdClean = project.hasProperty("ndkBuildCmdClean") ? project.ndkBuildCmdClean : []
        if (ndkBuildCmdClean.size() == 0) {
            enabled = false
        }

        doFirst {
            delete ndkBuildCmdClean
        }
    }
    runBefore("clean", ndkCmdBuildCleanTask)
}

如上实现了一个全新的构建 task,叫做compile${targetName}NdkBuildCmd,so 的构建都通过他来处理,需要构建项目如下配置即可:

apply from: '../../build_common.gradle'
apply from: '../../ndk_cmd_build.gradle'
ext {
    ndkBuildCmdArgs = ['-C', file('jni').absolutePath, '-j', Runtime.runtime.availableProcessors(), 'NDK_DEBUG=0']
    ndkBuildCmdClean = [file('libs'), file('obj')]
}

android {
    sourceSets {
        main {
            jniLibs.srcDir 'libs'
            java.srcDirs = ['src', 'androidImpl']
        }
    }
}

这样在构建时就能看到 so 了,也就解决这个问题。还是要升级 gradle 就美了,就能迁移到官方标准参数了,因为高版本 gradle 已经完美支持参数配置了。

多 dex 爆炸问题

我们的 dex 分包是在主项目合并后的结果中依据包目录结构和 jar 包路径进行划分剪切到对应 dex 目录实现的,原来 ant 构建很多路径在现有的多 dex 插件分包规则中已经不匹配,所以一上来构建就炸了,按照规则重新适配即可解决。

apk 大小对比分析及 dex 打错问题

一切构建都通过了,接下来就是二次确认,所以确认操作就是通过我们多 dex 构建的 log 去排查有没有打串或者打错 dex 目录结构的,确认后进行微调即可。

apk 大小是通过 AS 的分析工具进行处理的,简单来说就是对比即可,首先构建一个 ant 包,接着构建一个同一基线的 gradle 包,然后通过 AS 分析工具的 compare 进行对比生成差异清单,看下多打了什么,然后删除多打的东西即可。

定位 apk 多打入莫名其妙东西和少打入一些东西的问题

通过 gradle 构建后的包确实多了一些东西,譬如 xml、assert、okhttp 的一些文件等。这些文件需要处理掉,如下:

android {
    //打包时不打入assert目录下的指定资源
    aaptOptions {
        //https://stackoverflow.com/questions/25910206/gradle-exclude-file-from-android-assets-folder
        ignoreAssetsPattern "!xxxx"
    }

    //不打包的东西
    packagingOptions {
        exclude 'xxx/xxx/*.xml'
        //......
    }
}

接着就是少打入东西的问题,原因其实就是原来 eclipse 项目,有一些配置文件放在项目的 src 目录下的,所以 gradle java 构建 jar 默认是不包含的,因此想让这些位于 src 下的配置文件打入最终 apk 的根目录就需要如下配置:

apply from: '../build_java.gradle'

processResources {
    from('src') {
        include 'xxxx.json'
        include 'xxxx.types'
        include 'sdk-xxx.txt'
        include 'xxx-certs.crt'
    }
}

如上方式打包出来的 apk 根目录下就自动有这些配置文件了。

启动 apk 加载 assert 下指定文件崩溃

这个问题是因为 aapt 在打包时会对 assert 目录下的一些非特定后缀文件进行压缩,所以 app 中直接 assert 读取时就会提示格式错误异常,解决这个问题也很容易,如下:

android {
    //指定后缀文件不压缩
    aaptOptions {
        noCompress "tflite"
    }
}

sourceSet 不覆盖问题

这个问题是因为 apply 了一些 common 的 gradle 文件,其中已经定义了 sourceSet,如果当前文件对 sourceSet 进行 include 新目录会导致原来 common 里面的目录失效,所以需要完全重写即可。

windows ndk 问题

这个问题十分坑爹,linux 构建完全没问题,windows 下构建 ndk 出问题,构建 log 看到的 path 总是没转义的,奇怪了很久,发下那是自己修改的问题,其实就是Android.mk中的路径结尾少加了字符串换行连接操作,如下:

//缩略样例
export include = $(path)/ \

Application.mkAPP_PLATFORM := android-14无效问题

这个问题是因为 ndk 构建失败才发现的,一开始还在纳闷,两种构建没区别啊!代码也一样啊,为啥这样就编译不过,后来看 log 才发现 gradle 读到的 APP_PLATFORM 是 1,所以认为 jni 代码里面很多有兼容问题,所以去看了下 gradle 插件代码,发现这玩意在 gradle 下已经不起作用了,赋值是读的 minSdkVersion 值,所以对该 module 的 gradle 设置 minSdkVersion 为 14 即可构建通过(这其实也是 ndk 的最小兼容特性,没读到就会有最低版本的 ndk 去尝试编译,所以失败了)。

总结

路漫漫其修远兮,吾将上下而求索。

很多人说为什么要这么去兼容,自己也不会遇到这些问题,为什么不用官方的,我只想说我也想,只是项目真的太大,很多事不是开发层面的问题,而是沟通协作的问题。

统归来说,收获的经验就是知道与做到的差距其实真的很大。上面总结的问题其实大多数自己都知道,但是配合上不熟悉且复杂的构建后,思维总是在掉链子。所以纯技术上的收获其实没什么,但是技术外的感悟却收获满满,真的是行胜于言,做一个行动派吧。

【工匠若水 未经允许严禁转载,请尊重作者劳动成果。+微信 codedeveloper 联系我】