[쿠키런 쿠키 정보 앱 만들기 #4] 프로젝트 Gradle 추가 및 테스트 (1)

이전글에서 결정한 라이브러리들을 추가해보도록 하겠습니다.

프로젝트 시작


저는 Empty Activity로 시작하겠습니다. 안드로이드 8 부터 백그라운드 동작이 달라져서 저는 minSdk 를 28로 세팅하겠습니다.

Hilt

우선 힐트 자체만 추가 해주겠습니다.

Project build.gradle

buildscript {
	dependencies {
    	...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.1'
    }
}

Module build.gradle

// Module build.gradle
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
...
dependencies {
  // Hilt
  implementation "com.google.dagger:hilt-android:2.40.1"
  kapt "com.google.dagger:hilt-android-compiler:2.40.1"
}

힐트를 사용하려면 Application에 @HiltAndroidApp 어노테이션을 붙여주면 됩니다. 현재는 Application 클래스가 없으니 MainActivity 가 있는 곳에 LaunchedApplication 클래스를 만들어주겠습니다.

LaunchedApplication.kt

package com.study.cookie

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class LaunchedApplication: Application() {

}

AndroidManifest.xml

...
<application
	android:name=".LaunchedApplication"
	...

MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

LifeCycle & ViewModel

Project build.gradle

dependencies {
	...
    // ktx
    implementation 'androidx.fragment:fragment-ktx:1.4.0'

    // LifeCycle
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
    
    // Hilt
    implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
    kapt 'androidx.hilt:hilt-compiler:1.0.0'
}

ktx 라이브러리는 코틀린 델리게이트로 by viewModels() 로 쉽게 ViewModel 을 가져올 수 있기 때문에 추가했습니다. 제대로 적용됐는지 코드를 작성해보겠습니다. 이번 코드들은 라이브러리 체크 후 전부 삭제될 코드들이기 때문에 아무곳에 작성하셔도 됩니다. 저는 MainActivity.kt 아래에 전부 적었습니다.

package com.study.cookie

import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

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

        lifecycleScope.launchWhenCreated {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.count.collect {
                    Log.d("MainActivity", "$it")
                }
            }
        }
    }
}



@HiltViewModel
class MainViewModel @Inject constructor(
    testRepository: TestRepository
): ViewModel() {
    private val _count = MutableStateFlow<Int>(0)
    val count = _count.asStateFlow()

    init {
        viewModelScope.launch {
            launch {
                val number = testRepository.fetchCount()
                _count.value = number
            }
            launch {
                while (true) {
                    delay(500L)
                    _count.value++
                }
            }
        }
    }
}

class TestRepository {
    suspend fun fetchCount(): Int {
        delay(2000L)
        return 1000
    }
}

@Module
@InstallIn(SingletonComponent::class)
class TestModule {

    @Singleton
    @Provides
    fun provideTestRepository(): TestRepository {
        return TestRepository()
    }
}

Hilt 설명

우선 Hilt에서 ViewModel 을 쓰기 위해서는 클래스에 @HiltViewModel만 붙여주면 됩니다. ViewModel 은 TestRepository 를 의존하고 있습니다. Hilt 에게 의존 관계를 알려야하기 때문에 constructor@Inject 어노테이션을 붙여주면 됩니다.

하지만 Hilt는 이 TestRepository 를 어디서 가져와야 하는지 알지 못합니다 이럴 때 사용할 수 있는게 @Module입니다.

이후 InstallIn으로 스코프를 정해주고 @Singleton, @Provides를 가지고 있는 provideTestRepository() 함수를 정의해주면 싱글톤으로 InstallIn에 적용된 범위에서 TestRepository 가 필요한 곳에 DI 를 시켜줍니다.

코드 동작 설명

이제 빌드 후 앱을 실행시키면 Hilt 는 의존관계에 따라 자동으로 DI 를 시켜줍니다. MainActivity에서 by viewModels() 로 ViewModel 을 생성시키면 init 부분에서 TestRepository 의 fetchCount를 가져옵니다. 이 함수는 suspend fun이기 때문에 정지됩니다.

다른 코루틴에서는 0.5초마다 count의 값을 1씩 늘리고 있습니다. MainActivity를 보면 lifecycleScope에서 collect 하고 있기 때문에 0.5 초마다 로그가 찍히는것을 확인할 수 있습니다. 이후 2초가 지나서 fetchCount 함수가 resumed 되면 count 값을 1000으로 바꿉니다.

