🗿

Unity ARDKのwayspotをネイティブ地図画面で表示する

2023/06/05に公開

ARDKのドキュメントにwayspotに関する地図UIについての言及が出てきます。これを今回は実現したいと思います。
https://lightship.dev/ja/docs/ardk/vps/using_the_coverage_api.html#get-vps-localization-target-details

Lightshipから紹介されているのはWebViewでGoogleMapやMapboxを表示してみるという方法です。
そのため、当初WebViewを表示する方針でした。しかし、調べたところUnityでWebViewを表示するには何かしらのサードパーティのライブラリをインポートするのが一般的?のようでした。

WebViewを表示するだけでライブラリに頼るのが悔しいのと、iOS,Androidのネイティブで表示した方がUI的にも良さそうだろうと思い、今回はネイティブ画面で作成することにしました。

そもそもWaySpotとは?

ARDKでVPSが使用できる特徴地点のことです。Wayspotを地図で表示することによって自分の近くにどれだけVPSが使用できる場所があるかを直感的に理解できます。

iOS Android共通 UnityでWayspotのGeojsonを取得する。

C#側からWayspotのデータを送る必要があるので、ここはiOSとAndroidは共通です。
まず、ICoverageClientを作成します。

 private ICoverageClient _coverageClient;
 private RuntimeEnvironment _runtimeEnvironment = RuntimeEnvironment.Default;
 private ILocationService _locationService; // 現在地を取得するために必要
 private int _queryRadius = 2000; // WaySpotを探索する半径
 
  private void Start()
    {
      _locationService = LocationServiceFactory.Create();
        _coverageClient = CoverageClientFactory.Create(_runtimeEnvironment);
    }

このICoverageClientにWayspotを取得できるAPIが存在します。

public void RequestAreas()
    { 
        _coverageClient.RequestCoverageAreas(_locationService.LastData, _queryRadius, ProcessAreasResult);
    }
    
     private void ProcessAreasResult(CoverageAreasResult areasResult)
    {
        if (CheckAreaResult(areasResult))
        {
            SendAreaResult(areasResult);
        }
    }

RequestAreasメソッドに現在位置と探索する半径を引数に入れて、ProcessAreasResultでコールバックを受け取ります。CheckAreaResultはリクエストが成功している確認するだけのメソッドなので割愛します。

その後、リクエスト結果を取得します。

 private void SendAreaResult(CoverageAreasResult areasResult)
    {
	 var roots = new List<Root>();
        foreach (var area in areasResult.Areas)
        {

             var root = JsonMapper.ToObject<Root>(area.ToGeoJson());
	     roots.Add(root);
        }
	....送る処理
    }

Rootは[Serializable]をつけたクラスです。

[Serializable]
public class Root
{
    public string type;
    public List<Feature> features;
}

[Serializable]
public class Feature
{
    public string type;
    public Geometry geometry;
    public Properties properties;
}

[Serializable]
public class Geometry
{
    public string type;
    public List<List<List<double>>> coordinates;
}

[Serializable]
public class Properties
{
    public string location_type;
    public string localizability_quality;
    public List<string> location_target_identifiers;
}

WayspotはGeojsonの形になっているので既存のJsonUtilityは使用できません。他の方法で対応してください。
これでWayspotの情報がGeoJsonの形で取得できました。これをネイティブ画面に送ります。GeoJsonの形でそのまま送っても良いですし、使いやすい形に整形してから送るのも良いかなと思います。

iOS

iOSで地図UIを作成するので、MapKitを使用して実現します。
プラグインはSwiftで書く方針です。
プラグインの導入は以前記事にしたのでご参照ください。
https://zenn.dev/katopan/articles/31d9aac63da8b2

iOSプラグイン側でUnityの画面を取得します。Unityの画面はアプリの最前面にViewControllerとして表示されているだけなのでUIApplicationにUtilメソッドを定義して取得できるようにします。(他により良い方法はありそう。。)
Unity画面を取得後、画面遷移するか、もしくはaddSubViewでMapViewを追加してください。

import UIKit

extension UIApplication {
   
   private var rootViewControllerInKeyWindow: UIViewController? {
       if #available(iOS 13.0, *) {
           return UIApplication.shared.connectedScenes
               .map { $0 as? UIWindowScene }
               .compactMap { $0 }
               .first?
               .windows
               .filter { $0.isKeyWindow }
               .first?
               .rootViewController
       } else {
           // Fallback on earlier versions
           return nil
       }
   }

      // 最前面の画面を知るために用いる。
      class func keyWindowTopViewController(on controller: UIViewController? = UIApplication.shared.rootViewControllerInKeyWindow) -> UIViewController? {
          if let navigationController = controller as? UINavigationController {
              return keyWindowTopViewController(on: navigationController.visibleViewController)
          }
          if let tabController = controller as? UITabBarController,
              let selected = tabController.selectedViewController {
              return keyWindowTopViewController(on: selected)
          }
          if let presented = controller?.presentedViewController {
              return keyWindowTopViewController(on: presented)
          }
          return controller
      }
}

自分は子ViewControllerとして表示する形にしました。

