요구사항
- https://www.gettyimages.com/photos/collaboration 의 내용을 리스트 형태로 출력합니다.
- 컬럼은 3개입니다.
- 이미지를 가져오는데 있어, Getty api 를 사용하는게 아닌, 다른 방식으로의 처리를 부탁드립니다.(오픈소스 사용가능합니다.)
- 사용 언어 및 개발 환경은 안드로이드 기기에서 실행이 되는 조건만 만족하면 자유롭게 선택할 수 있습니다.
구현 내용
이미지 데이터 읽어오는 순서
- 이미지 데이터를 웹으로 부터 읽어옵니다.
1) 시작 시, ViewModel에서 loadWebPage 함수 호출 -> UseCase 호출 -> 이미지 정보 List<UiGettyModel> 얻어오기
eventFlow를 통해, 값을 onSuccess로 얻어왔다면, getImage.emit(true)를 보냄
fun loadWebPage(url: String) {
Timber.e("hello? ${url}")
viewModelScope.launch(Dispatchers.IO) {
gettyItemUseCase(url).onSuccess {
_gettyList.postValue(it)
_getImage.emit(true)
}.onFailure {
_getImage.emit(false)
}
}
}
2) UseCase 에서 repository.getGettyItem(url)을 부르게 됨 - 클래스나 객체에 invoke 함수를 정의하면, 해당 클래스의 인스턴스를 함수처럼 호출함
class GetGettyItemUseCase @Inject constructor(
private val gettyRepository: GettyRepository
){
suspend operator fun invoke(
url: String
) : Result<List<UiGettyModel>>{
return gettyRepository.getGettyItem(url)
}
}
3) Repository에서 getGettyItem을 부르게 됨
interface GettyRepository{
suspend fun getGettyItem(url: String): Result<List<UiGettyModel>>
}
4) RepositoryImpl에서 dataRemoteDateSource로 부터 얻어온 element를 List<UiGettyModel>로 바꾸게 됨
class GettyRepositoryImpl @Inject constructor(private val gettyRemoteDataSource: GettyRemoteDataSource): GettyRepository {
override suspend fun getGettyItem(url: String): Result<List<UiGettyModel>>{
return Result.success(gettyRemoteDataSource.getGettyItem(url).toUiGettyModel())
}
}
5) datasource에서 getGettyItem을 부르게 됨
interface GettyRemoteDataSource {
suspend fun getGettyItem(url: String): Element
}
6) datasourceImpl에서 gettyService.getGettyItem을 부르게 됨
class GettyRemoteDataSourceImpl @Inject constructor(private val gettyService: GettyService): GettyRemoteDataSource {
override suspend fun getGettyItem(url: String): Element {
return gettyService.getGettyItem(url)
}
}
7) url에 해당하는 HTML 를 파싱해서 Element 값을 들고 옴
class GettyService @Inject constructor() {
suspend fun getGettyItem(url : String): Element {
return withContext(Dispatchers.IO) {
Jsoup.connect(url).get()
}
}
}
8) 따라서, Element 값을 -> UiGettyModel로 얻는 순서
- service에서 url에 요청하여 해당하는 element 값을 얻게 됨 (HTML 전체)
- datasource를 거쳐 repositoryImpl에서 받아온 Element 값을 GettyMapper를 통해 List<UiGettyModel>로 바꿈
fun Element.toUiGettyModel() : List<UiGettyModel> {
val elements: List<Element> = this.select("img").drop(2) // 2번째부터 보여지게.
return elements.map {
UiGettyModel(
src = it.attr("src"),
title = it.attr("alt"),
link = "https://www.gettyimages.com${it.parentNode()!!.parentNode()!!.parentNode()!!.attr("href")}"
)
}
}
- css 컬렉터를 이용하여 img Element 리스트를 얻어옴
- 각 img 태그 안에 해당하는 이미지 src(링크), alt(이름), link(상세링크) 순으로 읽어옴
이미지 데이터 리스트 설정
MainActivity에서 getImage true 값을 collect함.
GetAdapter에서 viewModel에서 받아온 gettyList값을 submitList하게 됨.
fun stateObserver(){
lifecycleScope.launch {
viewModel.getImage.collect(){
if(it){
adapter = GettyAdapter { link ->
if (URLUtil.isValidUrl(link)) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
} else {
Toast.makeText(this@MainActivity, "유효하지 않은 링크입니다.", Toast.LENGTH_LONG).show()
}
}
binding.rvCalendar.adapter = adapter
adapter.submitList(viewModel.gettyList.value!!)
}
else {
Toast.makeText(this@MainActivity, "이미지를 찾지 못했습니다.", Toast.LENGTH_LONG).show()
}
}
}
}
GridLayoutManager를 이용하여 컬럼 3개(각각 이미지, 이미지 이름)를 표시하였습니다.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_calendar"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="20dp"
android:visibility="@{vm.gettyList.size() > 0 ? View.VISIBLE : View.GONE}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/li_my_page_inform_email_nickname_view"
app:spanCount="3" />
<?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:bind="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="200dp"
android:padding="5dp">
<ImageView
android:id="@+id/iv_getty_image"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginTop="20dp"
android:clipToOutline="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_getty_image_title"
android:layout_width="match_parent"
android:layout_height="28dp"
android:layout_marginTop="20dp"
android:fontFamily="@font/pretendard_regular"
android:gravity="center"
android:textSize="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv_getty_image" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
MVVM 패턴 적용
- Model, View, ViewModel 순서로 짚어보겠습니다.
Model - UiGettyModel
data class UiGettyModel (
val title : String, // 이미지 제목
val src : String, // 이미지 src 랑크
val link : String // 상세페이지 링크
)
즉, 위와 같이 짜여져 있는 데이터 구조를 사용하여 특정 '비즈니스로직을 수행'하는 컴포넌트들의 집합이 바로 '모델'이라 할 수 있는 것이다.
View - MainActivity, GettyAdapter
@AndroidEntryPoint
class MainActivity : BindingActivity<ActivityMainBinding>(R.layout.activity_main) {
private lateinit var adapter: GettyAdapter
private val viewModel : MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpUi()
stateObserver()
}
fun setUpUi(){
binding.vm = this@MainActivity.viewModel
}
fun stateObserver(){
lifecycleScope.launch {
viewModel.getImage.collect(){
if(it){
adapter = GettyAdapter { link ->
if (URLUtil.isValidUrl(link)) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
} else {
Toast.makeText(this@MainActivity, "유효하지 않은 링크입니다.", Toast.LENGTH_LONG).show()
}
}
binding.rvCalendar.adapter = adapter
adapter.submitList(viewModel.gettyList.value!!)
}
else {
Toast.makeText(this@MainActivity, "이미지를 찾지 못했습니다.", Toast.LENGTH_LONG).show()
}
}
}
}
}
package com.rsupport.mobile1.test.activity.presentation.view
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.rsupport.mobile1.test.R
import com.rsupport.mobile1.test.activity.domain.model.UiGettyModel
import com.rsupport.mobile1.test.activity.presentation.ext.setImageToUrl
import com.rsupport.mobile1.test.databinding.ItemGettyBinding
class GettyAdapter (
private val onItemClick: (String) -> Unit
) : ListAdapter<UiGettyModel, GettyAdapter.ViewHolder>(
ItemDiffCallback<UiGettyModel>(
onItemsTheSame = { old, new -> old == new },
onContentsTheSame = { old, new -> old == new }
)
) {
class ViewHolder(
private val binding : ItemGettyBinding,
private val onItemClick: (String) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
private val imgSrc: ImageView = binding.ivGettyImage
private val imgTitle: TextView = binding.tvGettyImageTitle
fun onBind(
item : UiGettyModel
) {
imgTitle.text = item.title
imgSrc.setImageToUrl(item.src)
binding.root.setOnClickListener {
if (item.link != null) {
onItemClick(item.link)
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding =
ItemGettyBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding, onItemClick)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val calendarItem = getItem(position)
holder.onBind(calendarItem)
}
}
class ItemDiffCallback<T : Any>(
val onItemsTheSame: (T, T) -> Boolean,
val onContentsTheSame: (T, T) -> Boolean
) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(
oldItem: T,
newItem: T
): Boolean = onItemsTheSame(oldItem, newItem)
override fun areContentsTheSame(
oldItem: T,
newItem: T
): Boolean = onContentsTheSame(oldItem, newItem)
}
사용자에게 직접 보일 수 있고 상호작용 할 수 있는 컴포넌트가 바로 view
사용자들에게 데이터를 보여주기도 하며, 사용자들로부터 데이터를 입력받음
ViewModel - MainViewModel
@HiltViewModel
class MainViewModel @Inject constructor(
private val gettyItemUseCase: GetGettyItemUseCase
): ViewModel() {
// 현재 page
private var _page = MutableLiveData<Int>(1)
val page: LiveData<Int> get() = _page
// 검색 단어
private var _phrase = MutableLiveData<String>("collaboration")
val phrase: LiveData<String> get() = _phrase
// 이미지 속성 정보들
private var _gettyList = MutableLiveData<List<UiGettyModel>>()
val gettyList: LiveData<List<UiGettyModel>> get() = _gettyList
// 이미지 불러오기
private var _getImage = MutableEventFlow<Boolean>()
val getImage: EventFlow<Boolean> get() = _getImage
// 검색 단어 Text
var inputPhrase = MutableLiveData<String>("")
init{
doTask()
}
// 크롤링 하기
fun doTask() {
loadWebPage(buildGettyImagesUrl(_page.value!!, _phrase.value!!))
}
fun loadWebPage(url: String) {
Timber.e("hello? ${url}")
viewModelScope.launch(Dispatchers.IO) {
gettyItemUseCase(url).onSuccess {
_gettyList.postValue(it)
_getImage.emit(true)
}.onFailure {
_getImage.emit(false)
}
}
}
fun buildGettyImagesUrl(page: Int, phrase: String): String {
val baseUrl = "https://www.gettyimages.com/photos/collaboration"
val assetType = "image"
val sort = "best"
return "$baseUrl?assettype=$assetType&sort=$sort&phrase=${phrase.replace(" ", "+")}&page=$page"
}
fun onClickPreviousPage()
{
_page.value = _page.value?.minus(1)
doTask()
}
fun onClickNextPage(){
_page.value = _page.value?.plus(1)
doTask()
}
fun onClickSearch(){
Timber.e("hello ${inputPhrase.value}")
if(inputPhrase.value!!.isNotEmpty()) {
_page.value = 1 // 1로 초기화
loadWebPage(buildGettyImagesUrl(_page.value!!, inputPhrase.value!!))
}
}
}
View를 통해 데이터를 입력받게 된다면, 이를 ViewModel에서 수신
반대로 ViewModel에서 View에 데이터를 내보내줄 수도 있는 것
ViewModel을 통해 데이터 CRUD를 수행하기 위해 Model에 데이터를 보내주기도하며, 반대로 받기도 한다.
MVVM의 장점
1. dataBinding + bindingAdapter를 사용하여 ui코드의 관심사를 또 다시 분리할 수 있다.
2. ViewModel을 재사용함으로써 비즈니스로직의 중복을 줄일 수 있다.
첫 페이지 설정
첫 페이지는, gettyImages의 collaboration에 대한 이미지 결과 1페이지(과제링크)로 설정해두었습니다.
1) ViewModel LiveData 변수 값 설정 - 시작 페이지는 1Page 부터, 검색 단어는 Collaboration으로
// 현재 page
private var _page = MutableLiveData<Int>(1)
val page: LiveData<Int> get() = _page
// 검색 단어
private var _phrase = MutableLiveData<String>("collaboration")
val phrase: LiveData<String> get() = _phrase
2) xml databinding
<TextView
android:id="@+id/tv_getty_page_title"
android:layout_width="match_parent"
android:layout_height="28dp"
android:fontFamily="@font/pretendard_regular"
android:gravity="center"
android:text="@{vm.gettyList.size()>0 ? String.valueOf(vm.page)+` of `+ String.valueOf(vm.gettyList.size()): `0 of 0` }"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv_next"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="10dp"
android:onClick="@{() -> vm.onClickNextPage()}"
android:src="@drawable/icon_chevron_right_period"
android:visibility="@{vm.gettyList.size() == vm.page || vm.gettyList.size() == 0 ? View.GONE : View.VISIBLE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
구현 기능
- 단어 검색에 따른 gettyImages 이미지 정보 표시
- Page 이동 기능
- 이미지 내용 클릭 시, 상세 페이지(웹 링크)로 이동
사용 라이브러리
- Jsoup, Glide, EventFlow, LiveData, Coroutine, Hilt
Package
📂 data
┣ 📂api
┣ 📂mapper
┣ 📂repository.remote
📂 domain
┣ 📂model
┣ 📂repository
┣ 📂usecase
📂 presentation
┣ 📂base
┣ 📂di
┣ 📂ext
┣ 📂module
┣ 📂util
┣ 📂view
'Android' 카테고리의 다른 글
[Android] 단위 테스트 코드 작성 (0) | 2024.05.24 |
---|---|
[Android] FCM (Firebase Cloude Messaging Service) 구현 과정 (0) | 2024.05.21 |
[Android] 네이버 부스트캠프 웹·모바일과 함께하는 부캠라디오 (0) | 2024.04.22 |
[GDG Android] Fireside Chat 취업 관련 토크 (0) | 2024.03.24 |
[GDG Korea Android] 신입으로 취업하기 (0) | 2024.03.24 |