Simple&Natural

Coroutine + Jsoup + MVVM 을 이용한 안드로이드 뉴스 앱 만들기 본문

안드로이드(Android)/연구 및 프로젝트

Coroutine + Jsoup + MVVM 을 이용한 안드로이드 뉴스 앱 만들기

Essense 2020. 9. 18. 05:46
728x90

 

 

 

 

전체적인 앱 아키텍처는 구글 안드로이드에서 공식적으로 권장하고 있는 구조를 따르고 있다.

 

 

과거 과제로 만들었던 앱을 새로운 구조를 학습과 패턴 적용을 연습하는 목적으로 리팩토링한 내용을 기록한 것입니다.

전체 프로젝트 소스는 다음 링크에서 볼 수 있습니다.

 

github.com/unnamedw/NewsReaders-Task

 

unnamedw/NewsReaders-Task

Contribute to unnamedw/NewsReaders-Task development by creating an account on GitHub.

github.com

 

 

 

 

 

 

 

[앱 설명]

구글 뉴스 기사를 크롤링하여 보여주는 앱입니다.

클릭하면 상세 기사를 볼 수 있으며 화면을 당겨 새로운 기사로 업데이트 할 수 있습니다.

작업이 완료된 데이터부터 순차적으로 보여줍니다. 

 

데이터가 많고 파싱하는 작업이 오래 걸리기 때문에 프로그레스 바가 오래 떠있는 것을 볼 수 있는데 이 부분에 대한 개선 방법은 아래 부분에서 다루고 있습니다.

 

 

 

 

 

 


 

 

[NewsListActivity]

(DI 와 DataBinding 은 아직 적용하기 전입니다)

ViewModel의 기본 생성자는 파라미터를 넘겨받을 수가 없는 형태입니다. 따라서 viewmodel 생성 시 파라미터가 필요할 경우 ViewModelFactory 를 이용하여 넘겨주게 되는데 만약 파라미터로 Context만 넘겨주는 경우엔 팩토리를 이용하지 않고 AndroidViewModel을 이용해 생성할 수도 있습니다.

 

코틀린 사용 시 ViewModelProvider 대신 by viewModels 라는 property delegate을 이용해 뷰모델을 생성할 수 있습니다. 이를 사용하기 위해서는 Gradle에 다음 의존성을 추가하셔야 합니다.

implementation "androidx.activity:activity-ktx:1.1.0"

 

뷰모델의 데이터 갱신 여부를 액티비티에서 observe를 통해 관찰할 수 있는데 이는 Jetpack에서 제공하고 있는 데이터 바인딩을 통해 처리하면 불필요한 코드를 없애고 보다 깔끔하게 작성할 수 있습니다. 

 

