이번 코드리뷰에서 다룰 코드는 제가 구현한 코드입니다. 제가 구현한 부분은 처음에 나타나는 화면으로 구현을 한 내용은 다음과 같습니다.
HomeMainFragment
)과 상품 목록을 보여주는 화면(HomeFragment
)의 이동아래는 해당 구현 내용들에 대한 상세한 설명입니다. ViewHolder는 정보를 불러와서 binding을 해주는 단순한 동작만 하므로 설명을 생략하였습니다.
// HomeMainViewModel.kt
private val _marketData = MutableLiveData<HomeMainState>(HomeMainState.Uninitialized)
val marketData: LiveData<HomeMainState> = _marketData
private val _itemData = MutableLiveData<HomeMainState>(HomeMainState.Uninitialized)
val itemData: LiveData<HomeMainState> = _itemData
private lateinit var allNewSaleItemsList: List<HomeItemModel>
marketData
와 itemData
는 각각 근처 마켓 정보와 새로운 할인 상품을 나타낼 LiveData
입니다. Backing property를 이용하여 LiveData
를 필요한 곳에서 observe 가능하게 하면서 value의 변경은 ViewModel에서만 가능하도록 하였습니다. 처음에는 마켓과 상품의 정보를 불러오기 전이므로 초기 상태를 Uninitialized
로 설정하였습니다.
allNewSaleItemsList
는 모든 새로운 할인 상품을 저장하고 있을 List
로 카테고리별로 상품을 보여줄 때 사용을 하게 됩니다.
override fun fetchData(): Job = viewModelScope.launch {
if (LocationData.locationStateLiveData.value is LocationState.Success) {
fetchMarketData()
fetchItemData()
}
}
Fragment
의 View
가 생성될 때 근처 마켓 정보와 새로운 할인 상품 정보를 불러 올 때 사용할 Method입니다. 사용자의 위치 정보를 기반으로 정보를 불러오기 때문에 위치 정보를 성공적으로 불러왔을 때만 동작하도록 하였습니다. fetchMarketData()
와 fetchItemData()
의 상세 내용은 아래에서 설명을 하였습니다.
fun reloadData(): Job {
_marketData.value = HomeMainState.Loading
_itemData.value = HomeMainState.Loading
return fetchData()
}
추후 정보를 새로 불러올 때 사용할 method입니다. 근처 마켓 정보와 새로운 할인 상품 정보의 상태를 Loading
으로 전환 후 fetchData()
를 호출하여 Job
을 반환하도록 하였습니다.
// HomeMainViewModel.kt
private suspend fun fetchMarketData() {
if (marketData.value !is HomeMainState.Success<*>) {
_marketData.value = HomeMainState.Loading
// 거리가 가까운 순으로 정렬
// 임시로 ViewModel에서 type을 HOME_CELL로 변경
_marketData.value = HomeMainState.Success(
modelList = homeRepository.getAllMarketList().map {
it.copy(type = CellType.HOME_CELL)
}.sortedBy { it.distance }
)
}
}
homeRepsitory
에서 근처 마켓의 정보를 가져와서 거리가 가까운 순으로 정렬하는 method 입니다. Success
가 아닐 때만 정보를 가져오도록 하여 화면 이동과 같은 상황에서 불필요하게 계속 정보를 불러오는 것을 방지했습니다.
// HomeMainFragment.kt
marketData.observe(viewLifecycleOwner) {
when (it) {
...
is HomeMainState.Success<*> -> {
nearbyMarketAdapter.submitList(it.modelList)
}
...
}
}
HomeMainFragment
에서 marketData
를 observe하고 있다가 Success
상태가 되면 성공적으로 불러온 데이터를 nearbyMarketAdapter
에 리스트를 submitList(it.modelList)
을 이용하여 업데이트 하여 RecyclerView
에 정보를 출력하게 됩니다.
// HomeMainViewModel.kt
private suspend fun fetchItemData() {
if (itemData.value !is HomeMainState.Success<*>) {
_itemData.value = HomeMainState.Loading
allNewSaleItemsList = homeRepository.getAllNewSaleItems()
_itemData.value = HomeMainState.ListLoaded
}
}
homeRepository
에서 새로운 할인 상품 정보를 가져와서 allNewSaleItemsList
로 저장하는 method입니다. 성공적으로 정보를 가져오면 새롭게 추가한 ListLoaded
상태가 됩니다.
// HomeMainFragment.kt
itemData.observe(viewLifecycleOwner) {
when (it) {
...
is HomeMainState.ListLoaded -> with(binding.newSaleItemSpinner) {
viewModel.setItemFilter(categories[selectedItemPosition])
}
is HomeMainState.Success<*> -> {
newSaleItemsAdapter.submitList(it.modelList)
}
...
}
}
ListLoaded
상태가 되면 HomeMainViewModel
의 setItemFilter(category)
를 이용하여 알맞은 카테고리의 상품을 출력하게 됩니다.
// HomeMainViewModel.kt
fun setItemFilter(category: HomeListCategory) {
if (::allNewSaleItemsList.isInitialized) {
_itemData.value = HomeMainState.Success(
modelList = allNewSaleItemsList.filter { it.homeListCategory == category }
)
}
}
allNewSaleItemList
가 초기화 되었을 경우에만 실행되도록 하였고 List
의 filter
method를 이용하여 parameter로 넘겨받은 카테고리와 일치하는 상품만 나타내도록 하고 상태를 Success
로 전환하여 newSaleItemsAdapter
에 submitList(it.modelList)
로 List
를 넘겨주어 RecyclerView
에 출력하게 됩니다.
// HomeListCategory.kt
enum class HomeListCategory(
@StringRes val categoryNameId: Int,
@StringRes val categoryTypeId: Int
) {
TOWN_MARKET(R.string.all, R.string.all_type), // Not needed
FOOD(R.string.food, R.string.food_type),
MART(R.string.mart, R.string.mart_type),
SERVICE(R.string.service, R.string.service_type),
FASHION(R.string.fashion, R.string.fashion_type),
ACCESSORY(R.string.accessory, R.string.accessory_type),
ETC(R.string.etc, R.string.etc_type)
}
// HomeMainFragment.kt
private val categories = HomeListCategory.values().drop(1)
Spinner를 이용하여 상품을 카테고리별로 보여주어야 하므로 첫 번째인 마켓 카테고리는 필요가 없어서 drop(1)
을 이용하여 제외하였습니다.
// HomeMainFragment.kt
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.setItemFilter(categories[position])
}
setItemFilter(categories[position])
를 이용하여 선택된 Spinner item 카테고리의 상품만 출력하게 됩니다.
위와 같이 Spinner의 item이 설정된 것을 확인할 수 있고 제대로 동작하는 것을 확인 하였습니다.
Navigation component에 대해서 자세히 설명하기에는 많은 내용이 필요하여 나중에 따로 글을 작성할 예정입니다.
Navigation component를 이용하여 BottonNavigationView
에서 사용할 Navigation graph입니다.
home.xml
, order_list.xml
, like.xml
, map.xml
, my_info.xml
을 include하고 있고 각각은 BottomNavigationView
의 menu item들에 대응되는 Navigation graph입니다.
Navigation component를 BottomNavigationView
에서 사용하기 위해서 Navigation graph와 BottomNavigationView
의 menu item들의 android:id
를 일치시켜 주어야합니다.
현재 home.xml
을 제외한 graph에는 하나의 Fragment
만 존재하므로 home.xml
만 설명을 하도록 하겠습니다. home.xml
에는 homeMainFragment
와 homeFragment
가 존재하며 homeMainFragment
에서 homeFragment
로의 action
이 존재하는 것을 볼 수 있습니다.
<!-- home.xml -->
<fragment
android:id="@+id/homeFragment"
android:name="com.example.YUmarket.screen.home.HomeFragment"
android:label="HomeFragment" >
<argument
android:name="goToTab"
app:argType="com.example.YUmarket.model.homelist.category.HomeListCategory"
android:defaultValue="TOWN_MARKET" />
</fragment>
homeFragment
에 argument
를 설정하여 homeMainFragment
에서 homeFragment
로 이동시에 어떤 탭을 표시할지 HomeListCategory
의 값을 넘겨주도록 하여 argument
를 이용하여 알맞은 탭을 출력하도록 하였습니다.
<!-- activitiy_main.xml -->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragmentContainer"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottomNav"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:navGraph="@navigation/navigation_graph" />
FragmentContainerView
에 android:name
을 NavHostFragment
로 명시를 하여 containerFragment
를 설정해주고 app:navGraph
에 사용할 Navigation graph를 설정합니다. app:defaultNavHost
를 true
로 하여 back button의 동작을 intercept하도록 하였습니다.
// MainActivity.kt
private val navController by lazy {
val hostContainer =
supportFragmentManager.findFragmentById(R.id.fragmentContainer) as NavHost
hostContainer.navController
}
NavHostFragment
에서 NavController
를 lazy하게 가져오는 코드입니다.
Activity
의 extension 함수인 Activity.findNavController(viewId: Int)
를 사용하지 않는 이유는 FragmentContainerView
를 이용하여 NavHostFragment
를 생성하였기 때문입니다.
When creating the
NavHostFragment
usingFragmentContainerView
or if manually adding theNavHostFragment
to your activity via aFragmentTransaction
, attempting to retrieve theNavController
inonCreate()
of an Activity viaNavigation.findNavController(Activity, @IdRes int)
will fail. You should retrieve theNavController
directly from theNavHostFragment
instead. [1]
이와 같은 경우 해당 함수가 제대로 동작하지 않아서 NavController
를 제대로 가져오지 못하게 되어 에러가 발생합니다.
Android Developers 공식 페이지에서는 위와 같이 NavHostFragment
에서 NavController
를 직접 가져오라고 적혀있습니다.
// MainActivity.kt
bottomNav.setupWithNavController(navController)
NavController
를 이용하여 BottonNavigationView
의 동작을 설정하여 알맞은 Fragment
가 출력되도록 하였습니다.
// HomeMainFragment.kt
override fun initViews() {
...
showMoreTextView.setOnClickListener {
findNavController().navigate(
HomeMainFragmentDirections
.actionHomeMainFragmentToHomeFragment()
)
}
...
setCategoryButtonListener()
}
}
private fun setCategoryButtonListener() = with(binding) {
foodCategoryListButton.setOnClickListener {
findNavController().navigate(
HomeMainFragmentDirections
.actionHomeMainFragmentToHomeFragment(HomeListCategory.FOOD)
)
}
martCategoryListButton.setOnClickListener {
findNavController().navigate(
HomeMainFragmentDirections
.actionHomeMainFragmentToHomeFragment(HomeListCategory.MART)
)
}
...
}
버튼별로 HomeFragment
에서 알맞은 탭을 보여줄 수 있도록 알맞은 argument
를 넘겨주고 HomeMainFragment
에서 HomeFragment
로 이동하도록 OnClickListener
를 설정해주었습니다. showMoreTextView
의 action
에 argument
의 기본값이 TOWN_MARKET
으로 설정되어 있기 때문에 아무것도 넘겨주지 않도록 했습니다.
private val args by navArgs<HomeFragmentArgs>()
...
override fun observeData() = with(binding) {
LocationData.locationStateLiveData.observe(viewLifecycleOwner) {
when (it) {
is LocationState.Success -> {
initViewPager()
viewPager.currentItem = args.goToTab.ordinal
}
}
}
}
by navArgs<HomeFragmentArgs>()
로 argument
를 lazy하게 가져오도록 하였습니다.
Fragment
이동시에 argument
로 받은 HomeListCategory
의 값을 이용하여 viewPager.currentItem = args.goToTab.ordinal
로 알맞은 카테고리의 화면을 출력합니다.
Navigation graph를 이용하여 Fragment
간 관계를 파악하기 쉬워진다.
원래 Fragment
간 관계를 파악하기 위해서는 직접 구현한 코드들을 살펴봐야합니다.
다른 동작을 구현한 코드들 사이에서 Fragment
이동 코드들을 찾고 만약 관련된 코드들이 분산되어 있을 경우 코드를 읽는 것은 쉬운일이 아닙니다.
Navigation component를 이용하면 Navigation graph를 살펴봄으로써 한눈에 어떻게 Fragment
간 이동이 이루어지는지 그리고 이동시에 어떤 argument
를 넘겨주어야 하는지 알 수 있습니다.
Boilerplate 코드를 줄일 수 있다.
원래는 매번 FragmentManager
를 통해 FragmentTransaction
을 이용하여 직접 어떻게 화면을 전환할지 boilerplate 코드를 작성하여야 했습니다. 하지만 Navigation component를 사용함으로써 NavController
의 navigate(resId)
를 이용하여 boilerplate 코드를 줄일 수 있습니다.
SafeArgs
를 이용하여 type safe하게 Fragment
에 argument
를 넘겨줄 수 있다.
다음 글에서는 코드 리뷰를 진행하면서 받았던 질문들과 해당 질문들의 답변에 대해서 작성할 예정입니다.
[1] "Get started with the Navigation component," Android Developers, last modified Feb 09, 2022, accessed Feb 13, 2022, https://developer.android.com/guide/navigation/navigation-getting-started#navigate.