이 코드에서 주목해야할 점은 repeatOnLifecycle 입니다. StateFlow는 코틀린 라이브러리이기 때문에 안드로이드의 라이프사이클을 알 수 없습니다. 즉 안드로이드 백그라운드 상태에 들어가도 계속 collect하게 됩니다. 이때 repeatOnLifecycle 을 사용하면 백그라운드 상태일 때 코루틴을 멈추고 다시 시작될 때 재개시킬 수 있습니다.

Retrofit2 && Moshi

Project build.gradle

@Module
@InstallIn(SingletonComponent::class)
dependencies {
    ...
    // Retrofit2
    implementation "com.squareup.retrofit2:retrofit:2.9.0"

    // Moshi
    implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
}

기본 Retrofit2Json 데이터를 data class 로 변환시켜줄 Moshi 컨버터입니다. 여기에 보통 RxJava3 Adapter 모듈을 붙여서 같이 쓰는 경우도 있지만 이번에 저는 Coroutine (suspend fun) 을 사용해서 작업하도록 하겠습니다.

FastAPI 로 API 서버 만들기에서 만든 API 서버를 띄우고 코드를 작성하겠습니다.

class TestModule {
	...
    @Singleton
    @Provides
    @Named("localhost")
    fun provideLocalhost(): String {
        return "192.168.0.7"
    }

    @Singleton
    @Provides
    @Named("baseUrl")
    fun provideBaseUrl(
        @Named("localhost") localhost: String
    ): String {
        return "http://$localhost:8000"
    }

    @Singleton
    @Provides
    fun provideMoshiConverterFactory(): MoshiConverterFactory {
        return MoshiConverterFactory.create()
    }

    @Singleton
    @Provides
    fun provideRetrofit(
        moshiConverterFactory: MoshiConverterFactory,
        @Named("baseUrl") baseUrl: String
    ): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(moshiConverterFactory)
            .baseUrl(baseUrl)
            .build()
    }

    @Singleton
    @Provides
    fun provideCookieService(retrofit: Retrofit): CookieService {
        return retrofit.create(CookieService::class.java)
    }
}

어떤 이유에서인지(아마 애뮬레이터의 localhost가 다른것 같습니다.) localhost 를 사용하면 api 요청을 못하는 문제가 있었고 내부 아이피를 사용해서 작업했습니다. Hilt 를 사용하면 @Named 어노테이션으로 어떤것을 DI 시켜야하는지 지정이 가능한데 이 프로젝트는 지금 내부망을 사용하기 때문에 인터넷 재접속 (컴퓨터 재부팅)시 마다 IP 가 달라지기 때문에 분리를 해줬습니다.

data class CookieInfoList(
    @field:Json(name = "list")
    val cookieList: List<CookieInfo>,
    @field:Json(name = "last")
    val last: Int?,
    @field:Json(name = "next")
    val next: Boolean
)

data class CookieInfo(
    @field:Json(name = "cookie_id")
    val cookieId: Int,
    @field:Json(name = "cookie_name")
    val cookieName: String,
    @field:Json(name = "cookie_image")
    val cookieImage: String
)

interface CookieService {
    @GET("/cookie/v1/cookie_id/list")
    suspend fun getCookieInfoList(
        @Query("start") start: Int,
        @Query("length") length: Int
    ): CookieInfoList
}

이후 데이터 모델을 설정한 후 @Get 어노테이션과 suspend fun 을 사용해서 코루틴으로 사용하게 되면 RxJavaenqueue 를 사용할 때 Single<T>이나 Call<T> 같은 홀더클래스를 감쌀 필요 없이 값을 바로 사용 가능합니다 (suspend fun 은 정지가 가능하기 때문)

viewModelScope.launch {
    launch {
        while (true) {
            delay(1000L)
            cookieList.value.last?.let {
                val fetchCookieList = testRepository.fetchCookieList(
                    it,
                    length = 4
                )

                _cookieList.value = fetchCookieList
            }
        }
    }
}

이후 코루틴 안에서 데이터를 통신해보면 콜백이 필요 없이 동기적인것처럼 코드를 작성할 수 있습니다. 여기서 viewModelScope.launch는 기본이 MainThread 이기 때문에 withContext(Dispatchers.IO) 와 같은 쓰레드 전환이 필요할거라고 생각하실 수 있는데 Retrofit2 내부에서 IO로 처리하기 때문에 불필요한 코드입니다. 더 자세한 내용은 Taewhan 님의 Coroutine + Retrofit2 블로그를 보시면 됩니다.

Coil

Project build.gradle

