(二)Unity 与 Android的布局管理
1. 简述
在上一章我们讨论了最基础的Unity与Android数据交互的细节,为我们传递人脸识别信息给Unity做好了技术铺垫。接下来要讨论的是怎么让Unity绘制的图像呈现在屏幕上,因为虚拟形象都是由Unity绘制出来的。其实Unity的绘制工作都是在UnityPlayer这个自定义的Surfaceview上进行的,我们只需要把它放置到我们的布局文件中,管理好他的生命周期即可。
2. 集成UnitySdk到Android工程中
那么问题来了,UnityPlayer在哪里能获取到?我们首先要把Unity工程打包成Android工程,打出来的新工程是一个project形式,能直接run起来并且安装APK到手机上,这个工程的lib文件夹下,有一个unity-classes.jar包,就是我们需要的库,其中包含了UnityPlayer等文件。
因为我们的项目架构是以 Android 为主( Android 的部分需要经常改动, Unity 部分比较固定),这时就把 Unity 作为 Android 的库来使用,所以我们把这个打出来的工程打出来,通过修改这个工程的build.gradle和AndroidManifest文件,从project改造为module的形式。
下载完成Unity后,我们首先要给Unity配置好Android打包相关的配置,主要是这里:
-
首先 打开Unity的Preferences
-
然后点击External Tools , 红框内就是需要填写JDK与SDK的地方
为了防止某些人会填错,我在这里声明一下。
- Android SDK Location填写的就是所下载的SDK解压的路径
- JDK Location填写的就是JDK安装的路径,也就是JAVA_HOME的变量值
全部都配置完毕后就大功告成,紧接着就可以进行打包了。
-
在Unity的工程中,我们点击顶部菜单栏的File,在点击其中的Build Setting一项,会弹出下面对话框
这个弹窗就是我们打包相关配置,在这里我们选择把Unity打包成一个Android工程,所以BuildSystem选为Gradle,另外把Export Porject的勾勾上,如果我们想对打出来的Android工程之前做一些配置,可以点击PlayerSetting,这时候右侧会弹出一个窗
我们可以在这个名为Inspector的弹窗中,对我们打出来的Android工程做一些配置,如白色箭头所示,我们可以对我们打出来的工程文件夹做指定命名,也可以指定此Android工程的最低支持的API版本,最高支持的API版本等等,其实配置这里相当于配置AndroidManifest文件。
都配置完成,点击Export就能导出工程到我们需要的路径了,我们通过改造build.gradle文件和AndroidManifest文件,最终把它继承在了我们的Android工程中
2. 加载UnityPlayer到布局中
完成了最重要的第一步后,我们在此module的java包下会发现一个Unity帮我们写好的UnityPlayerActivity,这个类的主要作用是把UnityPlayer的生命周期和Activity生命周期绑定在一起
public class UnityPlayerActivity extends Activity
{
protected UnityPlayer mUnityPlayer; // don't change the name of this variable; referenced from native code
// Setup activity layout
@Override protected void onCreate (Bundle savedInstanceState)
{
requestWindowFeature(Window.FEATURE_NO_TITLE);
super.onCreate(savedInstanceState);
getWindow().setFormat(PixelFormat.RGBX_8888); // <--- This makes xperia play happy
mUnityPlayer = new UnityPlayer(this);
setContentView(mUnityPlayer);
mUnityPlayer.requestFocus();
}
@Override protected void onNewIntent(Intent intent)
{
// To support deep linking, we need to make sure that the client can get access to
// the last sent intent. The clients access this through a JNI api that allows them
// to get the intent set on launch. To update that after launch we have to manually
// replace the intent with the one caught here.
setIntent(intent);
}
// Quit Unity
@Override protected void onDestroy ()
{
mUnityPlayer.quit();
super.onDestroy();
}
// Pause Unity
@Override protected void onPause()
{
super.onPause();
mUnityPlayer.pause();
}
// Resume Unity
@Override protected void onResume()
{
super.onResume();
mUnityPlayer.resume();
}
// Low Memory Unity
@Override public void onLowMemory()
{
super.onLowMemory();
mUnityPlayer.lowMemory();
}
// Trim Memory Unity
@Override public void onTrimMemory(int level)
{
super.onTrimMemory(level);
if (level == TRIM_MEMORY_RUNNING_CRITICAL)
{
mUnityPlayer.lowMemory();
}
}
// This ensures the layout will be correct.
@Override public void onConfigurationChanged(Configuration newConfig)
{
super.onConfigurationChanged(newConfig);
mUnityPlayer.configurationChanged(newConfig);
}
// Notify Unity of the focus change.
@Override public void onWindowFocusChanged(boolean hasFocus)
{
super.onWindowFocusChanged(hasFocus);
mUnityPlayer.windowFocusChanged(hasFocus);
}
// For some reason the multiple keyevent type is not supported by the ndk.
// Force event injection by overriding dispatchKeyEvent().
@Override public boolean dispatchKeyEvent(KeyEvent event)
{
if (event.getAction() == KeyEvent.ACTION_MULTIPLE)
return mUnityPlayer.injectEvent(event);
return super.dispatchKeyEvent(event);
}
// Pass any events not handled by (unfocused) views straight to UnityPlayer
@Override public boolean onKeyUp(int keyCode, KeyEvent event) { return mUnityPlayer.injectEvent(event); }
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { return mUnityPlayer.injectEvent(event); }
@Override public boolean onTouchEvent(MotionEvent event) { return mUnityPlayer.injectEvent(event); }
/*API12*/ public boolean onGenericMotionEvent(MotionEvent event) { return mUnityPlayer.injectEvent(event); }
}
- 在Activity的onResume操作完成后,会执行mUnityPlayer.onResume方法,如果此方法第一次调用,会通知Unity引擎完成他们的初始化工作。
- 另外在Activity的onPause触发后,也会调用mUnityPlayer的onPause方法,通知Unity引擎停止渲染等相关工作
- 从onPause恢复,触发Activity的onResume方法,在onResume再中又会调用mUnityPlayer.onResume方法,通知Unity继续渲染
通过以上三个方法,我们了解到了Activity与UnityPlayer的联动关系,那么点击事件是怎么传递的呢?我们可以观察到以下代码片段
// For some reason the multiple keyevent type is not supported by the ndk.
// Force event injection by overriding dispatchKeyEvent().
@Override public boolean dispatchKeyEvent(KeyEvent event)
{
if (event.getAction() == KeyEvent.ACTION_MULTIPLE)
return mUnityPlayer.injectEvent(event);
return super.dispatchKeyEvent(event);
}
// Pass any events not handled by (unfocused) views straight to UnityPlayer
@Override public boolean onKeyUp(int keyCode, KeyEvent event) { return mUnityPlayer.injectEvent(event); }
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { return mUnityPlayer.injectEvent(event); }
@Override public boolean onTouchEvent(MotionEvent event) { return mUnityPlayer.injectEvent(event); }
/*API12*/ public boolean onGenericMotionEvent(MotionEvent event) { return mUnityPlayer.injectEvent(event); }
- 通过转发onKeyUp,onKeyDown事件到Unity底层, Unity得知了我们Android设备物理键的触发时机
- 通过转发onTouchEvent,onGenericMotionEvent事件到Unity底层,Unity得知我们触摸事件的发生,进而自己内部做处理。
那么UnityPlayer的布局文件是怎么样的,通过一下代码我们可以看出
// Setup activity layout
@Override protected void onCreate (Bundle savedInstanceState)
{
requestWindowFeature(Window.FEATURE_NO_TITLE);
super.onCreate(savedInstanceState);
getWindow().setFormat(PixelFormat.RGBX_8888); // <--- This makes xperia play happy
mUnityPlayer = new UnityPlayer(this);
setContentView(mUnityPlayer);
mUnityPlayer.requestFocus();
}
setContentView(mUnityPlayer)直接传入了一个自定义的View,不存在预料中的XML文件。这是因为默认的UnityActivity就只显示Unity,如果我们要在上面显示android相关的widget,怎么办?
我们可以调整一下setContentView方法,传入一个自定义的Layout文件
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 设置test为我们的根布局
setContentView(R.layout.test);
// 通过刚才的源码分析,知道mUnityPlayer为一个全局的引用变量,而且已经在父类中设置好了,所以直接拿来用就可以了
View playerView = new UnityPlayer(this);
// 将Unity的视图添加到我们为其准备的父容器中
LinearLayout ll = (LinearLayout) findViewById(R.id.unityViewLyaout);
ll.addView(playerView);
}
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<Button
android:id="@+id/topButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:text="PREVIOUS" />
<FrameLayout
android:id="@+id/unityViewLyaout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/bottomBtn"
android:layout_below="@+id/topButton"
android:orientation="horizontal" >
</FrameLayout>
<Button
android:id="@+id/bottomBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:text="NEXT" />
</RelativeLayout>
通过自定义XML布局的方式,我们把UnityPlayer动态add到布局中,而布局XML可以放入我们相关的业务的widget,从而达到在Unity的页面显示我们Android相关widget的功能,这样就能方便我们进行业务扩展了。
3. 多UnityPlayerActivity的解决方案
如果文章到此为止,就和市面上的Unity的文章没什么区别了,接下来才是最宝贵的地方。
我们紧接着要讨论的问题是,如果有以下业务场景,即AB两个Activitiy页面,他们都需要展示Unity动画模型的,但是我们测试发现,UnityPlayer只能被new一次,如果重复new会触发崩溃,那么原本的动态new UnityPlayer然后添加到布局里面的方式就不能成功了,那我们应该怎么解决这个问题?
解决方案可以用一个简单的例子描述:如AB页面都是需要展示Unity动画模型的,我们从A页面打开B页面,那么在B的onResume方法中,我们直接把A上的unityView remove掉,再添加到B页面即可。
UnityPlayer只能被new一次,那么其实他是一个类似于单例的存在,全局只能有一个,我们可以使用叫做UnityPlayerManager的类来单独管理此UnityPlayer的创建与相关生命周期方法,外部统一从此Manager获取UnityPlayer。
public class UnityPlayerManager {
private static final String TAG = "UnityPlayerManager";
private static volatile UnityPlayerManager sInstance;
private HandlerThread mHandlerThread;
private Handler mHandler;
private UnityPlayer mUnityPlayer;
private ViewGroup mUnityContainer;
private UnityPlayerManager() {
mUnityPlayer = new UnityPlayer(ApplicationHelper.getInstance().getApplication());
mUnityPlayer.getView().setId(R.id.unity_player_view);
initHandlerThread();
}
private void initHandlerThread() {
if (mHandlerThread == null) {
mHandlerThread = new HandlerThread(TAG);
mHandlerThread.start();
}
if (mHandler == null) {
mHandler = new Handler(mHandlerThread.getLooper());
}
}
public static UnityPlayerManager getInstance() {
if (null == sInstance) {
synchronized (UnityPlayerManager.class) {
if (null == sInstance) {
sInstance = new UnityPlayerManager();
}
}
}
return sInstance;
}
public UnityPlayer getUnityPlayer() {
return mUnityPlayer;
}
如代码所示,我们在构造方法中初始化了UnityPlayer,外部只需要通过UnityPlayerManager.getInstance().getUnityPlayer()方法就能获取到UnityPlayer实例了。
public class UnityPlayerManager {
private HandlerThread mHandlerThread;
private Handler mHandler;
...
private void initHandlerThread() {
if (mHandlerThread == null) {
mHandlerThread = new HandlerThread(TAG);
mHandlerThread.start();
}
if (mHandler == null) {
mHandler = new Handler(mHandlerThread.getLooper());
}
}
public UnityPlayer getUnityPlayer() {
return mUnityPlayer;
}
public void pause() {
if (mUnityPlayer != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
mUnityPlayer.pause();
}
});
}
}
public void resume() {
if (mUnityPlayer != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
mUnityPlayer.resume();
}
});
}
...
}
如扇面代码段所示,为什么需要初始化一个HandlerThread的线程池,这是因为我们测试发现,在调用UnityPlayer.onPause和UnityPlayer.onResume方法,是相当耗时的,会影响到UI线程的绘制。所以我们使用一个异步队列来维护UnityPlayer的生命周期。任何关于UnityPlayer的生命周期方法,我们都使用一个异步队列进行包装,这里为什么使用队列的形式是因为希望能保持生命周期方法的有序执行。
public final class UnityPlayerManager {
private UnityPlayer mUnityPlayer;
private ViewGroup mUnityContainer;
...
public void addUnityView(ViewGroup unityContainer) {
if (unityContainer == mUnityContainer) {
return;
}
removePreviousContainerUnityView();
//添加UnityView到新容器中
mUnityContainer = unityContainer;
//添加unity布局
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
mUnityContainer.addView(UnityPlayerManager2.getInstance().getView(), params);
WTLog.d(Constants.TAG_ANDROID_UNITY_FLOW, getClass().getSimpleName() + " 添加UnityPlayer到Layout中" + unityContainer.toString());
}
/**
* 从旧的容器中移除UnityView
*/
public void removePreviousContainerUnityView() {
FrameLayout unityPlayer = UnityPlayerManager2.getInstance().getUnityPlayer();
ViewGroup parent = (ViewGroup) unityPlayer.getParent();
if (parent != null) {
WTLog.d(Constants.TAG_ANDROID_UNITY_FLOW, getClass().getSimpleName() + "从Layout中移除UnityPlayer" + parent.toString());
parent.removeView(unityPlayer);
}
}
...
}
最后我们再来描述最核心的逻辑removeView和addView。我们之前把UnityView添加到页面A中,是通过代码动态添加的,我们把原本应该写在A页面的addView的相关代码段,都迁移到这个UnityPlayerManager,A页面如果想添加View,就传入相关参数到此类中,让此类帮忙完成即可。
在addView完成后,UnityPlayerManager会把当前的容纳UnityPlayer的容器mUnityContainer的索引保存起来,如果打开B页面,再次调用addView方法,会首先检查mUnityContainer是否为空,如果不为空,说明有其他页面正在持有此UnityPlayer,我们先把它从容器中移除掉,再把UnityPlayer添加到B的容器中,最后再更新mUnityContainer的索引,让它代表的是B的容器。
最后,由于我们的页面架构是Activity+Fragment的形式,我们把刚才对于UnityPlayer的生命周期的管理以及addRemove等方法,都封装在BaseUnityActivity 与BaseCameraUnityFragment 中,别人的页面如果希望展示Unity的画面,直接继承我们的BaseUnityActivity 和BaseCameraUnityFragment 即可。
package com.meitu.uue.unity.base.activity;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.meitu.uue.app.common.base.activity.BaseAppCompatActivity;
import com.meitu.uue.unity.constants.Constants;
import com.meitu.uue.unity.manager.UnityPlayerManager;
import com.meitu.uue.unity.sdk.UnityManager;
/**
* 负责管理UnityPlayer的基类Activity,任何要显示Unity内容的Activity都必须继承此类
* <p>
* Created by guowenming on 2018/3/27.
*/
public abstract class BaseUnityActivity extends BaseAppCompatActivity {
@Override
protected void onPause() {
super.onPause();
UnityPlayerManager.getInstance().pause();
}
@Override
protected void onResume() {
super.onResume();
UnityPlayerManager.getInstance().resume();
}
@Override
protected void onDestroy() {
super.onDestroy();
}
@Override
public void onLowMemory() {
super.onLowMemory();
UnityPlayerManager.getInstance().lowMemory();
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (UnityManager.getInstance().isUnityInitialized()) {
return super.dispatchTouchEvent(ev);
} else {
return false;
}
}
@Override
public void onTrimMemory(int level) {
super.onTrimMemory(level);
if (level == TRIM_MEMORY_RUNNING_CRITICAL) {
UnityPlayerManager.getInstance().lowMemory();
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
UnityPlayerManager.getInstance().configurationChanged(newConfig);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
UnityPlayerManager.getInstance().windowFocusChanged(hasFocus);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_MULTIPLE)
return UnityPlayerManager.getInstance().injectEvent(event);
return super.dispatchKeyEvent(event);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
return UnityPlayerManager.getInstance().injectEvent(event);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
return UnityPlayerManager.getInstance().injectEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return UnityPlayerManager.getInstance().injectEvent(event);
}
public boolean onGenericMotionEvent(MotionEvent event) {
return UnityPlayerManager.getInstance().injectEvent(event);
}
}
可以看到,BaseUnityActivity 的代码其实和Unity帮我们写好的UnityPlayerActivity是差不多的,我们只是在生命周期方法里面重新包装了一下,使用异步队列形式来对Unity生命周期进行管理,避免页面跳转引发的卡顿的问题。
接下来我们展示BaseUnityFragment相关代码
public abstract class BaseUnityFragment extends BaseFragment {
protected ViewGroup mUnityContainer;
@Override
public void onResume() {
super.onResume();
UnityPlayerManager.getInstance().addUnityView(mUnityContainer);
}
}
这样基本的BaseUnityActivity 和BaseUnityFragment的基本框架就完成了。
4. BaseUnityActivity与BaseUnityFragment的使用实例
如果你需要在你的页面上快速展示Unity的动画模型,你可以使用刚才装好的BaseUnityActivyt和BaseUnityFragment类来进行快速接入。在快速接入的过程中,你需要完成以下几个步骤(假设这个新建立的Activity名为FatherActivity ):
- Activity继承BaseUnityActivity
public class FatherActivity extends BaseUnityActivity {
@Override
protected void onCreate(Bundle arg0) {
super.onCreate(arg0);
setContentView(R.layout.activity_father);
....
}
}
- 在FatherFragment的layout文件中,添加一个FrameLayout布局,此布局主要是用来放UnityPlayer用的。
...
<FrameLayout
android:id="@+id/unity_player_layout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
...
- 使FatherFragment继承BaseUnityFragment。继承完后,我们实例化好mUnityContainer ,这样在走到onResume中,BaseCameraUnityFragment 会把我们需要的UnityPlayer的view添加到mUnityContainer
public class FatherFragmentextends BaseCameraUnityFragment {
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_pixar_effect, container, false);
mUnityContainer = rootView.findViewById(R.id.unity_player_layout);
return rootView;
}
}
这样,你的界面上就能呈现出Unity的模型了,你可以在此基础上开始上层的业务开发了。
5. 结尾
在这一章中,我们介绍了如何把UnityPlayer放置在我们的布局中,并且解决了一个常见的问题:UnityPlayer全局只有一个的情况下,如果显示在多个页面中?最终我们通过动态add view和和remove view的方式来达到效果,并且把相关逻辑封装在了BaseUnityActivity 和BaseCameraUnityFragment 中,外部可以非常方便的使用我们封装的类,来快速接入Unity到我们的页面中
下一章中,我们将会讨论更为重要的问题,如果把相机的人脸数据传递给Unity,进而让Unity中的模型跟着人脸动的问题。