1. 스마트폰 정보 구하기
전화 정보
앱에서 전화 기능을 이용하려면 PhoneStateListener를 상속받아 그 객체를 TelephonyManager에 등록해야 합니다. 그러면 스마트폰의 전화 관련 상태가 바뀔 때마다 PhoneStateListener의 다음과 같은 함수가 자동으로 호출됩니다.
- onCallForwardingIndicatorChanged(boolean cfi): 통화 전달 상태 변경
- onCallStateChanged(int state, String incomingNumber): 통화 상태 변경
- onCellLocationChanged(CellLocation location): 폰의 기지국 위치 변경
- onDataActivity(int direction): 데이터 송수신 활동
- onDataConnectionStateChanged(int state, int networkType): 데이터 연결 상태 변경
- onMessageWaitingIndicatorChanged(boolean mwi): 메시지 대기 상태 변경
- onServiceStateChanged(ServiceState serviceState): 단말기의 서비스 상태 변경
- onSignalStrengthsChanged(SignalStrength signalStrength): 신호 세기 변경
이 중에서 필요한 함수만 재정의해 놓으면 앱에서 상태 변화를 감지할 수 있습니다.
val phoneStateListender = object : PhoneStateListener() {
override fun onServiceStateChanged(serviceState: ServiceState?) {
super.onServiceStateChanged(serviceState)
...
}
}
그런 다음 getSystemService() 함수로 TelephonyManager 객체를 얻고 이 객체의 Listen() 함수에 PhoneStateListener 객체를 등록합니다.
val manager = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
manager.listen(phoneStateListener, PHoneStateListener.LISTEN_SERVICE_STATE)
listen() 함수의 두 번째 매개변수에는 감지할 상태를 지정해야 하는데 이때 PhoneStateListener 뒤에 다음과 같은 상수를 사용합니다.
- LISTEN_CALL_FORWARDING_INDICATOR : 통화 전달 지시자
- LISTEN_CALL_STATE : 통화 상태
- LISTEN_CELL_LOCATION : 기지국 위치
- LISTEN_DATA_ACTIVITY : 데이터 송수신 활동
- LISTEN_DATA_CONNECTION_STATE : 데이터 연결 상태
- LISTEN_MESSAGE_WAITING_INDICATOR : 메시지 대기 지시자
- LISTEN_SERVICE_STATE : 단말기의 서비스 상태
- LISTEN_SIGNAL_STRENGTHS : 신호 세기
만약 여러 개의 상태를 함께 감지하려면 상수를 OR 연산자로 나열하면 됩니다.
manager.listen(phoneStateListener, PhoneStateListener.LISTEN_SERVICE_STATE or PhoneStateListener.LISTEN_CALL_STATE)
그리고 상태 변화 감지를 해제할 때는 listen() 함수에 LISTEN_NONE 상수를 지정합니다.
manager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
서비스 상태 감지
onServiceStateChanged() 함수는 비행 모드나 긴급 통화 등 스마트폰의 서비스 상태가 바뀌는 순간에 호출됩니다. 스마트폰의 서비스 상태가 바뀌면 onServiceStateChanged() 함수가 호출되면서 매개변수인 ServiceState 객체의 state 값으로 전달됩니다. 이때 서비스 상태는 ServiceState 뒤에 다음과 같은 상수로 전달됩니다.
- STATE_IN_SERVICE : 서비스 가능 상태
- STATE_EMERGENCY_ONLY : 긴급 통화만 가능한 상태
- STATE_OUT_OF_SERVICE : 서비스 불가 상태
- STATE_POWER_OFF : 비행 모드 등 전화 기능을 꺼 놓은 상태
전화가 걸려오는 상태 감지
이번에는 전화가 걸려오는 상태를 감지하는 onCallStateChanged() 함수를 알아보겠습니다.
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
when (state) {
TelephonyManager.CALL_STATE_IDLE -> Log.d("kkang", "IDLE..")
TelephonyManager.CALL_STATE_RINGING -> Log.d("kkang", "RINGING..$phoneNumber")
TelephonyManager.CALL_STATE_OFFHOOK -> Log.d("kkang", "OFFHOOK..$phoneNumber")
}
}
onCallStateChanged() 함수의 첫 번째 매개변수로 전화가 걸려오는 상태를 구별할 수 있으며 TelephonyManager 뒤에 다음과 같은 상수가 전달됩니다.
- CALL_STATE_IDLE : 통화 대기 상태
- CALL_STATE_RINGING : 벨이 울리는 상태
- CALL_STATE_OFFHOOK : 통화 중인 상태
두 번째 매개변수는 걸려오는 전화번호인데 앱에서 전화번호를 받으려면 다음과 같은 퍼미션이 필요합니다.
<uses-permission android:name="android.permission.READ_CALL_LOG" />
네트워크 제공 국가, 사업자, 전화번호 얻기
TelephonyManager 는 네트워크 제공 국가, 사업자, 전화번호 등을 반환하는 다음 함수도 제공합니다.
- getNetworkCountryIso() : 네트워크 제공 국가
- getNetworkOperatorName() : 네트워크 제공 사업자
- getLine1Number() : 스마트폰의 전화번호
먼저 getLine1Number() 함수를 이용해 사용자 스마트폰의 전화번호를 추출하려면 다음과 같은 퍼미션이 필요합니다.
<users-permission android:name="android.permission.READ_PHONE_NUMBERS" />
networkCountryIso 속성은 네트워크 제공 국가 정보이므로 국내라면 'kr'이고, networkOperatorName 속성은 네트워크 사업자명입니다.
val countryIso = telephonyManager.networkCountryIso
val operatorName = telephonyManager.networkOperatorName
val phoneNumber = telephonyManager.line1Number
네트워크 접속 정보
현재 스마트폰이 네트워크가 가능한지, 가능하다면 이동 통신망에 접속되었는지 와이파이에 접속되었는지 등을 파악해야 합니다. 이처럼 네트워크 접속 정보를 파악할 때는 ConnectivityManager를 이용합니다.
ConnectivityManager를 이용하려면 먼저 다음 퍼미션을 선언해야 합니다.
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
getActiveNetwork() 함수를 이용하는 방법
스마트폰에서 네트워크가 가능한지 가능하다면 어떤 네트워크에 접속되었는지 알아볼 때는 ConnectivityManager의 getActiveNetwork() 함수로 Network 객체를 얻어서 이용합니다. 그런데 이 함수는 API 레벨 23부터 제공합니다. 만약 23 하위 버전에서도 실행되는 앱을 개발한다면 ConnectivityManager의 getActiveNetworkInfo() 함수를 이용해 NetworkInfo 객체를 얻어야 합니다.
private fun isNetworkAvailable(): Boolean {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManger
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val nw = connectivityManager.activeNerwork ?: return false
val actNw = connectivityManger.getNetworkCapabilities(nw) ?: return false
return when {
actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
Log.d("kkang", "wifi available")
true
}
actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
Log.d("kkang", "cellular available")
true
}
else -> false
}
} else {
return connectivityManager.activeNetworkInfo?.isConnected ?: false
}
}
activityNetwork 정보가 널이면 현재 스마트폰은 네트워크에 접속할 수 없다는 이야기입니다. activityNetwork 로 얻은 Network 객체를 다시 getNetworkCapabilities() 함수의 매개변수로 지정하면 현재 접속된 네트워크 정보를 얻을 수 있습니다. 이 함수의 반환값은 NetworkCapabilities 객체이며 hasTransport() 함수를 이용해 와이파이에 접속된 상태인지 아니면 이동 통신망에 접속된 상태인지를 알 수 있습니다.
requestNetwork() 함수를 이용하는 방법
네트워크 접속 정보를 파악할 때 getActiveNetwork() 함수 말고 ConnectivityManager 클래스의 requestNetwork() 함수를 이용할 수도 있습니다. 다만 이 함수를 이용하려면 다음 퍼미션을 선언해야 합니다.
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
먼저 requestNetwork() 함수는 다음처럼 정의되어 있습니다.
public void requestNetwork(NetworkRequest request, ConnectivityManager.NetworkCallback networkCallback)
첫 번째 매개변수에는 네트워크 타입을 나타내는 NetworkRequest 객체를 지정합니다. NetworkRequest의 addCapability() 와 addTransportType() 함수를 이용하면 네트워크 타입을 지정할 수 있습니다.
val networkReq: NetworkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNER)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
addCapability() 함수에 전달한 상수는 일반적인 데이터 통신을 의미하며 addTransportType() 함수에 전달한 두 상수는 각각 이동 통신방(TRANSPORT_CELLULAR)과 와이파이(TRANSPORT_WIFI)를 의미합니다.
이렇게 NetworkRequest 객체에 네트워크 타입을 설정했다면 이 객체를 requestNetwork() 함수의 두 번째 매개변수로 지정합니다. 그러면 콜백 함수가 자동으로 호출되면서 해당 네트워크를 이용할 수 있는지를 반환합니다.
conManager.requestNetwork(networkReq, object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("kkang", "NetworkCallback...onAvailable.....")
}
override fun onUnavailable() {
super.onUnavailable()
Log.d("kkang", "NetworkCallback...onUnavailable.....")
}
})
만약 onAvailable() 함수가 호출되면 지정한 타입의 네트워크가 가능하다는 의미이고, onUnavailable() 함수가 호출되면 불가능하다는 의미입니다. 참고로 requestNetwork() 함수는 API 레벨 21에서 추가되었으므로 minSdkVersion을 21보다 낮춰서 개발한다면 @RequiresApi(Build.VERSION_CODES.LOLLIPOP)와 같은 호환성 코드를 추가해줘야 합니다.
2. HTTP 통신하기
앱에서 네트워크 통신을 하려면 우선 메니페스트 파일에 다음처럼 퍼미션 선언을 해야합니다.
<uses-permission android:name="android.permission.INTERNET" />
안드로이드 앱은 네트워크 통신을 할 때 기본으로 HTTPS 보안 프로토콜을 사용합니다. 만약 HTTP 프로토콜로 통신하려면 특정 도메인만 허용하도록 선언해줘야 합니다. res/xml 폴더에 임의의 이름으로 XML파일을 만들고 다음처럼 작성합니다.
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">xxx.xxx.xxx.xxx</domain>
</domain-config>
</network-security-config>
<domain> 태그에 HTTP 통신을 허용할 서버의 IP나 도메인을 작성합니다. 이렇게 작성한 XML 파일을 매니페스트의 <application> 태그에 networkSecurityConfig 속성으로 알려주면 해당 도메인에 한해서만 HTTP 통신을 할 수 있습니다.
<application
...
android:networkSecurityConfig="@xml/network_security_config">
또는 매니페스트에서 usesCleartextTraffic 속성을 true 로 설정하면 앱 전체에서 모든 도메인의 서버와 HTTP 통신을 할 수 있습니다.
<application
...
android:usesCleartextTraffic="true">
Volley 라이브러리
안드로이드 앱을 개발할 때 네트워크 프로그래밍을 돕는 라이브러리에는 여러 가지가 있지만 여기서는 구글에서 제공하는 Volley와 스퀘어에서 제공하는 Retrofit을 이용하는 방법을 알아보겠습니다.
Volley는 2013년 구글 IO 행사에서 공개된 라이브러리로 안드로이드 앱에서 HTTP 통신을 좀 더 쉽게 구현하게 해줍니다. Volley를 사용하려면 빌드 그래들 파일의 dependencies 항목에 다음처럼 등록해야 합니다.
implementation 'com.android.volley:volley:1.1.1'
Volley에서 핵심 클래스는 RequestQueue와 XXXRequest 입니다.
- RequestQueue : 서버 요청자
- XXXRequest : XXX 타입의 결과를 받는 요청 정보
RequestQueue 객체는 서버에 요청을 보내는 역할을 하며 이때 서버 URL과 결과를 가져오는 콜백 등 다양한 정보는 XXXRequest 객체에 담아서 전송합니다. 서버로부터 가져온 결과가 문자열이면 StringRequest 를 이용하는 것처럼 데이터 타입에 따라 ImageRequest, JsonObjectRequest, JsonArrayRequest 등을 이용합니다.
문자열 데이터 요청하기 - StringRequest
val stringRequest = StringRequest(
Request.Method.GET,
url,
Response.Listener<String> {
Log.d("kkang", "server data : $it")
},
Response.ErrorListener {
Log.d("kkang", "error........$error")
}
)
StringRequest 에 담은 정보대로 서버에 요청을 보낼 때는 RequestQueue 객체를 이용합니다.
val queue = Volley.newRequestQueue(this)
queue.add(stringRequest)
Volley.newRequestQueue(this) 로 RequestQueue 객체를 얻고 이 객체의 add() 함수에 앞에서 정의한 RequestString 객체를 전달하면 서버에 요청을 보냅니다.
만약 POST 방식으로 호출한다면 StringRequest를 상속받은 클래스를 이용해야 합니다.
val stringRequest = StringRequest(
Request.Method.GET,
url,
Response.Listener<String> {
Log.d("kkang", "server data : $it")
},
Response.ErrorListener {
Log.d("kkang", "error........$error")
}) {
override fun getParams() : MutableMap<String, String> {
return mutableMapOf<String, String>("one" to "hello", "two" to "world")
}
}
이미지 데이터 요청하기 - ImageRequest
val imageRequest = ImageRequest(
url,
Response.Listener { response -> binding.imageView.setImageBitmap(response) },
0,
ImageView.ScaleType.CENTER_CROP,
null,
Response.ErrorListener { error ->
Log.d("kkang", "error..........$error")
})
val queue = Volley.newRequestQueue(this)
queue.add(imageRequest)
두 번째 매개변수에 콜백 함수를 Bitmap 타입으로 지정했으므로 서버에서 가져온 이미지를 Bitmap 객체로 전달받습니다. 이 이미지를 이미지 뷰에 출력했습니다.
화면 출력용 이미지 데이터 요청하기 - NetworkImageView
만약 서버에서 가져온 이미지를 화면에 출력만 한다면 ImageRequest를 사용할 수도 있지만, NetworkImageView를 사용하면 조금 더 편리합니다. NetworkImageView는 Volley 에서 제공하는 이미지 출력용 뷰입니다.
<com.android.volley.toolbox.NetworkImageView
android:id="@+id/networkImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
액티비티 화면을 구성하는 레이아웃 XML에 <com.android.volley.toolbox.NetworkImageView> 를 작성하고 이 객체의 setImageUrl() 함수만 호출하면 서버에서 이미지를 가져오는 통신부터 이미지를 NetworkImageView에 출력하는 것까지 자동으로 이뤄집니다.
setImageUrl() 함수만으로도 서버에 요청을 보낼 수 있습니다. 즉, RequestQueue의 add() 함수를 호출하지 않아도 서버와 자동으로 연동됩니다.
val queue = Volley.newRequestQueue(this)
val imgMap = HashMap<String, Bitmap>()
imageLoader = ImageLoader(queue, object : ImageLoader.ImageCache {
override fun getBitmap(url: String): Bitmap? {
return imgMap[url]
}
override fun putBitmap(url: String, bitmap: Bitmap) {
imgMap[url] = bitmap
}
})
binding.networkImageView.setImageUrl(url, imageLoader)
ImageLoader 객체를 setImageUrl() 함수의 두 번째 매개변수에 지정하면 서버 이미지를 가져오기 전에 ImageLoader의 getBitmap() 함수가 자동으로 호출됩니다. 이 함수의 반환값이 널이면 서버에 요청을 보내고 Bitmap 객체이면 요청을 보내지 않고 Bitmap 객체를 그대로 NetworkImageView에 출력합니다.
또한 getBitmap() 함수가 널을 반환하여 서버로부터 이미지를 가져오면 putBitmap() 함수가 자동으로 호출되어 서버 이미지를 putBitmap() 두 번째 매개변수로 전달해 줍니다. 결국 같은 URL의 이미지를 반복해서 가져오지 않도록 합니다.
JSON 데이터 요청하기 - JsonObjectRequest
서버에 JSON 데이터를 요청할 때는 JsonObjectRequest를 이용합니다. 그러면 자동으로 JSON 데이터를 파싱한 JSONObject 객체가 콜백 함수에 전달됩니다.
val jsonRequest =
JsonObjectRequest(
Request.Method.GET,
url,
null,
Response.Listener<JSONObject> { response ->
val title = response.getString("title")
val date = response.getString("date")
Log.d("kkang", "$title, $date")
},
Response.ErrorListener { error -> Log.d("kkang", "error....$error") }
)
val queue = Volley.newRequestQueue(this)
queue.add(jsonRequest)
JSON 배열 요청하기 - JsonArrayRequest
서버에 JSON 배열을 요청할 때는 JsonArrayRequest를 이용합니다.
val jsonArrayRequest = JsonArrayRequest(
Request.Method.GET,
url,
null,
Response.Listener<JSONArray> { response ->
for (i in 0 until response.length()) {
val jsonObject = response[i]. as JSONObject
val title = jsonObject.getString("title")
val date = jsonObject.getString("date")
Log.d("kkang", "$title, $date")
}
},
Response.ErrorListener { error -> Log.d("kkang", "error....$error") }
)
val queue = Volley.newRequestQueue(this)
queue.add(jsonArrayRequest)
Retrofit 라이브러리
Retrofit은 스퀘어에서 만든 HTTP 통신을 간편하게 만들어 주는 라이브러리입니다. Retrofit은 네트워크 통신 정보만 주면 그대로 네트워크 프로그래밍을 대신 구현해 줍니다.
인터페이스는 코틀린의 interface 키워드로 직접 만들어야 합니다. 그리고 인터페이스의 함수는 통신할 때 필요합니다. 그런데 인터페이스에는 함수를 선언만 하며 통신할 때 필요한 어떤 코드도 담지 않습니다.
이렇게 만든 인터페이스를 Retrofit에게 주면 인터페이스 정보를 보고 실제 통신할 때 필요한 코드를 담은 서비스 객체를 만들어 줍니다. Retrofit은 우리가 알려 준 인터페이스를 바탕으로 서비스를 만들므로 인터페이스에 선언한 함수를 그대로 포함합니다. 이 서비스의 함수를 호출하면 Call 객체를 반환하는데, 이 Call 객체의 enqueue() 함수를 호출하는 순간 통신을 수행합니다.
- 통신용 함수를 선언한 인터페이스를 작성합니다.
- Retrofit에 인터페이스를 전달합니다.
- Retrofit이 통신용 서비스 객체를 반환합니다.
- 서비스의 통신용 함수를 호출한 후 Call 객체를 반환합니다.
- Call 객체의 enqueue() 함수를 호출하여 네트워크 통신을 수행합니다.
라이브러리 선언
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
첫 번째 라이브러리는 Retrofit을 이용할 때 필수이며 나머지 2개는 다른 라이브러리를 이용할 수도 있습니다.
모델 클래스 선언
모델 클래스란 서버와 주고받는 데이터를 표현하는 클래스입니다. 흔히 VO 클래스라고도 하며 JSON, XML 데이터를 파싱해 모델 클래스 객체에 담는 것을 자동화해줍니다. 다음은 서버에서 넘어오는 JSON 데이터입니다.
{
"id": 7,
"email": "michael.lawson@reqres.in",
"first_name": "Michael",
"last_name": "Lawson",
"avatar": "https://reqres.in/img/faces/7-image.jpg"
}
데이터를 담은 모델 클래스를 선언하고 클래스 정보만 알려 주면 모델 클래스의 객체를 알아서 생성하고 그 객체에 데이터를 담아 줍니다. 앞의 JSON 정보를 담을 모델 클래스는 다음처럼 작성할 수 있습니다.
data class UserModel(
var id: String,
@SerializedName("first_name")
var firstName: String,
// @SerializedName("last_name")
var lastName: String,
var avatar: String,
var avatarBitmap: Bitmap
)
모델 클래스의 프로퍼티에 데이터가 자동으로 저장되는 기본 규칙은 데이터의 키와 프로퍼티 이름을 매칭하는 것입니다. 예를 들어 id라는 키값은 id 프로퍼티에 저장됩니다. 만약 키와 프로퍼티 이름이 다를 때는 @SerializedName 이라는 애너테이션으로 명시해주면 됩니다. 그런데 키와 프로퍼티 이름이 다르더라도 밑줄 다음에 오는 단어의 첫 글자를 대문자로 바꾼 프로퍼티명이 있을 때는 @SerializedName 애너테이션을 사용하지 않아도 됩니다. 예를 들어 키가 last_name이면 자동으로 lastName 프로퍼티에 저장됩니다.
모델 클래스를 만들 때 서버의 데이터와 상관없는 프로퍼티를 선언해도 됩니다. 코드를 보면 avatarBitmap 이라는 프로퍼티를 선언했는데 서버로부터 넘어오는 JSON에는 이와 관련된 데이터가 없습니다. 이처럼 모델에 서버 연동과 상관없는 데이터를 담는 프로퍼티를 선언해도 됩니다.
그리고 하나의 모델 클래스로 담기 어려운 경우에는 이를 나눠도 됩니다. 예를 들어 UserListModel 클래스에서 UserModel 을 리스트로 가질 수 있습니다.
data class UserListModel (
var page: String,
@SerializedName("per_page")
var perPage: String,
var total: String,
@SerializedName("total_pages")
var totalPages: String,
var data: List<UserModel>?
)
Retrofit을 이용할 떄 UserListModel을 알려주면 JSON 데이터를 파싱해 프로퍼티에 저장하며 data 키값은 data 프로퍼티에 선언된 UserModel 클래스의 객체에 담아줍니다.
서비스 인터페이스 정의
Retrofit을 이용할 때 가장 중요한 부분은 네트워크 통신이 필요한 순간에 호출할 함수를 포함하는 서비스 인터페이스를 작성하는 것입니다.
interface INetworkService {
@GET("api/users")
fun doGetUserList(@Query("page") page: String): Call<UserListModel>
@GET
fun getAvatarImage(@Url url: String): Call<ResponseBody>
}
INetworkService라는 이름의 인터페이스를 선언하고 그 안에 doGetUserList(), getAvatarImage()라는 이름의 함수를 정의했습니다. 그런데 이 인터페이스명과 함수명은 개발자가 지은 이름일 뿐입니다. 이 인터페이스를 구현해 실제로 통신하는 클래스는 Retrofix이 자동으로 만들어주는데 이때 애너테이션을 참조합니다. 즉, 함수에 선언한 애너테이션을 보고 그 정보대로 네트워크 통신을 할 수 있는 코드를 자동으로 만들어 줍니다.
함수에 선언한 애너테이션을 살펴보면, @GET은 서버와 연동할 때 GET 방식으로 해달라는 의미이며 @Query는 서버에 전달되는 데이터, @Url은 요청 URL을 뜻합니다.
Retrofit 객체 생성
Retrofit을 사용할 때 가장 먼저 Retrofit 객체를 생성하는 코드를 실행해야 합니다.
val retrofit: Retrofit
get() = Retrofit.Builder()
.baseUrl("https://reqres.in/")
.addConverterFactory(GsonConverterFactory.create())
.build()
Retrofit 객체를 생성하는 코드는 초기 설정을 하므로 한 번만 생성하면 됩니다. baseUrl() 함수로 URL을 설정하면 이후에 이 URL 뒤에 올 경로만 지정해서 서버와 연동할 수 있습니다. 예를 들어 baseUrl을 앞의 코드처럼 선언하고 어디선가 @GET("api/users") 처럼 경로를 지정했다면 서버 요청 URL은 https://reqres.in/api/users가 됩니다. 물론 baseUrl을 선언했더라도 전혀 다른 URL로 요청할 수도 있습니다.
그리고 addConverterFactory() 함수로 데이터를 파싱해 모델 객체에 담는 역할자를 지정해줍니다. 앞에서는 GsonConverterFactory.create() 로 작성했으므로 GsonConverter를 이용하겠다는 의미입니다.
인터페이스 타입의 서비스 객체 얻기
Retrofit 객체를 생성한 다음에는 이 객체로 서비스 인터페이스를 구현한 클래스의 객체를 얻습니다.
var networkService: INetworkService = retrofit.create(INetworkService::class.java)
Retrofit의 create() 함수에 앞에서 만든 서비스 인터페이스 타입을 전달합니다. 그러면 이 인터페이스를 구현한 클래스의 객체를 반환해줍니다. 실제 네트워크가 필요할 때 이 객체의 함수를 호출하면 됩니다.
네트워크 통신 시도
이제 모든 준비가 끝났으므로 네트워크 통신이 필요한 순간에 Retrofit 객체로 얻은 서비스 객체의 함수를 호출만 해주면 됩니다. 서비스 클래스와 객체는 Retrofit이 만들어 주지만 우리가 만든 인터페이스를 구현한 클래스이므로 인터페이스의 함수를 호출하면 네트워크 통신을 시도합니다.
val userListCall = networkService.doGetUserList("1")
인터페이스에 선언한 함수를 호출하면 위 코드에서 userListCall처럼 Call 객체가 반환됩니다. 실제 통신은 이 Call 객체의 enqueue() 함수를 호출하는 순간 이뤄집니다.
userListCall.enqueue(object : Callback<UserListModel> {
override fun onResponse(call: Call<UserListModel>, response: Response<UserListModel>) {
val userList = response.body()
(... 생략...)
}
override fun onFailure(call: Call<UserListModel>, t: Throwable) {
call.cancel()
}
})
Call 객체의 enqueue() 함수를 호출하면 비로소 통신이 수행됩니다. 그리고 enqueue() 함수의 매개변수로 지정한 Callback 객체의 onResponse(), onFailure() 함수가 자동으로 호출됩니다. 만약 통신에 성공하면 onResponse() 함수가, 실패하면 onFailure() 함수가 호출됩니다.
Retrofit 애너테이션
@GET, @PORT, @PUT, @DELETE, @HEAD
HTTP 메서드를 정의하는 애너테이션입니다. @GET 처럼 메서드명만 지정하거나 @GET("users/list") 처럼 URL 경로를 지정해도 됩니다. 또한 @GET("users/list?sort=desc") 처럼 ?로 URL 뒤에 데이터를 추가할 수도 있습니다. 이처럼 경로를 지정하면 baseURL 뒤에 추가되어 최종 서버 요청 URL이 됩니다.
// 인터페이스에 선언한 함수
@GET("users/list?sort=desc")
fun test1(): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test1()
// 최종 서버 요청 URL
https://reqres.in/users/list?sort=desc
@Path
URL의 경로를 동적으로 지정해야 할 때도 있습니다. 예를 들어 group/{id}/users 라고 지정하면 {id} 영역은 동적 데이터가 들어갈 자리이며 id는 개발자가 임의로 작성하면 됩니다.
// 인터페이스에 선언한 함수
@GET("group/{id}/users/{name}")
fun test2(
@Path("id") userId: String,
@Path("name") arg2: String
): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test2("10", "kkang")
// 최종 서버 요청 URL
https://reqres.in/group/10/users/kkang
@Query
경로에 ?를 이용해 서버에 전달할 데이터를 지정할 수도 있지만, 함수의 매개변숫값을 서버에 전달하고 싶다면 @Query 애너테이션을 사용합니다.
// 인터페이스에 선언한 함수
@GET("group/users")
fun test3(
@Query("sort") arg1: String,
@Query("name") arg2: String
): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test3("age", "kkang")
// 최종 서버 요청 URL
https://reqres.in/group/users?sort=age&name=kkang
@QueryMap
만약 서버에 전송할 데이터가 많다면 함수의 매개변수를 여러 개 선언해야 하는 부담이 있습니다. 이때에는 @QueryMap을 이용해 서버에 전송할 데이터를 Map 타입의 매개변수로 받으면됩니다.
// 인터페이스에 선언한 함수
@GET("group/users")
fun test4(
@QueryMap options: Map<String, String>,
@Query("name") name: String
): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test4(
mapOf<String, String>("one" to "hello", "two" to "world"),
"kkang"
)
// 최종 서버 요청 URL
https://reqres.in/users?one=hello&two=world&name=kkang
@Body
서버에 전송할 데이터를 모델 객체로 지정하고 싶다면 @Body 애너테이션을 사용합니다. 모델 객체의 프로퍼티명은 키로, 프로퍼티의 데이터를 값으로 해서 JSON 문자열을 만들어 서버에 전송합니다. @Body는 JSON 문자열을 데이터 스트림으로 전송하므로 @POST에서 써야합니다.
// 인터페이스에 선언한 함수
@POST("group/users")
fun test5(
@Body user: UserModel,
@Query("name") name: String
): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test5(
UserModel(id="1", firstName = "gildong", lastName = "hong", avatar = "someurl"),
"kkang"
)
// 최종 서버 요청 URL
https://reqres.in/group/users?name=kkang
// 서버에 스트림으로 전송되는 데이터
{"id":"1", "first_name":"gildong", "last_name":"hong", "avatar":"someurl"}
@FormUrlEncoded와 @Field
@FormUrlEncoded 애너테이션은 데이터를 URL 인코딩 형태로 만들어 전송할 때 사용합니다. 앞에서 살펴본 @Body는 데이터를 JSON으로 만들어 전송하지만, @FormUrlEncoded는 서버 전송 데이터를 '키=값' 형태의 URL 인코딩으로 전송합니다. @FormUrlEncoded 애너테이션은 POST 방식에서만 사용할 수 있습니다.
// 인터페이스에 선언한 함수
@FormUrlEncoded
@POST("user/edit")
fun test6(
@Field("first_name") first: String?,
@Field("last_name") last: String?,
@Query("name") name: String?
): Call<UserModel>
// Call 객체를 얻는 구문
val call: Call<UserModel> = networkService.test6(
"gildong",
"hong 홍",
"kkang"
)
// 최종 서버 요청 URL
https://reqres.in/user/edit?name=kkang
@Field 애너테이션은 모델 객체에서는 사용할 수 없으며 데이터 여러 건을 한꺼버에 지정하고 싶다면 배열이나 List 객체를 이용해야 합니다. 배열이나 List 객체에 @Field 애너테이션을 사용하면 데이터 여러 건을 같은 키로 서버에 전달할 수 있습니다.
// 인터페이스에 선언한 함수
@FormUrlEncoded
@POST("tasks")
fun test7(@Field("title") titles: List<String>): Call<UserModel>
// Call 객체를 얻는 구문
val list: MutableList<String> = ArrayList()
list.add("홍길동")
list.add("류현진")
val call = networkService.test(list)
// 최종 서버 요청 URL
https://reqres.in/tasks
@Header
서버 요청에서 헤더값을 조정하고 싶다면 @Header 에너테이션을 사용합니다.
// 인터페이스에 선언한 함수
@Headers("Cache-Control: max-age=640000")
@GET("widget/list")
fun test8(): Call<UserModel>
@Url
baseUrl을 무시하고 전혀 다른 URL을 지정하고 싶다면 @Url 애너테이션을 사용합니다.
// 인터페이스에 선언한 함수
@GET
fun test9(@Url url: String, @Query("name") name: String): Call<UserModel>
// Call 객체를 얻는 구문
val call = networkService.test9("http://www.google.com", "kkang")
// 최종 서버 요청 URL
https://www.google.com/?name=kkang
3. 이미지 처리하기 - Glide 라이브러리
Glide를 사용하면 네트워크 부분을 Volley나 Retrofit 보다 더 쉽고 빠르게 개발할 수 있습니다. Glide는 Bump라는 앱에서 내부적으로 이용하다가 구글이 인수하여 공개한 라이브러리입니다. Glide는 모든 종류의 이미지를 가능한 한 빠르게 가져와서 이용할 수 있게 합니다. 또한 이미지의 크기를 조절하거나 로딩 이미지, 오류 이미지 표시 등을 쉽게 구현할 수 있습니다. Glide를 이용하려면 빌드 그래들의 dependencies에 다음처럼 등록해야 합니다.
implementation 'com.github.bumptech.glide:glide:4.11.0'
이미지를 가져와 출력하기
앱에서 이용하는 이미지는 리소스, 파일, 서버 이미지입니다. 다음은 리소스 이미지를 이미지 뷰에 출력하는 코드입니다. load() 함수에 리소스를 전달하고, into() 함수에 이미지 뷰 객체를 전달하면 리소스 이미지를 자동으로 가져와 출력합니다.
Glide.with(this)
.load(R.drawable.seoul)
.into(binding.resultView)
다음은 파일 이미지를 이미지 뷰에 출력하는 코드입니다. 갤러리 앱의 목록 화면을 보여주고 사용자가 사진을 선택하면 그 경로를 받아서 Glide로 이미지 뷰에 출력합니다.
val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
intent.type = "image/*"
startActivityForResult(intent, 10)
...
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode === 10 && resultCode === Activity.RESULT_OK) {
Glide.with(this)
.load(data!!.data)
.into(binding.resultView)
}
}
서버에 있는 이미지를 가져오는 코드도 같습니다. load() 함수에 이미지 URL을 문자열로 지정하면 자동으로 서버에서 이미지 데이터를 읽어 into() 함수에 지정한 이미지 뷰에 출력합니다.
Glide.with(this)
.load(url)
.into(binding.resultView)
때로는 PNG나 JPG가 아니라 애니메이션 효과를 포함한 GIF 이미지를 출력할 때도 있습니다. 이때 이미지 뷰는 GIF 이미지를 출력할 수는 있지만 애니메이션 효과가 적용되지는 않습니다. 하지만 Glide를 이용하면 특별히 처리하지 않아도 이미지 뷰에 애니메이션 효과가 그대로 출력되는 GIF 이미지를 출력할 수 있습니다.
크기 조절
Glide를 이용하면 코드에서 이미지 크기를 줄이지 않아도 이미지 뷰의 크기에 맞게 자동으로 줄여서 불러옵니다. 그래서 OOM 문제를 크게 신경 쓰지 않아도 됩니다. 만약 특정한 크기로 이미지를 줄이고 싶다면 다음처럼 override() 함수를 사용하면 됩니다.
Glide.with(this)
.load(R.drawable.seoul)
.override(200, 200)
.into(binding.resultView)
로딩, 오류 이미지 출력
파일이나 서버에 있는 이미지를 이용할 때는 이미지를 불러오는 데 시간이 오래 걸릴 수 있습니다. 보통 이럴 때는 로딩 상태를 표현하는 이미지를 먼저 출력했다가 완료되었을 때 본래 이미지를 출력합니다. 또한 서버에 접속이 원활하지 않아서 이미지를 불러올 수 없다면 오류 이미지를 출력합니다.
이러한 기능을 구현하려면 placeholder(), error() 함수를 사용해 해당 상황에 표시할 이미지를 지정하면 됩니다.
Glide.with(this)
.load(url)
.override(200, 200)
.placeholder(R.drawable.loading)
.error(R.drawable.error)
.into(binding.resultView)
placeholder() 함수에 지정한 이미지를 먼저 출력하며 load() 함수에 지정한 이미지를 불러오면 그 이미지를 출력합니다. 그리고 이미지를 불러오는 데 실패하면 error() 함수에 지정한 이미지를 출력합니다.
4. 뉴스 앱 만들기

