|
起源近期,由于引入的新工具依赖AndroidGradlePlugin(后面都简写为AGP)4.1或以上版本,而项目当前使用的AGP版本为3.5.0,需进行升级。考虑到一些第三方库尚未对最新的AGP4.2版本提供支持,决定将AGP升级到4.1中的最高版本4.1.3,遂开启了本次AGP升级之旅。依据官方文档适配升级的第一步当然是阅读官方AndroidGradle插件版本说明文档,根据文档所列版本变更进行适配。AGP3.6适配AGP3.6引入了如下行为变更:默认情况下,原生库以未压缩的形式打包该变更使原生库(nativelibrary)以未压缩方式打包,会增加APK大小,带来的收益有限,且部分收益依赖GooglePlay,如评估后认为弊大于利,可在AndroidManifest.xml中添加如下配置改为压缩原生库:AGP4.0适配AGP4.0引入了如下新特性:依赖项元数据该变更会将应用依赖项的元数据进行压缩加密后存储于APK签名块中,GooglePlay会使用这些依赖项来做问题提醒,收益有限,但会增加APK大小,如App不在GooglePlay上架,可在build.gradle中添加如下配置来关闭这个特性:android { dependenciesInfo { // Disables dependency metadata when building APKs. includeInApk = false // Disables dependency metadata when building Android App Bundles. includeInBundle = false }}AGP4.1适配AGP4.1引入了如下行为变更:从库项目中的BuildConfig类中移除了版本属性该变更从库模块(librarymodule)的BuildConfig类中删除了VERSION_NAME和VERSION_CODE字段。一般而言在库模块中获取版本号是希望获取App的版本号,而库模块中的BuildConfig.VERSION_NAME和BuildConfig.VERSION_CODE为库模块自身的版本号,此时不应该使用库模块中的这2个字段,可用如下代码来在库模块中获取App的版本号:private var appVersionName: String = ""private var appVersionCode: Int = 0fun getAppVersionName(context: Context): String { if (appVersionName.isNotEmpty()) return appVersionName return runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName.also { appVersionName = it } }.getOrDefault("")}fun getAppVersionCode(context: Context): Int { if (appVersionCode > 0) return appVersionCode return runCatching {  ackageInfoCompat.getLongVersionCode( context.packageManager.getPackageInfo(context.packageName, 0) ).toInt().also { appVersionCode = it } }.getOrDefault(0)}遇到的问题按官方文档适配后,不出意料地还是遇到了不少问题,这些问题部分由未在官方文档中明确指出的行为变更导致,部分由不规范做法命中了新版AGP更严格的限制导致,下面介绍这些问题的表现、原因分析和解决方案。BuildConfig.APPLICATION_ID找不到我们的部分组件库模块中使用了BuildConfig.APPLICATION_ID字段,编译时出现Unresolvedreference错误。原因是库模块中的BuildConfig.APPLICATION_ID字段名存在歧义,其值是库模块的包名,并不是应用的包名,因此该字段从AGP3.5开始被废弃,替换为LIBRARY_PACKAGE_NAME字段,且从AGP4.0开始被彻底删除。我们原来在App模块中的部分代码使用APPLICATION_ID获取App包名,在后面的组件化拆分过程中将App模块中的代码抽取到组件库时,为避免错误地用库模块的包名作为App包名,应该同步修改获取App包名方式,但遗漏了,没有修改,导致本次AGP升级后编译失败。针对这个问题,将库模块中获取App包名方式改为使用Context.getPackageName()方法即可。R和ProGuardmapping文件找不到我们会备份构建发布包时产生的R和ProGuardmapping文件以备后面需要时使用,升级后备份失败。这是因为从AGP3.6开始,构建产物中这2个文件的路径会改变:R.txt:build/intermediates/symbols/${variant.dirName}/R.txt->build/intermediates/runtime_symbol_list/${variant.name}/R.txtmapping.txt:build/outputs/mapping/${variant.dirName}/mapping.txt->build/outputs/mapping/${variant.name}/mapping.txt其中${variant.dirName}为$flavor/$buildType(例如full/release),${variant.name}为$flavor${buildType.capitalize()}(例如fullRelease)。可按如下方式将备份逻辑中的文件路径修改为上述新路径来解决这个问题:afterEvaluate { android.applicationVariants.all { variant -> def variantName = variant.name def variantCapName = variant.name.capitalize() def assembleTask = tasks.findByName("assemble${variantCapName}") assembleTask.doLast { copy { from "${buildDir}/outputs/mapping/${variantName}/mapping.txt" from "${buildDir}/intermediates/runtime_symbol_list/${variantName}/R.txt" into backPath } } }}固定资源id失效为避免App升级覆盖安装后可能出现inflate通知等RemoteView时,由于通过资源id找到错误的资源文件,导致崩溃的问题,我们在构建时进行了固定资源id处理,使部分资源文件的id在多次构建之间始终不变,升级后这部分资源id发生了变化。原固定资源id的实现方式是在afterEvaluate后,使用tasks.findByName方法获取process${variant.name.capitalize()}Resouces(例如processFullReleaseResources)任务对象,然后在AGP3.5以前使用调getAaptOptions方法,在AGP3.5中使用反射的方式获取任务对象中的aaptOptions属性对象,然后向其additionalParameters属性对象添加--stable-ids参数及对应的资源id配置文件路径值。但在AGP4.1中,处理资源任务类不再有aaptOptions属性,导致固定失效。对于AGP4.1,可换成如下直接设置android.aaptOptions.additionalParameters的方式来固定资源id:afterEvaluate { def additionalParams = android.aaptOptions.additionalParameters if (additionalParams == null) { additionalParams = new ArrayList() android.aaptOptions.additionalParameters = additionalParams } def index = additionalParams.indexOf("--stable-ids") if (index > -1) { additionalParams.removeAt(index) additionalParams.removeAt(index) } additionalParams.add("--stable-ids") additionalParams.add("${your stable ids file path}")}Manifest文件修改失败我们会在构建过程中修改AndroidManifest.xml文件加入额外信息,升级后修改失败。在分析本次升级包含的各版本AGP构建日志后,发现AGP4.1针对Manifest处理新增了process${variant.name.capitalize()}ManifestForPackage(例如processFullReleaseManifestForPackage)任务,该任务在原Manifest处理任务process${variant.name.capitalize()}Manifest(例如processFullReleaseManifest)后执行,其产物[1]跟原任务不同。而原来向Manifest添加额外信息的方式是在原Manifest处理任务执行后,执行自定义Manifest处理任务cmProcess${variant.name.capitalize()}Manifest(例如cmProcessFullReleaseManifest),向原Manifest处理任务的产物[2]写入信息。升级后,如果2个处理Manifest的任务都命中了缓存(执行状态为FROM-CACHE),那么最终APK内的Manifest文件中的额外信息会是以前编译写入的旧信息。[1]:processFullReleaseManifestForPackage任务的产物为build/intermediates/packaged_manifests/fullRelease/AndroidManifest.xml[2]:processFullReleaseManifest任务的产物为build/intermediates/merged_manifests/fullRelease/AndroidManifest.xml因此,写入信息的方式应如下图所示,改为在新增的Manifest处理任务执行后,向其产物文件写入信息。Transform插件执行失败我们在构建过程中加入了一些Transform插件,升级后其中一个使用ASM进行代码插桩的插件在执行时出现如下错误:Execution failed for task ':app:transformClassesWithxxx'.> java.lang.ArrayIndexOutOfBoundsException (no error message)上面错误提示中的异常也可能是java.lang.IllegalArgumentException:Invalidopcode169。为找到异常的具体来源,加入--stacktrace参数重新构建,定位异常由插件中引入的第三方库Hunter触发。这个插件运行时使用的ASM是AGP自带的,AGP3.5使用的是ASM6,而从AGP3.6开始使用的是ASM7,应该是引入的Hunter在ASM7上存在缺陷,导致升级后出现异常。考虑到Hunter只是对使用ASM的Transform做了些简单封装,且这个插件实现的功能比较简单,所以采用移除Hunter重新实现的方式解决这个问题。Cannotchangedependenciesofdependencyconfiguration我们使用了resolutionStrategy.dependencySubstitution来实现组件库源码切换,升级后,如果将组件库切成了源码,在AndroidStudio中点击Run按钮构建时会出现如题错误。在排查问题的过程中发现在命令行中执行./gradlewassembleRelease能构建成功,而通过AndroidStudioRun构建与上述命令行构建的区别仅仅是所执行的任务前增加了模块前缀(:app:assembleRelease)。从这个区别出发,最终找到问题的原因是在gradle.properties中开启了org.gradle.configureondemand这个孵化中的特性,使gradle只配置跟请求的任务相关的project,导致以指定module方式执行任务时,切为源码的project没有配置。关闭org.gradle.configureondemand特性即可解决这个问题。Entryname'xxx'collided升级后构建,在执行打包任务package${variant.name.capitalize()}(例如packageFullRelease)时会出现如题错误。由官方文档可知AGP3.6引入了如下新功能:新的默认打包工具该功能会在构建debug版时,使用新打包工具zipflinger来构建APK,且从AGP4.1开始,构建release版时也会使用这个新打包工具。错误发生在打包生成APK的任务中,很容易联想到跟上述新功能有关。使用官方文档提供的方式,在gradle.properties文件中添加android.useNewApkCreator=false配置恢复使用旧打包工具后,可以成功构建。但生成的APK中缺失Java资源文件,导致运行时出现各种问题(如OkHttp缺少publicsuffixes.gz文件,导致请求一直不返回)。现在解决问题的方向有2个:解决Java资源文件缺失问题和解决如题构建错误。为解决这些问题,需要先分析问题产生的原因,通过调试AGP构建过程,分析AGP源码,发现打包任务对应的实现类为PackageApplication,主要实现逻辑在其父类PackageAndroidArtifact中,向APK文件写入Android和Java资源文件的调用过程如下图所示:updateSingleEntryJars方法写入asset文件,addFiles方法写入其他Android资源文件和Java资源文件。调writeZip之前会根据android.useNewApkCreator配置决定使用哪个打包工具,值为true用ApkFlinger,否则用ApkZFileCreator,android.useNewApkCreator默认值为true。如通过配置使用旧打包工具ApkZFileCreator,它会用ZFile读资源缩减后生成的文件[3],以及混淆后生成的文件[4],将其中的Android和Java资源文件写入到APK文件中。[3]:build/intermediates/shrunk_processed_res/${varient.name}/resources-$flavor-$buildType-stripped.ap_(例如build/intermediates/shrunk_processed_res/fullRelease/resources-full-release-stripped.ap_)[4]:build/intermediates/shrunk_java_res/${varient.name}/shrunkJavaRes.jar(例如build/intermediates/shrunk_java_res/fullRelease/shrunkJavaRes.jar),如关闭了R8,则是build/intermediates/shrunk_jar/${varient.name}/minified.jar(例如build/intermediates/shrunk_jar/fullRelease/minified.jar)下面的源码片段展示了写入的主要逻辑,分为如下3步:创建ZFile对象,读取zip文件将centraldirectory中的每项加入到entries中遍历ZFile中的entries,将压缩的资源文件合并到APK文件中遍历ZFile中的entries,将非压缩的资源文件写入到APK文件中// ApkZFileCreator.javapublic void writeZip( File zip, @Nullable Function transform, @Nullable redicate isIgnored) throws IOException { // ... try { ZFile toMerge = closer.register(ZFile.openReadWrite(zip)); // ...  redicate noMergePredicate = v -> ignorePredicate.apply(v) || noCompressPredicate.apply(v); this.zip.mergeFrom(toMerge, noMergePredicate); for (StoredEntry toMergeEntry : toMerge.entries()) { String path = toMergeEntry.getCentralDirectoryHeader().getName(); if (noCompressPredicate.apply(path) & !ignorePredicate.apply(path)) { // ... try (InputStream ignoredData = toMergeEntry.open()) { this.zip.add(path, ignoredData, false); } } } } catch (Throwable t) { throw closer.rethrow(t); } finally { closer.close(); }}// ZFile.javaprivate void readData() throws IOException { // ... readEocd(); readCentralDirectory(); // ... if (directoryEntry != null) { // ... for (StoredEntry entry : directory.getEntries().values()) { // ... entries.put(entry.getCentralDirectoryHeader().getName(), mapEntry); //... } directoryStartOffset = directoryEntry.getStart(); } else { // ... } // ...}public void mergeFrom(ZFile src, redicate ignoreFilter) throws IOException { // ... for (StoredEntry fromEntry : src.entries()) { if (ignoreFilter.apply(fromEntry.getCentralDirectoryHeader().getName())) { continue; } // ... }}在调试过程中发现读取minified.jar文件创建的ZFile中的entries中没有Java资源文件,而在前面IncrementalSplitterRunnable.execute中调PackageAndroidArtifact.getChangedJavaResources获取改变的Java资源文件时,使用ZipCentralDirectory能正常读取到Java资源文件,说明ZFile存在缺陷。上述Java资源文件缺失的问题是在关闭R8时出现的,后面开启R8测试正常,新建demo工程测试,无论是否开启R8都正常。因此,可得到如下结论:如ZFile注释中所述,它不是通用的zip工具类,对zip格式和不支持的特性有严格的要求;它在某些特殊条件下存在限制,可能会出现读取文件缺失等问题由于旧打包工具使用了ZFile可能导致存在生成的APk缺失Java资源文件等问题,且已被官方废弃,不应该再使用现在解决问题的方向回到解决如题构建错误上来,新打包工具ApkFlinger写入Android或Java资源文件的调用过程如下图所示:从如下源码片段可看到,在ZipArchive.writeSource中会调validateName检查写入的entry名称的有效性,如果当前zip文件的centraldirectory中已存在相同名字的内容,则抛出IllegalStateException异常,提示如题错误。// ZipArchive.javaprivate void writeSource(@NonNull Source source) throws IOException { // ... validateName(source); // ...}private void validateName(@NonNull Source source) { byte[] nameBytes = source.getNameBytes(); String name = source.getName(); if (nameBytes.length > Ints.USHRT_MAX) { throw new IllegalStateException( String.format("Name '%s' is more than %d bytes", name, Ints.USHRT_MAX)); } if (cd.contains(name)) { throw new IllegalStateException(String.format("Entry name '%s' collided", name)); }}从源码和调试结果来看,出现如题错误的原因一般是某些不规范的做法使jar文件中存在同名的Android资源文件,我们遇到的2例为:某个第三方库的aar中存在asset文件,同时其classes.jar中也存在相同的asset文件某个第三方库将另外一个第三方库的aar文件当做普通jar文件依赖,导致其classes.jar中存在AndroidManifest.xml文件知道问题的原因后,可根据提示的文件名在shrunkJavaRes.jar或minified.jar中找到对应的文件,然后根据文件中的信息(如AndroidManifest.xml中的包名)定位到工程中的具体位置,再做相应的修改即可。so文件没有strip升级后,构建生成的APK中的so文件没有strip,使用ndk中的nm工具[5](在macOS中也可用系统自带的nm)查看,发现符号表和调试信息依然存在。[5]:toolchains/aarch64-linux-android-4.9/prebuilt/$HOST_TAG/aarch64-linux-android/bin/nm,HOST_TAG在不同操作系统中的值不同,在macOS中为darwin-x86_64,在Windows中为windows-x86_64分析构建日志后发现strip${variant.name.capitalize()}Symbols(例如stripFullReleaseSymbols)任务有执行,接着分析AGP源码,调试构建过程,发现该任务通过StripDebugSymbolsRunnable对so进行strip,从如下源码片段可看到其主要逻辑为:调SymbolStripExecutableFinder.stripToolExecutableFile获取ndk中的strip工具路径如果没有找到工具,则直接拷贝so到目标位置并返回调用这个工具对so进行strip并输出到目标位置private class StripDebugSymbolsRunnable @Inject constructor(val params: arams): Runnable { override fun run() { // ... val exe = params.stripToolFinder.stripToolExecutableFile(params.input, params.abi) { UnstrippedLibs.add(params.input.name) logger.verbose("$it ackaging it as is.") return@stripToolExecutableFile null } if (exe == null || params.justCopyInput) { // ... FileUtils.copyFile(params.input, params.output) return } val builder = rocessInfoBuilder() builder.setExecutable(exe) // ... val result = params.processExecutor.execute( builder.createProcess(), LoggedProcessOutputHandler(logger) ) // ... } // ...}因此,so没被strip的原因应该是没找到ndk中的strip工具。进一步分析源码可知SymbolStripExecutableFinder通过NdkHandler提供的ndk信息找strip工具路径,而NdkHandler通过NdkLocator.findNdkPathImpl这个顶层函数找ndk路径,所以so能否被strip最终取决于能否找到ndk路径。查找ndk主要逻辑如下:const val ANDROID_GRADLE_PLUGIN_FIXED_DEFAULT_NDK_VERSION = "21.1.6352462"private fun findNdkPathImpl( userSettings: NdkLocatorKey, getNdkSourceProperties: (File) -> SdkSourceProperties?, sdkHandler: SdkHandler?): NdkLocatorRecord? { with(userSettings) { // ... val revisionFromNdkVersion = parseRevision(getNdkVersionOrDefault(ndkVersionFromDsl)) ?: return null // If android.ndkPath value is present then use it. if (!ndkPathFromDsl.isNullOrBlank()) { // ... } // If ndk.dir value is present then use it. if (!ndkDirProperty.isNullOrBlank()) { // ... } // ... if (sdkFolder != null) { // If a folder exists under $SDK/ndk/$ndkVersion then use it. val versionedNdkPath = File(File(sdkFolder, FD_NDK_SIDE_BY_SIDE), "$revisionFromNdkVersion") val sideBySideRevision = getNdkFolderRevision(versionedNdkPath) if (sideBySideRevision != null) { return NdkLocatorRecord(versionedNdkPath, sideBySideRevision) } // If $SDK/ndk-bundle exists and matches the requested version then use it. val ndkBundlePath = File(sdkFolder, FD_NDK) val bundleRevision = getNdkFolderRevision(ndkBundlePath) if (bundleRevision != null & bundleRevision == revisionFromNdkVersion) { return NdkLocatorRecord(ndkBundlePath, bundleRevision) } } // ... }}private fun getNdkVersionOrDefault(ndkVersionFromDsl : String?) = if (ndkVersionFromDsl.isNullOrBlank()) { // ... ANDROID_GRADLE_PLUGIN_FIXED_DEFAULT_NDK_VERSION } else { ndkVersionFromDsl }上面源码片段对应的主要查找流程如下图所示:根据上述ndk查找逻辑,可以知道so没被strip的根本原因是我们没有在build.gradle中配置android.ndkPath和android.ndkVersion,在打包机上打包时也不存在local.properties文件,也就不存在ndk.dir属性,打包机上安装的ndk版本也不是AGP指定的默认版本21.1.6352462,导致找不到ndk路径。虽然找到了原因,但还是有个疑问:为什么升级之前能正常strip?为了寻找答案,再来看看AGP3.5查找ndk的方式,其主要逻辑如下:private fun findNdkPathImpl( ndkDirProperty: String?, androidNdkHomeEnvironmentVariable: String?, sdkFolder: File?, ndkVersionFromDsl: String?, getNdkVersionedFolderNames: (File) -> List, getNdkSourceProperties: (File) -> SdkSourceProperties?): File? { // ... val foundLocations = mutableListOf() if (ndkDirProperty != null) { foundLocations += Location(NDK_DIR_LOCATION, File(ndkDirProperty)) } if (androidNdkHomeEnvironmentVariable != null) { foundLocations += Location( ANDROID_NDK_HOME_LOCATION, File(androidNdkHomeEnvironmentVariable) ) } if (sdkFolder != null) { foundLocations += Location(NDK_BUNDLE_FOLDER_LOCATION, File(sdkFolder, FD_NDK)) } // ... if (sdkFolder != null) { val versionRoot = File(sdkFolder, FD_NDK_SIDE_BY_SIDE) foundLocations += getNdkVersionedFolderNames(versionRoot) .map { version -> Location( NDK_VERSIONED_FOLDER_LOCATION, File(versionRoot, version) ) } } // ... val versionedLocations = foundLocations .mapNotNull { location -> // ... } .sortedWith(compareBy({ -it.first.type.ordinal }, { it.second.revision })) .asReversed() // ... val highest = versionedLocations.firstOrNull() if (highest == null) { // ... return null } // ... if (ndkVersionFromDslRevision != null) { // If the user specified ndk.dir then it must be used. It must also match the version // supplied in build.gradle. if (ndkDirProperty != null) { val ndkDirLocation = versionedLocations.find { (location, _) -> location.type == NDK_DIR_LOCATION } if (ndkDirLocation == null) { // ... } else { val (location, version) = ndkDirLocation // ... return location.ndkRoot } } // If not ndk.dir then take the version that matches the requested NDK version val matchingLocations = versionedLocations .filter { (_, sourceProperties) -> isAcceptableNdkVersion(sourceProperties.revision, ndkVersionFromDslRevision) } .toList() if (matchingLocations.isEmpty()) { // ... return highest.first.ndkRoot } // ... val foundNdkRoot = matchingLocations.first().first.ndkRoot // ... return foundNdkRoot } else { // If the user specified ndk.dir then it must be used. if (ndkDirProperty != null) { val ndkDirLocation = versionedLocations.find { (location, _) -> location.type == NDK_DIR_LOCATION } // ... val (location, version) = ndkDirLocation // ... return location.ndkRoot } // ... return highest.first.ndkRoot }}对应的大致流程如下图所示:可以看到在AGP3.5中,如果没有配置ndk路径和版本,会取ndk目录中的最高版本,只要ndk目录中存在一个版本就能找到,所以升级前没问题。AGP3.6和4.0的查找逻辑跟AGP3.5类似,只不过增加了在android.ndkVersion未配置时取AGP内置的默认版本逻辑,AGP3.6的默认版本为20.0.5594570,AGP4.0的默认版本为21.0.6113669。通过上面的分析找到问题原因后,解决方式就呼之欲出了,为具备更广泛的适应性,可采用配置android.ndkVersion将ndk版本设置为跟打包机一致的方式来解决问题。小结本文介绍了AGP升级(3.5到4.1)过程,对所遇问题提供了原因分析和解决方式。虽然本次升级的初衷不是优化构建,但升级后,我们的构建速度提升了约36%,包大小减少了约5M。希望本文能够帮助需要升级的读者顺利完成升级,享受到官方对构建工具持续优化的成果。至此,本次AGP升级之旅已到终点,而我们的开发之旅还将继续。本文发布自网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们grp.music-fe(at)corp.netease.com!
|
|