ai蓝图 unreal
2016-08-11 18:13:11 sgnyyy 阅读数 609
2019-04-27 10:59:52 Neil3D 阅读数 272

Unreal的蓝图和C++一样,也是一种静态类型的编程语言,它又不像其他静态类型语言那样支持模板,有些时候就觉得很不方便。思考了一下这个问题。想要蓝图节点支持任意类型的参数,主要分为两种情况:

  • UObject派生类对象:那很简单了,使用基类指针作为参数就好,在C++里面可以Cast,或者取得对象的UClass,就可以根据反射信息做很多事了;
  • Struct类型,或者TArray<MyStruct>类型:这个是本文的重点。

其实说蓝图完全不支持“模板”也是不对的,引擎中其实已经有很多能够处理任意Struct或者TArray<MyStruct>类型的节点了!官方文档中把这种情况叫做参数“Wildcard”(通配符)。感谢Unreal开源,通过阅读源代码,加上一点实验,就能够搞清楚具体实现方法和背后的细节。

下面主要探讨使用UFUNCTION的CustomThunk描述符,实现自定义的Thunk函数;然后通过指定meta的CustomStructureParamArrayParm参数,来实现参数类型“通配符”!这中间的难点是:需要明确蓝图Stack的处理方式。Demo如下图所示:
在这里插入图片描述

在上图的Demo中:

  1. 自定义了一个蓝图Struct:MyStruct
  2. 使用C++实现了一个蓝图节点“Show Struct Fields”:可以接受任意UStruct的引用,具体类型可以由C++或者蓝图定义;
  3. 蓝图节点“Array Numeric Field Average”:可以接受任意类型的TArray<MyStruct>,并对数组中指定的数值型字段求平均;

完整的Demo工程可以从我的GitHub下载:https://github.com/neil3d/UnrealCookBook

实现蓝图功能节点的几种方式

在Unreal开发中可以使用C++对蓝图进行扩展,生成Unreal蓝图节点最方便的方法就是写一个UFUNCTION,无论是定义在UBlueprintFunctionLibrary派生类里面的static函数,还是定义在UObject、AActor派生类里面的类成员函数,只要加上UFUNCTION宏修饰,并在宏里面添加BlueprintCallable标识符,就可以自动完成蓝图编辑节点、蓝图节点执行调用的整个过程。不过,由于C++和蓝图都属于“静态类型”编程语言,这种形式编写的蓝图节点,所有的输入、输出参数的类型都必须是固定的,这样引擎才能自动处理蓝图虚拟机的栈。

先来总结一下C++实现蓝图节点的几种方式:

  1. UFUNCTION,上面已经说过了;
  2. 实现class UK2Node的派生类,这是最强大的方式,是对蓝图节点最深入的定制开发,如果你需要动态的添加、删除蓝图节点的针脚,就只能用这种方式了。例如我们常用的“Format Text”节点,可以根据输入字符串中的“{index}”来动态增加输入节点,输入节点的类型也是动态的,这个就是通过class UK2Node_FormatText这个类来实现的;
  3. 还有介于上面两者之间的一种方式,就是在UFUNCTION中使用“CustomThunk”标识,告诉UHT(Unreal Header Tool)不要生成默认的蓝图包装函数,而是由我们手工实现。这种方式,需要手工控制蓝图虚拟机的“栈”,但是不用处理蓝图编辑器UI部分,相对第2种来说代码量要少很多,相对第1种来说,又多了很多控制力;
  4. 另外,蓝图的“宏”–Macros,也可以实现自己的节点。

使用第3种方式,结合UFUNCTION的其它meta标识符,可以实现参数类型的“通配符”,就可以实现模板函数,也就是输入、输出参数可以处理多种数据类型,类似C++的泛型。这些meta标识符主要有:

  1. ArrayParm="Parameter1, Parameter2, ..":说明 BlueprintCallable 函数应使用一个Call Array Function节点,且列出的参数应被视为通配符数组属性;
  2. ArrayTypeDependentParams="Parameter":使用 ArrayParm 时,此说明符将指定一个参数,其将确定 ArrayParm 列表中所有参数的类型;
  3. CustomStructureParam="Parameter1, Parameter2, ..":列出的参数都会被视为通配符。

