新アンドロイド地図アプリの開発(その1) まずは地図を表示させてみる

再三このブログで書いている通り、オリジナルの地図アプリがAndroid12以上では正常に動作しないことが判明し、新しくアプリを開発することになりました。
alasixosaka.hatenablog.com
alasixosaka.hatenablog.com
Androidの仕様も、Android APIの仕様もどんどん変わるのでなかなかこっちの頭がついていかないで苦戦していますが、とりあえず新シリーズとして記事にしていきたいと思っています。
まずは、地図が表示できないと話にならないので、前回やったことの繰り返しみたいになりますが、ベルリンの地図を表示させるところから始めてみたいと思います。

Getting Startedを試す

たんに地図を表示させるだけなら、オリジナルサイトのGettingStarged.javaを動かせば表示させることができます。
github.com

ただし、前のアプリを開発するときにも書きましたが、オリジナルサイトのサンプルプログラムは複雑怪奇かつReadMeがとっても不親切なので、(おそらく)誰かに助けてもらわないといきなり動かすのは難しいのではないかと思います。これには、Androidソースコードのシステムが非常に複雑であるという理由も関係していると思います。単純なプログラムを動かすだけでも、いろいろなファイルをの集合体としてのプロジェクトができてしまい、それらをちゃんと書いてあげないと動ないというようになっています。
そのあたりのファイルの書き方は、入門書や入門者向けのサイトなどを見てもらうとして、ここではある程度知識があるという前提で下記進めていきます。
まず、オリジナルサイトのGettingStarted.javaですが少しずつ読み進めていきます。
まず、冒頭の部分

public class GettingStarted extends Activity {

    // Request code for selecting a map file
    private static final int SELECT_MAP_FILE = 0;

    private MapView mapView;

まず、GettingStartedというクラスを定義しています。これは、サンプルプログラムが沢山のプログラムの塊からなっていて、そのメインプログラムからそれぞれを呼び出す形になっているためで、GettingStartedを単独で動かすときにはこの定義は不要になります。代わりに、プロジェクトを作成したときにできるMainActivityの中に書いていけば良いということになります。
その下は、マップファイルが選択されているかどうかを示すフラグSELECT_MAP_FILEを定義していて、初期値は0で、定義していないということを示しています。後で書きますが、このサンプルファイルでは以前のものと違ってマップファイルを選択できるようにしてありますが、今回私が作ったプログラムでは面倒なのでファイル名を固定にして選択する部分は省略しています。
その下は、ビューの定義で、MapViewというのを定義しています。

次は、onCreatです。

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        /*
         * Before you make any calls on the mapsforge library, you need to initialize the
         * AndroidGraphicFactory. Behind the scenes, this initialization process gathers a bit of
         * information on your device, such as the screen resolution, that allows mapsforge to
         * automatically adapt the rendering for the device.
         * If you forget this step, your app will crash. You can place this code, like in the
         * Samples app, in the Android Application class. This ensures it is created before any
         * specific activity. But it can also be created in the onCreate() method in your activity.
         */
        AndroidGraphicFactory.createInstance(getApplication());

        /*
         * A MapView is an Android View (or ViewGroup) that displays a mapsforge map. You can have
         * multiple MapViews in your app or even a single Activity. Have a look at the mapviewer.xml
         * on how to create a MapView using the Android XML Layout definitions.
         */
        setContentView(R.layout.mapviewer);
        mapView = findViewById(R.id.mapView);

        /*
         * Open map.
         */
        Intent intent = new Intent(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? Intent.ACTION_OPEN_DOCUMENT : Intent.ACTION_GET_CONTENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("*/*");
        startActivityForResult(intent, SELECT_MAP_FILE);
    }

まず、AndroidGraphicFactoryを初期化しています。その上に注釈が書いてありますが、要約すると、この初期化をすることで各デバイスの解像度に応じた処理がなされるようになり、初期化をしないとアプリがクラッシュしますよというようなことが書いてあります。
次は、ビューの処理で、冒頭で定義したmapViewに実際のビューを割り付けています。
その下のOpen mapのところが、先ほど書いたマップファイルの選択の部分です。

その次が実際にマップファイルを選択する処理の部分です。ファイルを選択し、openMapを呼び出しています。

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == SELECT_MAP_FILE && resultCode == Activity.RESULT_OK) {
            if (data != null) {
                Uri uri = data.getData();
                openMap(uri);
            }
        }
    }

