ショウのプログラミング教室

【2025年最新版】Google Maps APIで位置情報アプリを作る完全ガイド【Kotlin + Jetpack Compose実装】

地図を使ったアプリ開発に興味はありませんか?Google Maps APIを使えば、現在地表示、マーカー配置、ルート検索など、本格的な位置情報アプリが簡単に作れます。

この記事では、KotlinとJetpack Composeを使って、Google Maps APIをAndroidアプリに実装する方法を初心者にもわかりやすく解説します。2025年3月に変更された最新の料金体系についても詳しく説明します。

Google Maps APIとは?できること

Google Maps APIは、Googleマップの機能をアプリやWebサイトに組み込むためのAPIです。Android向けにはMaps SDK for Androidが提供されており、Kotlin/Javaでの開発に対応しています。

主な機能

地図表示機能では、インタラクティブな地図を表示し、ズーム・パンなどの操作が可能です。マーカー機能を使えば、地図上に目印を配置してクリックイベントを処理できます。現在地取得では、GPS情報を取得してユーザーの位置を表示します。ルート検索機能により、出発地から目的地までの経路を表示でき、住所検索(ジオコーディング)で住所から緯度経度を取得できます。カスタマイズ機能も充実しており、地図のスタイルを自由に変更可能です。

これらの機能を組み合わせることで、店舗検索アプリ、配達追跡アプリ、観光案内アプリ、不動産物件検索アプリなど、様々な位置情報アプリが作れます。

2025年3月の料金体系変更について【重要】

Google Maps APIを使う前に、2025年3月1日から適用された新しい料金体系を理解しておきましょう。

旧料金体系(2025年2月まで)

以前は、月額200ドルの無料クレジットが一律で提供されていました。例えば、地図表示(Dynamic Maps)は1,000回あたり7ドルなので、月28,500回まで無料で利用できました。

新料金体系(2025年3月から)

現在は、SKU(機能単位)ごとに無料枠が設定されています。カテゴリは3つに分類されます。

Essentialsカテゴリ(基本機能)は月10,000回まで無料で、Dynamic Maps(地図表示)、Static Maps(静的地図)、Geocoding(住所検索)などが含まれます。

Proカテゴリ(応用機能)は月5,000回まで無料で、Places API(施設検索)、Directions API(ルート検索)などが含まれます。

Enterpriseカテゴリ(高度な機能)は月1,000回まで無料です。

料金変更の影響

単一の機能を多く使う場合、例えば地図表示だけを月20,000回使うと、旧体系では全て無料でしたが、新体系では10,000回を超える10,000回分に課金されます(約70ドル)。

複数の機能を使う場合は、例えば地図5,000回、施設検索3,000回、ルート検索2,000回を使うと、旧体系では合計84ドル分で、200ドルクレジットで無料でした。新体系では各SKUの無料枠内なので完全に無料になります。

個人開発での実用性

月10,000回の地図表示は、1日約333回に相当します。個人開発や小規模アプリなら、無料枠内で十分実用的に使えるケースが多いでしょう。予算制限を設定することで、想定外の課金を防げます。

API Keyの取得と設定

まず、Google Cloud Consoleでプロジェクトを作成し、APIキーを取得します。

手順1:Google Cloudプロジェクトの作成

Google Cloud Console(https://console.cloud.google.com/)にアクセスし、新しいプロジェクトを作成します。プロジェクト名は任意で設定できます(例:MyMapApp)。

手順2:Maps SDK for Androidの有効化

左メニューから「APIとサービス」→「ライブラリ」を選択し、「Maps SDK for Android」を検索して「有効にする」をクリックします。

手順3:APIキーの作成

「APIとサービス」→「認証情報」を選択し、「認証情報を作成」→「APIキー」をクリックします。APIキーが生成されるので、安全な場所にコピーしておきます。

手順4:APIキーの制限設定(重要)

セキュリティのため、APIキーに制限を設定します。「APIキーの編集」画面で、「アプリケーションの制限」を「Androidアプリ」に設定します。パッケージ名とSHA-1フィンバープリントを追加します。

SHA-1フィンガープリントの取得方法は以下の通りです。

# デバッグ用
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

# リリース用
keytool -list -v -keystore /path/to/your/keystore -alias your_alias

手順5:課金アカウントの設定

APIを使用するには、課金アカウントの登録が必要です(無料枠内なら課金されません)。左メニューから「お支払い」を選択し、クレジットカード情報を登録します。

予算アラートの設定(推奨)

想定外の課金を防ぐため、予算アラートを設定しましょう。「お支払い」→「予算とアラート」から、月の予算上限を設定できます(例:10ドル)。

Android Studioプロジェクトのセットアップ

Android StudioでKotlinプロジェクトを作成し、必要な設定を行います。

build.gradle.kts(Project)

plugins {
    id("com.android.application") version "8.2.0" apply false
    id("org.jetbrains.kotlin.android") version "1.9.20" apply false
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false
}

build.gradle.kts(Module: app)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
}

