传递对象_看懂Xlua实现原理——从宏观到微观(1)传递c#对象到Lua
CSDN
我們要解決什么問題?
為了使基于unity開發的應用在移動平臺能夠熱更新,我們嵌入了Lua虛擬機,將需要熱更新的邏輯用lua實現。c#通過P/Invoke和lua交互(lua由ANSI C實現)。在這個過程中,由于數據的交換需要使用lua提供的虛擬棧,不夠簡單高效,為了解決這個問題,我們引入了*lua框架(xlua、slua、ulua)來達到類似RPC式的函數調用、類原生對象式的對象訪問以及高效的對象傳遞。
業務中,有以下幾種場景:
1. c#對Lua方法的調用
2. Lua對c#方法的調用
3. Lua持有一個c#對象
4. c#持有一個Lua對象
通過對場景的歸納,我們發現,最終其實是兩個需求:
1. 傳遞一個C#對象給Lua
2. 傳遞一個lua對象給c#
這里我們把函數調用歸納為“傳遞”函數對象,因為只要我們能夠把函數“傳遞”過去,就能完成對函數的調用。傳遞是雙向的(pull/push),但同時我們又可以把get一個對象理解為對方push一個返回值給我們。
c#對象傳遞到lua
首先我們要知道的是,lua本身提供了C_API,讓我們push一個值到lua虛擬棧上。lua可以通過訪問lua虛擬棧,來訪問這個對象。
lua_pushnil、lua_pushnumber、lua_pushinteger、lua_pushstring、lua_pushcclosure、lua_pushboolean、lua_pushlightuserdata、lua_pushthread等等。Lua虛擬棧是lua和其他語言交換數據的中介。
xlua對以上接口進行了封裝,并同樣提供了一系列的push方法,讓我們可以把一個c#對象push到lua的虛擬棧上。
可以把xlua的push API歸為兩類:一類是針對某種特定類型的push,暫且叫做LowLevelAPI;還有一類是基于LowLevelAPI封裝的更上層的HighLevelAPI。
門面模式使用HighLevelAPI時你只要簡單的傳入你想push的對象,HighLevelAPI會幫你找到最適合的LowLevelAPI調用,因為就算同一種類型的push方法,也可能有用戶自定義的優化版本。而對于LowLevelAPI最終是需要調用xlua.dll中提供的C API來協調完成最終的工作。
#LowLevelAPI#
//using RealStatePtr = System.IntPtr; //using LuaCSFunction = XLua.LuaDLL.lua_CSFunction; //typedef int (*lua_CFunction) (lua_State *L);//ObjectTranslator.cs void pushPrimitive(RealStatePtr L, object o) public void Push(RealStatePtr L, object o) public void PushObject(RealStatePtr L, object o, int type_id) public void Push(RealStatePtr L, LuaCSFunction o) internal void PushFixCSFunction(RealStatePtr L, LuaCSFunction func) public void Push(RealStatePtr L, LuaBase o) public void PushDecimal(RealStatePtr L, decimal val)傳遞基元類型
void pushPrimitive(RealStatePtr L, object o) 基元類型為 Boolean、Byte、SByte、Int16、UInt16、Int32、UInt32、Int64、UInt64、UIntPtr、Char、Double、Single和IntPtr (對應的void*)。對于C#中的基元類型,大部分可以直接對應的lua中的類型,并使用對應的luaAPI進行push:
//push一個int LUA_API void xlua_pushinteger (lua_State *L, int n) //push一個double #define LUA_NUMBER double typedef LUA_NUMBER lua_Number; LUA_API void lua_pushnumber (lua_State *L, lua_Number n) //push一個IntPtr LUA_API void lua_pushlightuserdata (lua_State *L, void *p)而有些需要在lua中定義對應的類型,比如對于long,xlua中定義了一個Integer64與之對應,以及相應的操作接口:
//i64lib.c //在lua中表示c#中的long typedef struct {int fake_id;int8_t type;union {int64_t i64;uint64_t u64;} data; } Integer64;注意pushPrimitive會產生裝箱拆箱的GC,所以不推薦使用。事實上xlua也針對基元類型做了優化,真實環境中不會用到這個方法。傳遞 object
public void Push(RealStatePtr L, object o) public void PushObject(RealStatePtr L, object o, int type_id)索引
不管object是什么類型,最終的push都是使用:
//xlua.c /* key:傳遞的對象 meta_ref:對象所屬類型的元表的索引 need_cache:此對象是否需要在lua緩存 cache_ref:緩存表的偽索引 */ LUA_API void xlua_pushcsobj(lua_State *L, int key, int meta_ref, int need_cache, int cache_ref) {int* pointer = (int*)lua_newuserdata(L, sizeof(int));*pointer = key;if (need_cache) cacheud(L, key, cache_ref);//R.cache_ref[Key] = pointerlua_rawgeti(L, LUA_REGISTRYINDEX, meta_ref);lua_setmetatable(L, -2);//setmetatable(Key,R[meta_ref]) }為什么我們傳給lua的對象是一個int類型(這里的key)?其實我們這里的key是我們要傳遞的c#對象的一個索引,我們可以通過這個索引找到這個c#對象。
當傳遞一個c#對象的時候,我們創建一個userdate,并把這個索引值賦給這個userdata。然后,lua在全局注冊表中,有一張專門的表用來存放c#各種類型所對應的元表,而**meta_ref**就是當前這個對象所對應類型的元表的索引id,我們通過他找到對應的元表,就可以通過setmetatable來綁定操作這個對象的方法。最終lua就可以愉快的使用這個對象。
每種類型所對應的元表,是我們在push一種類型的對象之前,提前注冊進來的,后面詳述。但是對于引用類型的對象,其生命周期是有可能超出當前的調用棧的(比如lua用一個變量引用了這個對象) 。這時,我們就不僅要能夠通過這個key找到c#原始對象,還要通過這個key能夠找到對應的lua代理對象。因此,對于引用類型,我們在lua中同樣也要建立一套索引機制,這就是need_cache和cache_ref的作用:
static void cacheud(lua_State *L, int key, int cache_ref) {lua_rawgeti(L, LUA_REGISTRYINDEX, cache_ref);lua_pushvalue(L, -2);lua_rawseti(L, -2, key);lua_pop(L, 1); }緩存
再回過頭來看看c#中的索引和緩存機制:
在調用xlua_pushcsobj之前,所有object都會被放入一個對象的緩存池中ObjectTranslator.objects。而我們得到的key就是這個對象在緩存池中的下標。
//以下是經過刪減的偽代碼,只保留我們現在需要關注的流程 public void Push(RealStatePtr L, object o) {if (o == null){LuaAPI.lua_pushnil(L);return;}int index = -1;Type type = o.GetType();bool is_enum = type.IsEnum;bool is_valuetype = type.IsValueType;bool needcache = !is_valuetype || is_enum;//如果是引用類型(或者是enum),可能已經緩存在lua,所以先看看是不是在lua緩存中if (needcache && (is_enum ? enumMap.TryGetValue(o, out index) : reverseMap.TryGetValue(o, out index))){//如果是已經push到lua的對象,從lua的c#對象緩存中獲取這個對象if (LuaAPI.xlua_tryget_cachedud(L, index, cacheRef) == 1){//==1表示獲取lua緩存成功(并且已經在棧頂,所以我們直接退出)return;}}bool is_first;//getTypeId這個函數的設計有點丑。職責有點多還和外部調用者耦合。吐槽下。(后面詳述)int type_id = getTypeId(L, type, out is_first);//對于要push到lua的c#對象,進行緩存,并獲得索引keyindex = addObject(o, is_valuetype, is_enum);//xlua_pushcsobj(lua_State *L, int key, int meta_ref, int need_cache, int cache_ref)LuaAPI.xlua_pushcsobj(L, index, type_id, needcache, cacheRef); }int addObject(object obj, bool is_valuetype, bool is_enum) {//objects是所有push進lua的對象的緩存池int index = objects.Add(obj);//對于引用(和enum)類型,我們可以反查到id(方便我們快速判斷,這個對象是不是已經push到lua)if (is_enum){enumMap[obj] = index;}else if (!is_valuetype){reverseMap[obj] = index;}return index; }gc
對于引用類型,它的生命周期管理會略微復雜。mono和lua虛擬機有各自的gc系統,并且相互無法感知。當lua和c#同時引用一個對象時,我們需要能夠保證對象生命周期的正確,不能一邊還在引用,另一邊卻把它釋放掉了。
這個過程是由lua的gc驅動的。我們把對象push到lua時,會緩存在c#的對象池中,所以是不會被mono的gc所釋放掉,這樣就保證了lua能夠安全的持有c#對象。同時我們也會把這個對象的代理緩存到lua中,而lua中對象的緩存表是一個弱表,也就是說,當沒有其他的lua引用這個對象時,lua的gc會把這個對象從lua的緩存中回收,而對象被gc回收的過程會觸發這個對象的的__gc元方法。
而這個__gc元方法就會通知到c#這端,來告訴我們lua不再使用這個對象,我們可以把它從對象緩存池中移除。當沒有其他c#對其的引用時,mono的gc就會正常的回收這個對象。
//__gc元方法: public static int LuaGC(RealStatePtr L) {try{int udata = LuaAPI.xlua_tocsobj_safe(L, 1);if (udata != -1){ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);if ( translator != null ){translator.collectObject(udata);}}return 0;}catch (Exception e){return LuaAPI.luaL_error(L, "c# exception in LuaGC:" + e);} }//從緩存池中刪除 internal void collectObject(int obj_index_to_collect) {object o;if (objects.TryGetValue(obj_index_to_collect, out o)){objects.Remove(obj_index_to_collect);if (o != null){int obj_index;//lua gc是先把weak table移除后再調用__gc,這期間同一個對象可能再次push到lua,關聯到新的indexbool is_enum = o.GetType().IsEnum();if ((is_enum ? enumMap.TryGetValue(o, out obj_index) : reverseMap.TryGetValue(o, out obj_index))&& obj_index == obj_index_to_collect){if (is_enum){enumMap.Remove(o);}else{reverseMap.Remove(o);}}}} }元表
對于業務來說,我們只是單純的把對象的索引傳遞過去,是遠遠不夠的,我們還需要提供直接使用和操作對象的方法。前面我們提到,在我們把一個對象push到lua之前,我們會把對象類型所對應的元表提前注冊到lua之中。這樣在我們真正push一個對象時,就會用這個元表來設置操作這個對象的方法。
首先第一個問題就是,如何表示c#對象的類型?回過頭來看看我們的Push函數,其中最重要的就是getTypeId:
首先會嘗試從c#的類型緩存typeIdMap中檢查是否已經注冊過這種類型,如果沒有的話,我們就需要為其生成一個type_id。
再從lua的類型緩存中用類型名來檢索是否已經注冊過這種類型,如果沒有的話,意味著我們還沒有為這種類型在lua中注冊一個元表,繼而通過TryDelayWrapLoader來生成這個類型的元表。
// public void Push(RealStatePtr L, object o) {//...Type type = o.GetType();bool is_first;int type_id = getTypeId(L, type, out is_first);//... }//這里再次吐槽getTypeId函數的設計和實現,為了保持清楚,我只保留能大體說明邏輯的的代碼 internal int getTypeId(RealStatePtr L, Type type, out bool is_first, LOGLEVEL log_level = LOGLEVEL.WARN) {//嘗試獲取c#中檢索if (typeIdMap.TryGetValue(type, out type_id)){return;}//嘗試從lua中檢索LuaAPI.luaL_getmetatable(L,type.FullName);if (LuaAPI.lua_isnil(L, -1)) {LuaAPI.lua_pop(L, 1);//獲取類型的元表if (TryDelayWrapLoader(L, type)){LuaAPI.luaL_getmetatable(L, type.FullName);}else{throw new Exception("Fatal: can not load metatable of type:" + type);}}//生成新的type_idtype_id = LuaAPI.luaL_ref(L, LuaIndexes.LUA_REGISTRYINDEX);//注冊到luaLuaAPI.lua_pushnumber(L, type_id);LuaAPI.xlua_rawseti(L, -2, 1);LuaAPI.lua_pop(L, 1);if (type.IsValueType()){typeMap.Add(type_id, type);}typeIdMap.Add(type, type_id); } 再次吐槽,表面上getTypeId只是獲取一個類型的type_id,但其實上,注冊(甚至生成)類型元表和元方法也是在這里完成的!!可能是為了解決循環依賴的問題而破壞了代碼的結構?這其中最重要的就是元表的生成:
用過xlua的應該都知道,xlua是可以通過配置的方式,在編譯期幫我們生成優化的元表元方法的 (無gc)。這屬于用戶自定義的針對某種類型的元表,是高度優化的,所以也是優先級最高的。因此這里首先嘗試從delayWrap中查找有沒有用戶事先注冊的自定義的類型元表生成器 (大多數情況下就是通過xlua的Gen工具生成的)。
這里獲取到的loader并不是元表,而是元表的構造器。雖然我們提前定義了很多元表構造器,但只有在這個類型第一次用到的時候,才會去構造元表。也就是說,這個過程是惰性的,這也是為什么函數名里有一個Delay的原因吧。如果沒有用戶提前注冊的自定義元表生成器。接下來是一個很抖機靈的方式,居然內嵌了一個代碼生成器,幫助用戶在運行時動態生成針對類型優化的元表生成器。
聽上去有點繞,簡單來說:元表的構建是由構建函數來完成的,而構建函數是由生成函數生成的;元表的構建是在運行時,而構建函數的生成可以是編譯期也可以是運行時。當然,這個在ios下是無法使用。
最后,如果沒有內嵌構建函數生成器。我們嗨可以使用最萬能的反射方式,為任意的類型構建元表。當然,一般來說,這種方式構建出來的元表也是性能最差的。
//這個函數也可能被lua調用,所以再加一層類型緩存,防止同一類型被多次調用。 Dictionary<Type, bool> loaded_types = new Dictionary<Type, bool>(); //構造類型元表 public bool TryDelayWrapLoader(RealStatePtr L, Type type) {if (loaded_types.ContainsKey(type)) return true;loaded_types.Add(type, true);LuaAPI.luaL_newmetatable(L, type.FullName); //先建一個metatable,因為加載過程可能會需要用到LuaAPI.lua_pop(L, 1);Action<RealStatePtr> loader;//這個loader就是wrap中的_Regster方法,用來生成這種類型的元表int top = LuaAPI.lua_gettop(L);//首先檢索是否有用戶預定義的元表生成器if (delayWrap.TryGetValue(type, out loader)){delayWrap.Remove(type);//構造元表loader(L);}else{ #if !GEN_CODE_MINIMIZE && !ENABLE_IL2CPP && (UNITY_EDITOR || XLUA_GENERAL) && !FORCE_REFLECTION && !NET_STANDARD_2_0 //如果內嵌了代碼生成器,則動態生成這個類型的Warp,并使用動態生成的warp來生成元表if (!DelegateBridge.Gen_Flag && !type.IsEnum() && !typeof(Delegate).IsAssignableFrom(type) && Utils.IsPublic(type)){Type wrap = ce.EmitTypeWrap(type);MethodInfo method = wrap.GetMethod("__Register", BindingFlags.Static | BindingFlags.Public);method.Invoke(null, new object[] { L });}else{Utils.ReflectionWrap(L, type, privateAccessibleFlags.Contains(type));} #else //否則的話使用反射Utils.ReflectionWrap(L, type, privateAccessibleFlags.Contains(type)); #endif}if (top != LuaAPI.lua_gettop(L)){throw new Exception("top change, before:" + top + ", after:" + LuaAPI.lua_gettop(L));}foreach (var nested_type in type.GetNestedTypes(BindingFlags.Public)){if (nested_type.IsGenericTypeDefinition()){continue;}GetTypeId(L, nested_type);}return true; } 代碼生成器、反射的生成方式,隨后詳解。傳遞c#函數
這里主要是指LuaCSFunction,也就是可以被lua直接調用的c#函數。
public delegate int lua_CSFunction(IntPtr L); 普通的c#函數也可以傳遞,屬于前面的基元類型,只是簡單的傳遞一個IntPtr指針,雖然不能直接被lua調用,但是可以被lua傳遞(函數式編程,比如作為回調和返回值)。xlua通過lua_pushstdcallcfunction來push一個LuaCSFunction,其調用的時xlua.dll提供的xlua_push_csharp_function。
//LUADLL.cs public static void lua_pushstdcallcfunction(IntPtr L, lua_CSFunction function, int n = 0)//[-0, +1, m] {IntPtr fn = Marshal.GetFunctionPointerForDelegate(function);xlua_push_csharp_function(L, fn, n); }當我們push一個LuaCSFunction函數到lua中后,這個函數和棧上的參數會被當作另一個包裝函數csharp_function_wrap的upvalue,生成一個閉包,最終把這個閉包push到lua虛擬棧上。這樣的話,我們在調用這個函數時就可以做一些額外的事情,比如錯誤檢測、鉤子函數的回調。
AOP面向切面編程 //xlua.c//push一個LuaCSFunctionLUA_API void xlua_push_csharp_function(lua_State* L, lua_CFunction fn, int n) { lua_pushcfunction(L, fn);if (n > 0) {lua_insert(L, -1 - n);}lua_pushboolean(L, 0);if (n > 0) {lua_insert(L, -1 - n);}//把原函數、參數作為包裝函數的upvaluelua_pushcclosure(L, csharp_function_wrap, 2 + (n > 0 ? n : 0)); }//包裝函數 static int csharp_function_wrap(lua_State *L) {lua_CFunction fn = (lua_CFunction)lua_tocfunction(L, lua_upvalueindex(1));//真正調用的地方int ret = fn(L); //錯誤檢測if (lua_toboolean(L, lua_upvalueindex(2))){lua_pushboolean(L, 0);lua_replace(L, lua_upvalueindex(2));return lua_error(L);}//鉤子函數if (lua_gethook(L)) {call_ret_hook(L);}return ret; }最終提供給用戶的是這兩個接口:
internal void PushFixCSFunction(RealStatePtr L, LuaCSFunction func) public void Push(RealStatePtr L, LuaCSFunction o)這兩個函數都做了一件事情,就是在LuaCSFunction函數push到lua之前,用另一個LuaCSFunction來包裝了一層,用來做異常捕獲。
和gc一樣,mono和lua有自己的異常不同的是,包裝函數中索引原函數的方式不同:
PushFixCSFunction()使用FixCSFunction()來包裝原函數。為了能夠調回到原函數,用一個List<LuaCSFunction> fix_cs_functions來建立了下標到原函數的映射,最終push到lua的只是這個下標。調用lua_pushstdcallcfunction()時,這個下標作為upvalue一起傳遞。FixCSFunction()被調到時,通過upvalue取到下標,進而取到原函數,最終完成調用。
//PushFixCSFunction使用的的包裝函數 static int FixCSFunction(RealStatePtr L) {try{ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);int idx = LuaAPI.xlua_tointeger(L, LuaAPI.xlua_upvalueindex(1));LuaCSFunction func = (LuaCSFunction)translator.GetFixCSFunction(idx);return func(L);}catch (Exception e){return LuaAPI.luaL_error(L, "c# exception in FixCSFunction:" + e);} }Push()使用StaticCSFunction()來包裝原函數。原函數通過之前push一個objec的t函數Push(RealStatePtr L, object o)被push到lua(因此其實push的也是一個objkect的索引),同樣也是作為StaticCSFunction()的upvalue。包裝函數被調到時,通過upvalue取到索引,再通過FastGetCSObj()(下一篇介紹)取到原函數,最終完成調用。
//Push(RealStatePtr L, LuaCSFunction o)使用的包裝函數 static int StaticCSFunction(RealStatePtr L) {try{ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);//獲取被包裝的func,是在這個包裝方法入棧之前被壓入的LuaCSFunction func = (LuaCSFunction)translator.FastGetCSObj(L, LuaAPI.xlua_upvalueindex(1));return func(L);}catch (Exception e){return LuaAPI.luaL_error(L, "c# exception in StaticCSFunction:" + e);} }兩種索引方式的不同,使用在了不同的場景。
PushFixCSFunction()大量被用在我們靜態生成的元表構造器中,做為默認需要支持的類型的元表,注冊進lua,并永久存在。而Push()被大量使用在反射生成的元表之中,在使用完之后,可能就會被釋放。
最后還有一個小細節,Push()中對IsStaticPInvokeCSFunction的函數沒有加包裝,因為這種類型的函數是我們靜態生成的,在生成時,我們已經加入了異常捕獲的代碼,不需要再被捕獲了。
可以看到,一個函數在被調用之前,進行了多次的包裝,每次包裝都附帶了一些額外的功能,但又對原函數沒有侵入。(函數式編程,面向切片編程)其他push
//push一個lua在c#中的代理對象 public void Push(RealStatePtr L, LuaBase o)LuaBase是c#對lua中特有的類型的封裝。比如說LuaTable對應table、LuaFunction對應luafunction(此處不是luacfunction)。C#可以通過對應的類型去創建、操作一個lua原生對象。
所以,LuaBase只是一個lua對象在c#中的代理,我們push一個LuaBase其實是找到真正的lua對象,并push。
//重載push一個decimal,避免gc void PushDecimal(RealStatePtr L, decimal val)#HighLevelAPI#
對于HighLevelAPI,里面不包含具體的push實現,而是通過獲取對象的類型,來選擇性的調用類型所對應的具體push函數。
可以看作類似是編譯器的函數重載功能public void PushAny(RealStatePtr L, object o) public void PushByType<T>(RealStatePtr L, T v)顧名思義,PushAny()可以用來push所有的類型,可以被用在我們提前沒法知道對象類型的地方。最典型的例子就是在反射生成元表時,我們動態的獲取對象,通過PushAny()把類型未知的對象push到lua。
實現也是簡單明了:
public void PushAny(RealStatePtr L, object o){if (o == null){LuaAPI.lua_pushnil(L);return;}Type type = o.GetType();if (type.IsPrimitive()){pushPrimitive(L, o);}else if (o is string){LuaAPI.lua_pushstring(L, o as string);}else if (type == typeof(byte[])){LuaAPI.lua_pushstring(L, o as byte[]);}else if (o is decimal){PushDecimal(L, (decimal)o);}else if (o is LuaBase){((LuaBase)o).push(L);}else if (o is LuaCSFunction){Push(L, o as LuaCSFunction);}else if (o is ValueType){PushCSObject push;if (custom_push_funcs.TryGetValue(o.GetType(), out push)){push(L, o);}else{Push(L, o);}}else{Push(L, o);}}而PushByType()是對PushAny()的封裝,唯一的不同就是做了一個優化:
對于基元類型,不再調用pushPrimitive() (會有裝箱/拆箱),而是通過查表的方式直接獲取針對各個基元類型的直接push的方式。
//針對基元類型的push函數表 push_func_with_type = new Dictionary<Type, Delegate>() {{typeof(int), new Action<RealStatePtr, int>(LuaAPI.xlua_pushinteger) },{typeof(double), new Action<RealStatePtr, double>(LuaAPI.lua_pushnumber) },{typeof(string), new Action<RealStatePtr, string>(LuaAPI.lua_pushstring) },{typeof(byte[]), new Action<RealStatePtr, byte[]>(LuaAPI.lua_pushstring) },{typeof(bool), new Action<RealStatePtr, bool>(LuaAPI.lua_pushboolean) },{typeof(long), new Action<RealStatePtr, long>(LuaAPI.lua_pushint64) },{typeof(ulong), new Action<RealStatePtr, ulong>(LuaAPI.lua_pushuint64) },{typeof(IntPtr), new Action<RealStatePtr, IntPtr>(LuaAPI.lua_pushlightuserdata) },{typeof(decimal), new Action<RealStatePtr, decimal>(PushDecimal) },{typeof(byte), new Action<RealStatePtr, byte>((L, v) => LuaAPI.xlua_pushinteger(L, v)) },{typeof(sbyte), new Action<RealStatePtr, sbyte>((L, v) => LuaAPI.xlua_pushinteger(L, v)) },{typeof(char), new Action<RealStatePtr, char>((L, v) => LuaAPI.xlua_pushinteger(L, v)) },{typeof(short), new Action<RealStatePtr, short>((L, v) => LuaAPI.xlua_pushinteger(L, v)) },{typeof(ushort), new Action<RealStatePtr, ushort>((L, v) => LuaAPI.xlua_pushinteger(L, v)) },{typeof(uint), new Action<RealStatePtr, uint>(LuaAPI.xlua_pushuint) },{typeof(float), new Action<RealStatePtr, float>((L, v) => LuaAPI.lua_pushnumber(L, v)) }, }; (2)傳遞lua對象到c#敬請期待總結
以上是生活随笔為你收集整理的传递对象_看懂Xlua实现原理——从宏观到微观(1)传递c#对象到Lua的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 研究发现早晨课越多成绩越差 网友:翘课有
- 下一篇: 微软 Win11 份额创新高,每五台 W