lee-ji-hoon / afreecatv_pre_task

아프리카 2023년 신입/경력 공개채용 Android 앱 개발 사전 과제

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

아프리카 2023년 신입/경력 공개채용 Android 앱 개발 사전 과제

image

실행 방법

API KEY를 local.properties 에서 관리

  • local.properties에 client_id=af_mobilelab_dev_e0f147f6c034776add2142b425e81777 추가 후 실행

구현 모습

방송 목록 상세 화면 새로고침
목록 상세화면 새로고침
Configuration Changed NetWork Error 방송목록 존재하지 않을 때
컨피규레이션체인지 네트워크끊겼을때
Popup Menu 가로 모드
popup메뉴 ezgif com-gif-maker

📋 구조

개발 환경

  • Language : Kotlin
  • minSdk : 23
  • targetSdk : 31

사용한 라이브러리

  • ViewPager2 + Coil + Lottie
  • JetPack Navigation
  • Retrofit2 + OkHttp3 + Moshi
  • Coroutine + Flow + TestCoroutine
  • Mockk + Truth + turbine
  • SwipeRefreshLayout

✋ 멀티 모듈

클린 아키텍처 적용

✍️ UnitTest 작성

  • 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
image image

🤔 고민했던 내용들

RecylcerView 페이지 요청 시점

  • 무조건 맨 아래에 도착했을 때 요청하는 것은 비효율적이라고 판단
  • 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 {
        // 요청 코드 생략    
    }
}

SafeApi 모듈 추가

  • 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 요청 코드 중복 최소화

🚀 트러블 슈팅

Circle + gif 구현

  • 아프리카 섬네일은 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()

image

  • 그 결과 위처럼 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에 해당 속성을 추가해서 해결을 했습니다! 

About

아프리카 2023년 신입/경력 공개채용 Android 앱 개발 사전 과제


Languages

Language:Kotlin 100.0%