Inside of LOVOT

GROOVE X 技術ブログ

LOVOTのお出迎え機能の中国向け対応について

こんにちは、APPチームの黒田です。 この記事は GROOVE X Advent Calendar 2023 の24日目の記事です。
LOVOTは2023年より中国での販売を開始しました。今回は、それに関連してLOVOTアプリの中国向け対応、中でも特に位置情報を使った機能の対応について紹介します。

アプリで中国向け対応が必要な機能

アプリの中国対応と聞いてすぐ思い浮かぶことと言えば、Googleのサービスが使えない、ということが挙げられますね。
LOVOTアプリでは、主にLOVOTのお出迎え機能の実現のため、位置情報を取得したりGeofenceの機能を利用する必要がありますが、Androidで位置情報取得といえば、通常、Google Play services の一部である Location API を利用することになります。
我々がよく見るおなじみの位置情報サービスの設定画面、こちらは Google Play services の機能によって実現されているという訳です。

おなじみの画面

さて、これが中国本土向けのAndroid端末だとどうなるかと言えば、端末によってはこの画面がある場合もあるし、無い場合もある、ということになっているようです。
GMS(Google Mobile Service)がインストールされた端末であれば Google Play が利用可能、また Xiaomi 端末にて Basic Google services の設定をオンにすることでGoolge位置情報サービスが有効化されるものもあったりします。

みなれない設定(Xiaomi端末)

なんにせよ、中国本土においては Google の Location API が利用できるという前提で開発ができない、ということになりますので、位置情報サービスが利用できるよう、別の手段を用意しなければなりません。
(なおiOSの場合は、何もしなくても日本国内向けアプリで実装した位置情報取得や Geofence の機能がそのまま使えます。素晴らしい。)

その他、FCM(Firebase Cloud Messaging)が使えない、Firestoreも使えない、Firebase Authentication を直接利用することも出来ない、電話番号によるログインがほぼ必須、などといった問題もあり、それぞれ対応が必要でしたが、以下、この記事では位置情報サービスの利用に話題を絞ります。

中国本土での位置情報サービスの利用

中国本土で位置情報サービスを使いたい、となると、中国で地図サービスを提供している企業がが開発者向けに用意している SDK を利用するのだろう、ということになります。
地図サービスの主なプロバイダーとして、百度地图(Baidu)高德地图(Amap)腾讯地图(Tencent) などがありますが、これらはどれも開発者向け SDK を提供しています。

それらの中から目的に合ったものを選んで利用すれば良さそうですね、という訳で、LOVOTアプリでは Baidu の SDK を利用することとなりました。
今回の記事では位置情報や Geofence の話題に絞っていますが、アプリでの地図データの取得にも Baidu のサービスを利用しています。中国での地図アプリのシェアは高德地图と百度地图が2大巨頭ですので、Baidu の地図を利用しておけばユーザーにとって馴染みのある表示にできる、ということもありますし、クオリティーにも信頼がおけそうです。
Baidu でなく高德地图の利用を検討することも可能でしたが、(今回の記事ではサービスの利用申請に関しては触れませんが)各サービスの利用のため開発者登録を行うためには、企業認証の申請を通すため中国法人側で書類を用意したりと手続きもやや面倒ですし時間もかかります。そういったこともあり、Baidu の SDK が必要な機能を満たしていること、また費用面でも高德地图と同等であるということが確認できたため、そのまま Baidu の SDK を利用しています。

座標系について

ここから実際の SDK の利用について説明したいのですが、その前に中国本土内での位置情報の座標系について触れておきます。
我々が普段地図で見ている緯度経度、開発者であれば Location API で取得したり、Maps API で地図データを取得するのに利用している緯度経度の情報は、WGS-84 と呼ばれる座標系における値となっています。
正直、私もそのようなことは意識したこともなかったのですが、ここで中国本土においては GCJ-02 と呼ばれる別の座標系が採用されている、ということが問題になります。
WGS-84 と GCJ-02 の相互変換アルゴリズムは公式には公開されていないため、簡単に言えば、中国本土内にてある位置の地図を取得しようと思えば、中国国内で提供されているサービスを使わざるを得ない、ということになります。
(例えば、中国本土でどうしても Google Maps API を利用したければ、中国のクラウドサービス上に Google Maps API へアクセスするProxyを実装する、といった荒業も可能かもしれませんが、それで取得できたマップは正しくないので意味がない、ということになります。)
座標系の問題も、Baidu などの SDK を利用せざるを得ない理由の一つという訳です。

