Android打包流程-Assets&So&Manifest

2,112

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文件压缩

在把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文件打包

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 构建系统会将所有清单文件合并成一个清单文件打包到应用中。

这个任务主要用来合并主模块、构建变体和依赖库的Manifest文件,同时会对Manifest做一些参数校验和成员属性的注入。

主要流程如下:

  • manifest文件中是否存在package属性,默认需要有pacakge属性
  • 各个不同的manifest文件是否存在相同包名,默认不允许存在
  • 给manifest文件注入版本号、版本名称等系统属性
  • 合并library、主库的模版Manifest文件
  • 再与构建变体中的manifest文件合并

合并规则

根据每个清单文件的优先级按顺序合并,将所有清单文件组合到一个文件中。例如,如果您有三个清单文件,则会先将优先级最低的清单合并到优先级第二高的清单中,然后再将合并后的清单合并到优先级最高的清单中。

有三种基本的清单文件可以互相合并,它们的合并优先级如下(按优先级由高到低的顺序)

  • 构建变体中的清单文件,优先级和上面的MergeSourceSetFolders提到的一致。
  • 应用模块的主清单文件
  • 所有依赖库的清单文件

如下图所示:

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的区别:

  1. RRO能直接定制替换第三方APK的资源,而不需要其源码。SRO如上节所述,则需要对应APK的源码才能完成,一般而言,第三方是不会提供项目源码的。
  2. 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文件打包输出

因为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合并

这个任务主要是把工程内的so库都合并到指定的路径下。具体的合并流程可以参数上面的Assets文件合并

依赖So库合并

在打包流程中,需要把各个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,才解决这个问题。

本文属于学习过程中的记录,有不对的地方请谅解下可以提出来

参考文章

  1. android overlay: www.ccbu.cc/index.php/f…