Android LocationApp

這是一個練習定位權限的App,我將重要的部分記錄下來。

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  • android.permission.INTERNET : 允許應用程式開啟網路連線
  • android.permission.ACCESS_COARSE_LOCATION : 允許應用程式存取使用粗略的位置
  • android.permission.ACCESS_FINE_LOCATION : 允許應用程式存取使用精確的位置

LocationUtils.kt

hasLocationPermission function 回傳一個 Booleantruefalse,代表有沒有定位權限

class LocationUtils(val context: Context) {
    private val _fusedLocationClient: FusedLocationProviderClient =
        LocationServices.getFusedLocationProviderClient(context)

    @SuppressLint("MissingPermission")
    fun requestLocationUpdates(viewModel: LocationViewModel) {
        val locationCallback = object : LocationCallback() {
            override fun onLocationResult(locationResult: LocationResult) {
                super.onLocationResult(locationResult)
                locationResult.lastLocation?.let {
                    val location = LocationData(latitude = it.latitude, longitude = it.longitude)
                    viewModel.updateLocation(location)
                }
            }
        }
        val locationRequest = LocationRequest.Builder(
            Priority.PRIORITY_HIGH_ACCURACY, 1000
        ).build()
        _fusedLocationClient.requestLocationUpdates(
            locationRequest,
            locationCallback,
            Looper.getMainLooper()
        )
    }

    fun hasLocationPermission(context: Context): Boolean {
        return ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.ACCESS_FINE_LOCATION
        ) == PackageManager.PERMISSION_GRANTED
                &&
                ContextCompat.checkSelfPermission(
                    context,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
    }

    fun reverseGeocodeLocation(location: LocationData): String {
        // The Geocoder constructor expects a java.util.Locale
        val geocoder = Geocoder(context, Locale.getDefault())
        val coordinate = LatLng(location.latitude, location.longitude)
        val addresses: MutableList<Address>? =
            geocoder.getFromLocation(coordinate.latitude, coordinate.longitude, 1)

        return if (addresses?.isNotEmpty() == true) {
            addresses[0]?.getAddressLine(0) ?: "Address line not found"
        } else {
            "Address not found"
        }
    }
}

private val _fusedLocationClient: FusedLocationProviderClient =
    LocationServices.getFusedLocationProviderClient(context)

這是 Google 提供的 Fused Location Provider API,能整合 GPS、Wi-Fi、藍牙與行動網路的定位來源,讓定位更準確且省電。_fusedLocationClient 用來執行「請求位置更新」、「停止更新」、「取得最後位置」等操作。

requestLocationUpdates