左: Google Mapで取得した地図(赤丸が中心点)
右: Baiduで取得した同じ座標の地図(赤丸がGoogle Mapでの中心点)

さて、Baidu の SDK により位置情報を取得した際は、座標系も一緒に返されるようになっています。ここで、必ずしも GCJ-02 で取得できるとは限らない、というのもポイントです。SDK を利用して位置情報の取得をテストしてみると、WGS-84 と GCJ-02、どちらで取得されることもある、ということが分かります。
Geofence の登録時にも座標系を一緒に登録できるようになっており、SDK 内では座標系の相互変換が可能なため、WGS-84 と GCJ-02、どちらで取得された位置情報であっても、正しく Geofence が動作する、ということになっているようです。
(実は、更に BD-09 などという Baidu 独自の座標系もあるのですが、ともかく Baidu の SDK を利用している限りこれ以上気にすることはありません)

SDKの導入

SDKの導入については、ドキュメントにコード例も記載されているので、基本的にそれに従って実装すれば良い、となります。
とはいえ実際に実装してみると上手く動かない、ということもあると多々思いますので、以下、LOVOTアプリの場合のコード例とともに、幾つかポイントについて説明しておきたいと思います。
前提として、LOVOTのお出迎え機能がオンの間は、常にスマホの Geofence のイベントを監視していたいため、Foreground Service を起動するようにしています。以下のコード例は、そのサービスのコードからの関連部分を抜粋し編集したものであり、実際のコードとは異なります。
なお、LOVOTアプリは Unity で作られているため、これらの機能は Unity のネイティブプラグインとして実装されています。

public class LocationUpdateService extends Service {
    private GeoFenceClient geofenceClient;
    private LocationClient locationClient;
    private LocationUpdateCallback currentCallback;
    private GeofenceBroadcastReceiver geofenceBroadcastReceiver;

    interface OnLocationResultListener {
        void onReceiveLocation(BDLocation location);
    }

    public class LocationUpdateCallback extends BDAbstractLocationListener {
        OnLocationResultListener listener;

        public void Initialize(OnLocationResultListener listener) {
            this.listener = listener;
        }

        @Override
        public void onReceiveLocation(BDLocation location) {
            listener.onReceiveLocation(location);
        }
    }

    @Override
    public void onCreate() {
        // LocationClient や GeoFenceClient の初期化を行う
        // 国内向けアプリでは FusedLocationProviderClient の取得のみを行っていたところ

        super.onCreate();

        try {
            LocationClient.setAgreePrivacy(true);
            locationClient = new LocationClient(this);
        } catch (Exception e) {}
            return;
        }

        LocationClientOption option = new LocationClientOption();
        option.setLocationMode(LocationClientOption.LocationMode.Hight_Accuracy);
        option.setCoorType("gcj02");
        option.setFirstLocType(LocationClientOption.FirstLocType.ACCURACY_IN_FIRST_LOC);
        option.setScanSpan(60 * 1000);
        option.setOpenGnss(true);
        option.setLocationNotify(true);
        option.setIgnoreKillProcess(true);
        option.SetIgnoreCacheException(false);
        option.setWifiCacheTimeOut(60 * 1000);
        option.setEnableSimulateGnss(false);
        option.setNeedNewVersionRgc(true);
        locationClient.setLocOption(option);

        IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
        // Geofence イベントの通知を受ける GeofenceBroadcastReceiver は別途実装しておく
        filter.addAction(GeofenceBroadcastReceiver.ACTION_PROCESS_UPDATES);
        geofenceBroadcastReceiver = new GeofenceBroadcastReceiver();
        registerReceiver(geofenceBroadcastReceiver, filter);

