[Android]TFT API 이용해서 전적 검색 앱 만들기 - 4

우발자·2025년 10월 20일
1

이전글
4탄에서 해결하겠다고한 문제점은 해결 못했다. 방법을 모르겠다. 공식 문서에 있는 메타데이터를 이용하여 챔피언 ID값을 매칭시켜서 가져온 것 뿐인데 없는 정보들이 있다. 그건 아마 라이엇에 실수라고 생각할려고 한다..

이번 글에 목차

  • JetpackPaging3를 적용
  • 서치바를 Sticky Header에서 반응형으로 변경
  • 유저의 프로필 추가

📃 Jetpack Paging3

💉 적용한 계기

왜 모든 전적검색 앱들이 웹뷰를 사용하는 지 알 것 같다. 왜냐하면 매치정보를 보여주는 화면에서 보여줘야될게 너무 많았다. 챔피언, 특성, 아이템등등 렌더링 할 게 너무 많으니 앱에서 구동하기엔 너무 벅찼다. 그래서 나는 모든 기능과 화면을 앱으로 만들고 있어서 최대한 렌더링을 줄일 수 있는 방법을 찾았지만 꼭 필요한 기능이라 줄일 수가 없었다. 그래서 초기에 렌더링에 방해되지 않게 최소한에 데이터를 가져오는 게 나의 최선인 것 같았다. 그래서 페이징을 이용하여 최소한에 데이터를 초기에 보여주기 위해 적용했다.

👿 적용

매치id를 list로 가져오는 api가 있고 그 매치id로 매치정보를 가져오는 api가 별도로 있었다. 그래서 나는 구조적으로 매치id를 가져오는 기능에 페이징을 적용하여 그 값으로 페이징에서 load를 할 때 마다 매치정보를 가져온 후 매핑까지 해줘서 그 값을 페이징 데이터로 Flow형태로 내보내기로 하였다.

MatchItemPagingSource.kt

   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MatchEntity> {
        return try {
            val currentPage = params.key ?: 1
            val matchIds = tftRepository.getMatchIdsByPuuid(
                start = (currentPage - 1) * PAGE_SIZE, count = PAGE_SIZE, puuid = puuid
            )
            val nextPage = if (matchIds.size != 5) null else currentPage + 1
            val prevPage = if (currentPage == 1) null else currentPage - 1
            val data = matchIds.mapNotNull { matchId ->
                tftRepository.getMatchEntity(puuid = puuid, matchId = matchId)
            }
            LoadResult.Page(
                data = data, nextKey = nextPage, prevKey = prevPage
            )
        } catch (t: Throwable) {
            LoadResult.Error(throwable = t)
        }
    }

load만 가져와봤다.PAGE_SIZE는 5로 설정하였다. puuid로 매치Id를 리스트로 가져온 후
매치Id로 매치정보를 가져오는 로직이다. getMatchEntity에서는 매치정보를 가져온 후 Entity로 맵핑하여 반환해주는 로직이다.

PagingRepositoryImpl.kt

override suspend fun getMatchPagingData(puuid: String): Flow<PagingData<MatchEntity>> {
        return Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE,
                initialLoadSize = PAGE_SIZE,
                prefetchDistance = 1,
                enablePlaceholders = false
            ), pagingSourceFactory = {
                MatchItemPagingSource(
                    tftRepository = tftRepository, puuid = puuid
                )
            }
        ).flow
    }

Pager를 만들어 반환하는 로직이다. 우선 pageSize는 아까 정의한 5를 넣고,
초기 로드하는 크기도 5로 하였다. 어차피 화면에 보이는 아이템은 기기마다 다르겠지만 5를 안넘길 것 같아서 그렇게 설정하였다. prefetchDistance 이 속성은 몇개 남았을 때 다음 페이지를 불러올 것인가를 정의하는 건데 나는 최소한에 페이지를 불러오는 게 목표이기에 최소값인 1를 설정해주었다.

MainViewModel.kt

	private val _puuid = MutableStateFlow<String?>(null)

    @OptIn(ExperimentalCoroutinesApi::class)
    val matchListFlow: Flow<PagingData<MatchEntity>> = _puuid.filterNotNull().flatMapLatest {
        pagingRepository.getMatchPagingData(it)
    }.cachedIn(viewModelScope)

이제 viewModel에서 소비 할 차례이다. 우선 _puuid가 바뀔 때 마다 불러오도록하였고 마지막에 cachedIn이라는 메서드를 통해 viewModelScope의 생명주기동안에 캐싱할 수 있도록 설정해주었다. 그렇게 해야 화면회전을 해도 재생성되지 않고 유지할 수 있다.

