Android構建工具--AAPT2原始碼解析(一)

vivo網際網路技術發表於2021-10-26

一、什麼是AAPT2

在Android開發過程中,我們通過Gradle命令,啟動一個構建任務,最終會生成構建產物“APK”檔案。常規APK的構建流程如下:

(引用自Google官方文件)

  • 編譯所有的資原始檔,生成資源表和R檔案;

  • 編譯Java檔案並把class檔案打包為dex檔案;

  • 打包資源和dex檔案,生成未簽名的APK檔案;

  • 簽名APK生成正式包。

老版本的Android預設使用AAPT編譯器進行資源編譯,從Android Studio 3.0開始,AS預設開啟了 AAPT2 作為資源編譯的編譯器,目前看來,AAPT2也是Android發展的主流趨勢,學習AAPT2的工作原理可以幫助Android開發更好的掌握APK構建流程,從而幫助解決實際開發中遇到的問題。

AAPT2 的可執行檔案隨 Android SDK 的 Build Tools 一起釋出,在Android Studio的build-tools資料夾中就包含AAPT2工具,目錄為(SDK目錄/build-tools/version/aapt2)。

二、AAPT2如何工作

在看Android編譯流程的時候,我忍不住會想一個問題:

Java檔案需要編譯才能生class檔案,這個我能明白,但資原始檔編譯到底是幹什麼的?為什麼要對資源做編譯?

帶著這個問題,讓我們深入的學習一下AAPT2。和AAPT不同,AAPT2把資源編譯打包過程拆分為兩部分,即編譯和連結:

編譯:將資原始檔編譯為二進位制檔案(flat)。

連結:將編譯後的檔案合併,打包成單獨檔案。

通過把資源編譯拆分為兩個部分,AAPT2能夠很好的提升資源編譯的效能。例如,之前一個資原始檔發生變動,AAPT需要做一全量編譯,AAPT2只需要重新編譯改變的檔案,然後和其他未發生改變的檔案進行連結即可。

2.1 Compile命令

如上文描述,Complie指令用於編譯資源,AAPT2提供多個選項與Compile命令搭配使用。

Complie的一般用法如下:

aapt2 compile path-to-input-files [options] -o output-directory/

執行命令後,AAPT2會把資原始檔編譯為.flat格式的檔案,檔案對比如下。

Compile命令會對資原始檔的路徑做校驗,輸入檔案的路徑必須符合以下結構:path/resource-type[-config]/file。

例如,把資原始檔儲存在“aapt2”資料夾下,使用Compile命令編譯,則會報錯“error: invalid file path '.../aapt2/ic_launcher.png'”。把aapt改成“drawable-hdpi”,編譯正常。

在Android Studio中,可以在app/build/intermediates/res/merged/ 目錄下找到編譯生成的.flat檔案。當然Compile也支援編譯多個檔案;

aapt2 compile path-to-input-files1 path-to-input-files2 [options] -o output-directory/

編譯整個目錄,需要制定資料檔案,編譯產物是一個壓縮檔案,包含目錄下所有的資源,通過檔名把資源目錄結構扁平化。

aapt2 compile --dir .../res [options] -o output-directory/resource.ap_

可以看到經過編譯後,資原始檔(png,xml ... )會被編譯成一個FLAT格式的檔案,直接把FLAT檔案拖拽到as中開啟,是亂碼的。那麼這個FLAT檔案到底是什麼?

2.2 FLAT檔案

FLAT檔案是AAPT2編譯的產物檔案,也叫做AAPT2容器,檔案由檔案頭和資源項兩大部分組成:

檔案頭

資源項

資源項中,按照 entry_type 值分為兩種型別:

  • 當entry_type 的值等於 0x00000000時,為RES_TABLE型別。

  • 當entry_type的值等於 0x00000001時,為RES_FILE型別。

RES_TABLE包含的是protobuf格式的 ResourceTable 結構。資料結構如下:

// Top level message representing a resource table.
message ResourceTable {
    // 字串池
    StringPool source_pool = 1;
    // 用於生成資源id
    repeated Package package = 2;
    // 資源疊加層相關
    repeated Overlayable overlayable = 3;
    // 工具版本
    repeated ToolFingerprint tool_fingerprint = 4;
}

資源表(ResourceTable)中包含:

StringPool:字串池,字串常量池是為了把資原始檔中的string複用起來,從而減少體積,資原始檔中對應的字串會被替換為字串池中的索引。

message StringPool {
  bytes data = 1;
}

Package:包含資源id的相關資訊

// 資源id的包id部分,在 [0x00, 0xff] 範圍內
message PackageId {
  uint32 id = 1;
}
// 資源id的命名規則
message Package {
  // [0x02, 0x7f) 簡單的說,由系統使用
  // 0x7f 應用使用
  // (0x7f, 0xff] 預留Id
  PackageId package_id = 1;
  // 包名
  string package_name = 2;
  // 資源型別,對應string, layout, xml, dimen, attr等,其對應的資源id區間為[0x01, 0xff]
  repeated Type type = 3;
}

