2012-11-29

Unity 3D + JmDNS + Android

此篇文章實作將 JmDNS 包裝成可以在 Android 平台上執行之 Unity3D Plugin。使用 JmDNS 可以方便行動裝置在區域網路內發現對方,以進行後續的連線。(此機制也稱為 Bonjour ,最初由 Apple 提出。)因為 iOS 平台的 NSNetService 也提供相同的功能,所以 Android 裝置和 iOS 裝置皆可以透過此機制取得對方的網路位置。底下為實作步驟:

A) 至 jmdns.sourceforge.net 取得 JmDNS 並利用 ant 進行編譯:
1) 以 osx 平台為例,在 terminal 輸入:
svn checkout https://jmdns.svn.sourceforge.net/svnroot/jmdns/tags/jmdns-3.4.1 jmdns

2) 待程式碼下載完成後進入 jmdns 資料夾輸入 :
ant jar

3) 完成之後進入資料夾 ./build/lib/ (此時應該找到檔案 jmdns.jar),並輸入底下指令:
mkdir unjar
cd unjar
jar xf ../jmdns.jar
jar cfm ../jmdns.jar META-INF/MANIFEST.MF javax/

目的在於去除重覆的 class 檔案,以避免 Android dex 工具編譯時出現錯誤。

B) 建立 Unity 專案並設定權限:
1) 將 jmdns.jar 檔案復製到 Unity 專案底下資料夾 (Assets/Plugins/Android)
2) 在 Assets/Plugins/Android 資料夾底下新增檔案 AndroidManifest.xml 並設定內容如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.jmdns" 
          xmlns:android="http://schemas.android.com/apk/res/android">
  <supports-screens android:anyDensity="true" android:smallScreens="true" 
                    android:normalScreens="true" android:largeScreens="true" 
                    android:xlargeScreens="true" />
  <application android:label="@string/app_name" 
               android:icon="@drawable/app_icon" android:debuggable="false">
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerProxyActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              />
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerNativeActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <meta-data android:name="android.app.lib_name" android:value="unity" />
      <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" 
                 android:value="false" />
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.VideoPlayer" 
              android:screenOrientation="behind" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" 
              />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="android.permission.INTERNET"/>
  <uses-permission 
    android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
</manifest>

值得注意的是 uses-permission 的部分,其設定網路使用權限使 JmDNS 在 Android 系統下能夠作用。

C) 撰寫使用 JmDNS 之 Java Class ( jmDNSWrapper.java ):
這裡不直接從 C# 透過 JNI 呼叫 JmDNS 之函數,而是先撰寫一個名為 JmDNSWrapper 的 Java Class 將 JmDNS 的使用簡化,再讓 Unity 透過 JNI 呼叫使用此 JmDNSWrapper Class,目的是簡化 JNI 的使用。此 Class 大略分為五個部分分別敘述如下

1) 初始化 jmDNS 及 wifi service:
public jmDNSWrapper()
{
    Log.i("jmDNSWrapper", "onStart()...");
    mActivity =UnityPlayer.currentActivity;
    if (wifi ==null){
        Log.i("jmDNSWrapper", "get wifi manager...");
        wifi = (android.net.wifi.WifiManager)mActivity.getSystemService(
            android.content.Context.WIFI_SERVICE
        );
    }
        
    if (lock ==null && wifi !=null){
        Log.i("jmDNSWrapper", "lock wifi multicast...");
        lock = wifi.createMulticastLock("mylockthereturn");
        lock.setReferenceCounted(true);
        lock.acquire();
    }
        
    if (mJmDNS ==null){
        try{
            mJmDNS =JmDNS.create();
        }
        catch(IOException e){
            e.printStackTrace();
        }
    } 
}

2) 結束此 class(停止 jmDNS):
public void dispose()
{
    Log.i("jmDNSWrapper", "onStop()...");
 
    stopListener();
    stopService();
        
    if (mJmDNS !=null){
        try{
            mJmDNS.close();
        }
        catch(IOException e){
            e.printStackTrace();
        }
        mJmDNS =null;
    }
        
    if (lock !=null){
        Log.i("jmDNSWrapper", "unlock wifi multicast...");
        lock.release();
    }
    lock =null; 
}

