본문 바로가기

Android + Kotlin

Koin + MVVM + Coroutine + Flow 를 이용한 상세화면 만들기

반응형

앱개발에서 가장 기본이라 할수 있는 Rest API로 서버에서 데이터를 요청하고, 화면에 표시하는 일련의 과정을 정리해보려고 합니다.

요즘 Android 개발을 하면서 가장 많이 사용하고 있는(실제로도 사용중인) Koin + MVVM + Coroutine + Flow 를 이용한 방법입니다.

 


 

1. Koin을 build.gradle(모듈 수준)에 implementation 합니다.

 

dependencies {
    ...
    
    // Koin Current version
    def koin_version= "3.1.4"
    // Koin main features for Android
    implementation "io.insert-koin:koin-android:$koin_version"
}

 

자세한 설정은 아래 링크를 참고해주세요.

 

Koin v3 | Koin

Setup Koin for your project

insert-koin.io

 


 

2. AppMain.kt 파일(Application class) 를 생성하고 Koin 이 시작할 수 있도록 application context를 적용해줍니다.

 

class AppMain: Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@AppMain)
        }
    }
}

 

3. AndroidManifest.xml 파일의 application의 name에 등록을 합니다.

서버와 통신을 해야하기에 인터넷 사용 퍼미션도 추가해 줍니다.

 

<uses-permission android:name="android.permission.INTERNET" />

<application
  android:name=".AppMain"
  ...>

 


 

4. AppModules.kt 파일을 생성합니다. repository, viewmodel, retrofit 인스턴스를 관리하는 module 클래스입니다.

 

object AppModules {
    private val repositories = module {}
    private val viewModels = module {}
    private val etc = module {}

    val modules = listOf(etc, repositories, viewModels)
}

 

5. 다시 AppMain.kt 파일에 modules를 선언해 줍니다.

 

override fun onCreate() {
  ...
  startKoin {
    ...
    modules(AppModules.modules)
  }
}

 


 

6. 데이터 클래스를 만들어 줄 차례입니다. REST API는 https://picsum.photos/ 사이트의 이미지 API를 이용합니다.

이미지의 상세 정보를 가져와서 화면에 표시할 예정이므로 데이터를 실제 REST API 테스터 어플을 이용해서 서버에 데이터를 요청하여 확인해 봅니다.

데이터 확인은 아래 링크의 크롬 확인 어플리케이션을 사용했습니다.

 

무료 REST(HTTP) API TESTER - Talend API Tester (크롬 확장 프로그램)

서비스를 개발하게되면 필수적으로 서버와 통신을 하게되는데, 이때 REST API를 사용하게 됩니다. 개발 또는 운영중에 REST API가 정상적으로 동작을 하는지 검증이 필요한 경우가 있습니다. 이럴때

heeeju4lov.tistory.com

 

실제 응답 데이터를 확인해 보니 아래와 같습니다.

 

{
  "id": "0",
  "author": "Alejandro Escamilla",
  "width": 5616,
  "height": 3744,
  "url": "https://unsplash.com/photos/yC-Yzbqy7PY",
  "download_url": "https://picsum.photos/id/0/5616/3744"
}

 

화면에서 필요한 정보는 id, author, download_url 정도이니, 필요한 것에 맞게 데이터 클래스(ImageInfo.kt)를 생성해 줍니다.

 

data class ImageInfo (
    val id: String?,
    val author: String?,
    val image_url: String?
)

 

7. Rest Api의 수신 데이터 클래스와 이를 mapping하여 위의 ImageInfo 클래스에 값을 넣어주는 코드를 구현합니다.

mapper 함수를 선언한 추상 클래스(BaseResponse.kt)를 생성합니다.

 

abstract class BaseResponse<M> {
    abstract fun mapper(): M
}

 

다음으로 실제 Rest Api 수신 데이터 클래스와 이를 ImageInfo 클래스로 반환할 mapper를 생성합니다.

 

class ResImageInfo {

    data class Response(
        val id: String?,
        val author: String?,
        val width: Int?,
        val height: Int?,
        val url: String?,
        val download_url: String?
    ): BaseResponse<ImageInfo> () {
        override fun mapper(): ImageInfo {
            return ImageInfo(
                id = id,
                author = author,
                image_url = download_url
            )
        }

    }
}

 