class NewsListActivity : AppCompatActivity() {
//    lateinit var newsList: List<News>
    private val adapter by lazy { NewsListAdapter(this, listOf()) }
    private val remoteDataSource = RemoteNewsData()
    private val repository = NewsRepository(remoteDataSource)
    private val viewModel: NewsListViewModel by viewModels { NewsListViewModelFactory(repository) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = DataBindingUtil.setContentView<ActivityNewsListBinding>(this, R.layout.activity_news_list)
            .apply {
                lifecycleOwner = this@NewsListActivity
            }

        // 뉴스 세팅
        viewModel.newsList.observe(this, Observer {
            adapter.items = it
            adapter.notifyDataSetChanged()
        })
        viewModel.progress.observe(this, Observer {
            layout_refresh.isRefreshing = it
        })

        // RecyclerView
        rv_news_list.layoutManager = LinearLayoutManager(this)
        rv_news_list.adapter = adapter

        // 뉴스 기사를 클릭하면 웹뷰를 통해 기사를 띄워준다.
        adapter.setOnNewsClickListener(OnNewsClickListener {
            val intent = Intent(this, NewsViewActivity::class.java)
            intent.putExtra("url", adapter.items[it].url)
            startActivity(intent)
        })

        // 페이징 처리
        /*rv_news_list.addOnScrollListener(object: RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
                val totalItemCount = (recyclerView.adapter as NewsListAdapter).itemCount
                val lastItemPosition = totalItemCount-1

                // 스크롤이 마지막 아이템에 도달하면 로그를 출력
                if (lastVisibleItemPosition == lastItemPosition) {
                    Log.d("mainactivity", "도착! lastVisibleItemPosition=$lastVisibleItemPosition itemTotalCount=$totalItemCount")
                } else {
                    Log.d("mainactivity", "lastVisibleItemPosition=$lastVisibleItemPosition itemTotalCount=$totalItemCount")
                }
            }
        })*/

        // 화면을 당겨서 새로고침 처리
        layout_refresh.setOnRefreshListener {
            Log.d("MyRefresh", "새로고침 당김")
            viewModel.updateNewsData()
        }
        
    }

 

 

 

[NewsListViewModel]

뉴스 데이터를 받아와 View에 데이터를 전달해주는 역할을 합니다.

만약 단순히 데이터 리스트의 형태로 받아 온다면 모든 데이터의 작업이 완료될 때까지 UI를 업데이트 할 수 없기 때문에 데이터를 Flow<News> 형태로 받아오고 Collect를 통해 News 데이터가 완료될 때마다 ViewModel 내부의 LiveData를 업데이트하여 View에 넘겨줍니다. 

 

각 데이터들은 은닉성을 위해 private으로 설정해주고 getter을 정의하여 넘겨주는 방식으로 구현합니다.

또한 viewModelScope를 사용하여 viewModel 파괴 시 자동으로 진행중인 작업이 취소될 수 있게 하였습니다.

코루틴의 굉장히 편리한 기능 중 하나로써 만약 스레드를 사용하거나 GlobalScope을 사용하게 되면 사용하지 않는 작업들을 일일이 직접 취소해주어야 하는 번거로움이 있습니다.

 

class NewsListViewModel(
    private val repository: NewsRepository
): ViewModel() {
//    private val job = Job()
//    private val scope = CoroutineScope(Dispatchers.Main + job)

    private val _newsList = MutableLiveData<MutableList<News>>()
    val newsList: LiveData<MutableList<News>>
        get() = _newsList

    private val _progress = MutableLiveData<Boolean>()
    val progress: LiveData<Boolean>
        get() = _progress

    init {
        updateNewsData()
    }

    @ExperimentalCoroutinesApi
    fun updateNewsData() {
        _progress.value = true
        _newsList.value = mutableListOf()
        viewModelScope.launch {
            val data = repository.getAllNews()
            data
                .onCompletion {
                    _progress.value = false
                }
                .collect {
                Log.d("MyFlow", it.title)
                _newsList.value = _newsList.value?.apply { add(it) } ?: mutableListOf(it)
            }
        }
    }
}

 

 

[NewsListViewModelFactory]

 

/**
 * Parameter 가 필요할 경우
 * */
@Suppress("UNCHECKED_CAST")
class NewsListViewModelFactory(
    private val repository: NewsRepository
): ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return NewsListViewModel(repository) as T
    }
}

 

 

[DataSource]

미리 정해 둔 데이터 규격을 인터페이스로 정의합니다.

Repository와 RemoteDataSource에서 이 DataSource를 구현하게 됩니다. 

 

interface DataSource {
    fun getAllNews():Flow<News>
}

 

 

[NewsRepository]

여기서는 단순히 RemoteDataSource에 있는 데이터를 받아 ViewModel로 넘겨주는 작업을 담당합니다.

하지만 본래 Repository 패턴 사용의 핵심적인 목적은 SSOT(Single Source Of Truth) 원칙을 지키기 위해서입니다.

또한 사용 목적에 따라 데이터 Mapping 등의 작업을 수행하기도 하지만 여기서는 단일 데이터 소스를 사용하고 있고 딱히 Mapper도 필요하지 않으므로 단순히 데이터 전달의 매개만을 담당하는 것입니다.

 

