Cocos2d-x下Lua調用自定義C++類和函數的最佳實踐

關於cocos2d-x下Lua調用C++的文檔看了不少,但沒有一篇真正把這事給講明白了,我自己也是個初學者,摸索了半天,總結如下:

cocos2d-x下Lua調用C++這事之所以看起來這麼複雜、網上所有的文檔都沒講清楚,是因爲存在5個層面的知識點:

1、在純C環境下,把C函數註冊進Lua環境,理解Lua和C之間可以互相調用的本質
2、在cocos2d-x項目裏,把純C函數註冊進Lua環境,理解cocos2d-x是怎樣創建Lua環境的、以及怎樣得到這個環境並繼續自定義它
3、瞭解爲什麼要使用toLua++來註冊C++類
4、在純C++環境下,使用toLua++來把一個C++類註冊進Lua環境,理解toLua++的用法
5、在cocos2d-x項目裏,使用cocos2d-x註冊自身的方式把自定義的C++類註冊進Lua環境,理解cocos2d-x是怎樣通過bindings-generator腳本來封裝toLua++的用法來節省工作量的

只有理解了前4層,在最後使用bindings-generator腳本的時候心裏纔會清清楚楚。而網上的文檔,要麼是隻解釋了第1層,要麼是隻填鴨式地告訴你第5層怎麼用bindings-generator腳本,不僅中間重要的知識點一概不提,示例代碼往往也寫的不夠簡潔,這讓我這種看見C++就眼暈的人理解起來大爲頭疼(不是我不會C++,而是我非常不接受C++的設計哲學,能避就避)。所以接下來的講解我會對每一層知識點逐一講解,示例代碼也不求完整嚴謹,而是儘量用最簡潔的方式把程序的關鍵點說明白。


第一層:純C環境下,把C函數註冊進Lua環境

直接看代碼比囉哩囉嗦講一大堆概念要清晰明瞭的多。建立一個a.lua和一個a.c文件,內容如下,一看就明白是怎麼回事了:

a.lua

print(foo(99))

a.c

#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>

int foo(lua_State *L)
{
  int n = lua_tonumber(L, 1);

  lua_pushnumber(L, n + 1);

  return 1;
}

int main()
{
  lua_State *L = lua_open();

  luaL_openlibs(L);

  lua_register(L, "foo", foo);

  luaL_dofile(L, "a.lua");

  lua_close(L);

  return 0;
}

怎麼樣,這代碼簡單吧?一看就明白,簡單的不能再簡單了。我特別煩示例代碼裏又是判斷錯誤又是加代碼註釋的,本來看自己不會的代碼就夠吃力的了,還加那麼多花花綠綠的干擾項,純粹增加學習負擔。

在命令行下用gcc來編譯並執行吧:

gcc a.c -llua && ./a.out

注意-llua選項是必要的,因爲要連接lua的庫。

看完上面那段代碼,再解釋起來就容易多了:

1、要想註冊進Lua環境,函數需要定義爲這個樣:int xxx(lua_State *L)
2、使用lua_tonumberlua_tostring等函數,來取得傳入的參數,比如lua_tonumber(L, 1)就是得到傳入的第一個參數,且類型爲數字
3、使用lua_pushnumberlua_pushstring等函數,來將返回值壓入Lua的環境中,因爲Lua支持函數返回多個值,所以可以push多個返回值進Lua環境
4、最終函數返回的數字表示有多少個返回值被壓入了Lua環境
5、使用lua_register宏定義來將這個函數註冊進Lua環境,Lua腳本里就可以用它了,大功告成!就這麼簡單!


第二層:在cocos2d-x環境下,把C函數註冊進Lua環境

也簡單:

1、在frameworks/runtime-src/Classes/目錄下,找到AppDelegate.cpp文件。如果frameworks目錄不存在,則需要參考這篇Blog:用Cocos Code IDE寫Lua,如何與項目中的C++代碼和諧相處