android {
    namespace = "com.example.mymapapp"
    compileSdk = 34
    
    defaultConfig {
        applicationId = "com.example.mymapapp"
        minSdk = 21
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }
    
    buildFeatures {
        compose = true
        buildConfig = true
    }
    
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.8"
    }
}

dependencies {
    // Jetpack Compose
    val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
    implementation(composeBom)
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.activity:activity-compose:1.8.2")
    
    // Google Maps Compose
    implementation("com.google.maps.android:maps-compose:4.3.3")
    
    // Google Maps SDK
    implementation("com.google.android.gms:play-services-maps:18.2.0")
    
    // Location Services
    implementation("com.google.android.gms:play-services-location:21.1.0")
    
    // ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
    
    // Permissions
    implementation("com.google.accompanist:accompanist-permissions:0.32.0")
}

local.properties

APIキーを安全に管理するため、local.propertiesに記述します。

MAPS_API_KEY=YOUR_API_KEY_HERE

このファイルは.gitignoreに含まれているため、Gitにコミットされません。

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    
    <!-- 権限の設定 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.MyMapApp">
        
        <!-- Google Maps APIキーの設定 -->
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="${MAPS_API_KEY}" />
        
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

基本的な地図の表示

Jetpack Composeで地図を表示する基本的な実装です。

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MapScreen()
        }
    }
}

@Composable
fun MapScreen() {
    // 東京駅の座標
    val tokyo = LatLng(35.6812, 139.7671)
    
    // カメラの初期位置
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(tokyo, 15f)
    }
    
    GoogleMap(
        modifier = Modifier.fillMaxSize(),
        cameraPositionState = cameraPositionState
    ) {
        Marker(
            state = rememberMarkerState(position = tokyo),
            title = "東京駅",
            snippet = "日本の中心駅"
        )
    }
}

これで地図とマーカーが表示されます。ピンチ操作でズーム、スワイプで地図の移動ができます。

現在地の取得と表示

GPS情報を使って現在地を取得し、地図上に表示します。