MainScreen.kt

 	val matchPagingData = viewModel.matchListFlow.collectAsLazyPagingItems()
    val isLoadingAppend = matchPagingData.loadState.append is LoadState.Loading
    val isLoadingRefresh by remember {
        derivedStateOf {
            matchPagingData.loadState.refresh is LoadState.Loading && state.hasSearch
        }
    }
    
// MatchItemComponent.kt
fun LazyListScope.matchItemsComponent(
    matchItems: LazyPagingItems<MatchEntity>,
    onClickID: (Participant) -> Unit
) {
    items(
        count = matchItems.itemCount,
        key = matchItems.itemKey { it.gameId },
        contentType = { matchItems[it] }) { index ->
        val matchItem = remember { matchItems[index] }
        matchItem?.let {
            MatchItem(matchItem = matchItem, onClickID = onClickID)
        }
    }
}

페이징 데이터를 LazyPagingItems으로 변환시켜주는 collectAsLazyPagingItems을 사용하여
LazyColumn안에서 적용하면 된다. pagingData로 초기 로딩과 아이템을 새로 가져올 때를 알 수 있기에 적절하게 로딩을 띄워 줄 수가 있다. items를 이용할 때 아직은 lazyPagingItems를 지원해주는 게 없기 때문에 count를 이용하여 index를 받아와서 직접 가져와야 된다.


🔍 SearchBar Sticky Header -> 반응형으로 변경

🤦🏻‍♂️ 변경한 이유

흠.. 그냥 sticky header를 썼는데 뒤에 남은 필요없는 공간들이 답답하게 느껴졌다. 그래서 공부도 할겸 반응형으로 변경하기로 하였다

💉 적용

우선 기존에 Sticky Header를 그냥 item으로 바꿔서 column에 최상단에 위치하도록 하였다.

                item {
                    Box(
                        modifier = Modifier
                            .background(color = AppColors.PrimaryColor)
                            .padding(bottom = 10.dp)
                    ) {
                        MainTopbar(
                            onClickSearch = {
                                viewModel.setEvent(
                                    MainContract.Event.OnClickSearch(
                                        it
                                    )
                                )
                            },
                            textFieldState = textFieldState,
                            currentText = currentText
                        )
                    }
                }

그 뒤에 이제 맨 상단에 아이템이 기존에 서치바이기 때문에 나는 이 서치바가 안보이면 반응형 서치바를 보이도록 하고 서치바가 화면에 보이는 순간 반응형 서치바를 숨기도록 할 것이다.
즉, 2개의 서치바가 있다고 생각하면 된다. 그래서 같은 상태를 보여주기위해 기존에 MainTopbar에 있던 로컬 변수들을 모두 상태 호이스팅하여 최상단에서 관리해줬다. 그래서 같은 상태를 유지할 수 있도록 하였다.

	val lazyListState = rememberLazyListState()
    val showReactiveBar by remember {
        derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
    }

lazyListState를 생성한 뒤 최상단에 있는 서치바가 안보이면 showReactiveBar를 true로 하여 반응형 바를 노출시켜줄 것이다.

         Box(contentAlignment = Alignment.TopCenter) {
            LazyColumn(
                modifier = Modifier.padding(paddingValues),
                state = lazyListState,
                contentPadding = PaddingValues(vertical = 10.dp)
            ) {
            // ...
            } // LazyColumn
            if (showReactiveBar) {
                MainTopbar(
                    modifier = Modifier.padding(top = 10.dp),
                    onClickSearch = {
                        viewModel.setEvent(
                            MainContract.Event.OnClickSearch(
                                it
                            )
                        )
                    },
                    textFieldState = textFieldState,
                    currentText = currentText
                )
            }
        } // Box
	}

로직을 LazyColumn밖에 Box 스코프를 넣고 코드 맨 밑에 적용하면 된다.
그럼 최상단 Layer에서 보여주게 된다. 최상단에 item에선 반응형 바가 노출될 때 안보여주도록 구현하지 않은 이유는 LazyColumn에 item이 갑자기 안보여지면 위치가 위로 땡겨지기 때문에 부자연스러운 움직임이 된다. 그래서 기존 서치바는 항상 노출시켜준 상태에서 반응형 서치바만 노출/미노출 시켜주는 게 특징이다.

동작 영상은 마지막에 올리도록 하겠다.


🙋‍♂️ 프로필 적용

💉 적용

프로필이라면 소환사의 아이콘, 레벨, 전적, 아이디, 승률, 티어정도로 생각했다. 그래서 이정보들을 가져올려면 3개의 api들이 필요하다.

