Places Api (New) 활용
- 사내 프로젝트에 예전부터 Places SDK가 적용되어 위치 검색 기능을 담당하고 있었는데, 어느 시점부터 아래와 같이 오류가 표시되는 지역들이 증가하기 시작했다.
- Google Places API(신규)는 기존 Places API를 대체하는 서비스로, 장소 검색, 자동완성, 상세 정보 조회, 좌표 변환 등의 기능을 제공한다. 그리고 데이터가 등록된 장소 개수 자체도 훨씬 많은 것으로 알고 있다.
- 기존에 사용하고 있던 API Key가 있다면 Places Api (New)를 사용으로 설정한 뒤, 제한 목록에 추가해 주면 된다.
- 신규, 기존 상관없이 적어도 아래 목록은 키 제한 API 목록에 포함시켜야한다. 그렇지 않다면 권한 거부 오류가 발생한다.
- Places SDK for Android
- Places API (New)
- Places API
- Maps SDK for Android
- Maps JavaScript API
- Geocoding API
- 신규라면 결제 프로필이 제대로 구성되어있는지도 확인 필요하다.
초기 구성
- 이제 안드로이드 프로젝트를 열어서 app 수준의 gradle에 다음 내용을 추가한다.
implementation 'com.google.android.libraries.places:places:4.1.0'
implementation 'com.google.firebase:firebase-appcheck-playintegrity:18.0.0'
- 하나는 places api의 최신 버전이고, 나머지 하나는 api 호출 보안을 위한 내용이다.
- 물론, Manifest에 API KEY가 제대로 선언되어 있는지도 반드시 확인한다.
<manifest>
<uses-permission android:name="android.permission.INTERNET"/>
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_API_KEY"/>
</application>
</manifest>
- 다음으로, 애플리케이션 클래스를 생성하거나 이미 존재하는 곳 onCreate()에 Places API의 초기 설정을 진행한다.
private void initPlacesToken() {
FirebaseApp.initializeApp(this);
FirebaseAppCheck firebaseAppCheck = FirebaseAppCheck.getInstance();
firebaseAppCheck.installAppCheckProviderFactory(PlayIntegrityAppCheckProviderFactory.getInstance());
if (!Places.isInitialized()) {
String keyValue = null;
try {
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
if (appInfo.metaData != null) keyValue = appInfo.metaData.getString("com.google.android.geo.API_KEY");
} catch (PackageManager.NameNotFoundException e) {
//TODO : Error Log
}
if (keyValue != null) Places.initializeWithNewPlacesApiEnabled(context, keyValue);
}
Places.setPlacesAppCheckTokenProvider(new PlaceTokenProvider());
}
- App Check라는 부분이 있다. App Check는 합법적인 앱 이외의 소스에서 발생하는 트래픽을 차단하여 앱에서 Google Maps Platform으로의 호출을 보호하는 역할을 한다.
https://developers.google.com/maps/documentation/places/android-sdk/app-check?hl=ko
Places SDK for Android | Google for Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 의견 보내기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 플랫폼 선택: Android iOS JavaScript App Check를
developers.google.com
- 앱을 앱 체크와 통합하면 악의적인 요청으로부터 보호할 수 있으므로 승인되지 않은 API 호출에 대한 요금이 청구되지 않는다고 한다.
- 근데 이 App Check를 활용한 호출이 일일 10,000회로 제한되어 있다고 한다. 정책에 따라 할당량 상향 조정을 요청할 수도 있다.
https://developer.android.com/google/play/integrity/setup?hl=ko#increase-daily
설정 | Google Play | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 설정 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 Play Integrity API를 사용하도록 앱
developer.android.com
- 위 코드에서 App Check를 초기화한 부분
FirebaseApp.initializeApp(this);
FirebaseAppCheck firebaseAppCheck = FirebaseAppCheck.getInstance();
firebaseAppCheck.installAppCheckProviderFactory(PlayIntegrityAppCheckProviderFactory.getInstance());
- 위 코드에서 Places API를 초기화한 부분
if (!Places.isInitialized()) {
String keyValue = null;
try {
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
if (appInfo.metaData != null) keyValue = appInfo.metaData.getString("com.google.android.geo.API_KEY");
} catch (PackageManager.NameNotFoundException e) {
//TODO : Error Log
}
if (keyValue != null) Places.initializeWithNewPlacesApiEnabled(context, keyValue);
}
- 여기서 중요한 것은 초기화 함수명에 기존과 다르게 New~가 포함되어있는 것이다.
- 이제 토큰을 받는다. PlacesAppCheckTokenProvider를 구현하여 토큰을 받거나 실패에 대한 처리를 진행, 또는 어떻게 토큰을 관리하고 활용할 것인지 지정할 수 있다.
/** Sample client implementation of App Check token fetcher interface. */
static class TokenProvider implements PlacesAppCheckTokenProvider {
@Override
public ListenableFuture<String> fetchAppCheckToken() {
SettableFuture<String> future = SettableFuture.create();
FirebaseAppCheck.getInstance()
.getAppCheckToken(false)
.addOnSuccessListener(
appCheckToken -> {
future.set(appCheckToken.getToken());
})
.addOnFailureListener(
ex -> {
future.setException(ex);
});
return future;
}
}
.....
Places.setPlacesAppCheckTokenProvider(new PlaceTokenProvider());
Compose + AutoComplete 구현
- 이제는 검색 기능 UI를 커스텀하게 구성을 해야하는 것으로 보인다. 정말 똑같지는 않아도 기존에 구글에서 제공하던 UI와 비슷하게 만들었다.
- 뒷 배경 처리 및 외부를 누르면 검색 프로세스가 종료되도록 다이얼로그 기반으로 구현했다.
Dialog(onDismissRequest = onDismiss) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { onDismiss() },
contentAlignment = Alignment.TopCenter
) { }
}
- 컬럼을 통해 검색바와 검색 결과 목록을 배치하도록 한다.
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(16.dp)
) {}
- TextField로 검색바를 구현한다.
var query by remember { mutableStateOf("") }
var predictions by remember { mutableStateOf(emptyList<AutocompletePrediction>()) }
val coroutineScope = rememberCoroutineScope()
var searchJob by remember { mutableStateOf<Job?>(null) }
OutlinedTextField(
value = query,
onValueChange = { newQuery ->
query = newQuery
searchJob?.cancel()
if (newQuery.isNotEmpty() && newQuery.isNotBlank()) {
searchJob = coroutineScope.launch {
delay(250)
fetchAutoCompleteResults(newQuery, placesClient) { results ->
predictions = results
}
}
} else predictions = emptyList()
},
label = { Text(stringResource(R.string.search)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = stringResource(R.string.search)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Search
),
colors = TextFieldDefaults.colors(
unfocusedPlaceholderColor = Color.Black,
focusedTextColor = Color.Black,
focusedPlaceholderColor = Color.Black,
unfocusedTextColor = Color.Black,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Black,
focusedLabelColor = Color.Black,
cursorColor = Color.Black
)
)
- 여기서 검색 시, 매 텍스트 변화 시마다 굳이 API를 호출하지 않도록 250m/s 초과로 사용자의 입력이 더 이상 없을 경우 API를 호출하도록 했다. Coroutine을 기반으로 Debounce 처리를 진행했다.
- 입력 & 검색을 하나의 Job으로 묶고, 해당 Job 처리 중에 곧장 새로운 Job이 들어온다면 진행 중인 Job을 cancel 시킨다.
onValueChange = { newQuery ->
query = newQuery
searchJob?.cancel()
if (newQuery.isNotEmpty() && newQuery.isNotBlank()) {
searchJob = coroutineScope.launch {
delay(250)
fetchAutoCompleteResults(newQuery, placesClient) { results ->
predictions = results
}
}
} else predictions = emptyList()
},
- fetchAutoCompleteResults 함수는 쿼리를 보내 SDK로부터 반환된 검색 결과 목록을 predictions에 담는 역할을 한다.
fun fetchAutoCompleteResults(
query: String,
placesClient: PlacesClient,
onResult: (List<AutocompletePrediction>) -> Unit
) {
val request = FindAutocompletePredictionsRequest.builder()
.setQuery(query)
.build()
placesClient.findAutocompletePredictions(request)
.addOnSuccessListener { response ->
onResult(response.autocompletePredictions)
}
.addOnFailureListener { exception ->
Log.e("Places API", "AutoComplete Error: ${exception.message}")
}
}
- 여기서 인자로 받는 placesClient는 최상위 컴포저블 상단에 다음과 같이 선언되어 있다.
@Composable
fun rememberPlacesClient(context: Context): PlacesClient = remember { Places.createClient(context) }
@Composable
fun PlaceSearchScreen(onDismiss: () -> Unit, onClick: (Place) -> Unit) {
val context = LocalContext.current
val placesClient = rememberPlacesClient(context)
..... // UI 코드
- 검색된 리스트를 검색바 아래에 보여준다.
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(predictions) { prediction ->
PlaceItem(prediction) { placeId ->
fetchPlaceDetails(
placeId,
placesClient
) { place -> onClick.invoke(place) }
}
}
}
- 선택된 placeId를 기반으로 이번엔 해당 위치에 대한 상세 정보를 수신받는다.
fun fetchPlaceDetails(
placeId: String,
placesClient: PlacesClient,
onResult: (Place) -> Unit
) {
val request = FetchPlaceRequest.builder(
placeId,
listOf(Place.Field.DISPLAY_NAME, Place.Field.FORMATTED_ADDRESS, Place.Field.LOCATION)
).build()
placesClient.fetchPlace(request)
.addOnSuccessListener { response ->
onResult(response.place)
}
.addOnFailureListener { exception ->
Log.e("Places API", "Place Details Error: ${exception.message}")
}
}
- 대표적으로 이름, 주소, 좌표 값 3가지를 받도록 했다.
- 이제 반환된 Place 객체를 기반으로 이후 처리를 진행하면 된다.
- 리스트 영역을 지나 최하단에는 Google 로고 이미지를 표시했다. 정책상 필요하다고 들은 것 같은데, 구 버전 AutoComplete UI에도 표시되어 있었기 때문에 비슷하게 삽입해 줬다.
Spacer(modifier = Modifier.height(12.dp))
Image(
painterResource(R.drawable.google_on_white),
contentDescription = "Powered by Google",
modifier = Modifier.align(Alignment.Start)
)