import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState
import kotlinx.coroutines.tasks.await

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CurrentLocationMapScreen() {
    val context = LocalContext.current
    val locationPermissionState = rememberPermissionState(
        Manifest.permission.ACCESS_FINE_LOCATION
    )
    
    var currentLocation by remember { mutableStateOf<LatLng?>(null) }
    var isLoading by remember { mutableStateOf(false) }
    
    // 東京駅をデフォルト位置とする
    val defaultLocation = LatLng(35.6812, 139.7671)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            currentLocation ?: defaultLocation,
            15f
        )
    }
    
    // 現在地が更新されたらカメラを移動
    LaunchedEffect(currentLocation) {
        currentLocation?.let {
            cameraPositionState.position = CameraPosition.fromLatLngZoom(it, 15f)
        }
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            currentLocation?.let { location ->
                Marker(
                    state = rememberMarkerState(position = location),
                    title = "現在地",
                    snippet = "あなたはここにいます"
                )
            }
        }
        
        // 現在地取得ボタン
        FloatingActionButton(
            onClick = {
                if (locationPermissionState.status.isGranted) {
                    isLoading = true
                    getCurrentLocation(context) { location ->
                        currentLocation = location
                        isLoading = false
                    }
                } else {
                    locationPermissionState.launchPermissionRequest()
                }
            },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
        ) {
            if (isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.size(24.dp),
                    color = MaterialTheme.colorScheme.onPrimary
                )
            } else {
                Text("📍")
            }
        }
        
        // 権限が必要な場合のメッセージ
        if (!locationPermissionState.status.isGranted) {
            Card(
                modifier = Modifier
                    .align(Alignment.TopCenter)
                    .padding(16.dp)
            ) {
                Text(
                    text = "位置情報の権限が必要です",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

@SuppressLint("MissingPermission")
fun getCurrentLocation(
    context: Context,
    onLocationReceived: (LatLng) -> Unit
) {
    val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
    
    fusedLocationClient.getCurrentLocation(
        Priority.PRIORITY_HIGH_ACCURACY,
        null
    ).addOnSuccessListener { location ->
        location?.let {
            onLocationReceived(LatLng(it.latitude, it.longitude))
        }
    }
}

FloatingActionButtonをタップすると、現在地を取得してマーカーを表示します。

マーカーの追加とカスタマイズ

複数のマーカーを表示し、クリックイベントを処理する実装です。

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*

data class Place(
    val id: Int,
    val name: String,
    val location: LatLng,
    val category: String
)

@Composable
fun MultipleMarkersScreen() {
    // サンプルの場所データ
    val places = remember {
        listOf(
            Place(1, "東京タワー", LatLng(35.6586, 139.7454), "観光"),
            Place(2, "スカイツリー", LatLng(35.7101, 139.8107), "観光"),
            Place(3, "皇居", LatLng(35.6852, 139.7528), "観光"),
            Place(4, "上野公園", LatLng(35.7148, 139.7738), "公園"),
            Place(5, "浅草寺", LatLng(35.7148, 139.7967), "寺院")
        )
    }
    
    var selectedPlace by remember { mutableStateOf<Place?>(null) }
    
    val tokyo = LatLng(35.6812, 139.7671)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(tokyo, 12f)
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            places.forEach { place ->
                val markerColor = when (place.category) {
                    "観光" -> BitmapDescriptorFactory.HUE_RED
                    "公園" -> BitmapDescriptorFactory.HUE_GREEN
                    "寺院" -> BitmapDescriptorFactory.HUE_AZURE
                    else -> BitmapDescriptorFactory.HUE_ORANGE
                }
                
                Marker(
                    state = rememberMarkerState(position = place.location),
                    title = place.name,
                    snippet = place.category,
                    icon = BitmapDescriptorFactory.defaultMarker(markerColor),
                    onClick = {
                        selectedPlace = place
                        true
                    }
                )
            }
        }
        
        // 選択された場所の情報表示
        selectedPlace?.let { place ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = place.name,
                        style = MaterialTheme.typography.titleLarge
                    )
                    Text(
                        text = "カテゴリ: ${place.category}",
                        style = MaterialTheme.typography.bodyMedium
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    Button(onClick = { selectedPlace = null }) {
                        Text("閉じる")
                    }
                }
            }
        }
    }
}

マーカーの色をカテゴリごとに変えることで、視覚的にわかりやすくしています。

ルート検索機能の実装

Directions APIを使って、2地点間のルートを表示します。

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URL

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RouteSearchScreen() {
    val scope = rememberCoroutineScope()
    
    // 出発地と目的地
    val origin = LatLng(35.6812, 139.7671) // 東京駅
    val destination = LatLng(35.6586, 139.7454) // 東京タワー
    
    var routePoints by remember { mutableStateOf<List<LatLng>>(emptyList()) }
    var distance by remember { mutableStateOf("") }
    var duration by remember { mutableStateOf("") }
    var isLoading by remember { mutableStateOf(false) }
    
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(origin, 13f)
    }
    
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("ルート検索") })
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            GoogleMap(
                modifier = Modifier.fillMaxSize(),
                cameraPositionState = cameraPositionState
            ) {
                // 出発地マーカー
                Marker(
                    state = rememberMarkerState(position = origin),
                    title = "出発地",
                    snippet = "東京駅"
                )
                
                // 目的地マーカー
                Marker(
                    state = rememberMarkerState(position = destination),
                    title = "目的地",
                    snippet = "東京タワー"
                )
                
                // ルートの描画
                if (routePoints.isNotEmpty()) {
                    Polyline(
                        points = routePoints,
                        color = Color.Blue,
                        width = 10f
                    )
                }
            }
            
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Button(
                    onClick = {
                        isLoading = true
                        scope.launch {
                            val result = getDirections(origin, destination)
                            routePoints = result.points
                            distance = result.distance
                            duration = result.duration
                            isLoading = false
                        }
                    },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    if (isLoading) {
                        CircularProgressIndicator(
                            modifier = Modifier.size(20.dp),
                            color = MaterialTheme.colorScheme.onPrimary
                        )
                    } else {
                        Text("ルートを検索")
                    }
                }
                
                if (distance.isNotEmpty()) {
                    Spacer(modifier = Modifier.height(8.dp))
                    Card(modifier = Modifier.fillMaxWidth()) {
                        Column(modifier = Modifier.padding(16.dp)) {
                            Text("距離: $distance")
                            Text("所要時間: $duration")
                        }
                    }
                }
            }
        }
    }
}