資源id的命令方式遵循0xPPTTEEEE的規則,其中PP對應PackageId,一般應用使用的資源為7f,TT對應的是資原始檔夾的名成,最後4位為資源的id,從0開始。

RES_FILE型別格式如下:

RES_FILE型別的FLAT檔案結構可以參考下圖;

從上圖展示的檔案格式中可以看出,一個FLAT中可以包含多個資源項,在資源項中,Header欄位中儲存的是protobuf格式序列化的 CompiledFile 內容。在這個結構中,儲存了檔名、檔案路徑、檔案配置和檔案型別等資訊。data欄位中儲存資原始檔的內容。通過這種方式,一個檔案中既儲存了檔案的外部相關資訊,又包含檔案的原始內容。

2.3 編譯的原始碼

上文,我們學習了編譯命令Compile的用法和編譯產物FLAT檔案的檔案格式,接下來,我們通過檢視程式碼,從原始碼層面來學習AAPT2的編譯流程,本文原始碼地址。

2.3.1 命令執行流程

根據常識,一般函式的入口都是和main有關,開啟Main.cpp,可以找到main函式入口;

int main(int argc, char** argv) {
#ifdef _WIN32
    ......
    //引數格式轉換
    argv = utf8_argv.get();
#endif
    //具體的實現MainImpl中
    return MainImpl(argc, argv);
}

在MainImpl中,首先從輸入中獲取引數部分,然後建立一個MainCommand來執行命令。

int MainImpl(int argc, char** argv) {
    if (argc < 1) {
        return -1;
    }
    // 從下標1開始的輸入,儲存在args中
    std::vector<StringPiece> args;
    for (int i = 1; i < argc; i++) {
        args.push_back(argv[i]);
    }
    //省略部分程式碼,這部分程式碼用於列印資訊和錯誤處理
    //建立一個MainCommand
    aapt::MainCommand main_command(&printer, &diagnostics);
    // aapt2的守護程式模式,
    main_command.AddOptionalSubcommand( aapt::util::make_unique<aapt::DaemonCommand>(&fout, &diagnostics));
    // 呼叫Execute方法執行命令
    return main_command.Execute(args, &std::cerr);
}

MainCommand繼承自Command,在MainCommand初始化方法中會新增多個二級命令,通過類名,可以容易的推測出,這些Command和終端通過命令檢視的二級命令一一對應。

explicit MainCommand(text::Printer* printer, IDiagnostics* diagnostics)
 : Command("aapt2"), diagnostics_(diagnostics) {
    //對應Compile 命令
    AddOptionalSubcommand(util::make_unique<CompileCommand>(diagnostics));
    //對應link 命令
    AddOptionalSubcommand(util::make_unique<LinkCommand>(diagnostics));
    AddOptionalSubcommand(util::make_unique<DumpCommand>(printer, diagnostics));
    AddOptionalSubcommand(util::make_unique<DiffCommand>());
    AddOptionalSubcommand(util::make_unique<OptimizeCommand>());
    AddOptionalSubcommand(util::make_unique<ConvertCommand>());
    AddOptionalSubcommand(util::make_unique<VersionCommand>());
}

AddOptionalSubcommand方法定義在基類Command中,內容比較簡單,把傳入的subCommand儲存在陣列中。

void Command::AddOptionalSubcommand(std::unique_ptr<Command>&& subcommand, bool experimental) {
    subcommand->full_subcommand_name_ = StringPrintf("%s %s", name_.data(), subcommand->name_.data());
    if (experimental) {
        experimental_subcommands_.push_back(std::move(subcommand));
    } else {
    subcommands_.push_back(std::move(subcommand));
    }
}

接下來,再來分析main_command.Execute的內容,從方法名可以推測這個方法裡面有指令執行的相關程式碼。在MainCommand中並沒有Execute方法的實現,那應該是在父類中實現了,再到Command類中搜尋,果然在這裡。

