- Android - RSS 피드와 API 통신으로 뉴스 데이터 가져오기2023년 12월 01일 20시 32분 00초에 업로드 된 글입니다.작성자: Moonsu99
환경
- OS - Mac OS 13.5.2
- Tools - Android Studio Iguana | 2023.2.1 Canary 5
- Language - kotlin
- android version - 12
- tartgetSDK - 33
- minSDK - 28
사용한 라이브러리
- Jsoup - 크롤링
- Glide - 이미지
- tikxml - retrofit 반환타입이 xml 일때
- retrofit - 네트워크 통신
- lottie - 애니메이션
RSS | 한경닷컴
RSS, 성공을 부르는 습관 한국경제신문 한경닷컴
www.hankyung.com
해당 사이트에서 RSS 를 제공해준다.
나는 공모전을 위해 정치 관련 뉴스정보만 크롤링 했다.
Mac을 사용하시는 분들은 safari로 들어가시지 말고 chrome으로 접속하시는 것을 추천한다.
safari는 Rss reader을 지원하지 않는다.
item안에 title,image,link에 대한 정보가 들어있는 것을 확인했으니 이제 코드를 작성해보겠다.
우선 내가 선택한 방식은 다음과 같다.
CardView와 RecyclerView를 통해 간략한 정보를 제공한 다음, 클릭 시 해당 뉴스에 맞는 주소로 바로가기를 제공하는 것이다.
item_news.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView 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" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="15dp" android:background="@color/white" app:cardCornerRadius="10dp" app:cardElevation="7dp"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <ImageView android:id="@+id/ImageView_News" android:layout_width="0dp" android:layout_height="0dp" android:scaleType="centerCrop" app:layout_constraintDimensionRatio="2:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:background="@color/black" /> <TextView android:id="@+id/TextView_Title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="10dp" android:paddingStart="15dp" android:paddingEnd="15dp" android:paddingBottom="15dp" android:fontFamily="@font/pretendard" android:textColor="@color/black" android:textSize="18sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/ImageView_News" tools:text="Article Title." /> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.cardview.widget.CardView>
이렇게 작성한다면 다음과 같은 결과를 기대할 수 있다.
그 다음. 해당 아이템을 RecyclerView로 받아와야 하므로 Activity나 Fragment을 구성해준다.
fragment_news.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout 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" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.appcompat.widget.SearchView android:id="@+id/searchTextInputEditText" android:layout_width="match_parent" android:layout_height="wrap_content" android:imeOptions="actionSearch" android:inputType="text" android:maxLines="1" tools:text="테스트" tools:ignore="MissingConstraints" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/newsRecyclerView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/searchTextInputEditText" tools:listitem="@layout/item_news" /> <com.airbnb.lottie.LottieAnimationView android:id="@+id/notFountView" android:layout_width="0dp" android:layout_height="0dp" android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/searchTextInputEditText" app:lottie_autoPlay="true" app:lottie_loop="true" app:lottie_rawRes="@raw/not_found" /> </androidx.constraintlayout.widget.ConstraintLayout>
여기까지 정상적으로 작성했다면 다음과 같은 결과를 기대할 수 있다.
그 다음. 해당 recyclerview를 클릭 했을 때 뉴스link로 intent를 해주는 이벤트를 처리해야 하기 때문에, WebView에 대한 xml파일도 작성해준다.
activity_webview.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"> <WebView android:id="@+id/webView" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:layout_width="0dp" android:layout_height="0dp"/> </androidx.constraintlayout.widget.ConstraintLayout>
이제 xml파일은 작성이 끝났고 kotlin 파일을 작성해서 api통신을 통해 화면에 데이터를 보여주는 것을 구현하면 된다.
나는 다음과 같은 class들을 작성했다.
NewsAdapter.kt: 뉴스 아이템을 리스트 형태로 보여주기 위한 'Adapter' 역할을 처리. 뉴스 데이터를 사용자 인터페이스에 연결하는 역할
NewsFragment.kt: 뉴스 관련 정보를 표시하는 데 사용
NewsModel.kt: 뉴스 데이터를 나타내는 모델(Model)을 정의
NewsRss.kt: 이 파일은 RSS 피드를 파싱하고 처리하는 데 사용. 필요한 데이터를 추출하는 기능
NewsService.kt
WebViewActivity.kt: WebView를 사용하여 웹 콘텐츠를 표시하는 데 사용. 사용자가 뉴스 아이템을 클릭했을 때 해당 뉴스 기사의 웹 페이지를 보여주는 용도
우선 NewsService먼저 살펴보겠다.
NewsService.kt
interface NewsService { @GET("feed/politics") fun politicsNews(): Call<NewsRss> }
feed/politics로 HTTP GET요청.
politicsNews라는 이름의 함수를 정의. Call<NewsRss> 타입의 객체를 반환한다. Call이란? Retrofit에서 비동기적으로 API 호출을 수행하는 데 사용되는 클래스다.
NewsRss는 이 함수가 요청을 보내고, 응답을 받을 때 사용되는 데이터 타입을 나타냄. 이 경우, 응답은 NewsRss 타입으로 파싱될 것이다.
그 다음으로 NewsRss를 살펴보겠다.
NewsRss.kt
@Xml(name = "rss") data class NewsRss( @Element(name = "channel") val channel: RssChannel ) @Xml(name = "channel") data class RssChannel( @PropertyElement(name = "title") val title: String, @Element(name = "item") val items: List<NewsItem>? = null, ) @Xml(name = "item") data class NewsItem( @PropertyElement(name = "title") val title: String? = null, @PropertyElement(name = "link") val link: String? = null, @PropertyElement(name = "image") val imageUrl: String? = null )
TikXML 라이브러리를 사용하여 XML 데이터를 Kotlin 객체로 파싱하기 위한 데이터 클래스다.
1. NewsRss class
- @Xml(name = "rss") 이 Annotation은 `NewsRss` 클래스가 XML의 <rss> 태그에 해당한다는 것을 나타낸다.
- data class NewsRss(...) 는 XML 데이터의 루트 요소를 나타낸다.
- @Element(name = "channel") 는"channel" 태그를 파싱하여 "RssChannel" 타입의 객체로 변환한다. 쉽게 말하자면, <channel> 태그 내의 데이터가 RssChannel 클래스에 매핑된다고 생각하면 된다.
2. RssChannel class
- @Xml(name = "channel") 은 <channel> 태그를 나타내는 것.
- @PropertyElement(name = "title") 은 <title> 태그 내의 텍스트를 RssChannel class의 "title" 속성에 매핑한다.
- @Element(name = "item") 은 여러 <item> 태그들을 NewsItem 객체의 리스트로 파싱한다.
3. NewsItem 클래스은 위와 비슷하므로 설명을 생략하겠다.그 다음으로 NewsItem 객체들을 포함하는 리스트를 받아, 해당 데이터를 NewsModel 객체들로 변환하는 기능을 수행하는 파일인 Model Class를 생성한다.
NewsModel.kt
data class NewsModel( val title: String, val link: String, var imageUrl: String? = null ) fun List<NewsItem>.transform(): List<NewsModel> { return this.map { NewsModel( title = it.title ?: "", link = it.link ?: "", imageUrl = it.imageUrl ) } }
data class는 대부분 이해할 수 있는 코드이므로 설명을 생략하고 tranform에 대해 간략히 설명하겠다.
transform 함수
fun List<NewsItem>.transform(): List<NewsModel>
이 함수는 List<NewsItem>을 List<NewsModel>로 변환하는 것이다.
확장 함수(extension function)로 정의되어 있어, List<NewsItem> 타입의 모든 객체에서 이 함수를 사용할 수 있다.
this.map {...}
this는 List<NewsItem> 객체를 참조한다.
map 함수를 사용하여 리스트의 각 NewsItem 객체를 NewsModel 객체로 변환한다.
NewsModel( title = it.title ?: "", link = it.link ?: "", imageUrl = it.imageUrl )
각 NewsItem 객체의 title, link, imageUrl 속성을 사용하여 NewsModel 객체를 생성한다.
it.title ?: ""와 같은 표현은 it.title이 null인 경우 빈 문자열("")을 반환한다.
이제 그 다음으로 Adapter class를 작성해보겠다.
NewsAdapter.kt
class NewsAdapter(private val onClick: (String) -> Unit) : ListAdapter<NewsModel, NewsAdapter.ViewHolder>( diffUtil ) { inner class ViewHolder(private val binding: ItemNewsBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(item: NewsModel) { binding.TextViewTitle.text = item.title binding.root.setOnClickListener { onClick(item.link) } Glide.with(binding.ImageViewNews.context) .load(item.imageUrl) .into(binding.ImageViewNews) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder( ItemNewsBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(getItem(position)) } companion object { val diffUtil = object : DiffUtil.ItemCallback<NewsModel>() { override fun areItemsTheSame(oldItem: NewsModel, newItem: NewsModel): Boolean { return oldItem === newItem } override fun areContentsTheSame(oldItem: NewsModel, newItem: NewsModel): Boolean { return oldItem == newItem } } } }
NewsAdapter class 선언
class NewsAdapter(private val onClick: (String) -> Unit)
NewsAdapter 클래스의 생성자는 클릭 이벤트를 처리하기 위한 람다 함수를 인자로 받는다. 이 함수는 뉴스 아이템이 클릭되었을 때 실행된다.
: ListAdapter<NewsModel, NewsAdapter.ViewHolder>(diffUtil)
NewsAdapter는 ListAdapter를 상속받으며, NewsModel 타입의 데이터와 ViewHolder 타입을 사용한다. diffUtil은 리스트 아이템이 변경되었을 때 어떻게 업데이트할지 결정하는 데 사용할 것이다.
ViewHolder 내부 class
inner class ViewHolder(private val binding: ItemNewsBinding)
ViewHolder 클래스는 각 뉴스 아이템의 레이아웃을 관리하는 것이다. ItemNewsBinding은 데이터 바인딩을 사용하여 레이아웃과 데이터를 연결처리한다.
fun bind(item: NewsModel)
bind 함수는 NewsModel 객체의 데이터를 레이아웃에 바인딩하는 것이다. 뉴스의 제목과 이미지를 레이아웃의 해당 뷰에 설정한다.
onCreateViewHolder 및 onBindViewHolder 메서드
onCreateViewHolder
새로운 ViewHolder 인스턴스를 생성한다. 각 뉴스 아이템에 대한 레이아웃을 초기화하는 데 사용된다.
onBindViewHolder
각 ViewHolder에 데이터를 바인딩한다.
getItem(position)을 호출하여 해당 위치의 NewsModel 객체를 가져오고, ViewHolder의 bind 메소드를 사용하여 데이터를 뷰에 설정한다.
DiffUtil companion object
companion object {...}
DiffUtil은 리스트의 기존 아이템과 새 아이템을 비교하는 데 사용된다.
이것을 통해 최소한의 업데이트로 리스트를 효율적으로 새로고침할 수 있다.
areItemsTheSame과 areContentsTheSame 메서드는 아이템이 같은지 여부를 결정하는 데 사용된다.성능 최적화를 위해 사용.
Adapter을 작성했으니 이제 Fragment 나 Activity를 작성한다. 나는 Fragment를 선택했다.
NewsFragment.kt
// NewsFragment 클래스 정의, Fragment 상속 class NewsFragment : Fragment() { // 뷰 바인딩, 늦은 초기화 사용 private lateinit var binding: FragmentNewsBinding // 뉴스 어댑터, 늦은 초기화 사용 private lateinit var newsAdapter: NewsAdapter // Retrofit 인스턴스 설정 private val retrofit = Retrofit.Builder() .baseUrl("https://www.hankyung.com/") // 베이스 URL 설정 .addConverterFactory( // TikXml 컨버터 팩토리 사용 TikXmlConverterFactory.create( TikXml.Builder() .exceptionOnUnreadXml(false) // 읽지 않은 XML에 대한 예외 처리 비활성화 .build() ) ).build() // 모든 뉴스 아이템을 저장할 리스트 private var allNewsItems: List<NewsModel> = listOf() // 뷰 생성 시 호출 override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // 뷰 바인딩 초기화 binding = FragmentNewsBinding.inflate(inflater, container, false) // 액션바 숨김 처리 (activity as AppCompatActivity).supportActionBar?.hide() // 뉴스 어댑터 초기화, 클릭 이벤트 처리 newsAdapter = NewsAdapter { url -> // 클릭 시 WebViewActivity 시작 startActivity( Intent(context, WebViewActivity::class.java).apply { putExtra("url", url) // URL 전달 } ) } // Retrofit을 통해 NewsService 인터페이스 생성 val newsService = retrofit.create(NewsService::class.java) // 비동기적으로 뉴스 데이터 요청 newsService.politicsNews().enqueue(object : Callback<NewsRss> { // 요청에 대한 응답 처리 override fun onResponse(call: Call<NewsRss>, response: Response<NewsRss>) { // 응답이 성공적인 경우 if (response.isSuccessful) { response.body()?.let { // 응답으로부터 뉴스 아이템 변환 val newsItems = it.channel.items?.transform() ?: listOf() // 전체 뉴스 아이템 리스트 업데이트 allNewsItems = newsItems // 어댑터에 뉴스 아이템 제출 newsAdapter.submitList(newsItems) // 로그 출력 Log.d("NewsFragment", "뉴스 아이템 로드됨: ${newsItems.size}개") // 샘플 뉴스 아이템 로그 출력 allNewsItems.take(5).forEach { item -> Log.d("NewsFragment", "샘플 뉴스 아이템: ${item.title}") } } } } // 요청 실패 시 처리 override fun onFailure(call: Call<NewsRss>, t: Throwable) { t.printStackTrace() } }) // RecyclerView 설정 binding.newsRecyclerView.apply { layoutManager = LinearLayoutManager(context) // 레이아웃 매니저 설정 adapter = newsAdapter // 어댑터 설정 } // 검색 입력 리스너 설정 binding.searchTextInputEditText.setOnQueryTextListener(object : SearchView.OnQueryTextListener { // 검색 제출 시 호출 override fun onQueryTextSubmit(query: String?): Boolean { query?.let { // 검색 실행 로그 Log.d("NewsFragment", "검색 실행: $it") performSearch(it) // 검색 수행 } return true } // 텍스트 변경 시 호출 (여기서는 사용하지 않음) override fun onQueryTextChange(newText: String?): Boolean { return false } }) // 뷰 반환 return binding.root } // 검색 수행 함수 private fun performSearch(query: String) { // 검색 쿼리 로그 Log.d("NewsFragment", "검색 쿼리: $query") // 제목으로 필터링 val filteredList = allNewsItems.filter { it.title.contains(query, ignoreCase = true) } // 검색 결과 로그 Log.d("NewsFragment", "검색 결과: ${filteredList.size}개 항목 찾음") // 어댑터에 필터링된 리스트 제출 newsAdapter.submitList(filteredList) } }
간단히 말해서, NewsFragment는 사용자에게 뉴스 아이템을 표시하고, 사용자의 클릭 및 검색 상호작용을 처리한다.
마지막으로 인텐트 후 콘텐츠를 표시하는 WebViewActivity를 작성한다.
WebViewActivity.kt
// WebViewActivity 클래스 정의, AppCompatActivity 상속 class WebViewActivity: AppCompatActivity() { // 뷰 바인딩, 늦은 초기화 사용 private lateinit var binding: ActivityWebviewBinding // 액티비티 생성 시 호출 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 뷰 바인딩 초기화 binding = ActivityWebviewBinding.inflate(layoutInflater) // 콘텐츠 뷰 설정 setContentView(binding.root) // 인텐트에서 URL 가져오기 val url = intent.getStringExtra("url") // WebView 클라이언트 설정 binding.webView.webViewClient = WebViewClient() // JavaScript 활성화 binding.webView.settings.javaScriptEnabled = true // URL 유효성 검사 if (url.isNullOrEmpty()) { // URL이 잘못되었을 때 토스트 메시지 표시 Toast.makeText(this, "잘못된 URL 입니다.", Toast.LENGTH_SHORT).show() // 액티비티 종료 finish() } else { // URL이 유효한 경우, 해당 URL 로드 binding.webView.loadUrl(url) } } }
웹 페이지를 로드하고 표시하는 역할.
사용자가 뉴스 아이템을 클릭했을 때, 이 액티비티는 해당 뉴스 아이템의 URL을 로드하여 웹뷰에서 표시한다.
JavaScript가 활성화되어 있어, 동적 웹 페이지도 적절히 처리할 수 있다.
URL이 유효하지 않으면 사용자에게 알림을 주고 액티비티를 종료한다.
해당 파일들을 모두 올바르게 작성 시 다음과 같은 결과를 기대할 수 있다.
실행결과
'Android [ Java, Kotlin ]' 카테고리의 다른 글
Rx란 무엇인가? (0) 2024.02.18 안드로이드 카카오톡 공유 기능 Release버전 관련 이슈 (0) 2024.01.23 안드로이드 프로그래밍 과제(Java) - Radio버튼 & AlertDialog (0) 2023.11.22 안드로이드 프로그래밍 과제(Java) - 이미지뷰어와 필터 (0) 2023.11.17 안드로이드 AAC(Android Architecture Components) 란? (0) 2023.11.17 다음글이 없습니다.이전글이 없습니다.댓글