data class RouteResult(
    val points: List<LatLng>,
    val distance: String,
    val duration: String
)

suspend fun getDirections(origin: LatLng, destination: LatLng): RouteResult = 
    withContext(Dispatchers.IO) {
        try {
            // 注意: 実際のAPIキーを使用してください
            val apiKey = "YOUR_API_KEY"
            val url = "https://maps.googleapis.com/maps/api/directions/json?" +
                    "origin=${origin.latitude},${origin.longitude}" +
                    "&destination=${destination.latitude},${destination.longitude}" +
                    "&key=$apiKey"
            
            val response = URL(url).readText()
            val json = JSONObject(response)
            
            val routes = json.getJSONArray("routes")
            if (routes.length() > 0) {
                val route = routes.getJSONObject(0)
                val legs = route.getJSONArray("legs").getJSONObject(0)
                
                val distance = legs.getJSONObject("distance").getString("text")
                val duration = legs.getJSONObject("duration").getString("text")
                
                // ポリラインのデコード
                val polyline = route.getJSONObject("overview_polyline").getString("points")
                val points = decodePolyline(polyline)
                
                RouteResult(points, distance, duration)
            } else {
                RouteResult(emptyList(), "", "")
            }
        } catch (e: Exception) {
            e.printStackTrace()
            RouteResult(emptyList(), "", "")
        }
    }

// Googleのポリラインエンコーディングをデコード
fun decodePolyline(encoded: String): List<LatLng> {
    val poly = ArrayList<LatLng>()
    var index = 0
    val len = encoded.length
    var lat = 0
    var lng = 0

    while (index < len) {
        var b: Int
        var shift = 0
        var result = 0
        do {
            b = encoded[index++].code - 63
            result = result or (b and 0x1f shl shift)
            shift += 5
        } while (b >= 0x20)
        val dlat = if (result and 1 != 0) (result shr 1).inv() else result shr 1
        lat += dlat

        shift = 0
        result = 0
        do {
            b = encoded[index++].code - 63
            result = result or (b and 0x1f shl shift)
            shift += 5
        } while (b >= 0x20)
        val dlng = if (result and 1 != 0) (result shr 1).inv() else result shr 1
        lng += dlng

        val latLng = LatLng(
            lat.toDouble() / 1E5,
            lng.toDouble() / 1E5
        )
        poly.add(latLng)
    }

    return poly
}

注意:Directions APIを使用するには、Google Cloud ConsoleでDirections APIを有効にする必要があります。

住所検索(ジオコーディング)の実装