그러므로 Repository는 구현하지 않아도 무방합니다.

 

/**
 * */
class NewsRepository(
    private val remoteNewsData: RemoteNewsData
): DataSource {
    @ExperimentalCoroutinesApi
    override fun getAllNews(): Flow<News> {
        return remoteNewsData.getAllNews()
    }

}

 

 

[RemoteNewsData]

직접적으로 뉴스 데이터를 가져오는 작업을 담당하는 부분입니다. flow builder를 통해 flow를 만들고 emit으로 뉴스 데이터가 완료될 때마다 하나 씩 즉시 반환해줍니다.

만약 Flow를 이용하지 않는다면 모든 데이터 로딩이 끝나기 전까지는 UI 업데이트 작업을 할 수가 없습니다.

뉴스기사의 전체 목록을 가져오기 전까지는 아무 것도 볼 수 없는 상태인 것이죠.

 

출처: https://medium.com/better-programming/asynchronous-data-loading-with-new-kotlin-flow-233f85ae1d8b

 

 

 

 

 

flowOn(Dispatchers.IO)은 해당 플로우를 MainThread 이외의 다른 스레드에서 처리하도록 도와주는데 이 경우 코루틴의 DIspatcher는 미리 정의된 ThreadPool을 이용합니다.

 

class RemoteNewsData(

): DataSource {

    @ExperimentalCoroutinesApi
    override fun getAllNews(): Flow<News> = flow {
//        val newsList = mutableListOf<News>()
        val googleRssUrl = "https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko"
        val newsUrls = getUrlsFromRss(googleRssUrl)
        for (newsUrl in newsUrls) {
//            getNewsFromUrl(newsUrl)?.let { newsList.add(it) }
            getNewsFromUrl(newsUrl)?.let { emit(it) }
        }
    }.flowOn(Dispatchers.IO)

    // 기사 url 로부터 News 를 추출
    private fun getNewsFromUrl(newsUrl: String): News? {
        try {
            val doc = Jsoup.connect(newsUrl).get().head() // 오래걸림 (0.4초 이상)
            val title = doc.select("meta[property=og:title]").first()?.attr("content")
                ?: doc.select("title").first().html()
            val image = doc.select("meta[property=og:image]").first()?.attr("content") ?: ""
            val description = doc.select("meta[property=og:description]").first()?.attr("content")
                ?: doc.select("description").first()?.text()
                ?: doc.select("meta[name=description]").attr("content")
            return News(
                newsUrl,
                title,
                image,
                description
            )
        } catch (e: Exception) {
            e.printStackTrace()
            return null
        }
    }

    // rss 페이지로부터 기사 url 을 추출
    private fun getUrlsFromRss(rssUrl: String): MutableList<String> {
        val newsUrls = mutableListOf<String>()
        // XML 파싱을 위한 XML Parser
        val parser = Jsoup.connect(rssUrl).get().let { document ->
            val rssXML = document.html()
            val rssXmlStrReader = StringReader(rssXML)
            val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware }
            factory.newPullParser().apply { setInput(rssXmlStrReader) }
        }
        // 기사 url 추출
        var eventType = parser.eventType
        var isNewsAddress = false
        while (eventType != XmlPullParser.END_DOCUMENT) {
            when (eventType) {
                XmlPullParser.START_TAG ->
                    if (parser.depth>3 && parser.name=="link") {
                        isNewsAddress = true
                    }
                XmlPullParser.TEXT ->
                    if (isNewsAddress) {
                        newsUrls.add(parser.text)
                        isNewsAddress = false
                    }
            }
            eventType = parser.next()
        }
        return newsUrls
    }

}

 

위 코드는 아직 개선할 여지가 남아있습니다.

바로 데이터 작업이 하나씩 실행된다는 것입니다.

예를 들어 30개 정도의 기사를 Parsing하는 작업이 필요한 상황에서 각각의 작업이 순차적으로 실행됩니다.