        geofenceClient = new GeoFenceClient(this);
        geofenceClient.setActivateAction(GEOFENCE_IN_OUT);
        geofenceClient.createPendingIntent(GeofenceBroadcastReceiver.ACTION_PROCESS_UPDATES);
        // 条件が満たされれば事実上無限に Geofence を発火させるようにしておく
        geofenceClient.setTriggerCount(100000, 100000, 0);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();

        if (locationClient != null) {
            locationClient.stop();
        }
        if (geofenceBroadcastReceiver != null) {
            try {
                unregisterReceiver(geofenceBroadcastReceiver);
            } catch (Exception ex) {}
        }
        if (geofenceClient != null) {
            geofenceClient.removeGeoFence();
        }
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.getAction().equals(START_ACTION)) {
            start(intent, startId);
            return START_REDELIVER_INTENT;
        } else {
            stop(intent, startId);
            return START_NOT_STICKY;
        }
    }

    private void start(Intent intent, int startId) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundWithNotification();
        }
        startGeofencing();
        startLocationUpdates();
    }

    private void stop(Intent intent, int startId) {
        if (geofenceClient != null) {
            // removeGeoFence() で削除するのではなく Geofence 登録を残したまま pause & resume で制御するようにしている
            geofenceClient.pauseGeoFence();
        }
        stopLocationUpdates();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // onStartCommand 後、5秒以内に startForeground が呼ばれないとANRとなる
            startForegroundWithNotification();
            stopForeground(true);
        }

    }

    private void startForegroundWithNotification() {
        Notification notification = createNotification(this);
        startForeground(1, notification);
    }

    public Notification createNotification(Context context) {
        // Notification を生成して返す
    }

    private void startLocationUpdates() {
        if (currentCallback != null) {
            stopLocationUpdates();
        }

        // 実際にはここでGPSオンの確認や、位置情報取得パーミッションのチェックを行う

        final GeoFenceClient geofenceClient = this.geofenceClient;
        final LocationUpdateCallback callback = new LocationUpdateCallback();
        OnLocationResultListener listener = new OnLocationResultListener() {
            @Override
            public void onReceiveLocation(BDLocation location) {
                int locType = location.getLocType();
                // https://mapopen-pub-androidsdk.cdn.bcebos.com/location/doc/v9.4.0/constant-values.html
                // TypeGpsLocation 61
                // TypeNetWorkLocation 161
                // TypeOffLineLocation 66
                boolean isSucceeded = locType == BDLocation.TypeGpsLocation || locType == BDLocation.TypeNetWorkLocation || locType == BDLocation.TypeOffLineLocation;
                if (isSucceeded) {
                    // 適宜ログ出力するなど
                }

                // pause 状態になっていることがあったので、resume するようにしている
                if (geofenceClient.isPause()) {
                    geofenceClient.resumeGeoFence();
                }
            }
        };
        callback.Initialize(listener);
        currentCallback = callback;

        locationClient.registerLocationListener(callback);
        locationClient.start();
        geofenceClient.resumeGeoFence();

    }

    private void startGeofencing() {
        // SharedPreferences に保存済みの設定データ取得し、設定に従って Geofence を登録するようにしている
        // (実際には複数の Geofence 設定が存在する場合があるのでそれぞれ登録)
        // また、削除されていた設定に対応するGeofenceの解除も行うようにしている

        JSONObject config = getGeofenceConfig();
        final double latitude = config.getDouble("Latitude");
        final double longitude = config.getDouble("Longitude");
        final float radius = 100F;

        final GeoFenceClient geofenceClient = this.geofenceClient;
        GeoFenceListener fenceListener = new GeoFenceListener() {
            @Override
            public void onGeoFenceCreateFinished(List<GeoFence> geoFenceList, int errorCode, String customId) {
                geofenceClient.setGeoFenceListener(null);
                if (errorCode == GeoFence.ADDGEOFENCE_SUCCESS) {
                    // 適宜ログ出力するなど
                }
            }
        };

        geoFenceClient.setGeoFenceListener(fenceListener);
        DPoint centerPoint = new DPoint(latitude, longitude);
        geoFenceClient.addGeoFence(centerPoint, GeoFenceClient.WGS84, radius, customId);
    }

    private void stopLocationUpdates() {
        if (locationClient == null || currentCallback == null) {
            return;
        }
        locationClient.stop();
        locationClient.unRegisterLocationListener(currentCallback);
        currentCallback = null;
    }
}

