android lua sd卡,记Android层执行Lua脚本的一次实践
0. 前言
最近一直在寫Lua腳本,有時候出了問題,不知道是Lua層的問題,還是上游的問題,不知道從何下手。于是我學習了一點C/C++和JNI,把整個解析Lua腳本包、執行Lua腳本的流程全部都讀了一遍。熟悉了一遍之后,就萌生了自己封一個Android跑Lua腳本庫的想法。于是就有這篇博文。C/C++和Kotlin我都不熟,所以這次我主要用這兩種語言來寫(所以會很Java Style)。
1. 環境搭建
首先現在Lua官網下載Lua的源碼,我用的是5.3.5版本的。然后把源碼導入到Project中,寫好CMakeList:
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.4.1)
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_definitions(-Wno-deprecated)
add_library( # Sets the name of the library.
luabridge
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
src/main/jni/lua/lapi.c
src/main/jni/lua/lauxlib.c
src/main/jni/lua/lbaselib.c
src/main/jni/lua/lbitlib.c
src/main/jni/lua/lcode.c
src/main/jni/lua/lcorolib.c
src/main/jni/lua/lctype.c
src/main/jni/lua/ldblib.c
src/main/jni/lua/ldebug.c
src/main/jni/lua/ldo.c
src/main/jni/lua/ldump.c
src/main/jni/lua/lfunc.c
src/main/jni/lua/lgc.c
src/main/jni/lua/linit.c
src/main/jni/lua/liolib.c
src/main/jni/lua/llex.c
src/main/jni/lua/lmathlib.c
src/main/jni/lua/lmem.c
src/main/jni/lua/loadlib.c
src/main/jni/lua/lobject.c
src/main/jni/lua/lopcodes.c
src/main/jni/lua/loslib.c
src/main/jni/lua/lparser.c
src/main/jni/lua/lstate.c
src/main/jni/lua/lstring.c
src/main/jni/lua/lstrlib.c
src/main/jni/lua/ltable.c
src/main/jni/lua/ltablib.c
src/main/jni/lua/ltm.c
src/main/jni/lua/lua.c
#src/main/jni/lua/luac.c
src/main/jni/lua/lundump.c
src/main/jni/lua/lutf8lib.c
src/main/jni/lua/lvm.c
src/main/jni/lua/lzio.c)
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
luabridge
# Links the target library to the log library
# included in the NDK.
${log-lib})
我要跑是 *.lua 類型的腳本,那就留下lua.c并刪掉luac.c,CMakeList里面也要跟著注釋掉。另外,因為我把Lua的源代碼導入進來當做一個庫,所以也不需要main方法了,把lua.c里面的main方法注釋掉。最后Rebuild一下Project就可以了。
2. Android單向調用Lua
先定一個小目標,Android層調用Lua層的函數,Lua層做一個加法后把結果返回給Android層。先寫好Lua腳本:
function test(a, b)
return a + b
end
這個Lua腳本很簡單,把傳過來的a和b相加后返回。現在我們可以開始考慮Native層的實現。在考慮實現之前,需要了解Lua虛擬棧和幾個Lua C API。
2.1. Lua虛擬棧
Lua層和Native層的數據交換是通過Lua虛擬棧來完成的。這個虛擬棧和普通的棧略有不同,它可以通過負值索引來訪問指定元素。如圖:
Lua虛擬棧
和普通的棧一樣,Lua虛擬棧同樣遵循先進后出原則,索引從下往上增加。不同的是Lua虛擬棧支持負值索引,使用負值索引可以自棧頂向下索引。
2.2. Lua C APIs
Lua提供了C APIs,方便Native層和Lua層之間的通訊。下面的Demo會用到這幾個C API。
lua_State *luaL_newstate (void);
新建一個Lua的context。
int luaL_loadbuffer (lua_State *L, const char *buff, size_t sz, const char *name);
編譯一個Lua chunk。如果編譯成功,它會把編譯結果包裝成一個函數,并把這個函數推入到棧中;否則,編譯失敗,它會把錯誤信息推入棧中。
參數
類型
說明
L
lua_State*
Lua的context
buff
const char*
需要加載的Lua腳本buffer
sz
size_t
Lua腳本buffer的長度
name
const char*
這個chunk的名稱,可空
int lua_pcall (lua_State *L, int nargs, int nresults, int errfunc);
以安全模式調用一個函數,即使拋出異常也不會崩潰。當拋出異常時,如果errfunc為0,Lua虛擬機會把錯誤信息推入到Lua虛擬棧中,如果errfunc不為0,則錯誤處理會交由Lua虛擬棧中索引為errfunc的函數處理。執行結束后,Lua虛擬機會把參數以及調用的函數從棧中彈出。
參數
類型
說明
L
lua_State*
Lua的context
nargs
int
需要調用的函數的參數個數
nresults
int
需要調用的函數的返回結果個數
errfunc
int
錯誤處理函數在Lua虛擬棧中的索引,如果為0,錯誤信息會推入到Lua虛擬棧中
void lua_getglobal (lua_State *L, const char *name);
獲取名字為name的全局變量,并推入棧中。
參數
類型
說明
L
lua_State*
Lua的context
name
const char*
變量名稱
void lua_pushinteger (lua_State *L, lua_Integer n);
推入一個lua_Integer類型的數據到棧中
參數
類型
說明
L
lua_State*
Lua的context
n
lua_Integer
需要推入的數字
lua_Integer lua_tointeger (lua_State *L, int index);
將棧中的索引為index的元素轉lua_Integer并返回
參數
類型
說明
L
lua_State*
Lua的context
index
int
指定元素在棧中的索引
除了這些C API,其他的介紹及其用法可以查看官網的說明。
通過理解Lua虛擬棧和了解一些Lua C API,我們就可以實現一個簡單的Native層調用Lua層函數的功能。
jint startScript(JNIEnv* env, jobject obj, jstring jLuaStr, jint a, jint b) {
// 創建一個lua context
lua_State* luaContext = lua_newstate();
// 初始化lua lib
luaL_openlibs(luaContext);
const char* cLuaStr = env->GetStringUTFChars(jLuaStr, NULL);
// 加載buff到內存
int loadStatus = luaL_loadbuffer(luaContext, cLuaStr, strlen(cLuaStr), NULL);
if (LUA_OK != loadStatus) {
const char *szError = luaL_checkstring(luaContext, -1);
Log_e(LOG_TAG, "%s", szError);
return -1;
}
env->ReleaseStringUTFChars(jLuaStr, cLuaStr);
int callStatus = lua_pcall(luaContext, 0, LUA_MULTRET, 0);
if (LUA_OK != callStatus) {
const char *szError = luaL_checkstring(luaContext, -1);
Log_e(LOG_TAG, "%s", szError);
return -1;
}
// 獲取test方法
lua_getglobal(luaContext, "test");
if (LUA_TFUNCTION != lua_type(luaContext, -1)) {
Log_d(LOG_TAG, "can not found func : %s", "test");
return false;
}
// 推入參數
lua_pushinteger(luaContext, a);
lua_pushinteger(luaContext, b);
// 執行test方法
int callTestStatus = lua_pcall(luaContext, 2, 1, 0);
if(LUA_OK == callTestStatus) {
int ret = lua_tointeger(luaContext, 1)
return ret;
} else {
const char* errMsg = lua_tostring(luaContext, 1)
Log_e(LOG_TAG, "%s", errMsg);
return -1;
}
}
流程如注釋。在這一個過程中,Lua虛擬棧的內容變化如圖,從luaL_loadbuffer開始:
Lua虛擬棧內容變化
首先,經過luaL_loadbuffer之后,luaL_loadbuffer會把傳過來的*.lua文件的buffer作為一個Lua Chunk,接著編譯它。編譯完后,把編譯結果包裝成一個function并推入Lua虛擬棧中。經過lua_pcall后,Lua虛擬機會把所執行的function及其參數從Lua虛擬棧中彈出。接著,通過lua_getglobal獲取Lua層的全局變量「test」,lua_getglobal會把這個變量的值推入Lua虛擬棧中。函數已經準備好,再經過lua_pushinteger(a)和lua_pushinteger(b)后,函數和參數都已經順序推入了,調用lua_pcall的先決條件已經滿足。接下來,調用lua_pcall后,Lua虛擬機會根據調用lua_pcall是傳入的nresults,將結果推入Lua虛擬棧中。最后,我們只需要lua_tointeger(index)來獲取執行結果,返回給Android層即可。可以看到,自始至終,Lua虛擬棧充當一個數據交換的橋梁,是一個十分重要的角色。
接下來,只需要在Native層Register一下NativeMethods,并在Android層聲明一下native方法就可以使用了。
class LuaExecutor {
init {
System.loadLibrary("luabridge")
}
external fun startScript(luaString: String): Boolean
}
然而,上面的實現只有啟動腳本的功能。在實際中,我們總不可能啟動腳本之后,就沒有對腳本執行流程有一點控制吧。因此有必要加一個停止腳本的功能。如何停止正在執行的腳本?先來看看Lua提供的C API:
int luaL_error (lua_State *L, const char *fmt, ...);
拋出一個異常,錯誤信息為fmt。
參數
類型
說明
L
lua_State*
Lua的context
fmt
const char*
錯誤信息
int lua_sethook (lua_State *L, lua_Hook f, int mask, int count);
設置一個鉤子函數。
參數
類型
說明
L
lua_State*
Lua的context
f
lua_Hook
鉤子函數,包含需要執行的語句
mask
int
指定被調用的時機,取值為常量LUA_MASKCALL,LUA_MASKRET,LUA_MASKLINE和LUA_MASKCOUNT的按位或。
mask取值
說明
LUA_MASKCALL
代表鉤子函數f會在進入任意函數后執行
LUA_MASKRET
代表鉤子函數在退出任意函數前執行
LUA_MASKLINE
代表鉤子函數f會在執行函數內一行代碼前執行
LUA_MASKCOUNT
代表鉤子函數f會在lua解釋器執行了count條指令后執行
有了這兩個C API,腳本的停止功能就可以實現了:
void stopLuaHooker(lua_State *L, lua_Debug *ar) {
luaL_error(L, "quit Lua");
}
void forceStopLua(lua_State *L) {
int mask = LUA_MASKCOUNT;
lua_sethook(L, &stopLuaHooker, mask, 1);
}
當我們調用forceStopLua時,會為Lua腳本的執行設置一個鉤子函數,這個鉤子函數的執行時機是:lua_sethook執行之后,Lua解釋器執行完一條指令時。也就是說,我們在Lua層代碼執行到任意地方時調用forceStopLua后,Lua解釋器會在執行完一條指令后,接著執行stopLuaHooker,進而執行lua_error,拋出異常,腳本即終止。因此,腳本的啟動和停止的功能已經實現好了,封到一個類里,叫做LuaEngine:
#ifndef ANDROIDLUA_LUAENGINE_H
#define ANDROIDLUA_LUAENGINE_H
#include
#include
#include
#include "lua/lua.hpp"
#include "utils/Log.h"
#include "JniManager.h"
#define LOG_TAG "LuaEngine"
class LuaEngine {
public:
LuaEngine();
virtual ~LuaEngine();
lua_State *getScriptContext() {
return mScriptContext;
}
bool startScript(jstring jBuff, const char *functionName);
bool isScriptRunning() {
return scriptRunning;
}
bool stopScript();
private:
lua_State *mScriptContext;
bool scriptRunning;
bool loadBuff(jstring jBuff);
bool runLuaFunction(const char *functionName);
};
void quitLuaThread(lua_State *L);
void quitLuaThreadHooker(lua_State *L, lua_Debug *ar);
#endif //ANDROIDLUA_LUAENGINE_H
#include "LuaEngine.h"
LuaEngine::LuaEngine() {
mScriptContext = luaL_newstate();
scriptRunning = false;
}
LuaEngine::~LuaEngine() {
if (isScriptRunning()) {
stopScript();
}
mScriptContext = nullptr;
}
bool LuaEngine::startScript(jstring jBuff, const char *functionName) {
scriptRunning = true;
luaL_openlibs(mScriptContext);
if (this->loadBuff(jBuff)) {
Log_d(LOG_TAG, "script start running..");
bool success = this->runLuaFunction(functionName);
scriptRunning = false;
return success;
} else {
scriptRunning = false;
return false;
}
}
bool LuaEngine::stopScript() {
if (scriptRunning) {
quitLuaThread(mScriptContext);
scriptRunning = false;
return true;
} else {
Log_d(LOG_TAG, "script is Not running");
return false;
}
}
bool LuaEngine::loadBuff(jstring jBuff) {
// 讀取buff
JNIEnv *env;
JniManager::getInstance()->getJvm()->GetEnv((void **) &env, JNI_VERSION_1_6);
const char *cBuff = env->GetStringUTFChars(jBuff, nullptr);
if (LUA_OK != luaL_loadbuffer(mScriptContext, cBuff, strlen(cBuff), NULL)) {
const char *szError = luaL_checkstring(mScriptContext, -1);
Log_e(LOG_TAG, "%s", szError);
return false;
}
// 加載buff到內存
if (LUA_OK != lua_pcall(mScriptContext, 0, LUA_MULTRET, 0)) {
const char *szError = luaL_checkstring(mScriptContext, -1);
Log_e(LOG_TAG, "%s", szError);
return false;
}
env->ReleaseStringUTFChars(jBuff, cBuff);
env->DeleteGlobalRef(jBuff);
return true;
}
bool LuaEngine::runLuaFunction(const char *functionName) {
// 獲取errorFunc
// 錯誤由__TRACKBACK__來處理,可以用來打印錯誤信息,
// __TRACKBACK__函數需要自己定義在lua腳本中
lua_getglobal(mScriptContext, "__TRACKBACK__");
if (lua_type(mScriptContext, -1) != LUA_TFUNCTION) {
Log_d(LOG_TAG, "can not found errorFunc : __TRACKBACK__");
return false;
}
int errfunc = lua_gettop(mScriptContext);
// 獲取指定的方法
lua_getglobal(mScriptContext, functionName);
if (lua_type(mScriptContext, -1) != LUA_TFUNCTION) {
Log_d(LOG_TAG, "can not found func : %s", functionName);
return false;
}
// 跑指定的方法
return LUA_OK == lua_pcall(mScriptContext, 0, 0, errfunc);
}
void quitLuaThread(lua_State *L) {
int mask = LUA_MASKCOUNT;
lua_sethook(L, &quitLuaThreadHooker, mask, 1);
}
void quitLuaThreadHooker(lua_State *L, lua_Debug *ar) {
luaL_error(L, "quit Lua");
}
3. Lua單向調用Android
前面的實現,只允許Android層調用Lua的方法,而Lua層并不能調用Android層的方法。可不可以在Lua層調用Android層的方法?答案是可以的。一個思路是,Lua層調用Native層的方法,Native層再通過反射調用Android層的方法。先看看Lua層是怎么調用Native層的方法。Lua提供了一個C API:lua_register,它的原型是:
void lua_register (lua_State *L, const char *name, lua_CFunction f);
注冊一個CFunction。
參數
類型
說明
L
lua_State*
Lua的context
name
const char*
Lua層全局變量的名稱
f
lua_CFunction
C函數。原型是:int functionXXX(lua_State* L);其返回值的意義代表返回結果的個數。
我們可以用這個C API實現Lua層調用Native層的方法:
lua_register(mScriptContext, "getString" , getString);
int getString(lua_State *L) {
const char *cStr = "String From C Layer";
lua_pushstring(L, cStr);
return 1;
}
上面的代碼很簡單,先注冊一個名字為getString的全局變量,指向C函數getString。C函數getString中,先聲明并分配一個字符串cStr,再把這個字符串推入到Lua棧中,并返回結果個數。因此,在Lua層,如果執行getString(),則會得到字符串"String From C Layer",Lua層就可以調用Native層的方法了。
然后看看Native層調用Android層的方法。代碼如下:
int getString(lua_State *L) {
JNIEnv* env;
g_pJvm->GetEnv((void **) &env, JNI_VERSION_1_6);
jclass clazz = env->FindClass("com/zspirytus/androidlua/shell/ShellBridge");
if (!clazz) {
Log_d(LOG_TAG, "class not found!");
return 0;
}
jmethodID methodId = env->GetStaticMethodID(clazz, "getStringFromKotlinLayer", "()Ljava/lang/String;");
if (!methodId) {
Log_d(LOG_TAG, "method %s not found!", "getStringFromStaticJavaMethod");
return 0;
}
jstring jStr = (jstring) env->CallStaticObjectMethod(clazz, methodId);
const char *cStr = env->GetStringUTFChars(jStr, NULL);
lua_pushstring(L, cStr);
env->ReleaseStringUTFChars(jStr, cStr);
env->DeleteLocalRef(jStr);
return 1;
}
解釋一下,首先通過在JNI_OnLoad保存下來的JavaVM指針指針獲得Jni的環境變量,再用Jni的環境變量找到class和method,最后通過env、class和method反射調用Android層的方法獲得返回的jstring,轉成C-style的string后推入lua棧中,釋放資源,并返回結果個數。
在Android層,留下一個方法以供調用:
@Keep
object ShellBridge {
private val TAG = ShellBridge.javaClass.simpleName
@Keep
@JvmStatic
fun getStringFromKotlinLayer(): String {
return "String From Android Layer"
}
}
至此,Android層與Lua層的交互已經實現了。
4. 避免ANR
然而上面的實現可能會導致ANR,原因在于Lua腳本的執行可能是耗時的。如果Lua腳本的執行時間超過5秒,必然ANR。一個解決方法是,把Lua腳本的執行放到子線程當中。這個子線程應當給Native層管理比較好,還是Android層管理比較好?我個人覺得放在Native層比較好,這樣Android層就不需要專為執行Lua腳本而新建和管理線程,代碼就不會太復雜;即使Native層的邏輯比較復雜,編好了so,一般就會當做一個庫來使用,而不會去動它。所以,還是在Native層創建和管理線程。
pthread_create是Unix、Linux等系統創建線程的函數,它的原型是:
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void *), void *restrict arg);
參數
類型
說明
tidp
pthread_t *restrict
線程ID
attr
const pthread_attr_t*restrict
線程屬性,默認為NULL
*(*start_rtn)(void *)
void
運行在新線程的函數
*restrict arg
void
start_rtn的所需參數
因此,我們可以把執行Lua腳本的邏輯移到新線程中:
void startWork() {
pthread_create(&mThreadId, NULL, &startWorkInner, (void*)this);
}
void stopWork() {
stopScript();
mThreadId = 0;
}
void* startWorkInner(void *args) {
startScript();
return nullptr;
}
這樣,startScript()就運行在新線程中,就不會有ANR的風險。我們把它封到一個類中,叫LuaTask,一次Lua腳本的開始與結束,都由這個類來管理。
#ifndef ANDROIDLUA_LUATASK_H
#define ANDROIDLUA_LUATASK_H
#include
#include
#include
#include "LuaEngine.h"
class LuaTask {
public:
LuaTask(jstring jBuff);
virtual ~LuaTask();
void startWork();
void stopWork();
bool isRunning();
private:
static void *startWorkInner(void *args);
private:
jstring mLuaBuff;
pthread_t mThreadId;
LuaEngine *mLuaEngine;
};
#endif //ANDROIDLUA_LUATASK_H
#include "LuaTask.h"
LuaTask::LuaTask(jstring jBuff) {
mLuaBuff = jBuff;
mLuaEngine = new LuaEngine();
mThreadId = 0;
}
LuaTask::~LuaTask() {
delete mLuaEngine;
}
void LuaTask::startWork() {
pthread_create(&mThreadId, NULL, &LuaTask::startWorkInner, (void*)this);
}
void LuaTask::stopWork() {
mLuaEngine->stopScript();
mThreadId = 0;
}
void* LuaTask::startWorkInner(void *args) {
LuaTask* task = (LuaTask*) args;
task->mLuaEngine->startScript(task->mLuaBuff, "main");
return nullptr;
}
bool LuaTask::isRunning() {
return mThreadId != 0;
}
但是,這是我們新創建的線程,還沒有attach到JavaVM。如果沒有attach到JavaVM,就會找不到JNIEnv,所以必須要attach到JavaVM,這樣才能拿到JavaVM的JNI環境變量,從而可以調用到Android層的方法。因此startWorkInner要改進一下:
void* startWorkInner(void *args) {
JNIEnv* env = nullptr;
JavaVMAttachArgs args{JNI_VERSION_1_6, nullptr, nullptr};
g_pJvm->AttachCurrentThread(&env, &args);
startScript()
g_pJvm->DetachCurrentThread();
return nullptr;
}
線程退出之前,記得要和JavaVM detach一下,這樣線程才能正常退出。
5. 運行腳本包
至此,我們完成了能夠隨時開始、停止,出錯能打印堆棧信息的執行Lua腳本功能。但實際上,我們不可能只跑單個腳本,并且腳本可能需要一些資源文件。因此我們一般會把腳本和資源文件打包成一個腳本包。在運行腳本之前,先解包,把腳本解析出來后再運行。
所以這個解析腳本的邏輯放在Native層還是Android層?我個人覺得放在Android層比較好。有兩點原因:
腳本包格式不確定,Native層不可能為每種情況進行適配,既然如此那就交由使用者來解析。
單一職責的原則,Native層負責還是只負責一種功能比較好。而且為解析腳本包而重新編譯一個so文件又太小題大做,所以解析的任務就交給使用者吧。
既然提到腳本包,我就簡單談談我的實現。我的實現是把lua腳本和資源文件一起壓縮成一個zip文件,在zip文件中有一個config文件,里面寫好了所有lua腳本的相對路徑。在解析的時候,先在內存中把config解壓出來,讀出所有lua腳本的相對路徑,然后在內存中把所有lua腳本文件都解壓出來后,拼接起來,在交給Native層運行。至于資源文件,根據腳本的運行情況進行動態解壓。我簡單的封裝了一下:
private external fun startScript(luaString: String): Boolean
external fun stopScript(): Boolean
external fun isScriptRunning(): Boolean
fun runScriptPkg(scriptPkg: File, configFile: String) {
mThreadPool?.execute {
val start = System.currentTimeMillis()
initScriptPkg(scriptPkg)
val zipFile = ZipFile(scriptPkg)
val config = ZipFileUtils.getFileContentFromZipFile(zipFile, configFile)
val luaScriptPaths = config.split("\r\n")
val luaScript = ZipFileUtils.getFilesContentFromZipFile(zipFile, luaScriptPaths)
Log.d("USE_TIME", "${System.currentTimeMillis() - start} ms")
mHandler?.post {
startScript(luaScript)
}
}
}
object ZipFileUtils {
fun getFileContentFromZipFile(zipFile: ZipFile, targetFile: String): String {
var ins: InputStream? = null
try {
val ze = zipFile.getEntry(targetFile)
return if (ze != null) {
ins = zipFile.getInputStream(ze)
FileUtils.readInputStream(ins)
} else {
""
}
} finally {
ins?.close()
}
}
fun getFilesContentFromZipFile(zipFile: ZipFile, targetFiles: List): String {
val stringBuilder = StringBuilder()
targetFiles.filter { it.isNotEmpty() and it.isNotBlank() }.forEach {
val content = getFileContentFromZipFile(zipFile, it)
stringBuilder.append(content).append('\n')
}
return stringBuilder.toString()
}
}
object FileUtils {
fun readInputStream(ins: InputStream): String {
return ins.bufferedReader().use(BufferedReader::readText)
}
}
至此,我們在原有功能的基礎上,增加了跑腳本包的功能。完整的代碼可以看倉庫。
6. 總結
Android層調用Lua層方法
Lua層調用Android層方法
7. 感想
Android跑Lua腳本這個過程其實是很簡單的,不是主要難點。這次主要卡住的地方是在JNI部分,因為我發現我所了解的C語言語法太古老了,跟不上現在的C語言。雖然我的C語言的代碼量也不多,加上我對JNI的一些編程規范不太了解,所以一路磕磕絆絆,但是總算是寫出來了。Kotlin和C/C++還是要多熟悉熟悉,多練練。
總結
以上是生活随笔為你收集整理的android lua sd卡,记Android层执行Lua脚本的一次实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 鸿蒙股票深度分析,本月华为鸿蒙概念股市回
- 下一篇: 华为鸿蒙备胎转正,华为鸿蒙是备胎系统,但