int Command::Execute(const std::vector<StringPiece>& args, std::ostream* out_error) {
    TRACE_NAME_ARGS("Command::Execute", args);
    std::vector<std::string> file_args;
    for (size_t i = 0; i < args.size(); i++) {
        const StringPiece& arg = args[i];
        // 引數不是 '-'
        if (*(arg.data()) != '-') {
        //是第一個引數
        if (i == 0) {
            for (auto& subcommand : subcommands_) {
                //判斷是否是子命令
                if (arg == subcommand->name_ || (!subcommand->short_name_.empty() && arg == subcommand->short_name_)) {
                //執行子命令的Execute 方法,傳入引數向後移動一位
                return subcommand->Execute( std::vector<StringPiece>(args.begin() + 1, args.end()), out_error);
            }
        }
        //省略部分程式碼
    //呼叫Action方法,在執行二級命令時,file_args儲存的是位移後的引數
    return Action(file_args);
}

在Execute方法中,會先對引數作判斷,如果引數第一位命中二級命令(Compile,link,.....),則呼叫二級命令的Execute方法。參考上文編譯命令的示例可知,一般情況下,在這裡就會命中二級命令的判斷,從而呼叫二級命令的Execute方法。

在Command.cpp的同級目錄下,可以找到Compile.cpp,其Execute繼承自父類。但是由於引數已經經過移位,所以最終會執行Action方法。在Compile.cpp中可以找到Action方法,同樣在其他二級命令的實現類中(Link.cpp,Dump.cpp...),其核心處理的處理也都有Action方法中。整體呼叫的示意圖如下:

在開始看Action程式碼之前,我們先看一下Compile.cpp的標頭檔案Compile.h的內容,在CompileCommand初始化時,會把必須引數和可選引數都初始化定義好。

SetDescription("Compiles resources to be linked into an apk.");
AddRequiredFlag("-o", "Output path", &options_.output_path, Command::kPath);
AddOptionalFlag("--dir", "Directory to scan for resources", &options_.res_dir, Command::kPath);
AddOptionalFlag("--zip", "Zip file containing the res directory to scan for resources", &options_.res_zip, Command::kPath);
AddOptionalFlag("--output-text-symbols", "Generates a text file containing the resource symbols in the\n" "specified file", &options_.generate_text_symbols_path, Command::kPath);
AddOptionalSwitch("--pseudo-localize", "Generate resources for pseudo-locales " "(en-XA and ar-XB)", &options_.pseudolocalize);
AddOptionalSwitch("--no-crunch", "Disables PNG processing", &options_.no_png_crunch);
AddOptionalSwitch("--legacy", "Treat errors that used to be valid in AAPT as warnings", &options_.legacy_mode);
AddOptionalSwitch("--preserve-visibility-of-styleables", "If specified, apply the same visibility rules for\n" "styleables as are used for all other resources.\n" "Otherwise, all stylesables will be made public.", &options_.preserve_visibility_of_styleables);
AddOptionalFlag("--visibility", "Sets the visibility of the compiled resources to the specified\n" "level. Accepted levels: public, private, default", &visibility_);
AddOptionalSwitch("-v", "Enables verbose logging", &options_.verbose);
AddOptionalFlag("--trace-folder", "Generate systrace json trace fragment to specified folder.", &trace_folder_);

官網中列出的編譯選項並不全,使用compile -h列印資訊後就會發現列印的資訊和程式碼中的設定是一致的。

在Action方法的執行流程可以總結為:

1)會根據傳入引數判斷資源型別,並建立對應的檔案載入器(file_collection)。

2)根據傳入的輸出路徑判斷輸出檔案的型別,並建立對應的歸檔器(archive_writer),archive_writer在後續的呼叫鏈中一直向下傳遞,最終通過archive_writer把編譯後的檔案寫到輸出目錄下。

3)呼叫Compile方法執行編譯。

過程1,2中涉及的檔案讀寫物件如下表。

簡化的主流程程式碼如下:

int CompileCommand::Action(const std::vector<std::string>& args) {
    //省略部分程式碼....
    std::unique_ptr<io::IFileCollection> file_collection;
    //載入輸入資源,簡化邏輯,下面會省略掉校驗的程式碼
    if (options_.res_dir && options_.res_zip) {
        context.GetDiagnostics()->Error(DiagMessage() << "only one of --dir and --zip can be specified");
        return 1;
    } else if (options_.res_dir) {
        //載入目錄下的資原始檔...
        file_collection = io::FileCollection::Create(options_.res_dir.value(), &err);
        //...
    }else if (options_.res_zip) {
        //載入壓縮包格式的資原始檔...
        file_collection = io::ZipFileCollection::Create(options_.res_zip.value(), &err);
        //...
    } else {
        //也是FileCollection,先定義collection,通過迴圈依次新增輸入檔案,再拷貝到file_collection
        file_collection = std::move(collection);
    }
    std::unique_ptr<IArchiveWriter> archive_writer;
    //產物輸出檔案型別
    file::FileType output_file_type = file::GetFileType(options_.output_path);
    if (output_file_type == file::FileType::kDirectory) {
        //輸出到檔案目錄
        archive_writer = CreateDirectoryArchiveWriter(context.GetDiagnostics(), options_.output_path);
    } else {
        //輸出到壓縮包
        archive_writer = CreateZipFileArchiveWriter(context.GetDiagnostics(), options_.output_path);
    }
    if (!archive_writer) {
        return 1;
    }
    return Compile(&context, file_collection.get(), archive_writer.get(), options_);
}

Compile方法中會編譯輸入的資原始檔名,每個資原始檔的處理方式如下:

  • 解析輸入的資源路徑獲取資源名,副檔名等資訊;

  • 根據path判斷檔案型別,然後給compile_func設定不同的編譯函式;

  • 生成輸出的檔名。輸出的就是FLAT檔名,會對全路徑拼接,最終生成上文案例中類似的檔名—“drawable-hdpi_ic_launcher.png.flat”;

  • 傳入各項引數,呼叫compile_func方法執行編譯。

