와챠의 우당탕탕 코딩 일기장

[Android/Kotlin]Udemy 강의 정리: #9~10: 네트워크 통신: 카테고리 목록~세부 카테고리(코루틴, 데이터 통신, Event, 데이터 전송, @Path) 본문

코딩 일기장/Android(Kotlin)

[Android/Kotlin]Udemy 강의 정리: #9~10: 네트워크 통신: 카테고리 목록~세부 카테고리(코루틴, 데이터 통신, Event, 데이터 전송, @Path)

minWachya 2022. 6. 16. 16:37
반응형

목차

  1. Coroutine이란?
  2. 코루틴 사용해서 데이터 통신 구현하기 + Event + 데이터 전달하기/받기
  3. REST API란?
  4. ShapeableImageView로 동그란 ImageView만들기
  5. navigation layout: 미리보기
  6. 상수 보관법
  7. AppbarLayout의 background color Style 정의
  8. RecyclerView 속성
  9. 2개의 Adapter를 하나의 RecyclerView에 할당하는 법: ConcatAdapter 사용!
  10. @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값 조정...요정도

반응형
Comments