소개
이전 Codelab에서는 웹 서비스에서 데이터를 가져와 응답을 Kotlin 객체로 파싱하는 방법을 배웠습니다. 이 지식을 기반으로 이 Codelab에서는 웹 URL에서 사진을 로드하고 표시합니다. 또한 RecyclerView
를 빌드하고 이 뷰를 사용해 개요 페이지에 이미지 그리드를 표시하는 방법을 다시 확인합니다.
기본 요건
- 프래그먼트를 만들고 사용하는 방법
- Retrofit 라이브러리와 Moshi 라이브러리를 사용하여 REST 웹 서비스에서 JSON을 검색하고 이 데이터를 Kotlin 객체로 파싱하는 방법
RecyclerView
로 그리드 레이아웃을 구성하는 방법Adapter
,ViewHolder
,DiffUtil
의 작동 방식
학습할 내용
- Coil 라이브러리를 사용하여 웹 URL에서 이미지를 로드하고 표시하는 방법
RecyclerView
및 그리드 어댑터를 사용하여 이미지 그리드를 표시하는 방법- 이미지를 다운로드하고 표시할 때 발생할 수 있는 오류를 처리하는 방법
빌드할 항목
- 화성 데이터에서 이미지 URL을 가져오도록 MarsPhotos 앱을 수정하고 Coil을 사용해 이 이미지를 로드하고 표시합니다.
- 앱에 로드 애니메이션과 오류 아이콘을 추가합니다.
RecyclerView
를 사용하여 화성 이미지의 그리드를 표시합니다.RecyclerView
에 상태 및 오류 처리를 추가합니다.
필요한 항목
- 최신 버전의 Chrome과 같은 최신 웹브라우저가 설치된 컴퓨터
- 컴퓨터에서 인터넷 액세스가 가능해야 함
이 Codelab에서는 이전 Codelab의 MarsPhotos 앱을 계속 사용하여 작업합니다. MarsPhotos 앱은 웹 서비스에 연결하여 Retrofit을 사용해 검색된 Kotlin 객체 수를 가져와 표시합니다. 이 Kotlin 객체에는 NASA의 화성 탐사 로봇이 화성 표면에서 촬영한 실제 사진의 URL이 포함되어 있습니다.
이 Codelab에서 빌드하는 버전의 앱은 화성 사진을 이미지 그리드로 보여주는 개요 페이지를 채웁니다. 이미지는 앱이 Mars 웹 서비스에서 검색한 데이터의 일부입니다. 앱은 Coil 라이브러리를 사용하여 이미지를 로드해 표시하고 RecyclerView
를 사용하여 이미지의 그리드 레이아웃을 만듭니다. 또한, 앱은 네트워크 오류를 적절히 처리합니다.
웹 URL에서 사진을 표시하는 것은 간단해 보일 수도 있지만 제대로 작동하려면 엔지니어링이 상당히 필요합니다. 이미지를 다운로드하고, 내부적으로 저장하고, 압축 형식에서 Android가 사용할 수 있는 이미지로 디코딩해야 합니다. 이미지는 메모리 내 캐시나 저장소 기반 캐시 또는 두 캐시 모두에 캐시해야 합니다. UI가 응답성을 유지하기 위해 이 모든 작업은 우선순위가 낮은 백그라운드 스레드에서 이루어져야 합니다. 또한 최상의 네트워크 및 CPU 성능을 위해 둘 이상의 이미지를 한 번에 가져오고 디코딩하는 것이 좋습니다.
다행히 커뮤니티에서 개발한 Coil이라는 라이브러리를 사용하여 이미지를 다운로드하고 버퍼링 및 디코딩하고 캐시할 수 있습니다. Coil을 사용하지 않으면 해야 할 작업이 훨씬 더 많습니다.
Coil에는 기본적으로 다음 두 가지가 필요합니다.
- 로드하고 표시할 이미지의 URL
- 이미지를 실제로 표시하는
ImageView
객체
이 작업에서는 Coil을 사용하여 Mars 웹 서비스의 단일 이미지를 표시하는 방법을 알아봅니다. 웹 서비스에서 반환되는 사진 목록에 있는 첫 번째 화성 사진의 이미지를 표시합니다. 다음은 전과 후의 스크린샷입니다.
Coil 종속 항목 추가하기
- 이전 Codelab의 MarsPhotos 솔루션 앱을 엽니다.
- 앱을 실행하여 어떻게 되는지 확인합니다. (검색한 화성 사진의 총 개수가 표시됨)
- build.gradle (Module: app)을 엽니다.
dependencies
섹션에서 다음과 같은 Coil 라이브러리 줄을 추가합니다.
// Coil
implementation "io.coil-kt:coil:1.1.1"
Coil 문서 페이지에서 최신 버전의 라이브러리를 확인하고 업데이트하세요.
- Coil 라이브러리는
mavenCentral()
저장소에서 호스팅되어 제공됩니다. build.gradle (Project: MarsPhotos)의 맨 위repositories
블록에mavenCentral()
을 추가합니다.
repositories {
google()
jcenter()
mavenCentral()
}
- Sync Now를 클릭하여 새 종속 항목으로 프로젝트를 다시 빌드합니다.
ViewModel 업데이트하기
이 단계에서는 LiveData
속성을 OverviewViewModel
클래스에 추가하여 수신된 Kotlin 객체인 MarsPhoto를 저장합니다.
overview/OverviewViewModel.kt
를 엽니다._status
속성 선언 바로 아래에 단일MarsPhoto
객체를 저장할 수 있는MutableLiveData
유형의 새로운 변경 가능 속성_photos
를 추가합니다.
private val _photos = MutableLiveData<MarsPhoto>()
요청이 있는 경우 com.example.android.marsphotos.network.MarsPhoto
를 가져옵니다.
_photos
선언 바로 아래에LiveData<MarsPhoto>
유형의photos
라는 공개 지원 필드를 추가합니다.
val photos: LiveData<MarsPhoto> = _photos
getMarsPhotos()
메서드의try{}
블록 내에서 웹 서비스에서 검색되는 데이터를listResult.
로 설정하는 줄을 찾습니다.
try {
val listResult = MarsApi.retrofitService.getPhotos()
...
}
- 검색되는 첫 번째 화성 사진을 새 변수
_photos
에 할당합니다.listResult
를_photos.value
로 변경합니다. 색인0
에 첫 번째 사진 URL을 할당합니다. 이렇게 하면 오류가 발생하며, 이 오류는 나중에 해결합니다.
try {
_photos.value = MarsApi.retrofitService.getPhotos()[0]
...
}
- 다음 줄에서
status.value
를 다음과 같이 업데이트합니다.listResult
대신 새 속성의 데이터를 사용합니다. 사진 목록의 첫 번째 이미지 URL을 표시합니다.
try {
...
_status.value = " First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
- 이제 전체
try{}
블록은 다음과 같습니다.
try {
_photos.value = MarsApi.retrofitService.getPhotos()[0]
_status.value = " First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
- 앱을 실행합니다.
TextView
가 이제 첫 번째 화성 사진의 URL을 표시합니다. 지금까지ViewModel
을 설정하고 이 URL의LiveData
를 설정했습니다.
결합 어댑터 사용하기
결합 어댑터는 뷰의 맞춤 속성을 위한 맞춤 setter를 만드는 데 사용되는 주석 처리된 메서드입니다.
일반적으로 XML에서 android:text="Sample Text"
코드를 사용하여 속성을 설정하는 경우 Android 시스템은 setText(String: text)
메서드를 통해 설정되는 text
속성과 같은 이름의 setter를 자동으로 찾습니다. setText(String: text)
메서드는 Android 프레임워크에서 제공하는 일부 뷰의 setter 메서드입니다. 결합 어댑터를 사용하여 유사한 동작을 맞춤설정할 수 있습니다. 데이터 결합 라이브러리에서 호출되는 맞춤 속성과 맞춤 로직을 제공할 수 있습니다.
예:
단순히 이미지 뷰에서 드로어블 이미지를 설정하는 setter를 호출하는 것보다 더 복잡한 무언가를 하려면 인터넷에서 UI 스레드(기본 스레드)의 로드 이미지를 가져오는 것을 고려합니다. 먼저 맞춤 속성을 선택하여 ImageView
에 이미지를 할당합니다. 다음 예에서는 imageUrl
입니다.
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:imageUrl="@{product.imageUrl}"/>
코드를 추가하지 않으면 시스템이 ImageView
에서 setImageUrl(String)
메서드를 찾으며, 찾지 못해 오류가 발생합니다. 프레임워크에서 제공되지 않는 맞춤 속성이기 때문입니다. app:imageUrl
속성을 구현하여 ImageView
로 설정하는 방법을 만들어야 합니다. 이렇게 하려면 결합 어댑터(주석 처리된 메서드)를 사용합니다.
결합 어댑터의 예:
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
// Load the image in the background using Coil.
}
}
}
@BindingAdapter
주석은 속성 이름을 매개변수로 사용합니다.
bindImage
메서드에서 첫 번째 메서드 매개변수는 타겟 뷰의 유형이고 두 번째 매개변수는 속성에 설정되는 값입니다.
메서드 내부에서 Coil 라이브러리는 UI 스레드에서 이미지를 로드하여 ImageView
로 설정합니다.
결합 어댑터 만들기 및 Coil 사용하기
com.example.android.marsphotos
패키지에서BindingAdapters
라는 Kotlin 파일을 만듭니다. 이 파일은 앱 전반에 사용하는 결합 어댑터를 보유하게 됩니다.
BindingAdapters.kt
에서 매개변수로ImageView
및String
을 사용하는bindImage()
함수를 만듭니다.
fun bindImage(imgView: ImageView, imgUrl: String?) {
}
요청이 있는 경우 android.widget.ImageView
를 가져옵니다.
- 함수에
@BindingAdapter
주석을 추가합니다.@BindingAdapter
주석은 뷰 항목에imageUrl
속성이 있는 경우 이 결합 어댑터를 실행하도록 데이터 결합에 지시합니다.
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
}
요청이 있는 경우 androidx.databinding.BindingAdapter
를 가져옵니다.
let 범위 함수
let
은 Kotlin의 범위 함수 중 하나로, 이 함수를 사용하여 객체의 컨텍스트 내에서 코드 블록을 실행할 수 있습니다. Kotlin에는 5가지 범위 함수가 있습니다. 자세히 알아보려면 문서를 참고하세요.
사용:
let
은 호출 체인의 결과에서 함수 하나 이상을 호출하는 데 사용됩니다.
let
함수는 안전 호출 연산자( ?.
)와 함께 객체에서 null 안전 연산을 실행하는 데 사용됩니다. 이 경우 let
코드 블록은 객체가 null이 아닌 경우에만 실행됩니다.
bindImage()
함수 내부에서 안전 호출 연산자를 사용하여let{}
블록을imageURL
인수에 추가합니다.
imgUrl?.let {
}
let{}
블록 내부에서toUri()
메서드를 사용해 URL 문자열을Uri
객체로 변환하도록 다음 줄을 추가합니다. HTTPS 스키마를 사용하려면buildUpon.scheme("https")
을toUri
빌더에 추가합니다.build()
를 호출하여 객체를 빌드합니다.
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
요청이 있는 경우 androidx.core.net.toUri
를 가져옵니다.
let{}
블록 내부에서imgUri
선언 다음에 Coil의load(){}
를 사용하여imgUri
객체에서imgView
로 이미지를 로드합니다.
imgView.load(imgUri) {
}
요청이 있는 경우 coil.load
를 가져옵니다.
- 전체 메서드는 다음과 같습니다.
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
imgView.load(imgUri)
}
}
레이아웃 및 프래그먼트 업데이트하기
이전 섹션에서 Coil 이미지 라이브러리를 사용하여 이미지를 로드했습니다. 이미지를 화면에서 보기 위한 다음 단계는 ImageView
를 새 속성으로 업데이트하여 단일 이미지를 표시하는 것입니다.
Codelab 후반에 res/layout/grid_view_item.xml
을 RecyclerView
의 각 그리드 항목을 위한 레이아웃 리소스 파일로 사용합니다. 이 작업에서는 이 파일을 임시로 사용하여 이전 작업에서 검색한 이미지 URL을 사용하여 이미지를 표시합니다. 임시로 fragment_overview.xml
대신 이 레이아웃 파일을 사용하게 됩니다.
res/layout/grid_view_item.xml
을 엽니다.<ImageView>
요소 위에 데이터 결합의<data>
요소를 추가하고OverviewViewModel
클래스에 결합합니다.
<data>
<variable
name="viewModel"
type="com.example.android.marsphotos.overview.OverviewViewModel" />
</data>
- 새 이미지 로드 결합 어댑터를 사용하도록
ImageView
요소에app:imageUrl
속성을 추가합니다.photos
에는MarsPhotos
가 서버에서 검색한 목록이 포함된다는 점을 기억하세요. 첫 번째 항목 URL을imageUrl
속성에 할당합니다.
<ImageView
android:id="@+id/mars_image"
...
app:imageUrl="@{viewModel.photos.imgSrcUrl}"
... />
overview/OverviewFragment.kt
를 엽니다.onCreateView()
메서드에서FragmentOverviewBinding
클래스를 확장하고 결합 변수에 할당하는 줄을 주석 처리합니다. 이 줄을 삭제한 것을 이유로 오류가 표시됩니다. 이 오류는 일시적이며 나중에 수정합니다.
//val binding = FragmentOverviewBinding.inflate(inflater)
fragment_overview.xml.
대신grid_view_item.xml
을 사용합니다. 다음 줄을 추가하여GridViewItemBinding
클래스를 대신 확장합니다.
val binding = GridViewItemBinding.inflate(inflater)
요청이 있는 경우 com.example.android.marsphotos. databinding.GridViewItemBinding
을 가져옵니다.
- 앱을 실행합니다. 이제 단일 화성 이미지가 표시됩니다.
로드 이미지와 오류 이미지 추가하기
Coil을 사용하면 이미지를 로드하는 동안 자리표시자 이미지를 표시하고 로드 실패 시(예: 이미지가 없거나 손상된 경우) 오류 이미지를 표시함으로써 사용자 경험을 개선할 수 있습니다. 이 단계에서는 이러한 기능을 결합 어댑터에 추가합니다.
res/drawable/ic_broken_image.xml
을 열고 오른쪽에서 Design 탭을 클릭합니다. 오류 이미지의 경우 내장된 아이콘 라이브러리에서 사용할 수 있는 손상 이미지 아이콘을 사용합니다. 이 벡터 드로어블은android:tint
속성을 사용하여 아이콘 색상을 회색으로 지정합니다.
res/drawable/loading_animation.xml
을 엽니다. 이 드로어블은 이미지 드로어블loading_img.xml
을 중심점을 축으로 회전시키는 애니메이션입니다. (이 애니메이션이 미리보기에 표시되지 않습니다.)
BindingAdapters.kt
파일로 돌아갑니다.bindImage()
메서드에서 다음과 같이imgView.
load
(imgUri)
호출을 업데이트하여 후행 람다를 추가합니다. 이 코드는 로드하는 동안 사용할 자리표시자 로드 이미지(loading_animation
드로어블)를 설정합니다. 또한 이 코드는 이미지를 로드하지 못한 경우 사용할 이미지(broken_image
드로어블)를 설정합니다.
imgView.load(imgUri) {
placeholder(R.drawable.loading_animation)
error(R.drawable.ic_broken_image)
}
- 이제 전체
bindImage()
메서드는 다음과 같습니다.
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
imgUrl?.let {
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
imgView.load(imgUri) {
placeholder(R.drawable.loading_animation)
error(R.drawable.ic_broken_image)
}
}
}
- 앱을 실행합니다. 네트워크 연결 속도에 따라 Glide가 속성 이미지를 다운로드하고 표시할 때 로드 이미지가 잠시 표시될 수도 있습니다. 그러나 네트워크를 사용 중지해도 손상 이미지 아이콘은 아직 표시되지 않습니다. 이 부분은 Codelab의 마지막 작업에서 수정합니다.
overview/OverviewFragment.kt
에서 적용한 임시 변경사항을 되돌립니다.onCreateview()
메서드에서FragmentOverviewBinding
을 확장하는 줄의 주석 처리를 삭제합니다.GridViewIteMBinding
을 확장하는 줄을 삭제하거나 주석 처리합니다.
val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)
이제 앱이 인터넷에서 화성 사진을 로드합니다. 첫 번째 MarsPhoto
목록 항목의 데이터를 사용하여 ViewModel
에 LiveData
속성을 만들고 이 화성 사진 데이터의 이미지 URL을 사용하여 ImageView
를 채웠습니다. 하지만 앱이 이미지 그리드를 표시하는 것이 목표이므로, 이 작업에서는 그리드 레이아웃 관리자와 함께 RecyclerView
를 사용하여 이미지 그리드를 표시합니다.
뷰 모델 업데이트하기
이전 작업에서는 OverviewViewModel
에서 웹 서비스의 응답 목록에 있는 첫 번째 객체인 MarsPhoto
객체 하나를 보유하는 LiveData
객체 _photos
를 추가했습니다. 이 단계에서는 MarsPhoto
객체의 전체 목록을 보유하도록 이 LiveData
를 변경합니다.
overview/OverviewViewModel.kt
를 엽니다._photos
유형을MarsPhoto
객체 목록으로 변경합니다.
private val _photos = MutableLiveData<List<MarsPhoto>>()
- 또한 지원 속성
photos
유형을List<MarsPhoto>
유형으로 바꿉니다.
val photos: LiveData<List<MarsPhoto>> = _photos
getMarsPhotos()
메서드 내에서 아래로 스크롤하여try {}
블록을 찾습니다.MarsApi.
retrofitService
.getPhotos()
는
MarsPhoto
객체의 목록을 반환하며, 이 목록을 _photos.value
에 할당하면 됩니다.
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
- 이제 전체
try/catch
블록은 다음과 같습니다.
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
} catch (e: Exception) {
_status.value = "Failure: ${e.message}"
}
그리드 레이아웃
RecyclerView
의 GridLayoutManager
는 아래와 같이 데이터를 스크롤 가능한 그리드로 배치합니다.
디자인 관점에서 볼 때 그리드 레이아웃은 아이콘이나 이미지로 표현할 수 있는 목록(예: 화성 사진 탐색 앱 내의 목록)에 가장 적합합니다.
그리드 레이아웃에 항목을 배치하는 방법
그리드 레이아웃은 항목을 행과 열의 그리드로 정렬합니다. 세로 스크롤을 사용하는 경우 기본적으로 행의 각 항목은 '스팬' 하나를 차지합니다. 한 항목이 여러 스팬을 차지할 수 있습니다. 아래 예의 경우 한 스팬이 한 열의 너비, 즉 3과 동일합니다.
아래의 두 예에서 각 행은 스팬 세 개로 구성됩니다. 기본적으로 GridLayoutManager
는 개발자가 지정한 스팬 수까지 각 항목을 한 스팬에 배치합니다. 스팬 수에 도달하면 다음 줄로 넘어갑니다.
Recyclerview 추가하기
이 단계에서는 단일 이미지 뷰가 아닌 그리드 레이아웃과 함께 Recycler 뷰를 사용하도록 앱의 레이아웃을 변경합니다.
layout/gridview_item.xml
을 엽니다.viewModel
데이터 변수를 삭제합니다.<data>
태그 내부에MarsPhoto
유형의 다음photo
변수를 추가합니다.
<data>
<variable
name="photo"
type="com.example.android.marsphotos.network.MarsPhoto" />
</data>
<ImageView>
에서MarsPhoto
객체의 이미지 URL을 참조하도록app:imageUrl
속성을 변경합니다. 이렇게 변경하면 이 이전 작업에서 적용한 임시 변경사항이 실행취소됩니다.
app:imageUrl="@{photo.imgSrcUrl}"
layout/fragment_overview.xml
을 엽니다. 전체<TextView>
요소를 삭제합니다.- 대신 다음
<RecyclerView>
요소를 추가합니다. ID를photos_grid
로 설정하고width
속성과height
속성을0dp
로 설정하여 상위ConstraintLayout
을 채웁니다. 그리드 레이아웃을 사용할 것이므로layoutManager
속성을androidx.recyclerview.widget.GridLayoutManager
로 설정합니다. 열이 두 개가 되도록spanCount
를2
로 설정합니다.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photos_grid"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager=
"androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2" />
- 위의 코드가 Design 뷰에서 어떻게 표시되는지 미리보려면
tools:itemCount
를 사용하여 레이아웃에 표시되는 항목의 수를16
으로 설정합니다.itemCount
속성은 Layout Editor에서 Preview 창에 렌더링해야 하는 항목의 수를 지정합니다.tools:listitem
을 사용하여 목록 항목의 레이아웃을grid_view_item
으로 설정합니다.
<androidx.recyclerview.widget.RecyclerView
...
tools:itemCount="16"
tools:listitem="@layout/grid_view_item" />
- Design 뷰로 전환하면 다음 스크린샷과 같은 미리보기가 표시됩니다. 화성 사진은 아니지만 recyclerview 그리드 레이아웃이 어떻게 표시되는지 보여줍니다. 미리보기에서는
recyclerview
의 모든 단일 그리드 항목에 패딩과grid_view_item
레이아웃을 사용합니다.
- 머티리얼 디자인 가이드라인에 따라 목록의 상단, 하단, 측면에
8dp
의 공간이 있어야 하고 항목 사이에는4dp
의 공간이 있어야 합니다. 이렇게 하려면fragment_overview.xml
레이아웃과gridview_item.xml
레이아웃의 패딩 조합을 사용하면 됩니다.
layout/gridview_item.xml
을 엽니다.padding
속성에는 이미 항목 외부와 콘텐츠 사이에2dp
의 패딩이 있습니다. 이에 따라 항목 콘텐츠 사이에4dp
의 공간이, 그리고 바깥 가장자리를 따라2dp
의 공간이 확보됩니다. 다시 말해서, 디자인 가이드라인과 일치하도록 바깥 가장자리에 추가로6dp
의 패딩이 필요합니다.layout/fragment_overview.xml
로 돌아갑니다.RecyclerView
의6dp
패딩을 추가하여 가이드라인에 따라 외부에8dp
를, 내부에4dp
를 확보합니다.
<androidx.recyclerview.widget.RecyclerView
...
android:padding="6dp"
... />
- 전체
<RecyclerView>
요소는 다음과 같습니다.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photos_grid"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="6dp"
app:layoutManager=
"androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2"
tools:itemCount="16"
tools:listitem="@layout/grid_view_item" />
사진 그리드 어댑터 추가하기
이제 fragment_overview
레이아웃에 그리드 레이아웃이 포함된 RecyclerView
가 있습니다. 이 단계에서는 웹 서버에서 검색한 데이터를 RecyclerView
어댑터를 통해 RecyclerView
에 결합합니다.
ListAdapter(복습)
ListAdapter
는 RecyclerView.Adapter
클래스의 서브클래스로, 백그라운드 스레드의 목록 간 차이를 계산하는 작업을 포함하여 목록 데이터를 RecyclerView
에 표시하기 위한 것입니다.
이 앱에서는 ListAdapter.
의 DiffUtil
구현을 사용합니다. DiffUtil
을 사용할 때 이점은 RecyclerView
에서 일부 항목이 추가되거나 삭제 또는 변경될 때마다 전체 목록이 새로고침되지 않는다는 점입니다. 변경된 항목만 새로고침됩니다.
앱에 ListAdapter
를 추가합니다.
overview
패키지에서PhotoGridAdapter.kt
라는 새 Kotlin 클래스를 만듭니다.- 아래와 같이 생성자 매개변수를 사용하여
ListAdapter
의PhotoGridAdapter
클래스를 확장합니다.PhotoGridAdapter
클래스는ListAdapter
를 확장합니다. 이 생성자에는 목록 항목 유형, 뷰 홀더,DiffUtil.ItemCallback
구현이 필요합니다.
class PhotoGridAdapter : ListAdapter<MarsPhoto,
PhotoGridAdapter.MarsPhotoViewHolder>(DiffCallback) {
}
요청이 있는 경우 androidx.recyclerview.widget.ListAdapter
클래스와 com.example.android.marsphoto.network.MarsPhoto
클래스를 가져옵니다. 다음 단계에서는 이 생성자에 누락되어 오류를 생성하는 다른 부분을 구현합니다.
- 위의 오류를 해결하기 위해 이 단계에서 필요한 메서드를 추가한 후 이 작업의 후반부에서 구현합니다.
PhotoGridAdapter
클래스를 클릭하고 빨간색 전구를 클릭한 다음 드롭다운 메뉴에서 Implement members를 선택합니다. 표시되는 팝업에서ListAdapter
메서드onCreateViewHolder()
,onBindViewHolder()
를 선택합니다. 이 작업을 마칠 때 수정되는 오류가 Android 스튜디오에 계속 표시됩니다.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPhotoViewHolder {
TODO("Not yet implemented")
}
override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPhotoViewHolder, position: Int) {
TODO("Not yet implemented")
}
onCreateViewHolder
메서드와 onBindViewHolder
메서드를 구현하려면 다음 단계에서 추가할 MarsPhotoViewHolder
가 필요합니다.
PhotoGridAdapter
내부에RecyclerView.ViewHolder
를 확장하는MarsPhotoViewHolder
의 내부 클래스 정의를 추가합니다.MarsPhoto
를 레이아웃에 결합하기 위한GridViewItemBinding
변수가 필요하므로, 이 변수를MarsPhotoViewHolder
에 전달합니다. 기본ViewHolder
클래스는 생성자에 뷰가 있어야 합니다. 이 클래스를 결합 루트 뷰에 전달합니다.
class MarsPhotoViewHolder(private var binding:
GridViewItemBinding):
RecyclerView.ViewHolder(binding.root) {
}
요청이 있는 경우 androidx.recyclerview.widget.RecyclerView
및 com.example.android.marsrealestate.databinding.GridViewItemBinding
을 가져옵니다.
MarsPhotoViewHolder
에서MarsPhoto
객체를 인수로 사용하고binding.property
를 이 객체로 설정하는bind()
메서드를 만듭니다. 속성을 설정한 후executePendingBindings()
를 호출하면 업데이트가 즉시 실행됩니다.
fun bind(MarsPhoto: MarsPhoto) {
binding.photo = MarsPhoto
binding.executePendingBindings()
}
onCreateViewHolder()
의PhotoGridAdapter
클래스 내부에서 TODO를 삭제하고 아래의 줄을 추가합니다.onCreateViewHolder()
메서드는GridViewItemBinding
을 확장하고 상위ViewGroup
컨텍스트의LayoutInflater
를 사용하여 생성된 새MarsPhotoViewHolder
를 반환해야 합니다.
return MarsPhotoViewHolder(GridViewItemBinding.inflate(
LayoutInflater.from(parent.context)))
요청이 있는 경우 android.view.LayoutInflater
를 가져옵니다.
onBindViewHolder()
메서드에서 TODO를 삭제하고 아래의 줄을 추가합니다. 여기서getItem()
을 호출하여 현재RecyclerView
위치와 연결된MarsPhoto
객체를 가져온 다음 이 속성을MarsPhotoViewHolder
의bind()
메서드에 전달합니다.
val marsPhoto = getItem(position)
holder.bind(marsPhoto)
PhotoGridAdapter
내부에 아래와 같이DiffCallback
의 컴패니언 객체 정의를 추가합니다.
DiffCallback
객체는 비교할 일반 객체 유형MarsPhoto
로DiffUtil.ItemCallback
을 확장합니다. 이 구현 내부에서 두 화성 사진 객체를 비교합니다.
companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
}
요청이 있는 경우 androidx.recyclerview.widget.DiffUtil
을 가져옵니다.
- 빨간색 전구를 눌러
DiffCallback
객체의 비교기 메서드areItemsTheSame()
및areContentsTheSame()
을 구현합니다.
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
TODO("Not yet implemented")
}
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
TODO("Not yet implemented") }
areItemsTheSame()
메서드에서TODO
를 삭제합니다. 이 메서드는DiffUtil
에서 호출되어 두 객체가 동일한 항목을 나타내는지 여부를 확인합니다.DiffUtil
은 이 메서드를 사용하여 새MarsPhoto
객체가 이전MarsPhoto
객체와 동일한지 확인합니다. 모든 항목(MarsPhoto
객체)의 ID는 고유합니다.oldItem
과newItem
의 ID를 비교하여 결과를 반환합니다.
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
return oldItem.id == newItem.id
}
areContentsTheSame()
에서TODO
를 삭제합니다. 이 메서드는 두 항목의 데이터가 동일한지 확인하려고 할 때DiffUtil
에서 호출됩니다. MarsPhoto에서 중요한 데이터는 이미지 URL입니다.oldItem
과newItem
의 URL을 비교하여 결과를 반환합니다.
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
return oldItem.imgSrcUrl == newItem.imgSrcUrl
}
오류 없이 앱을 컴파일하고 실행할 수 있어야 하지만, 에뮬레이터에 빈 화면이 표시됩니다. recyclerview가 준비되었지만 뷰에 전달된 데이터가 없기 때문입니다. 이 데이터는 다음 단계에서 구현합니다.
결합 어댑터를 추가하고 부분 연결하기
이 단계에서는 BindingAdapter
를 사용하여 MarsPhoto
객체 목록으로 PhotoGridAdapter
를 초기화합니다. BindingAdapter
를 사용하여 RecyclerView
데이터를 설정하면 데이터 결합이 자동으로 MarsPhoto
객체 목록의 LiveData
를 관찰합니다. 그런 다음 MarsPhoto
목록이 변경되면 결합 어댑터가 자동으로 호출됩니다.
BindingAdapters.kt
를 엽니다.- 파일 끝에
RecyclerView
와MarsPhoto
객체 목록을 인수로 사용하는bindRecyclerView()
메서드를 추가합니다. 이 메서드에listData
속성이 포함된@BindingAdapter
주석을 추가합니다.
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
data: List<MarsPhoto>?) {
}
요청이 있는 경우 androidx.recyclerview.widget.RecyclerView
및 com.example.android.marsphotos.network.MarsPhoto
를 가져옵니다.
bindRecyclerView()
함수 내부에서recyclerView.adapter
를PhotoGridAdapter
로 변환하여 새val
속성adapter.
에 할당합니다.
val adapter = recyclerView.adapter as PhotoGridAdapter
bindRecyclerView()
함수 끝부분에서 화성 사진 목록 데이터가 포함된adapter.submitList()
를 호출합니다. 그러면 새 목록을 사용할 수 있을 때RecyclerView
에 알려줍니다.
adapter.submitList(data)
요청이 있는 경우 com.example.android.marsrealestate.overview.PhotoGridAdapter
를 가져옵니다.
- 전체
bindRecyclerView
결합 어댑터는 다음과 같습니다.
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
data: List<MarsPhoto>?) {
val adapter = recyclerView.adapter as PhotoGridAdapter
adapter.submitList(data)
}
- 모든 항목을 연결하려면
res/layout/fragment_overview.xml
을 엽니다.RecyclerView
요소에app:listData
속성을 추가하고 데이터 결합을 사용하여 이 속성을viewmodel.photos
로 설정합니다. 이전 작업에서ImageView
에 실행한 작업과 유사합니다.
app:listData="@{viewModel.photos}"
overview/OverviewFragment.kt
를 엽니다.onCreateView()
의return
문 바로 앞에서binding.photosGrid
의RecyclerView
어댑터를 새PhotoGridAdapter
객체로 초기화합니다.
binding.photosGrid.adapter = PhotoGridAdapter()
- 앱을 실행합니다. 화성 이미지를 스크롤하는 그리드가 표시됩니다. 스크롤하면 새 이미지가 표시되지만 약간 이상해 보입니다. 스크롤하는 동안
RecyclerView
의 상단과 하단에 패딩이 유지되므로 목록이 작업 모음 아래에서 스크롤되는 것처럼 보이지 않습니다.
- 수정하려면 android:clipToPadding 속성을 사용해 내부 콘텐츠를 패딩에 맞춰 자르지 않도록
RecyclerView
에 알려야 합니다. 그러면 스크롤 뷰가 패딩 영역 안에 그려집니다.layout/fragment_overview.xml
로 돌아갑니다.RecyclerView
의android:clipToPadding
속성을 추가하고false
로 설정합니다.
<androidx.recyclerview.widget.RecyclerView
...
android:clipToPadding="false"
... />
- 앱을 실행합니다. 예상대로 이미지 자체가 표시되기 전에 로드 진행률 아이콘이 표시되는 것도 볼 수 있습니다. 이 아이콘은 Coil 이미지 라이브러리에 전달한 자리표시자 로드 이미지입니다.
- 앱이 실행되는 동안 비행기 모드를 사용 설정합니다. 에뮬레이터에서 이미지를 스크롤합니다. 아직 로드되지 않은 이미지는 손상 이미지 아이콘으로 표시됩니다. 이 아이콘은 네트워크 오류가 발생하거나 이미지를 가져올 수 없을 때 표시하도록 Coil 이미지 라이브러리에 전달한 이미지 드로어블입니다.
축하합니다. 거의 완료되었습니다. 다음이자 마지막 작업에서는 앱에 더 많은 오류 처리를 추가하여 사용자 경험을 더욱 개선합니다.
이미지를 가져올 수 없을 때 MarsPhotos 앱은 손상 이미지 아이콘을 표시합니다. 하지만 네트워크가 없으면 앱에서 빈 화면이 표시됩니다. 빈 화면은 다음 단계에서 확인합니다.
- 기기나 에뮬레이터에서 비행기 모드를 사용 설정합니다. Android 스튜디오에서 앱을 실행합니다. 빈 화면이 표시됩니다.
이는 만족스러운 사용자 경험이 아닙니다. 이 작업에서는 기본 오류 처리를 추가하여 사용자가 현재 상황을 더 잘 파악하도록 합니다. 앱은 인터넷을 사용할 수 없는 경우 연결 오류 아이콘을 표시하고 MarsPhoto
목록을 가져오는 동안에는 로드 애니메이션을 표시합니다.
ViewModel에 상태 추가하기
이 작업에서는 OverviewViewModel
에서 웹 요청의 상태를 나타내는 속성을 만듭니다. 로드, 성공, 실패 등 세 가지 상태를 고려합니다. 로드 상태는 데이터를 기다리는 동안 발생합니다. 성공 상태는 웹 서비스에서 데이터를 성공적으로 검색했음을 나타냅니다. 실패 상태는 네트워크 오류나 연결 오류를 나타냅니다.
Kotlin의 enum 클래스
애플리케이션에서 이 세 가지 상태를 나타내려면 enum
을 사용합니다. enum
은 열거의 단축형으로, 컬렉션의 모든 항목을 순서가 지정된 목록으로 나열한다는 의미입니다. 각 enum
상수는 enum
클래스의 객체입니다.
Kotlin에서 enum
은 상수 집합을 보유할 수 있는 데이터 유형입니다. 아래와 같이 클래스 정의 앞에 키워드 enum
을 추가하여 정의합니다. 열거형 상수는 쉼표로 구분됩니다.
정의:
enum class Direction {
NORTH, SOUTH, WEST, EAST
}
사용:
var direction = Direction.NORTH;
위와 같이 enum
객체 참조는 클래스 이름 뒤에 점(.) 연산자와 상수 이름을 사용하여 처리할 수 있습니다.
ViewModel에서 상태 값과 함께 enum 클래스 정의를 추가합니다.
overview/OverviewViewModel.kt
를 엽니다. 파일 상단에서(가져오기 뒤, 클래스 정의 앞에)enum
을 추가하여 사용 가능한 모든 상태를 나타냅니다.
enum class MarsApiStatus { LOADING, ERROR, DONE }
_status
속성과status
속성의 정의로 스크롤하고 유형을String
에서MarsApiStatus. MarsApiStatus
로 변경합니다. MarsApiStatus는 이전 단계에서 정의한 enum 클래스입니다.
private val _status = MutableLiveData<MarsApiStatus>()
val status: LiveData<MarsApiStatus> = _status
getMarsPhotos()
메서드에서"Success: ..."
문자열을MarsApiStatus.DONE
상태로,"Failure..."
문자열을MarsApiStatus.ERROR
로 변경합니다.
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception)
_status.value = MarsApiStatus.ERROR
}
- 상태를
try {}
블록 위에서MarsApiStatus.LOADING
으로 설정합니다. 이 상태는 코루틴을 실행하는 동안 데이터를 기다릴 때 초기 상태입니다. 이제 전체viewModelScope.launch
{}
블록은 다음과 같습니다.
viewModelScope.launch {
_status.value = MarsApiStatus.LOADING
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
}
}
catch {}
블록에서 오류 상태 다음에_photos
를 빈 목록으로 설정합니다. 이렇게 하면 Recycler 뷰가 삭제됩니다.
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
_photos.value = listOf()
}
- 전체
getMarsPhotos()
메서드는 다음과 같습니다.
private fun getMarsPhotos() {
viewModelScope.launch {
_status.value = MarsApiStatus.LOADING
try {
_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = MarsApiStatus.DONE
} catch (e: Exception) {
_status.value = MarsApiStatus.ERROR
_photos.value = listOf()
}
}
}
상태를 나타내는 enum 상태를 정의하고 코루틴 시작 시 로드 상태를 설정했습니다. 앱이 완료되어 웹 서버에서 데이터를 검색하면 완료로 설정되고, 예외가 있으면 오류로 설정됩니다. 다음 작업에서는 결합 어댑터를 사용하여 해당하는 아이콘을 표시합니다.
상태 ImageView용 결합 어댑터 추가하기
일련의 enum
상태를 사용하여 OverviewViewModel
에서 MarsApiStatus
를 설정했습니다. 이 단계에서는 상태를 앱에 표시합니다. ImageView
에 결합 어댑터를 사용하여 로드 상태 및 오류 상태의 아이콘을 표시합니다. 앱이 로드 상태이거나 오류 상태일 때 ImageView
가 표시됩니다. 앱에서 로드가 완료되면 ImageView
가 표시되지 않습니다.
BindingAdapters.kt
를 열고 파일의 끝으로 스크롤하여 다른 어댑터를 추가합니다.ImageView
값과MarsApiStatus
값을 인수로 사용하는bindStatus()
라는 새 결합 어댑터를 추가합니다. 맞춤 속성marsApiStatus
를 매개변수로 전달하는@BindingAdapter
주석을 메서드에 추가합니다.
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
status: MarsApiStatus?) {
}
요청이 있는 경우 com.example.android.marsrealestate.overview.MarsApiStatus
를 가져옵니다.
bindStatus()
메서드 내부에when {}
블록을 추가하여 서로 다른 상태 간에 전환합니다.
when (status) {
}
when {}
내부에 로드 상태(MarsApiStatus.LOADING
)의 사례를 추가합니다. 이 상태의 경우ImageView
를 visible로 설정하고 로드 애니메이션에 할당합니다. 이전 작업에서 Coil에 사용한 것과 동일한 애니메이션 드로어블입니다.
when (status) {
MarsApiStatus.LOADING -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.loading_animation)
}
}
요청이 있는 경우 android.view.View
를 가져옵니다.
- 오류 상태(
MarsApiStatus.ERROR
)의 사례를 추가합니다.LOADING
상태의 경우와 유사하게 상태ImageView
를 visible로 설정하고 연결 오류 드로어블을 사용합니다.
MarsApiStatus.ERROR -> {
statusImageView.visibility = View.VISIBLE
statusImageView.setImageResource(R.drawable.ic_connection_error)
}
- 완료 상태(
MarsApiStatus.DONE
)의 사례를 추가합니다. 여기서는 성공적인 응답이 있으므로 상태ImageView
의 공개 상태를View.
GONE
으로 설정하여 숨깁니다.
MarsApiStatus.DONE -> {
statusImageView.visibility = View.GONE
}
상태 이미지 뷰의 결합 어댑터를 설정했습니다. 다음 단계에서는 새 결합 어댑터를 사용하는 이미지 뷰를 추가합니다.
상태 ImageView 추가하기
이 단계에서는 이전에 정의한 상태를 표시하는 이미지 뷰를 fragment_overview.xml
에 추가합니다.
res/layout/fragment_overview.xml
을 엽니다.ConstraintLayout
의RecyclerView
요소 아래에 다음과 같이ImageView
를 추가합니다.
<ImageView
android:id="@+id/status_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:marsApiStatus="@{viewModel.status}" />
위의 ImageView
에는 RecyclerView
와 동일한 제약 조건이 있습니다. 그러나 이미지를 늘려 뷰를 채우는 대신, 너비와 높이가 wrap_content
를 사용하여 이미지를 중앙에 배치합니다. 또한 app:marsApiStatus
속성이 viewModel.status
로 설정되어 있어, ViewModel
의 상태 속성이 변경되면 BindingAdapter
가 호출됩니다.
- 위 코드를 테스트하려면 에뮬레이터나 기기에서 비행기 모드를 사용 설정하여 네트워크 연결 오류를 시뮬레이션합니다. 앱을 컴파일하고 실행하면 오류 이미지가 표시됩니다.
- Back 버튼을 탭하여 앱을 닫고 비행기 모드를 사용 중지합니다. 최근 항목 화면을 사용하여 앱을 반환합니다. 네트워크 연결 속도에 따라 앱이 웹 서비스를 쿼리할 때 이미지 로드가 시작되기 전에 로드 스피너가 아주 잠시 표시될 수도 있습니다.
이 Codelab을 완료하고 MarsPhotos 앱을 빌드한 것을 축하합니다! 이제 가족과 친구들에게 실제 화성 사진이 담긴 앱을 자랑하세요.
이 Codelab의 솔루션 코드는 아래 표시된 프로젝트에 있습니다. main 분기를 사용하여 코드를 가져오거나 다운로드하세요.
이 Codelab의 코드를 가져와서 Android 스튜디오에서 열려면 다음을 실행합니다.
코드 가져오기
- 제공된 URL을 클릭합니다. 브라우저에서 프로젝트의 GitHub 페이지가 열립니다.
- 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 대화상자를 엽니다.
- 대화상자에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
- 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
- ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.
Android 스튜디오에서 프로젝트 열기
- Android 스튜디오를 시작합니다.
- Welcome to Android Studio 창에서 Open an existing Android Studio project를 클릭합니다.
참고: Android 스튜디오가 이미 열려 있는 경우 File > New > Import Project 메뉴 옵션을 대신 선택합니다.
- Import Project 대화상자에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
- 프로젝트 폴더를 더블클릭합니다.
- Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
- Run 버튼 을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.
- Project 도구 창에서 프로젝트 파일을 살펴보고 앱이 구현된 방식을 확인합니다.
- Coil 라이브러리는 앱에서 이미지 다운로드, 버퍼링, 디코딩, 캐시와 같은 이미지 관리 프로세스를 단순화합니다.
- 결합 어댑터는 뷰와 이 뷰에 결합된 데이터 사이에 있는 확장 메서드입니다. 결합 어댑터는 데이터가 변경될 때(예: Coil을 호출하여 URL에서
ImageView
로 이미지 로드하기) 맞춤 동작을 제공합니다. - 결합 어댑터는
@BindingAdapter
주석이 추가된 확장 메서드입니다. - 이미지의 그리드를 표시하려면
GridLayoutManager
와 함께RecyclerView
를 사용합니다. - 변경 시 속성 목록을 업데이트하려면
RecyclerView
와 레이아웃 사이에 결합 어댑터를 사용합니다.
Android 개발자 문서:
기타: