오늘 확인해 볼 내용은 api.github.com에서 데이터를 크롤링하는데, Retrofit을 이용하여 데이터를 받아오고
결과를 RecyclerView의 ListAdapter를 통해 보여주는 앱을 만들어 볼 것입니다.
큰 틀로 우선 목차를 먼저 설정 후 순차적으로 진행해보도록 하겠습니다.
1) Retrofit / Retrofit-Gson implementation
2) object 형태로 객체 생성없이 어디서든 쓸 수 있도록 하며, 공통적으로 사용할
Retrofit, okHttpClient, GsonBuilder 등을 여기서 구현할 것
3) Service 인터페이스를 만들것(요청 보낼 곳을 설정하고 GET/POST/PARAM 등 다양한 구조로 정의)
4) Network 클래스 생성(→Service 인터페이스를 객체로 구현하되, 실질적으로 활용될 메서드 구현은 각 클래스에서)
5) 모델 만들기(응답을 받을때 어떻게 받을 것인지 구조를 짜는 것)
6) RecyclerView 구현 (item.xml, ListAdapter)
1) 필요한 의존성 추가 Sync
[build.gradle.kts (app)]
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
}
libs.retrofit, libs.retrofit.gson ! 꼭 추가해주기
[libs.version.toml]
[versions]
retrofit = "2.11.0"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
https://square.github.io/retrofit/
기본 예제들을 확인해보면 알겠지만 아래와 같은 구조를 기억해야 합니다.
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://api.github.com/")
.build();
GitHubService service = retrofit.create(GitHubService.class);
Retrofit을 만들어주고 만든 곳에서 Service 인터페이스를 가져와서 retrofit.create에 넣어주는 형태를 잡아야 합니다.
따라서 저희는 Retrofit 셋팅하는 부분과 그 곳에 GsonFactory, OkHttpClient, Gson 등은 처음에 한 번만 셋팅하면 되기에
공통 코드로 object에 넣어둘 것입니다.
이 object 객체는 객체 생성 없이 구현 가능하므로 Network라는 클래스에서 가지고 올 것입니다.
이 Network 클래스에서는 Service 인터페이스의 객체를 생성하되, object 객체로 가져온 retrofit을 이용해
해당 서비스를 retrofit.create로 붙여서 구현할 것입니다.
이러한 절차의 결과로 여러 액티비티에서 서버 통신이 필요한 경우 Network 객체와 그 안에 Service를 불러오기만 한다면
Service 인터페이스에 선언된 메서드(서버로 GET, POST 등.. 하는 것) 구현이 가능할 것입니다.
2) object 형태로 초기 한 번 선언할 내용을 담아 정리하기
object ApiClient {
private const val BASE_URL = "https://api.github.com/"
val httpClient = OkHttpClient.Builder() // .addInterceptor를 통해 헤더 구현도 가능
/*
예:)
var httpClient = OkHttpClient.Builder().addInterceptor {
val request = it.request().newBuilder()
.addHeader("Authorization", "auth_key") // 헤더 Key-Value
.build()
it.proceed(request)
}
*/
val gson = GsonBuilder().setLenient().create()
val retrofit: Retrofit = Retrofit.Builder().baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.client(httpClient.build()).build()
}
3. Service Interface 구현하기
[Service.interface]
interface Service {
@GET("users/{username}/repos")
fun listRepos(@Path("username") username: String, @Query("page") page:Int): Call<List<Repo>>
@GET("search/users")
fun searchUsers(@Query("q") query: String) : Call<UserDTO>
}
이 부분에서 @Path는 경로에 {username} 부분에 대해 값으로 들어가는걸 의미하므로 @Path("username")으로 셋팅한 것이고
페이징 처리를 위해 쿼리로 Page를 넘겨주도록 하였습니다.
마찬가지로 searchUsers에서도 Query문으로 query값을 보낼건데, 서버에서는 q를 키로 갖고 있기 때문에 "q"로 넣어준 것입니다.
이 부분은 서버에서 정의한 키값과 비교하여 맞춰 줘야 합니다.
4. Network 클래스 생성(→Service 인터페이스를 객체로 구현하되, 실질적으로 활용될 메서드 구현은 각 클래스에서)
[Network.kt]
class Network {
private var service: Serivce? = null
companion object {
@Volatile
private var instance: Network? = null
fun getInstance(): Network {
return instance ?: synchronized(this) {
instance ?: Network().alse { instance = it }
}
}
}
init {
service = ApiClient.retrofit.create(Service::class.java)
}
fun getService(): Service {
return service!!
}
}
다음 네트워크 클래스 코드를 설명하자면, 우선 여기서 서비스 인터페이스를 가져옵니다.
서비스 인터페이스는 ApiClient Object에서 retrofit을 가져와 생성하는 과정이고 이 서비스를 객체로 반환하여
다른 액티비티에서 [Network -> Service -> 메서드 구현] 형태로 진행될 것입니다.
Network 인스턴스를 외부에서 자유롭게 쓰기 위해서 companion object로 하여 static 형태로 구현하였습니다.
또한 여러 Thread에서 공통적으로 읽기 작업이 일어날 수 있으며, 캐싱처리 되어 빈번히 바뀌는 것이
아닌 "객체" 형태이기에 @Volatile를 사용하여 인스턴스를 만들었습니다.
또한 해당 인스턴스를 외부에서 진짜 Network 클래스로 생성할 수 있도록 getInstance 함수를 사용하여
Network 인스턴스를 반환하는데, 이 부분에서도 해당 Instance를 매번 최신화 작업이 유지되어야 하므로
다른 Thread에서 작업중이면 접근하지 못하도록 동기화 처리를 진행합니다.
이처럼 Network.getInstance를 통해 객체를 만든다면 생성자(init)에 의해 자동으로 service는 retrofit을 생성하게 될 것입니다.
이 객체는 getService()를 통해 Service 인터페이스에 접근하고 자연히 내부에 정의된 메서드를 구현하도록 할 것입니다.
이제 실제 액티비티에서는 어떻게 활용되는지 보여드리겠습니다.
우선 다음 내용들은 Network 작업이고 데이터를 다루는 작업이기 때문에 UI Thread에서 이뤄지지 않고
워커 스레드에서 활용되는 점을 생각해야 합니다.
RxJava나 Coroutine을 사용하면 더 좋지만, 우선 그 부분은 좀 더 자세히 다루고자 나중에 확인하도록 하고
Runnable 객체를 통한 스레드 생성을 하여 구현하도록 하겠습니다.
[MainActivity.kt]
class MainActivity: AppCompatActivity(){
private lateinit var binding: ActivityMainBinding
private var network: Network? = null
private var handler: Handler = Handler(Looper.getMainLooper())
private var searchFor: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
network = Network.getInstance(). // Network 객체 생성
val userAdapter = UserAdapter {
val intent = Intent(this@MainActivity, RepoActivity::class.java)
intent.putExtra("username", it.username)
startActivity(intent)
}
binding.userRecyclerView.apply {
layoutManager = LinearLayoutManager(context)
adapter = userAdapter
}
val runnable = Runnable {
searchUser(this@MainActivity, userAdapter, searchFor)
}
binding.searchEditText.addTextChangedListener { text ->
searchFor = text.toString()
handler.removeCallbacks(runnable). // 기존껀 지우고 새로운 값을 받을 준비
// DeBouncing --> a 들어가서 작업하는데 응답 없으면 b가 들어가서 작업하고 b가 먼저 반환되고..
// RxJava, Coroutine을 하면 좀 더 편하고 낫지만 우선 Handler로..
handler.postDelayed(
runnable,
300
)
}
}
private fun searchUsers(context: Context, userAdapter: UserAdapter, query: String){
network?.getService()!!.searchUsers(query).enqueue(object: Callback<UserDTO> {
override fun onResponse(call: Call<UserDTO>, response: Response<UserDTO>) {
if(response.isSuccessful){
userAdapter.submitList(response.body()?.items)
}
}
override fun onFailure(call: Call<UserDTO>, t: Throwable) {
Toast.makeText(context, "데이터 가져오기 실패", Toast.LENGTH_SHOR).show()
}
}
}
}
이런식으로 Network를 getInstance()를 통해 객체를 가져오고 getService()를 통해 service를 가져온 뒤
인터페이스 접근 후 서버로부터 데이터를 받는 것입니다.
자, 그렇다면 여기서 Serivce 인터페이스에서 searchUsers 함수를 구현하는것 알겠고 query라는 String 형태를 보내서
@GET("search/users") 경로에서 조건 형태로 가져오는 것까지 이해가 될 것입니다.
근데 뒤에 enqueue 부분은 무엇인지.. 모를거라 생각합니다. 지금부터 설명할 것입니다.
[Service.interface]
interface Service {
@GET("users/{username}/repos")
fun listRepos(@Path("username") username:String, @Query("page") page: Int) : Call<List<Repo>>
@GET("search/users")
fun searchUsers(@Query("q") query: String) : Call<UserDTO>
}
서비스 인터페이스에서 searchUsers 함수를 보게되면 "(baseUrl) + search/users 경로에서 query라는 문자열을
쿼리 형태로 받으면 (서버에 구축된 필드 값은 q임) 유저 정보를 넘겨줄 것인데 그 스키마는 UserDTO 형태입니다."
를 의미하게 됩니다.
또한 Call로 넘겨주고 있으니 이 함수를 받는 부분에서는 Callback으로 구현되어야 합니다.
다음 콜백이 호출되더라도 상황상 언제 시작될 지 모르기 때문에 우선 큐에 넣는 과정을 거치는 것이고
순차적으로 수행되도록 정의됩니다.
이제 여기서 UserDTO 형태를 확인하면 됩니다.
이 부분이 "5) 모델 만들기(응답을 받을때 어떻게 받을 것인지 구조를 짜는 것)" 입니다.
[UserDTO.kt] = data class
package com.example.githubrepositories.model
import com.google.gson.annotations.SerializedName
data class UserDTO(
@SerializedName("total_count")
val totalCount: Int,
@SerializedName("items")
val items: List<User>
)
이처럼 모델이 되는 부분은 data class를 통해 구현해줍니다.
@SerializeName은 서버로부터 받은 Key값과 동일하게 구현해야 합니다.
val로 정의한 totalCount, Items는 코틀린 환경에서 그냥 사용하는 상수로 쓰면 그만인데, 서버와 연동하여
데이터를 주고 받고 할 경우에는 반드시 서버에서 정의한 필드명과 동일해야하므로 @SerializedName을 통해 맞추는 것입니다.
따라서 나중에 apk 파일을 디컴파일 하더라도 서버와 연결된 키 값은 나오지 않고 val로 선언한 값만 나올 것이라 더 안전할 수 있습니다.
마지막으로 RecyclerView를 통해 서버로부터 받은 내용을 submitList에 넣어 어댑터를 관리하였습니다.
다음 RecyclerView는 실제로 ListAdapter를 활용하여 DiffUtil을 구현해둔 클래스이므로
별도의 데이터셋 변화에 대한 notify를 하지 않고 submitList로 변화된 리스트만 담아주더라도 자동으로
데이터 변경을 인식하고 알아서 처리하게 됩니다.
이 어댑터 쪽 부분은 아래 코드와 같습니다.
[UserAdapter.kt]
package com.example.githubrepositories.adapter
import android.content.Context
import android.text.Layout
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.githubrepositories.databinding.ItemUserBinding
import com.example.githubrepositories.model.User
class UserAdapter(val onClick: (User) -> Unit) :
ListAdapter<User, UserAdapter.UserAdapterViewHolder>(diffUtil) {
companion object {
// DiffUtl.ItemCallback -> ListAdapter에 인자로 사용
// 기존꺼, 새로운거 같은 값인지 확인
val diffUtil = object : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
// data class이기 때문에 이 안에서는 equals, toString 모두 다 내재되어 구현되어 있으므로
// 객체 자체로 비교해도 됨
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
}
inner class UserAdapterViewHolder(private val binding: ItemUserBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: User) {
binding.usernameTextView.text = item.username
binding.root.setOnClickListener {
onClick(item) // onClick -> User 넘겨주기
}
}
}
// ViewHolder를 리턴해주는데,
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAdapterViewHolder {
return UserAdapterViewHolder(
binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
// val binding: I
// val layoutInflater =
// parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
// val binding: ItemUserBinding = ItemUserBinding.inflate(layoutInflater, parent, false)
// return UserAdapterViewHolder(binding)
}
override fun onBindViewHolder(holder: UserAdapterViewHolder, position: Int) {
holder.bind(currentList[position])
}
}
전체적인 코드는 아래와 같습니다. 상세 내용은 코드가 좀 더 길기 때문에 깃허브 주소로 남겨두겠습니다.
충분히 Retrofit과 Service 인터페이스 그리고 Network를 활용한 접근방법 등에 대해서 정리하였습니다.
이러한 내용이 꽤나 많이 사용될 것이며, 다음 포스팅에서도 다음 내용을 좀 더 심화하고 Material Design으로 좀 더 깔끔한
형태의 프로젝트로 다시 찾아뵙겠습니다. 감사합니다.
'Android' 카테고리의 다른 글
Firebase를 통한 로그인 구현 (2) | 2024.10.28 |
---|---|
Network - OkHttpClient / Socket 통신 (0) | 2024.08.25 |
WebView & ViewPager2 / Fragment (0) | 2024.08.16 |
ViewPager2와 TabLayout (0) | 2024.08.14 |
Permission 처리와 ListAdapter 활용법 (0) | 2024.08.01 |