资源类型
资源在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资源生成
- 对应打包任务:generateDebugResValues
- 源码传送门:GenerateResValues源码地址
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());
资源编译
- 对应的打包任务:MergeResources
- 源码传送门: MergeResource源码地址
这个任务非常重要,如果单看名字可能会理解成合并资源了,其实合并资源只是这个编译任务的一个小点,它更重要的功能其实是对合并的资源进行编译。
合并资源
参与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解析过程如下所示:
- 通过XmlPullParser解析values文件夹下的xml文件
- 遍历每一个xml节点,尝试解析每一个节点资源数据
- 通过每一个节点,解析出资源类型、资源名称、资源Values,并创建对应的ParsedResource
- 将创建的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(©ing_adaptor, 1u);
pb::internal::CompiledFile pb_compiled_file;
SerializeCompiledFileToPb(file, &pb_compiled_file);
if (!container_writer.AddResFileEntry(pb_compiled_file, in)) {
return false;
}
到此,整体的资源编译流程就结束了,我们可以把上面的流程转化为下面的图:
资源链接
- 打包任务:processDebugResources
- 源码传送门: processDebugResources源码地址
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文件的中间产物有下面几个:
- R.java: 由aapt2编译生成的文件
- R.txt: 记录了当前project本地资源以及引用的所有资源列表,并生成了ID
- R-def.txt: 记录了当前project的本地资源,不包括依赖的资源
- 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的好处有下面几个:
- 假如我们当前是纯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文件而编译报错。
本文属于学习过程中的记录,有不对的地方请谅解下可以提出来
参考文档:
- flat文件格式解析:juejin.cn/post/700594…
- 关于R文件:medium.com/@morefreefg…