dependencies {
	...
    // Coil
    implementation "io.coil-kt:coil:1.4.0"
}
// MainActivity.kt
viewModel.cookieList.collect { data ->
    data.cookieList.forEach {
        binding.imageView.load(
            uri = uri	
        )
    }
}

Coil 은 내부적으로 Coroutine 을 사용해서 Glide보다 더 가벼운 이미지 라이브러리입니다.
ImageView의 확장함수로 .load함수를 제공해 아주 쉽게 이미지를 불러올 수 있습니다.

Navigation && SafeArgs

Project build.gradle

// Module build.gradle
buildscript {
	dependencies {
    	...
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
    }
}

Project build.gradle

// Module build.gradle
plugin {
    ....
    id 'androidx.navigation.safeargs.kotlin'
}
...
dependencies {
    // Navigation
    implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
    implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
}

Navigation 세팅부터는 이전 코드들을 전부 삭제 후 진행하겠습니다.

MainActivity.kt

 package com.study.cookie

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

Navigation 라이브러리로 1개의 Activity N개의 Fragment 구조를 만들겠습니다. MainActivity에 activity_main.xml 을 바인딩 해줍니다.

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=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"

        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

activity_main.xml 파일은 FragmentContainerView 뷰 하나만 가지고 있습니다. 여기서 중요한점은 nav_graph 로 아래에서 부가설명 하겠습니다.

fragment_first.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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment_first"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_second.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"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment_second"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout

우선 화면전환이 필요한 두 Fragment 를 만들어주겠습니다. 둘이 구분을 위해 TextView 에 xml 이름을 넣어주었습니다.

nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/firstFragment">

    <fragment
        android:id="@+id/firstFragment"
        android:name="com.study.cookie.FirstFragment"
        android:label="FirstFragment" >
        <action
            android:id="@+id/action_firstFragment_to_secondFragment"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.study.cookie.SecondFragment"
        android:label="SecondFragment" >
        <argument
            android:name="message"
            app:argType="string" />
        <argument
            android:name="something"
            app:argType="integer" />
    </fragment>
</navigation>

Navigation 라이브러리는 직관적인 라우트 관계를 보여줍니다. 화살표로 Fragment 간 화면전환을 정의할 수 있고 xml상에 action_firstFragment_to_secondFragment가 추가됐습니다. 또 우측에 Arguments 로 두가지 변수(message: String, somehting: Int)가 있다는것도 알 수 있습니다.

FirstFragment

@AndroidEntryPoint
class FirstFragment : Fragment() {
    private lateinit var binding: FragmentFirstBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentFirstBinding.inflate(inflater, container, false)

        binding.textView.setOnClickListener {
            val directions =
                FirstFragmentDirections.actionFirstFragmentToSecondFragment(
                    message = "navigate",
                    something = 10
                )

            it.findNavController().navigate(directions)
        }

        return binding.root
    }
}

Jetpack Navigation 라이브러리를 추가하면 ~Directions가 자동으로 생기며 Nav_Graph 에서 정의했던 이동 가능한 페이지가 나옵니다. 여기서 우린 Safe Args 라이브러리를 추가로 설치했기 때문에 message 와 something 을 필수로 입력해야 이동할 수 있습니다.

SecondFragment

package com.study.cookie

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.navArgs
import com.study.cookie.databinding.FragmentSecondBinding
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class SecondFragment : Fragment() {
    private lateinit var binding: FragmentSecondBinding
    private val args: SecondFragmentArgs by navArgs()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSecondBinding.inflate(inflater, container, false)
        binding.textView.text = args.message

        return binding.root
    }
}

받는곳에서는 navArgs<SecondFragmentArgs> 델리게이트로 message와 something 변수가 생긴것을 알 수 있습니다. SafeArgs 가 없다면 args["message"] 식으로 런타임에 체크를 해야하지만 SafeArgs 라이브러리로 컴파일 타임에 데이터 모델을 생성해두기 때문에 안전하게 Fragment 간 데이터 전송이 가능해집니다.

Timber

사용할 모든 라이브러리 세팅이 완료되었습니다. (Testing 라이브러리는 나중에 추가)
이제 기존에 작성했던 모든 코드들을 다 삭제한 후 프로젝트를 시작해보겠습니다.

Project build.gradle

dependencies {
    ...
    implementation 'com.jakewharton.timber:timber:5.0.1'
}

LaunchedApplication.kt

package com.study.cookie

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp
class LaunchedApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        Timber.plant(Timber.DebugTree())
    }
}

MainActivity.kt

package com.study.cookie

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Timber.d("Hello World!")
    }
}

좋은 웹페이지 즐겨찾기