본문 바로가기
Mobile Development/Android

[Android] MVVM + AAC + Coroutine + Retrofit + Github API 예제

by 두잇뚜 2020. 7. 13.
반응형

이번 포스팅은 Github API를 활용해서 특정 키워드로 Github의 저장소들을 검색하는 안드로이드 앱을 만들 것이다.

아래 기술들을 사용하는 것을 목표로 한다.

1. MVVM(Model-View-ViewModel) 아키텍처 패턴을 적용

2. AAC(Android Architecture Component)를 활용

3. 비동기 작업을 위하여 Kotlin의 Coroutine활용

4. API를 호출하기 위해 Retrofit 라이브러리 활용

최종 결과물은 아래와 같은 모습이다.

위에서 볼 수 있듯이 화면 및 기능 자체는 간단하다.

EditText 하나, 버튼 하나, RecyclerView 하나 끝이다. 추가적으로 각 아이템을 클릭했을 때 해당 저장소의 웹페이지로 이동시키는 정도로 구현한다.


1. 의존성 추가

구현 간 사용할 라이브러리들의 의존성부터 추가하겠다.

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    ...
    // JVM 1.6 관련 에러 처리
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_1_8.toString()
    }
}

dependencies {
    ...

    implementation "androidx.recyclerview:recyclerview:1.1.0"

    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"

    implementation 'com.google.code.gson:gson:2.8.6'
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

    implementation 'com.github.bumptech.glide:glide:4.11.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

    implementation "com.google.android.material:material:1.1.0"
}

Gradle이 위 처럼 구성되어 있으면 된다.

RecyclerView와 ViewModel, LiveData를 사용하기 위한 lifecycle, Retrofit을 사용하기 위한 라이브러리, 저장소의 프로필 이미지 처리를 위해 Glide를 추가하였다. 

또한 RecyclerView의 아이템 레이아웃을 구성할 때 CardView를 사용하기 위해 머터리얼 디자인 라이브러리도 추가한다.


2. activity_main.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"
    android:padding="16dp"
    tools:context=".view.MainActivity">

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/searchButton"
        android:text="찾기"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintRight_toRightOf="parent"/>

    <EditText
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:id="@+id/inputView"
        android:layout_marginEnd="8dp"
        android:maxLines="1"
        android:hint="검색어 입력"
        app:layout_constraintTop_toTopOf="@id/searchButton"
        app:layout_constraintBottom_toBottomOf="@id/searchButton"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toLeftOf="@id/searchButton"/>

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:id="@+id/githubReposView"
        android:layout_marginTop="8dp"
        app:layout_constraintTop_toBottomOf="@+id/inputView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

레이아웃 구성 자체는 어렵지 않을 것이다. 이 예제에서는 앞서 보여준 최종 결과물과 같은 형태의 레이아웃을 구성한다.


3. Model 구현

Github API를 https://developer.github.com/v3/, https://developer.github.com/v3/search/#search-repositories를 참고하여 Model을 구현하자.

{
  "total_count": 40,
  "items": [
    {
      "id": 3081286,
      "full_name": "dtrupenn/Tetris",
      "owner": {
        "avatar_url": "https://secure.gravatar.com/avatar/e7956084e75f239de85d3a31bc172ace?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png",
      },
      "html_url": "https://github.com/dtrupenn/Tetris",
      "description": "A C implementation of Tetris using Pennsim through LC4",
      "stargazers_count": 1,
    }
  ]
}

우리가 사용할 API의 응답은 저런식으로 온다. 필드는 이 외에도 굉장히 많지만, 이 예제에서 사용할 필드만 남겨 둔 것이다.

Model을 아래와 같이 작성한다.

data class GithubRepositoriesModel(
    @SerializedName("total_count")
    val totalCount: Int,

    @SerializedName("items")
    val items: List<GithubRepositoryModel>
)
data class GithubRepositoryModel(
    @SerializedName("id")
    val id: Long,

    @SerializedName("full_name")
    val fullName: String,

    @SerializedName("html_url")
    val htmlUrl: String,

    @SerializedName("description")
    val description: String,

    @SerializedName("stargazers_count")
    val stargazersCount: Int,

    @SerializedName("owner")
    val owner: GithubRepositoryOwnerModel
)
data class GithubRepositoryOwnerModel(
    @SerializedName("avatar_url")
    val avatarUrl: String
)

위  JSON 응답을 표현하면 위와 같은 Model을 작성할 수 있다.


4. API 구현

아래의 BaseService 클래스를 작성한다. 각 Network Service 들은 이 BaseService에 baseUrl을 전달하고 자신에게 맞는 Api를 create 하도록 한다.