住所から緯度経度を取得する機能です。

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URLEncoder

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeocodingScreen() {
    val scope = rememberCoroutineScope()
    
    var searchQuery by remember { mutableStateOf("") }
    var searchResult by remember { mutableStateOf<LatLng?>(null) }
    var addressName by remember { mutableStateOf("") }
    var isSearching by remember { mutableStateOf(false) }
    
    val defaultLocation = LatLng(35.6812, 139.7671)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            searchResult ?: defaultLocation,
            15f
        )
    }
    
    LaunchedEffect(searchResult) {
        searchResult?.let {
            cameraPositionState.position = CameraPosition.fromLatLngZoom(it, 15f)
        }
    }
    
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("住所検索") })
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                OutlinedTextField(
                    value = searchQuery,
                    onValueChange = { searchQuery = it },
                    label = { Text("住所を入力") },
                    modifier = Modifier.weight(1f),
                    singleLine = true
                )
                
                IconButton(
                    onClick = {
                        if (searchQuery.isNotEmpty()) {
                            isSearching = true
                            scope.launch {
                                val result = geocodeAddress(searchQuery)
                                result?.let {
                                    searchResult = it.location
                                    addressName = it.address
                                }
                                isSearching = false
                            }
                        }
                    },
                    enabled = !isSearching
                ) {
                    if (isSearching) {
<function_results>OK</parameter>
<parameter name="content">CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Icon(Icons.Default.Search, "検索")
}
}
}
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            searchResult?.let { location ->
                Marker(
                    state = rememberMarkerState(position = location),
                    title = addressName,
                    snippet = "検索結果"
                )
            }
        }
    }
}
}
data class GeocodingResult(
val location: LatLng,
val address: String
)
suspend fun geocodeAddress(address: String): GeocodingResult? =
withContext(Dispatchers.IO) {
try {
val apiKey = "YOUR_API_KEY"
val encodedAddress = URLEncoder.encode(address, "UTF-8")
val url = "https://maps.googleapis.com/maps/api/geocode/json?" +
                    "address=encodedAddress&key=apiKey"

        val response = URL(url).readText()
        val json = JSONObject(response)
        
        val results = json.getJSONArray("results")
        if (results.length() > 0) {
            val result = results.getJSONObject(0)
            val location = result.getJSONObject("geometry").getJSONObject("location")
            val lat = location.getDouble("lat")
            val lng = location.getDouble("lng")
            val formattedAddress = result.getString("formatted_address")
            
            GeocodingResult(LatLng(lat, lng), formattedAddress)
        } else {
            null
        }
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

地図のカスタマイズ

地図のスタイルをカスタマイズして、アプリのデザインに合わせることができます。

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MapStyleOptions
import com.google.maps.android.compose.*

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomMapStyleScreen() {
    var isDarkMode by remember { mutableStateOf(false) }

    val tokyo = LatLng(35.6812, 139.7671)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(tokyo, 12f)
    }

    // ダークモードのスタイルJSON
    val darkMapStyle = """
    [
      {
        "elementType": "geometry",
        "stylers": [{"color": "#212121"}]
      },
      {
        "elementType": "labels.text.fill",
        "stylers": [{"color": "#757575"}]
      },
      {
        "elementType": "labels.text.stroke",
        "stylers": [{"color": "#212121"}]
      },
      {
        "featureType": "road",
        "elementType": "geometry",
        "stylers": [{"color": "#484848"}]
      },
      {
        "featureType": "water",
        "elementType": "geometry",
        "stylers": [{"color": "#000000"}]
      }
    ]
    """.trimIndent()

    val mapProperties by remember(isDarkMode) {
        mutableStateOf(
            MapProperties(
                mapStyleOptions = if (isDarkMode) {
                    MapStyleOptions(darkMapStyle)
                } else {
                    null
                }
            )
        )
    }

    val uiSettings by remember {
        mutableStateOf(
            MapUiSettings(
                zoomControlsEnabled = false,
                myLocationButtonEnabled = true,
                compassEnabled = true
            )
        )
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("カスタムスタイル") },
                actions = {
                    Switch(
                        checked = isDarkMode,
                        onCheckedChange = { isDarkMode = it }
                    )
                    Text("ダーク", modifier = Modifier.padding(end = 8.dp))
                }
            )
        }
    ) { paddingValues ->
        GoogleMap(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            cameraPositionState = cameraPositionState,
            properties = mapProperties,
            uiSettings = uiSettings
        )
    }
}

ViewModelを使った実践的なアーキテクチャ

実際のアプリでは、ViewModelを使って状態管理を行います。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.maps.model.LatLng
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

data class MapUiState(
    val currentLocation: LatLng? = null,
    val markers: List<Place> = emptyList(),
    val selectedPlace: Place? = null,
    val isLoading: Boolean = false,
    val error: String? = null
)

class MapViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(MapUiState())
    val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()

    init {
        loadPlaces()
    }

    private fun loadPlaces() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)

            // 実際のアプリではAPIから取得
            val places = listOf(
                Place(1, "東京タワー", LatLng(35.6586, 139.7454), "観光"),
                Place(2, "スカイツリー", LatLng(35.7101, 139.8107), "観光"),
                Place(3, "皇居", LatLng(35.6852, 139.7528), "観光")
            )

            _uiState.value = _uiState.value.copy(
                markers = places,
                isLoading = false
            )
        }
    }

    fun updateCurrentLocation(location: LatLng) {
        _uiState.value = _uiState.value.copy(currentLocation = location)
    }

    fun selectPlace(place: Place) {
        _uiState.value = _uiState.value.copy(selectedPlace = place)
    }

    fun clearSelection() {
        _uiState.value = _uiState.value.copy(selectedPlace = null)
    }
}

