Lua 與 ObjC 的互動

發表於2016-12-31

1. 寫在前面

很多時候我們都需要藉助一些指令碼語言來為我們實現一些動態的配置,那麼就會涉及到如何讓指令碼語言跟原生語言互動的問題。平時在網上看得比較多的是使用JS(JavaScript)與iOS原生程式碼ObjC互動的文章。因為JS的解析器是iOS內部提供的(可以使用UIWebView或者JavaScriptCore.framework實現),所以使用JS來互動會感覺比較方便。

但是在這裡,我想跟大家分享另外一種指令碼語言的互動方式,就是使用Lua與原生的ObjC語言進行互動。Lua是一種輕量級的指令碼語言,它的指令碼解析器很小,編譯出來只有100多kb,因此,作為一個內嵌的指令碼解析器是首選的;而且Lua除了提供基本的指令碼語言特性和系統功能外(IO讀寫),沒有多餘的功能性框架(JS解析器因為要配合Web的功能實現帶有很多的工具庫),這也是它輕量的表現。同時,提供了豐富的C Api來讓其它的語言對其功能進行擴充套件,能夠真正做到按需定製。

那麼,這裡所說到的C Api就是用於與ObjC互動的重點。因為ObjC本來就是C語言的超集,所以能夠很方便的呼叫這些C Api,下面將一步一步實現互動的過程。

2. 下載和編譯Lua解析器

首先,跳轉到Lua官網的下載頁將原始碼下載下來。然後解壓下載包可以得到如下圖所示的目錄結構:

111804600-1fa7a76531138690
Lua原始碼目錄結構

對應的目錄說明如下表:

名稱 說明
doc Lua相關的文件,包括了編譯文件、介面文件等
Makefile 編譯Lua使用,在這裡我們不使用它來進行編譯
README 關於Lua的說明檔案
src Lua的原始碼檔案

3. 編譯Lua原始碼

在這裡我們只需要src目錄中的原始碼檔案,先開啟src目錄,將Makefile、lua.c、luac.c三個檔案刪除掉,需要說明的是lua.c和luac.c檔案是用於編譯生成lua和luac兩個命令不屬於解析器的功能,如果不刪除可能會導致XCode無法編譯通過。

接下來開啟XCode建立一個新的專案並把src目錄拖入專案中。如下圖所示:

121804600-364f34d7d650fab2
匯入Lua原始碼到專案

然後Command+B進行編譯,提示編譯成功!

4. Lua C Api 與 棧

在開始實現Lua與OC互動之前先來了解兩個非常重要的概念,一個是Lua的C Api,Lua的指令碼解析器是使用C語言來編寫的(基於C語言的原始碼跨平臺特性,使得Lua可以在各種系統下面使用),因此它提供了豐富的C語言定義的介面來訪問和操作Lua中的所有元素,掌握這些C Api可以更加靈活和方便地擴充套件Lua的功能,下面的互動實現正是使用這些C Api進行實現的。

另外一個就是 的概念,在Lua和C進行互動資料的時候會用到了一個棧的結構,棧中的每個元素都能儲存任何型別的Lua值。要獲取Lua中的一個值時,需要呼叫一個C Api函式,Lua就會將特定的值壓入棧中,然後再通過相應的C Api將值取出來,如圖所示:

131804600-3e645c88ed4d3b3f
C/C++獲取Lua值

同樣,要將一個值傳給Lua時,需要先呼叫C Api將這個值壓入棧,然後再呼叫C Api,Lua就會獲取該值並將其從棧中彈出。 如圖所示:

141804600-6b3ae0f1d72ccfb0
C/C++給Lua傳值

這種設計方式主要是為了多種程式語言中統一資料互動中的存取方式,並且方便Lua中的垃圾回收機制的檢測。

有了上述所說的概念,下面正式進入主題。

5. 初始化Lua環境

Lua環境的維護需要一個叫lua_State的結構體來支援,其貫穿了整個執行過程。因此,要使用Lua則需要先初始化一個lua_State結構體。修改ViewController的程式碼如下:

6. 關於棧操作的C Api

上面說到資料“棧”的概念,C Api中提供了很多操作棧的功能介面,通常可以分為四大類:入棧操作、查詢操作、取值操作和其他操作。

6.1 入棧操作

表示要將本地的某個型別的值放到資料棧中,然後提供給Lua層來獲取和操作。該類操作介面有如下定義:

從上面的介面方法定義可以看出來,不同的Lua型別對應著不通的入棧介面,包括了整型(Integer)、布林型別(Boolean)、浮點數(Number)、字串(String)、閉包(Closure)、使用者自定義資料(Userdata)、空型別(Nil)以及執行緒(Thread)。需要注意的是,C Api沒有提供直接入棧Table型別的介面(估計是該資料型別無法與本地結構進行對應),如果需要入棧一個Table型別,可以使用lua_createtable方法來入棧一個Table,呼叫該方法會在棧頂放入一個Table的引用。

可見,假如我們需要在原生程式碼中給Lua的一個全域性變數a賦一個整型值,那麼可以如下面程式碼的做法:

其中的lua_setglobal方法為設定全域性變數的值,該方法會把資料棧頂的元素放入該方法第二個引數所指定的變數名對應的變數中,同時移除棧頂元素。如圖:

151804600-9416fa7957a3e465
lua_setglobal示意圖

6.2 查詢操作

之前說到棧中的每個元素都可以為任意型別,那麼,對於如何判斷元素的型別就可以通過該類方法來實現。該類方法的定義如下:

同樣查詢操作也是提供了不同的方法來檢測不同的型別。其中第二個參數列示要檢測型別的元素處於棧中的哪個位置。

關於棧中位置在lua中有兩種形式表示,第一種是正數表示法,1表示棧底元素(即最先入棧的元素),然後越往上的元素,索引值越大。另外一種是負數表示法,-1表示棧頂元素(即最後入棧的元素),然後越往下的元素,索引值越小。如圖所示:

161804600-9a9409bc7aab5236
棧索引兩種表示方法示意圖

lua_isXXX系列方主要是判斷棧中資料是否能夠被轉換為對應資料型別時使用,如lua_isstring方法則是判斷棧中某個元素是否能夠被轉換為string型別,所以當棧中資料為number型別時,其返回值也為true。

如果要進行非轉換的強型別判斷,可以使用lua_type方法來獲取棧中元素的型別,然後根據型別來獲取值。如判斷棧頂元素的型別:

6.3 取值操作

棧中的所有元素的獲取都是通過該類方法來實現,通常該類方法跟在查詢類方法後,當知道某個資料型別後,則呼叫對應資料型別的取值方法來獲取元素。其方法定義如下:

取值操作的介面也相當簡單,分別傳入lua_State物件和棧索引即可。如果在呼叫時指定的型別跟棧中型別不同也不會有什麼問題,介面會因為型別不正確而返回0或者NULL。

要注意的是該系列介面跟lua_isXXX系列介面一樣,會對原始的型別進行轉換輸出,因此在做一些跟型別相關的操作時,最好時先判斷型別再根據型別呼叫該方法取值,否則會導致一些意想不到的異常,如下面例子:

上面例子中的原意是要輸出下面的內容:

但是實際上卻是這樣:

這是由於使用了lua_tostring把棧中的targetVar的值改變了導致的,所以類似這樣的操作一定要謹慎。

6.4 其他操作

使用上述3部分的操作可以滿足與棧中資料進行互動的大多數情況。如果需要更加靈活地對棧進行操作,例如拷貝棧中某個元素,互動棧中元素位置等等的操作可以使用下面所定義的介面:

其中lua_gettop為獲取棧頂位置,也即是棧中元素的個數,其實這個方法在處理原生方法的傳入引數時很有用,可以確認傳入引數的個數。有時候也可以用它來輸出各個狀態下的棧元素變化,來確認自己在操作棧時是否存在問題。

lua_settop方法用於設定棧頂位置,如果新棧頂高於之前的棧頂則會push一些nil的元素來填充;如果新棧頂低於之前的棧頂則會丟棄新棧頂之上的所有元素。如圖所示:

171804600-72a9c877ff2fda9f
lua_settop示意圖

lua_pushvalue方法表示將棧中某個元素的副本壓入棧頂。之前的棧元素不會發生變動。如圖所示:

181804600-8d07d485cbe559c8
lua_pushvalue示意圖

lua_remove方法用於移除指定索引上的元素,然後再該元素之上的所有元素會下移填補空缺(即元素的索引會發生變更)。如圖所示:

191804600-1f64039b49ea115f
lua_remove示意圖

lua_insert會將指定索引位置之上的所有元素上移來開闢一個新的位置。然後將棧頂元素插入到該位置。如圖所示:

201804600-39f55a54383d68b6
lua_insert示意圖

lua_replace方法會先彈出棧頂元素,然後將該元素覆蓋到指定索引位置上。如圖所示:

211804600-9c25186de8691996
lua_replace示意圖

lua_pop方法會從棧頂彈出指定數量的元素。如圖所示:

221804600-7e99d7279eb9e786
lua_pop示意圖

瞭解了上面的棧操作方法後,下面就是要結合這些方法來實現互動的實際操作。

