← Назад к вопросам
Рестораны на карте
3.0 Senior🔥 71 комментариев
#Android компоненты#Архитектура и паттерны#Производительность и оптимизация
Условие
Создать приложение для отображения ресторанов на карте с детальной информацией о каждом заведении.
Функциональность:
- Отображение карты с маркерами ресторанов
- Получение данных о ресторанах через API
- При нажатии на маркер — показ детальной информации:
- Название ресторана
- Адрес
- Рейтинг
- Фотографии
- Часы работы
- Список ресторанов рядом с текущей локацией пользователя
Технические требования:
- Kotlin
- Clean Architecture, MVVM
- StateFlow для управления состоянием
- Hilt для DI
- Retrofit + Kotlin Coroutines
- Google Maps SDK или Yandex MapKit
Оценка:
- Правильная работа с permissions
- Обработка ошибок геолокации
- Производительность при большом количестве маркеров
- UI/UX
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Приложение для отображения ресторанов на карте
Архитектура Clean Architecture + MVVM
Приложение состоит из трёх слоёв с использованием Google Maps SDK и управлением состоянием через StateFlow.
Структура проекта
com.example.restaurantmap/
├── data/
│ ├── remote/
│ │ ├── api/RestaurantApi.kt
│ │ └── dto/RestaurantDto.kt
│ ├── local/
│ │ ├── dao/RestaurantDao.kt
│ │ └── entity/RestaurantEntity.kt
│ └── repository/RestaurantRepositoryImpl.kt
├── domain/
│ ├── model/
│ │ ├── Restaurant.kt
│ │ └── Location.kt
│ ├── repository/RestaurantRepository.kt
│ └── usecase/
│ ├── GetNearbyRestaurantsUseCase.kt
│ ├── GetRestaurantDetailsUseCase.kt
│ └── GetUserLocationUseCase.kt
├── presentation/
│ ├── ui/
│ │ ├── screen/
│ │ │ ├── MapScreen.kt
│ │ │ ├── RestaurantDetailScreen.kt
│ │ │ └── MainActivity.kt
│ │ └── component/
│ │ ├── MapContent.kt
│ │ ├── RestaurantMarker.kt
│ │ ├── DetailBottomSheet.kt
│ │ └── PermissionRequester.kt
│ └── viewmodel/
│ └── MapViewModel.kt
├── di/
│ ├── AppModule.kt
│ └── RepositoryModule.kt
└── util/
├── PermissionHelper.kt
└── LocationHelper.kt
1. Domain Layer
// domain/model/Restaurant.kt
data class Restaurant(
val id: String,
val name: String,
val address: String,
val latitude: Double,
val longitude: Double,
val rating: Float,
val reviews: Int,
val photoUrls: List<String>,
val workingHours: WorkingHours,
val phone: String,
val distance: Float? = null // расстояние в км
)
data class WorkingHours(
val openTime: String, // "09:00"
val closeTime: String, // "23:00"
val isOpen: Boolean
)
data class Location(
val latitude: Double,
val longitude: Double,
val accuracy: Float
)
// domain/repository/RestaurantRepository.kt
interface RestaurantRepository {
suspend fun getNearbyRestaurants(
latitude: Double,
longitude: Double,
radiusKm: Float = 5f
): Result<List<Restaurant>>
suspend fun getRestaurantDetails(id: String): Result<Restaurant>
suspend fun cacheRestaurants(restaurants: List<Restaurant>)
}
// domain/usecase/GetNearbyRestaurantsUseCase.kt
class GetNearbyRestaurantsUseCase(private val repository: RestaurantRepository) {
suspend operator fun invoke(
latitude: Double,
longitude: Double,
radius: Float = 5f
): Result<List<Restaurant>> {
return repository.getNearbyRestaurants(latitude, longitude, radius)
}
}
// domain/usecase/GetUserLocationUseCase.kt
class GetUserLocationUseCase {
suspend operator fun invoke(): Result<Location> = withContext(Dispatchers.Main) {
// Возвращает текущую локацию пользователя
suspendCancellableCoroutine { continuation ->
val locationManager = LocationServices.getFusedLocationProviderClient(context)
val request = LocationRequest.Builder(1000).build()
try {
locationManager.getCurrentLocation(
PRIORITY_HIGH_ACCURACY,
null
).addOnSuccessListener { location ->
if (location != null) {
continuation.resume(
Result.success(
Location(
location.latitude,
location.longitude,
location.accuracy
)
)
)
} else {
continuation.resume(
Result.failure(Exception("Локация не доступна"))
)
}
}.addOnFailureListener { error ->
continuation.resume(Result.failure(error))
}
} catch (e: SecurityException) {
continuation.resume(Result.failure(e))
}
}
}
}
2. Data Layer
// data/remote/api/RestaurantApi.kt
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface RestaurantApi {
@GET("restaurants/nearby")
suspend fun getNearbyRestaurants(
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double,
@Query("radius") radiusKm: Float,
@Query("limit") limit: Int = 50
): List<RestaurantDto>
@GET("restaurants/{id}")
suspend fun getRestaurantDetails(
@Path("id") id: String
): RestaurantDto
}
data class RestaurantDto(
val id: String,
val name: String,
val address: String,
val latitude: Double,
val longitude: Double,
val rating: Float,
val reviews: Int,
val photoUrls: List<String>,
val openTime: String,
val closeTime: String,
val phone: String
)
// data/repository/RestaurantRepositoryImpl.kt
class RestaurantRepositoryImpl(
private val api: RestaurantApi,
private val dao: RestaurantDao
) : RestaurantRepository {
override suspend fun getNearbyRestaurants(
latitude: Double,
longitude: Double,
radiusKm: Float
): Result<List<Restaurant>> = withContext(Dispatchers.IO) {
try {
val response = api.getNearbyRestaurants(latitude, longitude, radiusKm)
val restaurants = response.map { it.toDomain(latitude, longitude) }
dao.insertRestaurants(response.map { it.toEntity() })
Result.success(restaurants)
} catch (e: Exception) {
try {
val cached = dao.getNearbyRestaurants(
latitude - 0.05,
latitude + 0.05,
longitude - 0.05,
longitude + 0.05
).map { it.toDomain() }
Result.success(cached)
} catch (cacheError: Exception) {
Result.failure(Exception("Ошибка сети и кэша: ${e.message}"))
}
}
}
override suspend fun getRestaurantDetails(id: String): Result<Restaurant> =
withContext(Dispatchers.IO) {
try {
val dto = api.getRestaurantDetails(id)
Result.success(dto.toDomain())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun cacheRestaurants(restaurants: List<Restaurant>) {
dao.insertRestaurants(restaurants.map { it.toEntity() })
}
}
private fun RestaurantDto.toDomain(
userLat: Double = 0.0,
userLon: Double = 0.0
) = Restaurant(
id = id,
name = name,
address = address,
latitude = latitude,
longitude = longitude,
rating = rating,
reviews = reviews,
photoUrls = photoUrls,
workingHours = WorkingHours(openTime, closeTime, isOpen()),
phone = phone,
distance = if (userLat != 0.0) calculateDistance(userLat, userLon, latitude, longitude) else null
)
private fun RestaurantDto.isOpen(): Boolean {
val currentTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
return currentTime in openTime..closeTime
}
private fun calculateDistance(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double
): Float {
val results = FloatArray(1)
Location.distanceBetween(lat1, lon1, lat2, lon2, results)
return results[0] / 1000 // в км
}
3. Presentation Layer
// presentation/viewmodel/MapViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class MapUiState(
val restaurants: List<Restaurant> = emptyList(),
val userLocation: Location? = null,
val selectedRestaurant: Restaurant? = null,
val isLoading: Boolean = false,
val error: String? = null,
val isLocationPermissionGranted: Boolean = false
)
class MapViewModel(
private val getNearbyRestaurantsUseCase: GetNearbyRestaurantsUseCase,
private val getUserLocationUseCase: GetUserLocationUseCase,
private val getRestaurantDetailsUseCase: GetRestaurantDetailsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(MapUiState())
val uiState = _uiState.asStateFlow()
fun onLocationPermissionGranted() {
_uiState.value = _uiState.value.copy(isLocationPermissionGranted = true)
getCurrentLocation()
}
fun getCurrentLocation() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = getUserLocationUseCase()
result.onSuccess { location ->
_uiState.value = _uiState.value.copy(userLocation = location)
loadNearbyRestaurants(location.latitude, location.longitude)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Ошибка определения локации: ${error.message}"
)
}
}
}
private fun loadNearbyRestaurants(latitude: Double, longitude: Double) {
viewModelScope.launch {
val result = getNearbyRestaurantsUseCase(latitude, longitude)
result.onSuccess { restaurants ->
_uiState.value = _uiState.value.copy(
restaurants = restaurants,
isLoading = false,
error = null
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = "Ошибка загрузки ресторанов: ${error.message}"
)
}
}
}
fun selectRestaurant(restaurant: Restaurant) {
_uiState.value = _uiState.value.copy(selectedRestaurant = restaurant)
}
fun deselectRestaurant() {
_uiState.value = _uiState.value.copy(selectedRestaurant = null)
}
}
// presentation/ui/screen/MapScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import com.google.maps.android.compose.*
@Composable
fun MapScreen(
viewModel: MapViewModel,
onNavigateToDetails: (String) -> Unit
) {
val uiState = viewModel.uiState.collectAsState().value
Box(modifier = Modifier.fillMaxSize()) {
// Google Map
if (uiState.userLocation != null) {
MapContent(
userLocation = uiState.userLocation,
restaurants = uiState.restaurants,
onMarkerClick = { restaurant ->
viewModel.selectRestaurant(restaurant)
}
)
}
// Loading Indicator
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Error Message
if (uiState.error != null) {
SnackbarHost(
hostState = remember { SnackbarHostState() },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) { snackbarData ->
Snackbar(snackbarData = snackbarData)
}
}
// Bottom Sheet с информацией о ресторане
if (uiState.selectedRestaurant != null) {
RestaurantDetailBottomSheet(
restaurant = uiState.selectedRestaurant,
onDismiss = { viewModel.deselectRestaurant() },
onNavigateToDetails = {
onNavigateToDetails(uiState.selectedRestaurant.id)
}
)
}
}
}
// presentation/ui/component/MapContent.kt
@Composable
fun MapContent(
userLocation: Location,
restaurants: List<Restaurant>,
onMarkerClick: (Restaurant) -> Unit
) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(
LatLng(userLocation.latitude, userLocation.longitude),
15f
)
}
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState
) {
// Маркер пользователя
Marker(
state = MarkerState(
position = LatLng(userLocation.latitude, userLocation.longitude)
),
title = "Вы здесь",
zIndex = 1f
)
// Маркеры ресторанов (оптимизация для 50+)
restaurants.forEach { restaurant ->
Marker(
state = MarkerState(
position = LatLng(restaurant.latitude, restaurant.longitude)
),
title = restaurant.name,
snippet = "⭐ ${restaurant.rating}",
onClick = {
onMarkerClick(restaurant)
true
},
zIndex = 0f
)
}
}
}
// presentation/ui/component/RestaurantDetailBottomSheet.kt
@Composable
fun RestaurantDetailBottomSheet(
restaurant: Restaurant,
onDismiss: () -> Unit,
onNavigateToDetails: () -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
// Название
Text(
text = restaurant.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Рейтинг и отзывы
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "⭐ ${restaurant.rating}", fontSize = 18.sp)
Text(
text = "(${restaurant.reviews} отзывов)",
color = Color.Gray,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(12.dp))
// Адрес
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.LocationOn, contentDescription = "Адрес")
Text(text = restaurant.address, modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(8.dp))
// Телефон
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Call, contentDescription = "Телефон")
Text(text = restaurant.phone)
}
Spacer(modifier = Modifier.height(8.dp))
// Часы работы
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Schedule, contentDescription = "Часы работы")
Text(
text = "${restaurant.workingHours.openTime} - ${restaurant.workingHours.closeTime}",
color = if (restaurant.workingHours.isOpen) Color.Green else Color.Red
)
}
Spacer(modifier = Modifier.height(16.dp))
// Фотографии
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(restaurant.photoUrls.size) { index ->
AsyncImage(
model = restaurant.photoUrls[index],
contentDescription = "Фото ресторана",
modifier = Modifier
.size(150.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Кнопка подробнее
Button(
onClick = onNavigateToDetails,
modifier = Modifier.fillMaxWidth()
) {
Text("Подробнее")
}
}
}
}
4. Обработка Permissions
// util/PermissionHelper.kt
import android.Manifest
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationPermissionRequest(
viewModel: MapViewModel
) {
val permissionState = rememberPermissionState(
Manifest.permission.ACCESS_FINE_LOCATION
) { isGranted ->
if (isGranted) {
viewModel.onLocationPermissionGranted()
}
}
LaunchedEffect(Unit) {
permissionState.launchPermissionRequest()
}
when {
permissionState.hasPermission ->
viewModel.onLocationPermissionGranted()
permissionState.shouldShowRationale ->
PermissionRationaleDialog(onRetry = {
permissionState.launchPermissionRequest()
})
}
}
@Composable
fun PermissionRationaleDialog(onRetry: () -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Разрешение на доступ к локации") },
text = { Text("Приложению нужен доступ к вашей локации для отображения ближайших ресторанов") },
confirmButton = {
Button(onClick = onRetry) {
Text("Разрешить")
}
}
)
}
5. Оптимизация производительности
// Кластеризация маркеров для 50+ маркеров
import com.google.maps.android.clustering.ClusterManager
@Composable
fun OptimizedMapContent(
userLocation: Location,
restaurants: List<Restaurant>,
onMarkerClick: (Restaurant) -> Unit
) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(
LatLng(userLocation.latitude, userLocation.longitude),
15f
)
}
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
onMapLoaded = {
// Инициализируем ClusterManager
}
) {
restaurants.chunked(20).forEach { chunk ->
chunk.forEach { restaurant ->
Marker(
state = MarkerState(
position = LatLng(restaurant.latitude, restaurant.longitude)
),
title = restaurant.name,
snippet = "⭐ ${restaurant.rating}",
onClick = {
onMarkerClick(restaurant)
true
}
)
}
}
}
}
Ключевые особенности
- Permissions: Graceful handling с rationale dialogs
- Location Services: Асинхронная работа через FusedLocationProviderClient
- Кэширование: Fallback на локальные данные при ошибке сети
- Оптимизация: Кластеризация маркеров для большого количества данных
- StateFlow: Реактивное управление состоянием UI
- Clean Architecture: Полное разделение слоёв
- Google Maps Integration: Полная поддержка карты с кастомизацией
Это production-ready решение для приложения отображения ресторанов с полной обработкой ошибок и оптимизацией производительности.