深入UE——UObject(十一)类型系统构造-构造绑定链接

引言

在上篇介绍了类型注册的最后阶段,为每一个enum、struct、class都进行了一次RegisterFn调用(忘了调用时机的请翻阅前文),而这些RegisterFn其实都指向生成代码里的函数。本篇就来讲解这个里面的类型对象生成和构造。 按照生成顺序,也是调用顺序,一一讲解UEnum和UScriptStruct的生成,以及UClass的继续构造。 注意:

  1. 代码里去除掉HotReload和MetaData的部分(那只是给编辑器提供额外信息的)。
  2. 代码里各Params的定义可到“代码生成重构”篇去查看,或者自己打开源码对照,都比较简单,就不列出来了。

UEnum

先从软的开始捏,这是用来测试的MyEnum:

UENUM(Blueprintable,meta=(EnumDisplayNameFn="GetMyEnumDisplayName"))
enum class MyEnum:uint8
{
    Dance   UMETA(DisplayName = "MyDance"),
    Rain    UMETA(DisplayName = "MyRain"),
    Song    UMETA(DisplayName = "MySong")
};

FText GetMyEnumDisplayName(int32 val)   //可以提供一个自定义函数给枚举来额外显示
{
    MyEnum enumValue = (MyEnum)val;
    switch (enumValue)
    {
    case MyEnum::Dance:
        return FText::FromString(TEXT("Hello_Dance"));
    case MyEnum::Rain:
        return FText::FromString(TEXT("Hello_Rain"));
    case MyEnum::Song:
        return FText::FromString(TEXT("Hello_Song"));
    default:
        return FText::FromString(TEXT("Invalid MyEnum"));
    }
}

生成代码的关键:

static UEnum* MyEnum_StaticEnum()   //RegisterFn指向该函数
{
    static UEnum* Singleton = nullptr;
    if (!Singleton)
    {
        Singleton = GetStaticEnum(Z_Construct_UEnum_Hello_MyEnum, Z_Construct_UPackage__Script_Hello(), TEXT("MyEnum"));
    }
    return Singleton;
}

static FCompiledInDeferEnum Z_CompiledInDeferEnum_UEnum_MyEnum(MyEnum_StaticEnum, TEXT("/Script/Hello"), TEXT("MyEnum"), false, nullptr, nullptr);    //收集点

UEnum* Z_Construct_UEnum_Hello_MyEnum() //实际调用点
{
    static UEnum* ReturnEnum = nullptr;
    if (!ReturnEnum)
    {
        static const UE4CodeGen_Private::FEnumeratorParam Enumerators[] = {
            { "MyEnum::Dance", (int64)MyEnum::Dance }, //注意枚举项的名字是以"枚举::"开头的
            { "MyEnum::Rain", (int64)MyEnum::Rain },
            { "MyEnum::Song", (int64)MyEnum::Song },
        };

        static const UE4CodeGen_Private::FEnumParams EnumParams = {
            (UObject*(*)())Z_Construct_UPackage__Script_Hello,
            UE4CodeGen_Private::EDynamicType::NotDynamic,
            "MyEnum",
            RF_Public|RF_Transient|RF_MarkAsNative,
            GetMyEnumDisplayName,//一般为nullptr,我们自定义了一个,所以这里才有
            (uint8)UEnum::ECppForm::EnumClass,
            "MyEnum",
            Enumerators,
            ARRAY_COUNT(Enumerators),
            METADATA_PARAMS(Enum_MetaDataParams, ARRAY_COUNT(Enum_MetaDataParams))
        };
        UE4CodeGen_Private::ConstructUEnum(ReturnEnum, EnumParams);//最终生成点
    }
    return ReturnEnum;
}

生成代码里的收集点,把MyEnum_StaticEnum注册给了RegisterFn。调用的时候,内部的GetStaticEnum会调用参赛里的Z_Construct_UEnum_Hello_MyEnum。而Z_Construct_UEnum_Hello_MyEnum内部其实比较简单,定义了枚举项参数和枚举参数,最终发给UE4CodeGen_Private::ConstructUEnum调用。

void ConstructUEnum(UEnum*& OutEnum, const FEnumParams& Params)
{
    UObject* (*OuterFunc)() = Params.OuterFunc;
    UObject* Outer = OuterFunc ? OuterFunc() : nullptr; //先确保创建Outer
    if (OutEnum) {return;}  //防止重复构造

    UEnum* NewEnum = new (EC_InternalUseOnlyConstructor, Outer, UTF8_TO_TCHAR(Params.NameUTF8), Params.ObjectFlags) UEnum(FObjectInitializer());    //创建一个UEnum
    OutEnum = NewEnum;

    //生成枚举名字值对数组
    TArray<TPair<FName, int64>> EnumNames;
    EnumNames.Reserve(Params.NumEnumerators);
    for (const FEnumeratorParam* Enumerator = Params.EnumeratorParams, *EnumeratorEnd = Enumerator + Params.NumEnumerators; Enumerator != EnumeratorEnd; ++Enumerator)
    {
        EnumNames.Emplace(UTF8_TO_TCHAR(Enumerator->NameUTF8), Enumerator->Value);
    }
    //设置枚举项数组
    NewEnum->SetEnums(EnumNames, (UEnum::ECppForm)Params.CppForm, Params.DynamicType == EDynamicType::NotDynamic);
    NewEnum->CppType = UTF8_TO_TCHAR(Params.CppTypeUTF8);  //cpp名字

    if (Params.DisplayNameFunc)
    {
        NewEnum->SetEnumDisplayNameFn(Params.DisplayNameFunc);  //设置自定义显示名字回调
    }
}

基本上代码都是不言自明的,只有两处需要注意:一是OuterFunc()的调用是用来先确保外界所属于的UPackage存在。二是UEnum的构造,源码里用了重载new的方式,注意不是placement new。这个new的方式定义在DECLARE_CLASS宏中:

/** For internal use only; use StaticConstructObject() to create new objects. */ 
inline void* operator new(const size_t InSize, EInternal InInternalOnly, UObject* InOuter = (UObject*)GetTransientPackage(), FName InName = NAME_None, EObjectFlags InSetFlags = RF_NoFlags) 
{ 
    return StaticAllocateObject(StaticClass(), InOuter, InName, InSetFlags); 
} 
/** For internal use only; use StaticConstructObject() to create new objects. */ 
inline void* operator new( const size_t InSize, EInternal* InMem ) 
{ 
    return (void*)InMem; 
}

