봄날은 갔다. 이제 그 정신으로 공부하자

MVVM + koin 최소 샘플 앱 개발 - part3 본문

Android jetpack

MVVM + koin 최소 샘플 앱 개발 - part3

길재의 그 정신으로 공부하자 2020. 12. 7. 07:52

해당 글은 koin, DataBinding, Rxjava, retrofit, Coroutine, PagingLibrary을 사용하여. MVVM 구조로 된 

github 사용자 검색 앱을 개발하는 과정을 기술합니다.

 

해당 글을 작성하는 이유는 위 기술이 적용된 최소 샘플 앱을 만들어 놓고 필요 시 참고하기 위합입니다.

 

해당 글은 최소 샘플앱 개발 세번째 글로 이전 글을 읽지 않은 분들은 이전 글 읽어보시면 해당 글을 이해하는데 더 도움이 됩니다.

als2019.tistory.com/20

 

MVVM + Koin 최소 샘플 앱 개발 - part 1

해당 글은 koin, DataBinding, Rxjava, retrofit, Coroutine, PagingLibrary을 사용하여. MVVM 구조로 된 github 사용자 검색 앱을 개발하는 과정을 기술합니다. 해당 글을 작성하는 이유는 위 기술이 적용된 최..

als2019.tistory.com

https://als2019.tistory.com/21

이번 글에서는 검색으로 가져온 사용자를 Paging library를 사용하여 화면에 보여주는 부분을 처리합니다.

 

앱 구조 정리

샘플앱은 Activity에서 검색어를 입력하고 검색 버튼을 클릭하면 Fragment에 검색 결과 리스트가 보여지는 방식입니다.

검색 결과 더보기가 되어야 하므로 Activity는 검색 트리거만 동작 시키고 실제 검색은 Paging library를 사용하여 Fragment에서 검색하는 구조를 사용하도록 하겠습니다.

 

이러한 동작이 되도록 Activity와 Fragment구조를 아래와 같이 변경합니다.

 

우선 MainViewModel에서 검색 버튼이 눌렸을 동작할 트리거를 등록해줍니다.

val search = MutableLiveData<String>() 

fun onClickSearch(view: View){
    if(!keyword.value.isNullOrEmpty()) {
        search.postValue(keyword.value)
    }
}

MainActivity 트리거를  observe합니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    …

    viewDataBinding.vmMain?.search?.observe(this@MainActivity, Observer {
	    ((vpUserList.adapter as PagerAdapterUserList).getItem(0) as UserListFragment)
        		.search(it)
     })

}

UserListFragment search() 함수를 추가합니다.

fun search(keyword: String){
	…
}

 

Paging Library 추가하기

model 폴더에 Paging Library 관련 class 1개(DataSourceUserManager)를 추가해줍니다.

생성 시 파라미터로 keyword와 API를 입력 받는 Item Key 방식의 DataSource class이지만 딱히 Item key를 사용하지는 않습니다. 만들어보니 DB 연동하는 경우가 아니면 Item Key방식이든 Position 방식이든 Page index든 크게 상관은 없더라구요.

 

loadInitial()함수와 loadAfter()함수에 사용자 검색 api를 추가합니다.

이렇게 구현하면 초기에 loadInitial()함수에서 20개의 사용자를 읽어오고 loadAfter()함수를 통해 다음 페이지의 사용자 20개를 읽어오게 됩니다.

class DataSourceUserManager private constructor(val keyword: String, val github: GithubApiInterface): ItemKeyedDataSource<Int, DataUser>() {

    class Factory(val keyword: String, val github: GithubApiInterface): DataSource.Factory<Int, DataUser>(){
        override fun create(): DataSource<Int, DataUser> {
            return DataSourceUserManager(keyword, github)
        }
    }

    var pageIndex: Int = 1

    override fun loadInitial(
        params: LoadInitialParams<Int>,
        callback: LoadInitialCallback<DataUser>
    ) {
        pageIndex = 1
        github.getUserSearch(keyword, pageIndex)
            .subscribeOn(Schedulers.io())
            .observeOn(Schedulers.single())
            .subscribe({
                callback.onResult(it.items)
            },{
                Log.d(MainViewModel.TAG, "error: ${it.message}")
            })
    }

    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<DataUser>) {
        pageIndex += 1
        github.getUserSearch(keyword, pageIndex)
            .subscribeOn(Schedulers.io())
            .observeOn(Schedulers.single())
            .subscribe({
                callback.onResult(it.items)
            },{
                Log.d(MainViewModel.TAG, "error: ${it.message}")
            })
    }

    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<DataUser>) {

    }

    override fun getKey(item: DataUser): Int {
        return item.id
    }
}

GitHub API 인스턴스를 MainViewModel UserListViewModel 갖고 있도록 아래와 같이 MyDi.kt 파일을 수정합니다.

// MyDi.kt
var viewModelpart = module{
    viewModel{
        MainViewModel()
    }

    viewModel{
        UserListViewModel(get())
    }
}

// UserListViewModel.kt
class UserListViewModel(private val github: GithubApiInterface): BaseViewModel() {
    …
}

