이전 글: [Android] 클린 아키텍처+ Dagger Hilt 적용하기 - 1
Dagger Hilt란?
프로젝트에서 종속 항목 수동 삽입을 실행하려면 모든 클래스와 종속 항목을 수동으로 구성하고 컨테이너를 사용해 종속 항목을 재사용 및 관리해야 합니다.
Hilt는 프로젝트의 모든 안드로이드 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI(의존성 주입)를 사용하는 표준 방법을 제공해줍니다.
의존성 주입(DI)의 장점은 상위 모듈과 하위 모듈의 의존성을 느슨하게 해주는 장점이 있습니다!
또하나의 장점으론 모듈들이 더욱 분리되므로 단위(Unit) 테스트에도 유리합니다
Hilt에 대한 더 자세한 설명은 Hilt 자세히 알아보기 를 참고해주시기 바랍니다!
Dagger hilt는 러닝 커브가 Koin (또다른 DI 라이브러리)보다 낮지만 그렇다고 진입장벽이 낮은편은 아닙니다!
종속 항목 추가 (2024.3.17 기준)
build.gradle (project 수준)
plugins {
...
id("com.google.dagger.hilt.android") version "2.44" apply false
}
build.gradle (app 수준)
plugins {
kotlin("kapt")
id("com.google.dagger.hilt.android")
}
android {
...
}
dependencies {
implementation("com.google.dagger:hilt-android:2.44")
kapt("com.google.dagger:hilt-android-compiler:2.44")
}
// Allow references to generated code
kapt {
correctErrorTypes = true
}
서울 열린데이터 광장에서 주차장의 정보를 받고 각 지역마다 주차장의 위치, 개장/폐쇄 시간을 사용자에게 보여주는 앱을 클린 아키텍처 + Dagger Hilt 구조로 만들어보겠습니다!
1.서울열린데이터 광장 접속 (https://data.seoul.go.kr/) - 회원가입 및 로그인
2.서울시 공영주차장 안내 정보 접속 (https://data.seoul.go.kr/dataList/OA-13122/S/1/datasetView.do)
3. 인증키 신청
우선 요청인자부터 확인해보겠습니다!
타입 중에 필수라 적혀있는 부분은 반드시 적어주셔야 원활한 서버 요청을 할 수 있습니다.
한번 url로 API를 호출해볼까요?
여기서 주의해야할 점은 본인 인증키 작성란엔 꼭 발급받은 인증키로 사용하셔야합니다!
이렇게 주차장에 대한 정보들을 JSON 형태로 확인할 수 있습니다!
이제 적용해볼까요?
디렉터리 구조
Hilt를 사용하기 위해선 @HiltAndroidApp 어노테이션을 사용해 컴포넌트 빌딩에 필요한 클래스들을 초기화해줘야합니다! Application 클래스를 상속받고 있는 클래스에 선언해주시면 됩니다!
@HiltAndroidApp
class BaseApplication: Application() {
override fun onCreate() {
super.onCreate()
}
}
보통 API 키 및 BASE URL은 환경변수나 다른 방법을 통해 숨겨서 관리하는 것이 맞지만, 해당 프로젝트에선 공부용이기 때문에 간편하게 Constants Object에 선언해줍니다!
data 계층
data 디렉터리는 총 3가지로 나뉘게 해놨습니다!
RetrofitApi: API 통신에 필요한 컴포넌트 모듈들을 install
SeoulParkResponse: 서버에서 응답받는 JSON 데이터
SeoulParkService: 서버 통신 요청 인터페이스
@Module
@InstallIn(SingletonComponent::class)
class RetrofitApi {
@Singleton
@Provides
fun getInstance(okHttpClient: OkHttpClient): Retrofit{
return Retrofit.Builder().client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.client(getOkHttpClient())
.baseUrl(Constants.park_BASE_URL)
.build()
}
@Singleton
@Provides
fun getOkHttpClient(): OkHttpClient{
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
}
@Singleton
@Provides
fun getSeoulParkService(retrofit: Retrofit): SeoulParkService {
return retrofit.create(SeoulParkService::class.java)
}
}
추가적으로 HTTP API 통신을 하기 위한 라이브러리로 Retrofit을 사용했고, 컨버터는 모시(Moshi)를 사용했습니다!
GSON이나 다른 요소들도 있다고 하는데 모시가 가장 코틀린 친화적이기 때문에 모시를 사용했습니다.
Retrofit, Okhttp의 경우 Hilt는 외부 라이브러리에 대해 직접적으로 종속관계 삽입이 불가하기 때문에 Hilt 모듈울 이용해 Hilt에 결합 정보를 제공할 수 있습니다!
설명드리기 앞서 Kotlin Data Class Code라는 외부 플러그인을 설치하시기 바랍니다!
이 플러그인을 사용하면 직접 JSON 데이터들을 하나하나 작성하는 것이 아닌, 플러그인이 자동으로 JSON Text로 변환해줍니다!
일전에 응답 받았던 데이터들을 전부 복사해줍니다!
플러그인에 붙여놓고 format 버튼을 누르면 가독성 좋게 자동으로 변환해줍니다!
이제 Class Name(SeoulParkResposne)을 작성하고 Generate를 누르면
총 4개의 데이터 클래스들이 생성되는 것을 볼 수 있습니다!
이렇게 분리해도 상관은 없지만, 관리하기 편하게 모두 상위 데이터 클래스 위치(SeoulParkResponse)에 나머지 코드들을 붙여줍시다!
interface SeoulParkService {
@GET("{api_key}/json/GetParkInfo/1/50")
suspend fun getDataCoroutine(
@Path("api_key") api_key: String
): Response<SeoulParkResponse>
}
비동기 통신을 위한 suspend 키워드를 선언해준 SeoulParkService 인터페이스를 생성합니다. 여기서 @Path 어노테이션을 통해 직접 GET에 넣지 않고 적용할 우 있습니다!
domain 계층
이렇게 소규모 앱은 굳이 도메인 계층을 작성하지 않아도 되고, 필수계층은 아니기에 생략합니다! 추후에 프로젝트에 적용 할 일 생기면 게시글 한번 더 올려보려고 합니다
presentation 계층
UI를 담당하는 부분입니다. 들어가기 앞서 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=".presentation.MainActivity">
<ProgressBar
android:id="@+id/pb_main"
android:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:padding="10dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/item_parkinfo" />
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/layout_viewholder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="10dp"
>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="9"
android:orientation="vertical"
>
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="주차장명"
android:textStyle="bold"
android:textSize="24sp"
android:padding="3dp"
/>
<TextView
android:id="@+id/tv_parkType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="주차장 종류"
android:textSize="18sp"
/>
<TextView
android:id="@+id/tv_parkTell"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="번호"
android:textSize="18sp"
/>
<TextView
android:id="@+id/tv_parkStartTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="운영시작"
android:textSize="18sp"
/>
<TextView
android:id="@+id/tv_parkEndTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="운영종료"
android:textSize="18sp"
/>
</LinearLayout>
<ImageView
android:layout_width="30dp"
android:layout_height="30dp"
android:src="@drawable/baseline_format_list_bulleted_24"
/>
</LinearLayout>
@HiltViewModel
class MainViewModel @Inject constructor(
private val seoulParkService: SeoulParkService
): ViewModel() {
private val _response = MutableLiveData<List<Row?>>()
val response: LiveData<List<Row?>>
get() = _response
fun retrofitLoad(){
viewModelScope.launch {
withContext(Dispatchers.Main){
val parkResponse = seoulParkService.getDataCoroutine(Constants.park_api_key)
if(parkResponse.isSuccessful){
_response.value = parkResponse.body()?.getParkInfo?.row
}else{
_response.value = null
}
}
}
}
}
아까 생성했던 SeoulParkService를 주입받는 MainViewModel 코드입니다!
response LiveData를 생성해 응답 데이터를 Observe 할 수 있도록 선언해두었습니다
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val binding: ActivityMainBinding by lazy {
ActivityMainBinding.inflate(layoutInflater)
}
private val parkAdapter: SeoulParkAdapter by lazy {
SeoulParkAdapter()
}
// Hilt가 mainViewModel을 초기화 시켜준다
private val mainViewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
// 어뎁터 연결
binding.rvMain.apply {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
adapter = parkAdapter
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
// api로부터 데이터 받아오는 retrofitLoad() 수행
mainViewModel.retrofitLoad()
// observe를 통해 데이터 변경을 감지
mainViewModel.response.observe(this, Observer {Row->
Row?.let {
// 응답이 온다면 ProgressBar 숨기기
binding.pbMain.visibility = View.INVISIBLE
// 데이터 갱신
parkAdapter.submitList(it)
}
})
}
}
class SeoulParkAdapter: ListAdapter<Row, SeoulParkAdapter.SeoulParkViewHolder>(DiffCallback) {
class SeoulParkViewHolder(private val binding: ItemParkinfoBinding):
RecyclerView.ViewHolder(binding.root){
fun bind(item: Row){
with(binding){
tvTitle.text = item.pARKINGNAME
tvParkType.text = item.pARKINGTYPE
tvParkTell.text = item.tEL ?: "번호정보 없음"
tvParkStartTime.text = "${convertTimeFormat(item.wEEKDAYBEGINTIME.toString())}"
tvParkEndTime.text = "${convertTimeFormat(item.wEEKENDENDTIME.toString())}"
}
}
fun convertTimeFormat(time: String): String{
val inputTime = SimpleDateFormat("HHmm", Locale.getDefault())
val outputTime = SimpleDateFormat("HH:mm", Locale.getDefault())
val parsedTime = inputTime.parse(time)
return outputTime.format(parsedTime)
}
}
companion object{
private val DiffCallback = object : DiffUtil.ItemCallback<Row>(){
override fun areItemsTheSame(oldItem: Row, newItem: Row): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
override fun areContentsTheSame(oldItem: Row, newItem: Row): Boolean {
return oldItem == newItem
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SeoulParkViewHolder {
val binding = ItemParkinfoBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return SeoulParkViewHolder(binding)
}
override fun onBindViewHolder(holder: SeoulParkViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
마치며..
클린아키텍처의 필요성에 대해 확실하게 느껴지는 프로젝트는 아니지만, 이 정도로 감을 잡고 추후 대형 프로젝트를 경험할 기회가 생긴다면 꼭 적용해보길 바랍니다!
저도 엄청 큰 규모는 아니지만 몇번 프로젝트를 진행하며 클린아키텍처를 적용한 프로젝트와 그렇지 않은 프로젝트의 차이점이 상당하더라구요...
실제 프로젝트 막바지 쯤에 기능 수정 할 일이 생겼는데 클린 아키텍처를 적용한 프로젝트는 정말 수월하게 부품갈아끼우듯이 수정이 가능했지만
그렇지 않은 프로젝트는 하나 수정하니 여러개를 수정해야해서 많은 시간을 허비했습니다..
무조건 클린아키텍처다! 이건아닙니다. 다른 프로젝트에 알맞는 디자인 패턴이나 팀원들과의 조율을 통해 더 좋은 설계도 할 수 있습니다!