引擎源代码中,这种编程方式的典型的例子有:

  • 蓝图编辑器中的“Utilities”->“Array”菜单中的所有节点,他们可以处理任意的UStruct类型的数组。这些节点对应的源代码是:class UKismetArrayLibrary
  • class UDataTableFunctionLibrary::GetDataTableRowFromName(UDataTable* Table, FName RowName, FTableRowBase& OutRow)

详见官方文档:UFunctions

CustomThunk函数

如果在UFUNCTION宏里面指定了CustomThunk,那么UHT就不会自动生成这个函数的“thunk”,而需要开发者自己实现。这里的“thunk”是什么呢?我们看个例子。

我们来做个最简单的小试验,在工程中建立一个Blueprint Function Library,添加一个简单的UFUNCTION:

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "MyBlueprintFunctionLibrary.generated.h"

UCLASS()
class MYBLUEPRINTNODES_API UMyBlueprintFunctionLibrary : public UBlueprintFunctionLibrary
{
	GENERATED_BODY()
public:
	UFUNCTION(BlueprintCallable)
	static int Sum(int a, int b);
};

然后在对应的cpp文件中,使用C++实现这个函数:

#include "MyBlueprintFunctionLibrary.h"

int UMyBlueprintFunctionLibrary::Sum(int a, int b) {
	return a + b;
}

项目build一下,然后你就可以在“Intermediate”目录找到这个"MyBlueprintFunctionLibrary.generated.h"文件。在这个文件里面,你可以找到这样一段代码:

    DECLARE_FUNCTION(execSum) \
	{ \
		P_GET_PROPERTY(UIntProperty,Z_Param_a); \
		P_GET_PROPERTY(UIntProperty,Z_Param_b); \
		P_FINISH; \
		P_NATIVE_BEGIN; \
		*(int32*)Z_Param__Result=UMyBlueprintFunctionLibrary::Sum(Z_Param_a,Z_Param_b); \
		P_NATIVE_END; \
	}

这段代码就是蓝图函数节点的thunk了!这段代码做了这样几件事:

  1. 声明了一个名为“execSum”的函数,函数的签名为:void func( UObject* Context, FFrame& Stack, RESULT_DECL )
  2. 使用P_GET_PROPERTY宏,从“FFrame& Stack”(也就是蓝图虚拟机的栈)中取出函数参数;
  3. 调用P_FINISH宏;
  4. 使用取出的这些参数调用我们实现的UMyBlueprintFunctionLibrary::Sum()函数;

“thunk”函数是一个包装,它完成的核心任务就是处理蓝图虚拟机的Stack,然后调用我们使用C++实现的函数。

我们还可以看一下UHT帮我们生成的另外一个文件:MyBlueprintFunctionLibrary.gen.cpp,在其中有这样一段代码:

void UMyBlueprintFunctionLibrary::StaticRegisterNativesUMyBlueprintFunctionLibrary()
	{
		UClass* Class = UMyBlueprintFunctionLibrary::StaticClass();
		static const FNameNativePtrPair Funcs[] = {
			{ "Sum", &UMyBlueprintFunctionLibrary::execSum },
		};
		FNativeFunctionRegistrar::RegisterFunctions(Class, Funcs, ARRAY_COUNT(Funcs));
	}

这段代码把刚才"MyBlueprintFunctionLibrary.generated.h"中声明的excSum函数注册到了UMyBlueprintFunctionLibrary::StaticClass()这个UClass对象之中,并指定它的名字为“Sum”,也就是我们原始C++代码中声明的函数名,也是在蓝图编辑器中显示的名字。

看清楚了什么是“thunk函数”,“CustomThunk函数”也就不言自明了。在UFUNCTION中指定“CustomThunk”标识符,就是告诉UHT,不要在.generated.h中生成DECLARE_FUNCTION那部分代码,这部分代码改由手写。为啥要抛弃自动生成,而手写呢?回到本文主题:要实现“参数类型通配符”(或者叫做“蓝图模板节点”),就必须手写thunk!

蓝图Stack探索

