[쿠키런 쿠키 정보 앱 만들기 #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'
}
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"
}
@Module
@InstallIn(SingletonComponent::class)
dependencies {
...
// Retrofit2
implementation "com.squareup.retrofit2:retrofit:2.9.0"
// Moshi
implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
}
기본 Retrofit2
와 Json 데이터를 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
을 사용해서 코루틴으로 사용하게 되면 RxJava
나 enqueue
를 사용할 때 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
)
}
}
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"
}
// Module build.gradle
buildscript {
dependencies {
...
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
}
}
// 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!")
}
}
Author And Source
이 문제에 관하여([쿠키런 쿠키 정보 앱 만들기 #4] 프로젝트 Gradle 추가 및 테스트 (1)), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@someh/쿠키런-쿠키-정보-앱-만들기-4-프로젝트-Gradle-추가-및-테스트-1저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)