Google Maps Android APIでsetMyLocation()を正しく設定する

AndroidアプリでGoogle Mapをライブラリとして使うGoogle Maps Android APIというのがGoogle Play servicesにあるのですが、こいつの setMyLocation() まわりがここ1年でずいぶん変わりました。

Android 6.0 / Google Play services 8.4.0現在、これを正しく設定する方法を調べたので記録しておきます。

なおこれらのtipsは半径Nキロメートルという物件検討用メモアプリを作る際に調べたもので、このアプリにはもう適用済みです。

実装については、「半径N」では当初PermissionDispatcher を使おうとしたのですが、パーミッションチェックのコード量が減るわけではないので結局すべて自前で実装することにしました。


さて、まず問題は2つ。それぞれ見ていきます。

  1. GoogleMap#setMyLocationEnabled() のruntime permissions対応
  2. GoogleMap#getMyLocation()GoogleMap#setOnMyLocationChangeListener() がdeprecatedになった対応

GoogleMap#setMyLocationEnabled() のruntime permissions対応

GoogleMap#setMyLocationEnabled() はruntime permissionsを要求するようになりました。ACCESS_FINE_LOCATION または ACCESS_COARSE_LOCATION (以降は総称してlocation permissionsとします)が必要です。

なお GoogleMap自体はlocation permissionsがなくても動作する ので、まず本当にMyLocation(=デバイスの位置情報)が必要かどうかを検討してください。「半径N」の場合はMyLocationは必須ではないため、パーミッションの要求が拒否されても動作するようにしました。

これの対応の概要は以下のとおりです。

  • setMyLocationEnabled() を呼び出す前にパーミッションのチェックと requestPermissions() の呼び出しを行う
  • Activity#onRequestPermissionsResult()requestPermissions() の結果を受け取って、GRANTEDであればもう一度 setMyLocationEnabled() の呼び出しを行う

今回は shouldShowRequestPermissionRationale() は使いませんでした。

requestPermissions()

runtime permissionsのフローは Android 6.0 の Runtime Permissions (M Permissions) に対応するためのアクティビティ図 - visible true がよくまとまっています。

requestPermissions() は、パーミッションが必要なタイミングでコントローラが行います。パーミッションの必要なメソッドを呼ぶとIDEがlint errorを出し、IDEにしたがってコードテンプレートを生成すればだいたい合ってます。

今回は、以下のようなテンプレートが生成されます。

if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
        != PackageManager.PERMISSION_GRANTED
        && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
        != PackageManager.PERMISSION_GRANTED) {
    // TODO: Consider calling
    //    ActivityCompat#requestPermissions
    // here to request the missing permissions, and then overriding
    //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
    //                                          int[] grantResults)
    // to handle the case where the user grants the permission. See the documentation
    // for ActivityCompat#requestPermissions for more details.
    return;
}
map.setMyLocationEnabled(true);

コメントに書いてあるとおり、 requestPermissions() を呼んで onRequestPermissionsResult() で権限付与を処理しろとありますね。これにしたがうと以下のようなコードになります。

static final int RC_LOCATION_PERMISSIONS = 0x01;

static final String[] PERMISSIONS = {
        Manifest.permission.ACCESS_FINE_LOCATION,
        Manifest.permission.ACCESS_COARSE_LOCATION
};

void setMyLocationEnabled() {
    if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
            != PackageManager.PERMISSION_GRANTED
            && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
            != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this, PERMISSIONS, RC_LOCATION_PERMISSIONS);
        return;
    }
    map.setMyLocationEnabled(true);
}

@TargetApi(Build.VERSION_CODES.M)
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == RC_LOCATION_PERMISSIONS) {
        onRequestLocationPermissionsResult(permissions, grantResults);
    }
}

@DebugLog
void onRequestLocationPermissionsResult(String[] permissions, int[] grantResults) {
    int[] granted2 = {PackageManager.PERMISSION_GRANTED, PackageManager.PERMISSION_GRANTED};
    if (Arrays.equals(permissions, PERMISSIONS) && Arrays.equals(grantResults, granted2)) {
        // 権限を取得したのでもう一度setMyLocationEnabled()を呼び出す
        setMyLocationEnabled();
    } else {
        // 権限を取得できなかったので諦める
        Toast.makeText(this, "No location permissions granted", Toast.LENGTH_LONG).show();
    }
}

厄介なのは、パーミッションを要求するメソッドを呼び出すたびに checkSelfPermission() しなければならないことです。そうしないと、lint errorになります。したがって、パーミッションを要求するメソッドはすべて checkSelfPermission() を呼び出すコードでラップして、そのラッパーメソッドを呼び出すようにするのがいいでしょう。

GoogleMap#getMyLocation()GoogleMap#setOnMyLocationChangeListener() がdeprecatedになった対応

runtime permissionsと直接は関係ないと思いますが、GoogleMapでMyLocationを取得するメソッドが非奨励になりました。かわりにGoogleApiClientから取得しなければいけません*1。GoogleMapはたとえ setMyLocationEnabled(true) していても自動でMyLocationにカメラを移動したりはしないので、「カメラの初期位置をMyLocationにする」というだけのためにGoogleApiClientを使うことになります。

「半径N」の場合は位置情報まわりの操作をPlaceEngineというクラスにまとめているので少しごちゃごちゃしていますが、シンプルに実装すると以下のようになるでしょう。 getLastLocation() はlocation permissionsを要求するので、そのハンドルがまた必要です。「半径N」の場合はマップのカメラの初期位置を設定するだけなので、情報がとれなければそのまま諦めています*2

 void initiGoogleAPiClient() {
    googleApiClient = new GoogleApiClient.Builder(context)
            .addApi(LocationServices.API)
            .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
                @Override
                public void onConnected(@Nullable Bundle bundle) {
                    handleLastLocation();
                }

                @Override
                public void onConnectionSuspended(int i) {
                    Timber.i("GoogleApiClient connection suspended");
                }
            })
            .addOnConnectionFailedListener(new GoogleApiClient.OnConnectionFailedListener() {
                @Override
                public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {
                    Timber.w("GoogleApiClient connection failed: %s", connectionResult.getErrorMessage());
                }
            })
            .build();
}

private void handleLastLocation() {
    if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
            != PackageManager.PERMISSION_GRANTED
            && ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
            != PackageManager.PERMISSION_GRANTED) {
        return;
    }

    Location currentLocation = LocationServices.FusedLocationApi.getLastLocation(googleApiClient);
    if (currentLocation != null) {
        // コントローラがLatLngを受け取ってくれることを期待してイベントを投げる
        castMyLocation(new LatLng(currentLocation.getLatitude(), currentLocation.getLongitude()), true);
    }
}

さらなる工夫

ざっくり必要最低限の実装をしましたが、さらなる工夫もできます。

まず実サービスの場合、 shouldShowRequestPermissionRationale() を使ってパーミッションが必要な理由を説明すべきです。

また、location permissionsの場合、高精度位置情報(ACCESS_FINE_LOCATION)と低精度位置情報(ACCESS_COARSE_LOCATION)の いずれか が必要なのですが、パーミッション取得の説明画面で高精度位置情報を落とすオプションを選ばせることも、技術的には可能だと思います。実装は大変ですが、サービスの性質次第では必要かもしれません。

*1:Android Frameworkのandroid.locationtというのも存在しますが、奨励されません。参考: Making Your App Location-Aware | Android Developers

*2:実際には、SIMやlocaleなどから位置情報を取得しようとしますが、正確な情報はとれないので諦めているも同然です。