所以是会先触发StaticAllocateObject来分配出一块对象内存(简易初始化过的,后续对象分配章节再讲解),接着才会调用UEnum的构造函数,在构造完毕后再往里面填充枚举项。

思考:为什么不用我们常用的NewObject来创建UEnum呢?

实际上,这里如果换成UEnum* NewEnum= NewObject< UEnum>(Outer, UTF8_TO_TCHAR(Params.NameUTF8), Params.ObjectFlags);也是可行的。那么这二者差在哪呢?微乎其微。二者的流程大体都是一样的,只差在FObjectInitializer的值不一样,new构造会调用FObjectInitializer()默认构造函数,而NewObject会调用FObjectInitializer(Result, InTemplate, bCopyTransientsFromClassDefaults, true, InInstanceGraph)。其中差在第4个参数bShouldInitializePropsFromArchetype,前者默认为=false,会略过构造过程中的UClass里属性值初始化的过程。因为enum里也没有属性,所以略过该过程会稍微提高点性能,但也微乎其微啦。

继续往里面看下SetEnums

bool UEnum::SetEnums(TArray<TPair<FName, int64>>& InNames, UEnum::ECppForm InCppForm, bool bAddMaxKeyIfMissing)
{
    if (Names.Num() > 0)
    {
        RemoveNamesFromMasterList();   //去除之前的名字
    }
    Names   = InNames;
    CppForm = InCppForm;
    if (bAddMaxKeyIfMissing)
    {
        if (!ContainsExistingMax())
        {
            FName MaxEnumItem = *GenerateFullEnumName(*(GenerateEnumPrefix() + TEXT("_MAX")));
            if (LookupEnumName(MaxEnumItem) != INDEX_NONE)
            {
                // the MAX identifier is already being used by another enum
                return false;
            }
            Names.Emplace(MaxEnumItem, GetMaxEnumValue() + 1);
        }
    }
    AddNamesToMasterList();
    return true;
}

代码也很简单,enum就是简单呀。有两个操作比较重要:

  1. RemoveNamesFromMasterList和AddNamesToMasterList一起维护UEnum里的static TMap<FName, UEnum*> AllEnumNames;,该数据结构可以允许我们通过一个枚举项名字来搜索到枚举类型。比如通过"MyEnum::Dance"用LookupEnumName来返回枚举的UEnum*。
  2. bAddMaxKeyIfMissing在native的情况下为true,所以在C++里定义的枚举,其实是会自动添加一项形如MyEnum::MyEnum_MAX=最大值+1,方便蓝图中使用。

然后就enum就结束啦!

UScriptStruct

依然来先看下结构的测试代码:

USTRUCT(BlueprintType)
struct HELLO_API FMyStruct
{
    GENERATED_BODY()
    UPROPERTY(BlueprintReadWrite)
    float Score;
};

UE里的结构不可继承自c++ struct,但可以继承自USTRUCT标记的struct,里面也不可以添加UFUNCTION。以上的测试代码非常简单,生成代码(为了可读性进行了一些排版调整):

class UScriptStruct* FMyStruct::StaticStruct()  //RegisterFn指向该函数
{
    static class UScriptStruct* Singleton = NULL;
    if (!Singleton)
    {
        Singleton = GetStaticStruct(Z_Construct_UScriptStruct_FMyStruct, Z_Construct_UPackage__Script_Hello(), TEXT("MyStruct"), sizeof(FMyStruct), Get_Z_Construct_UScriptStruct_FMyStruct_CRC());
    }
    return Singleton;
}

static FCompiledInDeferStruct Z_CompiledInDeferStruct_UScriptStruct_FMyStruct(FMyStruct::StaticStruct, TEXT("/Script/Hello"), TEXT("MyStruct"), false, nullptr, nullptr);     //收集点

static struct FScriptStruct_Hello_StaticRegisterNativesFMyStruct
{
    FScriptStruct_Hello_StaticRegisterNativesFMyStruct()
    {
        UScriptStruct::DeferCppStructOps(FName(TEXT("MyStruct")),new UScriptStruct::TCppStructOps<FMyStruct>);
    }
} ScriptStruct_Hello_StaticRegisterNativesFMyStruct; //收集点

struct Z_Construct_UScriptStruct_FMyStruct_Statics
{
    static void* NewStructOps() //创建结构操作辅助类
    {
        return (UScriptStruct::ICppStructOps*)new UScriptStruct::TCppStructOps<FMyStruct>();
    }
    //属性参数...
    //结构参数
    static const UE4CodeGen_Private::FStructParams ReturnStructParams= 
    {
        (UObject* (*)())Z_Construct_UPackage__Script_Hello,//Outer
        nullptr,    //构造基类的函数指针
        &NewStructOps,//构造结构操作类的函数指针
        "MyStruct",//结构名字
        RF_Public|RF_Transient|RF_MarkAsNative, //对象标记
        EStructFlags(0x00000201),   //结构标记
        sizeof(FMyStruct),//结构大小
        alignof(FMyStruct), //结构内存对齐
        PropPointers, ARRAY_COUNT(PropPointers) //属性列表
    };
};

UScriptStruct* Z_Construct_UScriptStruct_FMyStruct() //真正的构造实现
{
    static UScriptStruct* ReturnStruct = nullptr;
    if (!ReturnStruct)
    {
        UE4CodeGen_Private::ConstructUScriptStruct(ReturnStruct, Z_Construct_UScriptStruct_FMyStruct_Statics::ReturnStructParams);
    }
    return ReturnStruct;
}

同Enum的套路一样,RegisterFn指向FMyStruct::StaticStruct()函数,其内部会继续调用Z_Construct_UScriptStruct_FMyStruct。最终还是用ConstructUScriptStruct来通过参数生成UScriptStruct。而那些参数我在代码里的注释一看也都明白,结构套结构而已。