@SuppressLint("MissingPermission")
fun requestLocationUpdates(viewModel: LocationViewModel) {

⚠️ 注意:這裡用了 @SuppressLint("MissingPermission"),代表你必須在呼叫這個函式前,確認已取得權限(也就是前面 LocationDisplay 檢查的部分)。

意思是保證外層一定會先檢查 hasLocationPermission() 才會進來,所以不用再提醒我 MissingPermission。

這個函式會每秒更新一次位置(因為下面設定了 1000 毫秒),然後將新位置傳給你的 LocationViewModel

val locationCallback = object : LocationCallback() {
    override fun onLocationResult(locationResult: LocationResult) {
        super.onLocationResult(locationResult)
        locationResult.lastLocation?.let {
            val location = LocationData(latitude = it.latitude, longitude = it.longitude)
            viewModel.updateLocation(location)
        }
    }
}

當系統取得新的定位資訊時,會呼叫 onLocationResult

lastLocation 是最新的 Location 物件(包含緯度與經度)。

package com.example.locationapp

data class LocationData(
    val latitude: Double,
    val longitude: Double
)

LocationData 是我們定義的資料類別,用來包裝座標。

呼叫 viewModel.updateLocation(location) → 把資料傳給 ViewModel 更新 UI。

_fusedLocationClient.requestLocationUpdates(
    locationRequest,
    locationCallback,
    Looper.getMainLooper()
)

這行讓 app 持續監聽位置變化。

hasLocationPermission

LocationUtils 要持有 Context,才能呼叫系統 API(如 ContextCompat.checkSelfPermission)來檢查權限。

return 這段是重點:ContextCompat.checkSelfPermission() 是Android官方提供的工具,用來檢查 App 是否擁有某個權限。它會回傳一個整數值(PERMISSION_GRANTEDPERMISSION_DENIED)。

這段程式碼的功能就像是你在檢查一個房間的門:

「門有沒有上鎖?如果鎖是開著的,就代表我可以進去。」

ContextCompat (檢查員) 你可以把它想像成 Android 系統中的一位通用檢查員萬用工具箱。它的工作就是確保你的程式碼在所有不同型號和版本的 Android 手機上都能正常運作(這就是 Compat 兼容性的意思)。

.checkSelfPermission() (檢查方法) 這是檢查員專門用來檢查特定權限狀態的方法。

  • 參數一 (context): 告訴檢查員「在哪個應用程式環境」下進行檢查。
  • 參數二 (Manifest.permission.ACCESS_FINE_LOCATION): 告訴檢查員「要檢查哪一種權限」。

為什麼需要 ContextCompat.checkSelfPermission()

我們不直接使用 PackageManager.PERMISSION_GRANTED 進行判斷,是因為 PackageManager.PERMISSION_GRANTED 只是一個「結果代碼」(密碼),而 ContextCompat.checkSelfPermission() 才是「執行檢查的動作

reverseGeocodeLocation

fun reverseGeocodeLocation(location: LocationData): String {
    val geocoder = Geocoder(context, Locale.getDefault())
    val coordinate = LatLng(location.latitude, location.longitude)
    val addresses: MutableList<Address>? =
        geocoder.getFromLocation(coordinate.latitude, coordinate.longitude, 1)

Geocoder 根據經緯度查詢地點名稱getFromLocation() 會回傳一個地址列表(可能含國家、城市、街道)。

MainActivity.kt

@Composable
fun MyApp(modifier: Modifier) {
    val context = LocalContext.current
    val locationUtils = LocationUtils(context)
    LocationDisplay(locationUtils = locationUtils, context = context)
}
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            val viewModel: LocationViewModel = viewModel()
            LocationAppTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    MyApp(viewModel = viewModel,modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

Context 是一個核心的 Android 系統類別,它提供了應用程式全域資訊,例如資源、資料庫、文件系統路徑,以及存取系統服務(如定位服務)的管道。

LocalContext.current 這是 Jetpack Compose 框架提供的一個 CompositionLocal。它用於在可組合函數中存取 Android 應用程式的 Context 物件

LocationDisplay(locationUtils = locationUtils, context = context) 這行程式碼的作用是調用一個名為 LocationDisplayCompose UI 元件向它傳遞所需的資料和環境依賴

@Composable
fun LocationDisplay(
    locationUtils: LocationUtils,
    viewModel: LocationViewModel,
    context: Context
) {
    val location = viewModel.location.value
    
    val address = location?.let {
        locationUtils.reverseGeocodeLocation(location)
    }

    val requestPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions(),
        onResult = { permissions ->
            if (permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
                && permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
            ) {
                // 擁有 location 權限
                locationUtils.requestLocationUpdates(viewModel = viewModel)

            } else {
                // 要求權限
                val rationaleRequired = ActivityCompat.shouldShowRequestPermissionRationale(
                    context as MainActivity,
                    Manifest.permission.ACCESS_FINE_LOCATION
                ) || ActivityCompat.shouldShowRequestPermissionRationale(
                    context as MainActivity,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                )

                if (rationaleRequired) {
                    Toast.makeText(
                        context,
                        "Location Permission is required for this feature to work",
                        Toast.LENGTH_LONG
                    ).show()
                } else {
                    Toast.makeText(
                        context,
                        "Location Permission is required, Please enable from settings",
                        Toast.LENGTH_LONG
                    ).show()
                }
            }
        }
    )

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        if(location != null) {
            Text("Address: ${location.latitude} ${location.longitude} \n $address ")
        }
        Text(text = "Location not available")

        Button(onClick = {
            if (locationUtils.hasLocationPermission(context)) {
                locationUtils.requestLocationUpdates(viewModel)
            } else {
                requestPermissionLauncher.launch(
                    arrayOf(
                        Manifest.permission.ACCESS_FINE_LOCATION,
                        Manifest.permission.ACCESS_COARSE_LOCATION
                    )
                )
            }
        }) {
            Text(text = "Get Location")
        }
    }
}

主要公能:顯示一個文字 + 按鈕,並處理按下按鈕後檢查或請求定位權限。

contract = ActivityResultContracts.RequestMultiplePermissions()

表示這個 launcher 可以一次請求多個權限(這裡是 coarse + fine)。

onResult = { permissions -> ... }

系統對話框關閉後,會回傳使用者是否授權。

if (permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
    && permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
) {
    // 已擁有定位權限
} else {
    // 尚未取得權限,顯示提示
}

若兩個權限都有允許,目前就什麼都不做;否則,進入「沒有權限」的分支。

如果沒權限 → 顯示提示 Toast

val rationaleRequired = ActivityCompat.shouldShowRequestPermissionRationale(
    context as MainActivity,
    Manifest.permission.ACCESS_FINE_LOCATION
) || ActivityCompat.shouldShowRequestPermissionRationale(
    context as MainActivity,
    Manifest.permission.ACCESS_COARSE_LOCATION
)

shouldShowRequestPermissionRationale(…)

這是 Android 的 API,用來判斷:

  • 使用者曾拒絕過權限(但沒勾「不再詢問」 → 回傳 true
  • 使用者勾了「不再詢問」或 第一次詢問 → 回傳 false
if (rationaleRequired) {
    Toast.makeText(context, "Location Permission is required for this feature to work", Toast.LENGTH_LONG).show()
} else {
    Toast.makeText(context, "Location Permission is required, Please enable from settings", Toast.LENGTH_LONG).show()
}

LocationViewModel.kt

package com.example.locationapp

import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel

class LocationViewModel: ViewModel() {
    private val _location = mutableStateOf<LocationData?>(null)
    val location: State<LocationData?> = _location

    fun updateLocation(newLocation: LocationData) {
        _location.value = newLocation
    }

}

private val _location = mutableStateOf<LocationData?>(null)

mutableStateOf 它包裝了一個變數,當值改變時,Compose UI 會自動重新組合(recompose)

LocationData? → 泛型裡面的 ? 代表可以是 null,表示「可能還沒取得位置」。

_location 是私有的可變,location 是公開的唯讀。

fun updateLocation(newLocation: LocationData) {
    _location.value = newLocation
}

LocationUtils 拿到新座標後,會呼叫updateLocation更新座標。

Table Of Contents

stackpenguin

SearchButton