ResourcePathData中包含了資源路徑,資源名,資源副檔名等資訊,AAPT2會從中獲取資源的型別。

 int Compile(IAaptContext* context, io::IFileCollection* inputs, IArchiveWriter* output_writer, CompileOptions& options) {
    TRACE_CALL();
    bool error = false;
    // 編譯輸入的資原始檔
    auto file_iterator  = inputs->Iterator();
    while (file_iterator->HasNext()) {
        // 省略部分程式碼(檔案校驗相關...)
        std::string err_str;
        ResourcePathData path_data;
        // 獲取path全名,用於後續檔案型別判斷
        if (auto maybe_path_data = ExtractResourcePathData(path, inputs->GetDirSeparator(), &err_str)) {
            path_data = maybe_path_data.value();
        } else {
            context->GetDiagnostics()->Error(DiagMessage(file->GetSource()) << err_str);
            error = true;
            continue;
        }
 
        // 根據檔案型別,選擇編譯方法,這裡的 CompileFile 是函式指標,指向一個編譯方法。
        // 使用使用設定為CompileFile方法
        auto compile_func = &CompileFile;
        // 如果是values目錄下的xml資源,使用 CompileTable 方法編譯,並修改副檔名為arsc
        if (path_data.resource_dir == "values" && path_data.extension == "xml") {
            compile_func = &CompileTable;
            // We use a different extension (not necessary anymore, but avoids altering the existing // build system logic).
            path_data.extension = "arsc";
        } else if (const ResourceType* type = ParseResourceType(path_data.resource_dir)) {
            // 解析資源型別,如果kRaw型別,執行預設的編譯方法,否則執行如下程式碼。
            if (*type != ResourceType::kRaw) {
                //xml路徑或者檔案擴充套件為.xml
                if (*type == ResourceType::kXml || path_data.extension == "xml") {
                    // xml類,使用CompileXml方法編譯
                    compile_func = &CompileXml;
                } else if ((!options.no_png_crunch && path_data.extension == "png") || path_data.extension == "9.png") { //如果字尾名是.png並且開啟png優化或者是點9圖型別
                    // png類,使用CompilePng方法編譯
                    compile_func = &CompilePng;
                }
            }
        } else {
            // 不合法的型別,輸出錯誤資訊,繼續迴圈
            context->GetDiagnostics()->Error(DiagMessage() << "invalid file path '" << path_data.source << "'");
            error = true;
            continue;
        } 
        // 校驗檔名中是否有.
        if (compile_func != &CompileFile && !options.legacy_mode && std::count(path_data.name.begin(), path_data.name.end(), '.') != 0) {
            error = true;
            context->GetDiagnostics()->Error(DiagMessage(file->GetSource()) << "file name cannot contain '.' other than for" << " specifying the extension");
            continue;
        }
        // 生成產物檔名,這個方法會生成完成的flat檔名,例如上文demo中的 drawable-hdpi_ic_launcher.png.flat
        const std::string out_path = BuildIntermediateContainerFilename(path_data);
        // 執行編譯方法
        if (!compile_func(context, options, path_data, file, output_writer, out_path)) {
            context->GetDiagnostics()->Error(DiagMessage(file->GetSource()) << "file failed to compile"); error = true;
        }
    }
    return error ? 1 : 0;
}

不同的資源型別會有四種編譯函式:

  • CompileFile

  • CompileTable

  • CompileXml

  • CompilePng

raw目錄下的XML檔案不會執行CompileXml,猜測是因為raw下的資源是直接複製到APK中,不會做XML優化編譯。values目錄下資源除了執行CompileTable編譯之外,還會修改資原始檔的副檔名,可以認為除了CompileFile,其他編譯方法多多少少會對原始資源做處理後,在寫編譯生成的FLAT檔案中。這部分的流程如下圖所示:

編譯命令執行的主流程到這裡就結束了,通過原始碼分析,我們可以知道AAPT2把輸入檔案編譯為FLAT檔案。下面,我們在進一步分析4個編譯方法。

2.3.2 四種編譯函式

CompileFile

函式中先構造ResourceFile物件和原始檔案資料,然後呼叫 WriteHeaderAndDataToWriter 把資料寫到輸出檔案(flat)中。

static bool CompileFile(IAaptContext* context, const CompileOptions& options, const ResourcePathData& path_data, io::IFile* file, IArchiveWriter* writer, const std::string& output_path) {
    TRACE_CALL();
    if (context->IsVerbose()) {
        context->GetDiagnostics()->Note(DiagMessage(path_data.source) << "compiling file");
    }
    // 定義ResourceFile 物件,儲存config,source等資訊
    ResourceFile res_file;
    res_file.name = ResourceName({}, *ParseResourceType(path_data.resource_dir), path_data.name);
    res_file.config = path_data.config;
    res_file.source = path_data.source;
    res_file.type = ResourceFile::Type::kUnknown; //這型別下可能有xml,png或者其他的什麼,統一設定型別為unknow。
    // 原始檔案資料
    auto data = file->OpenAsData();
    if (!data) {
        context->GetDiagnostics()->Error(DiagMessage(path_data.source) << "failed to open file ");
        return false;
    }
    return WriteHeaderAndDataToWriter(output_path, res_file, data.get(), writer, context->GetDiagnostics());
}