* 포스팅을 하다보니 더 좋은 클래스 작성이 생각났다. 상속을 이용하는 것이 어땠을까?

class BaseService {
    fun getClient(baseUrl: String): Retrofit? = Retrofit.Builder()
        .baseUrl(baseUrl)
        .client(OkHttpClient())
        .addConverterFactory(GsonConverterFactory.create())
        .build()
}

 

Github API Interface를 작성한다. Retrofit에 대해 잘 모른다면 https://square.github.io/retrofit/ 를 우선 참고하자.

suspend는 Coroutine을 위해 사용하는 키워드다. 

interface GithubApi {
    @GET("search/repositories")
    suspend fun getRepositories(
        @Query("q") query: String
    ): Response<GithubRepositoriesModel>
}

 

이제 위 GithubApi를 활용하여 GithubService 클래스를 만든다. object로 작성하여 Singletone으로 관리한다.

object GithubService {
    private const val GITHUB_URL = "http://api.github.com"

    val client = BaseService().getClient(GITHUB_URL)?.create(GithubApi::class.java)
}

4. Repository 작성

Google의 MVVM + Repository 패턴을 알고 있다면 익숙 할 것이다. GithubRepository 클래스를 만든다.

class GithubRepository {
    private val githubClient = GithubService.client

    suspend fun getRepositories(query: String) = githubClient?.getRepositories(query)
}

 

사실 위와 같이 만드는 것은 좋은 코드는 아니다. githubClient는 생성자로부터 의존성을 주입받도록 하고 Dagger, Koin을 활용하는 것이 좋다. 이는 예제이므로 최대한 덜 복잡하게 하기 위해 위처럼 간단하게 하였다.


5. ViewModel 작성

class MainViewModel(private val githubRepository: GithubRepository) : ViewModel() {
    private val _githubRepositories = MutableLiveData<List<GithubRepositoryModel>>()
    val githubRepositories = _githubRepositories

    fun requestGithubRepositories(query: String) {
        CoroutineScope(Dispatchers.IO).launch {
            githubRepository.getRepositories(query)?.let { response ->
                if(response.isSuccessful) {
                    response.body()?.let {
                        _githubRepositories.postValue(it.items)
                    }
                }
            }
        }
    }
}

MainViewModel을 위와 같이 작성한다. AAC의 ViewModel을 상속받는다.

생성자로부터 Repository를 주입받는다. 4번에서 좋지 않다고 했던 부분과 비교 해 보도록 한다. 위 처럼 생성자로 부터 전달받고 의존성 주입을 받는 것이 바람직하다.

Coroutine을 활용하여 비동기로 API를 호출하여 Github의 저장소들을 획득한다. 그리고 결과 리스트를 LiveData에 post한다.


6. ViewModelFactory 작성

생성자에 파라미터가 있는 ViewModel들은 ViewModelFactory를 작성하고 이를 통해 초기화 해야 한다.

class MainViewModelFactory(private val githubRepository: GithubRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return modelClass.getConstructor(GithubRepository::class.java).newInstance(githubRepository)
    }
}

위와 같은 Factory 클래스를 작성한다.


7. item_github_repository.xml 작성

RecyclerView에서 사용할 ItemHolder의 layout을 작성한다.

<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_margin="4dp"
    app:cardCornerRadius="8dp"
    app:cardElevation="2dp"
    app:cardBackgroundColor="#cccccc">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#FFFFFF"
        android:padding="8dp">

        <ImageView
            android:layout_width="36dp"
            android:layout_height="36dp"
            android:id="@+id/avatarView"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/fullNameView"
            android:textSize="16dp"
            android:textColor="#000000"
            android:layout_marginStart="12dp"
            app:layout_constraintHorizontal_bias="0"
            app:layout_constraintTop_toTopOf="@+id/avatarView"
            app:layout_constraintBottom_toBottomOf="@+id/avatarView"
            app:layout_constraintLeft_toRightOf="@+id/avatarView"
            app:layout_constraintRight_toRightOf="parent"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/descriptionView"
            android:textSize="12dp"
            android:textColor="#000000"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/avatarView"
            app:layout_constraintLeft_toLeftOf="parent"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/starCountView"
            android:textSize="14dp"
            android:textColor="#000000"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/descriptionView"
            app:layout_constraintLeft_toLeftOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>

프로필 이미지, 저장소의 이름과 설명, 별의 개수 정도를 표현하는 레이아웃이다. 특별히 어렵지 않다.


8. GithubRepositoryItemHolder 작성

RecyclerView에서 사용할 ViewHolder를 아래와 같이 작성한다.

