정상에서 IT를 외치다

[Android, MVVM] MVVM 따라하기 - 1 (Github 프로젝트) 본문

안드로이드

[Android, MVVM] MVVM 따라하기 - 1 (Github 프로젝트)

Black-Jin 2020. 6. 5. 22:57
반응형

안녕하세요. 블랙진입니다.

 

안드로이드 개발을 어느정도 경험하게 되면 MVVM에 대해 한번 쯤은 들어보시게 될겁니다. 프로젝트의 크기가 커지게 되면 앱의 유지 보수를 위해 어떤 아키텍처를 선택하는지가 중요하게 됩니다. 여기서 아키텍처는 MVC, MVP, MVVM 등등 있지만 왜 많은 안드로이드 개발자들이 MVVM을 선택했을까요?

 

MVVM을 처음 시작하고 공부하는 가장 큰 이유는 구글에서 권장(Guide to app architecture)하고 있다는 것과 많은 가이드를 제공하며 주변에서 많이 쓰니까이지 않을까 조심스럽게 생각합니다. (필자 또한 MVVM이 좋다고 해서 시작했습니다.) 우리는 MVVM에 조금만 검색해보면 아래와 같은 구현 방법을 읽을 수 있습니다.

 

1. View와 ViewModel은 다대다 관계이다.

2. View는 ViewModel을 알지만 ViewModel을 View를 몰라야 한다.

3. ViewModel에는 context를 참조하면 안된다.

4. AAC를 사용해서 구현하는걸 권장한다.

5. Model은 Repository 패턴으로 만든다.

...

 

하지만!

 

제가 처음 MVVM을 구현할때에는 위 장점들이 와닿지 않았습니다. '이렇게 구현하는게 좋으니깐 좋은거겠지' 라는 막연한 생각으로 작업했던 것 같아 이번 포스팅을 준비해 보았습니다. MVVM을 적용 하기 전과 후를 비교 분석해보며 어떤점이 어떻게 좋아졌고 유지 보수가 용이해 졌는지를 step by step 형식으로 진행 해볼까 합니다.

 

포스팅 순서는 아래와 같습니다.

 

1. 아무런 기술 적용 없이 요구사항에 맞춰 프로젝트를 구현합니다. (현재)

2. Repository 패턴을 적용하여 Model을 구현합니다.

3. Rx를 적용하여 구현합니다.

4. Databinding을 사용해 MVVM을 구현합니다.

5. AAC의 ViewModel을 사용해 MVVM을 구현합니다.

 

순차적으로 코드를 리펙토링 하면서 어떤 점에서 코드의 유지보수가 좋아졌는지 보겠습니다.

 

 

 

토이 프로젝트

 

 

프로젝트는 두 개의 화면으로 구성되어 있습니다. 

 

1.  검색화면

2. 상세 화면

 

 

사용한 API

 

깃허브 api를 사용합니다.

 

Github Search, Repositories, Users

 

 

패키지 구성

 

 

패키지는 크게 화면을 담당하는 ui와 데이터를 담당하는 data로 구성하였습니다. 

 

 

Data

 

Retrofit을 사용해 네트워크 통신을 처리했습니다. ApiProvider 안에 객체들은 매번 생성할 필요 없이 한 번 생성 후 계속 사용하는게 메모리 효율에서 좋기 때문에 kotlin 에서 SingleTon 을 구현해주는 object 를 사용했습니다.

 

ApiProvider

object ApiProvider {

    private const val baseUrl = "https://api.github.com/"

    fun provideRepoApi(): RepoApi = getRetrofitBuild().create(RepoApi::class.java)

    fun provideUserApi(): UserApi = getRetrofitBuild().create(UserApi::class.java)

    private fun getRetrofitBuild() = Retrofit.Builder()
        .baseUrl(baseUrl)
        .client(getOkhttpClient())
        .addConverterFactory(getGsonConverter())
        .build()

    private fun getGsonConverter() = GsonConverterFactory.create()

    private fun getOkhttpClient() = OkHttpClient.Builder().apply {

        //TimeOut 시간을 지정합니다.
        readTimeout(60, TimeUnit.SECONDS)
        connectTimeout(60, TimeUnit.SECONDS)
        writeTimeout(5, TimeUnit.SECONDS)

        // 이 클라이언트를 통해 오고 가는 네트워크 요청/응답을 로그로 표시하도록 합니다.
        addInterceptor(getLoggingInterceptor())
    }.build()

    private fun getLoggingInterceptor(): HttpLoggingInterceptor =
        HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
}

 