3) 發佈/停止 Service :
public void publishService(
    String strService, 
    String strDeviceName, 
    String strDesc, 
    int port)
{
    if (mJmDNS ==null)
        return;
        
    stopService();
        
    Log.i("jmDNSWrapper", "trying to create service...");
    try{
        ServiceInfo serviceInfo = 
            ServiceInfo.create(strService, strDeviceName, port, strDesc);
        mJmDNS.registerService(serviceInfo);
            
    }
    catch(IOException e){
        e.printStackTrace();
    }    
}
public void stopService()
{
    if (mJmDNS !=null){
        Log.i("jmDNSWrapper", "unregisterAllServices()...");
        mJmDNS.unregisterAllServices();
    }
}

4) 搜尋/停止 Service :
public void startListener(
    String strServiceType, 
    String strEventHandlerObjName)
{
    if (mJmDNS ==null)
        return;
        
    stopListener();
    mStrEventHandlerName =strEventHandlerObjName;
    mListener =new ServiceListener()
    {
        @Override
        public void serviceResolved(ServiceEvent e)
        {
            String additions = "";
            if (e.getInfo().getInetAddresses() != null && 
                e.getInfo().getInetAddresses().length > 0) {
                additions = 
                    e.getInfo().getInetAddresses()[0].getHostAddress();
            }
                
            Log.i("jmDNSWrapper", "service resolved: "+
                e.getInfo().getQualifiedName()+", port: "+
                e.getInfo().getPort()+", ip: "+additions);
 
            UnityPlayer.UnitySendMessage(
                mStrEventHandlerName, 
                "serviceResolved", "{\"name\":\""+
                e.getInfo().getQualifiedName()+"\",\"ip\":\""+additions+
                "\",\"port\":\""+e.getInfo().getPort()+"\"}");
        }
            
        @Override
        public void serviceRemoved(ServiceEvent e)
        {
            String additions = "";
            if (e.getInfo().getInetAddresses() != null && 
                e.getInfo().getInetAddresses().length > 0) {
                additions = 
                    e.getInfo().getInetAddresses()[0].getHostAddress();
            }
                
            Log.i("jmDNSWrapper", "service removed: "+e.getName());
            UnityPlayer.UnitySendMessage(mStrEventHandlerName, 
                "serviceRemoved", 
                "{\"name\":\""+e.getInfo().getQualifiedName()+
                "\",\"ip\":\""+additions+"\",\"port\":\""+
                e.getInfo().getPort()+"\"}");
        }
            
        @Override
        public void serviceAdded(ServiceEvent e)
        {
            mJmDNS.requestServiceInfo(e.getType(), e.getName(), 1);
        }
    };
        
    Log.i("jmDNSWrapper", "add service listener...");
    mStrServiceType =strServiceType;        
    mJmDNS.addServiceListener( mStrServiceType, mListener);
}
    
public void stopListener()
{
    if (mJmDNS !=null && mListener !=null){
        Log.i("jmDNSWrapper", "removeServiceListener()...");            
        mJmDNS.removeServiceListener( mStrServiceType, mListener );
    }
        
    mListener =null;
    mStrServiceType =null;
}
其中使用 UnitySendMessage 來傳送字串資訊給 Unity,字串格式為 JSON 格式。

D) 編譯 jmDNSWrapper.java:
與 jmDNS 相同,將 jmDNSWrapper 編譯成 jar 檔案,此文章假設使用者已安裝 Android SDK 且安裝之 Android 平台版本為 4.0.3 (API Level 15)。

1) 輸入指令,將 java 編譯成 jmDNSWrapper.class :
javac jmDNSWrapper.java -cp ~/Downloads/android-sdk-macosx/platforms/android-15/android.jar:../jmDNS.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .

此指令分別提供三個 jar 檔在編譯時使用。

2) 將 com 資料夾(資料夾按照 jmDNSWrapper.java 裡 "package com.macaronics" 產生)底下的 class 檔壓成 jar 檔案:
jar cvfM jmDNSWrapper.jar com/