1. 빌드 그래들 수정하기
android {
...
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation("com.android.volley:volley:1.2.1")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
}
2. 코틀린 코드
- model/ItemModel
package com.example.myapplication18.model
class ItemModel {
var id: Long = 0
var author: String? = null
var title: String? = null
var description: String? = null
var urlToImage: String? = null
var publishedAt: String? = null
}
- model/PageListModel
package com.example.myapplication18.model
class PageListModel {
var articles: MutableList<ItemModel>? = null
}
- recycler/MyAdapter.kt
package com.example.myapplication18.recycler
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myapplication18.databinding.ItemMainBinding
import com.example.myapplication18.model.ItemModel
class MyViewHolder(val binding: ItemMainBinding): RecyclerView.ViewHolder(binding.root)
class MyAdapter(val context: Context, val datas: MutableList<ItemModel>?): RecyclerView.Adapter<RecyclerView.ViewHolder>(){
override fun getItemCount(): Int{
return datas?.size ?: 0
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
= MyViewHolder(ItemMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val binding=(holder as MyViewHolder).binding
//add......................................
val model=datas!![position]
binding.itemTitle.text=model.title
binding.itemDesc.text=model.description
binding.itemTime.text="${model.author} At ${model.publishedAt}"
model.urlToImage?.let {
if(!it.equals("null")) {
Glide.with(context)
.load(model.urlToImage)
.into(binding.itemImage)
}
}
}
}
- retrofit/NetworkService
package com.example.myapplication18.retrofit
import com.example.myapplication18.model.PageListModel
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
interface NetworkService {
@GET("/v2/everything")
fun getList(
@Query("q") q: String?,
@Query("apiKey") apiKey: String?,
@Query("page") page: Long,
@Query("pageSize") pageSize: Int
): Call<PageListModel>
}
- MainActivity
package com.example.myapplication18
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication18.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
lateinit var volleyFragment: VolleyFragment
lateinit var retrofitFragment: RetrofitFragment
var mode = "volley"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding= ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
volleyFragment= VolleyFragment()
retrofitFragment= RetrofitFragment()
supportFragmentManager.beginTransaction()
.replace(R.id.activity_content, volleyFragment)
.commit()
supportActionBar?.title="Volley Test"
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if(item.itemId === R.id.menu_volley && mode !== "volley"){
supportFragmentManager.beginTransaction()
.replace(R.id.activity_content, volleyFragment)
.commit()
mode="volley"
supportActionBar?.title="Volley Test"
}else if(item.itemId === R.id.menu_retrofit && mode !== "retrofit"){
supportFragmentManager.beginTransaction()
.replace(R.id.activity_content, retrofitFragment)
.commit()
mode="retrofit"
supportActionBar?.title="Retrofit Test"
}
return super.onOptionsItemSelected(item)
}
}
- MyApplication
package com.example.myapplication18
import android.app.Application
import com.example.myapplication18.retrofit.NetworkService
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class MyApplication: Application() {
companion object {
val QUERY = "travel"
val API_KEY = "079dac74a5f94ebdb990ecf61c8854b7"
val BASE_URL = "https://newsapi.org"
val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36"
//add....................................
var networkService: NetworkService
val retrofit: Retrofit
get() = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
init {
networkService = retrofit.create(NetworkService::class.java)
}
}
}
- RetrofitFragment
package com.example.myapplication18
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.myapplication18.databinding.FragmentRetrofitBinding
import com.example.myapplication18.model.PageListModel
import com.example.myapplication18.recycler.MyAdapter
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class RetrofitFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = FragmentRetrofitBinding.inflate(inflater, container, false)
val call: Call<PageListModel> = MyApplication.networkService.getList(
MyApplication.QUERY,
MyApplication.API_KEY,
1,
10
)
call?.enqueue(object: Callback<PageListModel> {
override fun onResponse(
call: Call<PageListModel>,
response: Response<PageListModel>
) {
if (response.isSuccessful()) {
binding.retrofitRecyclerView.layoutManager =
LinearLayoutManager(activity)
binding.retrofitRecyclerView.adapter =
MyAdapter(activity as Context, response.body()?.articles)
}
}
override fun onFailure(
call: Call<PageListModel?>,
t: Throwable
) {
}
})
return binding.root
}
}
- VolleyFragment
package com.example.myapplication18
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.android.volley.Request
import com.android.volley.Response
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.example.myapplication18.databinding.FragmentVolleyBinding
import com.example.myapplication18.model.ItemModel
import com.example.myapplication18.recycler.MyAdapter
import org.json.JSONObject
class VolleyFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = FragmentVolleyBinding.inflate(inflater, container, false)
//add...................
val url =
MyApplication.BASE_URL + "/v2/everything?q=" +
"${MyApplication.QUERY}&apiKey=" +
"${MyApplication.API_KEY}&page=1&pageSize=5"
val queue = Volley.newRequestQueue(activity)
val jsonRequest =
object : JsonObjectRequest(
Request.Method.GET,
url,
null,
Response.Listener<JSONObject> { response ->
val jsonArray = response.getJSONArray("articles")
val mutableList= mutableListOf<ItemModel>()
for(i in 0 until jsonArray.length()){
ItemModel().run {
val article = jsonArray.getJSONObject(i)
author=article.getString("author")
title=article.getString("title")
description=article.getString("description")
urlToImage=article.getString("urlToImage")
publishedAt=article.getString("publishedAt")
mutableList.add(this)
}
}
binding.volleyRecyclerView.layoutManager =
LinearLayoutManager(activity)
binding.volleyRecyclerView.adapter =
MyAdapter(activity as Context, mutableList)
},
Response.ErrorListener { error ->
println("error............$error") }) {
override fun getHeaders(): MutableMap<String, String> {
val map = mutableMapOf<String, String>(
"User-agent" to MyApplication.USER_AGENT
)
return map
}
}
queue.add(jsonRequest)
return binding.root
}
}
3. 리소스 파일
- layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
app:theme="@style/ToolbarIconColor"
app:titleTextColor="#FFFFFF"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/activity_content"/>
</LinearLayout>
- layout/fragment_retrofit.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/retrofitRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
- layout/fragment_volley.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/volleyRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
- layout/item_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:background="@android:color/white"
android:orientation="vertical"
android:paddingTop="10dp"
android:paddingBottom="15dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingTop="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp">
<TextView
android:id="@+id/item_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#212121"
android:textSize="14sp" />
<TextView
android:id="@+id/item_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/item_title"
android:layout_marginTop="3dp"
android:textColor="#727272"
android:textSize="12sp" />
</RelativeLayout>
</RelativeLayout>
<TextView
android:id="@+id/item_desc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="3dp"
android:layout_marginTop="15dp"
android:layout_marginRight="10dp"
android:lineSpacingExtra="2dp"
android:paddingLeft="10dp"
android:textColor="#212121"
android:textSize="14sp" />
<ImageView
android:id="@+id/item_image"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="5dp"
android:scaleType="fitXY" />
<View
android:layout_width="match_parent"
android:layout_height="0.2dp"
android:layout_gravity="center_vertical"
android:layout_marginTop="3dp"
android:background="#40000000" />
</LinearLayout>
- menu/menu_main.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_volley"
android:title="volley"
app:showAsAction="always"/>
<item
android:id="@+id/menu_retrofit"
android:title="retrofit"
app:showAsAction="always"/>
</menu>
- values/themes.xml
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.MyFirstApplication" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.MyFirstApplication" parent="Base.Theme.MyFirstApplication" />
<style name="ToolbarIconColor" parent="ThemeOverlay.AppCompat.ActionBar">
<item name="colorControlNormal">#FFFFFF</item>
</style>
</resources>
- AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MyFirstApplication">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
- 앱 실행 테스트


'Android' 카테고리의 다른 글
| [깡쌤의 안드로이드 프로그래밍 with 코틀린][6. 앱에 다양한 기능 추가하기] 17. 저장소에 데이터 보관하기 (0) | 2024.12.25 |
|---|---|
| [깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 16. 콘텐츠 프로바이더 컴포넌트 (0) | 2024.12.25 |
| [깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 15. 서비스 컴포넌트 (0) | 2024.12.25 |
| +[깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 14. 브로드캐스트 리시버 컴포넌트 (0) | 2024.12.25 |
| [깡쌤의 안드로이드 프로그래밍 with 코틀린][5. 컴포넌트 이해하기] 13. 액티비티 컴포넌트 (0) | 2024.12.25 |