GPS位置情報を反映させる(アンドロイド地図アプリの開発 その4)

さて、今回も地図アプリ開発の続きです。なんか、トレランのブログらしくなくなってきましたが。
今日の体調は、喘息もだいぶ収まっていい感じにはなってきています。体調を整えるためにたまっているリフレッシュ休暇を消化することにしたので、アプリの開発を続けています。
喘息は良くなってきましたが、期待したような高地トレーニングの効果は表れないようです。普通に病み上がりの状態で、体力が落ちてちょっとだるい感じですね。昨日は、子供の付き添いで、イベントに行ってきましたが、2時間以上も立ちっぱなしでくたびれてしまいました。こんなことではいかんと思っているところです。3週間後にレースがあるのでこれから状態を上げていかないとと思っているところです。

アプリ開発の話に戻って、本日のお題は、GPSの位置情報を地図に反映させて位置を表示するということをやります。
現在地に丸マークを表示する方法は、mapsforgeのサンプルプログラムにあるので、それを導入すれば済む話です。と言ってしまえばことは簡単であえてブログにやり方を書くまでもないようですが、そこらへんは素人の悲しさで、今回も結構苦労しました。

GPSで位置情報を得る方法

スマホにはGPSが搭載されていて、GoogleMap等の地図アプリで現在地や目的地までのルートを確認できます。我が地図アプリにもGPS機能を搭載してやろうと思います。
AndroidGPSで位置情報を取得するには、最近ではFused Location Providerというのが推奨されているようです。これは、従来のLocation Managerに比べてバッテリーの消費を適切に抑制できるものらしいです。しかしながら、このプリでは、もっぱらGPSを使って位置情報取得し、常に最新の位置を把握するのが目的ですから、バッテリー消費については目をつぶることにして、あえてLocation Managerを使って位置情報を取得することにします。(単に新しい方法のやり方がわからないという話もあります。)
Location Managerを使う方法ですが、Location Managerのインスタンスを作成、requestLocationUpdatesのメソッドで位置情報を取得し、Location Listenerでコールバックを受け取るというのが基本的な流れのようです。
ベースになるプログラムは、前回のファイルマネージャーを実装したものでなく、シンプルにベルリンの地図を表示するものにします。
alasixosaka.hatenablog.com

こいつに、GPSの機能を追加するわけですが、今回もエミュレータを使い、疑似的に位置を表示させることにします。開発はなるべくエミュレータを使っていきたいと思っているのですが、本当にGPSが機能するのか確かめるためには実機を使う必要があるので、エミュレータの使用はおそらく今回が最後になりそうです。
まず、GPSを使用するのにパーミッションが許可が必要ですので、AndroidManifest.xmlに以下の一文を追加します。

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

次に、パーミッションに対するリクエストコードを定義します。MainActivityのonCreate()より前の部分に書きます。

private final static int PERMISSION_REQUEST_CODE = 1;
private final static int PERMISSION_GPS_CODE = 1001;

最初の行は、外部フォルダに対するパーミッションのリクエストコードで、ベースのプログラムに記述されているものです。
GPSに対するパーミッションのリクエストコードが次の行で、PERMISSION_GPS_CODEとし、値を1001にして区別しています。

次に、MainActivityのonCreate()の下にGPS機能を有効にする処理を書き加えます。

LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
GPSLocationListener locationListener = new GPSLocationListener();
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)  {
    String[] permissions = {Manifest.permission.ACCESS_FINE_LOCATION};
    ActivityCompat.requestPermissions(MainActivity.this, permissions, 1001);
    return;
}
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListener);

最初の行がLocation Managerオブジェクトの取得、次の行がリスナオブジェクトの生成、3行目以下がパーミッションチェック、最後の行がGPSの有効化になります(たぶん)。
次にパーミッションダイアログに対する処理ですが、このアプリには、すでに外部フォルダにアクセスするパーミッション処理があり、ダイアログ処理の記述があります。そこで、その部分に少し手を加えます。

public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        switch(requestCode) {
            case PERMISSION_REQUEST_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    displayMap();
                    return;
                }
            case PERMISSION_GPS_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                    LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
                    GPSLocationListener locatioListener = new GPSLocationListener();
                    if(ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION)
                    != PackageManager.PERMISSION_GRANTED){
                        return;
                    }
                    locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,0,0,locatioListener);
                }
            default:
        }

        // アプリを終了
        this.finish();
    }