ResourceFile的內容相對簡單,完成檔案相關資訊的賦值後就會呼叫通過WriteHeaderAndDataToWriter方法。

在WriteHeaderAndDataToWriter這個方法中,對之前建立的archive_writer(可在本文搜尋,這個歸檔寫建立完成後,會一直傳下來)做一次包裝,經過包裝的ContainerWriter則具備普通檔案寫和protobuf格式序列化寫的能力。

pb提供了ZeroCopyStream 介面使用者資料讀寫和序列化/反序列化操作。

WriteHeaderAndDataToWriter的流程可以簡單歸納為:

  • IArchiveWriter.StartEntry,開啟檔案,做好寫入準備;

  • ContainerWriter.AddResFileEntry,寫入資料;

  • IArchiveWriter.FinishEntry,關閉檔案,釋放記憶體。

static bool WriteHeaderAndDataToWriter(const StringPiece& output_path, const ResourceFile& file, io::KnownSizeInputStream* in, IArchiveWriter* writer, IDiagnostics* diag) {
    // 開啟檔案
    if (!writer->StartEntry(output_path, 0)) {
        diag->Error(DiagMessage(output_path) << "failed to open file");
        return false;
    }
    // Make sure CopyingOutputStreamAdaptor is deleted before we call writer->FinishEntry().
    {
        // 對write做一層包裝,用來寫protobuf資料
        CopyingOutputStreamAdaptor copying_adaptor(writer);
        ContainerWriter container_writer(©ing_adaptor, 1u);
        //把file按照protobuf格式序列化,序列化後的檔案是 pb_compiled_file,這裡的file檔案是ResourceFile檔案,包含了原始檔案的路徑,配置等資訊
        pb::internal::CompiledFile pb_compiled_file;
        SerializeCompiledFileToPb(file, &pb_compiled_file);
        // 再把pb_compiled_file 和 in(原始檔案) 寫入到產物檔案中
        if (!container_writer.AddResFileEntry(pb_compiled_file, in)) {
            diag->Error(DiagMessage(output_path) << "failed to write entry data");
            return false;
        }
    }
    // 退出寫狀態
    if (!writer->FinishEntry()) {
        diag->Error(DiagMessage(output_path) << "failed to finish writing data");
        return false;
    }
    return true;
}

我們再分別來看這三個方法,首先是StartEntry和FinishEntry,這個方法在Archive.cpp中,ZipFileWriter和DirectoryWriter實現有些區別,但邏輯上是一致的,這裡只分析DirectoryWriter的實現。

StartEntry,呼叫fopen開啟檔案。

bool StartEntry(const StringPiece& path, uint32_t flags) override {
    if (file_) {
        return false;
    }
    std::string full_path = dir_;
    file::AppendPath(&full_path, path);
    file::mkdirs(file::GetStem(full_path).to_string());
    //開啟檔案
    file_ = {::android::base::utf8::fopen(full_path.c_str(), "wb"), fclose};
    if (!file_) {
        error_ = SystemErrorCodeToString(errno);
        return false;
    }
    return true;
}

FinishEntry,呼叫reset釋放記憶體。

bool FinishEntry() override {
    if (!file_) {
        return false;
    }
    file_.reset(nullptr);
    return true;
}

ContainerWriter類定義在Container.cpp這個類檔案中。在ContainerWriter類的構造方法中,可以找到檔案頭的寫入程式碼,其格式和上文“FLAT格式”一節中介紹的一致。

// 在類的構造方法中,寫入檔案頭的資訊
ContainerWriter::ContainerWriter(ZeroCopyOutputStream* out, size_t entry_count)
  : out_(out), total_entry_count_(entry_count), current_entry_count_(0u) {
    CodedOutputStream coded_out(out_);
    // 魔法資料,kContainerFormatMagic = 0x54504141u
    coded_out.WriteLittleEndian32(kContainerFormatMagic);  
    // 版本號,kContainerFormatVersion = 1u
    coded_out.WriteLittleEndian32(kContainerFormatVersion);
    // 容器中包含的條目數 total_entry_count_是在ContainerReader構造時賦值,值由外部傳入
    coded_out.WriteLittleEndian32(static_cast<uint32_t>(total_entry_count_));
    if (coded_out.HadError()) {
        error_ = "failed writing container format header";
    }
}

呼叫ContainerWriter的AddResFileEntry方法,寫入資源項內容。