이렇게 Retrofit을 사용해 RepoApi와 UserApi를 구현했습니다.

 

RepoApi

interface RepoApi {

    @GET("search/repositories")
    fun searchRepository(@Query("q") query: String): Call<RepoSearchResponse>

    @GET("repos//")
    fun getRepository(
        @Path("owner") ownerLogin: String,
        @Path("name") repoName: String
    ): Call<RepoModel>
}

 

UserApi

interface UserApi {

    @GET("users/")
    fun getUser(@Path("name") userName: String): Call<UserModel>
}

 

 

model

 

통신에 사용하는 모델 객체로 RepoModel과 UserModel을 구현하였습니다. 각 통신 모델에서는 View에서 사용할 모델로 맵핑 할 수 있는 확장 함수들을 구현했습니다. 이렇게 함으로써 통신에서 가져온 데이터들을 View에만 필요한 데이터로 구분하고 가공할 수 있습니다.

 

RepoModel

data class RepoModel(
    @SerializedName("name")
    val name: String,
    @SerializedName("full_name")
    val fullName: String,
    @SerializedName("owner")
    val owner: OwnerModel,
    @SerializedName("description")
    val description: String?,
    @SerializedName("language")
    val language: String?,
    @SerializedName("updated_at")
    val updatedAt: Date,
    @SerializedName("stargazers_count")
    val stars: Int
) {
    data class OwnerModel(
        @SerializedName("login")
        val login: String,
        @SerializedName("avatar_url")
        val avatarUrl: String
    )
}

fun RepoModel.mapToPresentation(context: Context) = RepoItem(
    title = fullName,
    repoName = name,
    owner = RepoItem.OwnerItem(
        ownerName = owner.login,
        ownerUrl = owner.avatarUrl
    ),

    description = if (TextUtils.isEmpty(description))
        context.resources.getString(R.string.no_description_provided)
    else
        description,

    language = if (TextUtils.isEmpty(language))
        context.resources.getString(R.string.no_language_specified)
    else
        language,

    updatedAt = try {
        DateUtils.dateFormatToShow.format(updatedAt)
    } catch (e: IllegalArgumentException) {
        context.resources.getString(R.string.unknown)
    },

    stars = context.resources.getQuantityString(R.plurals.star, stars, stars)
)

 

 

RepoItem

data class RepoItem(
    val title: String,
    val repoName: String,
    val owner: OwnerItem,
    val description: String?,
    val language: String?,
    val updatedAt: String,
    val stars: String
) {
    data class OwnerItem(
        val ownerName: String,
        val ownerUrl: String
    )
}

 

서버에서 받아온 데이터인 RepoMode에서 View에 필요한 데이터인 RepoItem으로 가공해 사용합니다.

 

UserModel

data class UserModel(
    @SerializedName("bio")
    val bio: String,
    @SerializedName("blog")
    val blog: String,
    @SerializedName("company")
    val company: String,
    @SerializedName("created_at")
    val createdAt: String,
    @SerializedName("email")
    val email: String,
    @SerializedName("followers")
    val followers: Int,
    @SerializedName("following")
    val following: Int,
    @SerializedName("id")
    val id: Int,
    @SerializedName("location")
    val location: String,
    @SerializedName("login")
    val login: String,
    @SerializedName("name")
    val name: String,
    @SerializedName("public_gists")
    val publicGists: Int,
    @SerializedName("public_repos")
    val publicRepos: Int,
    @SerializedName("updated_at")
    val updatedAt: String,
    @SerializedName("avatar_url")
    val profileImgUrl: String
)

fun UserModel.mapToView(context: Context) = UserItem(
    followers = followers.let {
        if (it > 100) context.getString(R.string.max_follow_number) else it.toString()
    },
    following = following.let {
        if (it > 100) context.getString(R.string.max_follow_number) else it.toString()
    }
)

 

UserItem

data class UserItem(
    val followers: String,
    val following: String
)

 

UserModel에서 화면에 보여줄 데이터인 followers, folowing 정보만 UserItem에 두었습니다.

 

 

UI

 

화면 부는 MainAcitvity 에서 검색을 담당하는 SearchFragment와 상세 화면을 보여주는 DetailFragment를 보여줄 수 있게 작업하였습니다.

 

 

SearchFragment

 

요구사항

 

1. 빈 값을 검색하면 toast를 보여줍니다.

2. 검색 값이 있을 경우 서버로 부터 데이터를 가져옵니다.

3. 검색 성공 여부와 에러 여부를 화면에 보여줍니다.

 