switch(requestCode)で処理を分岐します。requestCodeがPERMISSION_GPS_CODEでパーミッションが許可されたら、上の部分と同様に、Location Managerオブジェクトの取得、リスナオブジェクトの生成、GPSで位置情報取得の開始という順に処理が進みます。

次に、対応するリスナクラスを作成します。

private class GPSLocationListener implements LocationListener {
        @Override
        public void onLocationChanged(Location location){
            ~この部分に位置が変化したときの処理を記述する~
        }
        @Override
        public void onStatusChanged(String provider, int status, Bundle extras){}

        @Override
        public void onProviderEnabled(String provider){}

        @Override
        public void onProviderDisabled(String provider){}
    }

onLocationChanged()以下は、位置情報が変化したときの処理を記述します。ここでは、マップ上の丸マークを動かして、その位置をセンターにするという処理になりますが、詳細は後で書きます。
onStatusChanged()、onProviderEnabled()、onProviderDisabled()は処理を何も書かなくても必要だということです。ないとエラーになります。
これで、位置情報が取得できるようになりました。

地図上に現在地をマークする

次に、地図上に現在地をマークする方法ですが、mapsforgeのサンプルプログラムの中のLocationOverLayMapViewerの部分を参考にしました。といっても、サンプルプログラム自体が色々な処理のかたまりで複雑なうえ、それぞれの処理を別のアクティビティとして書いてあるので、最初はさっぱりわかりませんでした。今回も伊勢在住のプログラマーさんの記事
mapsforge でポップアップするマーカーを試す - プログラマーのメモ書き
を参考にして、プログラムを見比べながら試行錯誤でようやくコールにたどり着きました。
参考サイトでは、地図のある地点をロングタップしたときに、マーカーが表示されるようになっています。そこで、まず、マーカーを表示させる部分を読み比べてみました。
mapsforge本家のサイトでは、マーカーを表示させる部分は

protected void createLayers() {
        super.createLayers();

        // marker to show at the location
        Drawable drawable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? getDrawable(R.drawable.ic_maps_indicator_current_position) : getResources().getDrawable(R.drawable.ic_maps_indicator_current_position);
        Marker marker = new Marker(null, AndroidGraphicFactory.convertToBitmap(drawable), 0, 0);

        // circle to show the location accuracy (optional)
        Circle circle = new Circle(null, 0,
                getPaint(AndroidGraphicFactory.INSTANCE.createColor(48, 0, 0, 255), 0, Style.FILL),
                getPaint(AndroidGraphicFactory.INSTANCE.createColor(160, 0, 0, 255), 2, Style.STROKE));

        // create the overlay
        this.myLocationOverlay = new MyLocationOverlay(marker, circle);
        this.mapView.getLayerManager().getLayers().add(this.myLocationOverlay);
    }

で、参考サイトでは

   private boolean setMarker(LatLong latlong) {

        Marker marker = createMarker(latlong, R.drawable.marker_red);
        mapView.getLayerManager().getLayers().add(marker);

        return true;
    }

    private Marker createMarker(LatLong latlong, int resource) {
        Drawable drawable = getResources().getDrawable(resource);
        Bitmap bitmap = AndroidGraphicFactory.convertToBitmap(drawable);
        return new Marker(latlong, bitmap, 0, -bitmap.getHeight() / 2);
    }

