Макросы Haxe. Автоматическое встраивание ресурсов (assets embedding).

Исходники первой статьи с рабочей версией для Haxe 3 и решенным дополнительным заданием тут. В файле Main3.hx можно найти еще пару вариантов getBuildDate с упрощенным синтаксисом из Haxe 3.

Как и ожидалось, первая статья вызвала хоть и не большой, но интерес. И дабы не остужать его, было решено сделать что-то более интересное и полезное. Полезным это будет для flash разработки, но идеи и техники, описанные в статье, можно будет использовать в различного рода макросах. Еще этот макрос я решил писать под Haxe 3. А все потому, что вышел Haxe 3 RC 2 и самое время его скачать и начать использовать, особенно легко это сделать пользователям FlashDevelop, т.к. им достаточно указать папку с Haxe 3 в табе SDK настроек проекта, а для линукса надо всего лишь собрать Haxe из исходников самому. Да и под Haxe 2 макрос отказывался работать со странной ошибкой, которую, видимо, поправили в 3-й версии.

Из названия статьи понятно, что мы будем что-то встраивать, а именно звуки и графику, как самые распространенные ассеты.

Для начала посмотрим, как в Haxe реализуется встраивание ресурсов без макросов для flash платформы:

@:sound("file.wav|mp3") class MySound extends flash.media.Sound {}
@:bitmap("myfile.png|jpg|gif") class MyBitmapData extends flash.display.BitmapData {}

Выглядит очень неплохо. Но как только таких встраиваний надо сделать более 20, начинаются сложности: рутинная работа, сложно уследить за всеми ассетами, менять и редактировать существующие. И тут на помощь приходят макросы.

Идея очень простая: напишем макрос, который автоматически встроит все ассеты из папки, а также создаст статические переменные для доступа к экземплярам встроенных BitmapData и Sound (опять-таки не самое изящное решение, но для примера и для упрощения задачи оно вполне сгодится).

Для того, чтобы создать статические переменные в уже определенном ранее классе, нам нужно узнать еще один мета тег:

@:build(MyMacro.build()) class A {}

Данная конструкция говорит компилятору, чтобы он (компилятор) при создании класса A, вызвал статическую макро функцию build из класса MyMacro. При этом макрос MyMacro.build возвращает не Expr, как обычные макросы, а массив haxe.macro.Field структур, которые дополнят определение класса новыми полями и методами и/или отредактируют существующие:

macro public static function build():Array<Field>

Описание Field структуры можно найти в модуле haxe.macro.Expr (самое время туда заглянуть, если вы этого еще не сделали). Скажу лишь, что Field может описать любую переменную или функцию внутри класса (в нашем случае это класс A). Еще, обратите внимание, что мета тег @:macro превратился в аксессор macro. Видно, что в Haxe 3 макросы стали неотъемлемой частью языка и заслужили отдельный аксессор.

Но вернемся к нашей идее получить список всех файлов из указанной папки и попробуем написать базу для нашего макроса:

package deep.macro;

import haxe.macro.Context;
import haxe.macro.Expr;
import sys.FileSystem;

class AssetsMacros {
       
        macro static public function embed(path:String):Array<Field> {
               
                trace(path);
                path = Context.resolvePath(path);
                trace(path);
                for (f in FileSystem.readDirectory(path)) {
                        trace(f);
                }
                return [];
        }
}

И класс, который мы и будем “редактировать” (в нем то и появятся наши статические переменные):

package assets;
@:build(deep.macro.AssetsMacros.embed("../assets")) class Assets { }

Давайте разбираться подробнее. Если класс Assets и мета перед ним, надеюсь, понятны, то в макро функции есть кое-что новое. Для начала обратите внимание, что метод embed принимает в качестве параметра константу строкового типа - path (изначально макросы могли принимать в роли параметров только Expr структуры, но потом и тут сделали упрощение и позволили использовать константы базовых типов, массивы и анонимные структуры, содержащие константы базовых типов напрямую). А теперь посмотрим на результат выполнения данной макро функции, а чтобы ее выполнить нужно обязательно упомянуть класс Assets где-то в коде, импорта класса будет достаточно:

src/deep/macro/AssetsMacros.hx:11: ../assets
src/deep/macro/AssetsMacros.hx:13: src/../assets
src/deep/macro/AssetsMacros.hx:15: haxe.png
src/deep/macro/AssetsMacros.hx:15: folder
src/deep/macro/AssetsMacros.hx:15: flash2.png
src/deep/macro/AssetsMacros.hx:15: 2.mp3
src/deep/macro/AssetsMacros.hx:15: 1.mp3

Первая строчка - это путь, указанный при вызове метода в мете @:build, а вторая строка - путь, отформатированный методом Context.resolvePath. Второй путь более правильный, и понятный Haxe компилятору. А вот дальше перечисляется содержимое папки assets (видно, что там находятся два изображения, два аудио файла и одна вложенная папка folder). Содержимое папки нам удалось получить благодаря методу FileSystem.readDirectory из пакета sys. Раньше реализации класса FileSystem для каждой из доступных платформ находились в отдельных пакетах, но потом их объединили в общий пакет sys, доступный и для макросов. Итого: мы передали путь к папке, где искать ассеты, и из макроса считали содержимое папки. Самое время отобрать из всего обилия нужные нам файлы и встроить их в проект (тут я позволю себе пропустить ту часть, где я нахожу имя и расширение файла и по нему определяю тип ассета).

Для определения типов, неважно, будь то класс, enum или typedef структура, существует специальная структура TypeDefinition (доступная в модуле haxe.macro.Expr):

typedef TypeDefinition = {
        var pack : Array<String>;
        var name : String;
        var pos : Position;
        var meta : Metadata;
        var params : Array<TypeParamDecl>;
        var isExtern : Bool;
        var kind : TypeDefKind;
        var fields : Array<Field>;
}

Взгляните, как можно заполнить эту структуру для решения нашей задачи:

var clazz:TypeDefinition  = {
        pos : filePos,
        fields : [],
        params : [],
        pack : ["assets"],
        name : getPrefix(type) + name,
        meta : [ { name : getMetaName(type), params : [ { expr :EConst(CString("data:" + data)), pos :filePos } ], pos : filePos } ],
        isExtern : false,
        kind : getKind(type),
};

Видно, что мы создадим класс в пакете assets с одной метой и без полей. Название класса собирается из двух частей: префикса, зависящего от типа ассета, и названия файла без расширения. Вид меты и базовый класс также зависит от типа ассета.

Обратите внимание на поле meta. Дело в том, что его содержимое не просто строка с путем к файлу, а само содержимое файла в виде строки с префиксом “data:”. В документации по Haxe я не нашел информации о префиксе data, но Николас в своих макросах использовал его таким образом, поэтому я сделал по аналогии.

Тип ассета и методы, возвращающие префикс, базовый класс и название меты выглядят следующим образом:

#if macro
enum AssetType {
        AImage;
        ASound;
}
#end
...
#if macro

// Название мета тега
static function getMetaName(type:AssetType) {
        return switch (type) {
                case AImage: ":bitmap";
                case ASound: ":sound";
        }
}
       
// Базовый тип
static function getKind(type:AssetType) {
        return switch (type) {
                case AImage: TDClass( { pack : ["flash", "display"], name : "BitmapData", params :[] } );
                case ASound: TDClass( { pack : ["flash", "media"], name : "Sound", params :[] } );
        }
}
       
// префикс класса
static function getPrefix(type:AssetType) {
        return switch (type) {
                case AImage: "Bitmap_";
                case ASound: "Sound_";
        }
}
#end