AppDelegate.cpp文件中的關鍵代碼如下:
```c++
auto engine = LuaEngine::getInstance();
ScriptEngineManager::getInstance()->setScriptEngine(engine);

LuaStack* stack = engine->getLuaStack();
stack->setXXTEAKeyAndSign("2dxLua", strlen("2dxLua"), "XXTEA", strlen("XXTEA"));

//register custom function
//LuaStack* stack = engine->getLuaStack();
//register_custom_function(stack->getLuaState());

可以看到cocos2d-x已經爲我們留出了註冊自定義C函數的位置,在註釋代碼後面這麼寫就可以了: ```cpp lua_State *L = stack->getLuaState(); lua_register(L, "test_lua_bind", test_lua_bind);

也可以通過ScriptEngineManager類從頭取得當前的LuaEngine對象,然後再getLuaStack()方法得到封裝的LuaStack對象,再調用getLuaState()得到原始的lua_State結構指針。只要知道了入口位置,其他一切就不成問題了,還是挺簡單的。

感興趣的話可以去看一下ScriptEngineManager類的詳細定義,在frameworks/cocos2d-x/cocos/base/CCScriptSupport.h文件中。

BTW:這裏還有一個小知識點,插入在AppDelegate.cpp中的自定義代碼儘量寫在COCOS2D_DEBUG宏定義的判斷前面,因爲在調試環境下和真機環境下後續執行的代碼是不一樣的:

#if (COCOS2D_DEBUG>0)
    if (startRuntime())
        return true;
#endif

    // 調試環境下代碼就不會走到這裏了
    engine->executeScriptFile(ConfigParser::getInstance()->getEntryFile().c_str());
    return true;

2、接下來,找個地方把test_lua_bind函數定義寫進去就算大功告成了。如果追求文件組織的優雅,按理說應該新建一個.c文件,但這樣的話搞不好會把自己陷入到編譯階段的泥潭裏,所以先不追求優雅,而就在AppDelegate.cpp文件末尾寫上函數的定義就可以了,簡單清楚明瞭:

int test_lua_bind(lua_State *L)
{
    int number = lua_tonumber(L, 1);

    number = number + 1;

    lua_pushnumber(L, number);

    return 1;
}

3、大功告成,現在就可以在main.lua文件裏使用test_lua_bind()函數了:

  local i = test_lua_bind(99)
  print("lua bind: " .. tostring(i))

4、如果是新建一個.c文件呢?把AppDelegate.cpp文件裏test_lua_bind函數定義的代碼刪掉,在頭部#include後面加入:

#include "test_lua_bind.h"

frameworks/runtime-src/Classes目錄下創建test_lua_bind.h文件,內容如下:

extern "C" {
#include "lua.h"
#include "lualib.h"
}

int test_lua_bind(lua_State *L);

再創建test_lua_bind.c文件,內容不變:

#include "test_lua_bind.h"

int test_lua_bind(lua_State *L)
{
    int number = lua_tonumber(L, 1);

    number = number + 1;

    lua_pushnumber(L, number);

    return 1;
}

此時用cocos compile -p mac命令編譯,會發現test_lua_bind.c文件並沒有被編譯。這是當然的,普通的C/C++項目都是用Makefile來指定編譯哪些.c/cpp文件的,當前的cocos2d-x項目雖然沒有Makefile文件,但也是遵循這個原則的,也即肯定是有一個地方來指定所有要編譯的文件的,需要在這個地方把test_lua_bind.c加進去,使得整個項目編譯時把它也作爲項目的一部分。

答案是,cocos2d-x項目沒有使用Makefile,而是非常聰明地使用了與具體環境相關的工程文件來作爲命令行編譯的環境,比如在編譯iOS或Mac時就使用Xcode工程文件,在編譯Android時就使用Android.mk文件。

所以,添加好了test_lua_bind.htest_lua_bind.c文件後,用Xcode打開項目,將這倆文件添加進工程中就行了。

注意,千萬不要勾選“Copy items into destination group's folder(if needed)”,因爲cocos2d-x的Xcode工程目錄組織不是常規的結構,一旦勾選這個,會導致這兩個文件被拷貝至frameworks/runtime-src/proj.ios_mac目錄下,原來frameworks/runtime-src/Classes目錄下的文件就廢掉了,這樣的組織方式會亂,而且會影響Android那邊對這倆文件的引用。