要实现自己的thunk函数,核心任务就是“准确的处理蓝图虚拟机的栈”,可惜的是官方并没有这方面的文档!下面我就把自己的一些探索记录下来,请大家指正。

以上面的int Sum(int a, int b)函数为例,thunk函数使用P_GET_PROPERTY宏从Stack取值,这个宏P_GET_PROPERTY(UIntProperty,Z_Param_a)展开之后的代码如下所示:

	UIntProperty::TCppType Z_Param_a = UIntProperty::GetDefaultPropertyValue();
	Stack.StepCompiledIn<UIntProperty>(&Z_Param_a);

其中UIntProperty派生自TProperty_Numeric<int32>UIntProperty::TCppType就是“int32”无疑!

我们还需要处理TArray<MyStruct>这样的数据,所以我们重点要看一下这种参数类型的栈处理。
假设我们有一个C++的UStruct:

USTRUCT(Blueprintable)
struct FMyStruct {
	GENERATED_USTRUCT_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FString Name;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int Value;
};

类似这样一个UFUNCTION:

UFUNCTION(BlueprintCallable)
static void PrintMyStructArray(const TArray<FMyStruct>& MyStructArray);

则在.h中的thunk函数为:

DECLARE_FUNCTION(execPrintMyStructArray) \
	{ \
		P_GET_TARRAY_REF(FMyStruct,Z_Param_Out_MyStructArray); \
		P_FINISH; \
		P_NATIVE_BEGIN; \
		UMyBlueprintFunctionLibrary::PrintMyStructArray(Z_Param_Out_MyStructArray); \
		P_NATIVE_END; \
	} \

其中P_GET_TARRAY_REF(FMyStruct,Z_Param_Out_MyStructArray);这个宏展开之后的代码为:

PARAM_PASSED_BY_REF(Z_Param_Out_MyStructArray, UArrayProperty, TArray<FMyStruct>)

最终展开为:

TArray<FMyStruct> Z_Param_Out_MyStructArrayTemp;
TArray<FMyStruct>& Z_Param_Out_MyStructArray = Stack.StepCompiledInRef<UArrayProperty, TArray<FMyStruct> >(&Z_Param_Out_MyStructArrayTemp);

综合上面两个例子,我们发现核心操作都是调用template<class TProperty> void FFrame::StepCompiledIn(void*const Result)这个模板函数。通过跟踪这个函数的执行,发现它实际调用了UObject::execInstanceVariable()函数。

  1. 更新"FFrame::PropertyChainForCompiledIn"这个成员变量;
  2. 使用更新后的“FFrame::PropertyChainForCompiledIn”值,更新了"FFrame::MostRecentPropertyAddress"成员变量。

再结合引擎中CustomThunk函数的实现源码,可以得出这样的结论:

  1. 通过调用Stack.StepCompiledIn()函数,就可以更新蓝图虚拟机的栈顶指针;

  2. Stack.MostRecentPropertyAddressStack.MostRecentProperty这两个变量,就是当前参数值的内存地址和反射信息。

有了具体变量的内存地址和类型的反射信息,就足够做很多事了。下面我们就开始实践。

实践1:接受任意UStruct类型参数

下面我们就看一下文章开头的这张图里面的蓝图节点“Show Struct Fields”是如何接受任意类型UStruct参数的。

先上代码, BlueprintWildcardLibrary.h

USTRUCT(BlueprintInternalUseOnly)
struct FDummyStruct {
	GENERATED_USTRUCT_BODY()

};

UCLASS()
class UNREALCOOKBOOK_API UBlueprintWildcardLibrary : public UBlueprintFunctionLibrary {
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, CustomThunk, Category = "MyDemo", meta = (CustomStructureParam = "CustomStruct"))
		static void ShowStructFields(const FDummyStruct& CustomStruct);
	static void Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty);

	DECLARE_FUNCTION(execShowStructFields) {

		Stack.MostRecentPropertyAddress = nullptr;
		Stack.MostRecentProperty = nullptr;

		Stack.StepCompiledIn<UStructProperty>(NULL);
		void* StructAddr = Stack.MostRecentPropertyAddress;
		UStructProperty* StructProperty = Cast<UStructProperty>(Stack.MostRecentProperty);


		P_FINISH;

		P_NATIVE_BEGIN;
		Generic_ShowStructFields(StructAddr, StructProperty);
		P_NATIVE_END;
	}
};

