이전글
4탄에서 해결하겠다고한 문제점은 해결 못했다. 방법을 모르겠다. 공식 문서에 있는 메타데이터를 이용하여 챔피언 ID값을 매칭시켜서 가져온 것 뿐인데 없는 정보들이 있다. 그건 아마 라이엇에 실수라고 생각할려고 한다..
왜 모든 전적검색 앱들이 웹뷰를 사용하는 지 알 것 같다. 왜냐하면 매치정보를 보여주는 화면에서 보여줘야될게 너무 많았다. 챔피언, 특성, 아이템등등 렌더링 할 게 너무 많으니 앱에서 구동하기엔 너무 벅찼다. 그래서 나는 모든 기능과 화면을 앱으로 만들고 있어서 최대한 렌더링을 줄일 수 있는 방법을 찾았지만 꼭 필요한 기능이라 줄일 수가 없었다. 그래서 초기에 렌더링에 방해되지 않게 최소한에 데이터를 가져오는 게 나의 최선인 것 같았다. 그래서 페이징을 이용하여 최소한에 데이터를 초기에 보여주기 위해 적용했다.
매치id를 list로 가져오는 api가 있고 그 매치id로 매치정보를 가져오는 api가 별도로 있었다. 그래서 나는 구조적으로 매치id를 가져오는 기능에 페이징을 적용하여 그 값으로 페이징에서 load를 할 때 마다 매치정보를 가져온 후 매핑까지 해줘서 그 값을 페이징 데이터로 Flow형태로 내보내기로 하였다.
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로 맵핑하여 반환해주는 로직이다.
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를 설정해주었다.
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의 생명주기동안에 캐싱할 수 있도록 설정해주었다. 그렇게 해야 화면회전을 해도 재생성되지 않고 유지할 수 있다.
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를 받아와서 직접 가져와야 된다.
흠.. 그냥 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들이 필요하다.
우선 puuid를 통해 아이디를 가져오는 api이다. 기존엔 아이디랑 태그로 가져오고 있어서 새로 필요했다.
response는 puuid, gameName, tagline이다.
기존에 검색한 값을 쓰면 안되냐?라는 궁금증이 있을 것 같은데 기존에는 소문자 대문자를 구별하지 않았기에 정확한 아이디와 태그값을 가져오기를 원해서 귀찮더라도 api를 통해 가져오고 싶었다.
++ 기존에는 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로 변경할 때 해도 상관 없을 것 같다. 근데 유지보수하기엔 이게 깔끔해보여서 적용해봤다.
++이 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탄은 마지막으로 마무리 및 배포까지 포스팅 할 것 같다.