void ConstructUScriptStruct(UScriptStruct*& OutStruct, const FStructParams& Params)
{
    UObject* Outer = Params.OuterFunc ? Params.OuterFunc() : nullptr;//构造Outer
    UScriptStruct* Super = Params.SuperFunc ? Params.SuperFunc() : nullptr;//构造SuperStruct
    UScriptStruct::ICppStructOps* StructOps = Params.StructOpsFunc ? Params.StructOpsFunc() : nullptr;//构造结构操作类

    if (OutStruct) {return;}
    //构造UScriptStruct
    UScriptStruct* NewStruct = new(EC_InternalUseOnlyConstructor, Outer, UTF8_TO_TCHAR(Params.NameUTF8), Params.ObjectFlags) UScriptStruct(FObjectInitializer(), Super, StructOps, (EStructFlags)Params.StructFlags, Params.SizeOf, Params.AlignOf);
    OutStruct = NewStruct;
    //构造属性集合
    ConstructUProperties(NewStruct, Params.PropertyArray, Params.NumProperties);
    //链接
    NewStruct->StaticLink();
}

和UEnum一样的模式调用,先依次构造出依赖的Outer、Super和CppStructOps。然后依然是overload new构造出UScriptStruct。但是UScriptStruct的构造函数里多了一步调用PrepareCppStructOps(比较简单提一下,主要是从CppStructOps提出特征然后存在StructFlags里)。 接着的ConstructUProperties是个从属性参数数组里构造出UProperty*数组,在后面构造UClass*的时候也会复用调用到。因此留待后文一起讲解。 所有复合类型(继承于UStruct)都会接着调用StaticLink来链接子属性。关于Link的作用在后文讲解。

ICppStructOps的作用

很多朋友在看源码的时候,可能会对UScriptStruct里定义的ICppStructOps类以及模板子类TCppStructOps<CPPSTRUCT>感到疑惑。其实它们是C++的一种常见的架构模式,用一个虚函数基类定义一些公共操作,再用一个具体模板子类来实现,从而既可以保存类型,又可以有公共操作接口。

针对于UE4这里来说,ICppStructOps就定义了这个结构的一些公共操作。而探测这个C++结构的一些特性就交给了TCppStructOps<CPPSTRUCT>类里的TStructOpsTypeTraits<CPPSTRUCT>。一些C++结构的信息不能通过模板探测出来的,就需要我们手动标记提供了,所以具体的代码是:

template <class CPPSTRUCT>
struct TStructOpsTypeTraitsBase2
{
    enum
    {
        WithZeroConstructor = false, // 0构造,内存清零后就可以了,说明这个结构的默认值就是0
        WithNoInitConstructor = false, // 有个ForceInit的参数的构造,用来专门构造出0值结构来
        WithNoDestructor = false, // 是否没有结构有自定义的析构函数, 如果没有析构的话,DestroyStruct里面就可以省略调用析构函数了。默认是有的。结构如果是pod类型,则肯定没有析构。
        WithCopy = !TIsPODType<CPPSTRUCT>::Value, // 是否结构有自定义的=赋值函数。如果没有的话,在CopyScriptStruct的时候就只需要拷贝内存就可以了
        WithIdenticalViaEquality = false, // 用==来比较结构
        WithIdentical = false, // 有一个自定义的Identical函数来专门用来比较,和WithIdenticalViaEquality互斥
        WithExportTextItem = false, // 有一个ExportTextItem函数来把结构值导出为字符串
        WithImportTextItem = false, // 有一个ImportTextItem函数把字符串导进结构值
        WithAddStructReferencedObjects = false, // 有一个AddStructReferencedObjects函数用来添加结构额外的引用对象
        WithSerializer = false, // 有一个Serialize函数用来序列化
        WithStructuredSerializer = false, // 有一个结构结构Serialize函数用来序列化
        WithPostSerialize = false, // 有一个PostSerialize回调用来在序列化后调用
        WithNetSerializer = false, // 有一个NetSerialize函数用来在网络复制中序列化
        WithNetDeltaSerializer = false, // 有一个NetDeltaSerialize函数用来在之前NetSerialize的基础上只序列化出差异来,一般用在TArray属性上进行优化
        WithSerializeFromMismatchedTag = false, // 有一个SerializeFromMismatchedTag函数用来处理属性tag未匹配到的属性值,一般是在结构进行升级后,但值还是原来的值,这个时候用来把旧值升级到新结构时使用
        WithStructuredSerializeFromMismatchedTag = false, // SerializeFromMismatchedTag的结构版本
        WithPostScriptConstruct = false,// 有一个PostScriptConstruct函数用在蓝图构造脚本后调用
        WithNetSharedSerialization = false, // 指明结构的NetSerialize函数不需要用到UPackageMap
    };
};
template<class CPPSTRUCT>
struct TStructOpsTypeTraits : public TStructOpsTypeTraitsBase2<CPPSTRUCT>
{
};

这些枚举值定义了一个结构的特性,我已经在源码里一一解释了。 说回ICppStructOps里的接口,内部实现大部分都是通过TStructOpsTypeTraits<CPPSTRUCT>的结构来分别调用不同版本的函数。结构的操作可以分为:

  • 构造:HasNoopConstructor、HasZeroConstructor、Construct、HasPostScriptConstruct、PostScriptConstruct、IsAbstract
  • 析构:HasDestructor、Destruct
  • 拷贝:IsPlainOldData、HasCopy、Copy
  • 比较:HasIdentical、Identical
  • 导入导出:HasExportTextItem、ExportTextItem、HasImportTextItem、ImportTextItem
  • GC:HasAddStructReferencedObjects、AddStructReferencedObjects
  • 序列化:HasSerializer、HasStructuredSerializer、Serialize、HasPostSerialize、PostSerialize、HasNetSerializer、HasNetSharedSerialization、NetSerialize、HasNetDeltaSerializer、NetDeltaSerialize、HasSerializeFromMismatchedTag、HasStructuredSerializeFromMismatchedTag、SerializeFromMismatchedTag、StructuredSerializeFromMismatchedTag

有了ICppStructOps的公共接口,和上面特化的信息,UE4在内部对结构进行构造析构或序列化的时候,就可以选择最优的步骤(比如拷贝的时候只需要直接拷贝内存而不需要调用赋值函数),在GC的时候,也可以告诉UE4这个结构内部有可能有额外的UObject*对象。从而让UE4实现对该结构实现出更高的性能。

比如对于常见的FVector来说,源码里就定义了这么一个特化来描述其特性:

template<>
struct TStructOpsTypeTraits<FVector> : public TStructOpsTypeTraitsBase2<FVector>
{
    enum 
    {
        WithNoInitConstructor = true,
        WithZeroConstructor = true,
        WithNetSerializer = true,
        WithNetSharedSerialization = true,
        WithSerializer = true,
    };
};