BlueprintWildcardLibrary.cpp

#include "BlueprintWildcardLibrary.h"
#include "Engine/Engine.h"

void UBlueprintWildcardLibrary::Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty) {
	UScriptStruct* Struct = StructProperty->Struct;
	for (TFieldIterator<UProperty> iter(Struct); iter; ++iter) {

		FScreenMessageString NewMessage;
		NewMessage.CurrentTimeDisplayed = 0.0f;
		NewMessage.Key = INDEX_NONE;
		NewMessage.DisplayColor = FColor::Blue;
		NewMessage.TimeToDisplay = 5;
		NewMessage.ScreenMessage = FString::Printf(TEXT("Property: [%s].[%s]"),
			*(Struct->GetName()),
			*(iter->GetName())
		);
		NewMessage.TextScale = FVector2D::UnitVector;
		GEngine->PriorityScreenMessages.Insert(NewMessage, 0);
	}
}

解释一下这段代码:

  1. 首先声明了一个UFunction:static void ShowStructFields(const FDummyStruct& CustomStruct);,其参数类型是“FDummyStruct”,这只是一个占位符;
  2. 在UFUNCTION宏里面指定“CustomThunk”和“CustomStructureParam”;
  3. 实现一个execShowStructFields函数。这个函数很简单,主要是处理蓝图的Stack,从中取出需要的参数,然后对用C++的实现;
  4. 具体功能实现在:static void Generic_ShowStructFields(const void* StructAddr, const UStructProperty* StructProperty)这个函数中。

实践2:对数组中的Struct的数值型求平均

下面我们再来一下文章开头的这张图里面的“Array Numeric Field Average”蓝图节点是如何通过“CustomThunk”函数来实现的。

参照引擎源代码,我定义了这样一个宏,用来从栈上取出泛型数组参数,并正确的移动栈指针:

#define P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty) Stack.MostRecentProperty = nullptr;\
		Stack.StepCompiledIn<UArrayProperty>(NULL);\
		void* ArrayAddr = Stack.MostRecentPropertyAddress;\
		UArrayProperty* ArrayProperty = Cast<UArrayProperty>(Stack.MostRecentProperty);\
		if (!ArrayProperty) {	Stack.bArrayContextFailed = true;	return; }

通过这个宏,可以得到两个局部变量:

  • void* ArrayAddr: 数组的起始内存地址;
  • UArrayProperty* ArrayProperty: 数组的反射信息,ArrayProperty->Inner就是数组成员对应的类型了;

有了这个宏,我们就可以很方便的写出thunk函数了:

DECLARE_FUNCTION(execArray_NumericPropertyAverage) {

		// get TargetArray
		P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty);

		// get PropertyName
		P_GET_PROPERTY(UNameProperty, PropertyName);

		P_FINISH;

		P_NATIVE_BEGIN;
		*(float*)RESULT_PARAM = GenericArray_NumericPropertyAverage(ArrayAddr, ArrayProperty, PropertyName);
		P_NATIVE_END;
	}

经过以上的准备,我们就已经可以正确的处理“泛型数组”了。下一步就是对这个数组中指定的数“值类型成员变量”求均值了,这主要依靠Unreal的反射信息,一步步抽丝剥茧,找到数组中的每个变量即可。反射系统的使用不是本文的重点,先看完整代码吧。

BlueprintWildcardLibrary.h

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "BlueprintWildcardLibrary.generated.h"

#define P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty) Stack.MostRecentProperty = nullptr;\
		Stack.StepCompiledIn<UArrayProperty>(NULL);\
		void* ArrayAddr = Stack.MostRecentPropertyAddress;\
		UArrayProperty* ArrayProperty = Cast<UArrayProperty>(Stack.MostRecentProperty);\
		if (!ArrayProperty) {	Stack.bArrayContextFailed = true;	return; }

UCLASS()
class UNREALCOOKBOOK_API UBlueprintWildcardLibrary : public UBlueprintFunctionLibrary {
	GENERATED_BODY()

public:

	UFUNCTION(BlueprintPure, CustomThunk, meta = (DisplayName = "Array Numeric Property Average", ArrayParm = "TargetArray", ArrayTypeDependentParams = "TargetArray"), Category = "MyDemo")
		static float Array_NumericPropertyAverage(const TArray<int32>& TargetArray, FName PropertyName);
	static float GenericArray_NumericPropertyAverage(const void* TargetArray, const UArrayProperty* ArrayProperty, FName ArrayPropertyName);

public:
	DECLARE_FUNCTION(execArray_NumericPropertyAverage) {

		// get TargetArray
		P_GET_GENERIC_ARRAY(ArrayAddr, ArrayProperty);

		// get PropertyName
		P_GET_PROPERTY(UNameProperty, PropertyName);

		P_FINISH;

		P_NATIVE_BEGIN;
		*(float*)RESULT_PARAM = GenericArray_NumericPropertyAverage(ArrayAddr, ArrayProperty, PropertyName);
		P_NATIVE_END;
	}
};

BlueprintWildcardLibrary.cpp


#include "BlueprintWildcardLibrary.h"
#include "Engine/Engine.h"

float UBlueprintWildcardLibrary::Array_NumericPropertyAverage(const TArray<int32>& TargetArray, FName PropertyName) {
	// We should never hit these!  They're stubs to avoid NoExport on the class.  Call the Generic* equivalent instead
	check(0);
	return 0.f;
}

float UBlueprintWildcardLibrary::GenericArray_NumericPropertyAverage(const void* TargetArray, const UArrayProperty* ArrayProperty, FName PropertyName) {

	UStructProperty* InnerProperty = Cast<UStructProperty>(ArrayProperty->Inner);
	if (!InnerProperty) {
		UE_LOG(LogTemp, Error, TEXT("Array inner property is NOT a UStruct!"));
		return 0.f;
	}

	UScriptStruct* Struct = InnerProperty->Struct;
	FString PropertyNameStr = PropertyName.ToString();
	UNumericProperty* NumProperty = nullptr;
	for (TFieldIterator<UNumericProperty> iter(Struct); iter; ++iter) {
		if (Struct->PropertyNameToDisplayName(iter->GetFName()) == PropertyNameStr) {
			NumProperty = *iter;
			break;
		}
	}
	if (!NumProperty) {
		UE_LOG(LogTemp, Log, TEXT("Struct property NOT numeric = [%s]"),
			*(PropertyName.ToString())
		);
	}


	FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray);
	int Count = ArrayHelper.Num();
	float Sum = 0.f;

	if(Count <= 0)
		return 0.f;

	if (NumProperty->IsFloatingPoint())
		for (int i = 0; i < Count; i++) {
			void* ElemPtr = ArrayHelper.GetRawPtr(i);
			const uint8* ValuePtr = NumProperty->ContainerPtrToValuePtr<uint8>(ElemPtr);
			Sum += NumProperty->GetFloatingPointPropertyValue(ValuePtr);

		}
	else if (NumProperty->IsInteger()) {
		for (int i = 0; i < Count; i++) {
			void* ElemPtr = ArrayHelper.GetRawPtr(i);
			const uint8* ValuePtr = NumProperty->ContainerPtrToValuePtr<uint8>(ElemPtr);
			Sum += NumProperty->GetSignedIntPropertyValue(ValuePtr);
		}
	}
	// TODO: else if(enum类型)

	return Sum / Count;
}
2016-08-11 17:32:26 sgnyyy 阅读数 659

1. 通过设置两个蓝图的变量,并通过CreateUIWidget保存获取相应的UI界面,这样另一个蓝图就可以获取对象,从而通过变量进行沟通

2. 通过命令行Execute Console Command

3. 通过Event Dispatchers进行绑定时间,并在触发的地方Call。 注意:绑定bind的事件已经要运行到节点才能绑定上。例子中使用了在初始化的时候就进行了绑定



2015-01-11 01:14:55 shangguanwaner 阅读数 5452