// file:protobuf格式的資訊檔案,in:原始檔案
bool ContainerWriter::AddResFileEntry(const pb::internal::CompiledFile& file, io::KnownSizeInputStream* in) {
    // 判斷條目數量,大於設定數量就直接報錯
    if (current_entry_count_ >= total_entry_count_) {
        error_ = "too many entries being serialized";
        return false;
    }
    // 條目++
    current_entry_count_++;
    constexpr const static int kResFileEntryHeaderSize = 12; 、
    //輸出流
    CodedOutputStream coded_out(out_);
    //寫入資源型別
    coded_out.WriteLittleEndian32(kResFile);
 
    const ::google::protobuf::uint32
    // ResourceFile 檔案長度 ,該部分包含了當前檔案的路徑,型別,配置等資訊
    header_size = file.ByteSize();
    const int header_padding = CalculatePaddingForAlignment(header_size);
    // 原始檔案長度
    const ::google::protobuf::uint64 data_size = in->TotalSize();
    const int data_padding = CalculatePaddingForAlignment(data_size);
    // 寫入資料長度,計算公式:kResFileEntryHeaderSize(固定12) + ResourceFile檔案長度 + header_padding + 原始檔案長度 + data_padding
    coded_out.WriteLittleEndian64(kResFileEntryHeaderSize + header_size + header_padding + data_size + data_padding);
 
    // 寫入檔案頭長度
    coded_out.WriteLittleEndian32(header_size);
    // 寫入資料長度
    coded_out.WriteLittleEndian64(data_size);
    // 寫入“頭資訊”
    file.SerializeToCodedStream(&coded_out);
    // 對齊
    WritePadding(header_padding, &coded_out);
    // 使用Copy之前需要呼叫Trim(至於為什麼,其實也不太清楚,好在我們學習AAPT2,瞭解底層API的功能即可。如果有讀者知道,希望賜教)
    coded_out.Trim();
    // 異常判斷
    if (coded_out.HadError()) {
        error_ = "failed writing to output"; return false;
    } if (!io::Copy(out_, in)) {  //資源資料(原始碼中也叫payload,可能是png,xml,或者XmlNode)
        if (in->HadError()) {
            std::ostringstream error;
            error << "failed reading from input: " << in->GetError();
            error_ = error.str();
        } else {
            error_ = "failed writing to output";
        }
        return false;
    }
    // 對其
    WritePadding(data_padding, &coded_out);
    if (coded_out.HadError()) {
        error_ = "failed writing to output";
        return false;
    }
    return true;
}

這樣,FLAT檔案就完成寫入了,並且產物檔案除了包含資源內容,還包含了檔名,路徑,配置等資訊。

CompilePng

該方法和CompileFile流程上是類似的,區別在於會先對PNG圖片做處理(png優化和9圖處理),處理完成後在寫入FLAT檔案。

​static bool CompilePng(IAaptContext* context, const CompileOptions& options, const ResourcePathData& path_data, io::IFile* file, IArchiveWriter* writer, const std::string& output_path) {
    //..省略部分校驗程式碼
    BigBuffer buffer(4096);
   // 基本一樣的程式碼,區別是type不一樣
    ResourceFile res_file;
    res_file.name = ResourceName({}, *ParseResourceType(path_data.resource_dir), path_data.name);
    res_file.config = path_data.config;
    res_file.source = path_data.source;
    res_file.type = ResourceFile::Type::kPng;
 
    {
        // 讀取資源內容到data中
        auto data = file->OpenAsData();
        // 讀取結果校驗
        if (!data) {
            context->GetDiagnostics()->Error(DiagMessage(path_data.source) << "failed to open file ");
            return false;
        }
        // 用來儲存輸出流
        BigBuffer crunched_png_buffer(4096);
        io::BigBufferOutputStream crunched_png_buffer_out(&crunched_png_buffer);
 
        // 對PNG圖片做優化
        const StringPiece content(reinterpret_cast<const char*>(data->data()), data->size());
        PngChunkFilter png_chunk_filter(content);
        std::unique_ptr<Image> image = ReadPng(context, path_data.source, &png_chunk_filter);
        if (!image) {
            return false;
        }
         
        // 處理.9圖
        std::unique_ptr<NinePatch> nine_patch;
        if (path_data.extension == "9.png") {
            std::string err;
            nine_patch = NinePatch::Create(image->rows.get(), image->width, image->height, &err);
            if (!nine_patch) {
                context->GetDiagnostics()->Error(DiagMessage() << err); return false;
            }
            // 移除1畫素的邊框
            image->width -= 2;
            image->height -= 2;
            memmove(image->rows.get(), image->rows.get() + 1, image->height * sizeof(uint8_t**));
            for (int32_t h = 0; h < image->height; h++) {
                memmove(image->rows[h], image->rows[h] + 4, image->width * 4);
            } if (context->IsVerbose()) {
                context->GetDiagnostics()->Note(DiagMessage(path_data.source) << "9-patch: " << *nine_patch);
            }
        }
 
        // 儲存處理後的png到 &crunched_png_buffer_out
        if (!WritePng(context, image.get(), nine_patch.get(), &crunched_png_buffer_out, {})) {
            return false;
        }
 
        // ...省略部分圖片校驗程式碼,這部分程式碼會比較優化後的圖片和原圖片的大小,如果優化後比原圖片大,則使用原圖片。(PNG優化後是有可能比原圖片還大的)
    }
    io::BigBufferInputStream buffer_in(&buffer);
    // 和 CompileFile 呼叫相同的方法,寫入flat檔案,資原始檔內容是
    return WriteHeaderAndDataToWriter(output_path, res_file, &buffer_in, writer, context->GetDiagnostics());
}