Уф, как много получилось, а ведь я так хотел сделать все попроще. Сразу уточню, что #if macro тут действительно нужен, хотя и не обязателен, однако он позволяет отделить часть кода, которая доступна только из макро функций и невидима для остальных. Надеюсь, что работа методов getMetaName и getPrefix понятна, и на них мы останавливаться не будем. А вот метод getKind возвращает один из конструкторов enum-а TypeDefKind (тут вы уже точно ищите его в haxe.macro.Expr и внимательно изучите весь модуль, пожалуйста). Если присмотреться, то видно, что для картинки базовый класс окажется flash.display.BitmapData, а для звуков flash.media.Sound. Enum AssetType сделал я сам и в будущем его можно будет дополнить, например, AFont и ABytes, как вариант.

Подведем очередной итог: мы получили список всех файлов в папке, определили тип файлов - AssetType и в зависимости от типа создали класс в пакете assets, так что картинки начинаются с префикса “Bitmap_”, а звуки - с “Sound_” и связали с этими классами внешние ассеты с помощью метатегов.

Остается только сказать компилятору, чтобы он использовал эти классы наравне с другими:

Context.defineType(clazz);

и тогда можно создавать наши автоматически сгенерированные ассеты и работать с ними:

var s = new assets.Sound_1();
s.play();
               
var i = new assets.Bitmap_flash(0, 0);
Lib.current.addChild(new Bitmap(i));

А все потому, что все ассеты уже встроены в результирующую флешку:

Context.defineType - очередной метод класса Context, который продолжает нас радовать. Вдумайтесь: мы только что, создали новый класс и компилятор его знает, но текстового файла с этим классом нет и не будет. А для того, чтобы компилятор знал откуда этот класс взялся, мы создадим для него свой Position, на месте файла ассета (помните в прошлой статье я говорил, что Position иногда нужно создавать самому? И это как раз тот самый случай):

var filePos = Context.makePosition( { min:0, max:0, file:file } );

Ну и напоследок создадим и инициализируем наши статические аксессоры:

var res = Context.getBuildFields();
...
res.push( {
        name : getPrefix(type).toLowerCase() + name,
        access : [APublic, AStatic],
        doc : null,
        kind : FVar(null, { expr : ENew( { pack : ["assets"], name : getPrefix(type) + name, params : [] }, getArgs(type)), pos : pos } ),
        meta : [],
        pos : pos,
});

Для этого дополним массив res статическими публичными переменными [APublic, AStatic] с именами, совпадающим с именами классов, но с маленькой буквы, и сразу инициализируем переменные, вызвав конструктор ENew классов. Массив res изначально хранит все поля класса, которые были ранее в нем определенны, если этого не сделать, то все, что бы мы не определили в классе Assets в итоге исчезнет, все методы и свойства, кроме новых, созданных макросом.

Как я и предупреждал, все мало-мальски сложные макросы строятся на Expr, и macro reification тут встречается не часто. Осталось только показать, как работает автодополнение в случае такого вмешательства в класс Assets:

Как видно, все сгенерированные налету статические поля видны в автодополнении, что только упрощает работу и дает надежду на светлое будущее :).

На этом я, пожалуй, остановлюсь. Можно еще сделать рекурсивную обработку подпапок, можно не инициировать все ассеты сразу, а сделать геттеры, которые будут создавать ассеты только по необходимости, можно оптимизировать макрос для автодополнения (исключив часть инструкций и, тем самым, ускорив процесс автодополнения), можно дополнить тип ассетов шрифтами и бинарными файлами, да и текстовыми тоже можно! Займитесь этим на досуге. Считайте это заданием к этому уроку. Тем более, что на этот раз я приложу все исходники, которые позволят вам увидеть весь проект целиком и даже те несколько строк кода, которые я скрыл из статьи. Вот вам еще идея: налету оптимизировать графику и звуки, правда такие вещи лучше кешировать в файлы и делать оптимизацию только по необходимости, иначе каждое автодополнение будет опять таки пережимать файлы, можно например создавать image_80.jpg для 80% сжатой картинки и т.д.

Ну и конечно же ссылка на макрос Николаса, которым я вдохновлялся.

Проект целиком можно найти на гитхабе.

Отдельная благодарность Александру Хохлову, Александру Кузьменко и SlavaRa за помощью в написании статьи, подсказки, замечания и поиск допущенных мной ошибок.