一看我们就知道FVector有个0值构造函数,和有Serializer函数。对于我们自己定义的UStruct,如果有需要,也可以定义这么一个模板特化,来更详细的提供结构信息。

UClass

接着是我们自己最常用的Class和Interface,一起梭哈一把:

UINTERFACE(BlueprintType, Blueprintable)
class HELLO_API UMyInterface:public UInterface
{
    GENERATED_BODY()
};
class IMyInterface
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
        void NativeInterfaceFunc();

    UFUNCTION(BlueprintCallable, BlueprintImplementableEvent)
        void ImplementableInterfaceFunc();
};

UCLASS(BlueprintType, Blueprintable)
class HELLO_API UMyClass :public UObject, public IMyInterface
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadWrite)
        float Score;
public:
    UFUNCTION(BlueprintCallable, Category = "Hello")
        int32 Func(float param1);    //C++实现,蓝图调用

    UFUNCTION(BlueprintNativeEvent, Category = "Hello")
        void NativeFunc();  //C++实现默认版本,蓝图可重载实现

    UFUNCTION(BlueprintImplementableEvent, Category = "Hello")
        void ImplementableFunc();   //C++不实现,蓝图实现
};

生成的代码的关键部分(做了些排版调整):

//函数参数...
struct Z_Construct_UClass_UMyClass_Statics
{
    //依赖项列表
    static UObject* (*const DependentSingletons[])()=
    {
        (UObject* (*)())Z_Construct_UClass_UObject,//依赖基类UObject
        (UObject* (*)())Z_Construct_UPackage__Script_Hello,//依赖所属于的Hello模块
    };
    //属性参数...
    //函数参数...
    //接口
    static const UE4CodeGen_Private::FImplementedInterfaceParams InterfaceParams[]= 
    {
        {
            Z_Construct_UClass_UMyInterface_NoRegister,//构造UMyInterface所属的UClass*函数指针
            (int32)VTABLE_OFFSET(UMyClass, IMyInterface),//多重继承的指针偏移
            false   //是否是在蓝图实现
        }
    };

    static const FCppClassTypeInfoStatic StaticCppClassTypeInfo= {
        TCppClassTypeTraits<UMyClass>::IsAbstract,//c++类信息,是否是虚类
    };

    static const UE4CodeGen_Private::FClassParams ClassParams = 
    {
        &UMyClass::StaticClass,//取出UClass*的函数指针
        DependentSingletons, ARRAY_COUNT(DependentSingletons),//依赖项
        0x001000A0u,//类标志
        FuncInfo, ARRAY_COUNT(FuncInfo),//函数列表
        PropPointers, ARRAY_COUNT(PropPointers),//属性列表
        nullptr,//Config文件名
        &StaticCppClassTypeInfo,//c++类信息
        InterfaceParams, ARRAY_COUNT(InterfaceParams)//接口列表
    };
};

UClass* Z_Construct_UClass_UMyClass()
{
    static UClass* OuterClass = nullptr;
    if (!OuterClass)
    {
        UE4CodeGen_Private::ConstructUClass(OuterClass, Z_Construct_UClass_UMyClass_Statics::ClassParams);
    }
    return OuterClass;
}

IMPLEMENT_CLASS(UMyClass, 4008851639); //收集点
static FCompiledInDefer Z_CompiledInDefer_UClass_UMyClass(Z_Construct_UClass_UMyClass, &UMyClass::StaticClass, TEXT("/Script/Hello"), TEXT("UMyClass"), false, nullptr, nullptr, nullptr); //收集点

Class的信息就比较多了,可以拥有接口、属性和函数。属性和函数在后文讲解,多出来的MyInterface,因为也是个UObject类,所以其实也会对UMyInterface生成一个UClass*对象并添加函数,和UMyClass的模式是一样的,因此不再赘述。但UMyClass因为继承于IMyInterface,因此就要在UMyClass的UClass*里添加额外的接口继承信息。C++里实现interface是用多继承,因此就有对象基类指针偏移的问题(不懂这个的同学请自觉补C++基础的课),于是FImplementedInterfaceParams里每一项就有一个用VTABLE_OFFSET获取的指针偏移,有了Offset就可以根据Obj+Offset来获取IMyInterface*地址,从而调用接口函数,这也是GetInterfaceAddress的逻辑实现。

FCppClassTypeInfoStatic的作用其实和结构的ICppStructOps差不多,都是识别原生C++里的类型信息,但UClass毕竟和UStruct的作用不一样,没有那些纯内存构造析构的操作,也都必然有序列化器,所以FCppClassTypeInfoStatic里目前只有一项bIsAbstract来判断是否该类是虚类。

构造的代码(做了些排版和易读调整):

void ConstructUClass(UClass*& OutClass, const FClassParams& Params)
{
    if (OutClass && (OutClass->ClassFlags & CLASS_Constructed)) {return;}  //防止重复构造
    for(int i=0;i<Params.NumDependencySingletons;++i)
    {
        Params.DependencySingletonFuncArray[i]();   //构造依赖的对象
    }

    UClass* NewClass = Params.ClassNoRegisterFunc();    //取得先前生成的UClass*,NoRegister是指没有经过DeferRegister
    OutClass = NewClass;

    if (NewClass->ClassFlags & CLASS_Constructed) {return;}//防止重复构造

    UObjectForceRegistration(NewClass); //确保此UClass*已经注册

    NewClass->ClassFlags |= (EClassFlags)(Params.ClassFlags | CLASS_Constructed);//标记已经构造

    if ((NewClass->ClassFlags & CLASS_Intrinsic) != CLASS_Intrinsic)
    {
        check((NewClass->ClassFlags & CLASS_TokenStreamAssembled) != CLASS_TokenStreamAssembled);
        NewClass->ReferenceTokenStream.Empty();//对于蓝图类需要重新生成一下引用记号流
    }
    //构造函数列表
    NewClass->CreateLinkAndAddChildFunctionsToMap(Params.FunctionLinkArray, Params.NumFunctions);
    //构造属性列表
    ConstructUProperties(NewClass, Params.PropertyArray, Params.NumProperties);

    if (Params.ClassConfigNameUTF8)
    {   //配置文件名
        NewClass->ClassConfigName = FName(UTF8_TO_TCHAR(Params.ClassConfigNameUTF8));
    }

    NewClass->SetCppTypeInfoStatic(Params.CppClassInfo);//C++类型信息

    if (Params.NumImplementedInterfaces)
    {
        NewClass->Interfaces.Reserve(Params.NumImplementedInterfaces);
        for(int i=0;i<Params.Params.NumImplementedInterfaces;++i)
        {
            const auto& ImplementedInterface = Params.ImplementedInterfaceArray[i];
            UClass* (*ClassFunc)() = ImplementedInterface.ClassFunc;
            UClass* InterfaceClass = ClassFunc ? ClassFunc() : nullptr;//取得UMyInterface所属于的UClass*对象

            NewClass->Interfaces.Emplace(InterfaceClass, ImplementedInterface.Offset, ImplementedInterface.bImplementedByK2);//添加实现的接口
        }
    }

    NewClass->StaticLink();//链接
}