そして、その次が呼び出されるopenMap関数です。長いのでいくつかに分割しています。
最初に、スケールバーとズームコントロールを表示させる処理を行っています。
それから、タイルキャッシュというのを作成しています。キャッシュを作成することで、いちいちマップファイルからの呼び出しを避けるという趣旨のことが注釈に書いてあります。

    private void openMap(Uri uri) {
        try {
            /*
             * We then make some simple adjustments, such as showing a scale bar and zoom controls.
             */
            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.
             */
            TileCache tileCache = AndroidUtil.createTileCache(this, "mapcache",
                    mapView.getModel().displayModel.getTileSize(), 1f,
                    mapView.getModel().frameBufferModel.getOverdrawFactor());

その次がTileRenderLayerの処理です。注釈を読むと、地図の表示はいくつものレイヤーからなり、地図やマーカーなどを表示するレイヤーがある。ここでは地図のみを表示する。そのために、TileRenderLayerというのが必要でTileRenderLayerはTileCashを必要とする。タイルというのが地図そのもので、レンダーテーマというのが地図をどう表示するかを決める。というような感じでしょうか。

            /*
             * Now we need to set up the process of displaying a map. A map can have several layers,
             * stacked on top of each other. A layer can be a map or some visual elements, such as
             * markers. Here we only show a map based on a mapsforge map file. For this we need a
             * TileRendererLayer. A TileRendererLayer needs a TileCache to hold the generated map
             * tiles, a map file from which the tiles are generated and Rendertheme that defines the
             * appearance of the map.
             */
            FileInputStream fis = (FileInputStream) getContentResolver().openInputStream(uri);
            MapDataStore mapDataStore = new MapFile(fis);
            TileRendererLayer tileRendererLayer = new TileRendererLayer(tileCache, mapDataStore,
                    mapView.getModel().mapViewPosition, AndroidGraphicFactory.INSTANCE);
            tileRendererLayer.setXmlRenderTheme(MapsforgeThemes.MOTORIDER);

それから、tileRenderLayerはどこに地図を表示するかわからないので、mapViewと関連させる必要があると書いてあり、mapViewのレイヤーにtileRenderLayerをaddしています。
そして、マップの初期表示位置とズームレベルを指示しています。
最後はエラーの処理です。

            /*
             * On its own a tileRendererLayer does not know where to display the map, so we need to
             * associate it with our mapView.
             */
            mapView.getLayerManager().getLayers().add(tileRendererLayer);

            /*
             * The map also needs to know which area to display and at what zoom level.
             * Note: this map position is specific to Berlin area.
             */
            mapView.setCenter(new LatLong(52.517037, 13.38886));
            mapView.setZoomLevel((byte) 12);
        } catch (Exception e) {
            /*
             * In case of map file errors avoid crash, but developers should handle these cases!
             */
            e.printStackTrace();
        }
    }

最後は終了処理です。メモリー不足にならないようにこの処理が必要と書いてあります。

    @Override
    protected void onDestroy() {
        /*
         * Whenever your activity exits, some cleanup operations have to be performed lest your app
         * runs out of memory.
         */
        mapView.destroyAll();
        AndroidGraphicFactory.clearResourceMemoryCache();
        super.onDestroy();
    }
}

実際に動かしてみる

GettingStarted.javaを少しアレンジして単体で動かすようにしてみました。

MainActivity

まず、MainActivty.javaです。importの部分から前は省略しています。
上にも書いたように、マップファイルは最初からベルリンのマップを指定して、ファイル選択の部分はカットしました。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
以下の部分は内部ストレージに対する読み書きの権限をチェックしてない場合は権限付与の画面表示をさせる処理を行っています。
権限についてはManifestのところで詳しく書きます。
それ以外はサンプルプログラムとほぼ同じです。

public class MainActivity extends AppCompatActivity {
    private static final String MAP_FILE = "berlin.map";
    private final static int PERMISSION_REQUEST_CODE = 1;


    private MapView mapView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
      