1. /riot/account/v1/accounts/by-puuid/{puuid}

우선 puuid를 통해 아이디를 가져오는 api이다. 기존엔 아이디랑 태그로 가져오고 있어서 새로 필요했다.
response는 puuid, gameName, tagline이다.
기존에 검색한 값을 쓰면 안되냐?라는 궁금증이 있을 것 같은데 기존에는 소문자 대문자를 구별하지 않았기에 정확한 아이디와 태그값을 가져오기를 원해서 귀찮더라도 api를 통해 가져오고 싶었다.

2. tft/league/v1/by-puuid/{puuid}

++ 기존에는 BASE_URL이 모두 https://asia.api.riotgames.com/ 였다면
이 api는 https://kr.api.riotgames.com/ 로 써야된다.
그래서 할 수 없이 ApiService를 새로 만들어서 di를 써서 retrofit 객체를 새로 생성해주었다.

이 api의 response값은

@Serializable
data class LeagueByPuuidResponse(
    @SerialName("tier")
    val tier: Tier,
    @SerialName("rank")
    val rank: Rank,
    @SerialName("leaguePoints")
    val leaguePoints: Int,
    @SerialName("wins")
    val wins: Int,
    @SerialName("losses")
    val losses: Int,
)

전적, lp, 티어등에 정보를 가져올 수 있다. 여기서 Tier와 Ranks는 enum class로 따로 정의하여 맵핑시켜주고 있다.

@Serializable
enum class Tier(val displayName: String) {
    @SerialName("IRON")
    IRON("아이언"),

    @SerialName("BRONZE")
    BRONZE("브론즈"),

    @SerialName("SILVER")
    SILVER("실버"),

    @SerialName("GOLD")
    GOLD("골드"),

    @SerialName("PLATINUM")
    PLATINUM("플래티넘"),

    @SerialName("EMERALD")
    EMERALD("에메랄드"),

    @SerialName("DIAMOND")
    DIAMOND("다이아몬드"),

    @SerialName("MASTER")
    MASTER("마스터"),

    @SerialName("GRANDMASTER")
    GRANDMASTER("그랜드마스터"),

    @SerialName("CHALLENGER")
    CHALLENGER("챌린저");
}

@Serializable
enum class Rank(val number: Int) {
    @SerialName("I")
    I(1),

    @SerialName("II")
    II(2),

    @SerialName("III")
    III(3),

    @SerialName("IV")
    IV(4)
}

굳이 enum으로 맵핑 안시켜주고 맵퍼에서 entity로 변경할 때 해도 상관 없을 것 같다. 근데 유지보수하기엔 이게 깔끔해보여서 적용해봤다.

3.tft/summoner/v1/summoners/by-puuid/{puuid}

++이 api도 BASE_URL이 https://kr.api.riotgames.com/ 로 써야된다.

@Serializable
data class SummonerByPuuidResponse(
    @SerialName("profileIconId")
    val profileIconId: Int,
    @SerialName("summonerLevel")
    val summonerLevel: Long,
)

이 api는 puuid로 프로필 id와 레벨을 가져올 수 있다.

저 profileIconId를 이용하여 cdn으로 잘 조합해서 아이콘 이미지를 가져올 수있는데

// ImageUtils.kt
    fun createImageUrl(id: String, type: String, version: String): String {
        val regex = Regex("""\d+\.\d+""")
        val match = regex.find(version)?.value
        val currentVersion = "$match.1"
        val imageName = when (type) {
            ImageType.ITEM.type, ImageType.PROFILE.type -> {
                "$id.png"
            }

            else -> {
                if (id.contains("png")) {
                    id
                } else {
                    val season = Regex("""TFT(\d+)""").find(id.uppercase())?.groupValues?.get(1)
                    "$id.TFT_Set$season.png"
                }
            }
        }
        return "https://ddragon.leagueoflegends.com/cdn/$currentVersion/img/$type/$imageName"
    }

기존에 챔피언과 아이템에 조합처럼 id를 마지막에 넣고 png를 붙여주면 된다. 근데 version에 대한 값은 어디서 가져오는 지 몰라서 무난하게 15.1.1을 넣어주고 있다.


🎬 동작 영상

아직도 버벅임이 조금 있지만 렌더링할 게 너무 많아서 어쩔 수 없는 것 같다. ㅠㅠ
좀 더 개선할 방법을 찾아봐야될 것 같다.

아마 5탄은 마지막으로 마무리 및 배포까지 포스팅 할 것 같다.

profile
어제보다 나은 개발자가 되자

0개의 댓글