위의 코드에서 mapper() 함수에서 Response 클래스 데이터를 ImageInfo 클래스로 변환하여 반환하는 것을 확인하실 수 있습니다.

 


 

8. 이제 서버와 통신을 위한 retrofit 라이브러리를 build.gradle(모듈 수준)에 implementation 합니다.
정상적인 사용을 위해서는 converter-gson과 okhttp 라이브러리도 추가해야 합니다.

 

dependencies {
  ...
  
  // retrofit
  implementation 'com.squareup.retrofit2:retrofit:2.9.0'
  implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
  implementation "com.squareup.okhttp3:okhttp:4.9.1"
}

 

9. Api Service 인터페이스 클래스(LoremPicsumApiService.kt)를 생성합니다.

데이터의 유효성과 네트워크 에러 처리를 위해서 응답은 ResponseBody 로 전달한다.

 

interface LoremPicsumApiService {

    @GET("/id/{imageId}/info")
    suspend fun imageInfo(@Path("imageId") imageId: String): Response<ResponseBody>
}

 

10. AppModules.kt 에 etc module에 retrofit 인스턴스를 정의해줄 차례입니다.

 

class ApiConstants {
    companion object {
        const val API_LOREM_PICSUM = "api.lorem_picsum"
    }
}

object AppModules {
    ...
    private val etc = module {
        factory {
            GsonBuilder()
                .enableComplexMapKeySerialization()
                .setPrettyPrinting()
                .create()
        }
        single(named(ApiConstants.API_LOREM_PICSUM)) {
            Retrofit.Builder()
                .client(OkHttpClient.Builder().run {
                    connectTimeout(10, TimeUnit.SECONDS)
                    readTimeout(10, TimeUnit.SECONDS)
                    writeTimeout(10, TimeUnit.SECONDS)
                    build()
                })
                .baseUrl("https://picsum.photos")
                .addConverterFactory(GsonConverterFactory.create(get()))
                .build()
                .create(LoremPicsumApiService::class.java)
        }
    }
    ...
}

 


 

11. repository 파일(LoremPicsumRepository.kt)을 생성한다.

 

class LoremPicsumRepository(private val service: LoremPicsumApiService) {

    fun getImageInfo(imageId: String): Flow<ImageInfo> = flow {
        val response = service.imageInfo(imageId)
        if(response.isSuccessful) {
            kotlin.runCatching {
                Gson().fromJson(
                    response.body()?.string(),
                    ResImageInfo.Response::class.java)
            }.onSuccess {
                emit(it.mapper())
            }.onFailure {
                throw RuntimeException("invalid JSON File.")
            }
        } else {
            throw RuntimeException("response is Failed.")
        }
    }

}

 

Repository 생성시에 api service 인스턴스를 Koin으로부터 주입을 받을 수 있도록 생성합니다.

실패시에 exception 처리를 하여 viewmodel에서 화면에 사용자 안내를 표시하거나, 다른 처리를 할수 있도록 해줍니다.

 

12. repository 를 생성하였으니, AppModules.kt 파일에 module로 추가합니다.

 

object AppModules {
    private val repositories = module {
        factory { LoremPicsumRepository(service = get(named(ApiConstants.API_LOREM_PICSUM))) }
    }
    ...
}

 


 

13. 이제 ViewModel을 구현할 차례인데, 그에 앞서 Coroutines과 lifecycle 라이브러리를 build.gradle(모듈 수준)에 implementation을 해줍니다.

 

dependencies {
    ...
    
    // lifecycle
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
    
    // coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
}

 

14. 이제 ViewModel(LoremPicsumViewModel.kt)을 생성합니다.

 

class LoremPicsumViewModel(private val repository: LoremPicsumRepository): ViewModel() {

    private val _imageInfo: MutableLiveData<ImageInfo> = MutableLiveData()
    val imageInfo: LiveData<ImageInfo> get() = _imageInfo

    private val _errorMsg: MutableLiveData<String> = MutableLiveData()
    val errorMsg: LiveData<String> get() = _errorMsg