@_cdecl("present_map_view")
public func prsentMapView() {
   guard let vc = UIApplication.shared.topViewController else { return }
   let mapVC = CustomViewController()
   vc.addChild(mapVC)
   vc.view.addSubview(mapVC.view)
   mapVC.didMove(toParent: vc)
}

// UnityDefaultViewControllerに戻る
@_cdecl("delete_map_view")
public func deleteMapView() {
   guard let vc = UIApplication.shared.topViewController else { return }
   vc.children.forEach { child in
       if (child is CustomViewController) {
           child.willMove(toParent: nil)
           child.view.removeFromSuperview()
           child.removeFromParent()
       }
   }
}

GeoJsonを受け取る様のメソッドを定義します。

@_cdecl("receive_map_info")
public func receiveMapInfo(geoJson: UnsafePointer<CChar>?) {
// GeoJsonを整形して、MKMapViewにマップをセットするメソッド。
let jsonStr = String(cString: geoJson!)
}

プラグイン用の関数は@_cdecl(Unity側から呼び出す関数名)の属性を与えてあげてください。これをすることでC言語の関数として認識されます。 Swift側で文字列を受け取るにはUnsafePointer<CChar>?で受け取ります。CCharはC言語に対応したString型で、UnsafePointerはポインタです。

MapKitのMKMapViewの使い方は割愛しますが、Wayspotの情報をiOSネイティブ側で受け取ることができれば勝利です。

Android

Androidで地図UIを作成する場合はGoogleMapを使用します。
GoogleMapを表示する方法は割愛しますがAPIKeyを発行して、AndroidManifestに追加したあと、Activityを作成するだけです。
プラグインはKotlinで書きます。
プラグインの導入は以前記事にしたのでご参照ください。
ただ言語はJavaになっているので微妙に異なっていますが方法は殆ど同じです。
https://zenn.dev/katopan/articles/31d9aac63da8b2

AndroidではUnityPlayerを使用することで簡単にUnityActivityを取得できます。
あとはIntentを使用して画面遷移するだけです。

import com.unity3d.player.UnityPlayer

 public fun presentMapView() {
       val activity = UnityPlayer.currentActivity;
      var intent = Intent(activity, MapsActivity::class.java)
      intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
      UnityPlayer.currentActivity.applicationContext.startActivity(intent)
   }

続いてGeoJsonを受け取ります。

public fun receiveMapInfo(geoJson: String) {
       // geoJsonを整形,Wayspotにマーカ-をセット
  }

AndroidObjectを使用してUnity側からGeoJsonが送られてくるのでAndroidネイティブ側でよしなに整形するだけです。

ただ、ここで問題があります。ビルドしたaarだけでは間違いなく動きません。理由は簡単で、aarとしてビルドしても依存しているライブラリは入らないからです。
なので、実行しようとするとライブラリが存在しないのでNoClassDefFoundErrorになります。
(自分のモジュールのbuild.gradleの依存関係は下記の様になっています)

dependencies {
    implementation 'com.google.android.gms:play-services-location:20.0.0'
    implementation 'com.google.android.gms:play-services-maps:18.1.0'
    compileOnly fileTree(dir: "libs", includes: ['*.jar'])
    implementation 'androidx.appcompat:appcompat:1.4.2'
    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0'
}

この問題を解決するためにこちらを使用します。
https://github.com/googlesamples/unity-jar-resolver
これはAndroid(iOSも)で使用されるライブラリを取得&依存関係を解決してくれる優れものです。
正直、 Androidプラグインを実装する上ではほぼ必須なのでは?と思っています。

Unityプロジェクトに入れた後、ExternalDependencyManager/Editor配下に〇〇Dependencies.xmlを作成します。
詳細な書き方はGithubを見て欲しいのですが、参考までに、自分のxmlファイルを貼っておきます。

<dependencies>
    <androidPackages>
        <repositories>
            <repository>https://repo.maven.apache.org/maven2</repository>
        </repositories>
        <androidPackage spec='com.google.android.gms:play-services-location:20.0.0' />
        <androidPackage spec='com.google.android.gms:play-services-maps:18.1.0' />
        <androidPackage spec='androidx.appcompat:appcompat:1.4.2' /> 
        <androidPackage spec='org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0'/>
       
    </androidPackages>
</dependencies>

xmlファイルを書いた後、Assets > External Dependency Manager > Android Resolver > Force Resolveの項目があるはずなので実行します。

無事、成功したらAndroidのプラグインの依存関係が解決されているはずです。

もし、それでもNoClassDefFoundErrorとなってしまった場合Kotlinの実行周りが怪しいかもしれません。
参考記事にもある通り、手動で同じバージョンのKotlinからzipファイルを落としてきてkotlin-stdlibを入れてみてください。(記事ではruntime-jarですが、現在では使用されていません。)
ただし、Unity-jar-resolverがすでに追加している可能性があります。気づかずに手動で追加すると重複して依存関係は解決できてもビルドできない状態になり得るので注意してください。

参考
https://qiita.com/kuroppe1819/items/9f9ab3cdaaa57aea21e9
https://github.com/JetBrains/kotlin

iOS、Android共に細かい実装の説明は省いてしまいましたが、大まかな流れは内容にできたのではないかと思います。

最後まで読んでいただきありがとうございました🙇

Discussion