일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 프래그먼트
- 2022년 6월 일상
- 막내의막무가내 SQL
- 막내의막무가내 안드로이드
- 막내의막무가내 코틀린 안드로이드
- 막내의 막무가내 알고리즘
- flutter network call
- 프로그래머스 알고리즘
- 부스트코스에이스
- 안드로이드
- 막내의막무가내 프로그래밍
- 막내의막무가내 코볼 COBOL
- 막내의막무가내 코틀린
- 막내의막무가내 목표 및 회고
- 주엽역 생활맥주
- 부스트코스
- 막내의막무가내 플러터
- Fragment
- 안드로이드 sunflower
- 막내의막무가내 일상
- 주택가 잠실새내
- 막내의막무가내 안드로이드 에러 해결
- 안드로이드 Sunflower 스터디
- 막내의막무가내 알고리즘
- 막내의막무가내 플러터 flutter
- 막내의 막무가내
- 막내의막무가내
- 막내의막무가내 rxjava
- 막내의막무가내 안드로이드 코틀린
- 막무가내
- Today
- Total
막내의 막무가내 프로그래밍 & 일상
[안드로이드] 안드로이드 MVVM + RxJava2 + Koin + Jsoup 정리 (충남대학교 컴퓨터공학과 공지사항 토이 프로젝트) 본문
[안드로이드] 안드로이드 MVVM + RxJava2 + Koin + Jsoup 정리 (충남대학교 컴퓨터공학과 공지사항 토이 프로젝트)
막무가내막내 2020. 6. 3. 17:04
[2021-04-07 업데이트]
매번 공지사항 들어가서 보기 귀찮아서 공지사항 앱을 1차로 만들어봤습니다.
추후 시간이 될때 클릭시 웹이 아닌 앱내에서 웹뷰로 띄워주거나 커뮤니티 기능도 추가해볼까 합니다. -> 업데이트 완료!
MVVM 아키텍처를 적용 및 학습을 위해 대학교 공지사항 토이프로젝트를 만들어봤었는데 그것에 대해 복습 및 정리 해볼려고 합니다.
API 를 사용한 것이 아니라 Jsoup 을 통해 크롤링하여 데이터를 가져온 것이기 때문에 레트로핏은 사용하지 않았습니다.
저도 배워가는 입장이라 수정이 필요한 부분을 지적해주시면 감사하겠습니다.
그래서 전체적인 프로젝트 구조는 다음과 같습니다.
MVVM 의 장점과 단점은 다음과 같습니다.
장점: 뷰와 모델간의 의존성이 없고 MVP 패턴처럼 V-VM이 1:1 관계가 아닌 독립적이기 때문에 둘 사이의 의존성도 해결된다. 데이터 바인딩을 통해 V-VM 의존성도 해결할 수 있습니다.
딘점 : VIewModel 설계가 어렵다.
전체 프로젝트는 다음 링크에서 볼 수 있습니다.
https://github.com/mtjin/cnu-notice-app-releaseversion
Retrofit2 를 사용한 프로젝트는 다음과 같습니다.
(이 프로젝트는 RxJava2는 사용하지 않았습니다.)
https://github.com/mtjin/CoronaKorea
1. 먼저 BaseActivity, BaseFragment, BaseViewModel 을 만들어 주었습니다. 데이터바인딩, 공통함수 등을 관리하기 쉽게 해줍니다.
[BaseActivity]
package com.mtjin.cnunoticeapp.base
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import io.reactivex.disposables.CompositeDisposable
abstract class BaseActivity<B : ViewDataBinding>(
@LayoutRes val layoutId: Int
) : AppCompatActivity() {
lateinit var binding: B
private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, layoutId)
binding.lifecycleOwner = this
}
protected fun showToast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
override fun onDestroy() {
super.onDestroy()
compositeDisposable.clear()
}
}
[BaseFragment]
viewModel 을 제네릭으로 넘기는데 이 부분은 안넘기고 그냥 프래그먼트에서 해결해도 됩니다.
Koin 사용시 val viewModel: BusinessNoticeViewModel by viewModel() 이런식으로 Base말고 실제 프래그먼트에서 해주면 됩니다.
실제 프래그먼트에서는 init()에 뷰를 초기화하는 작업을 override 해주도록 합니다.
package com.mtjin.cnunoticeapp.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
abstract class BaseFragment<B : ViewDataBinding, VM : BaseViewModel>(
@LayoutRes val layoutId: Int
) : Fragment() {
protected lateinit var binding: B
protected abstract val viewModel: VM
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, layoutId, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = this
init()
}
abstract fun init()
protected fun showToast(msg: String) =
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
[BaseViewModel]
RxJava2 관리를 하기 쉽게 CompositeDiasposable() 을 사용하고 onCleraed() 될 때 해제해주도록 합니다.
package com.mtjin.cnunoticeapp.base
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable
abstract class BaseViewModel : ViewModel() {
protected val compositeDisposable = CompositeDisposable()
private val _isLoading = MutableLiveData<Boolean>(false)
val isLoading: LiveData<Boolean> get() = _isLoading
override fun onCleared() {
compositeDisposable.dispose()
super.onCleared()
}
protected fun showProgress() {
_isLoading.value = true
}
protected fun hideProgress() {
_isLoading.value = false
}
}
2. Koin 사용을 위한 Application 과 Module 을 정의해줍니다.
의존성 주입을 사용함으로써 결합도를 낮춰주고 유연성 및 확장성은 높여주어 개발도 편리하고 유지보수하기 쉽게 만들어주므로 적극 사용하도록 합니다.
[KoinApplicaion]
package com.mtjin.cnunoticeapp.di
import android.app.Application
import com.mtjin.cnunoticeapp.BuildConfig
import com.mtjin.cnunoticeapp.module.*
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
class KoinApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
if (BuildConfig.DEBUG) {
androidLogger()
} else {
androidLogger(Level.NONE)
}
androidContext(this@KoinApplication)
modules(
repositoryModule,
localDataModule,
remoteDataModule,
viewModelModule,
apiModule
)
}
}
}
[Modules]
각각의 모듈들을 정의해줍니다.
package com.mtjin.cnunoticeapp.module
import org.koin.core.module.Module
import org.koin.dsl.module
val apiModule: Module = module {
//파싱할일만 있어서 일단은 안쓸듯
}
package com.mtjin.cnunoticeapp.module
import androidx.room.Room
import com.mtjin.cnunoticeapp.data.bachelor.source.local.BachelorNoticeDao
import com.mtjin.cnunoticeapp.data.bachelor.source.local.BachelorNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.bachelor.source.local.BachelorNoticeLocalDataSourceImpl
import com.mtjin.cnunoticeapp.data.business.source.local.BusinessNoticeDao
import com.mtjin.cnunoticeapp.data.business.source.local.BusinessNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.business.source.local.BusinessNoticeLocalDataSourceImpl
import com.mtjin.cnunoticeapp.data.employ.source.local.EmployNoticeDao
import com.mtjin.cnunoticeapp.data.employ.source.local.EmployNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.employ.source.local.EmployNoticeLocalDataSourceImpl
import com.mtjin.cnunoticeapp.data.favorite.source.local.FavoriteNoticeDao
import com.mtjin.cnunoticeapp.data.favorite.source.local.FavoriteNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.favorite.source.local.FavoriteNoticeLocalDataSourceImpl
import com.mtjin.cnunoticeapp.data.general.source.local.GeneralNoticeDao
import com.mtjin.cnunoticeapp.data.general.source.local.GeneralNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.general.source.local.GeneralNoticeLocalDataSourceImpl
import com.mtjin.cnunoticeapp.database.NoticeDatabase
import org.koin.core.module.Module
import org.koin.dsl.module
val localDataModule: Module = module {
single<BachelorNoticeLocalDataSource> { BachelorNoticeLocalDataSourceImpl(get()) }
single<GeneralNoticeLocalDataSource> { GeneralNoticeLocalDataSourceImpl(get()) }
single<BusinessNoticeLocalDataSource> { BusinessNoticeLocalDataSourceImpl(get()) }
single<EmployNoticeLocalDataSource> { EmployNoticeLocalDataSourceImpl(get()) }
single<FavoriteNoticeLocalDataSource> { FavoriteNoticeLocalDataSourceImpl(get()) }
single<BachelorNoticeDao> { get<NoticeDatabase>().bachelorNoticeDao() }
single<GeneralNoticeDao> { get<NoticeDatabase>().generalNoticeDao() }
single<BusinessNoticeDao> { get<NoticeDatabase>().businessNoticeDao() }
single<EmployNoticeDao> { get<NoticeDatabase>().employsNoticeDao() }
single<FavoriteNoticeDao> { get<NoticeDatabase>().favoriteNoticeDao() }
single<NoticeDatabase> {
Room.databaseBuilder(
get(),
NoticeDatabase::class.java, "Notice.db"
).build()
}
}
package com.mtjin.cnunoticeapp.module
import com.mtjin.cnunoticeapp.data.bachelor.source.remote.BachelorNoticeRemoteDataSource
import com.mtjin.cnunoticeapp.data.bachelor.source.remote.BachelorNoticeRemoteDataSourceImpl
import com.mtjin.cnunoticeapp.data.business.source.remote.BusinessNoticeRemoteDataSource
import com.mtjin.cnunoticeapp.data.business.source.remote.BusinessNoticeRemoteDataSourceImpl
import com.mtjin.cnunoticeapp.data.employ.source.remote.EmployNoticeRemoteDataSource
import com.mtjin.cnunoticeapp.data.employ.source.remote.EmployNoticeRemoteDataSourceImpl
import com.mtjin.cnunoticeapp.data.general.source.remote.GeneralNoticeRemoteDataSource
import com.mtjin.cnunoticeapp.data.general.source.remote.GeneralNoticeRemoteDataSourceImpl
import org.koin.core.module.Module
import org.koin.dsl.module
val remoteDataModule: Module = module {
single<BachelorNoticeRemoteDataSource> { BachelorNoticeRemoteDataSourceImpl() }
single<GeneralNoticeRemoteDataSource> { GeneralNoticeRemoteDataSourceImpl() }
single<BusinessNoticeRemoteDataSource> { BusinessNoticeRemoteDataSourceImpl() }
single<EmployNoticeRemoteDataSource> { EmployNoticeRemoteDataSourceImpl() }
}
package com.mtjin.cnunoticeapp.module
import com.mtjin.cnunoticeapp.data.bachelor.source.BachelorNoticeRepository
import com.mtjin.cnunoticeapp.data.bachelor.source.BachelorNoticeRepositoryImpl
import com.mtjin.cnunoticeapp.data.business.source.BusinessNoticeRepository
import com.mtjin.cnunoticeapp.data.business.source.BusinessNoticeRepositoryImpl
import com.mtjin.cnunoticeapp.data.employ.source.EmployNoticeRepository
import com.mtjin.cnunoticeapp.data.employ.source.EmployNoticeRepositoryImpl
import com.mtjin.cnunoticeapp.data.favorite.source.FavoriteNoticeRepository
import com.mtjin.cnunoticeapp.data.favorite.source.FavoriteNoticeRepositoryImpl
import com.mtjin.cnunoticeapp.data.general.source.GeneralNoticeRepository
import com.mtjin.cnunoticeapp.data.general.source.GeneralNoticeRepositoryImpl
import org.koin.core.module.Module
import org.koin.dsl.module
val repositoryModule: Module = module {
single<BachelorNoticeRepository> { BachelorNoticeRepositoryImpl(get(), get()) }
single<GeneralNoticeRepository> { GeneralNoticeRepositoryImpl(get(), get()) }
single<BusinessNoticeRepository> { BusinessNoticeRepositoryImpl(get(), get()) }
single<EmployNoticeRepository> { EmployNoticeRepositoryImpl(get(), get()) }
single<FavoriteNoticeRepository> { FavoriteNoticeRepositoryImpl(get()) }
}
package com.mtjin.cnunoticeapp.module
import com.mtjin.cnunoticeapp.views.bachelor.BachelorNoticeViewModel
import com.mtjin.cnunoticeapp.views.business.BusinessNoticeViewModel
import com.mtjin.cnunoticeapp.views.employ.EmployNoticeViewModel
import com.mtjin.cnunoticeapp.views.favorite.FavoriteViewModel
import com.mtjin.cnunoticeapp.views.general.GeneralNoticeViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.dsl.module
val viewModelModule: Module = module {
viewModel { BachelorNoticeViewModel(get()) }
viewModel { GeneralNoticeViewModel(get()) }
viewModel { BusinessNoticeViewModel(get()) }
viewModel { EmployNoticeViewModel(get()) }
viewModel { FavoriteViewModel(get()) }
}
3. Utils 클래스들을 정의 합니다. (데이터바인딩어댑터 , 무한스크롤 클래스, 네트워크 상태확인을 넣어줬습니다.)
[NetworkManager]
package com.mtjin.cnunoticeapp.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
class NetworkManager(private val context: Context) {
fun checkNetworkState(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val nw = connectivityManager.activeNetwork ?: return false
val actNw = connectivityManager.getNetworkCapabilities(nw) ?: return false
return when {
actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
else -> false
}
} else {
val nwInfo = connectivityManager.activeNetworkInfo ?: return false
return nwInfo.isConnected
}
}
}
[무한스크롤]
package com.mtjin.cnunoticeapp.utils
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
abstract class EndlessRecyclerViewScrollListener : RecyclerView.OnScrollListener {
// The minimum amount of items to have below your current scroll position
// before loading more.
private var visibleThreshold = 5
// The current offset index of data you have loaded
private var currentPage = 0
// The total number of items in the dataset after the last load
private var previousTotalItemCount = 0
// True if we are still waiting for the last set of data to load.
private var loading = true
// Sets the starting page index
private val startingPageIndex = 0
var mLayoutManager: RecyclerView.LayoutManager
constructor(layoutManager: LinearLayoutManager) {
mLayoutManager = layoutManager
}
constructor(layoutManager: GridLayoutManager) {
mLayoutManager = layoutManager
visibleThreshold *= layoutManager.spanCount
}
constructor(layoutManager: StaggeredGridLayoutManager) {
mLayoutManager = layoutManager
visibleThreshold *= layoutManager.spanCount
}
private fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
var maxSize = 0
for (i in lastVisibleItemPositions.indices) {
if (i == 0) {
maxSize = lastVisibleItemPositions[i]
} else if (lastVisibleItemPositions[i] > maxSize) {
maxSize = lastVisibleItemPositions[i]
}
}
return maxSize
}
// This happens many times a second during a scroll, so be wary of the code you place here.
// We are given a few useful parameters to help us work out if we need to load some more data,
// but first we check if we are waiting for the previous load to finish.
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
var lastVisibleItemPosition = 0
val totalItemCount = mLayoutManager.itemCount
if (mLayoutManager is StaggeredGridLayoutManager) {
val lastVisibleItemPositions =
(mLayoutManager as StaggeredGridLayoutManager).findLastVisibleItemPositions(null)
// get maximum element within the list
lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions)
} else if (mLayoutManager is GridLayoutManager) {
lastVisibleItemPosition =
(mLayoutManager as GridLayoutManager).findLastVisibleItemPosition()
} else if (mLayoutManager is LinearLayoutManager) {
lastVisibleItemPosition =
(mLayoutManager as LinearLayoutManager).findLastVisibleItemPosition()
}
// If the total item count is zero and the previous isn't, assume the
// list is invalidated and should be reset back to initial state
if (totalItemCount < previousTotalItemCount) {
currentPage = startingPageIndex
previousTotalItemCount = totalItemCount
if (totalItemCount == 0) {
loading = true
}
}
// If it’s still loading, we check to see if the dataset count has
// changed, if so we conclude it has finished loading and update the current page
// number and total item count.
if (loading && totalItemCount > previousTotalItemCount) {
loading = false
previousTotalItemCount = totalItemCount
}
// If it isn’t currently loading, we check to see if we have breached
// the visibleThreshold and need to reload more data.
// If we do need to reload some more data, we execute onLoadMore to fetch the data.
// threshold should reflect how many total columns there are too
if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
currentPage++
onLoadMore(currentPage, totalItemCount, view)
loading = true
}
}
// Call this method whenever performing new searches
fun resetState() {
currentPage = startingPageIndex
previousTotalItemCount = 0
loading = true
}
// Defines the process for actually loading more data based on page
abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?)
}
[데이터바인딩 어댑터]
보통 사용하는 클래스 별로 나눠서 파일을 만들어주지만 규모가 큰 프로젝트가 아니므로 묶어주었습니다.
리사이클러뷰에 아이템을 세팅하거나 무한스크롤관련 로직을 바인딩어댑터로 처리했습니다.
package com.mtjin.cnunoticeapp.utils
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import com.mtjin.cnunoticeapp.data.business.BusinessNotice
import com.mtjin.cnunoticeapp.data.employ.EmployNotice
import com.mtjin.cnunoticeapp.data.favorite.FavoriteNotice
import com.mtjin.cnunoticeapp.data.general.GeneralNotice
import com.mtjin.cnunoticeapp.views.bachelor.BachelorAdapter
import com.mtjin.cnunoticeapp.views.bachelor.BachelorNoticeViewModel
import com.mtjin.cnunoticeapp.views.business.BusinessAdapter
import com.mtjin.cnunoticeapp.views.business.BusinessNoticeViewModel
import com.mtjin.cnunoticeapp.views.employ.EmployAdapter
import com.mtjin.cnunoticeapp.views.employ.EmployNoticeViewModel
import com.mtjin.cnunoticeapp.views.employ.FavoriteAdapter
import com.mtjin.cnunoticeapp.views.general.GeneralAdapter
import com.mtjin.cnunoticeapp.views.general.GeneralNoticeViewModel
@BindingAdapter("setBachelorItems")
fun RecyclerView.setBachelorAdapterItems(items: List<BachelorNotice>?) {
with((adapter as BachelorAdapter)) {
this.clear()
items?.let { this.addItems(it) }
}
}
@BindingAdapter("setGeneralItems")
fun RecyclerView.setGeneralAdapterItems(items: List<GeneralNotice>?) {
with((adapter as GeneralAdapter)) {
this.clear()
items?.let { this.addItems(it) }
}
}
@BindingAdapter("setBusinessItems")
fun RecyclerView.setBusinessAdapterItems(items: List<BusinessNotice>?) {
with((adapter as BusinessAdapter)) {
this.clear()
items?.let { this.addItems(it) }
}
}
@BindingAdapter("setEmployItems")
fun RecyclerView.setEmployAdapterItems(items: List<EmployNotice>?) {
with((adapter as EmployAdapter)) {
this.clear()
items?.let { this.addItems(it) }
}
}
@BindingAdapter("setFavoriteItems")
fun RecyclerView.setFavoriteAdapterItems(items: List<FavoriteNotice>?) {
with((adapter as FavoriteAdapter)) {
this.clear()
items?.let { this.addItems(it) }
}
}
@BindingAdapter("bachelorEndlessScroll")
fun RecyclerView.setBachelorEndlessScroll(
viewModel: BachelorNoticeViewModel
) {
val scrollListener =
object : EndlessRecyclerViewScrollListener(layoutManager as LinearLayoutManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
viewModel.requestMoreNotice(totalItemsCount + 1)
}
}
this.addOnScrollListener(scrollListener)
}
@BindingAdapter("generalEndlessScroll")
fun RecyclerView.setGeneralEndlessScroll(
viewModel: GeneralNoticeViewModel
) {
val scrollListener =
object : EndlessRecyclerViewScrollListener(layoutManager as LinearLayoutManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
viewModel.requestMoreNotice(totalItemsCount + 1)
}
}
this.addOnScrollListener(scrollListener)
}
@BindingAdapter("businessEndlessScroll")
fun RecyclerView.setBusinessEndlessScroll(
viewModel: BusinessNoticeViewModel
) {
val scrollListener =
object : EndlessRecyclerViewScrollListener(layoutManager as LinearLayoutManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
viewModel.requestMoreNotice(totalItemsCount + 1)
}
}
this.addOnScrollListener(scrollListener)
}
@BindingAdapter("employEndlessScroll")
fun RecyclerView.setEmployEndlessScroll(
viewModel: EmployNoticeViewModel
) {
val scrollListener =
object : EndlessRecyclerViewScrollListener(layoutManager as LinearLayoutManager) {
override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
viewModel.requestMoreNotice(totalItemsCount + 1)
}
}
this.addOnScrollListener(scrollListener)
}
4. 뷰를 정의 해줍니다.
MainActivity와 BachelorFragment 만 예시로 보겠습니다.
[MainActivity]
Jeptack bottom navigation 을 사용했습니다. 밑 사이트에 설명이 잘되어있습니다!
https://medium.com/@maryangmin/navigation-components-in-android-jetpack-1-introduction-e38442f70f
package com.mtjin.cnunoticeapp.views.main
import android.os.Bundle
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.firebase.analytics.FirebaseAnalytics
import com.mtjin.cnunoticeapp.R
import com.mtjin.cnunoticeapp.base.BaseActivity
import com.mtjin.cnunoticeapp.databinding.ActivityMainBinding
class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
private var mFirebaseAnalytics: FirebaseAnalytics? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mFirebaseAnalytics = FirebaseAnalytics.getInstance(this)
initNavigation()
}
private fun initNavigation() {
val navController = findNavController(R.id.main_nav_host)
binding.mainBottomNavigation.setupWithNavController(navController)
}
}
<?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:tools="http://schemas.android.com/tools">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.MainActivity">
<FrameLayout
android:id="@+id/main_fl_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/main_bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<fragment
android:id="@+id/main_nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/bottom_nav_graph" />
</FrameLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/main_bottom_navigation"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/colorWhiteBlue"
app:itemIconTint="@color/colorWhite"
app:itemTextColor="@color/colorWhite"
app:layout_behavior="tech.thdev.app.view.BottomNavigationBehavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/main_fl_container"
app:menu="@menu/bottom_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
[BachelorFragment]
뷰들을 초기화하는 로직이 들어가 있습니다. 그리고 리사이클러뷰의 클릭 이벤트를 받아서 처리해줍니다.
xml 에서는 로딩이나 아이템 세팅 같은 것을 데이터바인딩 처리를 해주었습니다.
package com.mtjin.cnunoticeapp.views.bachelor
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import com.mtjin.cnunoticeapp.R
import com.mtjin.cnunoticeapp.base.BaseFragment
import com.mtjin.cnunoticeapp.constants.EXTRA_NOTICE_SAVE
import com.mtjin.cnunoticeapp.constants.TAG_DIALOG_EVENT
import com.mtjin.cnunoticeapp.data.favorite.FavoriteNotice
import com.mtjin.cnunoticeapp.databinding.FragmentBachelorBinding
import com.mtjin.cnunoticeapp.utils.NetworkManager
import com.mtjin.cnunoticeapp.views.dialog.DialogAddFragment
import org.koin.androidx.viewmodel.ext.android.viewModel
class BachelorNoticeFragment :
BaseFragment<FragmentBachelorBinding, BachelorNoticeViewModel>(R.layout.fragment_bachelor) {
private lateinit var noticeAdapter: BachelorAdapter
override val viewModel: BachelorNoticeViewModel by viewModel()
override fun init() {
binding.vm = viewModel
initAdapter()
val networkManager: NetworkManager? = context?.let { NetworkManager(it) }
if (!networkManager?.checkNetworkState()!!) {
showToast(getString(R.string.network_err_toast))
}
viewModel.requestNotice()
}
private fun initAdapter() {
noticeAdapter = BachelorAdapter(itemClick = { item ->
Intent(
Intent.ACTION_VIEW,
Uri.parse(item.link)
).run(this::startActivity)
},
numClick = {
val bundle = Bundle()
bundle.putParcelable(EXTRA_NOTICE_SAVE, FavoriteNotice(it.num, it.title, it.link))
val dialog: DialogAddFragment = DialogAddFragment().getInstance()
dialog.arguments = bundle
activity?.supportFragmentManager?.let { fragmentManager ->
dialog.show(
fragmentManager,
TAG_DIALOG_EVENT
)
}
})
binding.rvBachelors.adapter = noticeAdapter
}
}
<?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/tools">
<data>
<import type="android.view.View" />
<variable
name="vm"
type="com.mtjin.cnunoticeapp.views.bachelor.BachelorNoticeViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_bachelors"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
bind:bachelorEndlessScroll="@{vm}"
bind:setBachelorItems="@{vm.noticeList}" />
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{vm.isLoading() ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
[BachelorAdapter]
어댑터 또한 데이터바인딩 처리를 해주었습니다.
클릭리스너는 고차함수를 사용해 프래그먼트 쪽에서 처리하게 해줍니다.
package com.mtjin.cnunoticeapp.views.bachelor
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.cnunoticeapp.R
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import com.mtjin.cnunoticeapp.databinding.ItemBachelorBinding
class BachelorAdapter(
private val itemClick: (BachelorNotice) -> Unit,
private val numClick: (BachelorNotice) -> Unit
) :
RecyclerView.Adapter<BachelorAdapter.ViewHolder>() {
private val items: ArrayList<BachelorNotice> = ArrayList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BachelorAdapter.ViewHolder {
val binding: ItemBachelorBinding = DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.item_bachelor,
parent,
false
)
val viewHolder = ViewHolder(binding)
binding.root.setOnClickListener {
itemClick(items[viewHolder.adapterPosition])
}
binding.bachelorTvNum.setOnClickListener {
numClick(items[viewHolder.adapterPosition])
}
return viewHolder
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: BachelorAdapter.ViewHolder, position: Int) {
items[position].let {
holder.bind(it)
}
}
class ViewHolder(private val binding: ItemBachelorBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: BachelorNotice) {
binding.item = item
binding.executePendingBindings()
}
}
fun addItems(items: List<BachelorNotice>) {
this.items.addAll(items)
notifyDataSetChanged()
}
fun clear() {
this.items.clear()
notifyDataSetChanged()
}
}
<?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">
<data>
<variable
name="item"
type="com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="50dp">
<TextView
android:id="@+id/bachelor_tv_num"
android:layout_width="60dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="@{item.num}"
android:textColor="@color/colorBlack"
android:textSize="12sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/colorBlack"
app:layout_constraintEnd_toStartOf="@id/bachelor_tv_content"
app:layout_constraintStart_toEndOf="@id/bachelor_tv_num"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bachelor_tv_content"
android:layout_width="0dp"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="2"
android:padding="8dp"
android:text="@{item.title}"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/bachelor_tv_num"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/colorBlack"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/bachelor_tv_num" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
5. ViewModel 을 정의 해주도록 합니다.
[BacherlorNoticeViweModel]
Repository를 매개변수로 받습니다. (Koin을 통해 가져올 것 입니다.)
LiveData 를 사용해줍니다. (데이터의 상태를 Observe 해서 로직을 구현하기 위해)
BaseViewModel() 에 있는 compositeDisposable 을 사용하여 데이터를 가져오는 로직을 시작하도록 합니다.
그냥 가져오기와 페이징을 통해 가져오는 두가지 함수가 있습니다.
package com.mtjin.cnunoticeapp.views.bachelor
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.mtjin.cnunoticeapp.base.BaseViewModel
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import com.mtjin.cnunoticeapp.data.bachelor.source.BachelorNoticeRepository
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
class BachelorNoticeViewModel(private val bachelorRepository: BachelorNoticeRepository) :
BaseViewModel() {
private val _noticeList = MutableLiveData<ArrayList<BachelorNotice>>()
val noticeList: LiveData<ArrayList<BachelorNotice>> get() = _noticeList
fun requestNotice() {
compositeDisposable.add(
bachelorRepository.requestNotice()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { showProgress()}
.doAfterTerminate { hideProgress()}
.subscribe({
_noticeList.value = it as ArrayList<BachelorNotice>?
}, {
Log.d(TAG, "" + it)
})
)
}
fun requestMoreNotice(offset: Int) {
compositeDisposable.add(
bachelorRepository.requestMoreNotice(offset)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { showProgress() }
.doAfterTerminate { hideProgress() }
.subscribe({ notices ->
val pagingNoticeList = _noticeList.value
pagingNoticeList?.addAll(notices)
_noticeList.value = pagingNoticeList
}, {
Log.d(TAG, "" + it)
})
)
}
companion object {
const val TAG = "BachelorNoticeViewModel"
}
}
6. DataSource(Model) 을 만들어줍니다. 먼저 Repository 를 살펴보도록 하겠습니다.
인터페이스와 Impl class 로 나눠 구현해주었습니다.
인터넷 연결이 안된 경우는 Local 에서 데이터를 불러오도록 하고
[캐싱전략] 만약 인터넷 연결이 된 경우에는 Local 과 Remote 에서 동시에 불러옵니다. (캐싱된 데이터가 외부에서 불러오는 것보다 빠르므로 더 빠르게 데이터를 보게 해줄 수 있습니다.)
Remote에서 불러온 데이터는 Local DB 에 Insert 해주도록 합니다.
[BachelorNoticeRepository]
package com.mtjin.cnunoticeapp.data.bachelor.source
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Flowable
import io.reactivex.Single
interface BachelorNoticeRepository {
fun requestNotice(): Flowable<List<BachelorNotice>>
fun requestMoreNotice(offset: Int): Single<List<BachelorNotice>>
}
package com.mtjin.cnunoticeapp.data.bachelor.source
import android.util.Log
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import com.mtjin.cnunoticeapp.data.bachelor.source.local.BachelorNoticeLocalDataSource
import com.mtjin.cnunoticeapp.data.bachelor.source.remote.BachelorNoticeRemoteDataSource
import io.reactivex.Flowable
import io.reactivex.Single
class BachelorNoticeRepositoryImpl(
private val bachelorNoticeRemoteDataSource: BachelorNoticeRemoteDataSource,
private val bachelorNoticeLocalDataSource: BachelorNoticeLocalDataSource
) : BachelorNoticeRepository {
override fun requestNotice(): Flowable<List<BachelorNotice>> {
return bachelorNoticeLocalDataSource.getNotices()
.onErrorReturn { listOf() }
.flatMapPublisher { cachedMovies ->
if (cachedMovies.isEmpty()) {
requestBachelorNotice()
.toFlowable()
.onErrorReturn {
listOf()
}
} else {
val local = Single.just(cachedMovies)
val remote = requestBachelorNotice()
.onErrorResumeNext {
local
}
Single.concat(local, remote)
}
}
}
private fun requestBachelorNotice(): Single<List<BachelorNotice>> {
return bachelorNoticeRemoteDataSource.requestNotice()
.flatMap {
bachelorNoticeLocalDataSource.insertNotice(it)
.andThen(Single.just(it))
}
}
override fun requestMoreNotice(offset: Int): Single<List<BachelorNotice>> {
return bachelorNoticeRemoteDataSource.requestMoreNotice(offset)
}
}
7. LocalDataSource 를 구현합니다. Room 을 사용하며 ROOM DB, Dao 와 엔티티 모델 클래스도 구현해야합니다.
[Room DB]
package com.mtjin.cnunoticeapp.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import com.mtjin.cnunoticeapp.data.bachelor.source.local.BachelorNoticeDao
import com.mtjin.cnunoticeapp.data.business.BusinessNotice
import com.mtjin.cnunoticeapp.data.business.source.local.BusinessNoticeDao
import com.mtjin.cnunoticeapp.data.employ.EmployNotice
import com.mtjin.cnunoticeapp.data.employ.source.local.EmployNoticeDao
import com.mtjin.cnunoticeapp.data.favorite.FavoriteNotice
import com.mtjin.cnunoticeapp.data.favorite.source.local.FavoriteNoticeDao
import com.mtjin.cnunoticeapp.data.general.GeneralNotice
import com.mtjin.cnunoticeapp.data.general.source.local.GeneralNoticeDao
@Database(
entities = [BachelorNotice::class, GeneralNotice::class, BusinessNotice::class, EmployNotice::class, FavoriteNotice::class],
version = 1,
exportSchema = false
)
abstract class NoticeDatabase : RoomDatabase() {
abstract fun bachelorNoticeDao(): BachelorNoticeDao
abstract fun generalNoticeDao(): GeneralNoticeDao
abstract fun businessNoticeDao(): BusinessNoticeDao
abstract fun employsNoticeDao(): EmployNoticeDao
abstract fun favoriteNoticeDao(): FavoriteNoticeDao
}
참고로 지금까지 모든 것들이 그러하지만 생성은 Koin Module 에 다음과 같이 정의해놨습니다.
single<NoticeDatabase> {
Room.databaseBuilder(
get(),
NoticeDatabase::class.java, "Notice.db"
).build()
}
[Entitiy Model class]
테이블 네임과 컬럼명 프라이머리 키 등을 정의해 줄 수 있습니다.
package com.mtjin.cnunoticeapp.data.bachelor
import androidx.room.Entity
import androidx.room.PrimaryKey
/*
* num 값을 주요키 id 로 사용할려 했으나 공지 라는 이름의 아이디 떄문에 중복값이 발생하여 사용불가하다.
* */
@Entity(tableName = "bachelor")
data class BachelorNotice(
@PrimaryKey
val num: String,
val title: String,
var link: String
)
[BachelorDao]
SQL 을 정의 해줍니다.
package com.mtjin.cnunoticeapp.data.bachelor.source.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Completable
import io.reactivex.Single
@Dao
interface BachelorNoticeDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertNotice(notice: List<BachelorNotice>): Completable
@Query("SELECT * FROM bachelor ORDER BY 1 DESC")
fun getNotices(): Single<List<BachelorNotice>>
}
[BachelorLocalDataSource]
로컬 데이터 소스 족의 로직을 처리합니다. 삽입과 데이터를 가져오는 것을 구현했습니다.
package com.mtjin.cnunoticeapp.data.bachelor.source.local
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Completable
import io.reactivex.Single
interface BachelorNoticeLocalDataSource {
fun insertNotice(notice: List<BachelorNotice>) : Completable
fun getNotices(): Single<List<BachelorNotice>>
}
package com.mtjin.cnunoticeapp.data.bachelor.source.local
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Completable
import io.reactivex.Single
class BachelorNoticeLocalDataSourceImpl(private val bachelorNoticeDao: BachelorNoticeDao) :
BachelorNoticeLocalDataSource {
override fun insertNotice(notice: List<BachelorNotice>): Completable {
return bachelorNoticeDao.insertNotice(notice)
}
override fun getNotices(): Single<List<BachelorNotice>> {
return bachelorNoticeDao.getNotices()
}
}
8. RemoteDataSource 를 구현합니다. 네트워크 통신을 통해 외부 API 데이터를 가져오도록 합니다. 저는 이 프로젝트에서는 크롤링을 사용했으므로 Retrofit2 가 아닌 Jsoup을 통해 학교 웹 데이터를 크롤링 해왔습니다.
크롤링한 데이터를 Observable 형태로 만들어 레포지토리에 전달해주도록 했습니다.
package com.mtjin.cnunoticeapp.data.bachelor.source.remote
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Single
interface BachelorNoticeRemoteDataSource {
fun requestNotice(): Single<List<BachelorNotice>>
fun requestMoreNotice(offset: Int): Single<List<BachelorNotice>>
}
package com.mtjin.cnunoticeapp.data.bachelor.source.remote
import com.mtjin.cnunoticeapp.data.bachelor.BachelorNotice
import io.reactivex.Observable
import io.reactivex.Single
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
class BachelorNoticeRemoteDataSourceImpl : BachelorNoticeRemoteDataSource {
override fun requestNotice(): Single<List<BachelorNotice>> {
return Single.fromObservable(
Observable.create {
val bachNoticeList: ArrayList<BachelorNotice> = ArrayList()
val doc: Document =
Jsoup.connect("https://computer.cnu.ac.kr/computer/notice/bachelor.do")
.get() // Base Url
val contentElements: Elements =
doc.select("div[class=b-title-box]").select("a") // title, link
val idElements: Elements = doc.select("td[class=b-num-box]") // id값
for ((i, elem) in contentElements.withIndex()) {
bachNoticeList.add(
BachelorNotice(
idElements[i].text(),
elem.text(),
"https://computer.cnu.ac.kr/computer/notice/bachelor.do" + elem.attr("href")
)
)
}
it.onNext(bachNoticeList)
it.onComplete()
}
)
}
override fun requestMoreNotice(offset: Int): Single<List<BachelorNotice>> {
return Single.fromObservable(
Observable.create {
val bachNoticeList: ArrayList<BachelorNotice> = ArrayList()
val doc: Document =
Jsoup.connect("https://computer.cnu.ac.kr/computer/notice/bachelor.do?mode=list&&articleLimit=10&article.offset=$offset")
.get() // Base Url
val contentElements: Elements =
doc.select("div[class=b-title-box]").select("a") // title, link
val idElements: Elements = doc.select("td[class=b-num-box]") // id값
for ((i, elem) in contentElements.withIndex()) {
if (idElements[i].text() != "공지") { // 공지는 매 페이지마다 있으므로 중복제거
bachNoticeList.add(
BachelorNotice(
idElements[i].text(),
elem.text(),
"https://computer.cnu.ac.kr/computer/notice/bachelor.do" + elem.attr(
"href"
)
)
)
}
}
it.onNext(bachNoticeList)
it.onComplete()
}
)
}
}
P.S 다이어로그 프래그먼트입니다. 예, 아니오 로직의 간단한 프래그먼트입니다. (여러군데에서 재사용하며 두가지 로직을 갖고 있습니다. 상황에 따라 질문 Text 도 변경되게 해야했습니다.)
package com.mtjin.cnunoticeapp.views.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import com.mtjin.cnunoticeapp.R
import com.mtjin.cnunoticeapp.constants.EXTRA_NOTICE_DELETE
import com.mtjin.cnunoticeapp.constants.EXTRA_NOTICE_SAVE
import com.mtjin.cnunoticeapp.data.favorite.FavoriteNotice
import com.mtjin.cnunoticeapp.views.favorite.FavoriteViewModel
import kotlinx.android.synthetic.main.fragment_dialog_add.view.*
import org.koin.androidx.viewmodel.ext.android.viewModel
class DialogAddFragment : DialogFragment(), View.OnClickListener {
private val viewModel: FavoriteViewModel by viewModel()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_dialog_add, container, false)
processBundle(view)
return view
}
private fun processBundle(view: View) {
val bundle = arguments
val notice = bundle?.getParcelable<FavoriteNotice>(EXTRA_NOTICE_SAVE)
when (bundle?.getString(EXTRA_NOTICE_DELETE, "")) {
EXTRA_NOTICE_DELETE -> {
view.dialog_tv_question.text = getString(R.string.dialog_favorite_delete_text)
view.dialog_tv_yes.setOnClickListener {
notice?.let { notice ->
viewModel.delete(notice)
showToast(getString(R.string.delete_success_text))
dismiss()
}
}
view.dialog_tv_no.setOnClickListener {
dismiss()
}
}
else -> {
view.dialog_tv_yes.setOnClickListener {
notice?.let { notice ->
viewModel.insert(notice)
showToast(getString(R.string.add_success_text))
dismiss()
}
}
view.dialog_tv_no.setOnClickListener {
dismiss()
}
}
}
}
fun showToast(msg: String) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
fun getInstance(): DialogAddFragment {
return DialogAddFragment()
}
override fun onClick(p0: View?) {
dismiss()
}
}
[결과]
완벽하진 않지만 나름대로 MVVM 아키텍처를 적용해서 만들어봤습니다. Jsoup은 처음 사용하는거라 원인 모를 에러가 많아서 그 부분에서 시간이 조금 걸렸던 것 같습니다. (HTML id 구조 이상하게 된 부분이 한 군데 있더라고요)
처음 안드로이드 시작할 때 MVC 로 막 짜던 때보다 유지보수면에서도 좋은 것 같습니다.
댓글과 공감은 큰 힘이 됩니다. 감사합니다.!!!
'안드로이드 > 코틀린 & 아키텍처 & Recent' 카테고리의 다른 글
[안드로이드] 안드로이드 카카오톡 로그인 구현 및 MVVM 적용 (feat. SingleLiveEvent) (0) | 2020.06.22 |
---|---|
[안드로이드] ViewModel 에서 context 필요로 할 때 해결방법 (0) | 2020.06.22 |
[안드로이드] 코틀린 커스텀 다이얼로그 프래그먼트 (Custom Dialog Fragment) (3) | 2020.05.23 |
[안드로이드] Jetpack Bottom Navigation View refactor name 관련 주의할 점 (0) | 2020.05.23 |
[안드로이드] * What went wrong:Execution failed for task ':app:kaptDebugKotlin'.> A fail (7) | 2020.05.22 |