이제 UserListViewModel Paging lib 추가합니다.

class UserListViewModel(private val github: GithubApiInterface): BaseViewModel() {

    val config = PagedList.Config.Builder()
        .setInitialLoadSizeHint(5)
        .setPrefetchDistance(5)
        .setPageSize(20)
        .build()

    fun load(keyword: String) : LiveData<PagedList<DataUser>>{
        val dataSourceFactory = DataSourceUserManager.Factory(keyword, github)
        val pagedListBuilder = LivePagedListBuilder(dataSourceFactory, config)
        return pagedListBuilder.setInitialLoadKey(0).build()
    }
}

config는 한페이지에 20개의 item을 읽어오라는 의미이며 리스트의 마지막 5번째 아이템이 화면에 보여지면 loadAfter() 함수를 호출하라는 의미 입니다. View(UserListFragment)에서 ViewModel의 load()함수를 호출하면 keyword와 API를 매개변수로 DataSource를 만들어 리턴합니다.

 

View와 ViewModel 연결

ViewModel을 만들었으면 이제 View(UserListFragment)에 ViewModel을 연결해야 합니다.

List 보여질 layout xml 아래와 같이 간단히 만들어 줍니다.

// item_user.xml
<?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="itemUser"
            type="com.kiljae.mygitsample.common.data.DataUser" />
        <variable
            name="vmUserList"
            type="com.kiljae.mygitsample.viewmodel.UserListViewModel" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp">
        <androidx.appcompat.widget.AppCompatImageView
            android:id="@+id/imvIcon"
            android:layout_width="150dp"
            android:layout_height="150dp"
            drawBackgroundUrl="@{itemUser.avatar_url}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>
        <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/imvName"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:text="@{itemUser.login}"
            android:textSize="18dp"
            android:textStyle="bold"
            android:textColor="@color/colorBlackCherry"
            android:gravity="center"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/imvIcon"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

위 xml을 보면 ImageView에 drawBackgroundUrl이란 부분이 있는데 해당 부분은 BindingAdapter를 사용한 코드로 

Common 폴더에 BindingAdapterImageView object 파일을 만들어 아래와 같이 구현된 코드 입니다.

이미지를 가져오는 부분은 coroutines 사용해서 처리하도록 하였습니다.

object BindingAdapterImageView {

    @BindingAdapter("drawBackgroundUrl")
    @JvmStatic
    fun drawBackgroundUrl(view: ImageView, strUrl: String){
        if(!strUrl.isNullOrEmpty()){
            GlobalScope.launch(Dispatchers.Main) {
                val bitmap = async(Dispatchers.Default){
                    var conn = URL(strUrl).openConnection()
                    conn.connect()
                    BitmapFactory.decodeStream(conn.getInputStream())
                }
                view.setImageBitmap(bitmap.await())
            }
        }
    }

}

여기까지 만들었으면 해당 xml을 List에 보여줄 Adapter class를 만들어줍니다.

Adapter 만들때 PagedList 상속받아야 합니다.

class ListAdapterUser(var vm: UserListViewModel?) : PagedListAdapter<DataUser, RecyclerView.ViewHolder>(DIFF_CALLBACK){

    class UserViewHolder(val binding: ItemUserBinding): RecyclerView.ViewHolder(binding.root)

