Mapbox Android でのオフラインマップ作成

とある案件で OpenStreetMap を利用したオフラインマップ機能を持ったアプリを開発しました。 オフラ…

とある案件で OpenStreetMap を利用したオフラインマップ機能を持ったアプリを開発しました。
オフラインマップとは、オンラインでマップを開き 1 度ロードが完了したものを再度表示する場合にオンライン接続せずに表示できるといった機能です。
予め指定範囲内の地図データをダウンロードしておき利用する方法もあります。
今回は後者の手法を紹介します。
 

OpenStreetMap


オープンストリートマップ(英語:OpenStreetMap, OSM)は自由に利用でき、なおかつ編集機能のある世界地図を作るための共同作業プロジェクトである。GPS機能を持った携帯端末、空中写真やほかの無料機械からのデータをもとに作られていくのが基本だが、編集ツール上で道一本から手入力での追加も可能である。与えられた画像とベクトルデータセットはオープンデータベースライセンス (ODbL) 1.0のもと再利用可能である。登録したユーザーであれば、GPSのログファイルをアップロードしたり、ベクトルデータをエディタで修正することができる。OpenStreetMapはウィキペディアのようなウェブサイトに触発され、「編集」タブや履歴機能も保たれている。

wiki からの引用

 
要するに誰でも編集可能なフリーの地図です。
 

Mapbox


OpenStreetMap の地図データを利用してモバイルアプリに組み込めるようにした SDK です。
導入や表示方法に関しては以前にも紹介していますので こちら をご覧ください。
iOS の導入方法となっておりますが、 Android でも大差ないので 公式ページ を参照いただければと思います。
 

オフラインマップ作成


オフラインマップを作成する際のソースコードです。
layout ファイルは割愛、 DataBinding を利用。
 

package jp.co.supersoftware.android.islandsapp.activity

import android.content.res.AssetManager
import android.databinding.DataBindingUtil
import android.os.Bundle
import android.os.Environment
import android.support.v7.app.AppCompatActivity
import android.util.Log
import android.view.View
import android.widget.Toast
import com.google.common.io.Files
import com.mapbox.mapboxsdk.MapboxAccountManager
import com.mapbox.mapboxsdk.annotations.MarkerViewOptions
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.geometry.LatLngBounds
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.OnMapReadyCallback
import com.mapbox.mapboxsdk.offline.*
import jp.co.supersoftware.android.islandsapp.R
import jp.co.supersoftware.android.islandsapp.databinding.ActivityOfflineMapBinding
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream

/**
 * オフラインマップ作成用
 *
 * Created by Shirai on 2017/01/24.
 */
class OfflineMapActivity : AppCompatActivity() {

    /**
     * バインディング
     */
    lateinit private var mBinding: ActivityOfflineMapBinding
    /**
     * MapBoxのオフラインマネージャー
     */
    lateinit private var mOfflineManager: OfflineManager
    /**
     *
     */
    private var mIsEndNotified = false

    companion object {
        /**
         * Jsonフィールド(領域名)
         */
        const val JSON_FIELD_REGION_NAME = "FIELD_REGION_NAME"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        val offlineMapFile = File(filesDir, "mbgl-offline.db")
        when (offlineMapFile.exists()) {
            true -> offlineMapFile.delete()
        }
        super.onCreate(savedInstanceState)
        MapboxAccountManager.start(this, getString(R.string.access_token))
        mBinding = DataBindingUtil.setContentView(this, R.layout.activity_offline_map)

        mBinding.mapView.onCreate(savedInstanceState)
        mBinding.mapView.getMapAsync(mMapReadyCallback)
    }

    override fun onResume() {
        super.onResume()
        mBinding.mapView.onResume()
    }

    override fun onPause() {
        super.onPause()
        mBinding.mapView.onPause()
        mOfflineManager.listOfflineRegions(mListOfflineRegionsCallback)
    }

    override fun onSaveInstanceState(outState: Bundle?) {
        super.onSaveInstanceState(outState)
        if (outState != null) {
            mBinding.mapView.onSaveInstanceState(outState)
        }
    }

    override fun onLowMemory() {
        super.onLowMemory()
        mBinding.mapView.onLowMemory()
    }

    override fun onDestroy() {
        super.onDestroy()
        mBinding.mapView.onDestroy()
    }

    /**
     * マップビュー準備完了コールバック
     */
    private val mMapReadyCallback = object : OnMapReadyCallback {
        override fun onMapReady(mapboxMap: MapboxMap?) {
            if (mapboxMap == null) {
                return
            }
            // オフラインマネージャーのセットアップ
            mOfflineManager = OfflineManager.getInstance(this@OfflineMapActivity)
            // オフライン領域設定
            val offlineLatLngBounds = LatLngBounds.Builder().apply {
                include(LatLng(35.649194, 139.713058))
                include(LatLng(35.647677, 139.708766))
            }.build()
            val definition = OfflineTilePyramidRegionDefinition(
                    mBinding.mapView.styleUrl,
                    offlineLatLngBounds,
                    11.0,
                    18.0,
                    resources.displayMetrics.density)
            // メタデータ
            var metadata: ByteArray
            try {
                val jsonObject = JSONObject().apply {
                    put(JSON_FIELD_REGION_NAME, "Ebisu")
                }
                val json = jsonObject.toString()
                metadata = json.toByteArray(Charsets.UTF_8)
            } catch (e: Exception) {
                Log.e("OSM", "Failed to encode metadata: ${e.message}")
                metadata = byteArrayOf()
            }
            mOfflineManager.createOfflineRegion(definition, metadata, mCreateOfflineRegionCallback)
        }
    }

