Игровой движок Corona позволяет создавать кроссплатформенные приложения и игры. Но иногда предоставляемого им API бывает недостаточно. Для таких случаев есть Corona Native, позволяющий расширять функциональность с использованием родного кода для каждой платформы.
В статье пойдёт речь об использовании Java в проектах Corona для android
Для понимания происходящего в статье требуются базовые знания Java, Lua и движка Corona
Начало работы
На компьютере должны быть установлены Corona и Android Studio
В папке с установкой Corona также находится шаблон проекта: Native\Project Template\App. Копируем всю папку и переименовываем в имя своего проекта.
Настройка шаблона
Примечание: я использовал последний доступный public build для Corona — 2017.3184. В новых версиях шаблон может измениться, и некоторые приготовления из этой главы перестанут быть нужны.
Для android нам нужны 2 папки внутри: Corona и android
Из папки Corona удаляем Images.xcassets и LaunchScreen.storyboardc — эти папки нам не понадобятся. В файле main.lua также удаляем весь код — мы начнём создание проекта с нуля. Если вы хотите использовать существующий проект, то замените все файлы в папке Corona на свои
Папка android — это готовый проект для Android Studio, нам нужно открыть его. Первым же сообщением от студии будет "Gradle sync failed". Нужно исправить build.gradle:
Чтобы исправить ситуацию, добавляем ссылку на repositories в buildscript. Я также изменил версию в classpath 'com.android.tools.build:gradle' на более новую.
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:3.1.3'
}
repositories {
jcenter()
google()
}
}
allprojects {
repositories {
jcenter()
google()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
Следующим шагом будет изменение gradle-wrapper.properties. Изменить можно вручную, заменив в distributionUrl версию gradle. Или позволить студии всё сделать за вас.
Дополнительно нужно поправить build.gradle для модуля app: в cleanAssets нужно добавить строчку delete "$projectDir/build/intermediates/jniLibs", без которой придётся делать clean проекта перед каждым запуском (взято отсюда)
Теперь синхронизация удалась, осталось только несколько warnings, связанных с устаревшей buildToolsVersion и старым синтаксисом в конфигурации. Поправить их не составит труда.
Теперь в студии мы видим 2 модуля: app и plugin. Стоит переименовать приложение (com.mycompany.app) и плагин (plugin.library), перед тем как продолжить работу.
Далее в коде плагин будет называться plugin.habrExamplePlugin
В плагине по умолчанию находится класс LuaLoader — именно он будет отвечать за обработку вызовов из lua кода. Там уже есть некий код, но давайте его очистим.
package plugin.habrExamplePlugin;
import com.naef.jnlua.JavaFunction;
import com.naef.jnlua.LuaState;
@SuppressWarnings({"WeakerAccess", "unused"})
public class LuaLoader implements JavaFunction {
@Override
public int invoke(LuaState luaState) {
return 0;
}
}
Использование кода плагина из lua кода
Для биндинга между java и lua кодом в Corona Native используется jnlua. LuaLoader реализует интерфейс jnlua.JavaFunction, таким образом его метод invoke доступен из lua кода. Чтобы удостовериться, что всё в порядке, добавим логгирующий код в LuaLoader.invoke и сделаем require плагина в main.lua
@Override
public int invoke(LuaState luaState) {
Log.d("Corona native", "Lua Loader invoke called");
return 0;
}
local habrPlugin = require("plugin.habrExamplePlugin")
print("test:", habrPlugin)
Запустив приложение, среди логов увидим следующие 2 строчки:
D/Corona native: Lua Loader invoke called
I/Corona: test true
Итак, наше приложение загрузило плагин, а require возвращает true. Теперь попробуем вернуть из Java кода lua-таблицу с функциями.
Для добавления функций в модуль воспользуемся интерфейсом jnlua.NamedJavaFunction. Пример простой функции без аргументов и без возвращаемого значения:
class HelloHabrFunction implements NamedJavaFunction {
@Override
public String getName() {
return "helloHabr";
}
@Override
public int invoke(LuaState L) {
Log.d("Corona native", "Hello Habr!");
return 0;
}
}
Для регистрации нашей новой функции в lua используем метод LuaState.register:
public class LuaLoader implements JavaFunction {
@Override
public int invoke(LuaState luaState) {
Log.d("Corona native", "Lua Loader invoke called");
String libName = luaState.toString(1); // получаем имя модуля из стека (первый параметр require)
NamedJavaFunction[] luaFunctions = new NamedJavaFunction[]{
new HelloHabrFunction(), // создаём экземпляр нашей функции
};
luaState.register(libName, luaFunctions); // регистрируем наш модуль, он помещается наверх стека
// Цифра 1 показывает сколько аргументов из стека вернётся в lua код.
// Но в случае с require это ни на что не повлияет, require вернёт только наш модуль
return 1;
}
Данный код требует дополнительных пояснений:
LuaState, параметр метода invoke, по сути представляет обёртку над виртуальной машиной Lua (прошу меня скорректировать если я неверно выразился). Для тех, кто знаком с использованием lua кода из C, LuaState представляет собой то же, что и указатель lua_State в C.
Для тех, кто хочет углубиться в дебри работы с lua, рекомендую почитать мануал, начиная с The Application Program Interface
Итак, при вызове функции invoke мы получаем LuaState. У него есть стек, который содержит параметры, переданные в нашу функцию из lua кода. В данном случае это имя модуля, поскольку LuaLoader исполняется в момент вызова require("plugin.habrExamplePlugin").
Возвращаемое функцией invoke число показывает количество переменных из стека, которое вернётся в lua код. В случае с вызовом require это число ни на что не влияет, но мы воспользуемся этим знанием позже, создав функцию, возвращающую несколько значений
Добавление полей в модуль
Помимо функций мы также можем добавить в модуль дополнительные поля, например версию:
luaState.register(libName, luaFunctions); // регистрируем наш модуль, он будет расположен на вершине стека
luaState.pushString("0.1.2"); // кладём в стек строку
luaState.setField(-2, "version"); // установка поля version у нашего модуля.
В данном случае мы воспользовались индексом -2, чтобы указать, что поле нужно установить у нашего модуля. Отрицательный индекс означает, что отсчёт начинается с конца стека. -1 будет указывать на строку "0.1.2" (в lua индексы начинаются с единицы).
Чтобы не засорять стек, после установки поля я рекомендую вызывать luaState.pop(1) — выбрасывает из стека 1 элемент.
@SuppressWarnings({"WeakerAccess", "unused"})
public class LuaLoader implements JavaFunction {
@Override
public int invoke(LuaState luaState) {
Log.d("Corona native", "Lua Loader invoke called");
String libName = luaState.toString(1); // получаем имя модуля из стека (первый параметр require)
NamedJavaFunction[] luaFunctions = new NamedJavaFunction[]{
new HelloHabrFunction(), // создаём экземпляр нашей функции
};
luaState.register(libName, luaFunctions); // регистрируем наш модуль, он помещается наверх стека
luaState.register(libName, luaFunctions); // регистрируем наш модуль, он будет расположен на вершине стека
luaState.pushString("0.1.2"); // кладём в стек строку
luaState.setField(-2, "version"); // установка поля version у нашего модуля.
// Цифра 1 показывает сколько аргументов из стека вернётся в lua код.
// Но в случае с require это ни на что не повлияет, require вернёт только наш модуль
return 0;
}
}
Примеры функций
Реализация:
class StringJoinFunction implements NamedJavaFunction{
@Override
public String getName() {
return "stringJoin";
}
@Override
public int invoke(LuaState luaState) {
int currentStackIndex = 1;
StringBuilder stringBuilder = new StringBuilder();
while (!luaState.isNone(currentStackIndex)){
String str = luaState.toString(currentStackIndex);
if (str != null){ //toString возвращает null для non-string и non-number, игнорируем
stringBuilder.append(str);
}
currentStackIndex++;
}
luaState.pushString(stringBuilder.toString());
return 1;
}
}
Использование в lua:
local joinedString = habrPlugin.stringJoin("this", " ", "was", " ", "concated", " ", "by", " ", "Java", "!", " ", "some", " ", "number", " : ", 42);
print(joinedString)
class SumFunction implements NamedJavaFunction{
Override
public String getName() {
return "sum";
}
@Override
public int invoke(LuaState luaState) {
if (!luaState.isNumber(1) || !luaState.isNumber(2)){
luaState.pushNil();
luaState.pushString("Arguments should be numbers!");
return 2;
}
int firstNumber = luaState.toInteger(1);
int secondNumber = luaState.toInteger(1);
luaState.pushInteger(firstNumber + secondNumber);
return 1;
}
}
Java Reflection — использование Java классов напрямую в lua
В библиотеке jnlua есть специальный класс JavaReflector, который отвечает за создание lua таблицы из java объекта. Таким образом можно писать классы на java и отдавать их в lua код для дальнейшего использования.
Сделать это достаточно просто:
Пример класса
@SuppressWarnings({"unused"})
public class Calculator {
public int sum(int number1, int number2){
return number1 + number2;
}
public static int someStaticMethod(){
return 4;
}
}
Добавление экземпляра этого класса к нашему модулю
luaState.pushJavaObject(new Calculator());
luaState.setField(-2, "calc");
luaState.pop(1);
Использование в Lua:
local calc = habrPlugin.calc
print("call method of java object", calc:sum(3,4))
print("call static method of java object", calc:getClass():someStaticMethod())
Обратите внимание на двоеточие в вызове метода класса. Для статических методов также нужно использовать двоеточие.
Тут я заметил интересную особенность рефлектора: если мы передаём в lua только экземпляр класса, то вызов его статического метода возможен через getClass(). Но после вызова через getClass() последующие вызовы буду срабатывать и на самом объекте:
print("call method of java object", calc:sum(3,4)) -- ok
print("exception here", calc:someStaticMethod()) -- бросает исключение "com.naef.jnlua.LuaRuntimeException: no method of class plugin.habrExamplePlugin.Calculator matches 'someStaticMethod()'"
print("call static method of java object", calc:getClass():someStaticMethod()) -- ok
print("hmm", calc:someStaticMethod()) -- после вызова через getClass мы получили возможность работать с этим методом напрямую
Также, используя getClass(), мы можем создавать новые объекты прямо в lua:
local newInstance = calc:getClass():new()
К сожалению, сохранить Calculator.class в поле модуля мне не удалось из-за "java.lang.IllegalArgumentException: illegal type" внутри setField.
Создание и вызов lua функций "на лету"
Этот раздел появился по причине того, что корона не предоставляет возможность обратиться к функциям из своего api напрямую в Java. Но jnlua.LuaState позволяет загружать и выполнять произвольный lua код:
class CreateDisplayTextFunction implements NamedJavaFunction{
// Вызываем функцию из API короны
private static String code = "local text = ...;" +
"return display.newText({" +
"text = text," +
"x = 160," +
"y = 200," +
"});";
@Override
public String getName() {
return "createText";
}
@Override
public int invoke(LuaState luaState) {
luaState.load(code,"CreateDisplayTextFunction code"); // загружаем код в стек, создавая из него функцию
luaState.pushValue(1); // помещаем первый параметр функции на вершину стека
luaState.call(1, 1); // вызываем нашу функцию, указываем что она должна получить 1 параметр, а также вернуть 1
return 1;
}
}
Не забудьте зарегистрировать функцию через LuaLoader.invoke, аналогично предыдущим примерам
Вызов в lua:
habrPlugin.createText("Hello Habr!")
Заключение
Таким образом, ваше приложение на android может использовать все нативные возможности платформы. Единственный недостаток этого решения — вы лишаетесь возможности использовать Corona Simulator, что замедляет разработку (перезапуск симулятора практически мгновенен, в отличие от отладки на эмуляторе или устройстве, который требует build + install)
Полезные ссылки
Полный код доступен на гитхабе
3) Один из репозиториев jnlua. Помог мне разобраться в назначении некоторых функций.