构造的过程也很简明,无非是先确保一下依赖对象已经存在,然后一一把各种信息:函数、属性、配置文件名、C++类型信息和接口添加到UClass*里去。重要的只有三步:ConstructUProperties和UScriptStruct的时候一样;接口的实现是通过UClass*里的TArray<FImplementedInterface> Interfaces数组表达的;CreateLinkAndAddChildFunctionsToMap创造函数列表。

void UClass::CreateLinkAndAddChildFunctionsToMap(const FClassFunctionLinkInfo* Functions, uint32 NumFunctions)
{
    for (; NumFunctions; --NumFunctions, ++Functions)
    {
        const char* FuncNameUTF8 = Functions->FuncNameUTF8;
        UFunction*  Func         = Functions->CreateFuncPtr();//调用构造UFunction*对象

        Func->Next = Children;
        Children = Func;//新函数挂在UField*链表的开头

        AddFunctionToFunctionMap(Func, FName(UTF8_TO_TCHAR(FuncNameUTF8)));
        //内部实现是:FuncMap.Add(FuncName, Function);添加到FuncMap里
    }
}

一个个创建UFunction*对象然后添加到FuncMap里去就是了。这里有意思的一点是,Children其实一个UField*的单链表,添加属性和函数都是直接挂在链表头的。通过构造顺序(CreateLinkAndAddChildFunctionsToMap先,ConstructUProperties次之)可以得知,最后Children的顺序是先所有UProperty*,之后才是所有的UFunction*。但UProperty*的顺序跟代码里定义的顺序一致,因为UHT生成的代码里PropPointers里恰好是倒序排列的。而UFunction*的顺序是根据函数名字排序后的反序。

UPackage

我们注意到在构造UEnum、UScriptScript和UClass的一开始都有一个构造Outer的过程,这个OuterFunc其实指向了这个Module的Package的构造,这个函数一般在模块的init.gen.cpp里。

//Hello.init.gen.cpp
UPackage* Z_Construct_UPackage__Script_Hello()
{
    static UPackage* ReturnPackage = nullptr;
    if (!ReturnPackage)
    {   //先构造代码里的Dynamic Delegate
        static UObject* (*const SingletonFuncArray[])() = {
            (UObject* (*)())Z_Construct_UDelegateFunction_Hello_MyDynamicSinglecastDelegate_One__DelegateSignature,
            (UObject* (*)())Z_Construct_UDelegateFunction_Hello_MyDynamicMulticastDelegate_One__DelegateSignature,
        };
        static const UE4CodeGen_Private::FPackageParams PackageParams = {
            "/Script/Hello",
            PKG_CompiledIn | 0x00000000,
            0x135222B4,
            0x392E1CFD,
            SingletonFuncArray, ARRAY_COUNT(SingletonFuncArray),
            METADATA_PARAMS(nullptr, 0)
        };
        UE4CodeGen_Private::ConstructUPackage(ReturnPackage, PackageParams);//构造Pacakge
    }
    return ReturnPackage;
}

//DECLARE_DYNAMIC_DELEGATE_OneParam(FMyDynamicSinglecastDelegate_One, int32, Value);

唯一需要提醒的是对于代码里定义的动态委托(DynamicDelegate),其实是会在相应地生成UFunction*对象。这些散乱的DynamicDelegate是可以定义在类外面的,因此就不属于任一UClass*对象,所以就只好属于模块Package了。 构造代码也比较简单:

void ConstructUPackage(UPackage*& OutPackage, const FPackageParams& Params)
{
    if (OutPackage) {return;}

    UPackage* NewPackage = CastChecked<UPackage>(StaticFindObjectFast(UPackage::StaticClass(), nullptr, FName(UTF8_TO_TCHAR(Params.NameUTF8)), false, false));//找到之前创建的Package
    OutPackage = NewPackage;

    NewPackage->SetPackageFlags(Params.PackageFlags);//设定标记
    NewPackage->SetGuid(FGuid(Params.BodyCRC, Params.DeclarationsCRC, 0u, 0u));

    for (UObject* (*const *SingletonFunc)() = Params.SingletonFuncArray, *(*const *SingletonFuncEnd)() = SingletonFunc + Params.NumSingletons; SingletonFunc != SingletonFuncEnd; ++SingletonFunc)
    {
        (*SingletonFunc)();//调用构造前提对象
    }
}

需要注意的是ConstructUPackage这个时候,其实UPackage在之前UClass*对象Register的时候就已经Create出来了,所以只需要查找一下就行,然后设定一下信息标记,最后创建这个UPackage里的UFunction*对象。

UProperty

终于在前面讲完了大块头的构造,接着来说一下小小的属性。

//一个个属性的参数
static const UE4CodeGen_Private::FFloatPropertyParams NewProp_Score = 
{ 
    UE4CodeGen_Private::EPropertyClass::Float, 
    "Score", 
    RF_Public|RF_Transient|RF_MarkAsNative,//对象标记
    (EPropertyFlags)0x0010000000000004, //属性标记
    1,  //数组维度,固定的数组的大小
    nullptr, //RepNotify函数的名称
    STRUCT_OFFSET(FMyStruct, Score) //属性的结构偏移地址
};
 //结构参数数组,会发送给ConstructUProperties来构造。
static const UE4CodeGen_Private::FPropertyParamsBase* const PropPointers[] = 
{
    &NewProp_Score
};

ConstructUProperties其实就是会遍历数组调用ConstructUProperty:

void ConstructUProperty(UObject* Outer, const FPropertyParamsBase* const*& PropertyArray, int32& NumProperties)
{
    const FPropertyParamsBase* PropBase = *PropertyArray++;
    uint32 ReadMore = 0;
    UProperty* NewProp = nullptr;
    switch (PropBase->Type)
    {
       case EPropertyClass::Array:
        {
            const FArrayPropertyParams* Prop = (const FArrayPropertyParams*)PropBase;
            NewProp = new (EC_InternalUseOnlyConstructor, Outer, UTF8_TO_TCHAR(Prop->NameUTF8), Prop->ObjectFlags) UArrayProperty(FObjectInitializer(), EC_CppProperty, Prop->Offset, Prop->PropertyFlags);//构造Property对象

            // Next property is the array inner
            ReadMore = 1;//需要一个子属性
        }
        break;
        //case其他的各种类型属性
    }

    NewProp->ArrayDim = PropBase->ArrayDim;//设定属性维度,单属性为1,int32 prop[10]这种的为10
    if (PropBase->RepNotifyFuncUTF8)
    {   //属性的复制通知函数名
        NewProp->RepNotifyFunc = FName(UTF8_TO_TCHAR(PropBase->RepNotifyFuncUTF8));
    }

    --NumProperties;
    for (; ReadMore; --ReadMore)
    {   //构造子属性,注意这里以现在的属性NewProp为Outer
        ConstructUProperty(NewProp, PropertyArray, NumProperties);
    }
}

UHT会分析我们代码里的定义的属性类型,来生成不同的FPropertyParams类型。根据不同的EPropertyClass,构造生成不同类型的UProperty*。同时对于一些复合的属性类型,需要生成1或2个子属性。

这个表提供给大家对照。还有点需要注意的是UProperty的构造函数里会把自己添加到Outer里去:

void UProperty::Init()
{
    GetOuterUField()->AddCppProperty(this);//AddCppProperty是个虚函数
}
//重载
void UArrayProperty::AddCppProperty( UProperty* Property )
{
    Inner = Property;   //元素属性
}
void UMapProperty::AddCppProperty( UProperty* Property )
{
    if (!KeyProp) {KeyProp = Property;}//第一个是键属性
    else {ValueProp = Property;}//第二个是值属性
}
void USetProperty::AddCppProperty( UProperty* Property )
{
    ElementProp = Property;//元素属性
}

void UEnumProperty::AddCppProperty(UProperty* Inner)
{
    UnderlyingProp = CastChecked<UNumericProperty>(Inner);//依靠的整数属性
}
void UStruct::AddCppProperty( UProperty* Property )
{
    Property->Next = Children;
    Children       = Property;//新属性挂在UField*链表的开头
}

UFunction

属性说完,再来宠幸一下函数吧。测试代码请往上滚滚滚去MyClass里查看。

//测试函数:int32 Func(float param1);
void UMyClass::ImplementableFunc()  //UHT为我们生成了函数实体
{
    ProcessEvent(FindFunctionChecked("ImplementableFunc"),NULL);
}
void UMyClass::NativeFunc() //UHT为我们生成了函数实体,但我们可以自定义_Implementation
{
    ProcessEvent(FindFunctionChecked("NativeFunc"),NULL);
}
void UMyClass::StaticRegisterNativesUMyClass()  //之前的Native函数收集点
{
    UClass* Class = UMyClass::StaticClass();
    static const FNameNativePtrPair Funcs[] = {
        { "Func", &UMyClass::execFunc },
        { "NativeFunc", &UMyClass::execNativeFunc },
    };
    FNativeFunctionRegistrar::RegisterFunctions(Class, Funcs, ARRAY_COUNT(Funcs));
}
struct Z_Construct_UFunction_UMyClass_Func_Statics
{
    struct MyClass_eventFunc_Parms  //把所有参数打包成一个结构来存储
    {
        float param1;
        int32 ReturnValue;
    };
    static const UE4CodeGen_Private::FIntPropertyParams NewProp_ReturnValue= 
    { 
        UE4CodeGen_Private::EPropertyClass::Int, 
        "ReturnValue", 
        RF_Public|RF_Transient|RF_MarkAsNative,
        (EPropertyFlags)0x0010000000000580,
        1, 
        nullptr, 
        STRUCT_OFFSET(MyClass_eventFunc_Parms, ReturnValue) 
    };

    static const UE4CodeGen_Private::FFloatPropertyParams NewProp_param1 =
    { 
        UE4CodeGen_Private::EPropertyClass::Float,
        "param1", 
        RF_Public|RF_Transient|RF_MarkAsNative, 
        (EPropertyFlags)0x0010000000000080, 
        1,
        nullptr, 
        STRUCT_OFFSET(MyClass_eventFunc_Parms, param1) 
    };
    //函数的子属性
    static const UE4CodeGen_Private::FPropertyParamsBase* const PropPointers[]= 
    {
        &NewProp_ReturnValue,   //返回值也用属性表示
        &NewProp_param1,        //参数用属性表示
    };
    //函数的参数
    static const UE4CodeGen_Private::FFunctionParams FuncParams=
    { 
        (UObject*(*)())Z_Construct_UClass_UMyClass, //外部对象
        "Func", //名字
        RF_Public|RF_Transient|RF_MarkAsNative, //对象标记
        nullptr, //父函数,在蓝图中重载基类函数时候指向基类函数版本
        (EFunctionFlags)0x04020401, //函数标记
        sizeof(MyClass_eventFunc_Parms),//属性的结构大小
        PropPointers, ARRAY_COUNT(PropPointers),//属性列表
        0,  //RPCId
        0   //RPCResponseId
    };
};

UFunction* Z_Construct_UFunction_UMyClass_Func()
{
    static UFunction* ReturnFunction = nullptr;
    if (!ReturnFunction)
    {   //构造函数
        UE4CodeGen_Private::ConstructUFunction(ReturnFunction, Z_Construct_UFunction_UMyClass_Func_Statics::FuncParams);
    }
    return ReturnFunction;
}