class GithubRepositoryItemHolder(view: View, listener: GithubRepositoryAdapter.OnGithubRepositoryClickListener?) : RecyclerView.ViewHolder(view) {
    private val avatarView: ImageView = view.avatarView
    private val fullNameView: TextView = view.fullNameView
    private val descriptionView: TextView = view.descriptionView
    private val starCountView: TextView = view.starCountView

    init {
        view.setOnClickListener {
            listener?.onItemClick(adapterPosition)
        }
    }

    fun bind(model: GithubRepositoryModel) {
        model.run {
            avatarView.setImageWithGlide(owner.avatarUrl)
            fullNameView.text = fullName
            descriptionView.text = description
            starCountView.text = "Stars : $stargazersCount"
        }
    }
}

GithubRepositoryModel을 bind 하도록 한다. GithubRepositoryAdapter.OnGithubRepositoryClickListener 라는 녀석이 아직 없는데, 이는 Adapter를 만들며 작성 할 것이다.

포스팅하며 느끼는 거지만 순서가 참 애매할때가 많다.


9. GithubRepositoryAdapter 작성

class GithubRepositoryAdapter(private var repositories: List<GithubRepositoryModel>) : RecyclerView.Adapter<GithubRepositoryItemHolder>() {
    interface OnGithubRepositoryClickListener {
        fun onItemClick(position: Int)
    }

    var listener: OnGithubRepositoryClickListener? = null

    override fun getItemCount(): Int = repositories.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GithubRepositoryItemHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_github_repository, parent, false)
        return GithubRepositoryItemHolder(view, listener)
    }

    override fun onBindViewHolder(holder: GithubRepositoryItemHolder, position: Int) {
        holder.bind(repositories[position])
    }

    fun update(updated : List<GithubRepositoryModel>) {
        CoroutineScope(Dispatchers.Main).launch {
            val diffResult = async(Dispatchers.IO) {
                getDiffResult(updated)
            }
            repositories = updated
            diffResult.await().dispatchUpdatesTo(this@GithubRepositoryAdapter)
        }
    }

    private fun getDiffResult(updated: List<GithubRepositoryModel>): DiffUtil.DiffResult {
        val diffCallback = GithubRepositoryDiffCallback(repositories, updated)
        return DiffUtil.calculateDiff(diffCallback)
    }

    fun getItem(position: Int) = repositories[position]
}

RecyclerView를 위한 Adapter 클래스이다. 주목할 부분은 update 메서드다. Coroutine과 DiffUtil을 사용하고 있다. DiffUtil은 시간복잡도가 N^2 이기 때문에 백그라운드 쓰레드에서 작업 후 메인쓰레드에 반영하는 것을 추천한다.


10. GithubRepositoryDiffCallback작성

class GithubRepositoryDiffCallback(private val oldList: List<GithubRepositoryModel>, private val newList: List<GithubRepositoryModel>) : DiffUtil.Callback() {

    override fun getNewListSize(): Int = newList.size
    override fun getOldListSize(): Int = oldList.size
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition] == newList[newItemPosition]
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldList[oldItemPosition].id == newList[newItemPosition].id
}

Adapter 클래스의 update 메서드에서 사용한 DiffCallback을 위한 클래스를 위와 같이 작성한다.


10. GithubRepositoryItemDecoration작성

RecyclerView에서 아이템간의 여백 등을 위해서 RecyclerView.ItemDecoration 을 상속받는 클래스를 작성해야 한다. 아래와 같이 작성한다.

class GithubRepositoryItemDecoration(private val top: Int, private val bottom: Int) : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        super.getItemOffsets(outRect, view, parent, state)
        outRect.top = top
        outRect.bottom = bottom
    }
}

이를 RecyclerView에 적용하면 아이템 사이의 위와 아래에 여백을 설정할 수 있다. 


11. MainActivity 작성

코드는 두 부분으로 나누어 설명하겠다.

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: MainViewModel
    private lateinit var viewModelFactory: MainViewModelFactory
    private lateinit var mGithubRepositoryAdapter: GithubRepositoryAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initButton()
        initViewModel()
    }

    private fun initButton() {
        searchButton.setOnClickListener { onSearchClick() }
    }

    private fun initViewModel() {
        viewModelFactory = MainViewModelFactory(GithubRepository())
        viewModel = ViewModelProvider(this, viewModelFactory).get(MainViewModel::class.java)

        viewModel.githubRepositories.observe(this) {
            updateRepositories(it)
        }
    }
    ...

먼저 MainActivity는 viewModel, viewModelFactory, GithubRepositoryAdapter를 멤버로 갖고 있다.

onCreate시 검색을 위한 searchButton에 이벤트를 설정하고 ViewModel을 생성한다.

