아프리카 2023년 신입/경력 공개채용 Android 앱 개발 사전 과제
API KEY를 local.properties 에서 관리
- local.properties에
client_id=af_mobilelab_dev_e0f147f6c034776add2142b425e81777
추가 후 실행
Configuration Changed |
NetWork Error |
방송목록 존재하지 않을 때 |
|
|
|
Popup Menu |
가로 모드 |
|
|
- Language : Kotlin
- minSdk : 23
- targetSdk : 31
- ViewPager2 + Coil + Lottie
- JetPack Navigation
- Retrofit2 + OkHttp3 + Moshi
- Coroutine + Flow + TestCoroutine
- Mockk + Truth + turbine
- SwipeRefreshLayout
- mockk 객체와 TestDispacther를 활용
- Flow를 테스트하기 위해서 turebin 라이브러리 사용
Test용 Dispatcher 및 Mockk 객체 생성
- Dispatcher를 Main으로 초기화
- 사용할 UseCase mockk 객체로 생성
@OptIn(ExperimentalCoroutinesApi::class)
class BroadViewModelTest {
private val fetchBroadListUseCase: FetchBroadListUseCase = mockk()
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
- coEvery를 활용해서 mockk 객체의 반환 값 지정
- 성공 할 때의 UiState Event와 PageNumber가 증가가 정상적으로 이뤄졌는지 확인
- 예상한 대로 모든 Event가 소비 되는지 확인하기 위해서 cancelAndConsumeRemainingEvents를 활용
@Test
@DisplayName("[성공] 카테고리를 받아서 방송 리스트를 갖고 오는데 성공하면 UiState는 Loading이였다가 Success되고 pageNumber는 증가한다.")
fun fetchBroadSuccess() = runTest {
// given
coEvery {
fetchBroadListUseCase(TEST_CATEGORY, 1)
} returns ResultWrapper.Success(testBroad)
// when
val prevPageNum = viewModel.pageNumber
viewModel.fetchBroadList(TEST_CATEGORY)
// then
viewModel.uiState.test {
assertThat(cancelAndConsumeRemainingEvents()).containsExactly(
Event.Item(UiState.Loading),
Event.Item(UiState.Success(Unit))
)
assertThat(prevPageNum + 1).isEqualTo(viewModel.pageNumber)
}
}
- 실패한 경우 mockk 객체 반환 값 지정
- UiState 상태가 Failed이고 PageNumber가 증가하지 않았는지 확인
@Test
@DisplayName("[실패] 방송 리스트를 갖고 오는데 네트워크 오류가 난다면 UiState는 Failed가 되고 pageNumber는 증가하지 않는다.")
fun fetchBroadFailedNetWorkError() = runTest {
// given
coEvery {
fetchBroadListUseCase(TEST_CATEGORY, 1)
} returns ResultWrapper.Failed(ErrorData.Network)
// when
val prevPageNum = viewModel.pageNumber
viewModel.fetchBroadList(TEST_CATEGORY)
// then
viewModel.uiState.test {
assertThat(cancelAndConsumeRemainingEvents()).containsExactly(
Event.Item(UiState.Loading),
Event.Item(UiState.Failure(NETWORK_ERROR_STRING_RES)) // 네트워크 에러 StringRes 값
)
assertThat(prevPageNum).isEqualTo(viewModel.pageNumber)
}
}
HomeViewModelTest |
BroadViewModelTest |
|
|
- 무조건 맨 아래에 도착했을 때 요청하는 것은 비효율적이라고 판단
- 10개를 예시로 들면 약 6개가 보였을 때 미리 요청을 하게 되면 유저는 로딩 되는 화면을 짧게 볼 수 있을 것이라고 판단
RecyclerView의 addOnScrollListener
을 활용해서 구현
private fun initRecyclerViewScrollListener(
recyclerView: RecyclerView,
fetch: (() -> Unit),
pagingFetchCount: Int = DEFAULT_PAGING_FETCH_COUNT
) {
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager =
LinearLayoutManager::class.java.cast(recyclerView.layoutManager) ?: return
val totalItemCount = layoutManager.itemCount
val lastVisible = layoutManager.findLastCompletelyVisibleItemPosition()
if (endScrolled(lastVisible, totalItemCount)) {
fetch.invoke()
}
}
private fun endScrolled(lastVisible: Int, totalItemCount: Int) =
lastVisible >= totalItemCount - pagingFetchCount
})
}
- 내가 원하는 위치만큼 RecyclerView가 내려왔을 때 fetch 람다 실행 (ViewModel에게 새 데이터 요청)
- 유저의 로딩 시간 단축
- 중복 실행 방지를 위해서 실행 중인 Job이 있는지 확인하고 만약 없다면 요청 하는 방식으로 구현
private var job: Job? = null
fun fetchBroadList(categoryName: String) {
if (job != null && job?.isActive == true) return
job = viewModelScope.launch {
// 요청 코드 생략
}
}
- API 요청간에 중복 코드를 제거를 하기 위해서 구현
- 모듈로 분리해서 추후에 Remote / Local 모듈까지 분리 됐을 때 관심사 분리 생각
- ErrorHandling의 역할까지 포함하기 위해서 Handling 클래스 생성
interface ErrorHandler {
suspend fun <ResultType, RequestType> getSafe(
remoteFetch: suspend () -> Response<RequestType>,
mapping: (RequestType) -> ResultType
): ResultWrapper<ResultType>
fun getError(throwable: Throwable): ErrorData
}
abstract class ErrorHandlerImpl : ErrorHandler {
override fun getError(throwable: Throwable): ErrorData {
return when (throwable) {
is UnknownHostException,
is SocketException,
is NoInternetException -> ErrorData.Network
// 생략
else -> ErrorData.Unknown(message = "${throwable.message}")
}
}
}
class SafeApi : ErrorHandlerImpl() {
override suspend fun <ResultType, RequestType> getSafe(
remoteFetch: suspend () -> Response<RequestType>,
mapping: (RequestType) -> ResultType
): ResultWrapper<ResultType> = handleResponse({ remoteFetch() }, mapping)
private suspend fun <RequestType, ResultType> handleResponse(
call: suspend () -> Response<RequestType>,
converter: (RequestType) -> ResultType
): ResultWrapper<ResultType> {
return try {
val response = call()
if (response.isSuccessful) {
response.body()?.let {
return ResultWrapper.Success(
data = converter(it),
code = response.code()
)
}
}
return ResultWrapper.Failed(
error = ErrorData.Api(response.errorBody()?.string())
)
} catch (t: Throwable) {
ResultWrapper.Failed(getError(t))
}
}
}
- 에러가 발생한다면 catch에서 어떤 에러인지 값 받아와서 State 변경
- 성공이라면 ResultWrapper.Success 상태로 반환
API 요청은 아래와 같이 이뤄집니다.
override suspend fun fetchBrandCategoryList(): ResultWrapper<List<BroadCategory>> =
safeApi.getSafe(
remoteFetch = { apiService.fetchBroadCategoryList() },
mapping = { response ->
response.categoryList.map {
ConvertMapper<BroadCategoryData, BroadCategory>()(
it
)
}
}
)
- 어떤 요청을 할지 remoteFetch에 넣어주기만 하면 내부에서 ResultWrapper의 형태로 값 반환
- API 요청 코드 중복 최소화
- 아프리카 섬네일은 gif 즉 움짤로 오기도 한다.
- 그렇기에 Thumbnail 이미지 로드 부분을 circle 형태로 구현을 했다.
imageUrl?.let {
view.load(it, imageLoader) {
transformations(CircleCropTransformation())
}
}
- 이미지 뷰를 load 할 때 coil 라이브러리의 transformations의 CircleCropTransformation을 사용했습니다.
- 근데 막상 구현한 모습이 gif 처럼 동작을 하지 않는 모습이였습니다.
- 그래서 디버깅을 하면서 gif로 들어온게 맞는지 확인을 먼저 했습니다.
- 공식문서에서 권장하는 방법을 사용했습니다.
- GifDecoder와 ImageDecoderDecoder를 사용했습니다.
val imageLoader = ImageLoader.Builder(view.context).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}.build()
- 그 결과 위처럼 Gif로 인식한다는 것을 확인했고 구현 결과를 봤지만 gif로 동작을 안했습니다.
- 그래서 CircleCropTransformation() 함수가 문제라고 판단이 돼서 해당 함수 내부를 봤습니다.
class CircleCropTransformation : Transformation {
override val cacheKey: String = javaClass.name
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
val minSize = min(input.width, input.height)
val radius = minSize / 2f
val output = createBitmap(minSize, minSize, input.safeConfig)
output.applyCanvas {
drawCircle(radius, radius, radius, paint)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
drawBitmap(input, radius - input.width / 2f, radius - input.height / 2f, paint)
}
return output
}
override fun equals(other: Any?) = other is CircleCropTransformation
override fun hashCode() = javaClass.hashCode()
}
- 내부 코드를 보니 Bitmap을 만들고 Circle을 만들고 Bitmap을 만드는 것으로 보인다.
- 이 때 input이 들어가서 bitmap을 그리기 때문에 gif로 동작을 안한다고 판단
app:clipToOutline="@{true}" // circle을 사용해야하는 ImageView에 해당 속성을 추가해서 해결을 했습니다!