    fun getImageInfo(imageId: String) {
        viewModelScope.launch {
            repository.getImageInfo(imageId)
                .catch {
                    _errorMsg.postValue(it.message)
                }.collectLatest {
                    _imageInfo.postValue(it)
                }
        }
    }

}

 

viewModel 생성시에 repository의 인스턴스를 koin 으로부터 주입을 받을 수 있도록 생성합니다.

getImageInfo() 함수는 Flow를 받기때문에 coroutine launch(viewModelScope.launch) 로 호출합니다.

그리고, getImageInfo() 내부에서 요청 실패시 exception 처리를 했기때문에 catch로 실패 여부를 확인하여 UI로 전달을 해줍니다.

 

15. viewModel도 AppModule.kt 파일에 module 등록합니다.

 

private val viewModels = module {
    viewModel { LoremPicsumViewModel(repository = get()) }
}

 


 

16. 이제 상세화면을 만들 차례입니다.

상세화면은 이미지와 작가 이름을 표시 합니다.

이미지 표시를 위해 build.gradle(모듈 수준)에  Glide 라이브러리를 implementation 해줍니다.

그리고, glide를 BindingAdapter 를 이용해서 이미지 로딩을 해줄것이기때문에 kotlin-kapt 도 추가합니다.

 

plugins {
    ...
    id 'kotlin-kapt'
}

...
dependencies {
    ...
    // glide
    implementation 'com.github.bumptech.glide:glide:4.12.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
}

 

17. 상세화면 파일(LoremPicsumDetailActivity.kt, activity_lorempicsum_detail.xml)을 생성합니다.

생성 후에 AndroidManifest.xml에 Activity 등록을 해줍니다.

 

class LoremPicsumDetailActivity:
    BaseActivity<ActivityLorempicsumDetailBinding>(R.layout.activity_lorempicsum_detail) {

    private val viewModel: LoremPicsumViewModel by viewModel()

    override fun initViewModel() {
        super.initViewModel()
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
    }

    override fun afterOnCreate() {
        super.afterOnCreate()

        lifecycleScope.launchWhenStarted {
            viewModel.getImageInfo("1011")
        }
    }

}

@BindingAdapter("url")
fun setImageViewUrl(view: AppCompatImageView, url: String?) {
    url?.let {
        Glide.with(view.context).load(it).into(view)
    }
}

 

activity는 이전에 만들었었던 BaseActivity를 상속 받았습니다.

 

 

DataBinding을 결합한 BaseActivity 만들기

우리는 개발을 하면서, 반복적이고 공통적인 코드를 기계적으로 코딩(Bolierplate 코드)을 하게됩니다. 그로인해 코드가 늘어나고, 간결하게 보이지 않아 코드 분석이 불편한 경험도 하게 됩니다.

heeeju4lov.tistory.com

 

viewModel을 초기화 해주고, 이미지 상세 정보(getImageInfo())를 요청합니다.

BindingAdapter은 xml에서 imageView에 url 값을 적용하면 param의 적합성에 따라서 함수가 호출이 됩니다.

 

<?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="viewModel"
            type="com.remind.sampleapp.lorem_picsum.viewmodel.LoremPicsumViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.AppCompatImageView
            android:id="@+id/image_view"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintDimensionRatio="1:1"
            android:scaleType="centerCrop"
            app:url="@{viewModel.imageInfo.image_url}"/>

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toBottomOf="@+id/image_view"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            android:layout_marginTop="8dp"
            android:text="@{viewModel.imageInfo.author}"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

databinding에 viewModel 을 받아서 데이터를 view에 적용해 줍니다.

 

그리고, 실행!!

아래와 같이 상세화면이 표시되면 성공입니다.

 

 


 

 

긴 글 읽어주셔서 감사합니다.

전체 소스 코드는 아래 Github 프로젝트를 확인해주세요.

 

GitHub - rcbuilders/RemindSampleApp: https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함.

https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함. - GitHub - rcbuilders/RemindSampleApp: https://heeeju4lov.tistory.com/ 블로그에서 Android + Kotlin 강좌에서 사용함.

github.com

 

 

 

반응형