Android에서는 화면을 보여주기 위해서 다양한 곳에서 다양한 데이터를 받아와서 구성하고있습니다.
데이터를 가져오는 곳을 크게 나누면 휴대폰 내에서 가져오는 경우와 인터넷에서 가져오는 경우로 나눌 수 있습니다.
내부 저장소 또는 외부 저장소에서 파일로 가져오거나 key-value 형식으로 저장하는 sharedpreferences를 이용할 수도 있고
Fragment간 데이터 전달, Activity간 데이터 전달을 할 수도 있지만
여기서는 내부 DB를 이용하는 방식에 대해서 알아보겠습니다.
Android에서는 SQLite를 기본적으로 제공하지만 공식 문서에서는 아래의 이유로 Room 라이브러리를 추천하고 있습니다.
앱에서 가장 많이 수행하는 처리중 하나가 서버에 데이터를 요청하고 받아온 데이터를 화면에 보여주는 것입니다.
이 때 클라이언트와 서버가 통신하는 방식은 크게 소켓연결과 HTTP연결 두 가지로 나눌 수 있습니다.
소켓 연결
소켓 연결의 경우 서버와 연결을 유지하며 실시간으로 양방향 통신을 할 수 있어서 주로 동영상 스트리밍이나, 온라인 게임등에서 사용되는 연결방식입니다.
소켓 통신을 이용한 채팅앱 예제는 다음 사이트를 참고할 수 있습니다.
HTTP 연결
HTTP 통신에서는 클라이언트가 서버에 헤더(header)와 바디(body)로 이루어진 메시지를 요청(request)합니다.
그러면 서버는 해당 요청을 처리하고 응답코드와 함께 응답(response)을 반환하게 됩니다.
HTTP 프로토콜은 연결을 유지하지않고 통신이 일어날 때 새로운 접속을 생성하고 삭제하며 데이터를 주고 받는 Connectionless, stateless의 특징을 갖고있습니다.
HttpUrlConnection
안드로이드에서는 한때 HttpUrlConnection로 AsyncTask를 이용해 접근하는 방식을 사용했었다. 해당 방법은 버퍼를 통한 입출력, 예외처리, 쓰레드, 캐시처리 등 개발자가 많은 부분들을 신경써야 해 실수할 가능성이 컸습니다.
Volley
이를 위해 쓰기 쉽도록 래핑한 Volley를 2013년 Google I/O에서 발표하였습니다.
OkHttp
또한 같은 시기에 Square에서 okio와 코틀린을 이용해 OkHttp를 발표하게 됩니다.
Retrofit
이후 같은 Square에서 이를 래핑한 Retrofit 라이브러리가 나오게 됩니다.
하지만 안드로이드 5.1(롤리팝)부터 HttpClient가 Deprecated 된 후, HttpClient에 의존하던 Volley도 사실상 쓰이지 않게 되었습니다.
이로 인해 이번 포스팅에서는 Retrofit에 대해 알아보겠습니다.
Room 지속성 라이브러리는 SQLite를 활용하면서 원활한 데이터베이스 액세스가 가능하도록 SQLite에 추상화 계층을 제공합니다. 특히 Room을 사용하면 다음과 같은 이점이 있습니다.
종속성
dependencies {
def room_version = "2.4.2"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
// optional - RxJava2 support for Room
implementation "androidx.room:room-rxjava2:$room_version"
// optional - RxJava3 support for Room
implementation "androidx.room:room-rxjava3:$room_version"
// optional - Guava support for Room, including Optional and ListenableFuture
implementation "androidx.room:room-guava:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
// optional - Paging 3 Integration
implementation "androidx.room:room-paging:2.5.0-alpha02"
}
kotlin에서 Room을 사용 할 때 에러가 날 경우
annotationProcessor "androidx.room:room-compiler:$room_version"
를 아래와 같이 kapt로 바꿔야합니다.
kapt "androidx.room:room-compiler:2.4.2"
Room은 데이터 모델인 Entity, 데이터 접근 객체인 DAO, 데이터베이스로 구성되어 있습니다.
각각은 Annotation을 이용해 정의합니다.
Entity
다음 예시와 같이 모델을 정의할 수 있습니다.
@Entity
data class User(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)
DAO
다음 코드는 UserDao라는 DAO를 정의합니다.
UserDao는 앱의 나머지 부분이 user 테이블의 데이터와 상호작용하는 데 사용하는 메서드를 제공하도록 Interface를 만듭니다.
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<User>
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<User>
@Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
"last_name LIKE :last LIMIT 1")
fun findByName(first: String, last: String): User
@Insert
fun insertAll(vararg users: User)
@Delete
fun delete(user: User)
}
Database
다음 코드는 데이터베이스를 보유할 AppDatabase 클래스를 정의합니다.
AppDatabase는 데이터베이스 구성을 정의하고 데이터에 대한 앱의 기본 액세스 포인트 역할을 합니다.
데이터베이스 클래스에는 데이터베이스와 연결된 데이터 항목을 모두 나열하는 entities 배열이 포함된 @Database 주석이 달려야 합니다.
여기서 반환하는 UserDao는 위에서 만든 Entity입니다
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
각 RoomDatabase 인스턴스는 리소스를 상당히 많이 소비하며 단일 프로세스 내에서 여러 인스턴스에 액세스해야 하는 경우는 거의 없기에
앱이 단일 프로세스에서 실행된다면 AppDatabase 객체를 인스턴스화할 때 싱글톤 디자인 패턴을 따라야 합니다.
앱이 여러 프로세스에서 실행되는 경우 데이터베이스 빌더 호출 enableMultiInstanceInvalidation()을 포함하세요.
데이터베이스 사용
데이터 항목과 DAO, 데이터베이스 객체를 정의한 후에는 다음 코드를 사용하여 데이터베이스 인스턴스를 만들 수 있습니다.
val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java, "database-name"
).build()
또한 다음 코드를 이용해 생성된 객체를 이용할 수 있습니다.
val userDao = db.userDao()
val users: List<User> = userDao.getAll()
Primary Key 정의 : @asdf
@PrimaryKey val id: Int
자동 증가 키 생성
@PrimaryKey(autoGenerate = true) var id: Int = 0
복합 기본 키 정의 : @Entity(primaryKeys = ["firstName", "lastName"])
@Entity(primaryKeys = ["firstName", "lastName"])
복합 기본 키 정의 : @Entity(primaryKeys = ["firstName", "lastName"])
@Entity(primaryKeys = ["firstName", "lastName"])
기본적으로 Room 은 entity 에 정의된 모든 field 에 대해 column 을 생성한다.
만약 entity 가 가진 field 중에 column 으로 만들고 싶지 않은 것이 있다면, @Ignore
annotation 을 넣어주면 된다.
@Entity
data class User(
@PrimaryKey val id: Int,
val firstName: String?,
val lastName: String?,
@Ignore val picture: Bitmap?
)
상속받아 사용하는경우 Entity에 ignoredColumns 속성을 사용합니다.
open class User {
var picture: Bitmap? = null
}
@Entity(ignoredColumns = ["picture"])
data class RemoteUser(
@PrimaryKey val id: Int,
val hasVpn: Boolean
) : User()
insert, delete, update의 경우 annotation이 각 이름과 동일하다
insert에서 아래와 같이 onConflict 를 추가해 주면 동일 항목의 경우 자동으로 데이터를 덮어씌웁니다.
@Insert(onConflict = OnConflictStrategy.REPLACE)
Select문의 경우 @Query
문을 이용할 수 있다.
쿼리에 매개변수 전달
@Query("SELECT * FROM user WHERE age > :minAge")
fun loadAllUsersOlderThan(minAge: Int): Array<User>
멀티 매핑 반환
@Query(
"SELECT * FROM user" +
"JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<User, List<Book>>
Room은 버전 2.4 이상에서만 멀티매핑 반환 유형을 지원합니다.
전체 객채를 반환하지 않고 특정 컬럼만 반환할 경우
@MapInfo
주석에서 keyColumn 및 valueColumn 속성을 설정하여 특정 열 간 매핑을 반환할 수도 있습니다.
@MapInfo(keyColumn = "userName", valueColumn = "bookName")
@Query(
"SELECT user.name AS username, book.name AS bookname FROM user" +
"JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<String, List<String>>
관찰 가능한 쿼리는 읽기 작업으로, 쿼리에서 참조하는 테이블이 변경될 때마다 새 값을 내보냅니다.
이를 사용하여 기본 데이터베이스의 항목이 삽입되거나 업데이트되거나 삭제될 때 표시된 항목 목록을 최신 상태로 유지할 수 있습니다.
리턴 타입을 Flow나 LiveData로 설정합니다.
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id = :id")
fun loadUserById(id: Int): LiveData<User>
@Query("SELECT * from user WHERE region IN (:regions)")
fun loadUsersByRegion(regions: List<String>): Flow<List<User>>
}
받은 LiveData는 다음과 같은 형태로 쓰일 수 있습니다.
val currentID: MutableLiveData<Int> = MutableLiveData<Int>(0) val user: LiveData<User> = currentID.switchMap { userDao.getUserById(it) }
Room을 사용해서 데이터를 비동기로 만들고 보여주는 어플을 만들어 봅시다.
먼저 Room을 사용하기 위한 클래스들을 만듭니다.
@Entity
data class User(
@PrimaryKey val id: Int,
val email: String?,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?,
val avatar: String?
)
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id = :userId")
fun getUserById(userId:Int): LiveData<User>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(vararg users: User)
@Delete
fun delete(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object{
@Volatile private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return instance ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java, "user"
).build()
}
}
}
}
해당 데이터들을 ViewModel과 LiveData를 이용해서 비동기적으로 처리 및 MVVM패턴으로 구현하겠습니다.
ViewModel, LiveData, Databinding에 대한 정보는
해당 포스팅에 나와있습니다.
ViewModel
class UserViewModel(var userDao:UserDao):ViewModel() {
val currentID: MutableLiveData<Int> = MutableLiveData<Int>(0)
val user: LiveData<User> = currentID.switchMap {
userDao.getUserById(it)
}
fun addNewUser(){
viewModelScope.launch(Dispatchers.IO) {
userDao.insertAll(User(currentID.value ?: 0, "email","local","db",""))
}
}
fun moveToIndex(index:Int){
currentID.value = currentID.value?.plus(index)
}
}
class UserVMFactory(private val param: UserDao) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
UserViewModel(param) as T
} else {
throw IllegalArgumentException()
}
}
}
Activity
class MainActivity : AppCompatActivity() {
lateinit var binding:ActivityMainBinding
private lateinit var userViewModel:UserViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
setContentView(binding.root)
val userDao = AppDatabase.getInstance(applicationContext).userDao()
userViewModel = ViewModelProvider(this, UserVMFactory(userDao))[UserViewModel::class.java]
binding.viewModel = userViewModel
binding.lifecycleOwner = this
binding.btnAddRoom.setOnClickListener { btnOnClick(it) }
}
private fun btnOnClick(v: View){
if(userViewModel.user.value == null){
if(v.id == R.id.btnAddRoom)
userViewModel.addNewUser()
}else{
Toast.makeText(this,"이미 데이터가 있습니다.",Toast.LENGTH_SHORT).show()
}
}
}
여기까지 진행된 코드입니다.
https://github.com/WorldOneTop/AndroidJetpackSample/tree/7093a5f3f29d6ba16d47467cfabc0852d339e62d
먼저 Retrofit으로 통신을 하기 전에 의존성 및 인터넷 권한 설정을 설정합니다.
retrofit과 객체 변환을 도와줄 gson
과 retrofit-converter
또한 implementation합니다.
Gson는 google에서 제공하는 Java 라이브러리로 Java 객체를 Json으로 변환 또는 Json을 Java 객체로 변환하는 데 사용됩니다.
build.gradle
dependencies {
...
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.8.6'
}
권한설정은 manifest 파일에서 합니다.
<manifest
...>
<uses-permission android:name="android.permission.INTERNET"/>
<application
...
</application>
</manifest>
인터넷 권한은 따로 사용자에게 접근권한을 얻지 않아도 될 낮은 퍼미션입니다.
https가 아닌 http로 인터넷을 연결하고자 한다면 보안 예외처리를 해줘야합니다.
json 데이터를 받아올 무료 RestAPI 서버에서 진행하겠습니다.
https://reqres.in/api
https://reqres.in/api/users/2
받아오는 데이터 구조는 다음과 같습니다.
{
"data":{
"id":1,
"email":"george.bluth@reqres.in",
"first_name":"George",
"last_name":"Bluth",
"avatar":"https://reqres.in/img/faces/1-image.jpg"
},
"support":{
"url":"https://reqres.in/#support-heading",
"text":"To keep ReqRes free, contributions towards server costs are appreciated!"
}
}
해당 JSON에 맞게 객체로 치환할 data class를 준비합니다.
위에 Room에서 만든 User는 응용해봅시다.
처음에 위치한 키값이 data 이기 때문에 이를 래핑합니다.
@Entity
data class User(
@PrimaryKey val id: Int,
val email: String?,
val avatar: String?,
@SerializedName("first_name")
@ColumnInfo(name = "first_name") val firstName: String?,
@SerializedName("last_name")
@ColumnInfo(name = "last_name") val lastName: String?
)
data class ResponseData(
@SerializedName("data")
val user: User?,
)
@SerializedName를 사용하면 JSON 키와 다른 이름의 변수명을 사용할 수 있습니다.
하지만 이름의 변경이 없는 경우에도 @SerializedName 어노테이션을 붙이는 것이 좋은데, 그 이유는 애플리케이션을 Release 할 때 소스 코드가 난독화 되는 과정에서 kotlin 변수가 변환되고, 이로 인해 Gson 매핑에 오작동이 일어날 수 있기 때문에 @SerializedName는 되도록 사용하는 것이 좋다고 합니다.
그다음 Retrofit에 넣어줄 Request interface를 구현합니다.
interface Request {
@Headers("Content-Type: application/json")
@GET("users/{id}")
fun getUser(@Path("id") id: Int): Call<ResponseData>
}
@Headers
에 받을 컨텐츠 타입을 명시하고
@GET
으로 보낼 메서드와 경로를 적습니다. 이때 경로는 base url을 제외한 추가적으로 들어갈 경로입니다.
{id}
의 경우 인자값인 @Path("id")로 치환됩니다
그리고 레트로핏 객체를 생성할 빌더를 싱글톤으로 구현합니다.
Retrofit 객체는 비용이 높기 때문에 여러 객체가 만들어지면 자원낭비 및 통신에 혼선이 올 수 있기 때문에 object 키워드를 통해 싱글턴으로 만들어줍니다.
object RetrofitAPI {
private const val BASE_URL = "https://reqres.in/api/"
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val request: Request by lazy {
retrofit.create(Request::class.java)
}
}
builder 패턴으로 생성된 Retrofit을 create 함수로 request 보낼 클래스를 넣습니다.
이제 Request에 있는 getUser를 반환형인 Call 클래스를 통해 비동기적으로 처리해봅시다.
private fun getUserNetwork(id:Int){
RetrofitAPI.request.getUser(id)
.enqueue(object : retrofit2.Callback<ResponseData> {
override fun onResponse(call: Call<ResponseData>, response: Response<ResponseData>) {
if(response.code() == 404){
Toast.makeText(this@MainActivity,"해당 아이디의 유저가 없습니다.",Toast.LENGTH_SHORT).show()
}else if(response.code() == 200){
CoroutineScope(Dispatchers.IO).launch {
userDao.insertAll(response.body()!!.user!!)
}
}
}
override fun onFailure(call: Call<ResponseData>, t: Throwable) {
Toast.makeText(this@MainActivity,"서버와 연결이 실패하였습니다.",Toast.LENGTH_SHORT).show()
}
})
}
execute를 사용하면 request를 보내고 response를 받는 행위를 동기적으로 수행합니다. (동기 방식)
enqueue 작업을 실행하면 request는 비동기적으로 보내고, response는 콜백으로 메인 쓰레드에서 실행하게 됩니다. (비동기 방식)
reqres 서버에서 프로필 이미지를 제공해주는 데 이를 띄어봅시다.
이를 위해 url로 이미지를 가져오는 glide 라이브러리를 이용해 진행하겠습니다.
의존성을 추가하고
implementation 'com.github.bumptech.glide:glide:4.13.1'
url로 이미지를 가져오는 데이터바인딩을 추가합니다.
@BindingAdapter("imageFromUrl")
fun bindImageFromUrl(view: ImageView, avatar: String?) {
if (!avatar.isNullOrEmpty()) {
view.visibility = View.VISIBLE
Glide.with(view.context)
.load(avatar)
.transition(DrawableTransitionOptions.withCrossFade())
.into(view)
}else{
view.visibility = View.GONE
}
}
관련 stackoverflow 및 공식문서입니다.
해당 imageFromUrl 속성을 사용하는 imageView를 activity xml에 추가합니다.
<ImageView
android:id="@+id/imgProfile"
app:imageFromUrl="@{viewModel.user.avatar}"
android:layout_width="80dp"
android:layout_height="80dp"/>
그러면 완성입니다.
샘플 코드
https://github.com/WorldOneTop/AndroidJetpackSample/tree/Room_Retrofit
참고 사이트
Room 공식 문서
Retrofit 공식 문서
http library 비교
Rest API
retrofit
glide - 이미지 라이브러리