UE4的蓝图就跟C++等编程语言在概念上是非常类似的。在蓝图中你可以定义变量、函数、宏等等,高级点的,它还可以被继承。这还不算,我们还可以定义蓝图接口,规范子类的行为。基本上C++中可以做的,蓝图也可以做到,而且是所见即所得,拖拖拽拽,即时编译,立即生效。

一般的做法是,程序员在C++中做好功能模块,关卡设计师,用蓝图创建游戏的逻辑,关卡师不需要会编程、可以自己创建逻辑,至少在逻辑这一层不需要程序员来配合。这样的效率会高很多,而且也没有类似Lua这样的脚本语言参与,设计师的门槛要低很多了。

这里记录下蓝图中自定义事件的使用方法:

在蓝图的工具栏上,我们可以看到5个按钮,通过这5个按钮我们可以分别创建多种蓝图的元素。



通过在蓝图中右键、添加事件-》添加自定义事件,可以创建一个自定义事件。自定义事件可以指定输入参数、还可以指定事件是否能在客户端和服务器端同步。


复制那里有四个选项,默认是不能复制,还可以选择多路传送、在服务器上运行、在其所属的客户端上运行,如果需要让事件在网络上传送,可以选这几个选项。

输入值一栏可以添加参数,类型和变量类型一样。

我们来做个例子,实现当按下K按键时,触发这个自定义事件,打印一句Hello。


触发自定义事件,就像调用函数一样,直接调用即可,如果有参数,也可以像函数一样指定参数。

我们还可以动态的绑定将事件绑定到不同的调度器,就像高级编程语言中的Delegate一样。比如这样一个例子,我们按下K,为调度器绑定MyEvent,按下F,为调度器绑定MyEvent1,同样是调用同一个调度器,触发的行为是不同的。


2016-08-11 17:57:58 sgnyyy 阅读数 1720

《Unreal Engine 4蓝图可视化编程》一导读

阅读数 4

前言UnrealEngine4蓝图可视化编程游戏引擎(例如虚幻引擎4)作为强大的商业游戏的制作工具,越来越受传统游戏工作室以外的新老游戏开发者所欢迎。虚幻引擎为过去10年中发布的许多最受欢迎的控制台和PC游戏提供了动力,最新版本的虚幻引擎尽可能地包含了开发者所需的工具。这些工具中最具变革性的是蓝图可视化编程系统,其允许非专业程序人员创建和实现游...

博文 来自: weixin_34122548

Unreal Engine蓝图操作基本篇01

阅读数 40

一、什么是关卡蓝图:如果把场景比作施工现场,那么关卡蓝图则是可以具体到场景中的某个具体事物,它们会发生什么事情。关卡蓝图是针对到场景中已有的物体,并可以指挥这些物体该怎么去行动;关卡蓝图就是面向过程的编程;二、什么是蓝图类:蓝图类则是做一些功能的归类、封装;最后我们可以把类蓝图写好后,直接拖入场景中,便是实例化。例如,把人物蓝图类拖进来,就是实例化...

博文 来自: weixin_34376562

Unreal Editor Open Level 蓝图 移动 旋转

阅读数 424

博文 来自: Tgots

Unreal: Blueprint 虚幻教程:蓝图 Lynda课程中文字幕

阅读数 82

Unreal:Blueprint中文字幕虚幻教程:蓝图中文字幕Unreal:Blueprint学习如何使用Blueprints,这是虚幻可视化项目的可视化脚本语言本课程为理解如何使用蓝图构建Unreal提供了坚实的基础讲师ScottPagano探讨了蓝图的基本概念,并构建了基于蓝图的功能他还介绍了变量和事件的编程基础知识,展示了如何在不同的蓝图之间流动数据,以实现模块化可扩展...

博文 来自: lyndacn

Unreal 入门-Unreal Engine 4 AI Programming Essentials 书籍下载

阅读数 636

PDF文件:Unreal+Engine+4+AI+Programming+Essential....pdfKindle电子书文件:Unreal+Engine+4+AI+Programming+Essential....rarVR热门内容:实时演算:根据已知设备头盔,两个手柄。外加玩家的几个测量参数实时演算玩家手,手臂,手肘,动作

博文 来自: shidya
没有更多推荐了,返回首页