文章

Android Lua 相互调用

前言

本文基于 Lua 5.3.

Lua 是一个轻量级脚本语言,常用于嵌入其他语言作为补充。关于更多Lua本身的问题不在本文讨论范围之内。 在 Android 中嵌入 Lua 优点很多,借助 Lua 脚本语言的优势,可以轻松实现动态逻辑控制,应用可以随时从服务器读取最新 Lua 脚本文件,在不更新应用的情况下修改程序逻辑。 可惜 Lua 官方只提供了 C API ,而 Android 主要使用 JAVA 作为开发语言。我们可以借助 JNI 来间接实现在 Android 中嵌入 Lua 。 <!-- more -->

准备

自己实现 JNI 是一件很费力的事情,还好有人已经造好了轮子叫做 Luajava ,并且又有人基于 Luajava 做了 Android 专用库。网上流传最广的是 Androlua ,不过作者已经多年不维护了,对 Lua 的支持依然停留在5.1并且有一些bug,有人Fork了这个项目,并将其更新至 Lua 5.3 :New Androlua ,不过这个项目也存在一些问题,我修复了一下但是作者并没有处理我的 pull request ,各位可以直接使用我修复优化后的:Android-Lua.

在修复bug的同时,我也添加了一些中文注释,减少第一次接触 Lua C API 朋友们的学习记忆成本。

由于最终需要调用 Lua C API,所以请先配置 NDK 开发环境。在 Android Studio 中打开 SDK Manager,切换到 SDK Tools 标签页,勾选CMakeLLDBNDK下载安装之。

NDK环境

导入工程

仅仅想实现 Android Lua 互相调用,不关心具体过程的,可以直接添加依赖: implementation 'cc.chenhe:android-lua:1.0.2' 然后后边的导入部分可以跳过了。

Clone github 项目到本地并用 Android Studio 打开。大致可以看到下图目录结构。(由于后期更新,结构不一定完全相同)

目录结构

其中 androidlua是库,app是demo工程。库中,lua下是 Lua 解释引擎,luajava是 JNI 的有关代码。*.mk 文件是NDK配置文件,详情请参考Google NDK 文档

你可以将 androidluaModule 导入自己工程作为依赖库使用。

Lua API 知识普及

此时我们已经可以在 Android 与 Lua 直接互相调用了。但是在开始之前,还要学习下 Lua C API 的有关东西,因为这是与 Lua 交互的基础。

Lua 与 C 依靠一个虚拟的栈来完成数据交换。包括变量、函数、参数、返回值等在内的一切数据,都要放入栈中来共享。为了调用方便,这个栈并不是严格遵循栈的规则。从索引来看,栈底索引为1,往上依次递增。而栈顶索引是-1,往下依次递减。因此,正负索引都是合法的,但是0不可以。下面是栈的示意图:

Lua 栈

常用 Lua C API 介绍

Lua 提供了大量的 C API,与其他语言的交互完全依赖这些 API,下面的基础教程中本文会介绍几个基础的,具体可以查看Lua 官方手册

这些函数均由 Lua 提供,在 Luajava 中被封装在 LuaState 类下。

luaL_openlibs

加载 Lua 标准库,一般需要调用一下。

luaL_dostring

执行一段 Lua 脚本。

luaL_dofile

执行给定文件中的 Lua 脚本。

lua_dump

获取当前栈的内容。 java 中对应函数是dumpStack(),返回String,可以直接输出,便于调试。

lua_pushXXX

将各种类型的数据压入栈,以便未来使用。

lua_toXXX

将栈中指定索引处的值以xxx类型取出。

lua_getglobal

获取 Lua 中的全局变量(包括函数),并压入栈顶,以便未来使用。 参数就是要获取的变量的名字。

lua_getfield

获取 Lua 中 某一 table 的元素,并压入栈顶。 第一个参数是 table 在栈中的索引,第二个参数是要获取元素的 key.

lua_pcall

执行 Lua 函数。 第一个参数是此函数的参数个数,第二个是返回值个数,第三个是错误处理函数在栈中的索引。

Enjoy coding

终于要开始调用了,想想还有点小激动呢~

为了方便说明,我们先定义一个脚本文件叫 test.lua,然后将其放在assets目录下,因为这下面的文件不会被编译。 可以使用下面的函数来读取 assets 中文件的内容:

public static String readAssetsTxt( Context context, String fileName ){
	try {
		InputStream	is	= context.getAssets().open( fileName );
		int		size	= is.available();
		/* Read the entire asset into a local byte buffer. */
		byte[] buffer = new byte[size];
		is.read( buffer );
		is.close();
		/* Convert the buffer into a string. */
		String text = new String( buffer, "utf-8" );
		/* Finally stick the string into the text view. */
		return(text);
	} catch ( IOException e ) {
		e.printStackTrace();
	}
	return("err");
}

创建 Lua 栈

之前说了,依靠一个虚拟的栈来完成数据交换。那么首先我们当然要创建这个栈。

LuaState lua = LuaStateFactory.newLuaState(); //创建栈
lua.openLibs(); //加载标准库

lua.close(); //养成良好习惯,在执行完毕后销毁Lua栈。

执行 Lua 脚本

我们可以使用LdoString()来执行一段简单的脚本。

String l = "local a = 1";
lua.LdoString(l);

这样就执行了一个很简单的脚本,他声明了一个全局变量l,值为1.