if (query.isEmpty()) {
    //토스트를 보여줍니다.
    requireContext().toast(requireContext().getString(R.string.please_write_repository_name))
} else {
    //키보드를 내립니다.
    hideKeyboard()
    //이건 결과를 지웁니다.
    clearResults()
    //에러표시를 지웁니다.
    hideError()
    //로딩화면을 보여줍니다.
    showProgress()

    //서버로부터 검색된 리포지터리를 가져옵니다.
    //..
}

 

위와 같이 query 정보에 따라 해당 함수들을 실행되게 구성했습니다. 통신을 하는 부분은 ApiProvider로 부터 생성한 retrofit을 가져와 처리합니다.

 

//서버로부터 검색된 리포지터리를 가져옵니다.
repoCall = repoApi.searchRepository(query)
repoCall?.enqueue(object : Callback<RepoSearchResponse> {

    override fun onResponse(
        call: Call<RepoSearchResponse>,
        response: Response<RepoSearchResponse>
    ) {
        //로딩화면을 숨깁니다.
        hideProgress()

        //통신에 성공하면 검색된 리스트를 화면에 보여줍니다.
        val body = response.body()
        if (response.isSuccessful && null != body) {
            with(repoAdapter) {
                setItems(body.items.map { it.mapToView(requireContext()) })
            }

            if (0 == body.totalCount) {
                showError(getString(R.string.no_search_result))
            }
        } else {
            showError(response.message())
        }
    }

    override fun onFailure(call: Call<RepoSearchResponse>, t: Throwable) {
        hideProgress()
        showError(t.message)
    }
})

 

DetailFragment

 

요구사항

 

1. repo정보와 user정보를 가져오는 두 개의 api콜을 실행합니다.

 

companion object {

    private const val ARGUMENT_OWNER_NAME = "owner_name"

    private const val ARGUMENT_REPO = "repo"

    fun newInstance(ownerName: String, repoName: String) = DetailFragment().apply {
        arguments = bundleOf(
            Pair(ARGUMENT_OWNER_NAME, ownerName),
            Pair(ARGUMENT_REPO, repoName)
        )
    }
}

 

newInstance() 함수를 사용해 파라미터를 받아 DetailFragment를 그려줍니다.

 

 

private fun loadData(ownerName: String, repo: String) {
    //에러표시를 숨깁니다.
    hideError()
    //로딩화면을 보여줍니다.
    showProgress()

    //repo 데이터를 로딩합니다.
    loadRepoData(ownerName, repo)

    //user 데이터 로딩을 합니다.
    loadUserData(ownerName)
}

 

 두 개의 api call을 동시에 호출하면서 로딩화면을 화면에 보여줍니다.

 

private fun loadRepoData(ownerName: String, repo: String) {
    repoCall = repoApi.getRepository(ownerName, repo)
    repoCall?.enqueue(object : Callback<RepoModel> {
        override fun onResponse(call: Call<RepoModel>, response: Response<RepoModel>) {
            //로딩 화면을 숨깁니다.
            hideProgress()

            val body = response.body()
            if (response.isSuccessful && null != body) {
                //repo 데이터를 화면에 보여줍니다.
                setRepoData(body.mapToView(requireContext()))
            } else {
                //에러를 표시합니다.
                showError(response.message())
            }
        }

        override fun onFailure(call: Call<RepoModel>, t: Throwable) {
            hideProgress()
            showError(t.message)
        }
    })
}

 

Repo 정보를 보여줍니다.

 

private fun loadUserData(userName: String) {
userCall = userApi.getUser(userName)
userCall?.enqueue(object : Callback<UserModel> {
override fun onResponse(call: Call<UserModel>, response: Response<UserModel>) {
//로딩 화면을 숨깁니다.
hideProgress()

val body = response.body()
if (response.isSuccessful && null != body) {
//user 데이터를 화면에 보여줍니다.
setUserData(body.mapToView(requireContext()))
} else {
//에러를 표시합니다.
showError(response.message())
}
}

override fun onFailure(call: Call<UserModel>, t: Throwable) {
hideProgress()
showError(t.message)
}
})
}

 

User 정보를 보여줍니다.

 

하지만 이와 같이 할 경우 여러 문제가 발생합니다. 두 api call 중 하나만 성공하고 하나는 실패할 수도 있으며 서로 통신시간이 달라 화면에 노출되는 시간이 다를 수도 있습니다. 이와 같은 문제를 현재는 수동으로 관리해줘야 하지만 추후 rx를 사용해 효율적으로 개선해 보도록 하겠습니다.

 

 

토이프로젝트 깃허브 링크

반응형
Comments