    companion object{
        private val DIFF_CALLBACK: DiffUtil.ItemCallback<DataUser> =
            object : DiffUtil.ItemCallback<DataUser>(){
                override fun areItemsTheSame(oldItem: DataUser, newItem: DataUser): Boolean {
                    return oldItem.id == newItem.id
                }

                override fun areContentsTheSame(oldItem: DataUser, newItem: DataUser): Boolean {
                    return oldItem.equals(newItem)
                }
            }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        return UserViewHolder(
            DataBindingUtil.inflate(
                inflater,
                R.layout.item_user,
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        (holder as UserViewHolder).binding.run {
            itemUser = getItem(position)
            vmUserList = vm
        }
    }
}

이제 View(UserListFragment) 아까 만들어놓은 search()함수를 아래와 같이 수정합니다.

fun search(keyword: String){
    rcvUserList.adapter = ListAdapterUser(viewDataBinding.vmUserList)
    viewDataBinding.vmUserList?.load(keyword)?.observe(this, Observer {
        (rcvUserList.adapter as ListAdapterUser).submitList(it)
    })
}

여기까지 작업한 결과물을 아래와 같습니다.

 

조금 더 

List에 사용자가 보여지기는 하는데 보기에 좋지는 않네요.

보기 좋도록 fragment_user_list.xml 파일의 RecyclerView 아래와 같이 수정합니다.

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rcvUserList"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
    app:spanCount="2"/>

그럼 이렇게 조금은 그럴듯 하게 보여집니다.

 

Observable을 사용한 Activity & Fragment간 데이터 교환

아직 한가지 작업이 더 남았습니다.

검색은 Fragment의 page List에서 하는데 사용자 수를 보여주는 부분은 Activity에 있습니다.

이부분을 작업해보도록 하겠습니다.

여러가지 방법이 있겠지만 이글은 최대한 다양한 방식을 사용해보는게 목적이므로 뜬금없이 Observable을 사용하도록 하겠습니다.

 

저주받은 네이밍 능력을 최대한 발휘하여 해당 Observable class의 이름을 MyHeader로 명명하고 아래와 같이 구현해줍니다. 필요한 것은 전체 검색 사용자 수, 토탈 페이지 수, 현재 페이지 index 이렇게 3가지 입니다.

MyHeader Observable class이므로 BaseObservable 상속받도록 합니다.

class MyHeader: BaseObservable() {
    @get:Bindable
    var totalUserCount: Int = 0
        set(value){
            field = value
            notifyPropertyChanged(BR.totalUserCount)
        }
}

Page List의 변경 내용이 MainViewModel까지 한번에 전달되어야 하므로 관련 경로에 있는 모든 파일을 수정합니다.

우선 MainViewModel부터 수정합니다.

이제 count LiveData 사용하지 않으므로 삭제해주고 아래와 같이 MyHeader 추가해주고 관련 layout xml 수정해 줍니다.

// MainViewModel
// 삭제 val count = NotNullMutableLiveData<Int>(0)
val header = MyHeader(). // 추가

// activity_main.xml
// 삭제 android:text="@{String.valueOf(vmMain.count)}”
android:text="@{String.valueOf(vmMain.header.totalUserCount)}”. // 변경

MainActivity부터 DataSourceUserManager까지 header 전달되도록 코드를 수정해 줍니다.

// MainActivity.kt
viewDataBinding.vmMain?.search?.observe(this@MainActivity, Observer {
    viewDataBinding.vmMain?.header?.let { header ->
        ((vpUserList.adapter as PagerAdapterUserList).getItem(0) as UserListFragment)
            .search(it, header)
    }
})

// UserListFragment.kt
fun search(keyword: String, header: MyHeader){
    rcvUserList.adapter = ListAdapterUser(viewDataBinding.vmUserList)
    viewDataBinding.vmUserList?.load(keyword, header)?.observe(this, Observer {
        (rcvUserList.adapter as ListAdapterUser).submitList(it)
    })
}

// UserListViewModel.kt
fun load(keyword: String, header: MyHeader) : LiveData<PagedList<DataUser>>{
    val dataSourceFactory = DataSourceUserManager.Factory(keyword, github, header)
    val pagedListBuilder = LivePagedListBuilder(dataSourceFactory, config)
    return pagedListBuilder.setInitialLoadKey(0).build()
}

// DataSourceUserManager.kt
class DataSourceUserManager private constructor(val keyword: String, val github: GithubApiInterface, val header: MyHeader): ItemKeyedDataSource<Int, DataUser>() {

    class Factory(val keyword: String, val github: GithubApiInterface, val header: MyHeader): DataSource.Factory<Int, DataUser>(){
        override fun create(): DataSource<Int, DataUser> {
            return DataSourceUserManager(keyword, github, header)
        }
    }

   …
}

마지막으로 DataSourceUserManager에서 loadInitial() 함수 호출 검색 사용자 수가 입력 되도록 아래 코드를 추가합니다.

override fun loadInitial(
    params: LoadInitialParams<Int>,
    callback: LoadInitialCallback<DataUser>
) {
    pageIndex = 1
    github.getUserSearch(keyword, pageIndex)
        .subscribeOn(Schedulers.io())
        .observeOn(Schedulers.single())
        .subscribe({
             header.totalUserCount = it.total_count. // 추가
            callback.onResult(it.items)
        },{
            Log.d(MainViewModel.TAG, "error: ${it.message}")
        })
}

이렇게 하면 검색 버튼 선택 시 자동으로 전체 검색 사용자 수가 보여집니다.

TextView 숫자가 comma 없이 보여지면 좋지 않으니 이번에 BindingAdapter 아닌 전역 함수를 사용해 검색 사용자 수의 가독성을 높혀줍니다.

object Util {

    @JvmStatic
    fun prettyText(value : Int): String{
        val df = DecimalFormat("#,###")
        return (df.format(value.toLong()) as String)
    }

}


// activity_main.xml
// 추가
<import
    type="com.kiljae.mygitsample.common.Util"
    alias="util"/>

…

// 삭제 android:text="@{String.valueOf(vmMain.header.totalUserCount)}"
android:text="@{util.prettyText(vmMain.header.totalUserCount)}” // 변경

이제 보기에 좀 좋아졌습니다.

여기까지 작업한 결과물은 아래 경로에 있습니다.

github.com/lee-kil-jae/MyGitSample/tree/dev_third

 

lee-kil-jae/MyGitSample

최소 샘플 앱 개발 (Kotlin, MVVM, koin, DataBinding, LiveData, PagingLibrary, RxJava, retrofit, Coroutine) - lee-kil-jae/MyGitSample

github.com

 

다음 글에서는 다양한 List View표현 방식에 대해 다루어 보도록 하겠습니다.

그럼 이만

Comments