//其他函数...
static const FClassFunctionLinkInfo FuncInfo[]= //发给ClassParams来构造UClass*
{
    { &Z_Construct_UFunction_UMyClass_Func, "Func" }, // 2606493682
    { &Z_Construct_UFunction_UMyClass_ImplementableFunc, "ImplementableFunc" }, // 3752866266
    { &Z_Construct_UFunction_UMyClass_NativeFunc, "NativeFunc" }, // 3036938731
}; 
//接口函数...
void IMyInterface::Execute_ImplementableInterfaceFunc(UObject* O)
{   //通过名字查找函数
    UFunction* const Func = O->FindFunction("ImplementableInterfaceFunc");
    if (Func)
    {
        O->ProcessEvent(Func, NULL);
    }//找不到,其实不会报错,所以是在尝试调用一个接口函数
}
void IMyInterface::Execute_NativeInterfaceFunc(UObject* O)
{   //通过名字查找函数
    UFunction* const Func = O->FindFunction("NativeInterfaceFunc");
    if (Func)
    {
        O->ProcessEvent(Func, NULL);
    }
    else if (auto I = (IMyInterface*)(O->GetNativeInterfaceAddress(UMyInterface::StaticClass())))
    {   //如果找不到蓝图中的版本,则会尝试调用C++里的_Implementation默认实现。
        I->NativeInterfaceFunc_Implementation();
    }
}

代码有点多,但其实结构还是很简单的。函数的参数和返回值都打包成一个结构(MyClass_eventFunc_Parms),这样UProperty才能有一个Offset的宿主。但有意思的是,其实这个结构(MyClass_eventFunc_Parms)我们只需要它的大小,并不需要它的实际类型定义,因为只要用它分配一块内存布局一致的内存就可以了。

我们可以看见,UHT为我们生成了一些函数的默认实现,如ImplementableFunc何NativeFunc,还有接口里的函数,所以我们不应该在C++里再重复实现(写了也会报错)。它们的内部实现也都是通过ProcessEvent来调用蓝图中的版本,或者调用C++里的_Implementation默认版本实现。 开始构造的过程如下:

void ConstructUFunction(UFunction*& OutFunction, const FFunctionParams& Params)
    {
        UObject*   Outer = Params.OuterFunc ? Params.OuterFunc() : nullptr;
        UFunction* Super = Params.SuperFunc ? Params.SuperFunc() : nullptr;

        if (OutFunction) {return;}

        if (Params.FunctionFlags & FUNC_Delegate)   //生成委托函数
        {
            OutFunction = new (EC_InternalUseOnlyConstructor, Outer, UTF8_TO_TCHAR(Params.NameUTF8), Params.ObjectFlags) UDelegateFunction(
                FObjectInitializer(),
                Super,
                Params.FunctionFlags,
                Params.StructureSize
            );
        }
        else
        {
            OutFunction = new (EC_InternalUseOnlyConstructor, Outer, UTF8_TO_TCHAR(Params.NameUTF8), Params.ObjectFlags) UFunction(
                FObjectInitializer(),
                Super,
                Params.FunctionFlags,
                Params.StructureSize
            );
        }

        ConstructUProperties(OutFunction, Params.PropertyArray, Params.NumProperties);//生成属性列表
        OutFunction->Bind();//函数绑定
        OutFunction->StaticLink();//函数链接
    }

构造的老套路是生成依赖,然后再new出对象,UFunction生成UProperty列表当做参数和返回值,最后再依次Bind和Link。其中一个function的SuperFunc表达的其实是函数的从基类继承重载下来,它其实指向的是基类里的函数版本。但在C++里其实一直是nullptr,因为C++里继承一个UFUNCION,不允许子类重复加UFUNCION标记,避免出现UFUNCION里标记不一致的情况,但是因为是virtual,所以其实也是可以正常多态的。那什么时候UFunction* GetSuperFunction()的才有值呢?答案是在蓝图里使用的时候,蓝图里所有函数其实都是UFunction,所以在继承重载的时候,子类函数的SuperFunction就会指向基类的版本。如我们新创建一个BPMyActor继承自AActor,就会默认出现4个重载函数:ReceiveBeginPlay、ReceiveTick、ReceiveActorBeginOverlap、UserConstructionScript,它们的Outer其实是基类版本的同名UFunction*(其他的是属于外部的UStruct或UPacakge)。也正是因为如此,UE知道这个函数是重载下来的,所以在该蓝图节点上点右键,才有“AddParentCall”啊。

绑定链接

事实上在构造完各类型对象后,还需要再梳理一遍,完成一些后初始化工作。跟C++的编译机制有点像,最后一步都是链接,通过符号定位到函数地址来替换。UE里也一样,需要这么一个绑定链接操作。之前的时候有谈到Bind和StaticLink都略过了,这时再来讲解一下内部的操作。

Bind

绑定的作用是把函数指针绑定到正确的地址!

Bind其实是定义在UField里的方法:virtual void Bind(){},代表所有的字段都可能需要重载这么一个绑定操作,只不过事实上只有UFunction和UClass才有这两个操作。

UFunction::Bind()的目的是把FNativeFuncPtr Func绑定到正确的函数指针里去。

void UFunction::Bind()
{
    UClass* OwnerClass = GetOwnerClass();
    if (!HasAnyFunctionFlags(FUNC_Native))
    {
        Func = &UObject::ProcessInternal;   //非native函数指向蓝图调用
    }
    else
    {
        FName Name = GetFName();    //在之前注册的naive函数表里去查找函数指针
        FNativeFunctionLookup* Found = OwnerClass->NativeFunctionLookupTable.FindByPredicate([=](const FNativeFunctionLookup& NativeFunctionLookup){ return Name == NativeFunctionLookup.Name; });
        if (Found)
        {
            Func = Found->Pointer;  //定位到c++代码里的函数指针。
        }
    }
}

UClass的Bind在编译蓝图和载入Package里的类才需要调用,因为native的类在之前的GetPrivateStaticClassBody的时候已经传进去了函数指针。只有没有C++代码实体的类才需要绑定到基类里的构造函数等才能正确正确继承下来这些函数来调用。

void UClass::Bind()
{
    UStruct::Bind();
    UClass* SuperClass = GetSuperClass();
    if (SuperClass && 
            (ClassConstructor == nullptr || 
            ClassAddReferencedObjects == nullptr || 
            ClassVTableHelperCtorCaller == nullptr)
        )
    {
        SuperClass->Bind();//确保基类已经绑定
        if (!ClassConstructor)
        {
            ClassConstructor = SuperClass->ClassConstructor;//绑定构造函数指针
        }
        if (!ClassVTableHelperCtorCaller)
        {
            ClassVTableHelperCtorCaller = SuperClass->ClassVTableHelperCtorCaller;//绑定热载函数指针
        }
        if (!ClassAddReferencedObjects)
        {
            ClassAddReferencedObjects = SuperClass->ClassAddReferencedObjects;//绑定ARO函数指针
        }

        ClassCastFlags |= SuperClass->ClassCastFlags;
    }
}

