XLua热更新 ---- 学习笔记 ----

[本笔记参考Unity官方教程] https://blog.csdn.net/yhx956058885/category_10414478.html

           Xlua热更新: https://www.aliyundrive.com/s/23tvqvAEnJz 提取码: 6g5d 

1.什么热更新

软件的更新分为整包更新和热更新

整包更新(冷更新) : 为重新下载该程序包安装,体积较大,下载慢

热更新 : 为动态更新游戏内容,通过网络下载升级包,不需要发布新版本,升级包的体积比较小,下载速度快

热更新的方式可以绕过应用市场的审核,所以对于紧急的bug修复以及实时性较强的功能发布


2.热更新的方法

1.Lua系解决方案 :内置一个Lua虚拟机,做好UnityEngine与C#框架的Lua导出.典型的框架有XLua,Ulua,大体都差不多 求职必备

2.ILRuntime解决方案 :内置一个.net字节码解释器,解释执行.net字节码。

3.puerts解决方案 :内置一个JavaScript/TypeScript解释器,解释执行TypeScript代码。

4.HybridCLR(wolong) huatuo : 在IL2CPP VM中内置一个.net 字节码解释器,同时还会把.net里面的数据对象映射到native 的数据对象,huatuo的任何c#编写的代码都可以热更新 推荐学习,学习成本几乎零成本,一个特性完整、零成本、高性能、低内存的近乎完美的Unity全平台原生c#热更方案,其他方案则是 第三方解释器,与运行时不统一,最终结果是huatuo使用方便,兼容性好,性能和内存有极其巨大的优势


3.Xlua插件使用的语言

Lua语言 : 解释性语言 格式和python有点像 lua.exe 加入Lua到系统环境变量中去

可以看一下Lua的语法 Lua简单语法.pdf (大概花个一天时间过一下Lua语法最好)再来看接下来的步骤

XLua 是腾讯开发的一款用于 unity 热更新的插件,可以从github上下载源码 这里我已经提提前下好了 "Resources\xLua-master\Assets" 目录下导入Unity工程即可使用,如果出现抱错删除Xlua/Gen即可

https://github.com/Tencent/xLua (开源地址)可能需要加速器才可访问

                                        

开启热更新 : 进入(File/Build Setings.../Player Settings.../Player)后 ,找到 Scripting_Define Symbols 加上 HOTFIX_ENABLE 后 Apply,

然后查看Xlua下就出现了 Hotfix Inject In Editor