AAPT2 對於 PNG 圖片的壓縮可以分為三個方面:

  • RGB 是否可以轉化成灰度;

  • 透明通道是否可以刪除;

  • 是不是最多隻有 256 色(Indexed_color 優化)。

PNG優化,有興趣的同學可以看看

在完成PNG處理後,同樣會呼叫WriteHeaderAndDataToWriter來寫資料,這部分內容可閱讀上文分析,不再贅述。

CompileXml

該方法先會解析XML,然後建立XmlResource,其中包含了資源名,配置,型別等資訊。通過FlattenXmlToOutStream函式寫入輸出檔案。

static bool CompileXml(IAaptContext* context, const CompileOptions& options,
                       const ResourcePathData& path_data, io::IFile* file, IArchiveWriter* writer,
                       const std::string& output_path) {
  // ...省略校驗程式碼
  std::unique_ptr<xml::XmlResource> xmlres;
  {
    // 開啟xml檔案
    auto fin = file->OpenInputStream();
    // ...省略校驗程式碼
    // 解析XML
    xmlres = xml::Inflate(fin.get(), context->GetDiagnostics(), path_data.source);
    if (!xmlres) {
      return false;
    }
  }
  //
  xmlres->file.name = ResourceName({}, *ParseResourceType(path_data.resource_dir), path_data.name);
  xmlres->file.config = path_data.config;
  xmlres->file.source = path_data.source;
  xmlres->file.type = ResourceFile::Type::kProtoXml;
 
  // 判斷id型別的資源是否有id合法(是否有id異常,如果有提示“has an invalid entry name”)
  XmlIdCollector collector;
  if (!collector.Consume(context, xmlres.get())) {
    return false;
  }
 
  // 處理aapt:attr內嵌資源
  InlineXmlFormatParser inline_xml_format_parser;
  if (!inline_xml_format_parser.Consume(context, xmlres.get())) {
    return false;
  }
 
  // 開啟輸出檔案
  if (!writer->StartEntry(output_path, 0)) {
    context->GetDiagnostics()->Error(DiagMessage(output_path) << "failed to open file");
    return false;
  }
 
  std::vector<std::unique_ptr<xml::XmlResource>>& inline_documents =
      inline_xml_format_parser.GetExtractedInlineXmlDocuments();
 
  {
    // 和CompileFile 類似,建立可處理protobuf格式的writer,用於protobuf格式序列化
    CopyingOutputStreamAdaptor copying_adaptor(writer);
    ContainerWriter container_writer(©ing_adaptor, 1u + inline_documents.size());
 
    if (!FlattenXmlToOutStream(output_path, *xmlres, &container_writer,
                               context->GetDiagnostics())) {
      return false;
    }
    // 處理內嵌的元素(aapt:attr)
    for (const std::unique_ptr<xml::XmlResource>& inline_xml_doc : inline_documents) {
      if (!FlattenXmlToOutStream(output_path, *inline_xml_doc, &container_writer,
                                 context->GetDiagnostics())) {
        return false;
      }
    }
  }
  // 釋放記憶體
  if (!writer->FinishEntry()) {
    context->GetDiagnostics()->Error(DiagMessage(output_path) << "failed to finish writing data");
    return false;
  }
  // 編譯選項部分,省略
  return true;
}

在編譯XML方法中,並沒有像前面兩個方法那樣建立ResourceFile,而是建立了XmlResource,用於儲存XML資源的相關資訊,其結構包含如下內容:

在執行Inflate方法後,XmlResource 中會包含資源資訊和XML的dom樹資訊。InlineXmlFormatParser是用於解析出內聯屬性aapt:attr

使用 AAPT 的內嵌資源格式,可以在同一 XML 檔案中定義所有多種資源,如果不需要資源複用的話,這種方式更加緊湊。XML 標記告訴 AAPT,該標記的子標記應被視為資源並提取到其自己的資原始檔中。屬性名稱中的值用於指定在父標記內使用內嵌資源的位置。AAPT 會為所有內嵌資源生成資原始檔和名稱。使用此內嵌格式構建的應用可與所有版本的 Android 相容。——官方文件

解析後的FlattenXmlToOutStream 中首先會呼叫SerializeCompiledFileToPb方法,把資原始檔的相關資訊轉化成protobuf格式,然後在呼叫SerializeXmlToPb把之前解析的Element 節點資訊轉換成XmlNode(protobuf結構,同樣定義在 Resources),然後再把生成XmlNode轉換成字串。最後,再通過上文的AddResFileEntry方法新增到FLAT檔案的資源項中。這裡可以看出,通過XML生成的FLAT檔案檔案,存在一個FLAT檔案中可包含多個資源項。