绑定的这三个函数和GetPrivateStaticClassBody里传进来的一样。

Link

在构造UScriptStuct和UClass的最后一步都调用了StaicLink,它其实是UStruct的一个方法,包装了一个空的序列化归档类对象后转发到UStruct::Link函数上去。

void UStruct::StaticLink(bool bRelinkExistingProperties /*= false*/)
{
    FArchive ArDummy;   //一个空的序列化归档类
    Link(ArDummy, bRelinkExistingProperties);
}

UStruct::Link又是一个虚函数,在很多的子类中都有重载。StaicLink也在很多地方有调用。 Link这个词其实有三层意思:

  1. 跟编译器的Link一样,编译完成后的最后一个操作链接,替换符号地址等。典型的是在结构改变或者编译后重新Link。
  2. 把子字段们按照属性特征分成一个个链条,如RefLink。
  3. 序列化的时候也有概念Link,用来充当磁盘和内存里对象的链接桥梁。同样,在一个保存在磁盘里的类型被序列化出来之后,就需要再Link一下来重新设置属性的偏移,结构内存对齐等。这也是Link需要一个FArchive参数的原因。

UProperty里其实也有一个Link,分为LinkInternal和SetupOffset,LinkInternal主要是用来根据属性特征再设置一下PropertyFlags,而SetupOffset是用来在序列化后设置属性内存偏移。这部分比较散乱,就请读者朋友们自己查看了。 而重点的是UStruct::Link

void UStruct::Link(FArchive& Ar, bool bRelinkExistingProperties)
{
    for (UField* Field=Children; (Field != NULL) && (Field->GetOuter() == this); Field = Field->Next)
    {
        if (UProperty* Property = dynamic_cast<UProperty*>(Field))
        {
            Property->LinkWithoutChangingOffset(Ar);//对所有属性先Link一下。
        }
    }
    UProperty** PropertyLinkPtr = &PropertyLink;
    UProperty** DestructorLinkPtr = &DestructorLink;
    UProperty** RefLinkPtr = (UProperty**)&RefLink;
    UProperty** PostConstructLinkPtr = &PostConstructLink;
    TArray<const UStructProperty*> EncounteredStructProps;
    for (TFieldIterator<UProperty> It(this); It; ++It)  //遍历出所有属性
    {
        UProperty* Property = *It;
        if (Property->ContainsObjectReference(EncounteredStructProps) || Property->ContainsWeakObjectReference())
        {
            *RefLinkPtr = Property;//包含对象引用的属性
            RefLinkPtr = &(*RefLinkPtr)->NextRef;
        }
        const UClass* OwnerClass = Property->GetOwnerClass();
        bool bOwnedByNativeClass = OwnerClass && OwnerClass->HasAnyClassFlags(CLASS_Native | CLASS_Intrinsic);
        if (!Property->HasAnyPropertyFlags(CPF_IsPlainOldData | CPF_NoDestructor) &&
            !bOwnedByNativeClass) // these would be covered by the native destructor
        {   
            *DestructorLinkPtr = Property;//需要额外析构的属性
            DestructorLinkPtr = &(*DestructorLinkPtr)->DestructorLinkNext;
        }
        if (OwnerClass && (!bOwnedByNativeClass || (Property->HasAnyPropertyFlags(CPF_Config) && !OwnerClass->HasAnyClassFlags(CLASS_PerObjectConfig))))
        {
            *PostConstructLinkPtr = Property;//需要从CDO中获取初始值的属性
            PostConstructLinkPtr = &(*PostConstructLinkPtr)->PostConstructLinkNext;
        }
        *PropertyLinkPtr = Property;//所有属性
        PropertyLinkPtr = &(*PropertyLinkPtr)->PropertyLinkNext;
    }
    *PropertyLinkPtr = nullptr;
    *DestructorLinkPtr = nullptr;
    *RefLinkPtr = nullptr;
    *PostConstructLinkPtr = nullptr;
}

看起来有点乱,各种标记的判断,但其实只是把之前AddCppProperty添加到UField* Children里的字段们,抽出UProperty们,然后再串成4个链条:

  1. PropertyLink:所有的属性
  2. RefLink:包含对象引用(UObject*)的属性,这些属性对GC有影响,所以单独分类出来加速分析。
  3. PostConstructLink:所有需要从CDO中获取初始值的属性,属性可以从Config文件中或者CDO中获取初始值,因此在序列化后需要初始化一下属性们的值。
  4. DestructorLink:需要额外析构的属性,在析构的时候,需要去调用一下属性的析构函数。否则的话,如一个int属性,就不用管它,释放内存就可以了。

单独分类出来这4个链条也是为了在不同的应用场景下加速性能,不需要每次去遍历所有的属性。UFunction本身也是个UStruct,它的Link的之后会调用InitializeDerivedMembers来算一下参数和返回值的信息偏移而已。

总结

篇幅太长了都……但没办法,不一鼓作气的把它们都串起来也不好拆分。我知道,阅读文章时,最不喜欢看到贴大段的代码。但读者朋友们如果想对类型系统的组织形式有个更深的了解,还请对照着UE的源码,一点点加深理解。我们最后来梳理一下:

类型系统结构:

整个的类型系统结构还是挺明了的,UMetaData之前一直略去,其实是把宏标记里的那些信息采集起来关联到对象身上,在编辑器里使用。UUserDefinedEnum、UUUserDefinedStruct和UBlueprintGeneratedClass都是蓝图里定义编译出来的枚举结构和类。UDynamicClass是蓝图Native化后生成的类,FClass是UHT分析过程产生的类。

属性就有各种类型了,简直是各开花:

我也是敬业,把所有属性都列了出来。UE为了在这么多属性之间尽量复用接口,用了多继承的方式。每个属性的实现还是挺简单的,有兴趣的读者朋友们可以到源码里一观。有些属性我们在后面的GC章节也还会涉及到。

类型系统到这也就算是构造完了,我们在下篇来汇总总结一下。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值