        AndroidGraphicFactory.createInstance(getApplication());
        mapView = new MapView(this);
        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);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            if (isExternalStorageManager() == false) {

                Intent intent = new Intent();
                intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
                startActivity(intent);

            }
        }
        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();
        AndroidGraphicFactory.clearResourceMemoryCache();
        super.onDestroy();
    }
    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, AndroidGraphicFactory.INSTANCE) {

            };

            trl.setXmlRenderTheme(InternalRenderTheme.DEFAULT);

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

            mapView.setCenter(new LatLong(52.517037, 13.38886));
            mapView.setZoomLevel((byte) 12);
        }catch (Exception e) {
            /*
             * In case of map file errors avoid crash, but developers should handle these cases!
             */
            e.printStackTrace();
        }
    }
}

ただ、これだけでは動かないので他のファイルもそれなりに整える必要があります。

マニフェスト

AndroidManifest.xmlには、下記のように、ストレージに対して読み書きするパーミッションを与える記述を一文入れておきます。

~省略
     xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
~省略

Android13からストレージに対する権限の付与の仕方がかなり変わったということは以前にも書きました。
alasixosaka.hatenablog.com
今回も同様になんでも読み書きできるワイルドカードの権限を付与しています。

レイアウト

次はレイアウトファイル(Activity_Main.xml)です。昔のファイルから持ってきたので、Relativeレイアウトを使っています。ちなみにサンプルプログラムもRelativeレイアウトを使っています。
@dimenのところですが、なぜかリソースのところにdimen.xmlファイルがなく、エラーになってしまったので自分で作成しました。
以前のAndroidStudioでは自動生成されていたように思うのですが、デフォルトがConstraintレイアウトになったので変わってしまったのかもしれません。

<?xml version="1.0" encoding="utf-8"?>
<!--suppress ALL -->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity">

    <org.mapsforge.map.android.view.MapView
        android:id="@+id/mapView"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" />

    <TextView
        android:id="@+id/attribution"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="@dimen/attribution_margin"
        android:gravity="center"
        android:singleLine="true"
        android:text="@string/copyright_osm"
        android:textColor="#000"
        android:textSize="@dimen/attribution_size" />

</RelativeLayout>
dimen.xml

そのdimen.xmlです。マージンを決めてやるシンプルなファイルです。

<?xml version="1.0" encoding="utf-8"?>

<resources>
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
    <dimen name="attribution_margin">1dp</dimen>
    <dimen name="attribution_size">4dp</dimen>
</resources>
string.xml

string.xmlにもコピーライトを表示する一文を追加しています。なくてもかまわないと思いますが、サンプルプログラムに従い、一応敬意を表して入れておきました。

<resources>
    <string name="app_name">MapView5</string>
    <string name="copyright_osm">Map data © OpenStreetMap contributors</string>
</resources>
build.gradle

最後がbuild.gradleです。ここが一番苦労した部分です。
サンプルプログラムには、

dependencies {
    implementation project(":mapsforge-map-android")
    implementation project(":mapsforge-themes")
    implementation project(":mapsforge-poi-android")
    implementation 'androidx.annotation:annotation:1.2.0'
    implementation 'androidx.core:core:1.6.0'
    implementation 'androidx.fragment:fragment:1.3.6'
    androidTestImplementation 'com.jayway.android.robotium:robotium-solo:5.6.3'
}

となっていて、プロジェクトを呼び出すような記述になっています。この書き方が全然わからなくて調べてみたのですが、他のプロジェクトをライブラリとして利用しているような書き方をしています。
下記のサイトを参考に呼び出されている3つのプロジェクトを自分のプロジェクト内にコピーして同じようにやってみたのですがエラーになってビルドができませんでした。
tech.hikware.com
仕方がないので昔の記述をそのまま使うとビルドできたのでとりあえずこれで動かしています。

dependencies {

    implementation libs.appcompat
    implementation libs.material
    implementation libs.activity
    implementation libs.constraintlayout
    testImplementation libs.junit
    androidTestImplementation libs.ext.junit
    androidTestImplementation libs.espresso.core
    implementation 'org.mapsforge:mapsforge:0.6.1'
    implementation 'org.mapsforge:mapsforge-core:0.11.0'
    implementation 'org.mapsforge:mapsforge-map:0.11.0'
    implementation 'org.mapsforge:mapsforge-map-reader:0.11.0'
    implementation 'org.mapsforge:mapsforge-themes:0.11.0'
    implementation 'net.sf.kxml:kxml2:2.3.0'

    implementation 'org.mapsforge:mapsforge-map-android:0.11.0'
}
表示されたベルリンの地図