와챠의 우당탕탕 코딩 일기장
[Android/Kotlin]Udemy 강의 정리: #9~10: 네트워크 통신: 카테고리 목록~세부 카테고리(코루틴, 데이터 통신, Event, 데이터 전송, @Path) 본문
[Android/Kotlin]Udemy 강의 정리: #9~10: 네트워크 통신: 카테고리 목록~세부 카테고리(코루틴, 데이터 통신, Event, 데이터 전송, @Path)
minWachya 2022. 6. 16. 16:37목차
- Coroutine이란?
- 코루틴 사용해서 데이터 통신 구현하기 + Event + 데이터 전달하기/받기
- REST API란?
- ShapeableImageView로 동그란 ImageView만들기
- navigation layout: 미리보기
- 상수 보관법
- AppbarLayout의 background color Style 정의
- RecyclerView 속성
- 2개의 Adapter를 하나의 RecyclerView에 할당하는 법: ConcatAdapter 사용!
- @Path 사용법
1. Coroutine이란?
(공식문서)
코루틴은 비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴입니다.
동기적으로 실행되는 코드: 순차적으로 실행되는 코드(ex: 앞에서 얻은 값 뒤에서 활용할 때)
but, Android app은 main 스레드가 실행을 멈출 수도 있음.
=> 비동기적으로 실행되어야 함
비동기적으로 실행되는 코드: 연산이 오래 걸려서 응답이 오래 걸리는 경우 사용(ex: 네트워크 통산)
2. 코루틴 사용해서 데이터 통신 구현하기 + Event + 데이터 전달하기/받기
- 파이어베이스에 저장된 카테고리 목록 정보를 불러오기
- 카테고리 페이지 선택 시 카테고리 상세 페이지로 이동
- 이동 시 카테고리 id 전달하기
0. firebase 연동(공식문서)
1. 라이브러리 + 권한 추가
// Network
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" // http 통신 구현위해
// gson 라이브러리 사용해서 응답받은 결과값을 프로젝트에서 사용할 형태로 변환
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
// 네트워크 통신 결과를 log로 상세히 받기
implementation platform("com.squareup.okhttp3:okhttp-bom:4.9.0")
implementation "com.squareup.okhttp3:okhttp"
implementation "com.squareup.okhttp3:logging-interceptor"
// viewModel scope
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
retrofitVersion = '2.9.0'
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
2. network 패키지 생성
3. ApiClient.kt 생성: json 데이터 불러와서 Category 타입으로 변환하기
package com.example.shoppi.network
import com.example.shoppi.model.Category
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
// retrofit 라이브러리 사용법 따라 생성함
// 어떤 주소와 통신할지 선언
interface ApiClient {
@GET("categories.json") // 달라지는 주소만 선언
suspend fun getCategories(): List<Category> // 카테고리 목록 데이터 받아오기
// ApiClient 객체 생성
companion object {
private const val baseUrl = "firebase 프로젝트에 부여된 주소"
fun create(): ApiClient {
// okhttp에서 로그 확인용 client
val logger = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.build()
// baseUrl: firebase에서 봤던 값
// addConverterFactory: http 응답 결과을 프로젝트에서 사용하는 객체로 변환하는 방법
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiClient::class.java)
}
}
}
+ 참고: Category data class 내용
package com.example.shoppi.model
import com.google.gson.annotations.SerializedName
/*
"categories": [
{
"category_id": "fashion-female",
"label": "여성패션",
"thumbnail_image_url": "https://user-images.githubusercontent.com/61674991/173981555-6f917bc8-2d29-4a8b-9b39-9541b5d7b595.jpg",
"updated": false
},
{
"category_id": "fashion-male",
"label": "남성패션",
"thumbnail_image_url": "https://user-images.githubusercontent.com/61674991/173981548-5fd817a3-9ef0-4c66-a855-9a396162b533.jpg",
"updated": false
}
...
]
}
*/
data class Category(
@SerializedName("category_id") val categoryId: String,
val label: String,
@SerializedName("thumbnail_image_url") val thumbnailImageUrl: String,
val updated: Boolean
)
4. repository 패키지 안에 아래 3개의 파일 추가
- class CategoryRepository: ApiClent의 getCategories 호출하는 곳
- interface CategoryDataSource: Repository가 데이터 요청하는 곳
- class CategoryRemoteDataSource: CategoryDataSource를 구현한 객체
// ApiClient의 getCategories() 호출하는 클래스
class CategoryRepository(private val remoteDataSource: CategoryRemoteDataSource) {
// suspend: 코루틴 스코프에서 실행하는지 확인, 코루틴 스코프 아니면 실행 X
suspend fun getCategories(): List<Category> { // ViewModel이 호출
// 원래 이렇게 호출해야 하지만 retrofit 라이브러리가 대신 해줌: 삭제
// withContext(Dispatchers.IO) {
// remoteDataSource.getCategories()
// }
return remoteDataSource.getCategories()
}
}
//-----------------------------------------
// Repository는 DataSource에 데이터 요청
interface CategoryDataSource {
suspend fun getCategories(): List<Category>
}
//-----------------------------------------
// CategoryDataSource를 구현한 객체
class CategoryRemoteDataSource(private val apiClient: ApiClient): CategoryDataSource {
override suspend fun getCategories(): List<Category> {
return apiClient.getCategories()
}
}
5. Event.kt + EventObserver,kt 생성:
Event: 한 번 쓴 데이터는 다시 쓸 수 없도록 제어하는 클래스
EventObserver: LiveData의 데이터가 변경되었음을 알림받을 때마다 데이터 쓰인 적 있는지 확인
package com.example.shoppi.ui.common
// 데이터 한 번 사용되면 더이상 못 쓰게
class Event<T>(private val content: T) {
// 데이터 소비 여부
private var hasBeenHandled = false
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
}
// LiveData의 데이처가 변경되었는지 확인
// null 아닐 때만 데이터 전달
class EventObserver<T>(private val onEventUnhandledContent: (T) -> Unit): Observer<Event<T>> {
override fun onChanged(event: Event<T>?) {
event?.getContentIfNotHandled()?.let {
onEventUnhandledContent(it)
}
}
}
Event가 필요한 이유:
- category > category detail 이동은 했는데, 뒤로가기 눌렀을 때도 계속 category detail 화면이 보임
- LiveData의 특징때문:
- category > category detail 이동 했다가 뒤로가기 눌러서 category fragment로 돌아오지만,
- 여전히 category fragment는 liveData의 이벤트를 수신하고 있음.
- category detail fragment로 이동하기 직전에 데이터가 변경되어 받았던 category 데이터를 계속 수신하는 것.
- category fragment로 와도 아래 코드 호출돼서 category detail fragment로 이동함
- openCategoryDetail(it.categoryId, it.label)
6. ui 패키지에 CategoryViewModel 생성
카테고리 선택 여부 담는 데이터(openCategoryEvent)와 함수(openCategotyDetail)도 추가
package com.example.shoppi.ui.category
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.shoppi.model.Category
import com.example.shoppi.repository.category.CategoryRepository
import kotlinx.coroutines.launch
class CategoryViewModel(private val categoryRepository: CategoryRepository): ViewModel() {
private val _items = MutableLiveData<List<Category>>()
val items: LiveData<List<Category>> = _items
// 카테고리 선택 여부 담는 데이터
private val _openCategoryEvent = MutableLiveData<Category>()
val openCategoryEvent: LiveData<Category> = _openCategoryEvent
init { // ViewModel이 생성되는 시점에 loadCategory 호출
loadCategory()
}
fun openCategoryDetail(category: Category) {
_openCategoryEvent.value = category
}
// 네트워크로 통신은 ui 스레드가 아닌
private fun loadCategory() {
viewModelScope.launch {
val categories = categoryRepository.getCategories()
_items.value = categories
}
}
}
7. 클릭 시 이벤트 발생 원하는 곳에 클릭 이벤트 달기
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.example.shoppi.ui.category.CategoryViewModel" />
<variable
name="category"
type="com.example.shoppi.model.Category" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:layout_marginBottom="16dp"
android:background="@drawable/background_bluegray_r20"
android:onClick="@{() -> viewModel.openCategoryDetail(category)}">
// ,,생략
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
8. Adapter 생성 후 data binding
class CategoryAdapter(private val viewModel: CategoryViewModel): ListAdapter<Category, CategoryAdapter.CategoryViewHolder>(
CategoryDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder {
val binding = ItemCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return CategoryViewHolder(binding)
}
override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class CategoryViewHolder(private val binding: ItemCategoryBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(category: Category) {
binding.viewModel = viewModel
binding.category = category
binding.executePendingBindings()
}
}
}
9. CategoryViewModel 생성하는 방법 정의하기
common 패키지 안에 ViewModelFactory안에 추가하여 생성
class ViewModelFactory(private val context: Context): ViewModelProvider.Factory {
// ViewModel 생성하고 반환하기
override fun <T : ViewModel> create(modelClass: Class<T>): T {
// HomeViewModel 타입인지 검사
return when {
modelClass.isAssignableFrom(HomeViewModel::class.java) -> {
// 의존성 관리: 'Hilt' 라이브러리 사용할 수도 있음
val repository = HomeRepository(HomeAssetDataSource(AssetLoader(context)))
HomeViewModel(repository) as T
}
modelClass.isAssignableFrom(CategoryViewModel::class.java) -> {
val repository = CategoryRepository(CategoryRemoteDataSource(ApiClient.create()))
CategoryViewModel(repository) as T
}
else -> {
throw IllegalArgumentException("Failed to create ViewModel: ${modelClass.name}")
}
}
}
}
10. Fragment에서 ViewModel 생성함으로써 데이터 로드하기 + EventObserver 달기 + 이동할 화면에 데이터 전달
package com.example.shoppi.ui.category
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.example.shoppi.R
import com.example.shoppi.common.KEY_CATEGORY_ID
import com.example.shoppi.common.KEY_CATEGORY_LABEL
import com.example.shoppi.databinding.FragmentCategoryBinding
import com.example.shoppi.model.Category
import com.example.shoppi.ui.common.EventObserver
import com.example.shoppi.ui.common.ViewModelFactory
class CategoryFragment: Fragment() {
// ViewModel 생성함으로써 데이터 로드하기
private val viewModel: CategoryViewModel by viewModels { ViewModelFactory(requireContext()) }
private lateinit var binding: FragmentCategoryBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentCategoryBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val categoryAdapter = CategoryAdapter(viewModel)
binding.rvCategoryList.adapter = categoryAdapter
viewModel.items.observe(viewLifecycleOwner) {
categoryAdapter.submitList(it)
}
// EventObserver 달기
viewModel.openCategoryEvent.observe(viewLifecycleOwner, EventObserver {
openCategoryDetail(it.categoryId, it.label)
})
}
// 이동할 화면에 데이터 전달
private fun openCategoryDetail(categoryId: String, categoryLabel: String) {
findNavController().navigate(R.id.action_category_to_categoryDetail, bundleOf(
KEY_CATEGORY_ID to categoryId,
KEY_CATEGORY_LABEL to categoryLabel
))
}
}
11. 전달한 데이터 받기
CategoryDetailFragment.kt
val categoryId = requireArguments().getString(KEY_CATEGORY_ID)
val categoryLabel = requireArguments().getString(KEY_CATEGORY_LABEL)
끝!!!
3. REST API란?
(firebase 문서) (기타 설명 문서)
REST API 설계 시 가장 중요한 항목은 다음의 2가지로 요약할 수 있습니다.
첫 번째, URI는 정보의 자원을 표현해야 한다.
두 번째, 자원에 대한 행위는 HTTP Method(GET, POST, PUT, DELETE)로 표현한다.
METHOD역할
POST | POST를 통해 해당 URI를 요청하면 리소스를 생성합니다. |
GET | GET를 통해 해당 리소스를 조회합니다. 리소스를 조회하고 해당 도큐먼트에 대한 자세한 정보를 가져온다. |
PUT | PUT를 통해 해당 리소스를 수정합니다. |
DELETE | DELETE를 통해 리소스를 삭제합니다. |
4. ShapeableImageView로 동그란 ImageView만들기
1. styles.xml에 아래 스타일 정의하고
<style name="Circle">
<item name="cornerSize">50%</item>
</style>
2. 속성에 추가해주면 됨
app:shapeAppearanceOverlay="@style/Circle"
5. navigation layout: 미리보기
tools:layout="@layout/fragment_home"
<fragment
android:id="@+id/navigation_home"
android:name="com.example.shoppi.ui.home.HomeFragment"
android:label="HomeFragment"
tools:layout="@layout/fragment_home">
6. 상수 보관법
프로젝트>common 패키지>Constants.kt 파일
package com.example.shoppi.common
const val KEY_CATEGORY_ID = "category_id"
const val KEY_CATEGORY_LABEL = "category_label"
7. AppbarLayout의 background color Style 정의
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Shoppi" parent="Theme.MaterialComponents.DayNight.NoActionBar">
//...
<!-- Customize your theme here. -->
<item name="appBarLayoutStyle">@style/AppbarStyle</item>
</style>
//,,,
<style name="AppbarStyle" parent="Widget.Design.AppBarLayout">
<item name="android:background">@color/shoppi_white</item>
</style>
</resources>
8. RecyclerView 속성
- orientation: 스크롤 방향 설정
- layoutManager: 아이템 어떻게 보여질지 설정
- layout_behavior: 앱바만큼 아래로 내려오기
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/iv_category_detail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
9. 2개의 Adapter를 하나의 RecyclerView에 할당하는 법: ConcatAdapter 사용!
최신버전의 리사이클러뷰 라이브러리 추가 후
이렇게 연결해주면 됨
val titleAdapter = SectionTitleAdapter()
val promotionAdapter = ProductPromotionAdapter(this@HomeFragment)
binding.rvHome.adapter = ConcatAdapter(titleAdapter, promotionAdapter)
viewModel.promotions.observe(viewLifecycleOwner) { promotions ->
titleAdapter.submitList(listOf(promotions.title))
promotionAdapter.submitList(promotions.items)
}
10. @Path 사용법
ApiCilent에서 category/1, category/2 처럼 path 변동 있을 때 path 따라 서버에 데이터 요청하는 법은,,,
아래처럼 @Path 어노테이션 사용해서 변하는 path명 받고, @GET에 {} 안에 전달하기
@GET("{categoryId}.json")
suspend fun getCategoryDetail(@Path("categoryId") categoryId: String): CategoryDetail
기타 배운 것:
Toolbar에서
content 으로 시작하는 속성: padding, margin값 조정...요정도