ViewModel 생성시에는 아까 생성한 ViewModelFactory를 생성하고 생성자에 GithubRepository를 생성하여 넣어주고 ViewModelProvider에 이 Factory를 전달하여 생성하여야 한다.

그리고 ViewModel의 githubRepositories를 observe 하여 뷰를 업데이트 하도록 한다.


    ....
    
    private fun updateRepositories(repos: List<GithubRepositoryModel>) {
        if(::mGithubRepositoryAdapter.isInitialized) {
            mGithubRepositoryAdapter.update(repos)
        } else {
            mGithubRepositoryAdapter = GithubRepositoryAdapter(repos).apply {
                listener = object : GithubRepositoryAdapter.OnGithubRepositoryClickListener {
                    override fun onItemClick(position: Int) {
                        mGithubRepositoryAdapter.getItem(position).run {
                            openGithub(htmlUrl)
                        }
                    }
                }
            }

            githubReposView.run {
                setHasFixedSize(true)
                layoutManager = LinearLayoutManager(this@MainActivity)
                adapter = mGithubRepositoryAdapter
                addItemDecoration(GithubRepositoryItemDecoration(6, 6))
            }
        }
    }

    private fun openGithub(url: String) {
        try {
            val uri = Uri.parse(url)
            Intent(Intent.ACTION_VIEW, uri).run {
                startActivity(this)
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    private fun onSearchClick() {
        inputView.run {
            viewModel.requestGithubRepositories(inputView.text.toString())
            text.clear()
            hideKeyboard()
        }
    }

    private fun hideKeyboard() {
        currentFocus?.run {
            val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
            imm?.hideSoftInputFromWindow(windowToken, 0)
        }
    }
}

최초로 updateRepositories가 호출되면 Adapter를 생성하고 이벤트 처리를 위한 Interface를 구현한다. 그리고 RecyclervView를 세팅하고 어댑터와 ItemDecoration를 설정해준다.

openGithub는 아이템을 클릭했을 때 웹 페이지를 열도록 하는 것이다. 만약 인터넷을 열 수 있는 다른 앱이 없다면 예외가 발생하므로 처리해준다.

onSearchClick 함수가 발생하면, 즉 검색 버튼을 누르면 입력된 텍스트를 가져와 ViewModel에 전달하여 저장소 리스트 갱신을 요청한다.

그리고 입력된 텍스트를 제거하고 키보드를 내린다.


11단계에 거쳐 예제 프로젝트를 완성할 수 있었다. 실제로 코드를 작성한 순서는 위와 다르지만, 차근차근 따라해볼 수 있게 단계를 나누었다.

조금 맞지 않는 단계도 있지만.. 나름 노력을.. 쿨럭..

매우 기초적인 이야기이고 실제로 적용하면 좋지 않은 방법들도 몇 가지 있기 때문에 어떤 기술 혹은 라이브러리 등이 어떻게 사용되는지 정도만 파악하고 구글링을 통해 효과적인 방법을 찾도록 하자.

댓글5

  • Mvvm 2021.04.07 13:17

    안녕하세요 이 글 보고 많이 이해가 됬습니다 !
    그런데 혹시 retrofit을 사용하면서 연결실패 ex) 인터넷이 안된다던가 할 때 fail일 때를 생각해서 코딩해줘야할 거같은데 어디 부분을 어떻게 추가하면 좋을까요?
    답글

    • 두잇뚜 2021.04.08 19:29 신고

      안녕하세요. 도움이 되었다니 기쁩니다.

      음.. 방법은 다양하겠지만, 응답을 Result 패턴을 사용해서 감싼다거나, 코루틴 내부에서 Exception 처리를 한다거나.. 다양한 방법이 있을 것 같습니다!

      "android retrofit exception handling" 으로 구글링하시면 다양한 자료들이 있을거에요.

      https://medium.com/@douglas.iacovelli/how-to-handle-errors-with-retrofit-and-coroutines-33e7492a912

      위 링크에서도 흥미로운 방법을 확인하실 수 있습니다.

      인터넷 사용 가능 유무는 네트워크 호출 이전에 체크해서 처리하는 방법도 있을 것 같아요. :)

  • 용4 2021.04.29 14:48

    안녕하세요. 좋은 자료 감사합니다.
    혹시 setImageWithGlide 는 ImageView 에 확장함수로 만드신걸까요?
    이 부분에서 컴파일 오류가 떠서요.
    답글

  • khs613 2021.05.20 15:18

    안녕하세요! 코틀린과 MVVM 공부중이였는데 정말 도움 많이됐습니다.
    좋은 자료 감사합니다. :)
    혹시 코드에서 언급된 setImageWithGlide 처럼 확장함수에 대해서도 설명 부탁드려도 될까요?
    답글