UPL调研
基本思路:
使用UPL中的xml语言,将其应用于安卓的打包过程,以便在UE工程中添加所需功能。
UPL定义:
UPL全称为Unreal Plugin Language,主要用于参与UE的打包过程。
理解:
类比安卓工程中的activity_main.xml文件,在安卓包构建时规定了方式和顺序。
所以我们在开发的过程中,可以通过UPL在Gameactivty增加我们写的java代码来保证使用C++来在安卓的环境下来使用java的代码
<!-- 导入对象到 GameActivity.java --><gameActivityImportAdditions> <insert> import android.net.Uri; import android.net.ConnectivityManager; </insert></gameActivityImportAdditions><!-- 导入 java方法到 GameActivity.java --><gameActivityClassAdditions> </gameActivityClassAdditions><!-- 增加 代码到 OnCreateAdditions函数 --><gameActivityOnCreateAdditions></gameActivityOnCreateAdditions>
通过这些方法就可以将代码增加到Gameactivity.java,这些具体的定义可以参考
UnrealBuildTool/System/UnrealPluginLanguage.csUE的源码中的注释
Java函数签名
JNI:JNI全称Java Native Interface,及java原生接口,主要用于jave调用其他语言代码,其他语言调用java代码。
我们通过C++ 去java的方法,通常是使用函数签名来获取Java中的函数,函数签名定义了函数的名称,返回值和参数类型
public int AndroidThunkJava_GetCurCpu(){ int result = 0; FileReader fr = null; BufferedReader br = null; }
比如上面的这个java函数,他的函数签名就为 () I;,其中括号中为参数列表,I表示返回值为int
他的签名计算是有一个规则,可以参考下表
- String:Ljava/lang/String;
- Object:Ljava/lang/Object;
以上两条为补充内容,他不在基本类型中,所以为特殊表示
public string AndroidThunkJava_GetCurCpu(int val)
//他的函数签名为 (I)Ljava/lang/String;
JNI:Java to C++
UE 给我们的游戏生成的 GameActivity 中也声明了很多的 native 函数,这些函数是在 C++ 实现的,在 Java 中执行到这些函数会自动调用到引擎的 C++ 代码中:
public native int nativeGetCPUFamily();public native boolean nativeSupportsNEON();public native void nativeSetAffinityInfo(boolean bEnableAffinity, int bigCoreMask, int littleCoreMask);public native void nativeSetConfigRulesVariables(String[] KeyValuePairs);public native boolean nativeIsShippingBuild();public native void nativeSetAndroidStartupState(boolean bDebuggerAttached);public native void nativeSetGlobalActivity(boolean bUseExternalFilesDir, boolean bPublicLogFiles, String internalFilePath, String externalFilePath, boolean bOBBInAPK, String APKPath);public native void nativeSetObbFilePaths(String OBBMainFilePath, String OBBPatchFilePath);public native void nativeSetWindowInfo(boolean bIsPortrait, int DepthBufferPreference);public native void nativeSetObbInfo(String ProjectName, String PackageName, int Version, int PatchVersion, String AppType);public native void nativeSetAndroidVersionInformation( String AndroidVersion, String PhoneMake, String PhoneModel, String PhoneBuildNumber, String OSLanguage );public native void nativeSetSurfaceViewInfo(int width, int height);public native void nativeSetSafezoneInfo(boolean bIsPortrait, float left, float top, float right, float bottom);public native void nativeConsoleCommand(String commandString);public native void nativeVirtualKeyboardChanged(String contents);public native void nativeVirtualKeyboardResult(boolean update, String contents);public native void nativeVirtualKeyboardSendKey(int keyCode);public native void nativeVirtualKeyboardSendTextSelection(String contents, int selStart, int selEnd);public native void nativeVirtualKeyboardSendSelection(int selStart, int selEnd);public native void nativeInitHMDs();public native void nativeResumeMainInit();public native void nativeOnActivityResult(GameActivity activity, int requestCode, int resultCode, Intent data);public native void nativeGoogleClientConnectCompleted(boolean bSuccess, String accessToken);public native void nativeVirtualKeyboardShown(int left, int top, int right, int bottom);public native void nativeVirtualKeyboardVisible(boolean bShown);public native void nativeOnConfigurationChanged(boolean bPortrait);public native void nativeOnInitialDownloadStarted();public native void nativeOnInitialDownloadCompleted();public native void nativeHandleSensorEvents(float[] tilt, float[] rotation_rate, float[] gravity, float[] acceleration);
简单例子
在java中声明一个简单函数
<gameActivityClassAdditions> <insert> public native void nativeDoTester(String Msg); </insert></gameActivityClassAdditions>
在C++代码的任何位置去实现这个代码就好
#if PLATFORM_ANDROIDJNI_METHOD void Java_com_epicgames_unreal_GameActivity_nativeDoTester(JNIEnv* jenv, jobject thiz, jstring msg){ if (JNIEnv* Env = FAndroidApplication::GetJavaEnv()) { FString FinalResult = FJavaHelper::FStringFromLocalRef(Env, msg); MyFunc::Name = FinalResult; }}
com.epicgames_unreal 是 UE 生成的 GameActivity.java 的包名 (package com.epicgames_unreal)。
可以看到,在 C++ 中实现 JNIMETHOD 的函数名是以下规则:.
RType Java_PACKAGENAME_CLASSNAME_FUNCNAME(JNIEnv*,jobject thiz,Oher…)
JNI:C++ to Java
通过上面的转化,我们知道了怎么通过函数签名来找到java代码,我们在UE中就可以通过函数名和函数签名来在C++中调用java
其中通常用到的为下面三个头文件
// Runtime/Launch/Public/Android#include "Android/AndroidJNI.h"// Runtime/Core/Public/Android#include "Android/AndroidJavaEnv.h"// Runtime/Core/Public/Android#include "Android/AndroidJava.h"
想要在 UE 中调用到它,首先要获取它的 jmethodID,需要通过函数所属的类、函数名字,签名三种信息来获取:
如果 (JNIEnv* Env = FAndroidApplication::GetJavaEnv()) { // 通过FindMethod来获取他的jmethodID,其中需要填类名、函数名字、签名 static jmethodID Method = FJavaWrapper::FindMethod(Env, FJavaWrapper::GameActivityClassID, AndroidThunkJava_GetCurCpu, ()I, false); /遇到的问题
- 找不到头文件 #include “Android/AndroidJNI.h”
解决:需要在bulid.cs中添加if (Target.Platform == UnrealTargetPlatform.Android) { PrivateDependencyModuleNames.AddRange(new string[] { "Launch", "AndroidPermission" }); string PluginPath = Utils.MakePathRelativeTo(ModuleDirectory, Target.RelativeEnginePath); AdditionalPropertiesForReceipt.Add("AndroidPlugin", Path.Combine(PluginPath, "Android_clik_APL.xml")); }
直接在VS平台编译JNI代码无法通过编译
解决:需要在编译时增加预处理宏,保证在安卓平台时才可以运行
#if PLATFORM_ANDROID
#endif导入对象出现错误
解决:
如果有如下错误:提示下面两个包找不到
import android.support.v4.app.ActivityCompat;
import android.support.v7.app.AppCompatActivity;
可以改为:
import androidx.core.app.ActivityCompat;
import androidx.appcompat.app.AppCompatActivity;这里库找不到可以替换为
import android.support.annotation.替换为import androidx.annotation;4. 示例代码的修改,以下代码的删除,这里会导致软件闪退<gameActivityOnPauseAdditions> <insert> android.util.Log.d("UE4", "onPause"); nativeUELog("onPause"); </insert> </gameActivityOnPauseAdditions><gameActivityOnResumeAdditions> <insert> android.util.Log.d("UE4", "onResume"); nativeUELog("onResume"); </insert></gameActivityOnResumeAdditions>
功能实现效果
通过UMG在屏幕生成按钮,点击按钮后触发事件,调用JAVA函数,获取到CPU数值,并显示在屏幕右上角
java中native方法
定义:他的作用就是一个java调用非java接口的方法,他只是在java中声明,而具体的实现是在C语言中实现,其实很类似于C++中的extern c,是编译器的一种方法
public class IHaveNatives
{
native public void Native1( int x ) ;
native static public long Native2() ;
native synchronized private float Native3( Object o ) ;
native void Native4( int[] ary ) throws Exception ;
}举例是这样的,native可以与其他的java标识符连用,他的暗示是这些函数是有实现的
扩展:GameActivity.java文件的学习1.1 安卓项目文件结构
activity定义:用户看得见摸的着的是手机屏幕。我们要在手机屏幕上显示文字图像等信息,并对用户的点击滑动等等操作作出不同的反应。 App中与用户交互的大任由Activity来承担。当用户手指点击手机屏幕时,Android系统检测到屏幕发生的事情,将这一事件分发对应的App处理。这里要注意,activity接收到的是系统给的信息。系统会判断这些交互消息该给哪个app来处理。
入口函数:public class GameActivity extends NativeActivity
在gameActivity他的入口类为GameActivity ,是继承自NativeActivity,也就是他的页面
生命周期:
- Oncreate 布局的初始化
- Onstart 启动的时候的状态
- Onresume 渲染的完成
- Onpause 暂停后去停止
- Onstop 回到桌面
- Onresart 重新开始
同时在UE的UPL中也提供相应的接口供我们使用,在其中添加代码,下面是一些举例
* <!-- optional additions to GameActivity onCreate in GameActivity.java --> * <gameActivityOnCreateAdditions> </gameActivityOnCreateAdditions> * * <!-- optional additions to GameActivity onDestroy in GameActivity.java --> * <gameActivityOnDestroyAdditions> </gameActivityOnDestroyAdditions> * * <!-- optional additions to GameActivity onConfigurationChanged in GameActivity.java --> * <gameActivityonConfigurationChangedAdditions> </gameActivityonConfigurationChangedAdditions> * * <!-- optional additions to GameActivity onStart in GameActivity.java --> * <gameActivityOnStartAdditions> </gameActivityOnStartAdditions> * * <!-- optional additions to GameActivity onStop in GameActivity.java --> * <gameActivityOnStopAdditions> </gameActivityOnStopAdditions> * * <!-- optional additions to GameActivity onPause in GameActivity.java --> * <gameActivityOnPauseAdditions> </gameActivityOnPauseAdditions> * * <!-- optional additions to GameActivity onResume in GameActivity.java --> * <gameActivityOnResumeAdditions> </gameActivityOnResumeAdditions> *
2.1 Activity 启动,携带参数启动
通常activity离不开intent这个类,通常把信息包含在intent对象中,然后执行启动,常用的方法是 startActivity (Intent intent) ,我们在activity中通信的过程中的信息就可以听过intent这个类去传递
//实现一个简单的跳转
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);Intent intent = new Intent(getApplicationContext(),myActivity2.class);intent.putExtra("13",100);startActivities(new Intent[]{intent});
}
2.2 任务,返回栈
任务指的是在执行特定作业的时候与用户交互的一系列Activity,这些Activity按照各组打开顺序排列在堆栈中.
2.3 Avtivity的四种模式
2.4 Service综述
服务是一种在幕后运行的应用组件,它能够执行长时间运行的操作而无需提供用户界面。服务可以由其他组件启动,并且即使用户切换到其他应用程序时,服务仍然会在后台持续运行。
2.4.1 前台,后台服务与绑定
前台:前台服务执行一些用户能注意到的操作。例如,音频应用会使用前台服务来播放音频曲目。前台服务必 须显示通知。 即使用户停止与应用的交互,前台服务仍会继续运行
后台:后台服务执行用户不会直接注意到的操作。例如,如果应用使用某个服务来压缩其存储空间,则此服务通常是后台服务。
绑定服务:当应用组件通过调用 bindService() 绑定到服务时,服务即处于绑定状态。 绑定服务会提供客户端-服务器接口,以便组件与服务进行交互、发送请求、接收结果,甚至是利用进程间通信 (IPC) 跨进程执行,这些操作仅当与另一个应用组件绑定时,绑定服务才会运行。多个组件可同时绑定到该服务,但全部取消绑定后,该服务即会被销毁。2.4.2 启动服务
startService(Intent(applicationContext, ServiceStartDemo::class.java))
调用方法后,ServiceStartDemo服务会启动起来。 首次启动的话,服务会走 onCreate 和
onStartCommand 方法。 初始化性质的代码,放在 onCreate 。2.4.3 停止前台服务
在Service中使用stopForeground(boolean)方法可以停止前台服务,但不会终止整个服务。这个boolean参数用于指示是否取消前台服务的通知。当参数为false时,表示保留通知。
3.1 Fragment基础概念
定义:Fragment直译为碎片,Fragment表示FragmentActivity中行为或界面的一部分,你可以在Activity中组合多个片段,从而构建多窗格界面,其实可以理解为一个子Activity,但是片段必须始终托管在Activity中,他的生命周期也直接受Activity的影响
Fragment的优点
- Fragment加载灵活,替换方便。定制你的UI,在不同尺寸的屏幕上创建合适的UI,提高用户体
验。- 可复用,页面布局可以使用多个Fragment,不同的控件和内容可以分布在不同的Fragment上。
- 使用Fragment,可以少用一些Activity。一个Activity可以管辖多个Fragment。
3.2 fragment的生命周期使用DialogFragment来完成一个弹窗的功能
- 在onCreate方法中接收传入的数据。传递数据使用了Bundle
- 在onCreateView方法中,使用上文建立的layout
- 在onViewCreated方法中进行ui操作
import android.os.Bundle;import android.view.LayoutInflater;import android.view.View;import android.view.ViewGroup;import android.widget.TextView;import androidx.annotation.NonNull;import androidx.annotation.Nullable;import androidx.fragment.app.DialogFragment;
public class SimpleDialog extends DialogFragment { public static final String K_TITLE = "k_title"; // 传输数据时用到的key public static final String K_CONTENT = "k_content"; private String title; private String content; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle in = getArguments(); if (in != null) { title = in.getString(K_TITLE); content = in.getString(K_CONTENT); } } //作用是将片段布局插入到父级viewGrroup中 @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.dialog_simple, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); TextView titleTv = view.findViewById(R.id.title_tv); TextView contentTv = view.findViewById(R.id.content_tv); titleTv.setText(title); contentTv.setText(content); }}
定义的layout文件如下
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="12dp"> <TextView android:id="@+id/title_tv" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="#111111" android:textSize="16sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/content_tv" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:gravity="center" android:textColor="#111111" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/title_tv" /></androidx.constraintlayout.widget.ConstraintLayout>
最后在activity中调用
public class MainActivity extends AppCompatActivity {
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); popSimpleDialog1(123, test); } private void popSimpleDialog1(String title, String content) { SimpleDialog dialog = new SimpleDialog(title, content); }最终实现的效果如下图所示
java中native方法
定义:他的作用就是一个java调用非java接口的方法,他只是在java中声明,而具体的实现是在C语言中实现,其实很类似于C++中的extern c,是编译器的一种方法
public class IHaveNatives { native public void Native1( int x ) ; native static public long Native2() ; native synchronized private float Native3( Object o ) ; native void Native4( int[] ary ) throws Exception ; }
扩展:GameActivity.java文件的学习
1.1 安卓项目文件结构
activity定义:用户看得见摸的着的是手机屏幕。我们要在手机屏幕上显示文字图像等信息,并对用户的点击滑动等等操作作出不同的反应。 App中与用户交互的大任由Activity来承担。当用户手指点击手机屏幕时,Android系统检测到屏幕发生的事情,将这一事件分发对应的App处理。这里要注意,activity接收到的是系统给的信息。系统会判断这些交互消息该给哪个app来处理。入口函数:public class GameActivity extends NativeActivity
在gameActivity他的入口类为GameActivity ,是继承自NativeActivity,也就是他的页面生命周期:
- Oncreate 布局的初始化
- Onstart 启动的时候的状态
- Onresume 渲染的完成
- Onpause 暂停后去停止
- Onstop 回到桌面
- Onresart 重新开始
同时在UE的UPL中也提供相应的接口供我们使用,在其中添加代码,下面是一些举例* <!-- optional additions to GameActivity onCreate in GameActivity.java --> * <gameActivityOnCreateAdditions> </gameActivityOnCreateAdditions> * * <!-- optional additions to GameActivity onDestroy in GameActivity.java --> * <gameActivityOnDestroyAdditions> </gameActivityOnDestroyAdditions> * * <!-- optional additions to GameActivity onConfigurationChanged in GameActivity.java --> * <gameActivityonConfigurationChangedAdditions> </gameActivityonConfigurationChangedAdditions> * * <!-- optional additions to GameActivity onStart in GameActivity.java --> * <gameActivityOnStartAdditions> </gameActivityOnStartAdditions> * * <!-- optional additions to GameActivity onStop in GameActivity.java --> * <gameActivityOnStopAdditions> </gameActivityOnStopAdditions> * * <!-- optional additions to GameActivity onPause in GameActivity.java --> * <gameActivityOnPauseAdditions> </gameActivityOnPauseAdditions> * * <!-- optional additions to GameActivity onResume in GameActivity.java --> * <gameActivityOnResumeAdditions> </gameActivityOnResumeAdditions> *
2.1 Activity 启动,携带参数启动
通常activity离不开intent这个类,通常把信息包含在intent对象中,然后执行启动,常用的方法是 startActivity (Intent intent) ,我们在activity中通信的过程中的信息就可以听过intent这个类去传递
```java // 实现一个简单的跳转 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Intent intent = new Intent(getApplicationContext(), myActivity2.class); intent.putExtra(wdf, 100); startActivities(new Intent[]{intent}); } // 2.2 任务,返回栈 // 任务是指一系列与用户交互的Activity,按照打开顺序排列在堆栈中。 ```2.3 Avtivity的四种模式
2.4 Service综述
服务是一种在幕后运行的应用组件,它能够执行长时间运行的操作而无需提供用户界面。服务可以由其他组件启动,并且即使用户切换到其他应用程序时,服务仍然会在后台持续运行。
2.4.1 前台,后台服务与绑定
**前台:**前台服务执行一些用户能注意到的操作。例如,音频应用会使用前台服务来播放音频曲目。前台服务必 须显示通知。 即使用户停止与应用的交互,前台服务仍会继续运行
**后台:**后台服务执行用户不会直接注意到的操作。例如,如果应用使用某个服务来压缩其存储空间,则此服务通常是后台服务。
**绑定服务:**当应用组件通过调用 bindService() 绑定到服务时,服务即处于绑定状态。 绑定服务会提供客户端-服务器接口,以便组件与服务进行交互、发送请求、接收结果,甚至是利用进程间通信 (IPC) 跨进程执行,这些操作仅当与另一个应用组件绑定时,绑定服务才会运行。多个组件可同时绑定到该服务,但全部取消绑定后,该服务即会被销毁。2.4.2 启动服务
startService(Intent(applicationContext, ServiceStartDemo::class.java))
调用方法后,ServiceStartDemo服务会启动起来。 首次启动的话,服务会走 onCreate 和
onStartCommand 方法。 初始化性质的代码,放在 onCreate 。2.4.3 停止前台服务
在Service中使用stopForeground(boolean)方法可以停止前台服务,但不会终止整个服务。这个boolean参数用于指示是否取消前台服务的通知。当参数为false时,表示保留通知。
3.1 Fragment基础概念
定义:Fragment直译为碎片,Fragment表示FragmentActivity中行为或界面的一部分,你可以在Activity中组合多个片段,从而构建多窗格界面,其实可以理解为一个子Activity,但是片段必须始终托管在Activity中,他的生命周期也直接受Activity的影响
Fragment的优点
- Fragment加载灵活,替换方便。定制你的UI,在不同尺寸的屏幕上创建合适的UI,提高用户体
验。- 可复用,页面布局可以使用多个Fragment,不同的控件和内容可以分布在不同的Fragment上。
- 使用Fragment,可以少用一些Activity。一个Activity可以管辖多个Fragment。
3.2 fragment的生命周期使用DialogFragment来完成一个弹窗的功能
- 在onCreate方法中接收传入的数据。传递数据使用了Bundle
- 在onCreateView方法中,使用上文建立的layout
- 在onViewCreated方法中进行ui操作
import android.os.Bundle;import android.view.LayoutInflater;import android.view.View;import android.view.ViewGroup;import android.widget.TextView;import androidx.annotation.NonNull;import androidx.annotation.Nullable;import androidx.fragment.app.DialogFragment;public class SimpleDialog extends DialogFragment { public static final String K_TITLE = "k_title"; // 传输数据时用到的key public static final String K_CONTENT = "k_content"; private String title; private String content; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle in = getArguments(); if (in != null) { title = in.getString(K_TITLE); content = in.getString(K_CONTENT); } } //作用是将片段布局插入到父级viewGrroup中 @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.dialog_simple, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); TextView titleTv = view.findViewById(R.id.title_tv); TextView contentTv = view.findViewById(R.id.content_tv); titleTv.setText(title); contentTv.setText(content); }}
定义的layout文件如下
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="12dp"> <TextView android:id="@+id/title_tv" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textColor="#111111" android:textSize="16sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/content_tv" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:gravity="center" android:textColor="#111111" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/title_tv" /></androidx.constraintlayout.widget.ConstraintLayout>
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); popSimpleDialog1("王东方", "test"); } private void popSimpleDialog1(String title, String content) { SimpleDialog dialog = new SimpleDialog(); Bundle bundle = new Bundle(); bundle.putString(SimpleDialog.K_TITLE, title); bundle.putString(SimpleDialog.K_CONTENT, content); dialog.setArguments(bundle); //getSupportFragmentManager为获取FragmentTransaction实例 dialog.show(getSupportFragmentManager(), "one-tag"); }}
最终实现的效果如下图所示
res应用资源
资源分类的视图如下把资源放进对应的目录后,可使用在项目 R 类中生成的资源ID来访问这些资源。形如 R.drawable.icon , R.layout.main_activity 。 R 类是自动生成的。代表resources。
还木有评论哦,快来抢沙发吧~