之后点击Clear Generated Code ==> Generate Code ==> Hotfix lnject ln Editor 就会重新帮助构建Gen目录,(注意:但凡是在更改了C#代码中有关于热更新部分的地方,就需要重新重复以上三个步骤)


4.了解Xlua中的一些类和API

LuaEnv类

创建一个Lua虚拟环境

// 成员方法
 // 执行一个代码块
 object[] DoString(string chunk, string chunkName = "chuck", LuaTable env = null);
 // chunk : 表示执行的字符串
 // chunkName : 发生error时的debug显示中使用,指名那行代码出错
 // env : 这个代码的执行环境
 // return : 返回这个chunk返回的值
 ​
 ​
 // 加载一个代码块,但不执行,只返回指定为一个delegate 或者一个LuaFunction
 T LoadString<T>(string chunk, string chunkName = "chunk", LuaTable env = null);
 // chunk : 表示执行的字符串
 // chunkName : 发生error时的debug显示中使用,指名那行代码出错
 // env : 这个代码的执行环境
 // return : 代表该代码块的delegate或者LuaFunction类
 ​
 // 清除Lua的未手动释放的LuaBase对象(比如:LuaTable, LuaFunction),以及其它一些事情
 void Tick();
 ​
 ​
 // 增加一个自定义loader
 void AddLoader(CustomLoader loader);
 //loader:一个包含了加载函数的委托,其类型为delegate byte[] CustomLoader(ref string filepath),当一个文件被require时,这个loader会被回调,其参数是调用require所使用的参数,如果该loader找到文件,可以将其读进内存,返回一个byte数组。如果需要支持调试的话,而filepath要设置成IDE能找到的路径(相对或者绝对都可以)
 ​
 // 释放掉Lua环境和一些资源
 void Dispose();
 ​
 // 字段
 LuaTable Global;    // lua全局环境的LuaTable 相当与 Lua中的 _G

使用建议:全局就一个实例,并在Update中调用GC(Tick)方法,完全不需要时调用Dispose

LuaTable类

用于模拟Lua的表

// 成员方法
 T Get<T>(string key);
 // 获取在key下,类型为T的value,如果不存在或者类型不匹配,返回null
 ​
 T GetInPath<T>(string path);
 // 和Get的区别是,这个函数会识别path里头的“.”,比如var i = tbl.GetInPath<int>(“a.b.c”)相当于在lua里头执行i = tbl.a.b.c,避免仅为了获取中间变量而多次调用Get,执行效率更高
 ​
 void SetInPath<T>(string path, T val);
 // 和GetInPaht<T>对应的setter
 ​
 void Get<TKey, TValue>(TKey key, out TValue value);
 // 上面的API的Key都只能是string,而这个API无此限制
 ​
 void Set<TKey, TValue>(TKey key, TValue value);
 // 对应Get<TKey, TValue>的setter
 ​
 T Cast<T>();
 // 把该table转成一个T指明的类型,可以是一个加了CSharpCallLua声明的interface,一个有默认构造函数的class或者struct,一个Dictionary,List等等
 ​
 void SetMetaTable(LuaTable metaTable);
 // 设置metaTable为table的metatable

LuaFunction类

注意:用该类访问Lua函数会有boxing,unboxing的开销,为了性能考虑,需要频繁调用的地方不要用该类。建议通过table.Get<ABCDelegate>获取一个delegate再调用(假设<ABCDelegate>是C#的一个delegate)。在使用使用table.Get<ABCDelegate>之前,请先把<ABCDelegate>加到代码生成列表

// 以可变参数调用Lua函数,并返回该调用的返回值
 object[] Call(params object[] args);
 ​
 // 调用Lua函数,并指明返回参数的类型,系统会自动按指定类型进行转换
 object[] Call(object[] args, Type[] returnTypes);
 ​
 // 相当于lua的setfenv函数  设置一个函数的执行环境
 void SetEnv(LuaTable env)

LuaUserData类

对应非C# Managered对象的lua userdata 比如自定义结构体传入Lua


6.LuaAPI

CS对象

CS.命名空间.类() : 可以调用该类的构造函数并且返回一个示例对象

CS.命名空间.类.field : 访问该类的静态成员

CS.命名空间.enum.field : 访问一个枚举值

typeof 函数

类似C#里头的typeof关键字,返回一个Type对象

 -- 给newGameObj添加一个叫CS.UnityEngine.ParticleSystem组件
 newGameObj:AddComponent(typeof(CS.UnityEngine.ParticleSystem))

无符号 64 位支持

-- 无符号数转字符串
 uint64.tostring()
 ​
 -- 无符号数除法
 uint64.divide()
 ​
 -- 无符号比较,相等返回0,大于返回正数,小于返回负数
 uint64.compare()
 ​
 -- 无符号数取模
 uint64.remainder()
 ​
 -- 字符串转无符号数
 uint64.parse()

xlua.structclone

克隆一个c#结构体

xlua.private_accessible(class)

让一个类的私有字段,属性,方法等可用

 -- cast函数
 -- 指明以特定的接口访问对象
 a = cast(calc, typeof(CS.PerformentTest.ICalc))
 -- calc对象实现了C#的PerformentTest.ICalc接口
 -- a 可以调用该接口

建议:在Lua中定义变量时,尽量使用local本地变量,除非有想在C#中使用的变量

一些调用

local v1=CS.UnityEngine.Vector3(1,1,1)
 ​
 local v2=CS.UnityEngine.Vector3(1,1,1)
 ​
 v1.x = 100
 ​
 v2.y = 100
 ​
 print(v1, v2)
 ​
 local v3 = v1 + v2
 ​
 print(v1.x, v2.x)
 ​
 print(CS.UnityEngine.Vector3.one)
 ​
 print(CS.UnityEngine.Vector3.Distance(v1, v2))

7.类型映射

基本数据类型

C#类型Lua类型
sbyte,byte,short,ushort,int,uint,double,char,floatnumber
decimaluserdata
long,ulonguserdata/lua_Integer(lua53)
bytes[]string
boolboolean
stringstring

复杂数据类型

C#类型Lua类型
LuaTabletable
LuaFunctionfunction
class或者 struct的实例userdata,table
method,delegatefunction

class或者 struct的实例

  从C#传一个class或者struct的实例,将映射到Lua的userdata,并通过__index访问该userdata的成员

  C#侧指明从Lua侧输入指定类型对象,Lua侧为该类型实例的userdata可以直接使用;如果该指明类型有默认构造函数,Lua侧是table则会自动转换,转换规则是:调用构造函数构造实例,并用table对应字段转换到c#对应值后赋值各成员

method, delegate

  C#侧的普通参数以及引用参数,对应lua侧函数参数;C#侧的返回值对应于Lua的第一个返回值;引用参数和out参数则按序对应于Lua的第2到第N个参数


8.Xlua的配置

打标签;静态列表;动态列表

① 打标签 [LuaCallCSharp]

 [LuaCallCSharp]
 public class A
 {
 ​
 }
 // 该方式方便,但在il2cpp下会增加不少的代码量,不建议使用

② 静态列表

有时我们无法直接给一个类型打标签,比如系统api,没源码的库,或者实例化的泛化类型,据可以使用该方法打标签

[LuaCallCSharp]
public static List<Type> mymodule_lua_call_cs_list = new List<Type>()
{
    typeof(GameObject),
    typeof(Dictionary<string, int>),
};
// 表示为GamObject 和 Dictionary<string, int> 类配置[LuaCallCSharp]
// 这个字段需要放到一个静态类里头,建议放到Editor目录

③ 动态列表

声明一个静态属性,打上相应的标签即可

[Hotfix]
    public static List<Type> by_property
    {
        get
        {
            return (from type in Assembly.GetExecutingAssembly().GetTypes()
                    where type.Namespace == "XXXX"
                    select type).ToList();
        }
}
// 表示为
// 这个属性需要放到一个静态类里头,建议放到Editor目录

标签解释

XLua.LuaCallCSharp

  一个C#类型加了这个配置,xLua会生成这个类型的适配代码(包括构造该类型实例,访问其成员属性、方法,静态属性、方法),否则将会尝试用性能较低的反射方式来访问。

  一个类型的扩展方法(Extension Methods)加了这配置,也会生成适配代码并追加到被扩展类型的成员方法上。

  xLua只会生成加了该配置的类型,不会自动生成其父类的适配代码,当访问子类对象的父类方法,如果该父类加了LuaCallCSharp配置,则执行父类的适配代码,否则会尝试用反射来访问。

  反射访问除了性能不佳之外,在il2cpp下还有可能因为代码剪裁而导致无法访问,后者可以通过下面介绍的ReflectionUse标签来避免

XLua.ReflectionUse

一个C#类型类型加了这个配置,xLua会生成link.xml阻止il2cpp的代码剪裁

建议所有要在Lua访问的类型,要么加LuaCallCSharp,要么加上ReflectionUse,这才能够保证在各平台都能正常运行

XLua.CSharpCallLua

  如果希望把一个lua函数适配到一个C# delegate(一类是C#侧各种回调:UI事件,delegate参数,比如List<T>:ForEach;另外一类场景是通过LuaTable的Get函数指明一个lua函数绑定到一个delegate)。或者把一个lua table适配到一个C# interface,该delegate或者interface需要加上该配置。

XLua.GCOptimize

  除枚举之外,包含无参构造函数的复杂类型,都会生成lua table到该类型,以及改类型的一维数组的转换代码,这将会优化这个转换的性能,包括更少的gc alloc

默认情况下GCOptimize只对public的field打解包

XLua.AdditionalProperties

  这个是GCOptimize的扩展配置,有的时候,一些struct喜欢把field做成是私有的,通过property来访问field

  要求是Dictionary<Type, List<string>>类型,Dictionary的Key是要生效的类型,Value是属性名列表

XLua.BlackList

  不要生成一个类型的一些成员的适配代码

// 例如下面是对GameObject的一个属性以及FileInfo的一个方法列入黑名单
[BlackList]
public static List<List<string>> BlackList = new List<List<string>>()  {
    new List<string>(){"UnityEngine.GameObject", "networkView"},
    new List<string>(){"System.IO.FileInfo", "GetAccessControl", "System.Security.AccessControl.AccessControlSections"},
};

生成期配置 必须放入Editor目录下

CSObjectWrapEditor.GenPath

配置生成代码的放置路径,类型是string。默认放在“Assets/XLua/Gen/”下

CSObjectWrapEditor.GenCodeMenu

该配置用于生成引擎的二次开发,一个无参数函数加了这个标签,在执行“XLua/Generate Code”菜单时会触发这个函数的调用


9.Lua文件加载 自定义加载器

using UnityEngine;
using XLua;
using System.IO;
public class First : MonoBehaviour
{
    LuaEnv env;
    void Start()
    {
        // 创建运行环境
        env = new LuaEnv();
        // 添加加载器 否则Xlua会从默认路径找
        env.AddLoader(MyLoader);
        // env环境执行"require 'test00'"lua代码
        env.DoString("require 'test00'");
    }
    // 自定义加载器
    byte[] MyLoader(ref string filePath)
    {
        // 获取工程地址
        string projectPath = Application.dataPath;
        print(projectPath);
        // 获取存放Lua脚本的目录
        filePath = projectPath.Substring(0, projectPath.Length - 7) + "/Lua/" + filePath + ".lua";
        print(filePath);
        // 读取该lua脚本转化为byte[]
        return File.ReadAllBytes(filePath);
    }
    void Update()
    {
        // 对资源进行回收
        env.Tick();
    }
    private void OnDestroy()
    {
        // 销毁运行环境 回收资源
        env.Dispose();
    }
}

 


10.C#访问lua

C#主动发起对Lua数据结构的访问

一.获取一个全局基本数据类型 LuaEnv.Global + Get方法

env.Global.Get<int>("a");
env.Global.Get<string>("b");
env.Global.Get<bool>("a");

二.访问一个全局的table

也是通过Get来获取,但是需要指定泛型

1.自己来一个对应的class或者struct来接受 (需要有无参构造)

这个过程是值拷贝,如果class比较复杂代价会比较大。而且修改class的字段值不会同步到table,反过来也不会

这个功能可以通过把类型加到GCOptimize生成降低开销

2.映射到一个interface

这种方式依赖于生成代码(如果没生成代码会抛InvalidCastException异常),代码生成器会生成这个interface 的实例,如果get一个属性,生成代码会get对应的table字段,如果set属性也会设置对应的字段。甚至可以通过 interface的方法访问lua的函数

3、更轻量级的by value方式:映射到Dictionary<>,List<>

不想定义class或者interface的话,可以考虑用这个,前提table下key和value的类型都是一致的

4、另外一种by ref方式:映射到LuaTable类

这种方式好处是不需要生成代码,但也有一些问题,比如慢,比方式2要慢一个数量级,比如没有类型检查

三.访问一个全局的function

仍然是用Get方法,不同的是类型映射

1、映射到delegate

这种是建议的方式,性能好很多,而且类型安全。缺点是要生成代码

多返回值要怎么处理?

从左往右映射到c#的输出参数,输出参数包括返回值,out参数,ref参数

2、映射到LuaFunction

这种方式的优缺点刚好和第一种相反 慢 无需生成代码

LuaFunction上有个变参的Call函数,可以传任意类型,任意个数的参数,返回值是object的数组,对应于lua的多返回值

四.使用建议

1、访问lua全局数据,特别是table以及function,代价比较大,建议尽量少做,比如在初始化时把要调用的lua function获取一次(映射到delegate)后,保存下来,后续直接调用该delegate即可。table也类似。

2、如果lua侧的实现的部分都以delegate和interface的方式提供,使用方可以完全和xLua解耦:由一个专门的模块负责xlua的初始化以及delegate、interface的映射,然后把这些delegate和interface设置到要用到它们的地方。

五.例子

using UnityEngine;
using XLua;
using System.IO;
using System;

public class First : MonoBehaviour
{
    LuaEnv env;
    Action ac0;
    delegate void OneTest(object a);
    OneTest ac1;
    
    // 两个及以上参数的函数需要手动配置
    Action<int, int> ac2;
    Func<int, int, int> ac3;
	
    // 由于XLua没有开启自动适配,需要手动配置
    [CSharpCallLua]		// 注意delegate需要设置为public才可以设置[CSharpCallLua]
    public delegate void TestFunc(int a, int b, int c, out int d, out int e);
    TestFunc ac4 = null;
    
    LuaFunction ac5 = null;
    // Start is called before the first frame update
    void Start()
    {
        // 创建运行环境
        env = new LuaEnv();

        env.AddLoader(MyLoader);

        env.DoString("require 'test00'");

        print(env.Global["name"]);
        print(env.Global["age"]);
        print(env.Global["sex"]);

        ac0 = env.Global.Get<Action>("TestFunc");
        ac1 = env.Global.Get<OneTest>("OneTestFunc");
        ac2 = env.Global.Get<Action<int, int>>("Add");
        ac3 = env.Global.Get<Func<int, int, int>>("Sub");
        ac4 = env.Global.Get<TestFunc>("TestMoreFunc");
        ac5 = env.Global.Get<LuaFunction>("LuaFunctionTest");
        ac0();
        ac1(12);
        ac2(10, 10);
       
        print(ac3(20, 100));

        int d, e;
        ac4(1, 2, 3, out d, out e);
        print("d : " + d);
        print("e : " + e);
        
        print(ac5.Call(100)[0]);
        
        print("-------------映射Lua table--------------");
        print("-------映射到普通class或struct-----------");
        // 需要准备相对应的class 或者 struct
        // class struct 成员需要公开
        CSharpCallLuaTableTest00 test00 = env.Global.Get<CSharpCallLuaTableTest00>("CSharpCallLuaTableTest00");
        print(test00.name);
        print(test00.age);
        print(test00.address);
        // 修改其中一个的值
        test00.address = "成都";
        env.DoString("print(CSharpCallLuaTableTest00.address)");
        // Lua中结果没有改变,该方式是值传递,只修改C#中的数据
        print("-------------------END------------------");
        print("-------------映射到interface----------------");
        ICSharpCallLuaTableTest00 Itest00 = env.Global.Get<ICSharpCallLuaTableTest00>("CSharpCallLuaTableTest00");
        print(Itest00.name);
        print(Itest00.age);
        print(Itest00.address);
        Itest00.name = "小量";
        env.DoString("print(CSharpCallLuaTableTest00.name)");
        // Lua中结果改变了,该方式是引用拷贝,双方都要修改
        print("-------------------END------------------");
        print("-------------映射到interface采用复杂的表----------------");
        ICSharpCallLuaTableTest01 Itest01 = env.Global.Get<ICSharpCallLuaTableTest01>("CSharpCallLuaTableTest01");
        print("bookName : " + Itest01.bookName);
        print("price : " + Itest01.price);
        Itest01.ReadBook();
        Itest01.LendBook(200.0f);
        print("-------------------END------------------");
        print("-------------------通过Dictionary或者List------------------");
        // 使用dictionary映射的方式适合映射键值对类型的表
        Dictionary<string, object> test02 = env.Global.Get<Dictionary<string, object>>("CSharpCallLuaTableTest00");
        foreach(string key in test02.Keys)
        {
            print("Key : " + key + " Value : " + test02[key]);
        }
        test02["name"] = "阿达";
        env.DoString("print(CSharpCallLuaTableTest00.name)");
        // Lua中结果未改变,该方式是值拷贝,C#要修改
        print("-------------------END------------------");
        print("-------------映射到List方式----------------");
        // 使用List映射的方式适合映射数组类型的表
        List<string> stdentListtest00 = env.Global.Get<List<string>>("studentList");
        for(int i = 0; i < stdentListtest00.Count; i++)
        {
            print(stdentListtest00[i]);
        }
        stdentListtest00[0] = "长春";
        env.DoString("print(studentList[1])");
        // Lua中结果未改变,该方式是值拷贝,C#要修改
        print("-------------------END------------------");
        print("----------------通过LuaTable------------------");
        // 这种方式的优点是不需要生成代码,确定是比较慢(效率低下),比interface方式要慢一个数量级
        // 所以这种方式不推荐常用,适合用在一些比较复杂且使用频率很低的情况
        LuaTable luaTableTest00 = env.Global.Get<LuaTable>("CSharpCallLuaTableTest01");
        print(luaTableTest00.Get<string>("bookName"));
        print(luaTableTest00.Get<int>("price"));
        LuaFunction luaTableFunctionAc0 = luaTableTest00.Get<LuaFunction>("ReadBook");
        luaTableFunctionAc0.Call(luaTableTest00);
        LuaFunction luaTableFunctionAc1 = luaTableTest00.Get<LuaFunction>("LendBook");
        luaTableFunctionAc1.Call(luaTableTest00, 100);
    }
    // 自定义加载器
    byte[] MyLoader(ref string filePath)
    {
        // 获取工程地址
        string projectPath = Application.dataPath;
        // 获取存放Lua脚本的目录
        filePath = projectPath.Substring(0, projectPath.Length - 7) + "/Lua/" + filePath + ".lua";
        // 读取该lua脚本转化为byte[]
        return File.ReadAllBytes(filePath);
    }


    // Update is called once per frame
    void Update()
    {
        // 对资源进行回收
        env.Tick();
    }

    private void OnDestroy()
    {
        // 释放资源
        ac0 = null;
        ac1 = null;
        ac2 = null;
        ac3 = null;
        ac4 = null;
        ac5 = null;
        // 销毁运行环境 回收资源
        env.Dispose();
    }
}
-- GameObject =CS.UnityEngine.GameObject
-- Debug=CS.UnityEngine.Debug
-- Vector3=CS.UnityEngine.Vector3

print("------------------C#访问lua--------------------")
name = "小明"
age = 12
sex = "男"

function TestFunc()
    print("测试0个参数")
end

function OneTestFunc(a)
    print(a)
end

function Add(a,b)
    print(a+b)
end

function Sub(a,b)
    return a-b
end

function TestMoreFunc(a,b,c)
    print(a,b,c)
    return a+b,b+c
end

function LuaFunctionTest(a)
    print(a)
    return a
end

CSharpCallLuaTableTest00 = {
    name = "夏明",
    age = 12,
    address = "上海"
}

CSharpCallLuaTableTest01 = {
    bookName = "今天也要在异世界努力生存下去",
    price = 100,

    ReadBook = function (self)
        print("阅读 " ..self.bookName .. " ing")
    end,
    LendBook = function (self,lendBookPrice)
        print("借出 " ..self.bookName .. " ing")
        print("价格为 : " .. lendBookPrice)
    end
}

studentList = {"小明","小静","常量","凯斯"}
// 配置脚本
// 推荐写在Editor目录下
using System;
using System.Collections.Generic;
using XLua;

public static class CSharpCallLuaTest
{
    [CSharpCallLua]
    public static List<Type> CSharpCallLua = new List<Type>()
    {
        // 对需要适配Lua中的function进行配置
        typeof(Action<int,int>),
        typeof(Func<int, int, int>),
    };
}

Lua调用C#

C# : var newGameObj = new UnityEngine.GameObject(); ===> Lua : local newGameObj = CS.UnityEngine.GameObject()

Lua中没有new关键字

和C#相关的都放在了CS下 包括 构造 静态成员 方法

Xlua支持部分重载

读取静态属性

用点的方式

CS.UnityEngine.Debug.Log("Hello World")

写静态属性

同读一样,点出来直接修改就行

CS.UnityEngine.Time.timeScale = 0.2

调用静态方法

CS.UnityEngine.GameObject.Find("newObject")

Tips:如果需要经常访问的类,可以先用局部变量引用后访问,除了减少敲代码的时间,还能提高性能

GameObject = CS.UnityEngine.GameObject Debug=CS.UnityEngine.Debug Vector3=CS.UnityEngine.Vector3

访问成员属性.方法

掉用成员属性用点

调用成员方法用冒号

父类属性,方法

xlua支持(通过派生类)访问基类的静态属性,静态方法,

(通过派生类实例)访问基类的成员属性,成员方法

参数的输入输出属性(out,ref)

Lua侧调用C#:C#的普通参数算一个输入,ref算输入,out不算

C#得到Lua的返回值:return 算一个 out 算一个 ref 算一个 ,对应从左往右返回给C#

重载

Xlua支持一定程度上的重载 eg : C# :int,float,double ==> Lua:number

操作符

支持的操作符有:+,-,*,/,==,一元-,<,<=, %,[]

参数带默认值的方法

和C#一样,如果实参少于形参,会自动补上

可变参数

C# : void VariableParamsFunc(int a, params string[] strs)

Lua : testobj:VariableParamsFunc(5, 'hello', 'john')

使用Extension methods C#类方法扩展

Lua可以直接调用

泛化(模版)方法

不直接支持,可以通过Extension methods功能进行封装后调用

枚举类型

属于枚举类型下的静态属性一样,用点就行了

如果枚举类加入到生成代码的话,枚举类将支持__CastFrom方法,可以实现从一个整数或者字符串到枚举值的转换

CS.Tutorial.TestEnum.__CastFrom('E1')

表示把TestEnum下一个叫E1“的枚举类型转化为枚举值

delegate使用(调用,+,-)

C#的delegate调用:和调用普通lua函数一样

操作符:对应C#的+操作符,把两个调用串成一个调用链,右操作数可以是同类型的C# delegate或者是lua函数。

-操作符:和+相反,把一个delegate从调用链中移除。

Ps:delegate属性可以用一个luafunction来赋值

event

public event Action TestEvent

增加事件回调:class:TestEvent('+',function)

移除事件回调:class:TestEvent('-',function)

64位整型支持

Lua53原生有64位整型,直接映射

luajit版本:本身不支持64位,xlua做了个64位支持的扩展库,C#的long和ulong都将映射到userdata

C#复杂类型和table的自动转换

遇见类/结构体就大括号进行构造,普通的变量类型直接添值就行

获取类型(相当于C#的typeof)

typeof(CS.UnityEngine.ParticleSystem)

“强”转

cast(calc, typeof(CS.Tutorial.Calc))

上面就是指定用CS.Tutorial.Calc的生成代码来访问calc对象

例子

using System;
using System.IO;
using UnityEngine;
using XLua;

namespace Test
{
    public class LuaCallCsharp00
    {
        public static string name = "小明";
        public static int age = 12;

        public static void Speak()
        {
            Debug.Log("Hello!");
        }

        public string address = "仁寿";
        public virtual void Dosomething()
        {
            Debug.Log("DoSomeThing...");
        }
        public LuaCallCsharp00()
        {

        }
        public LuaCallCsharp00(string address)
        {
            this.address = address;
        }

    }

    public class LuaCallCsharp01 : LuaCallCsharp00
    {
        public void Dosomething()
        {
            Debug.Log("子类 DoSomeThing...");
        }
        public void Dosomething(string str)
        {
            Debug.Log(str);
        }

        public void Dosomething(int price)
        {
            Debug.Log("买菜 Price : " + price);
        }

        public void Dosomething(string boName,float price)
        {
            Debug.Log($"买书 {boName} 的 Price : " + price);
        }
    }

    public class Person
    {
        public void Method(Student student)
        {

            Debug.Log($"{student.name}  {student.age}  {student.address}  {student.ranking}");
            Debug.Log(student.level);

        }
        public void Method(IStudent student,int a)
        {

            Debug.Log($"{student.name}  {student.age}  {student.address}  {student.ranking}");
            Debug.Log(student.level);
            student.Speak();
            student.Dosomething();
            student.Dosomething1("asasa");
        }
        public void Method1(StudentDelegate sdel)
        {
            sdel.Invoke(new Student() { name = "大王",age = 30,ranking = 12,address = "广州",level = Level.three });
        }
    }

    public struct Student
    {
        public string name;
        public int age;
        public int ranking;
        public string address;
        public Level level;
    }
    public enum Level
    {
        one,
        two,
        three
    }
    [CSharpCallLua]
    public interface IStudent
    {
        string name { set; get; }
        int age { set; get; }
        int ranking { set; get; }
        string address { set; get; }
        Level level { set; get; }
        void Speak();
        void Dosomething();
        void Dosomething1(string str);
    }
    
    [CSharpCallLua]
    public delegate void StudentDelegate(Student student);
    // Lua通过扩展方法掉用泛型
    [LuaCallCSharp]
    public class MyGengerric
    {
        public T GetMax<T>(T num1, T num2) where T : IComparable
        {
            return (num1.CompareTo(num2) < 0) ? num2 : num1;
        }
    }

    [LuaCallCSharp]
    public static class Extension_MyGengerric
    {
        public static int GetMax(this MyGengerric gen, int num1, int num2)
        {
            return (num1 > num2) ? num1 : num2;
        }
    }
}

public class LuaCallCsharpTest : MonoBehaviour
{
    LuaEnv env;
    private void Awake()
    {
        env = new LuaEnv();
        env.AddLoader(MyLoader);
        env.DoString("require 'LuaCallCSharp'");

    }
    // 自定义加载器
    byte[] MyLoader(ref string filePath)
    {
        // 获取工程地址
        string projectPath = Application.dataPath;
        // 获取存放Lua脚本的目录
        filePath = projectPath.Substring(0, projectPath.Length - 7) + "/Lua/" + filePath + ".lua";
        // 读取该lua脚本转化为byte[]
        return File.ReadAllBytes(filePath);
    }
    void Update()
    {
        // 对资源进行回收
        env.Tick();
    }
    private void OnDestroy()
    {
        env.Dispose();
    }
}
local Input = CS.UnityEngine.Input
local TouchPhase = CS.UnityEngine.TouchPhase
local Mathf = CS.UnityEngine.Mathf
local Quaternion = CS.UnityEngine.Quaternion
local Vector2 = CS.UnityEngine.Vector2
local Vector3 = CS.UnityEngine.Vector3

GameObject =CS.UnityEngine.GameObject
Debug=CS.UnityEngine.Debug
Vector3=CS.UnityEngine.Vector3

print("---------Lua Call C#-----------")
print("掉用静态成员")
print(CS.Test.LuaCallCsharp00.name)
print(CS.Test.LuaCallCsharp00.age)
CS.Test.LuaCallCsharp00.age = 20
print("修改后的age : " ..CS.Test.LuaCallCsharp00.age)
print("掉用静态方法")
CS.Test.LuaCallCsharp00.Speak()

-- 创建LuaCallCsharp00对象,加 ()
local test00 = CS.Test.LuaCallCsharp00('青岛')
print(CS.Test.LuaCallCsharp00.age)
-- 调用成员字段
print(test00.address)
test00.address = "成都"
print(test00.address)
-- 调用成员方法
test00:Dosomething()

-- 调用父类方法
local test01 = CS.Test.LuaCallCsharp01()
-- 调用父类字段
print(test01.address)
-- 调用子类重写的方法
test01:Dosomething()
-- 函数重载调用
test01:Dosomething("做家务")
test01:Dosomething(30)
test01:Dosomething("世界遗忘了我之后,我找到了通向世界尽头的路",49.99)

-- 调用带有复杂的参数
local person = CS.Test.Person()
local student00 = {name = "小强",age = 17,ranking = 30,address = "沁阳",level = CS.Test.Level.two}
person:Method(student00)
-- 带有接口的参数
local Iperson = {
    name = "小钱",
    age = 16,
    ranking = 2,
    address = "沁阳",
    level = CS.Test.Level.one,
    Speak = function ()
        print("Hello!")
    end,
    Dosomething = function ()
        print("做点事情")
    end,
    Dosomething1 = function (self,str)
        print(str)
    end
}
person:Method(Iperson,1)

-- 调用带有委托参数的函数 
sdel = function (stu)
    print(stu.name)
    print(stu.age)
    print(stu.ranking)
    print(stu.address)
    print(stu.level)
end
person:Method1(sdel)

-- 调用泛型方法
-- lua不支持直接调用c#中的泛型方法,但是可以通过扩展方法功能进行封装后调用
local maxNum = CS.Test.MyGengerric():GetMax(10,20)
print("maxNum:"..maxNum)

AssetsBundle

1.什么是AB包

特定于平台的资产压缩包,类似与压缩包 == > 不能压缩C#文件

资产包括:模型,贴图,预设体,音效,材质球,动作等等

2.了解AB包的作用

相对于Resources文件夹而言:Resources在打包时定死的,只可读,无法修改

AB包:存储文件可以自定义路径,可以压缩空间,可以从网络上下载,热更新的基础,比Resources灵活

减少初始包的大小

热更新

资源热更新

脚本热更新

 

3.生成AB包资源文件

1.自定义打包工具

2.Unity提供的Assets Bundle Browser

"com.unity.assetbundlebrowser": "1.7.0"

如果没有搜到该安装包,就在Packages/manifest.json文件中添加上上面的那一串字符串

把资源进行打包

1.拖成预制体

2.在Inspector面板的最下面new一个写名称,右边那个为文件的后缀名

 

3.Window->AssetBundle Browser打开AssetBundle

4.Configure会出现new的物体

5.Build 面板中 Build Target 为打包到的平台

        Output Path : AB包的路径,在该工程文件下创建的

        Clear Folders:每次打包是否清除文件夹

        Copy to StreamingAssets : 是否复制AB包的文件拷贝到StreamingAssets 下

        Compression : NC : 不压缩,压缩文件最大

                LZMA :压缩文件最小,但是解压缩时需要全部解压才能用

                LZ4 : 压缩打包,需要那个就解压那个,内存占用低

         EIT:在资源包中 不包含资源的类型文件

         FR:重新打包时需要重新构建包,和Clear Folders很像,但是不会删除

                不存在的包

        ITTC:增量构建检查时,忽略类型的更改

        Append Hash:将文件Hash值附加到资源包上

        SM:严格模式,如果打包报错了,则打包失败无法成功

        DRB:运行时构建

打包文件类型:

        有manifst后缀的文件为对应资源文件的依赖

        没有的manifst后缀的为资源文件

        会有一个和该目录名一样的文件 主包

        该文件带有manifst的文件带有所有文件相对与那个包有依赖的信息

6.Inspect面板:查看文件大小

4.加载AB包和AB包资源

using System.Collections;
using UnityEngine;

public class ABundleTEst : MonoBehaviour
{
    public Transform Parent;
    // Start is called before the first frame update
    void Start()
    {
        // 同步加载
        // 加载   AB包
        AssetBundle ab = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/goodsitem");
        // 记载   AB包中的资源
        // 用名字加载会出现同名不同类型的资源
        // 建议使用泛型加载 或者时 指定Type加载
        //GameObject obj = ab.LoadAsset<GameObject>("goodsItem");
        GameObject obj = ab.LoadAsset("goodsItem", typeof(GameObject)) as GameObject;
        Instantiate(obj, Parent);
        // AB包不能加载两次,再次加载需要先卸载之前加载的AB包
        // true : 卸载AB包后在场景下的游戏物体会收到影响
        // false : 卸载AB包后在场景下的游戏物体不会收到影响
        ab.Unload(false);   // 卸载goodsitem AB包
        AssetBundle.UnloadAllAssetBundles(false);   // 卸载所有的AB包
        ab = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/goodsitem");  // 再加载
        ab.Unload(false);   // 再卸载
        // 异步加载 用到携程
        // StartCoroutine(LoadRes("goodsitem", "goodsitem"));


        // 依赖关系的问题
        // 如果一个包中的资源依赖另一个包中的资源
        // 需要手动把另外依赖的包给加载进来
        // 就可以依赖主包得到包和包之间的依赖信息
        // 加载 主包
        AssetBundle abmain = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/" + "PC");
        // 加载主包中的固定文件
        AssetBundleManifest manifest = abmain.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
        // 得到固定文件中的依赖信息
        string[] args = manifest.GetAllDependencies("cube");
        Debug.Log(args.Length);
        foreach (string arg in args)
        {
            // 打印出cube包依赖的其他包的名称
            Debug.Log(arg);
        }
        ab = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/" + "cube");
        obj = ab.LoadAsset("Cube", typeof(GameObject)) as GameObject;
        Instantiate(obj);
        ab.Unload(false);
        ab = AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/" + "goodsitem");
    }

    IEnumerator LoadRes(string abName,string objName)
    {
        // 加载AB包
        AssetBundleCreateRequest abcr = AssetBundle.LoadFromFileAsync(Application.streamingAssetsPath + "/" + abName);
        yield return abcr;
        // 加载资源
        AssetBundleRequest abr = abcr.assetBundle.LoadAssetAsync(objName,typeof(GameObject));
        yield return abr;
        GameObject obj = abr.asset as GameObject;
        Instantiate(obj, Parent);
        AssetBundle.UnloadAllAssetBundles(false);   // 卸载所有的AB包
    }
}

可以自己试着写一个加载AssetBundle的封装脚本

纯Lua案例:游戏背包界面(简单版)

  由于用Lua开发,那么就需要一个Lua文件来作为所有Lua文件打开的入口 ,我们把该文件命名为mian.lua,来作为所有lua文件的管理者,以后需要新建lua文件就在该文件下 "require 'lua文件'" 就可以了,由于是案例,不会去实现复杂的功能,不会使用难度大的脚本,主要为巩固Xlua的Lua调用C#,如果纯C#可以开发了,那么办C#半Lua开发就简单多了,由于案例比较简单为了方便看,我把写的代码放入同一个脚本中了,但是自己在开发时一定要把脚本分类好,这样有利于自己和他人阅读

using System.IO;
using UnityEngine;
using XLua;
public class Demo : MonoBehaviour
{ 
    LuaEnv env;
    private void Start()
    {
        env = new LuaEnv();
        env.AddLoader(MyLoader);
        env.DoString("require 'main'");

    }
    private void Update()
    {
        env.Tick();
    }
    private void OnDestroy()
    {
        env.DoString("ClearChache()");
        env.Dispose();
        env = null;
    }
    private byte[] MyLoader(ref string filePath)
    {
        filePath = Application.dataPath.Substring(0, Application.dataPath.Length - 7) + "/Lua/" + filePath + ".lua";
        print(filePath);
        return File.ReadAllBytes(filePath);
    }
}
​​​​​​​-- 简化一些语句
local GameObject = CS.UnityEngine.GameObject
local UI = CS.UnityEngine.UI
local AssetBundle = CS.UnityEngine.AssetBundle
local Application = CS.UnityEngine.Application
local Sprite = CS.UnityEngine.Sprite

-- 图标
local pc1 = nil
local pc2 = nil
local pc3 = nil
local pc4 = nil
local pc5 = nil


-- 找到需要生成图标的父对象
local grid = GameObject.Find("Grid")
grid:SetActive(false)
-- 找到关闭按钮
local close = GameObject.Find("Close")
close:SetActive(false)
-- 找到数据面板
local info = GameObject.Find("Info")
info:SetActive(false)


local bagButton = GameObject.Find("BagButton"):GetComponent(typeof(UI.Button))
local goodsit = nil
-- 加载AssetBundle
local LoadRes = function()
    -- 加载图片资源
    abpic = AssetBundle.LoadFromFile(Application.streamingAssetsPath .."/ui")
    pc1 = abpic:LoadAsset('goods (1)',typeof(Sprite))
    pc2 = abpic:LoadAsset('goods (2)',typeof(Sprite))
    pc3 = abpic:LoadAsset('goods (3)',typeof(Sprite))
    pc4 = abpic:LoadAsset('goods (4)',typeof(Sprite))
    pc5 = abpic:LoadAsset('goods (5)',typeof(Sprite))
    abpic:Unload(false)
    -- 加载背包槽资源
    absolt = AssetBundle.LoadFromFile(Application.streamingAssetsPath .."/goodsitem")
    goodsit = absolt:LoadAsset('goodsItem',typeof(GameObject))
    cast(goodsit,typeof(GameObject))
    absolt:Unload(false)
end

-- 关闭背包
local CloseBag = function()
    grid:SetActive(false)
    close:SetActive(false)
    info:SetActive(false)
end

-- 解除绑定
local unBind = function ()
    local maxIndex = grid.transform.childCount
    for i = 0,maxIndex-1 do
        local temp = grid.transform:GetChild(i)
        temp:GetComponent(typeof(UI.Button)).onClick = nil
    end
end

-- 打开背包
local OpenBag = function()
    if(grid.transform.childCount == 0) then
        if(not grid.activeSelf) then         
            -- 添加物品
            local temp1 = GameObject.Instantiate(goodsit,grid.transform):GetComponent(typeof(UI.Image))
            temp1.sprite  = pc1
            local temp2 = GameObject.Instantiate(goodsit,grid.transform):GetComponent(typeof(UI.Image))
            temp2.sprite  = pc3
            local temp3 = GameObject.Instantiate(goodsit,grid.transform):GetComponent(typeof(UI.Image))
            temp3.sprite = pc5
            temp1:GetComponent(typeof(UI.Button)).onClick:AddListener(function ()
                info:SetActive(true)
                info.transform:GetChild(1):GetComponent(typeof(UI.Text)).text = "temp1"
            end)
            temp2:GetComponent(typeof(UI.Button)).onClick:AddListener(function ()
                info:SetActive(true)
                info.transform:GetChild(1):GetComponent(typeof(UI.Text)).text = "temp2"
            end)
            temp3:GetComponent(typeof(UI.Button)).onClick:AddListener(function ()
                info:SetActive(true)
                info.transform:GetChild(1):GetComponent(typeof(UI.Text)).text = "temp3"
            end)
            
        end
    end  
    grid:SetActive(true)
    close:SetActive(true)
end

LoadRes()
bagButton.onClick:AddListener(OpenBag)
close:GetComponent(typeof(UI.Button)).onClick:AddListener(CloseBag)

-- 释放资源
function ClearChache()
    print("------------释放资源------------")
    bagButton.onClick = nil
    close:GetComponent(typeof(UI.Button)).onClick = nil
    unBind()
    CloseBag = nil
    OpenBag = nil
    unBind = nil
end

练习代码.unitypackage

HotFixTest.zip

用Unity打开即可,本案例旨在了解,熟悉Lua掉用C#的API,标准写法还需请自行上网查询

搭建一个本地服务器(自己测试用)实现简单得热更新功能

1.安装一下NeBox,配置好环境变量

2.在工程目录下新建目录NetBox,新建文件localNet.box,使用记事本打开输入以下字符串

Dim httpd
Shell.Service.RunService "NBWeb", "NetBox Web Server", "NetBox Http Server Sample"
'---------------------- Service Event ---------------------
Sub OnServiceStart()
Set httpd = NetBox.CreateObject("NetBox.HttpServer")
If httpd.Create("", 8009) = 0 Then
Set host = httpd.AddHost("", "")
host.EnableScript = true
host.AddDefault "1.html"
httpd.Start
else
Shell.Quit 0
end if
End Sub
Sub OnServiceStop()
httpd.Close
End Sub
Sub OnServicePause()
httpd.Stop
End Sub
Sub OnServiceResume()
httpd.Start
End Sub

3.新建一个文件,取名为 1.html,用来测试是否成功配置

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>Hello World</h1>
</body>
</html>

4.在浏览器输入 (http://localhost:8009/1.html),出现Hello World表示配置成功

5.Unity中 File->Build Settings->Player Settings->Player->Other Settings->Scripting Define Symbols」选项中添加`HOTFIX_ENABLE

6.把上面案例下得Lua文件夹和工程目录下的AssetBundles文件夹放入NetBox目录下

7.添加一个场景,用于通过从本地服务器下载文件到对应的自定义加载Lua文件夹下

using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;

public class CheckLoadRes : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        StartCoroutine(LoadRes());
    }
    IEnumerator LoadRes()
    {
        // 从本地下载Lua资源文件
        WWW www = new WWW("http://localhost:8009/Lua/main.lua");
        yield return www;
        string targetDir = Application.dataPath.Substring(0,Application.dataPath.Length - 7) + "/Lua";
        File.WriteAllText(targetDir + "/main.lua", www.text);
        SceneManager.LoadScene(1);
    }
}

8.给需要热更新的类加上[Hotfix]标签后,就可以在Lua中指定需要替换/增加的方法

local Demo = CS.Demo
-- 允许访问私有成员
-- 热更新替换
xlua.private_accessible(Demo)
xlua.hotfix(Demo,"Update",function(self)
    if(Input.GetKeyDown(CS.UnityEngine.KeyCode.Space)) then
        local temp1 = GameObject.Instantiate(goodsit,grid.transform):GetComponent(typeof(UI.Image))
        temp1.sprite  = pc1
        temp1:GetComponent(typeof(UI.Button)).onClick:AddListener(function ()
            info:SetActive(true)
            info.transform:GetChild(1):GetComponent(typeof(UI.Text)).text = "新生成的temp1"
        end)
    end
end)

-- 热更新增量
-- 只能增量C#,无法增量在Lua中修改的代码,原Lua替换的代码会被增量代码提换
-- local util = require 'util'
-- util.hotfix_ex(Demo,"Update",function(self)
-- 	-- 调用原本的方法
--     print('这是增量代码')
-- end)

在结束释放资源时,需要释放热更新资源

function ClearChache()
    print("------------释放资源------------")
    bagButton.onClick = nil
    close:GetComponent(typeof(UI.Button)).onClick = nil
    unBind()
    CloseBag = nil
    OpenBag = nil
    unBind = nil
     -- 释放热更新资源
    xlua.hotfix(CS.Demo,'Update',nil)
end

9.资源热更 把通过使用unity提供的打包工具打包的AB包放到服务器中去

从服务器下载AssetsBundles(AB包)资源包到StreamingAssets目录下
IEnumerator LoadABRes(string fileName)
    {
        string DownLoadAddress = "http://localhost:8009/AssetBundles/PC";
        string DownLoadToAddress = Application.streamingAssetsPath;
        WWW request = new WWW(DownLoadAddress + "/" + fileName);
        yield return request;
        File.WriteAllBytes(DownLoadToAddress + "/" + fileName, request.bytes);
        request = new WWW(DownLoadAddress + "/" + fileName + ".manifest");
        yield return request;
        File.WriteAllBytes(DownLoadToAddress + "/" + fileName + ".manifest", request.bytes);
        print("资源加载完毕");
    }
	private void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))
        {
            // 完成加载后点击空格切换场景
            SceneManager.LoadScene(1);
        }
    }

10.在脚本热更中使用新加载的资源包

-- 加载AssetBundle
local LoadRes = function()
    -- 加载图片资源
    local abpic = AssetBundle.LoadFromFile(Application.streamingAssetsPath .."/ui")
    pc1 = abpic:LoadAsset('goods (1)',typeof(Sprite))
    pc2 = abpic:LoadAsset('goods (2)',typeof(Sprite))
    pc3 = abpic:LoadAsset('goods (3)',typeof(Sprite))
    pc4 = abpic:LoadAsset('goods (4)',typeof(Sprite))
    pc5 = abpic:LoadAsset('goods (5)',typeof(Sprite))

    -- 加载背包槽资源
    local absolt = AssetBundle.LoadFromFile(Application.streamingAssetsPath .."/goodsitem")
    goodsit = absolt:LoadAsset('goodsItem',typeof(GameObject))
	
    -- 加载cube模型资源包
    local cube = AssetBundle.LoadFromFile(Application.streamingAssetsPath .."/cube")
    -- 解压取出	Cube 和 Sphere
    Cube = cube:LoadAsset('Cube',typeof(GameObject))
    Sphere = cube:LoadAsset('Sphere',typeof(GameObject))
end

local Demo = CS.Demo
-- 允许访问私有成员
-- 热更新替换
xlua.private_accessible(Demo)
xlua.hotfix(Demo,"Update",function(self)
    if(Input.GetKeyDown(CS.UnityEngine.KeyCode.Space)) then
        local temp1 = GameObject.Instantiate(goodsit,grid.transform):GetComponent(typeof(UI.Image))
        temp1.sprite  = pc1
        temp1:GetComponent(typeof(UI.Button)).onClick:AddListener(function ()
            info:SetActive(true)
            info.transform:GetChild(1):GetComponent(typeof(UI.Text)).text = "新生成的temp1"
        end)
    end
    -- 热更添加逻辑代码	使用新添加的资源
    if(Input.GetKeyDown(CS.UnityEngine.KeyCode.W)) then
        -- 生成Cube 和 Sphere
        GameObject.Instantiate(Cube)
        GameObject.Instantiate(Sphere)
    end
end)

HotFixTestDemo.zip

这个案例只是一个简单的入门Xlua,还需自己进行精进和探索

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值