    /**
     * オフライン領域作成時コールバック
     */
    private val mCreateOfflineRegionCallback = object : OfflineManager.CreateOfflineRegionCallback {
        override fun onCreate(offlineRegion: OfflineRegion?) {
            if (offlineRegion == null) {
                return
            }
            // ダウンロード状態をアクティブに設定
            offlineRegion.setDownloadState(OfflineRegion.STATE_ACTIVE)
            // プログレスバー表示
            startProgress()

            offlineRegion.setObserver(mOfflineRegionObserver)
        }

        override fun onError(error: String?) {
            Log.e("OSM", "Error: $error")
        }
    }

    /**
     * オフライン領域オブザーバー
     */
    private val mOfflineRegionObserver = object : OfflineRegion.OfflineRegionObserver {
        override fun mapboxTileCountLimitExceeded(limit: Long) {
            Log.e("OSM", "Mapbox tile count limit exceeded: $limit")
        }

        override fun onStatusChanged(status: OfflineRegionStatus?) {
            if (status == null) {
                return
            }
            val percentaged = when (status.requiredResourceCount > 0) {
                true -> 100.0 * status.completedResourceCount / status.requiredResourceCount
                else -> 0.0
            }
            when (status.isComplete) {
                true -> {
                    endProgress("Resion downloaded successfully.")
                }
                false -> {
                    when (status.isRequiredResourceCountPrecise) {
                        true -> {
                            setPercentage(Math.round(percentaged).toInt())
                            Log.v("OSM", "percentage=${Math.round(percentaged).toInt()}")
                        }
                    }
                }
            }
        }

        override fun onError(error: OfflineRegionError?) {
            Log.e("OSM", "onError reason: ${error?.let { it.reason } ?: "(null)"}")
            Log.e("OSM", "onError message: ${error?.let { it.message } ?: "(null)"}")
        }
    }

    /**
     * オフライン領域リストコールバック
     */
    private val mListOfflineRegionsCallback = object : OfflineManager.ListOfflineRegionsCallback {
        override fun onList(offlineRegions: Array<out OfflineRegion>?) {
            if (offlineRegions == null) {
                return
            }
            offlineRegions[offlineRegions.size - 1].delete(mOfflineRegionDeleteCallback)
        }

        override fun onError(error: String?) {
            Log.e("OSM", "onListError: $error")
        }
    }

    /**
     * オフライン領域削除コールバック
     */
    private val mOfflineRegionDeleteCallback = object : OfflineRegion.OfflineRegionDeleteCallback {
        override fun onDelete() {
            Toast.makeText(this@OfflineMapActivity, "Niijima offline map deleted", Toast.LENGTH_LONG).show()
        }

        override fun onError(error: String?) {
            Log.e("OSM", "On Delete error: $error")
        }
    }

    /**
     * プログレスバーを表示します。
     */
    private fun startProgress() {
        mIsEndNotified = false
        mBinding.offlineMapLoadingProgressBar.isIndeterminate = true
        mBinding.offlineMapLoadingProgressBar.visibility = View.VISIBLE
    }

    /**
     * ダウンロード進捗を設定します。
     *
     * @param percentage パーセンテージ
     */
    private fun setPercentage(percentage: Int) {
        mBinding.offlineMapLoadingProgressBar.isIndeterminate = false
        mBinding.offlineMapLoadingProgressBar.progress = percentage
    }

    /**
     * プログレスバーを非表示にします。
     *
     * @param message トーストに表示するメッセージ
     */
    private fun endProgress(message: String?) {
        when (mIsEndNotified) {
            true -> return
        }
        mIsEndNotified = true
        mBinding.offlineMapLoadingProgressBar.isIndeterminate = false
        mBinding.offlineMapLoadingProgressBar.visibility = View.GONE
        when (message != null && !message.isEmpty()) {
            true -> Toast.makeText(this, message, Toast.LENGTH_LONG).show()
        }
        // 作成したオフラインマップファイルを取得したいので、他から見えるところ(Downloadディレクトリ以下)にコピー
        Files.copy(File(filesDir, "mbgl-offline.db"), File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "mbgl-offline.db"))
    }

}

 
Mapbox SDK によって追加された MapView を画面に配置し、各イベントハンドラーにて Activity に紐付けます。
onCreate で MapView#getMapAsync でマップの準備が完了したらそのコールバックでオフラインマップの準備をします。
ダウンロードするエリア、ズーム範囲を指定したら OfflineManager#createOfflineRegion で生成できます。
生成されたマップは filesDir 配下に mbgl-offline.db というファイル名で保存されるので、これを取得して assets に組み込めば予めオフラインマップデータを持ったアプリが作成可能となります。
 

さいごに


生成されたマップデータはファイルサイズが大きいのでアプリが直接持つのは現実的ではありません。
初回アプリ起動時にサーバー経由でダウンロードさせるなどの工夫が必要になるかと思います。
オフラインマップは結構便利な機能なので電波の弱いところでマップを見たいときなどには重宝するかもしれません。