7. From OC to Lua

7.1 空值傳遞

使用lua_pushnil方法可以將任意一個Lua變數置空。如:

7.2 數值的傳遞

使用lua_pushinteger或者lua_pushnumber方法來將OC中的數值型別傳遞到Lua中指定的某個變數。如:

7.3 布林值的傳遞

使用lua_pushboolean方法來實現,如:

7.4 字串的傳遞

使用lua_pushstring方法可以傳遞字串給Lua,要注意的是該方法接收的是一個c描述的字串(即 char*)。如:

7.5 二進位制陣列的傳遞

二進位制陣列在Lua中其實與字串的儲存方式相同,但是OC中不能直接使用lua_pushstring來進行二進位制陣列的傳遞,可以使用lua_pushlstring方法來傳遞。如:

7.6 方法的傳遞

Lua中只能接受C定義的方法傳入,並且方法的宣告必須符合lua_CFunction函式指標的定義,即:

那麼,傳入方法則需要先定義一個C語言宣告的方法,如:

方法裡面簡單地進行了一下資訊列印,其中方法的返回值是一個整數,表明了該方法需要返回多少個值到Lua中(後續章節會進行返回值的相關演示),現在不需要返回值則為0。然後,再通過lua_pushcfunction方法將方法傳入:

操作完成後,在Lua中就可以直接呼叫了:

如果定義的方法是允許接受引數的,那麼可以從state引數裡面獲取傳入的引數。拿上面的例子,例如方法接收一個名字的字串引數,函式的程式碼則可以修改為:

然後在Lua中則可以這樣呼叫:

如果定義的方法不是直接列印字串,而是組合了字串給Lua返回,那麼定義的方法裡面則需要配合’lua_pushXXXX’系列方法來進行返回值傳遞。需要注意的是:方法中return的數量要與push到棧中的值要一致,否則可能出現異常。那麼,上面定義的函式可以做如下修改:

然後在Lua中則可以這樣呼叫:

7.7 陣列和字典的傳遞

在Lua中,陣列(Array)和字典(Dictionary)都由一個Table型別所表示(在Lua看來陣列其實也屬於一種字典,只是它的key是有序並且為整數)。如:

上面的例子分別用了不帶key的宣告和帶key的宣告兩種方式來建立Table型別。其中不帶key的宣告方式,解析器會預設為其建立一個key,該key是從1開始,由小到大進行分配,其等效於:

當然,兩種方式是可以混合使用,如:

Table屬於比較複雜的資料結構,因此提供操作它的C Api也比較複雜,下面將根據陣列和字典分別講述它們的傳遞方式。

7.7.1 陣列傳遞

首先,需要將一個Table型別入棧,這樣才能對其進行進一步的操作。由於沒有pushtable這樣的方法,但是可以使用lua_newtable來建立一個Table物件,並且該物件會自動放入棧頂位置。如:

然後對要傳遞的陣列進行遍歷,並通過lua_rawseti方法將元素值設定到Table中。如:

通過上面的程式碼就可以把一個陣列傳遞給arrayVal變數。值得注意的是:lua_rawseti方法表示要棧頂的元素設定給指定的Table物件的指定索引。其中的第二個引數是指Table物件在棧中的位置,第三個引數是表示在Table中的索引,一般索引是從1開始算起,因此上面程式碼中的idx需要加1。經過這樣的操作後,棧頂的元素會被移除。如下圖所示:

231804600-ed859d8b0a082a6a
lua_rawseti示意圖

7.7.2 字典傳遞

字典的傳遞同樣需要先入棧一個Table:

然後對要傳遞的字典進行遍歷,並通過lua_setfield方法將元素設定到Table中。如:

lua_setfieldlua_rawseti功能型別,都是把一個元素放入Table中,只是一個用於指定整數索引,一個是指定字串索引。通過上面的方式就可以把字典傳遞給Lua了。

7.8 自定義資料傳遞

Lua中一個比較強大的地方是它可以將任意的型別(包括類物件)進行傳遞。特別是在提供原生處理方法時,需要用到一些特定的資料型別作為引數時,Lua就可以幫我們實現這一塊的傳遞。

要想傳遞自定義的資料則必須要使用Lua提供的Userdata型別。該型別有兩種引用方式,一種是強引用Userdata,由Lua的GC來負責該型別變數的生命週期。另外一種是弱引用Userdata,又稱Light Userdata,該型別不被GC所管理,其生命週期由原生層來決定。下面來看一下兩種方式是如何實現的。

首先我們來定義一個OC的User類:

然後,利用lua_newuserdata方法來建立一個強引用Userdata,並建立一個User物件賦值給新建的Userdata。如:

通過上面的程式碼就可以把User類例項封裝成Userdata再傳遞給Lua。如果你要傳遞的物件並不需要Lua來管理生命週期,那麼就可以建立一個弱引用的Userdata,如:

下面來看一個比較實際的例子,假設有一個提供給Lua呼叫的原生介面printUser,該介面會列印傳入進來的使用者資訊,程式碼如下:

該方法通過lua_topointer方法來獲取了一個Userdata資料型別並轉換為User類例項物件然後列印其名稱。接下來將其匯出給Lua:

然後生成一個User物件,並呼叫該方法傳入該使用者物件。如:

上面的程式碼就是使用C Api來呼叫Lua的方法(下面的章節會詳細講述這塊內容),通過OC程式碼建立了一個User物件並將其作為了引數傳給了Lua的printUser方法。最終的輸出資訊如下:

從OC到Lua的所有型別的轉換和互動基本上都涉及到了,下面章節將會詳細描述從Lua到OC上的一些互動和資料交換。

8. From Lua to OC

8.1 獲取數值

通過lua_tonumber方法可以獲取某個數值變數的值。如:

上述程式碼中的lua_getglobal方法是用於獲取全域性變數的值,呼叫它會把一個值放入棧頂。然後再通過lua_tonumber把棧頂的值讀取出來並列印。需要注意的是通過lua_getglobal獲取得到的值,在棧中是不會自動清除,因此,在用完某個變數時記得把它從棧中清除掉,程式碼中是通過lua_pop把值彈出棧的。

8.2 獲取布林值

與獲取數值相同,通過lua_toboolean方法來獲取某個布林變數的值。如:

8.3 獲取字串

lua_tostring方法來獲取字串變數的值。如:

8.4 獲取二進位制陣列

lua_tolstring方法來獲二進位制陣列變數的值。如:

8.5 方法的獲取和呼叫

一般情況下,要獲取Lua中的某個Function主要是用於對其進行呼叫。假設有一個Lua方法定義如下:

那麼,對應OC中需要下面的程式碼來獲取和呼叫它:

上述程式碼中的lua_pcall方法表示將棧中的元素視作Function來進行呼叫。其中第二個引數為傳入引數的數量,必須與壓棧的引數數量一致;第三個引數為返回值的數量,表示呼叫後其放入棧中的返回值有多少個。第四個引數是用於發生錯誤處理時的程式碼返回。其執行原理如下圖所示:

241804600-c14e15dbb3ec4bec
lua_pcall原理示意圖

對於帶引數和返回值的方法,在獲得方法物件後,需要呼叫lua_pushXXX系列方法來設定傳入引數。可以參考下面例子:

假設有一個加法的Lua方法,其定義如下:

那麼,OC中則可以進行下面操作來呼叫方法並傳遞引數,最終取得返回值然後列印到控制檯:

8.6 Table的獲取和遍歷

Table的獲取跟其他變數一樣,一旦放入棧後可以根據需要通過呼叫lua_getfield方法來指定的key的值。如:

如果Table是宣告時沒有指定key,那麼則需要呼叫lua_rawgeti來獲取Table的值。如:

有時候,Table儲存的資訊會在函式體外被訪問,那麼我們需要對Table進行遍歷然後把它放入一個字典中,然後提供給程式使用。程式碼如下:

上述程式碼利用lua_next方法來遍歷Table的所有元素,該方法從棧頂彈出一個元素作為遍歷Table的起始Key,然後把每個元素的Key和Value放入棧中。為了遍歷所有元素所以起始的Key設定了一個nil值,證明要從Table最開始的Key進行遍歷。如圖:

251804600-4ffcc15ffe670483
lua_next原理示意圖

值得注意的是,在獲取Key值時,最好先判斷Key的型別,然後再根據其對應型別呼叫相應的lua_toXXX方法。否則,因為lua_toXXX系列方法會對元素值進行型別轉換,如整型的Key被lua_tostring轉換為String後再給到lua_next進行遍歷就會報找不到指定Key的錯誤。

8.7 獲取自定義資料

利用lua_topointer方法來獲取自定義資料,如:

9. 結語

Lua雖然小巧,但五臟俱全。最主要的是它提供了豐富又強大的C Api介面允許我們進行高度的定製和擴充套件。可利用這些擴充套件特性開發適合自己的一套指令碼引擎。基於上面所講的,鄙人開發了一個LuaScriptCore的開源橋接框架,簡化Lua與各種平臺的互動,有興趣的同學可以瞭解一下。

最後,希望通過本篇文章能夠讓大家對OC與Lua之間的互動有更近一步的瞭解,創造更多的可能性。

相關文章