static bool FlattenXmlToOutStream(const StringPiece& output_path, const xml::XmlResource& xmlres,
                                  ContainerWriter* container_writer, IDiagnostics* diag) {
  // 序列化CompiledFile部分
  pb::internal::CompiledFile pb_compiled_file;
  SerializeCompiledFileToPb(xmlres.file, &pb_compiled_file);
 
  // 序列化XmlNode部分
  pb::XmlNode pb_xml_node;
  SerializeXmlToPb(*xmlres.root, &pb_xml_node);
 
  // 專程string格式的流,這裡可以再找原始碼看看
  std::string serialized_xml = pb_xml_node.SerializeAsString();
  io::StringInputStream serialized_in(serialized_xml);
  // 儲存到資源項中
  if (!container_writer->AddResFileEntry(pb_compiled_file, &serialized_in)) {
    diag->Error(DiagMessage(output_path) << "failed to write entry data");
    return false;
  }
  return true;
}

protobuf格式處理的方法(SerializeXmlToPb)在ProtoSerialize.cpp中,其通過遍歷和遞迴的方式實現節點結構的複製,有興趣的讀者的可以檢視原始碼。

CompileTable

CompileTable函式用於處理values下的資源,從上文中可知,values下的資源在編譯時會被修改擴充套件為arsc。最終輸出的檔名為*.arsc.flat,效果如下圖:

在函式開始,會讀取資原始檔,完成xml解析並儲存為ResourceTable結構,然後在通過SerializeTableToPb將其轉換成protobuf格式的pb::ResourceTable,然後呼叫SerializeWithCachedSizes把protobuf格式的table序列化到輸出檔案。

static bool CompileTable(IAaptContext* context, const CompileOptions& options,
                         const ResourcePathData& path_data, io::IFile* file, IArchiveWriter* writer,
                         const std::string& output_path) {
  // Filenames starting with "donottranslate" are not localizable
  bool translatable_file = path_data.name.find("donottranslate") != 0;
  ResourceTable table;
  {
    // 讀取檔案
    auto fin = file->OpenInputStream();
    if (fin->HadError()) {
      context->GetDiagnostics()->Error(DiagMessage(path_data.source)
          << "failed to open file: " << fin->GetError());
      return false;
    }
 
    // 建立XmlPullParser,設定很多handler,用於xml解析
    xml::XmlPullParser xml_parser(fin.get());
     
    // 設定解析選項
    ResourceParserOptions parser_options;
    parser_options.error_on_positional_arguments = !options.legacy_mode;
    parser_options.preserve_visibility_of_styleables = options.preserve_visibility_of_styleables;
    parser_options.translatable = translatable_file;
    parser_options.visibility = options.visibility;
     
    // 建立ResourceParser,並把結果儲存到ResourceTable中
    ResourceParser res_parser(context->GetDiagnostics(), &table, path_data.source, path_data.config,
        parser_options);
    // 執行解析
    if (!res_parser.Parse(&xml_parser)) {
      return false;
    }
  }
  // 省略部分校驗程式碼
 
  // 開啟輸出檔案
  if (!writer->StartEntry(output_path, 0)) {
    context->GetDiagnostics()->Error(DiagMessage(output_path) << "failed to open");
    return false;
  }
 
  {
    // 和前面一樣,建立ContainerWriter 用於寫檔案
    CopyingOutputStreamAdaptor copying_adaptor(writer);
    ContainerWriter container_writer(©ing_adaptor, 1u);
 
    pb::ResourceTable pb_table;
    // 把ResourceTable序列化為pb::ResourceTable
    SerializeTableToPb(table, &pb_table, context->GetDiagnostics());
    // 寫入資料項pb::ResourceTable
    if (!container_writer.AddResTableEntry(pb_table)) {
      context->GetDiagnostics()->Error(DiagMessage(output_path) << "failed to write");
      return false;
    }
  }
 
  if (!writer->FinishEntry()) {
    context->GetDiagnostics()->Error(DiagMessage(output_path) << "failed to finish entry");
    return false;
  }
  // ...省略部分程式碼...
  }
 
  return true;
}

三、問題和總結

通過上文的學習,我們知道AAPT2是Android資源打包的構建工具,它把資源編譯分為編譯和連結兩個部分。其中,編譯是把不同的資原始檔,統一編譯生成針對 Android 平臺進行過優化的二進位制格式(flat)。FLAT檔案除了包含原始資原始檔的內容,還有該資源來源,型別等資訊,這樣一個檔案中包含資源所需的所有資訊,於其它依賴接耦。

在本文的開頭,我們有如下的問題:

Java檔案需要編譯才能生.class檔案,這個我能明白,但資原始檔編譯到底是幹什麼的?為什麼要對資源做編譯?

那麼,本文的答案是:AAPT2的編譯時把資原始檔編譯為FLAT檔案,而且從資源項的檔案結構可以知道,FLAT檔案中部分資料是原始的資源內容,一部分是檔案的相關資訊。通過編譯,生成的中間檔案包含的資訊比較全面,可用於增量編譯。另外,網上的一些資料還表示,二進位制的資源體積更小,且載入更快。

AAPT2通過編譯,實現把資原始檔編譯成FLAT檔案,接下來則通過連結,來生成R檔案和資源表。由於篇幅問題,連結的過程將在下篇文章中分析。

四、參考文件

1.https://juejin.cn

2.https://github.com

3.https://booster.johnsonlee.io

作者:vivo網際網路前端團隊-Shi Xiang

相關文章