APK打包流程-资源编译

1,896 阅读13分钟

资源类型

资源在APK中非常重要,Android内自定义的资源类型包括以下几种类型:

  • 动画资源:
    • 补间动画: 保存在res/anim中,通过R.anim类访问
    • 帧动画:保存在res/drawable中,通过R.drawable类访问
  • 颜色列表资源:定义根据View状态而变化的颜色资源,保存在res/color/中,并通过R.color类访问
  • 可绘制资源:使用位图或者XML定义各种图形,保存在res/drawable中,通过R.drawable类访问
  • 布局资源:定义页面的布局,保存在res/layout中,通过R.layout类访问
  • 菜单资源: 定义应用菜单布局,保存在res/menu中,并通过R.menu类访问
  • 字符串资源:定义字符串,保存在res/values/中,并通过R.string访问
  • 样式资源:定义界面元素的外观和格式,保存在res/values/中,并通过R.style访问
  • 字体资源:定义自定义字体,保存在res/font/中,并通过R.font类访问
  • 其他原始类型的静态资源
    • Bool: 包含bool值的xml资源
    • 颜色:包含颜色值的xml资源
    • ID:为应用资源、组件提供唯一ID的xml资源
    • 整数:包含整数值的XML资源

我们通过zip格式解压PAK,然后查看对应xml,xml资源无法直接的阅读的。为什么最终apk的xml无法直接阅读呢?我们带着这个问题一起看下面的资源编译。

资源编译

打包流程中关于整个资源相关的task有下面几个

:app:generateDebugResValues 
:app:generateDebugResources 
:app:mergeDebugResources
:app:processDebugResources
  • generateDebugResValues: 生成在gradle中配置的资源文件
  • generateDebugResources:空任务,没有真正的实现
  • mergeDebugResources:进行资源的合并和编译
  • processDebugResources:进行资源链接和打包

gradle资源生成

gradle支持我们在build.gradle中配置自定义资源。使用方式如下所示:

android {
    ...
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            resValue("string", "app_test", "123")
            resValue("bool", "test_result", "true")
        }
        debug{
            resValue("string", "app_test", "123")
            resValue("bool", "test_result", "true")
        }
    ...
}

generateDebugResValues就是用来解析在Gradle文件中配置的资源文件的。它会尝试解析在gradle中不同的buildType配置的资源列表,并生成gradleResValues.xml。比如上面的测试代码会生成下面的xml文件:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_test" translatable="false">123</string>
    <bool name="test_result">true</bool>
</resources>

对应的生成文件地址为:

build/generated/res/resValues/debug/values/gradleResValues.xml

后续在资源编译中,gradleResValues.xml会作为aapt2的资源文件输入,参与编译。

资源锚点任务

  • 对应的打包任务:generateDebugResources

这个是一个空Task,作为锚点使用的。 在Android的编译流程中,作为其他Task依赖的节点。

task.dependsOn(creationConfig.getTaskContainer().getResourceGenTask());

资源编译

这个任务非常重要,如果单看名字可能会理解成合并资源了,其实合并资源只是这个编译任务的一个小点,它更重要的功能其实是对合并的资源进行编译。

合并资源

参与merge的资源文件有下面几个:

  • Library内的资源:特指在aar、LibraryProject中的资源
  • RenderscriptRes生成的资源:RenderScript是在Android上以高性能运行计算密集型任务的框架。对于RenderScript对于专注于图像处理、计算机视觉尤为重要。 详细内容可以查看:RenderscriptRes介绍
  • GeneratedRes的资源:build.gradle中的资源配置生成的xml
  • ExtraGeneratedRes的资源:直接在build.gradle中配置的资源
  • 源文件中的资源:在main/res中的资源文件

在存在资源冲突的情况下,会按照下面的冲突解决方案做进行冲突解决。

  • 主工程的资源优先于Library资源
  • 后输入的Library资源优先于先输入的Library资源

resources.arsc

  • 结构定义源码传送门: ResourceType.h
  • aapt2中结构源码:

在讲资源编译之前,需要先讲一下resources.arsc, 也就是资源表。主要用来建立资源ID与资源的映射关系,当我们通过R.drawable.xxx去访问一个具体图片资源时,这个R.drawable.xxx与实际资源的映射关系会存储在resource.arsc中。

如下图所示:

资源表的的定义如下:

class ResourceTable {
    StringPool string_pool;
    std::vector<std::unique_ptr<ResourceTablePackage>> packages;
    std::map<size_t, std::string> included_packages_;
}
//package
class ResourceTablePackage {
 public:
  std::string name;
  std::vector<std::unique_ptr<ResourceTableType>> types;
};
​
//资源类型
class ResourceTableType {
 public:
  const ResourceType type;
  std::vector<std::unique_ptr<ResourceEntry>> entries;
};
​
// 资源Item
class ResourceEntry {
  const std::string name;
  Maybe<ResourceId> id;
  std::vector<std::unique_ptr<ResourceConfigValue>> values;
  ...
};
​

通过ResourceTable的数据结构,可以大概看出来整体的资源解析之后的存储结构。

ResourceTable ->  ResourceTablePackage -> ResourceTableType -> ResourceEntry

可以详细看一下如何添加一个资源到资源表中。

bool ResourceTable::AddResource(NewResource&& res, IDiagnostics* diag) {
    ...
    auto package = FindOrCreatePackage(res.name.package);
    auto type = package->FindOrCreateType(res.name.type);
    auto entry_it = std::equal_range(type->entries.begin(), type->entries.end(), res.name.entry,
                                   NameEqualRange<ResourceEntry>{});
    const size_t entry_count = std::distance(entry_it.first, entry_it.second);
    ResourceEntry* entry;
    if (entry_count == 0) {
      entry = type->CreateEntry(res.name.entry);
    }
    ...
    if (res.value != nullptr) {
      auto config_value = entry->FindOrCreateValue(res.config, res.product);
      if (!config_value->value) {
      config_value->value = std::move(res.value);
    }
}

上面添加到资源表的流程伪代码可以转化为下面的流程如下:

  • 根据当前资源,从资源表查找对应的ResourceTablePackage,如果存在,就返回,如果不存在,则新建一个
  • 根据当前资源,从ResourceTablePackage查找对应的资源类型,如果存在,就返回,如果不存在,则新建一个
  • 根据当前资源,从ResourceTableType查找对应的资源entry,如果存在,就返回,如果不存在,则新建一个
  • 根据当前资源,从ResourceEntry查找对应的资源value,如果存在,就返回,如果不存在,则新建一个,并赋值。
bool ResourceTable::addResourceImpl(const ResourceNameRef& name, const ResourceId resId,
                                    const ConfigDescription& config, const SourceLine& source,
                                    std::unique_ptr<Value> value, const char16_t* validChars) {
    std::unique_ptr<ResourceTableType>& type = findOrCreateType(name.type);
    std::unique_ptr<ResourceEntry>& entry = findOrCreateEntry(type, name.entry);
    const auto endIter = std::end(entry->values);
    auto iter = std::lower_bound(std::begin(entry->values), endIter, config, compareConfigs);
    ...
    if (resId.isValid()) {
        type->typeId = resId.typeId();
        entry->entryId = resId.entryId();
    }
    return true;
}

资源编译

资源编译的比较好的优势有下面两个:

  • 运行性能快:在编译阶段会将资源优化成针对Android平台的可运行的二进制格式,解析速度快
  • 空间占用小:二进制xml文件占用空间小,通过常量池节省空间

上面列出来的是本身对资源编译后带来的优势,还有另外一个原因,在Android中访问资源的方式是通过R.java中的方式访问的,这个R文件的生成完全依赖于对资源文件的遍历和ID收集,这个过程也是在资源编译的过程中操作的。

较早版本Android的资源编译时通过AAPT编译的,在Android Studio3.0之后,默认使用了AAPT2工具。

AAPT2相对于AAPT的最大优势就是在资源增量这一块,把原本的资源编译流程修改为编译、链接,有助于提高增量编译的性能。比如某个文件中有更改,我们只需要重新编译该文件,而不需要重新编译所有文件。 在MergeResourceTask中,会合并所有待编译的资源,作为AAPT2的输入,开始编译。

资源编译流程

AAPT2会针对不同的资源类型做不同的处理, 下面我给出了一份aapt2中的资源编译的伪代码。

    if (path_data.resource_dir == "values" && path_data.extension == "xml") {
      compile_func = &CompileTable;
    } else if (const ResourceType* type = ParseResourceType(path_data.resource_dir)) {
      if (*type != ResourceType::kRaw) {
        if (*type == ResourceType::kXml || path_data.extension == "xml") {
          compile_func = &CompileXml;
        } else if ((!options.no_png_crunch && path_data.extension == "png")
                   || path_data.extension == "9.png") {
          compile_func = &CompilePng;
        }
      }
    } else {
         compile_func = &CompileFile;
    }

通过上面的代码,我们可以简单看出APPT资源编译的一些关键步骤。

  • values文件编译
  • 普通xml文件编译
  • png、.9.png编译
  • 其他文件编译
value文件

为什么values下的文件类型也是xml格式,却要单独进行编译呢?

根本原因是values下的xml文件中,每一个Item都可以直接通过R文件访问,所以需要解析values文件夹下的每一个xml,并为每一个资源item设置索引ID。

在看资源文件的解析过程之前,我们可以先看看资源整体的资源存储结构。

在Aapt编译中,存储资源Item的数据结构为:

struct ParsedResource {
  ResourceName name;
  ...
};

主要关注的 ResourceName, 表示当前的资源名称。ResourceName内部定义了ResourceType, 在values文件解析时会同时设置上对应的ResourceType。

struct ResourceName {
  std::string package;
  ResourceType type = ResourceType::kRaw;
  std::string entry;
​
  ResourceName() = default;
  ResourceName(const android::StringPiece& p, ResourceType t, const android::StringPiece& e);
​
  int compare(const ResourceName& other) const;
​
  bool is_valid() const;
  std::string to_string() const;
};

每一个Item都对应着一个ParsedResource。一个Values文件的item解析过程如下所示:

  1. 通过XmlPullParser解析values文件夹下的xml文件
  2. 遍历每一个xml节点,尝试解析每一个节点资源数据
  3. 通过每一个节点,解析出资源类型、资源名称、资源Values,并创建对应的ParsedResource
  4. 将创建的ParsedResource添加到资源表(ResourceTable)中

在把资源添加到资源表之后,会把当前创建的资源表添加到资源表的Pb数据中。

    pb::ResourceTable pb_table;
    SerializeTableToPb(table, &pb_table, context->GetDiagnostics());
    if (!container_writer.AddResTableEntry(pb_table)) {
      return false;
    }

写入Pb结束后,会把当前的资源名称写入到R.txt中。

  io::FileOutputStream fout_text(options.generate_text_symbols_path.value());
  Printer r_txt_printer(&fout_text);
  for (const auto& package : table.packages) {
     for (const auto& type : package->types) {
        for (const auto& entry : type->entries) {
            r_txt_printer.Print("xxxx ");
            ...
        }
      }
   }
xml编译

xml是一种纯文本结构,比较适合可嵌套的结构化数据。在Android中,layout文件、部分drawable都是xml文件格式,出于对性能和包体积的考虑,在aapt编译的过程中,会对xml文件进行二次编译,编译的中间产物是flat。

编译xml会有以下3个步骤:

  • 通过文件输入,解析成XmlResource格式
  • 收集当前xml里面使用到的资源Id,存储到exported_symbols文件中
  • 将当前的xml转化为pb格式,并写入到最终的pb的outputStream中
  • 将在xml定义的id和使用到的id,都写入到R.txt中

对应的xml在源码编译的方式如下所示:

static bool CompileXml(IAaptContext* context, const CompileOptions& options,
                       const ResourcePathData& path_data, io::IFile* file, IArchiveWriter* writer,
                       const std::string& output_path) {
​
  ...
  xmlres = xml::Inflate(fin.get(), context->GetDiagnostics(), path_data.source);
  ...
  // Collect IDs that are defined here.
  XmlIdCollector collector;
  if (!collector.Consume(context, xmlres.get())) {
    return false;
  }
  ...
  if (!FlattenXmlToOutStream(output_path, *xmlres, &container_writer,
                             context->GetDiagnostics())) {
    return false;
  }
  ...
  
  if (options.generate_text_symbols_path) {
    io::FileOutputStream fout_text(options.generate_text_symbols_path.value());
​
    if (fout_text.HadError()) {
      context->GetDiagnostics()->Error(DiagMessage()
                                       << "failed writing to'"
                                       << options.generate_text_symbols_path.value()
                                       << "': " << fout_text.GetError());
      return false;
    }
​
    Printer r_txt_printer(&fout_text);
    for (const auto& res : xmlres->file.exported_symbols) {
      r_txt_printer.Print("default int id ");
      r_txt_printer.Println(res.name.entry);
    }
  }
  return true;
}
png编译

AAPT2中的png编译主要是利用libPng对图片进行压缩。

png编译的主逻辑如下:

bool Png::process(const Source& source, std::istream& input, BigBuffer* outBuffer,
                  const Options& options, std::string* outError) {
    ....
    readPtr = png_create_read_struct(PNG_LIBPNG_VER_STRING, 0, nullptr, nullptr);
    ...
    if (!readPng(readPtr, infoPtr, &pngInfo, outError)) {
        goto bail;
    }
    ...
    writePtr = png_create_write_struct(PNG_LIBPNG_VER_STRING, 0, nullptr, nullptr);
    
    if (!writePng(writePtr, writeInfoPtr, &pngInfo, options.grayScaleTolerance, &logger,
                  outError)) {
        goto bail;
    }
​
    return result;
}
​
static bool writePng(png_structp writePtr, png_infop infoPtr, PngInfo* info,
                     int grayScaleTolerance, SourceLogger* logger, std::string* outError) {
    ...
    png_set_compression_level(writePtr, Z_BEST_COMPRESSION);
​
    ....
    return true;
}
  • 通过libpng对图片进行预处理
  • 通过zlib在png写入时进行图片压缩,压缩等级为最高等级。

libPng是png的解析库,内部结合了zlib库,可以对图片进行压缩。

其他类型文件

除了xml和png图片,其他的文件会通过CompileFile进行编译,

    CopyingOutputStreamAdaptor copying_adaptor(writer);
    ContainerWriter container_writer(&copying_adaptor, 1u);
​
    pb::internal::CompiledFile pb_compiled_file;
    SerializeCompiledFileToPb(file, &pb_compiled_file);
​
    if (!container_writer.AddResFileEntry(pb_compiled_file, in)) {
      return false;
    }

到此,整体的资源编译流程就结束了,我们可以把上面的流程转化为下面的图:

资源链接

AAPT2 资源链接

在链接阶段,AAPT2 会合并在编译阶段生成的所有中间文件(如资源表、二进制 XML 文件和处理过的 PNG 文件),并将它们打包成一个 APK。此外,在此阶段还会生成其他辅助文件,如R.java和ProGuard规则文件。

解析manifest文件

  • 校验Manifest文件是否符合要求

  • 收集在manifest文件中使用的ID

合并输入文件

获取并合并到主资源表,资源合并默认是允许覆盖的,冲突资源合并时,后面的输入会把前置的输入给覆盖掉。

如果输入文件是以.flat、.jar、.jack或者.zip结尾,会通过Zip合并方式进行合并。

其他的文件类型需要自行处理资源合并。

bool MergePath(const std::string& path, bool override) {
  if (util::EndsWith(path, ".flata") || util::EndsWith(path, ".jar") ||
      util::EndsWith(path, ".jack") || util::EndsWith(path, ".zip")) {
    return MergeArchive(path, override);
  } else if (util::EndsWith(path, ".apk")) {
    return MergeStaticLibrary(path, override);
  }
​
  io::IFile* file = file_collection_->InsertFile(path);
  return MergeFile(file, override);
}

资源ID链接

相信大家都看过R文件,R文件结构如下所示:

public final class R {
  public static final class anim {
    public static final int abc_fade_in = 2130771968;
    public static final int abc_fade_out = 2130771969;
  }
    public static final class attr {
    public static final int actionBarDivider = 2130837504;
    public static final int actionBarItemBackground = 2130837505;
  }
}

资源ID命名遵循0xPPTTEEEE规则。

  • PP表示当前的PackageId,一般的应用默认为0x7f,如果是系统apk,默认为0x01
  • TT表示当前的资源类型,按照类型从0开始自增
  • EEEE表示当前的资源ID,按照输入顺序从0开始自增

在资源ID链接阶段,会按照以下流程进行对应的资源Id赋值

  • 收集提前设置的固定资源ID并记录,其实包括上一次link分配的资源ID和外部配置的stable的资源ID
  • 遍历资源表中 的所有Entry,分配资源ID,并记录

给资源分配ID主要逻辑如下:

  for (auto& package : table->packages) {
    for (auto& type : package->types) {
      for (auto& entry : type->entries) {
        const ResourceName name(package->name, type->type, entry->name);
        if (entry->id) {
          continue;
        }
        auto id = assigned_ids.NextId(name, context->GetDiagnostics());
        if (!id.has_value()) {
          return false;
        }
        entry->id = id.value();
      }
    }
  }
  • 替换先前收集的ID引用,即对exported_symbols自行引用ID的替换。

生成资源APK

  • 将proguard文件、manifest、资源表写入到APK文件中
  • 如果输入文件中有Asset文件,也会将assets文件合并的APK文件中

生成R.java

会给每一个Library和当前工程生成对应的R.java。

R文件

在APK的编译过程中,R文件的中间产物有下面几个:

  1. R.java: 由aapt2编译生成的文件
  2. R.txt: 记录了当前project本地资源以及引用的所有资源列表,并生成了ID
  3. R-def.txt: 记录了当前project的本地资源,不包括依赖的资源
  4. package-aware-r.txt:记录了当前project的所有资源,没有生成ID

因为最终的代码编译都需要R文件参与编译,那只有R.txt的情况下,是如何编译成功呢? 这个通过这个任务的源码可以了解缘由。

如果经历过比较早版本之前的Android Studio,应该还可以查看到对应的R.java文件。不过目前新版本的AGP已经没有R.java了,而是直接生成了R.jar。

R.jar

相关源码传送门:R.jar代码传送门

R.jar会把所有R.java打成一个jar包,作为一个classpath参与编译。 直接生成R.jar的好处有下面几个:

  1. 假如我们当前是纯kotlin工程,就不需要再开启javac去编译了,可以提升编译速度。

可能我们会有疑惑,如果是直接把AAPT2生成的R.java文件一起编译成一个jar,仍然需要开启javac去编译,就不存在上面的优点。所以新版本的AGP会直接丢弃生成R.java, 直接通过各个库的R.txt和aapt2生成的当前project的R.txt生成R.jar

生成R.jar的主要逻辑如下所示:

fun exportToCompiledJava(tables: Iterable<SymbolTable>, outJar: Path, finalIds: Boolean = false) {
    JarFlinger(outJar).use { jarCreator ->
        jarCreator.setCompressionLevel(NO_COMPRESSION)
        val mergedTables = tables.groupBy { it.tablePackage }.map { SymbolTable.merge(it.value) }
        mergedTables.forEach { table ->
            exportToCompiledJava(table, jarCreator, finalIds)
        }
    }
}

我们在代码都会使用R文件访问资源,所以javac编译和kotlinc编译任务需要的R.jar生成之后执行。否则会因为缺少R文件而编译报错。

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

参考文档:

  1. flat文件格式解析:juejin.cn/post/700594…
  2. 关于R文件:medium.com/@morefreefg…