Android Kotlin Programming - MVVM + Android Architecture Components + Koin
Kotlin 언어를 공부 할 겸 기존에 Java 기반으로 해서 구축한 프로젝트를 Kotlin 으로 다시 구축하면서 기본 Framework Project 를 만드는 과정을 글로 기록해봅니다. ( 공부하는 단계이므로 정확하지 않거나 잘못된 부분이 있을 수 있습니다. )
Stack
BaseProject 에서 사용할 스택은 아래와 같습니다.
- MVVM Design Pattern
- Koin : Dependency Injection
- Android Fast Networking
- RxJava2
- Android X
- Databinding
- Navigation
- Room ( 추가 예정 )
- Paging ( 추가 예정 )
최종 소스는 아래에서 확인할 수 있습니다.
개발 시작
구글의 안드로이드 아키텍쳐 가이드를 보면 ViewModel 을 사용하여 개발할 것을 권장하고 있습니다.
수많은 디자인 패턴이 존재하며, 어느것이 맞고 틀리고는 없습니다. 각 프로젝트에 맞게, 자신의 기호에 맞게 선택하여 프로젝트를 구축하면 될 것 같습니다.
그럼 MVVM 패턴을 이용하여 프로젝트를 구성하는 방법을 알아보겠습니다.
MVVM 패턴은 Model - View - ViewModel 의 형태로 구성된 것을 의미합니다. Android 에서 View 는 Activity 또는 Xml 이라 생각하면 되고 Model의 경우 Kotlin 의 Data Class 라고 생각하면 됩니다. ViewModel 은 View와 Model 사이의 다리 역할을 하는 것으로 화면을 그리는 역할을 담당합니다.
자세한 내용은 검색
Gradle 설정
Android Architecture Component 들과 koin 등등 사용할 라이브러리들의 버전을 설정하였습니다.
파일 build.gradle ( Project )
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
constraintLayoutVersion = '2.0.0-beta1'
coroutinesVersion = "1.1.1"
fragmentVersion = '1.1.0-alpha09'
gradleVersion = '3.4.1'
glideVersion = '4.9.0'
kotlinVersion = '1.3.31'
koinVersion = '2.0.0-GA6'
ktxVersion = '1.0.2'
lifecycleVersion = '2.2.0-alpha01'
materialVersion = '1.0.0'
navigationVersion = '2.1.0-alpha04'
recyclerViewVersion = '1.1.0-alpha05'
rxandroidVersion = '2.1.0'
rxjava2Version = '2.2.2'
rx2FastAndroidNetworking = '1.0.2'
roomVersion = '2.1.0-beta01'
supportLibraryVersion = '1.1.0-alpha05'
timberVersion = '4.7.1'
}
repositories {
google()
jcenter()
}
dependencies {
classpath "com.android.tools.build:gradle:$gradleVersion"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
파일 : build.gradle ( App )
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.nextus.baseapp"
minSdkVersion 15
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
dataBinding {
enabled = true
}
}
dependencies {
//implementation fileTree(dir: 'libs', include: ['*.jar'])
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
kapt "com.github.bumptech.glide:compiler:$rootProject.glideVersion"
//kapt "com.android.databinding:compiler:$rootProject.gradleVersion"
implementation "androidx.appcompat:appcompat:$rootProject.supportLibraryVersion"
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
implementation "androidx.core:core-ktx:$rootProject.ktxVersion"
implementation "androidx.fragment:fragment-ktx:$rootProject.fragmentVersion"
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
//annotationProcessor "androidx.lifecycle:lifecycle-compiler:$rootProject.lifecycleVersion"
implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.navigationVersion"
implementation "androidx.navigation:navigation-ui-ktx:$rootProject.navigationVersion"
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
implementation "com.amitshekhar.android:rx2-android-networking:$rootProject.rx2FastAndroidNetworking"
implementation "com.github.bumptech.glide:glide:$rootProject.glideVersion"
implementation "com.google.android.material:material:$rootProject.materialVersion"
implementation "com.jakewharton.timber:timber:$rootProject.timberVersion"
implementation "io.reactivex.rxjava2:rxjava:$rootProject.rxjava2Version"
implementation "io.reactivex.rxjava2:rxandroid:$rootProject.rxandroidVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$rootProject.kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutinesVersion"
implementation "org.koin:koin-androidx-scope:$rootProject.koinVersion"
implementation "org.koin:koin-androidx-viewmodel:$rootProject.koinVersion"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}
폴더 구조
프로젝트의 패키지 구조는 위 사진처럼 구성되어 있습니다.
- data : 기본적으로 DataManager 를 통해 REST API SERVER 의 주소와 Endpoint 들을 명시하고 서버로부터 데이터를 가져올 수 있도록 함수를 선언해놓은 class 가 속해 있으며, REST API 를 콜 하였을 때 이를 받을 수 있는 Data Class 들을 선언하여 사용합니다.
- di : Koin 을 이용하여 Dependency Injection 관련 처리를 하기 위한 부분입니다.
- ui : 화면 관련 패키지입니다. 각 화면별로 패키지를 생성하여 Activity 와 ViewModel 을 모아 관리합니다.
- utils : 유틸 관련 오브젝트들을 모아놓았습니다.
MVVM 패턴의 기본 Base
쉬운 MVVM 패턴 구현을 위해 ui -> base 패키지 안에 미리 관련 클래스들을 만들어 놓았습니다.
- BaseActivity : Activity 를 만들 때 이를 상속하여 사용합니다.
- BaseFragment : Fragment 를 만들 때 이를 상속하여 사용합니다.
- BaseRecyclerAdapter : RecyclerAdapter 를 만들 때 이를 상속하여 사용합니다.
- BaseViewModel : ViewModel Class 를 만들 때 이를 상속하여 사용합니다.
1. BaseActivity
BaseActivity는 Generic 으로 Android Data Binding 에 의해 생성되는 ViewDataBinding 객체와 ViewModel 객체를 받습니다. 그리고 기본적으로 AppCompatActivity를 상속하여 구성되어 있으며, Fragment 관련 처리를 위한 CallBack Interface 가 구현되어 있습니다.
abstract class BaseActivity<T: ViewDataBinding, V: BaseViewModel<*>> : AppCompatActivity(), BaseFragment.CallBack {
private lateinit var mViewDataBinding: T
@LayoutRes
abstract fun getLayoutId(): Int
abstract fun getViewModel(): V
/**
* Binding 을 위한 함수
*/
abstract fun getBindingVariable(): Int
abstract fun setUp()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
performDataBinding()
setUp()
}
private fun performDataBinding() {
mViewDataBinding = DataBindingUtil.setContentView(this, getLayoutId())
mViewDataBinding.lifecycleOwner = this
mViewDataBinding.setVariable(getBindingVariable(), getViewModel())
mViewDataBinding.executePendingBindings()
}
fun getViewDataBinding() : T {
return mViewDataBinding
}
override fun onFragmentAttached() {
}
override fun onFragmentDetached(tag: String) {
}
}
DataBinding을 사용하기 위해서는 Gradle 에서 아래와 같은 코드를 추가해야 하며,
dataBinding {
enabled = true
}
xml 파일을 아래처럼 최상위 레이아웃을 <layout> 태그로 감싸면 자동으로 ViewDataBinding 파일이 생성되게 됩니다. 이 파일의 이름은 layout 파일명을 기반으로 생성됩니다. 이런 생성 방식의 이름이 있었는데 까먹어서 기억이 잘 안나네요...
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable name="viewModel" type="com.nextus.baseapp.ui.main.MainViewModel"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.appbar.AppBarLayout>
<fragment
android:id="@+id/navHostfragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigation"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout"
app:navGraph="@navigation/navigation_graph"/>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_bottom_navigation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
예를 들자면 activity_main 이라는 이름의 파일이라면, ActivityMainBinding 이라는 이름으로 생성되고, fragment_test_case 라는 이름의 파일이라면, FragmentTestCaseBinding 이라는 이름으로 생성됩니다.
2. BaseActivity 사용
이젠 미리 만들어 놓은 BaseActivity 를 이용해서 Activity 를 만들어 보겠습니다. file 을 생성해서 Actiity 를 만들어도 되지만, 이렇게 하면 layout 파일도 일일이 만들어야하고, Manifest 에 Activity 를 추가해야 하는 작업을 해야 하기 때문에, 아래 그림처럼 Empty Activity 생성을 해줍시다.
이렇게 해서 MainActivity 를 생성하면 아래와 같은 코드가 보일것입니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
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"
tools:context=".ui.main.TestActivity">
</androidx.constraintlayout.widget.ConstraintLayout>
우선 layout 파일부터 binding 을 위해 변경해봅시다. layout 태그로 감싸고 그 안에 data 태그를 선언하고 viewModel을 연결시켜주면 됩니다. 지금은 ViewModel 클래스를 만들지 않았으니 ViewModel Class 를 만들어 줍니다.
class MainViewModel(
application: Application,
private val dataManager: DataManager
): BaseViewModel<Any>(application)
BaseViewModel 을 상속해서 만든 MainViewModel 입니다. BaseViewModel의 <Any> 부분에는 Navigator 를 연결하여 Activity 와 ViewModel 사이를 연결할 수 있도록 하는데, 필요 없는 경우에는 위에처럼 Any 를 입력하면 됩니다. 다양한 예제를 위해 여기선 Navigator Interface 를 생성하여 연결해 보겠습니다.
기존에는 하나의 화면을 패키지 하나로 구분시키고 이 패키지 안에 Activity, ViewModel, Navigator 형태의 파일로 구분했었는데 Navigator 를 ViewModel 파일에 같이 선언하는게 관리가 편한것 같아 그 방법으로 작성해보겠습니다.
interface MainNavigator {
fun test()
}
class MainViewModel(
application: Application,
private val dataManager: DataManager
): BaseViewModel<MainNavigator>(application) {
fun actionClickTest() {
getNavigator()?.test()
}
}
MainViewModel 클래스 위에 MainNavigator 를 선언해 주고 BaseViewModel<Any> 부분을 BaseViewModel<MainNavigator> 로 선언해줍니다. 그럼 binding 을 위해 이렇게 작성한 ViewModel 을 layout 에 연결해보겠습니다.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable name="viewModel" type="com.nextus.baseapp.ui.main.MainViewModel"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.appbar.AppBarLayout>
<fragment
android:id="@+id/navHostfragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@+id/bottomNavigation"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout"
app:navGraph="@navigation/navigation_graph"/>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_bottom_navigation_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
위 코드처럼 layout 태그로 감싸고 data 태그 안에 variable 태그를 이용해 viewModel 을 작성해주면 연결이 끝납니다. 중요한 점은, variable 의 name 입니다. 여기서 작성한 name 값을 Activity 에서 binding 할 때 setVariable 함수를 통해 동일한 이름을 입력해줘야 합니다. ( Activity 작성할 때 다시 설명하겠습니다. ) 위처럼 태그로 감싸게 되면 ActivityMainBinding 이라는 클래스가 자동적으로 생성됩니다. 혹시라도 해당 클래스가 생성되지 않는다 하면, Rebuild 를 하면 생성된 것을 확인 할 수 있습니다.
그럼 이제 마지막으로 Activity 를 작성해보겠습니다.
/**
* Navigator 는 필요한 경우에만 implement
* Navigator 는 View 와 ViewModel 을 연결시켜 주는 역할
*/
class MainActivity : BaseActivity<ActivityMainBinding, MainViewModel>(), MainNavigator {
private val mMainViewModel: MainViewModel by viewModel()
override fun getLayoutId(): Int {
return R.layout.activity_main
}
override fun getViewModel(): MainViewModel {
mMainViewModel.setNavigator(this) // Navigator 사용시
return mMainViewModel
}
override fun getBindingVariable(): Int {
return BR.viewModel
}
override fun setUp() {
val host = supportFragmentManager.findFragmentById(R.id.navHostfragment) as NavHostFragment
NavigationUI.setupWithNavController(bottomNavigation, host.navController)
}
override fun test() {
AppLogger.e("Navigator Test")
}
}
BaseActivity 를 상속하고, 생성된 ActivityMainBinding 과 MainViewModel 를 제네릭 타입으로 넘겨주고, MainNavigator Interface 도 구현하기 위해 implement 시켜줍니다. ( Navi 가 필요없는 경우 이부분은 생략해도 됩니다. )
기본적으로 BaseActivity 를 상속하게 되면 아래 함수들을 Override 해야 합니다.
- getLayoutId() : Int
- getViewModel() : BaseViewModel
- gtBindingVariable() : Int
- setUp()
1) getLayoutId() : Int
화면에 그릴 레이아웃의 주소값을 넘겨줘야 합니다. R.layout.activity_main
2) getViewModel() : BaseViewModel
ViewModel 을 return value 로 반환해야 합니다. 이 부분은 Dependency Injection Library 인 Koin 을 이용하여 MainViewModel 을 Inject 하여 값을 반환합니다.
3) getBindingVariable() : Int
activity_main.xml 에서 variable 태그의 name 에 적은 이름을 넘겨주어야 합니다. 예를들어, name = viewModel 이면 BR.viewModel 으로 작성하면 되고, name = test 이면 BR.test 라고 적어주면 됩니다.
4) setUp()
setUp 함수는 Activity 의 onCreate 함수에서 호출되게 하였습니다. setUp 이 호출되는 시점에는 이미 바인딩과 화면을 그리는 작업이 완성되었기 때문에 아이디 값을 참조하여 Recycler 의 어댑터를 설정한다던지 등등의 코드를 작성하면 됩니다.
getViewModel 부분에 대해 추가 설명을 하자면 Koin 을 이용해 MainViewModel 을 Inject 해야하기 때문에, DI 관련 코드를 작성해보겠습니다. ( Koin 을 통한 Dependency Injection 방법을 소개합니다. )
di 패키지에 AppModules 파일을 만들었습니다.
val viewModelModule = module {
viewModel {
MainViewModel(get(), get())
}
viewModel {
HomeViewModel(get(), get())
}
viewModel {
MyPageViewModel(get(), get())
}
}
val apiModule = module {
single {
DataManager()
}
}
val appModules = listOf(viewModelModule, apiModule)
Android LifeCycle 을 이용한 ViewModel 을 생성하기 위해서는 위 코드 처럼 viewModel { } 로 감싼 후 ViewModel 클래스 생성자를 작성하면 됩니다. 여기서 get() 은 Koin 이 필요로 하는 오브젝트를 탐색하여 적절하게 대입해주는 기능을 합니다. ViewModel 에는 Application 과 DataManager 가 필요로 하는데 Application 은 따로 작성하지 않아도 get() 을 통해 가져올 수 있고, DataManager 는 아래 apiModule 에 있는 것 처럼 single { } 태그로 선언해놨기에 get() 으로 사용할 수 있습니다. 여기서 single 은 싱글톤을 의미합니다. 자세한 내용은 Koin Github 을 참고하면 될 것 같습니다.
이렇게 작성한 appModules 를 Applicatin Class 에서 Module 로 등록해줘야 합니다.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
AppLogger.init()
AndroidNetworking.initialize(applicationContext)
if (BuildConfig.DEBUG) {
AndroidNetworking.enableLogging(HttpLoggingInterceptor.Level.BODY)
}
// start Koin!
startKoin {
// Android context
androidContext(this@MyApplication)
// modules
modules(appModules)
}
}
}
startKoin 부분처럼 작성하면 의존성 주입을 사용할 준비가 끝났습니다.
그럼 다시 Activity 코드를 보면 아래와 같이 ViewModel 을 주입하는 부분이 있습니다.
private val mMainViewModel: MainViewModel by viewModel()
이렇게 선언을 해 주면 Koin 에 의해 ViewModel 이 생성되게 됩니다. 이를 getViewModel 에서 return value 로 반환해주면 됩니다.
( * 네비게이션 사용 시 주의할 점은 getViewModel 에서 return 하기 전에 viewModel 에 Navigator 를 세팅해줘야 합니다. )
override fun getViewModel(): MainViewModel {
mMainViewModel.setNavigator(this) // Navigator 사용시
return mMainViewModel
}