test_lua_bind.htest_lua_bind.cpp這倆文件添加進Xcode工程後,再去命令行執行cocos compile -p mac,編譯就能成功了。

網上有其他文章說還要修改Xcode工程的“User Headers Path”,這個經過試驗是不需要的,哪怕把這倆文件放進新建的文件夾裏也不需要,只要加入了Xcode工程即可,因爲Xcode內部根本就不是按照文件夾的形式來組織文件的,它自己有一套叫做“Group”的東西。搞了好幾年iOS開發,對Xcode的這個特性還是熟悉的。

說到這就不禁要插一句對網上所有cocos2d-x文檔的吐槽了,學習cocos2d-x的人水平實在是良莠不齊,大部分人似乎都是對遊戲熱衷的編程初學者,他們大多底子薄基礎差,甚至一大部分人之前都沒做過移動APP的開發,他們學習cocos2d-x只想知其然而不想知其所以然,給他們講他們也看不明白(因爲編程基礎差),所以網上不少cocos2d-x文章都是隻講123步驟,而不告訴你爲什麼這麼做,包括cocos2d-x官方的大量文檔也是基於這個思路寫的,中文和英文都一樣。我看這些文章就特別痛苦,一邊看一邊心裏就總是在想,“憑什麼要這麼做啊”、“這一步是爲了什麼啊”、“怎麼這麼麻煩啊”、“這個步驟明顯不是最佳實踐啊”、“解決這事爲啥要這麼麻煩”、“有更好的方法嗎”,所以我這種初學者來看cocos2d-x文檔就變成了不是單純的學習,而是學習、質疑、求證、反思、優化的過程,對別人來說cocos2d-x的入門比較容易,到我這裏反倒成了入門比較難、入門之後比較容易了,因爲文檔中的垃圾信息和無效信息實在是太多了,別人可以照單全收、以後懂了之後再慢慢剔除,我是必須從一開始就自己甄別垃圾、只保留最佳實踐,這也是這篇Blog寫的比較長的原因。

扯遠了。反正經過以上步驟,就完成了在cocos2d-x項目中把C函數註冊進Lua環境這件事。至此,算是徹底搞懂了Lua和C函數之間的互相調用關係,也能在cocos2d-x的Lua環境中使用自定義的C函數了。但這還不夠,因爲一個正規的項目是需要狠好的組織結構的,全局C函數滿天飛肯定是不行的,好一點的情況是把所有的C函數都在Lua中組織爲模塊註冊進去,更好一點的情況是把C++類註冊進Lua、並且C++類也是以Lua模塊爲組織方式註冊進Lua環境的。這其實就是cocos2d-x自己把自己註冊進Lua環境的方式。


第三層:瞭解爲什麼要使用toLua++來註冊C++類

因爲Lua的本質是C,不是C++,Lua提供給C用的API也都是基於面向過程的C函數來用的,要把C++類註冊進Lua形成一個一個的table環境是不太容易一下子辦到的事,因爲這需要繞着彎地把C++類變成各種其他類型註冊進Lua,相當於用面向過程的思維來維護一個面向對象的環境。這其中的細節就不去深究了,總之正是因爲如此,所以單純地手寫lua_register()等代碼來註冊C++類是行不通的、代價高昂的,所以需要藉助toLua++這個工具。

這一層的知識點看似簡單,但其實是非常重要的,只有理解了手工用lua_register()去註冊C++類的難度,才能理解使用toLua++這類工具的必要性。只有理解了使用toLua++工具的必要性,纔會潛下心來冷靜地接受toLua++本身的優點和缺點。只有看到了toLua++本身的缺點和使用上的麻煩,纔會真心理解cocos2d-x使用bindings-generator腳本帶來的好處。只有理解了bindings-generator腳本帶來的好處,才能諒解這個腳本本身在使用上的一些不便之處。


第四層:在純C++環境下,使用toLua++來把一個C++類註冊進Lua環境