3) 將 jmDNSWrapper.jar 檔案復製到 Unity 專案底下資料夾 (Assets/Plugins/Android)

E) Unity 引用 jmDNSWrapper :
這邊的目的是讓 jmDNSWrapper.java 裡的函數能在 C# 環境下呼叫。
public class jmDNSWrapper
{
    AndroidJavaObject mJmDNSWrapperObj = null;
    public void init()
    {
        if (mJmDNSWrapperObj != null){
            mJmDNSWrapperObj.Call("dispose");
            mJmDNSWrapperObj.Dispose();
            mJmDNSWrapperObj = null;
        }
        mJmDNSWrapperObj = 
            new AndroidJavaObject("com.macaronics.jmDNSWrapper");
    }
 
    public void dispose()
    {
        if (mJmDNSWrapperObj != null){
            mJmDNSWrapperObj.Call("dispose");
            mJmDNSWrapperObj.Dispose();
        }
        mJmDNSWrapperObj = null;
    }
 
    public void publishService(
        string strService,
        string strDeviceName,
        string strDesc,
        int port)
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("publishService",
            new object[4] { strService, strDeviceName, strDesc, port });
    }
 
    public void stopService()
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("stopService");
    }
 
    public void startListener(
        string strServiceType, 
        string strEventHandlerObjName){
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("startListener",
            new object[2] { strServiceType, strEventHandlerObjName });
    }
 
    public void stopListener()
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("stopListener");
    }
}
其利用 Unity 提供的 AndroidJavaObject 來呼叫連結外部 Java 程式碼。

F) 實際範例
最後撰寫一個簡單的範例來實際測試一下 jmDNS,此範例有兩個按鈕,分別是發佈與停止服務,還有將搜尋到的服務顯示在 Console Window。
public class main : MonoBehaviour
{
    string strServiceType = "_macaronics._tcp.local.";
    jmDNSWrapper pp = null;
 
    void Start()
    {
        pp = new jmDNSWrapper();
        pp.init();
 
        //設定服務名稱及 jmDNS 回呼之物件名稱
        pp.startListener(strServiceType, gameObject.name);
    }
 
    void OnDisable()
    {
        if (pp != null)
        {
            pp.dispose();
            pp = null;
        }
    }
 
    void OnGUI()
    {
        if (GUI.Button(new Rect(15, 125, 450, 100), "publish service"))
        {
            pp.publishService(strServiceType,
                "MACARONICS",
                "BONJOUR TEST FROM MACARONICS",
                9000);
        }
 
        if (GUI.Button(new Rect(15, 125 + 100, 450, 100), "stop service"))
        {
            pp.stopService();
        }
    }
 
    public void serviceResolved(string msg)
    {
        //顯示發現的服務
        Debug.Log(msg);
    }
 
    public void serviceRemoved(string msg)
    {
        //顯示移除的服務
        Debug.Log(msg);
    }
}

G) 最後實機測試
很不幸的在 Android Emulator 上無法做 wifi 連線測試,因此需要一台真正的 Android 手機。在 Unity 上設定好 Android 編譯參數後按下 Build And Run,順利的話會看到程式在手機上跑起來了。此時在 Android SDK 裡找到 adb 執行檔,執行 adb logcat 之後就可以看到 Unity 的 Log 訊息。

4 則留言 :

  1. 请问可以把jmDNSWrapper.java源码给一份吗?

    回覆刪除
    回覆
    1. ok, 在近日內會把整個專案擺在 github 上

      刪除
    2. 刚才已经把 jmDNSWrapper.java 补全了,没用过 java,费了点时间。
      但是我在真机上测试不成功,没有能发现服务,也许是服务发布的地方出了问题,正在查原因。

      有一个问题 strServiceType = "_macaronics._tcp.local."
      这个 type 是 bonjour 里面的 type 和 replyDomain 合在一起吗?

      刪除
    3. 测试成功了,发现一个问题,如果主机名字里面含有.的话,是无法被发现了,比如:
      publishService(strServiceType,
      "MACARONICS.lan",
      "BONJOUR TEST FROM MACARONICS",
      9000);

      刪除