Assets
assets资源也是APK体积占用较大的一块内容, 打包流程中关于assets文件的任务有下面几个:
generateDebugAssets
mergeDebugAssets
compressDebugAssets
Assets锚点任务
这个是一个锚点Task,什么也没做,不需要关注。
Assets文件合并
对应的编译的Task为:MergeSourceSetFolders
这个任务主要是把工程内的Assets文件都合并到指定的路径下。在了解这个之前为什么需要这个合并任务之前,我们要先介绍一下Android中的构建变体(Build Variant)
构建变体
- Build Type:定义构建的类型,比如说Debug和Release就是两种不同的构建类型,可以通过不同的构建类型,配置不同的签名信息等。
- ProductFlavor: 表示产品变种,作用的定义项目中的不同版本,比如国内版、海外版等,这样就只需要一个工程了。
- Build Variant:主要依赖BuildType和ProductFlavor提供的属性和方法,配置一系列规则,将代码和资源进行组合。
在android中的src/main的源码集中,包含了其他其他构建变体公用的代码和资源。其他构建的的sourceSet是可选项,如果我们想为某个单独的构建变体添加特有的代码和资源,需要创建对应的目录。
在我们使用源代码构建时,可以针对资源、代码对以下几个目录单独进行配置。
- src/xjlDebug: build变体源代码集
- src/debug: build类型源代码集
- src/xjl: 产品变种源代码集
- src/main: 主源代码集
正因为在四个目录下都可以单独配置jniLibs等目录,所以需要通过这个Task做文件的聚合,选择具体参与编译的文件。
google官网给的优先级顺序为:
build变体源代码集 > build类型源代码集 > 产品变种源代码集 > 主源代码集
本地合并
MergeSourceSetFolders的文件合并的关键逻辑如下:
AssetFile assetFile = Preconditions.checkNotNull(item.getSourceFile());
Path fromFile = assetFile.getFile().toPath();
Path toFile = new File(rootFolder, item.getKey().replace('/', File.separatorChar)).toPath();
Files.copy(fromFile, toFile, StandardCopyOption.REPLACE_EXISTING);
逻辑比较简单,就是会直接把文件拷贝到目标目录。因为指定的copy策略是REPLACE_EXISTING,所以如果存在同名文件时会相互覆盖。
和上面的优先级相互结合,说明需要通过限制文件的输入顺序来保证上面的sourceSet优先级。
fun getSourceFilesAsAssetSets(
function: Function<SourceProvider, Collection<File>>,
aaptEnv: String?
): List<AssetSet> {
val assetSets = mutableListOf<AssetSet>()
val mainResDirs = function.apply(defaultSourceProvider)
var assetSet = AssetSet(BuilderConstants.MAIN, aaptEnv)
assetSet.addSources(mainResDirs)
assetSets.add(assetSet)
for (n in flavorSourceProviders.indices.reversed()) { ...}
multiFlavorSourceProvider?.let { .... }
if (buildTypeSourceProvider != null) { .... }
variantSourceProvider?.let { ..... }
return assetSets
}
Assets文件压缩
- 打包任务名称:compressDebugAssets
- 源码传送门: CompressAssetsTask源码传送门
在把Assets文件打包到apk前,会先进行Assets压缩。这个Task把每一个assets文件,会通过zip压缩,将其打包为jar,以jar文件的形式存在。 最终的输出是一个jar目录而不是一个jar,这样可以解决后续的任务不需要通过FileCache来管理其增量状态。
val entryPath = "assets/${change.normalizedPath}"
val targetFile = File(outputDir, entryPath + DOT_JAR)
val entryCompressionLevel = if (noCompressPredicate.test(entryPath)) {
Deflater.NO_COMPRESSION
} else {
compressionLevel
}
workQueue.submit(CompressAssetsWorkAction::class.java) {
it.input.set(change.file)
it.output.set(targetFile)
it.entryPath.set(entryPath)
it.entryCompressionLevel.set(entryCompressionLevel)
it.changeType.set(change.changeType)
}
中间的压缩后的jar文件路径在:
xx/build/intermediates/compressed_assets/debug/out
但是我们通过zip解压Apk时,其中的assets文件并非是上面压缩过后的jar,而是原始的asset文件,说明这个并不是真正打到apk中的产物,那是如何通过jar格式再次转换为最终的assets文件呢?
Assets文件打包
- 打包任务名称:PackageAndroidArtifact
- 源码传送门: PackageAndroidArtifact源码传送门
AAPT打包Assets
在aapt2代码中,发现aapt其实是支持Assets文件打包的。可以将assets文件作为输入,参与到aapt2的链接流程中。
AddOptionalFlagList("-A", "An assets directory to include in the APK. These are unprocessed.",
&options_.assets_dirs, Command::kPath);
如果在aapt2的链接阶段中,发现有输入的assets文件,那么会直接merge并写入到最终的APK中。
bool CopyAssetsDirsToApk(IArchiveWriter* writer) {
std::map<std::string, std::unique_ptr<io::RegularFile>> merged_assets;
for (const std::string& assets_dir : options_.assets_dirs) {
Maybe<std::vector<std::string>> files =
file::FindFiles(assets_dir, context_->GetDiagnostics(), nullptr);
for (const std::string& file : files.value()) {
std::string full_key = "assets/" + file;
std::string full_path = assets_dir;
file::AppendPath(&full_path, file);
auto iter = merged_assets.find(full_key);
if (iter == merged_assets.end()) {
merged_assets.emplace(std::move(full_key),
util::make_unique<io::RegularFile>(Source(std::move(full_path))));
}
}
}
for (auto& entry : merged_assets) {
uint32_t compression_flags = GetCompressionFlags(entry.first, options_);
if (!io::CopyFileToArchive(context_, entry.second.get(), entry.first, compression_flags,
writer)) {
return false;
}
}
return true;
}
Package打包Assets
最终APK中的Assets打包并没有使用aapt2,而是直接通过compressDebugAssets的产物,把其写入到最终的APK中。
- 首先收集assets临时文件,也就是上面一个Task生成的打包文件
packageAndroidArtifact
.getAssets()
.set(creationConfig.getArtifacts().get(COMPRESSED_ASSETS.INSTANCE));
- 通过APKCreator创建处理assets文件
private void updateSingleEntryJars(
@NonNull Collection<SerializableChange> changes) throws IOException {
....
Iterable<File> addedJars = changes.stream()
.filter(isNewOrChanged)
.map(SerializableChange::getFile)
.collect(Collectors.toList());
for (File addedJar : addedJars) {
getApkCreator().writeZip(addedJar, null, null);
}
}
- 通过ZIP合并,在asset文件直接合入APK的zip文件中。
public void writeZip(@Nonnull File zip, @Nullable Function<String, String> transform,
@Nullable Predicate<String> isIgnored) throws IOException {
...
ZFile toMerge = closer.register(new ZFile(zip));
...
this.zip.mergeFrom(toMerge, noMergePredicate);
...
}
整体的Assets打包流程如下所示:
AndroidManifest文件
在查看资源编译和链接的流程中,应该有看到AndroidManifest文件的身影,会在资源链接时作为aapt2的输入,然后编译成最终的flat格式并打包到输出apk中。
在AndroidManifest文件编译之前,需要先经过下面几个打包任务:
createDebugCompatibleScreenManifests
processDebugMainManifest
processDebugManifest
processDebugManifestForPackage
- createDebugCompatibleScreenManifests: 创建屏幕适配标签清单
- processDebugMainManifest:主清单和Library清单文件合并,mergeManifest.xml
- processDebugManifest: mergeManifest.xml和构建变体的清单文件合并
屏幕适配标签
- 对应打包任务名称:createDebugCompatibleScreenManifests
- 实际打包任务:CompatibleScreensManifest
- 源码传送门:CompatibleScreensManifest源码
会生成一个关于带有compatible-screens标签的Manifest文件。用于指定应用和兼容的各个屏幕配置。 App的Manifest文件中只能有一个compatible-screens元素,但是可以包含多个screen元素,每一个screen元素都指定了应用与之兼容的特定的屏幕尺寸-密度组合。
Android 系统不会读取 <compatible-screens>
清单元素(无论是在安装时还是在运行时)。此元素仅用于提供信息,可供外部服务(如 Google Play)用于更好地了解应用与特定屏幕配置的兼容性,并为用户启用过滤功能。未在此元素中声明的任何屏幕配置都是应用与之不兼容的屏幕。因此,外部服务(如 Google Play)不会向使用此类屏幕的设备提供应用。
Manifest文件合并
APK 或 Android App Bundle 文件只能包含一个 AndroidManifest.xml
文件,但 Android Studio 项目可以包含多个清单文件,这些清单文件由主源代码集、build 变体和导入的库提供。因此,在构建应用时,Gradle 构建系统会将所有清单文件合并成一个清单文件打包到应用中。
- 打包任务名称:processDebugMainManifest
- 源码Task为:ProcessApplicationManifest
- 源码传送门:ProcessApplicationManifest源码
这个任务主要用来合并主模块、构建变体和依赖库的Manifest文件,同时会对Manifest做一些参数校验和成员属性的注入。
主要流程如下:
- manifest文件中是否存在package属性,默认需要有pacakge属性
- 各个不同的manifest文件是否存在相同包名,默认不允许存在
- 给manifest文件注入版本号、版本名称等系统属性
- 合并library、主库的模版Manifest文件
- 再与构建变体中的manifest文件合并
合并规则
根据每个清单文件的优先级按顺序合并,将所有清单文件组合到一个文件中。例如,如果您有三个清单文件,则会先将优先级最低的清单合并到优先级第二高的清单中,然后再将合并后的清单合并到优先级最高的清单中。
有三种基本的清单文件可以互相合并,它们的合并优先级如下(按优先级由高到低的顺序)
- 构建变体中的清单文件,优先级和上面的MergeSourceSetFolders提到的一致。
- 应用模块的主清单文件
- 所有依赖库的清单文件
如下图所示:
overlay解析
- 对应打包任务:ProcessLibraryManifest
- 源码地址传送门:ProcessLibraryManifest源码
- overlay官方介绍:overlay官方传送门
这个Task用来解析各个Library的overlays属性。
Android Overlay是一种资源替换机制,它能在不重新打包apk的情况下,实现资源文件的替换(res目录非assert目录),Overlay又分为静态Overlay(Static Resource Overlay)与运行时Overlay(Runtime Resource Overlay)。
静态Overlay
静态Overlay,简称为SRO,发生在编译时,需要在Android系统源码环境中进行配置。
动态Overlay
运行时Overlay,简称RRO,顾名思义,该机制的资源替换发生在运行时。
与SRO的区别:
- RRO能直接定制替换第三方APK的资源,而不需要其源码。SRO如上节所述,则需要对应APK的源码才能完成,一般而言,第三方是不会提供项目源码的。
- RRO的编译结果会得到一个xxx_overlay.apk,加上原项目的apk,总共会有2个apk,而SRO最终只会得到一个已经完成资源替换的apk。得到的overlay.apk可以视为一个正常的apk,因为它能被安装,含有自己的AndroidManifest.xml文件,当然正常下,overlay.apk是不含有执行代码的。
RRO不能替换AndroidManifest.xml文件及reference resource 类型的文件,如layout、anim、xml目录中的xml文件。虽然RRO具有自己的AndroidManifest.xml文件,但它却不能替换源项目中的AndroidManifest.xml文件。关于layout目录中的xml文件,SRO是可以替换的。
Manifest文件打包输出
- 打包任务:processDebugManifestForPackage
- 对应源码地址:ManifestPackage源码传送门
因为Manifest以Apk包的形式存在,所以需要移除android:splitName属性。
val xmlDocument = BufferedInputStream(
FileInputStream(
workItemParameters.inputXmlFile.get().asFile)
).use {
PositionXmlParser.parse(it)
}
removeSplitNames(document = xmlDocument)
val outputFile = workItemParameters.outputXmlFile.get().asFile
outputFile.parentFile.mkdirs()
outputFile.writeText(
XmlDocument.prettyPrint(xmlDocument))
}
So文件
打包流程中关于So的任务有下面几个:
mergeDebugJniLibFolders
mergeDebugNativeLibs
stripDebugDebugSymbols
本地So合并
- 编译Task为:MergeSourceSetFolders
- 源码传送门:MergeSourceSetFolders源码传送门
这个任务主要是把工程内的so库都合并到指定的路径下。具体的合并流程可以参数上面的Assets文件合并
依赖So库合并
- 编译任务名: mergeDebugNativeLibs
- 源码中的编译Task为:MergeNativeLibsTask
- 源码地址传送门:MergeNativeLibsTask源码传送门
在打包流程中,需要把各个Library、aar、源码中的So整理合并,最终输出到APK下的同一个目录中。
在在build.gradle中,我们可以通过PackagingOptions配置关于So打包的策略:
- 是否移除某个So库
- 是否需要移除某个库的符号表
- 如果库重复,选择的策略是什么,默认会有运行时异常。
比如下面的例子:
packagingOptions {
exclude 'META-INF/rxjava.properties'
doNotStrip "*/armeabi/xxx.so"
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
}
packagingOptions的配置的处理就在这个Task中,
val mergeTransformAlgorithm = StreamMergeAlgorithms.select { path ->
val packagingAction = packagingOptions.getAction(path)
when (packagingAction) {
PackagingFileAction.EXCLUDE ->
// Should have been excluded from the input.
throw AssertionError()
PackagingFileAction.PICK_FIRST -> return@select StreamMergeAlgorithms.pickFirst()
PackagingFileAction.MERGE -> return@select StreamMergeAlgorithms.concat()
PackagingFileAction.NONE -> return@select StreamMergeAlgorithms.acceptOnlyOne()
else -> throw AssertionError()
}
}
移除So符号表
- 打包任务名称:stripDebugDebugSymbols
- 编译Task为:StripDebugSymbolsTask
- 源码地址为:源码传送门
这个Task的作用就和名字显示的一样,用来移除Native库Debug符号表。内部会通过配置的ndk对so进行符号表优化。
val builder = ProcessInfoBuilder()
builder.setExecutable(exe)
builder.addArgs("--strip-unneeded")
builder.addArgs("-o")
builder.addArgs(params.output.toString())
builder.addArgs(params.input.toString())
val result = params.processExecutor.execute(builder.createProcess(), LoggedProcessOutputHandler(logger))
所以在不做任何PackageOption配置的情况下,是会对原本的So做二次优化的。
之前我们项目中在升级64位系统时,就因为一个三方库的so在打包时被strip了,导致三方库内部的完整性校验失败出现线上问题。
后面把三方库的so添加到PackageOption中,配置上doNotStrip,才解决这个问题。
本文属于学习过程中的记录,有不对的地方请谅解下可以提出来
参考文章
- android overlay: www.ccbu.cc/index.php/f…