雖然終極方法是用bindings-generator腳本來註冊C++類進cocos2d-x的Lua環境,但理解toLua++本身的用法還是狠有必要的,只有知道了toLua++原本的用法,才能更好地理解cocos2d-x是怎麼把自己的C++類都註冊進Lua環境的,這不僅能讓編程時的思路更加清晰,也能爲日後在源碼中尋找各種接口文檔的過程中不至於看不懂那一大堆tolua_beginmoduletolua_function是什麼意思。影響程序員學習提高的一大障礙就是忽略那些一知半解的代碼,不去刨根究底地搞明白。

使用toLua++的標準做法是:

1、準備好自己的C++類,該怎麼寫就怎麼寫
2、仿造這個類的.h文件,改一個.pkg文件出來,具體格式要按照toLua++的規定,比如移除所有的private成員等
3、建一個專門用來橋接C++和Lua之間的C++類,使用特殊的函數簽名來寫它的.h文件,.cpp文件不寫,等着toLua++來生成
4、給這個橋接的C++類寫一個.pkg文件,按照toLua++的特殊格式來寫,目的是把真正做事的C++類給定義進去
5、在命令行下用toLua++生成橋接類的.cpp文件
6、程序入口引用這個橋接類,執行生成的橋接函數,Lua環境中就可以使用真正做事的C++類了

toLua++這種自己手寫.pkg文件的方式古老又難受,所以我沒有仔細地去學習,這套流程放在10年前的那個年代是沒有太大問題的,作者怎麼規定就怎麼用好了,但是放在2014年的今天,任何程序的架構設計都講究學習成本低、輕量化、符合以往的習慣,因此toLua++用起來我覺得其實是難受的。

下面我以儘量最少的代碼來走一遍toLua++的流程,注意這是在純C++環境下,跟任何框架都沒關係,也不考慮內存釋放等細節:

MyClass.h

class MyClass {
public:
  MyClass() {};

  int foo(int i);
};

MyClass.cpp

#include "MyClass.h"

int MyClass::foo(int i)
{
  return i + 100;
}

MyClass.pkg

class MyClass
{
  MyClass();
  int foo(int i);
};

MyLuaModule.h

extern "C" {
#include "tolua++.h"
}

#include "MyClass.h"

TOLUA_API int tolua_MyLuaModule_open(lua_State* tolua_S);

MyLuaModule.pkg

$#include "MyLuaModule.h"

$pfile "MyClass.pkg"

main.cpp

extern "C" { 
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
}

#include "MyLuaModule.h"

int main()
{
  lua_State *L = lua_open();

  luaL_openlibs(L);

  tolua_MyLuaModule_open(L);

  luaL_dofile(L, "main.lua");

  lua_close(L);

  return 0;
}

main.lua

local test = MyClass:new()
print(test:foo(99))

先在命令行下執行:

tolua++ -o MyLuaModule.cpp MyLuaModule.pkg

此命令用來生成橋接文件MyLuaModule.cpp。注意命令行中-o參數的順序不能隨意擺放,從這個小事也能看出tolua++的古老和難用

生成好MyLuaModule.cpp文件後,就能看到它裏面的那一大堆橋接代碼了,比如tolua_beginmoduletolua_function等。以後看到這些東西就不陌生了,就明白這些函數只是toLua++用來做橋接的必備代碼了,簡單看一下代碼,就理解toLua++是怎樣把MyClass這個C++類註冊進Lua中的了:

接下來,用g++來編譯:

g++ MyClass.cpp MyLuaModule.cpp main.cpp -llua -ltolua++

默認就生成了a.out文件,執行,就能看到main.lua的執行結果了:

至此,對toLua++的運作原理心裏就透亮了,無非就是:

1、把自己該寫的類寫好
2、寫個.pkg文件,告訴toLua++這個類暴露出哪些接口給Lua環境
3、再寫個橋接的.h和.pkg文件,讓toLua++去生成橋接代碼
4、在程序裏使用這個橋接代碼,類就註冊進Lua環境裏了


第五層:使用cocos2d-x的方式來將C++類註冊進Lua環境