@Composable
fun MapWithViewModelScreen(viewModel: MapViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    val defaultLocation = LatLng(35.6812, 139.7671)
    val cameraPositionState = rememberCameraPositionState {
        position = CameraPosition.fromLatLngZoom(
            uiState.currentLocation ?: defaultLocation,
            13f
        )
    }

    Box(modifier = Modifier.fillMaxSize()) {
        GoogleMap(
            modifier = Modifier.fillMaxSize(),
            cameraPositionState = cameraPositionState
        ) {
            uiState.currentLocation?.let { location ->
                Marker(
                    state = rememberMarkerState(position = location),
                    title = "現在地"
                )
            }

            uiState.markers.forEach { place ->
                Marker(
                    state = rememberMarkerState(position = place.location),
                    title = place.name,
                    snippet = place.category,
                    onClick = {
                        viewModel.selectPlace(place)
                        true
                    }
                )
            }
        }

        if (uiState.isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.align(Alignment.Center)
            )
        }

        uiState.selectedPlace?.let { place ->
            PlaceInfoCard(
                place = place,
                onDismiss = { viewModel.clearSelection() }
            )
        }
    }
}

@Composable
fun PlaceInfoCard(place: Place, onDismiss: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = place.name,
                style = MaterialTheme.typography.titleLarge
            )
            Text(
                text = "カテゴリ: ${place.category}",
                style = MaterialTheme.typography.bodyMedium
            )
            Spacer(modifier = Modifier.height(8.dp))
            Button(
                onClick = onDismiss,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("閉じる")
            }
        }
    }
}

パフォーマンス最適化とベストプラクティス

Google Maps APIを効率的に使うためのポイントをまとめます。

APIコールの最小化

不必要なAPI呼び出しを避けることで、コストとパフォーマンスの両方を改善できます。ユーザーが検索ボタンを押したときのみジオコーディングを実行し、地図の移動ごとにAPIを呼ばないようにします。キャッシュ可能なデータ(住所変換結果など)はローカルに保存しましょう。

地図の表示設定

不要な機能をオフにすることで、パフォーマンスが向上します。

val uiSettings = MapUiSettings(
    zoomControlsEnabled = false, // 不要ならオフ
    mapToolbarEnabled = false,   // Googleマップアプリへのリンクを非表示
    rotationGesturesEnabled = false // 回転操作が不要ならオフ
)

マーカーの最適化

大量のマーカーを表示する場合は、マーカークラスタリングを検討しましょう。表示領域外のマーカーは非表示にし、ズームレベルに応じてマーカーの表示を制御します。

メモリ管理

地図を含むActivityやFragmentを破棄する際は、適切にクリーンアップします。

DisposableEffect(Unit) {
    onDispose {
        // 必要なクリーンアップ処理
    }
}

トラブルシューティング

よくある問題と解決方法をまとめます。

地図が表示されない

考えられる原因は、APIキーが正しく設定されていない、Maps SDK for Androidが有効になっていない、課金アカウントが設定されていない、パッケージ名とSHA-1フィンガープリントが一致していない、などです。

Android StudioのLogcatでエラーメッセージを確認し、Google Cloud Consoleで「APIs & Services」→「ダッシュボード」でAPIの使用状況を確認しましょう。

現在地が取得できない

位置情報の権限が許可されているか確認します。デバイスのGPS設定がオンになっているか確認し、エミュレータの場合は位置情報をシミュレートする必要があります。

APIの課金が心配

予算アラートを必ず設定しましょう。開発時はAPIの使用状況を定期的にチェックし、本番環境では適切なAPIキーの制限を設定します。

まとめ

Google Maps APIを使ったAndroidアプリの実装方法を解説しました。重要なポイントをおさらいしましょう。

2025年3月から料金体系が変更され、SKUごとに無料枠が設定されました。基本的な地図表示なら月10,000回まで無料で使えます。Jetpack Composeを使うことで、モダンで保守性の高いUIが実装できます。現在地取得、マーカー表示、ルート検索、住所検索など、豊富な機能を実装できます。ViewModelを使った適切なアーキテクチャで、スケーラブルなアプリが作れます。APIコールの最小化とキャッシュ活用で、コストとパフォーマンスを最適化できます。

Google Maps APIは非常に強力で、様々な位置情報アプリを実現できます。無料枠を活用しながら、ぜひあなたもオリジナルの地図アプリを作ってみてください。

この記事が役に立ちましたら、ぜひシェアしてください。質問やコメントもお待ちしています!

モバイルバージョンを終了