API Key, Client Id 를 Local Properties에서 관리
- local.properties 에 밑 정보 입력 후 실행가능
api_key = "bNmSjmL3NWL/mAmsQV0SyDT+8DCdZckhVg5/tSsmJHa47eBZBE+aFvCHYxeM1Dsz2FcgQ64elqYL3mr6GUyjOg=="
client_id = "hx1egfkmv4"
- API Key: Data Module → util 패키지 → Constants → API_KEY 에 직접 추가
- Client Id: App Module → Manifest → meta-data value 에 client_id 직접 추가
정상 Splash Screen | 네트워크 연결 X Splash Screen | 네크워크 느린 Splash Screen |
위치 권한이 없을 때 초기 화면 | 위치 권한 없을 때 현재 위치 버튼 클릭 | 마커 이벤트 |
- Language : Kotlin
- minSdk : 21
- targetSdk : 32
- compileSdk 33
- JetPack Compose
- JetPack Compose Navigation
- Retrofit2 + OkHttp3
- Coroutine + Flow
- Hlit
- Room
- Never Map Compose : Naver Map 을 Compose 에 지원해 주는 라이브러리 (https://github.com/fornewid/naver-map-compose)
- Mockk + Truth + turbine + TestCoroutine
: 지도가 로딩 될때 모든 마커가 표시 되므로 비효율적이라 판단.
private fun getDistance(center: LatLng, target: LatLng): Double {
val earthRadius = 6372.8 * 1000
val diffLat = Math.toRadians(center.latitude - target.latitude)
val diffLon = Math.toRadians(center.longitude - target.longitude)
val a = kotlin.math.sin(diffLat / 2).pow(2.0)+
kotlin.math.sin(diffLon / 2).pow(2.0) *
kotlin.math.cos(Math.toRadians(target.latitude)) *
kotlin.math.cos(Math.toRadians(center.latitude))
val c = 2 * kotlin.math.asin(kotlin.math.sqrt(a))
return earthRadius * c
}
fun checkedRangeForMarker(
center: LatLng,
rangeLocation: LatLng?,
targetLocation: LatLng
): Boolean {
val range = getDistance(center,rangeLocation?: return false)
val distance = getDistance(center,targetLocation)
return range > distance
}
NaverMap(
properties = mapProperties,
uiSettings = mapUiSettings,
cameraPositionState = cameraPositionState,
onMapLoaded = { initPosition() },
onMapClick = { point, latLng -> onMapClick(point,latLng) }
) {
centerItems.forEach {
if(checkedRangeForMarker(
cameraPositionState.position.target, // 지도의 중앙 위치 좌표
cameraPositionState.contentBounds?.northEast, // 지도의 북동쪽 위치 좌표
LatLng(it.lat.toDouble(),it.lng.toDouble())) // 마커 좌표
) {
marker(it)
}
}
}
- 2초에 걸쳐 100%가 되도록 로딩바 구현
- 단, API 데이터 저장이 완료되지 않았다면 80%에서 대기 (저장이 완료되면 0.4초 걸쳐 100%를 만든 후 Map 화면으로 이동)
private lateinit var loadingScope: Job
private val _uiState = MutableStateFlow(UiState.EMPTY)
val uiState = _uiState.asStateFlow()
private fun increaseLoadingValue() {
loadingScope = viewModelScope.launch {
delay(20)
_loadingValue.value++
_loadingWidth.value += 2
}
}
fun startLoading() {
// 증가 Scope 실행
if(loadingValue.value < 100) {
increaseLoadingValue()
}
// 80% 에서 상태 체크 로딩이 완료되지 않았다면 증가 Scoope cancle
if (loadingValue.value == 80 && uiState.value != UiState.SUCCESS) {
loadingScope.cancel()
}
// 100% 에 데이터 저장 성공 시 Scoope cancle 후 Map Page 로 이동
if (loadingValue.value == 100 && uiState.value == UiState.SUCCESS) {
loadingScope.cancel()
onNavigateToMapView()
}
// 데이터 불러오기
if (loadingValue.value == 0) {
for (idx in 1..10) {
getCenterItems(idx)
}
}
}
private fun insertCenterItems(items: List<CenterItem>, page: Int) {
viewModelScope.launch {
splashUseCase.insertCenterItems(items)
.catch { Log.d(TAG, "Insert Center Items: ${it.message}") }
.collect {
// 마지막 페이지면 Loading 상태 체크 호출
if (it && page == 10) {
completeInsertCenterItems()
}
if (it.not()) {
Log.d(TAG, "Insert Center Items: No.$page Insert Failure")
}
}
}
}
private fun completeInsertCenterItems() {
// Loading 상태를 성공으로 만든다
updateUiState(UiState.SUCCESS)
// 로딩 완료후 증가 Scoope 가 cancle 이면서 Loading Value 가 80% 라면 증가 Scoope를 다시 실행 시킨다.
if (loadingScope.isCancelled && uiState.value != UiState.SUCCESS) {
increaseLoadingValue()
}
}
: Network 가 연결되어 있지 않다면 Loading 80% 에서 움직이지 않는 문제
interface NetworkManager {
fun getConnectivityManager(): ConnectivityManager
fun getNetworkRequest(): NetworkRequest
}
class NetworkManagerImpl(private val context: Context): NetworkManager {
override fun getConnectivityManager(): ConnectivityManager {
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
override fun getNetworkRequest(): NetworkRequest {
return NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
}
}
@Module
@InstallIn(SingletonComponent::class)
class NetworkManagerModule {
@Singleton
@Provides
fun provideNetworkManager(
@ApplicationContext context: Context
): NetworkManager = NetworkManagerImpl(context)
}
fun networkConnectCallback(callback: (Boolean) -> Unit): ConnectivityManager.NetworkCallback {
return object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
callback(true)
}
override fun onLost(network: Network) {
callback(false)
}
}
}
override suspend fun observeConnectivityAsFlow():Flow<Boolean> = callbackFlow {
val connectivityManager = networkManager.getConnectivityManager()
val callback = networkConnectCallback { result -> trySend(result) }
val networkRequest = networkManager.getNetworkRequest()
connectivityManager.registerNetworkCallback(networkRequest,callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
private val _networkConnectState = MutableStateFlow(false)
val networkConnectState = _networkConnectState.asStateFlow()
// NetWork 가 연결되어 있지 않고 로딩도 완료되지 않았다면 연결 요청 UI 표출
if(networkConnectState.not() && loadingState.not()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
){
Text(text = "네트워크를 연결해 주세요 !", color = Color.Red, fontSize = 20.sp)
}
// 로딩이 된 후에 네트워크가 끊겼을 경우 다시 처름부터 loading 되기 때문에 Loding Value 값을 초기화 한다.
splashViewModel.initLoadingValue()
}
- Dispatcher를 Main으로 초기화
- 사용할 UseCase mockk 객체로 생성
- Log mokk 객체로 생성
class SplashViewModelTest {
private val getSplashUseCase: SplashUseCase = mockk()
private val navigator: VaccinationAppNavigator = mockk()
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
private val viewModel: SplashViewModel by lazy { SplashViewModel(getSplashUseCase,navigator) }
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
mockkStatic(Log::class)
every { Log.v(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.e(any(), any()) } returns 0
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
@Test
@DisplayName("[성공] App 시작 시 네트워크가 연결 되어 있을 경우: Loading -> SUCCESS / loading Value 값 0 에서 100 증가 확인")
fun checkNetworkConnectSuccess() = runTest {
// given
coEvery {
getSplashUseCase.observeConnectivityAsFlow()
} returns flow { emit(true) }
for(i in 1 ..10) {
coEvery {
getSplashUseCase.getCenterInfo(i)
} returns flow { emit(ApiResult.Success(listOf(centerItemTest))) }
}
coEvery {
getSplashUseCase.insertCenterItems(listOf(centerItemTest))
} returns flow { emit(true) }
// 초기 값 확인
assertThat(viewModel.uiState.value).isEqualTo(UiState.LOADING)
// when
for(i in 0 .. 99) {
viewModel.startLoading()
}
// then
viewModel.uiState.test {
assertThat(this.awaitItem()).isEqualTo(UiState.SUCCESS)
}
viewModel.loadingValue.test {
for(i in 0 .. 100) {
assertThat(this.awaitItem()).isEqualTo(i)
}
}
}
@Test
@DisplayName("[실패] App 시작 시 네트워크가 연결 되어 있지 않을 경우: 함수 실행 X -> 초기값만 확인")
fun checkNetworkConnectError() = runTest {
// given
coEvery {
getSplashUseCase.observeConnectivityAsFlow()
} returns flow { emit(false) }
for(i in 1 ..10) {
coEvery {
getSplashUseCase.getCenterInfo(i)
} returns flow { emit(ApiResult.Success(listOf(centerItemTest))) }
}
coEvery {
getSplashUseCase.insertCenterItems(listOf(centerItemTest))
} returns flow { emit(true) }
// when X
// then
viewModel.uiState.test {
assertThat(this.awaitItem()).isEqualTo(UiState.ERROR)
}
viewModel.loadingValue.test {
assertThat(this.awaitItem()).isEqualTo(0)
}
}
@Test
@DisplayName("네트워크 데이터 저장이 완료되지 않았을 경우 : when 실행후에 UI State -> Loading")
fun checkNetworkConnectSlow() = runTest {
// given
coEvery {
getSplashUseCase.observeConnectivityAsFlow()
} returns flow { emit(true) }
for(i in 1 ..10) {
coEvery {
getSplashUseCase.getCenterInfo(i)
} returns flow { emit(ApiResult.Success(listOf(centerItemTest))) }
}
coEvery {
getSplashUseCase.insertCenterItems(listOf(centerItemTest))
} returns flow { emit(false) }
// when
for(i in 0 .. 99) {
viewModel.startLoading()
}
// then
viewModel.uiState.test {
assertThat(this.awaitItem()).isEqualTo(UiState.LOADING)
}
}