cocos2d-x在2.x版本里就是用toLua++和.pkg文件這麼把自己註冊進Lua環境裏的。不過這種方法明顯笨拙,既要寫真正做事的.pkg文件,也要寫橋接的.pkg文件和.h文件,工作量又大又枯燥。所以從cocos2d-x 3.x開始,用bindings-generator腳本代替了toLua++。

bindings-generator腳本的工作機制是:

1、不用挨個類地寫橋接.pkg和.h文件了,直接定義一個ini文件,告訴腳本哪些類的哪些方法要暴露出來,註冊到Lua環境裏的模塊名是什麼,就行了,等於將原來的每個類乘以3個文件的工作量變成了所有類只需要1個.ini文件
2、摸清了toLua++工具的生成方法,改由Python腳本動態分析C++類,自動生成橋接的.h和.cpp代碼,不調用tolua++命令了
3、雖然不再調用tolua++命令了,但是底層仍然使用toLua++的庫函數,比如tolua_function,bindings-generator腳本生成的代碼就跟使用toLua++工具生成的幾乎一樣

bindings-generator腳本掌握了生成toLua++橋接代碼的主動權,不僅可以省下大量的.pkg和.h文件,而且可以更好地插入自定義代碼,達到cocos2d-x環境下的一些特殊目的,比如內存回收之類的。所以cocos2d-x從3.x開始放棄了toLua++和.pkg而改用了自己寫的bindings-generator腳本是非常值得讚賞的聰明做法。

接下來說怎麼用bindings-generator腳本:

1、寫自己的C++類,按照cocos2d-x的規矩,繼承cocos2d::Ref類,以便使用cocos2d-x的內存回收機制。當然不這麼幹也行,但是不推薦,不然在Lua環境下對象的釋放狠麻煩。
2、編寫一個.ini文件,讓bindings-generator可以根據這個配置文件知道C++類該怎麼暴露出來
3、修改bindings-generator腳本,讓它去讀取這個.ini文件
4、執行bindings-generator腳本,生成橋接C++類方法
5、用Xcode將自定義的C++類和生成的橋接文件加入工程,不然編譯不到
6、修改AppDelegate.cpp,執行橋接方法,自定義的C++類就註冊進Lua環境裏了

看着步驟挺多,其實都狠簡單。下面一步一步來。

首先是自定義的C++類。我習慣將文件保存在frameworks/runtime-src/Classes/目錄下:

frameworks/runtime-src/Classes/MyClass.h

#include "cocos2d.h"

using namespace cocos2d;

class MyClass : public Ref
{
public:
  MyClass()   {};
  ~MyClass()  {};
  bool init() { return true; };
  CREATE_FUNC(MyClass);

  int foo(int i);
};

frameworks/runtime-src/Classes/MyClass.cpp

#include "MyClass.h"

int MyClass::foo(int i)
{
  return i + 100;
}

然後編寫.ini文件。在frameworks/cocos2d-x/tools/tolua/目錄下能看到genbindings.py腳本和一大堆.ini文件,這些就是bindings-generator的實際執行環境了。隨便找一個內容比較少的.ini文件,複製一份,重新命名爲MyClass.ini。大部分內容都可以湊合不需要改,這裏僅列出必須要改的重要部分:

frameworks/cocos2d-x/tools/tolua/MyClass.ini

[MyClass]
prefix           = MyClass
target_namespace = my
headers          = %(cocosdir)s/../runtime-src/Classes/MyClass.h
classes          = MyClass

也即在MyClass.ini中指定MyClass.h文件的位置,指定要暴露出來的類,指定註冊進Lua環境的模塊名。

注意,這個地方我踩了個坑。如果.ini配置文件中存在macro_judgement = ...宏定義,要特別小心,我第一次是從cocos2dx_controller.ini文件複製來的,結果沒注意macro_judgement,導致生成的橋接類文件加入了不該加入的宏,只在iOS和Android平臺上才起作用,對Mac平臺無效,這個要特別注意。

然後修改genbindings.py文件129行附近,將MyClass.ini文件加進去:

frameworks/cocos2d-x/tools/tolua/genbindings.py

cmd_args = {'cocos2dx.ini' : ('cocos2d-x', 'lua_cocos2dx_auto'), \
            'MyClass.ini' : ('MyClass', 'lua_MyClass_auto'), \
            ...

(其實這一步本來是可以省略的,只要讓genbindings.py腳本自動搜尋當前目錄下的所有ini文件就行了,不知道將來cocos2d-x團隊會不會這樣優化)

至此,生成橋接文件的準備工作就做好了,執行genbindings.py腳本:

python genbindings.py

(在Mac系統上可能會遇到缺少yaml、Cheetah包的問題,安裝這些Python包狠簡單,先sudo easy_install pip,把pip裝好,然後用pip各種pip searchsudo pip install就可以了)

成功執行genbindings.py腳本後,會在frameworks/cocos2d-x/cocos/scripting/lua-bindings/auto/目錄下看到新生成的文件:

每次執行genbindings.py腳本時間都挺長的,因爲它要重新處理一遍所有的.ini文件,建議大膽修改腳本文件,靈活處理,讓它每次只處理需要的.ini文件就可以了,比如像這個樣子:

frameworks/cocos2d-x/cocos/scripting/lua-bindings/auto/目錄下觀察一下生成的C++橋接文件lua_MyClass_auto.cpp,裏面的註冊函數名字爲register_all_MyClass(),這就是將MyClass類註冊進Lua環境的關鍵函數:

編輯frameworks/runtime-src/Classes/AppDelegate.cpp文件,首先在文件頭加入對lua_MyClass_auto.hpp文件的引用:

然後在正確的代碼位置加入對register_all_MyClass函數的調用:

最後在執行編譯前,將新加入的這幾個C++文件都加入到Xcode工程中,使得編譯環境知道它們的存在:

這其中還有一個小坑,由於lua_MyClass_auto.cpp文件要引用MyClass.h文件,而這倆文件分屬於不同的子項目,互相不認識頭文件的搜尋路徑,因此需要手工修改一下cocos2d_lua_bindings.xcodeproj子項目的User Header Search Paths配置。特別注意一共有幾個../

最後,就可以用cocos compile -p mac命令重新編譯整個項目了,不出意外的話編譯一定是成功的。

修改main.lua文件中,嘗試調用一下MyClass類:

local test = my.MyClass:create()
print("lua bind: " .. test:foo(99))

然後執行程序(用cocos rum -p mac或在Cocos Code IDE中均可),見證奇蹟的時刻~~~~咦我擦?!程序崩潰!爲毛?

這是我作爲cocos2d-x初學者遇到的最大的坑,坑了我整整一天半,具體的研究細節就不詳細說了,總之罪魁禍首是cocos2d-x框架中的CCLuaEngine.cpp文件的這段代碼:

原因是executeScriptFile函數執行時,對當前Lua環境中的棧進行了清理,當register_all_MyClass函數被調用時,Lua棧是全空的狀態,函數內部執行到tolua_module函數調用時就崩潰了:

解決辦法是修改AppDelegate.cpp爲這個樣子:

文本形式的代碼如下:

AppDelegate.cpp

lua_State *L = stack->getLuaState();
lua_getglobal(L, "_G");
register_all_MyClass(L);
lua_settop(L, 0);

重新編譯並執行,程序就正確執行了:

至此,就徹底搞清楚應該怎樣在cocos2d-x項目裏綁定一個C函數或者C++類到Lua環境中了,感興趣的話可以再進一步深入研究Lua內部metatable的運作原理、類對象的生成與釋放、以及垃圾回收。我自己也是剛接觸cocos2d-x不到一個星期,理解不深,以上難免會有用詞不當或理解錯誤的地方,如有錯誤請多包涵。

後記補充:如果C++類定義了namespace,則需要修改frameworks/cocos2d-x/tools/bindings-generator/targets/lua/conversions.yaml文件,定義namespace與Lua之間的映射關係,否則會報conversion wasn't set錯誤:

--
參考資料:

發佈了33 篇原創文章 · 獲贊 10 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章