본문 바로가기
Android

[Android] getty 이미지 가져오기 - 이미지 크롤링

by 박매트 2024. 5. 7.

요구사항

  1. https://www.gettyimages.com/photos/collaboration 의 내용을 리스트 형태로 출력합니다.
  2. 컬럼은 3개입니다.
  3. 이미지를 가져오는데 있어, Getty api 를 사용하는게 아닌, 다른 방식으로의 처리를 부탁드립니다.(오픈소스 사용가능합니다.)
  4. 사용 언어 및 개발 환경은 안드로이드 기기에서 실행이 되는 조건만 만족하면 자유롭게 선택할 수 있습니다.

 

구현 내용

 

이미지 데이터 읽어오는 순서

  • 이미지 데이터를 웹으로 부터 읽어옵니다. 

 

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