当然,也可以使用LdoFile()来加载脚本文件,不过这需要你先把脚本复制到 SD 卡才行。因为在 APK 中是没有“路径”可言的。

读取 Lua 变量与 table

首页要说明的是,只有全局变量(非 local)才可以读取。

test.lua:

a = 111;
t = {
    ["name"] = "Chenhe",
    [2] = 2222,
}

android:

lua.getGlobal("a"); //获取变量a并将值压入栈
Log.i("a", lua.toInteger(-1) + ""); //以int类型取出栈顶的值(也就是a)

lua.getGlobal("t"); //获取变量t并压入栈顶,此时table位于栈顶。
lua.getField(-1, "name"); //取出栈顶的table的name元素,压入栈顶。
Log.i("t.name", lua.toString(-1)); //以string类型取出栈顶的值(也就是t.name)

Log.i("dump",lua.dumpStack()); //输出当前栈

运行后可以看到log:

I/a: 111
I/t.name: Chenhe

I/dump: 1: number = 111.0
        2: table
        3: string = 'Chenhe'

我们已经成功读取了 Lua 中的变量。

执行 Lua 函数

调用 Lua 函数的一般流程为:

  1. 获取函数并入栈。
  2. 压入各个参数(如果有)
  3. 调用函数,指明参数个数、返回值个数、错误处理函数。
  4. 获取返回值(如果有)

test.lua:

function test(a, b) 
    return a + b, a - b;
end

android:

lua.getGlobal( "test" ); //获取函数并入栈
lua.pushInteger( 5 ); //压入第一个参数a
lua.pushInteger( 3 ); //压入第二个参数b
lua.pcall( 2, 2, 0 ); //执行函数,有2个参数,2个返回值,不执行错误处理。
Log.i( "r1", lua.toInteger( -2 ) + "" ); //输出第一个返回值
Log.i( "r2", lua.toInteger( -1 ) + "" ); //输出第二个返回值
Log.i( "dump", lua.dumpStack() );

运行后可以看到log:

I/r1: 8
I/r2: 2
I/dump: 1: number = 8.0
        2: number = 2.0

这就成功地执行了 Lua 函数,并取得2个返回值。而之前入栈的函数以及参数,在执行的时候 Lua 已经弹出了,所以最后栈里只剩下2个返回值。

传入 Java 对象

得益于 Luajava 以及 Androlua 的封装,我们可以直接将对象作为参数传入,并在 Lua 中直接执行对象的成员函数。

test.lua:

function setText(tv,s)
    tv:setText("set by Lua."..s);
    tv:setTextSize(50);
end

android:

lua.getGlobal("setText"); //获取函数
lua.pushJavaObject(textView); //把TextView传入
lua.pushString("Demo"); //传入一个字符串
lua.pcall(2,0,0); //执行函数,有2个参数。

执行结果

注入 Lua 变量

有时我们需要在 android 中创建 Lua 变量,这样就可以在 Lua 中直接使用了。注意,这里创建的变量都是全局的(非 local)。

test.lua:

function setText(tv)
    tv:setText("set by Lua."..s); --这里的s变量由java注入
    tv:setTextSize(50);
end

android:

lua.pushString( "from java" ); //压入欲注入变量的值
lua.setGlobal( "s" ); //压入变量名
lua.getGlobal( "setText" ); //获取Lua函数
lua.pushJavaObject( textView ); //压入参数
lua.pcall( 1, 0, 0 ); //执行函数

执行结果

可以看到 Lua 成功调用了 Java 注入的变量s.

Lua 调用 java

Lua 调用 java 函数相对复杂,毕竟 java 不是脚本语言。我们需要将 java 函数包装成一个 JavaFunction 类,实例化之后注册到 lua,这样才可以从 lua 调用。下面看一个例子:

public class MyJavaFunction extends JavaFunction {
    public MyJavaFunction(LuaState luaState) {
        super(luaState);
    }
    @Override
    public int execute() {
        // 获取Lua传入的参数,注意第一个参数固定为上下文环境。
        String str = L.toString(2);

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date date = new Date(System.currentTimeMillis());
        L.pushString(simpleDateFormat.format(date) + str);
        return 1; // 返回值的个数
    }

    public void register() {
        try {
            // 注册为 Lua 全局函数
            register("testJava");
        } catch (LuaException e) {
            e.printStackTrace();
        }
    }
}

我们创建一个类继承 JavaFunction 并实现 execute 方法,它将在被 lua 调用时执行。前面说过,lua 与 c 靠栈来交换数据,故调用函数所传的参数也会入栈。需要注意的是第一个参数恒为 lua 的上下文环境,实际传入的参数是从2开始。在这个简单的例子中,我们取得了 lua 传递的字符串,并将其拼接在由 java 获取的时间字符串后边一起返回给 lua。

与传参类似,返回值也是直接入栈即可。最后我们需要返回返回值的个数,这样 lua 就知道从栈里取出几个元素作为返回值了。这些元素会被自动出栈。这里我们利用 L.pushString() 将拼接后的字符串返回给 lua,并返回 1 表示有1个返回值。

最后,调用 register 方法注册到 lua,传入的字符串参数就是在 lua 中的函数名。

new MyJavaFunction(lua).register();

像这样实例化刚才包装的类就可以成功注册了。现在我们可以在 lua 中调用 testJava(String),它将返回一个字符串,内容是当前的时间加上我们传入的字符串。

相关链接