한 개의 기사를 처리하는 데 평균 1초가 걸린다고 가정하면 총 30초의 시간이 소요되는 문제가 발생합니다.

 

여러 개의 기사를 동시에 처리할 수는 없을까요?

 

override fun getAllNews(): Flow<News> = flow {
        val Time = measureTimeMillis {
            val googleRssUrl = "https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko"
            val newsUrls = getUrlsFromRss(googleRssUrl)
            val newsAsync = mutableListOf<Deferred<News?>>()
            for (newsUrl in newsUrls) {
                CoroutineScope(Dispatchers.Default).async { getNewsFromUrl(newsUrl) }.also { newsAsync.add(it) }
            }
            for (newsDeferred in newsAsync) {
                newsDeferred.await()?.let { emit(it) }
            }
        }
        Log.d("MyTime", "걸린시간: $Time ms")
    }.flowOn(Dispatchers.IO)

 

개선된 코드입니다.

 

원래의 작업이 22초가 걸린 반면 위의 코드는 평균 약 5초 정도로 줄어들었습니다.

Coroutine의 async를 이용하여 모든 작업을 각각 비동기로 시작하고, await을 이용하여 받아오는 순서대로 emit을 해주면 됩니다.

 

만약 하나의 작업이 중간에서 시간을 지체할 경우 전체 작업이 지연될 수가 있는데 이때는 타임아웃을 걸어주어

일정 시간을 초과하는 작업이 발생할 경우 다음 작업으로 넘어가도록 설정하면 됩니다.

제 경우에는 1.5초를 설정했습니다.

 

private fun getNewsFromUrl(newsUrl: String): News? {
        try {
            val doc by lazy {
                Jsoup.connect(newsUrl)
                    .timeout(1500) // 타임아웃 설정
                    .get()
                    .head() }
                    
                    ...
                    
                    

 

이 정도면 다 끝나지 않았나 싶은데 사실 아직 한 가지 더 개선할 수 있는 부분이 있습니다.

바로 Dispatcher에 관련된 부분입니다.

Async의 Dispatcher를 Default -> IO 로 변경하게 되면 다시 시간이 3초 정도로 줄어드는데

이에 대해서는 따로 글로 다루어보았습니다.

 

sandn.tistory.com/110

 

Coroutine의 IO Dispatcher와 Default Dispatcher의 차이

코루틴에서는 Dispatcher를 이용하여 다양하게 Scope를 지정할 수 있다. 특히 비동기 백그라운드 작업을 수행할 때 가장 많이 쓰이는 것이 IO 와 Default Dispatcher인데 정확히 이 두 Dispatcher의 차이가 무�

sandn.tistory.com

 

 

 

 

여기까지 대략적인 코드 작성이 끝났습니다.


 

마치며

 

초기의 앱은 거의 모든 로직들이 Activity 에 모여 있었고 데이터 작업은 AsyncTask 를 이용하였으나 리팩토링 과정을 거치며 Activity에 모여있던 로직을 View-ViewModel-Model 구조로 분리하고 비동기 데이터 처리는 Coroutine으로 변경하였습니다. 위 프로젝트는 간단한 구조이기에 MVVM으로 분리함으로써 얻는 이점을 분명히 느끼지는 못하지만 많은 구조 패턴들과 마찬가지로 결국 목적은 최대한 컴포넌트 간의 결합을 약화시켜 확장, 유지보수 및 테스트를 쉽게 만드는 것입니다. 

 

액티비티에서 observe하는 부분을 데이터 바인딩으로, 객체를 생성해서 넘겨주는 부분을 의존성 주입으로 처리하시면 더 깔끔하고 효율적인 코드를 만드실 수 있을 거라 생각합니다.

 

궁금한 점이 있으시면 댓글 남겨주시고 부족하지만 끝까지 봐주셔서 감사합니다 :)

 

 

 

 

 

728x90