イベント受信側については、BroadcastReceiver で受け Intent の Action や Extra にイベント種別など必要な情報が格納されている、という形なので、Google の Geofencing API を利用する場合と同じような実装となり、特に困ることはないでしょう。

以下、コード内のコメントでは説明できていないポイントについて補足しておきます。

登録した Geofence は、GeoFenceClient オブジェクトが破棄されれば無効となる

当然といえば当然なのですが、国内向けに Google の GeofencingClient を利用していた際は、一度 Geofence を登録すればアプリ側でその生存期間を気にする必要はなかったのですが、Baidu の GeoFenceClient の場合はオブジェクト破棄とともに Geofence が無効となるため、Foregroud Service内の処理側で必要に応じて毎回登録処理を行うようにしています。

LocationClient による位置情報取得を開始していないと Geofence のイベントは発火しない

公式ドキュメントには GeoFenceClient に Geofence を登録すれば内部的に位置情報取得も行われるというような記載があるのですが、それだと全く Geofence イベントが発火しませんでした。よって、明示的に LocationClient の開始を行っておく必要がありました。

Geofence イベントの発火には制限回数が必要

GeoFenceClient が生きている限り Geofence は有効なのですが、初期化時に制限回数をセットする必要があり、実質無限となるように十分大きな数をセットしています。セットしない場合、全くイベントが発火しなくなる現象が発生し、また無制限を指定するような方法は無さそうでした。

位置情報取得を止めない

国内向けのLOVOTアプリでは、状況に応じて位置情報の取得頻度を下げるような処理を行っているのですが、Baidu の LocationClient では位置情報取得の中断/再開処理をアプリ側で制御していると Geofence イベントが極端に発火しづらくなってしまいます。LocationClient の初期化時に位置情報取得の最低間隔を指定できるので、それによって制御するに留めるようにしています。

動作確認

アプリの開発は全て日本国内で行っており、位置情報を利用する機能について、実際に中国で動作させたときの挙動は、最終的には中国国内で確認する必要があります。
これには、中国側のスタッフや中国出張中のメンバーの手を借りることとなりました。
出張メンバーは中国語が分からないため、中国スマホを別のスマホのGoogleアプリで撮影して翻訳しながら作業する、といった技を編み出していたようです。こういった技を駆使しながら中国の街中をうろうろしていてもらっていたかと思うと、感謝しかありません。

スマホを撮影する出張メンバー

最後に

実は、アプリの中国対応をするにあたって何と言っても困るのは、中国語の情報しかない、ということだったりするかも知れません。
Baidu の SDK の開発者ドキュメントも英語版はありません。検索しようにもどう検索すれば良いかが分からなかったり、関連するワードで検索したとしても見つかる情報が少なかったり。もちろん、ChatGPTも正しいことは教えてくれませんでした。普段、大量の日本語や英語の情報に助けられて開発していることを痛感します...。
中国向けに位置情報アプリの対応を行う、といった機会はあまり多くはないかも知れませんが、今回の記事がいつか誰かの役に立てば幸いです。

Google翻訳されたドキュメント。コードなども微妙に翻訳されてしまうあるある。どうにかする良い方法あるんでしょうか


さて、GROOVE X では、一緒にアプリを開発してくれる方も募集中です。
Unity といえばゲームやVRなどの開発が殆どかと思いますが、それらとは一味違う開発の経験が可能です。大量の3Dオブジェクトのリアルタイム制御、美麗なグラフィック、というような開発要素はほぼ無いのですが、アプリと連携するクラウド側やLOVOT内で動作するサービスの開発、ネイティブの機能を利用するためのプラグイン開発などに興味のある方がいらっしゃれば、是非よろしくお願いします。

recruit.jobcan.jp