地図を使ったアプリ開発に興味はありませんか?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は非常に強力で、様々な位置情報アプリを実現できます。無料枠を活用しながら、ぜひあなたもオリジナルの地図アプリを作ってみてください。
この記事が役に立ちましたら、ぜひシェアしてください。質問やコメントもお待ちしています!