となっています。参考サイトには、「Markerというクラスをインスタンス化してMapViewに追加する」とさらっと書いてありますが、理解するのに色々調べる必要がありました(悲しい)。
まず、Markerクラスのインスタンス化ですが、
Marker marker = createMarker(latlong, R.drawable.marker_red);
の部分だと思われます。Markerクラスそのものは最後の
private Marker createMarker(LatLong latlong, int resource) {
以下の部分です。
そこまでは何とかなったんですが、R.drawable.marker_redが何のことやらわからず、同じようにAndroid Studioに書いてもエラーになってさっぱりわからず苦労しました。
ようやくわかったことは、R.はいわゆるRクラスで、データはresフォルダに入っている。そこでresフォルダを見ると、drawableというフォルダがある。そこで、参考サイトのプログラムをAndrodi Studioで開いて、resフォルダの中を見ると、drawable-mdpiというフォルダがあるので中を見てみると、marker_red.pngというファイルがある。どうやら、これがバルーンのマーカーの正体らしい。でもって、Markerクラスで、このファイル(ビットマップ形式)を変換してmarkerに入れているようだ。
そう思って、本家のプログラムを見ると、

Drawable drawable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? getDrawable(R.drawable.ic_maps_indicator_current_position) : getResources().getDrawable(R.drawable.ic_maps_indicator_current_position);

とある。
ic_maps_indicator_current_position
というのをマーカーに使っているようだ。そこで、本家のプログラムのresフォルダを見て、ファイルを拾ってきてコピーした。コピペはAndroid Studio で該当フォルダを右クリックすればできる。
もうひとつのMapViewに追加するという部分は、参考サイトでは
mapView.getLayerManager().getLayers().add(marker);
となっており、本家サイトでは
this.mapView.getLayerManager().getLayers().add(this.myLocationOverlay);
となっている。参考サイトではmarkerそのものをaddしているのに対し、本家はmyLocationOverlayをaddしている。myLocationOverlayはその上の行に
this.myLocationOverlay = new MyLocationOverlay(marker, circle);
という記述があって、markerとcircleを引数にオブジェクトを生成している(ようだ)。circleはさらにその上に記述があって

Circle circle = new Circle(null, 0,
                getPaint(AndroidGraphicFactory.INSTANCE.createColor(48, 0, 0, 255), 0, Style.FILL),
                getPaint(AndroidGraphicFactory.INSTANCE.createColor(160, 0, 0, 255), 2, Style.STROKE));

となっている。getPaintは別にクラスがあって、

private static Paint getPaint(int color, int strokeWidth, Style style) {
        Paint paint = AndroidGraphicFactory.INSTANCE.createPaint();
        paint.setColor(color);
        paint.setStrokeWidth(strokeWidth);
        paint.setStyle(style);
        return paint;
    }

ちなみに、ic_maps_indicator_current_position.pngが地図に表示されるマーカーそのもののようで、circleはなにを表しているのかよくわからない。マーカーだけでもいいような気がするのだが、MyLocationOverlayには2つの引数が必要なので、訳も分からずそのまま使っている。なんのことやらさっぱりな状態なのだが、とりあえず、このままコピーしてもエラーにならないのでまあいいかということにした。
そして、さっきのGPSの記述のところで、後で書くことにした、位置情報に変化があった時の処理が次の2行。

myLocationOverlay.setPosition(location.getLatitude(), location.getLongitude(), location.getAccuracy());
mapView.setCenter(new LatLong(location.getLatitude(), location.getLongitude()));

myLocationOverlayのsetPositionメソッドで、GPSから得た位置情報を記入している。緯度はlocation.getLatitude()、経度はlocation.getLongitude()、精度がlocation.getAccuracy()、精度が必要な理由がよくわからないが、とりあえず、本家の記述をそのまま写している。
そして、mapViewのsetCenterメソッドで、地図の中心を現在地にしている。

f:id:alasixOsaka:20190701153606j:plain
GPS疑似機能を使って現在地を表示
エミュレータのメニュ―(三つの丸)をクリックすると、GPSの位置情報を変更することできる。位置情報の数字を変えるとマークの位置が動いて、地図の中心になる。
f:id:alasixOsaka:20190701153842j:plain
メニューを開くとGPSの位置情報を書き換えできる

f:id:alasixOsaka:20190701160927j:plain
現在地を少し動かしてみた
MainActivityの全文は次のようになった。

public class MainActivity extends AppCompatActivity {
    // Name of the map file in device storage
    private static final String MAP_FILE = "berlin.map";

    private final static int PERMISSION_REQUEST_CODE = 1;
    private final static int PERMISSION_GPS_CODE = 1001;

    private MyLocationOverlay myLocationOverlay;


    private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //setContentView(R.layout.activity_main);
        createInstance(getApplication());

        LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
        GPSLocationListener locationListener = new GPSLocationListener();
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)  {
            String[] permissions = {Manifest.permission.ACCESS_FINE_LOCATION};
            ActivityCompat.requestPermissions(MainActivity.this, permissions, 1001);
            return;
        }
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListener);

        mapView = new MapView(this);
        //setContentView(R.layout.activity_main);
        setContentView(mapView);
        /*
         * We then make some simple adjustments, such as showing a scale bar and zoom controls.
         */
        mapView.setClickable(true);
        mapView.getMapScaleBar().setVisible(true);
        mapView.setBuiltInZoomControls(true);

        /*
         * To avoid redrawing all the tiles all the time, we need to set up a tile cache with an
         * utility method.
         */

        final int permission = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE);
        if (permission != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE);
        } else {
            displayMap();
        }
    }
    @Override
    protected void onDestroy() {
        /*
         * Whenever your activity exits, some cleanup operations have to be performed lest your app
         * runs out of memory.
         */
        mapView.destroyAll();
        clearResourceMemoryCache();
        super.onDestroy();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        switch(requestCode) {
            case PERMISSION_REQUEST_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    displayMap();
                    return;
                }
            case PERMISSION_GPS_CODE:
                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                    LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
                    GPSLocationListener locatioListener = new GPSLocationListener();
                    if(ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION)
                    != PackageManager.PERMISSION_GRANTED){
                        return;
                    }
                    locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,0,0,locatioListener);
                }
            default:
        }

        // アプリを終了
        this.finish();
    }

    private void displayMap() {

        try {

            TileCache tileCache = AndroidUtil.createTileCache(this, "mapcache", mapView.getModel().displayModel.getTileSize(), 1f, mapView.getModel().frameBufferModel.getOverdrawFactor());
            File mapFile = new File(getExternalStorageDirectory().getPath(), MAP_FILE);
            MapDataStore mds = new MapFile(mapFile);
            TileRendererLayer trl = new TileRendererLayer(tileCache, mds, mapView.getModel().mapViewPosition, INSTANCE) {
                @Override
                public boolean onLongPress(LatLong tapLatLong, Point layerXY, Point tapXY) {
                    return setMarker(tapLatLong);
                }
            };

            Drawable drawable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? getDrawable(R.drawable.ic_maps_indicator_current_position) : getResources().getDrawable(R.drawable.ic_maps_indicator_current_position);
            Marker marker = new Marker(null, AndroidGraphicFactory.convertToBitmap(drawable), 0, 0);
            Circle circle = new Circle(null, 0,
                    getPaint(AndroidGraphicFactory.INSTANCE.createColor(48, 0, 0, 255), 0, Style.FILL),
                    getPaint(AndroidGraphicFactory.INSTANCE.createColor(160, 0, 0, 255), 2, Style.STROKE));

            // create the overlay

            trl.setXmlRenderTheme(InternalRenderTheme.DEFAULT);

            mapView.getLayerManager().getLayers().add(trl);

            //mapView.setCenter(new LatLong(34.491297, 136.709685)); // 伊勢市駅
            mapView.setCenter(new LatLong(52.517037, 13.38886));
            mapView.setZoomLevel((byte) 12);
            myLocationOverlay = new MyLocationOverlay(marker, circle);
            mapView.getLayerManager().getLayers().add(myLocationOverlay);
        }catch (Exception e) {
            /*
             * In case of map file errors avoid crash, but developers should handle these cases!
             */
            e.printStackTrace();
        }
    }
    
    private static Paint getPaint(int color, int strokeWidth, Style style) {
        Paint paint = AndroidGraphicFactory.INSTANCE.createPaint();
        paint.setColor(color);
        paint.setStrokeWidth(strokeWidth);
        paint.setStyle(style);
        return paint;
    }
    private class GPSLocationListener implements LocationListener {
        @Override
        public void onLocationChanged(Location location){
            myLocationOverlay.setPosition(location.getLatitude(), location.getLongitude(), location.getAccuracy());
            mapView.setCenter(new LatLong(location.getLatitude(), location.getLongitude()));
        }
        @Override
        public void onStatusChanged(String provider, int status, Bundle extras){}

        @Override
        public void onProviderEnabled(String provider){}

        @Override
        public void onProviderDisabled(String provider){}
    }

}

これで、ようやく地図アプリらしくなってきたが、今は、地図の向きは常に北向きで、進行方向に向いていないので、次は進行方向と地図の向きを合わせるようにしてみたい。

参考にしたサイト
mapsforge/LocationOverlayMapViewer.java at c8294c7ae27b2ce4a6af56516216af8c6d7cb0b3 · mapsforge/mapsforge · GitHub
mapsforge でポップアップするマーカーを試す - プログラマーのメモ書き
LocationManagerはもう古い!Google Service の Location APIを使って現在位置を取得する - Androidはワンツーパンチ 三歩進んで二歩下がる
Android位置情報の取得 - Qiita