24년 4월을 기준으로 작성되었습니다.
8. 타임라인을 나눠서 불러오자
페이징(Paging)이란 한 번에 처리하기에 양이 많아 부담스러운 데이터를 일정한 크기의 페이지로 나눠 처리하는 것을 말한다.
대표적인 페이징 UI는 위 그림과 같이 3가지가 있다.
1) 페이지 버튼 UI
목록의 하단에 페이지 번호를 나열하여, 원하는 페이지로 이동하는 방법이다.
모바일보다는 -> 정교한 조작이 쉬운 브라우저에서 주로 사용된다.
2) 더 보기 버튼 UI
모바일에서는 터치 영역이 작으면 조작이 어렵기 때문에, 목록의 하단에 다음 목록을 불러오는 "하나의 큰 버튼"을 배치한다.
3) 무한 스크롤링
버튼 없이 목록의 특정 영역에 도달하면, 자동으로 다음 목록을 불러오는 방법이다.
사용자의 몰입이 끊기는 것을 최소화하기 때문에, SNS의 타임라인 기능에서 주로 사용된다.
※ 최근 안드로이드 Jetpack의 Paging 라이브러리를 사용하면, 간단히 만들 수 있다.
페이징 기능을 어떤 방법으로 만들 것인가는 "서버의 API 디자인" 에 영향을 받는다. API 디자인은 2가지로 구분할 수 있다.
1) 오프셋 기반 페이지네이션(Pagination)
GET /items?page=2&count=3
서버에서는 "페이지 번호 x 페이지 크기" 만큼 이동하여 결과를 보내준다.
이 방법은 클라이언트에서 요청한 대로 결과를 보내주는 유연한 구조라, 많은 API들이 사용하고 있다.
그러나, 매번 순차 접근이 필요하기 때문에 데이터베이스 성능에 좋지 않고, 데이터에 변화가 많은 경우는 중복과 누락이 많다.
따라서, 오프셋 방식의 API로 "더 보기 버튼 UI" 나 "무한 스크롤링" 을 만들면 문제가 발생하기 쉽다.
2) 커서 기반 페이지네이션
기준이 되는 값부터 일정한 개수를 가져오는 방법이다.
GET /itmes?after=5시 55분&count=3
이 구조에서는 이미 불러온 영역에 새로운 아이템이 추가되거나 삭제되어도, 중복이나 누락이 발생하지 않는다.
그러나, 바로 다음 페이지만 요청할 수 있기 때문에, 특정 페이지로 바로 이동하는 "페이지 버튼 UI"는 만들 수 없다.
또한 정렬된 필드가 값의 중복을 허용하는 경우에는(EX : 다른 아이템이 같은 시간을 가진다면), 조건에 따라 누락되거나 포함되는 문제가 발생한다.
따라서 커서 기반 페이지네이션은, "아이디"처럼 값이 중복되지 않는 필드를 키로 사용해야 한다.
8-1. Paging 라이브러리
안드로이드에서 무한 스크롤링 기능을 사용하려면, 다음 항목들을 구현해야 한다.
- RecyclerView의 위치를 감시하다가, 특정 위치에서 데이터 요청
- 불러올 데이터를 가리키는 키 관리
- 이미 요청한 상태인지 관리하고, 로딩 상태 표시
- 새로고침과 요청 실패 시, 재시도
- 새로 받은 데이터와 기존의 데이터 중복 확인
그러나 Paging 라이브러리는 이런 요구사항들이 이미 잘 구현되어 있어서, RecyclerView에서 무한 스크롤링을 사용할 수 있도록 지원하고, Room, LiveData 등 다른 Jetpack 컴포넌트들과 쉽게 통합될 수 있다.
PagingSource : 페이지의 번호나 키로, API나 데이터베이스에서 단순히 데이터를 불러오는 역할을 하는 추상 클래스
[추가 요소] RemoteMediator : API에서 데이터를 받아, 로컬 데이터베이스에 캐시하는 경우에 사용한다.
PagingSource가 로컬 데이터베이스에 캐시된 데이터를 Pager에 제공하여, 로컬 데이터베이스의 캐시 데이터를 모두
사용하면, 다시 RemoteMediator가 API에서 새로운 데이터를 불러와 데이터베이스를 채운다.
PagingConfig : 페이지 크기나, Placeholder의 사용 여부 등을 설정할 수 있다.
Pager : 페이징의 진입점. PagingConfig에서 설정된 값으로, PagingSource를 호출하고 키를 관리한다.
페이징된 데이터를 PagingData로 만들어, PagingDataAdapter에서 사용할 수 있도록 제공한다.
PagingDataAdapter : RecyclerView.Adapter를 상속해 데이터를 RecyclerView에 표시하고, 데이터의 로딩 상태를 노출한다.
그리고 아이템을 비교하는 DiffUtil.ItemCallback 을 생성자로 받아서,
새로운 데이터가 전달될 때 기존의 아이템과 비교하고 변경된 것을 어댑터에게 알려준다.
[추가 요소] LoadStateAdapter : 로드 상태에 따라 Progress(진행)바나 재시도 버튼을 표시하기 위해 사용한다.
※ Paging3은 로딩 상태를 표시하기 위해,
아이템을 제공하는 어댑터와 로딩 상태를 표시하는 어댑터를 연결하는 "ConcatAdapter"를 만드는 방법을 사용한다.
9. 캐시로 HTTP를 효율적으로 사용하자
9장에서는 상세보기 기능을 만들었고,
Retrofit(정확히는 OkHttp)에 캐시를 사용하도록 설정하여, 요청과 응답이 어떻게 달라지는지 보았다.
캐시를 사용하기 위해 작성한 코드는, ApiService의 3줄이 전부였다.
HTTP 캐시는 라이브러리의 지원으로 쉽게 사용할 수 있지만,
GET 요청의 응답만 캐싱하고, 캐싱한 리소스를 사용하려면 HTTP 요청을 통해서만 가져올 수 있다는 한계가 있다.
10. Room으로 오프라인 액세스 지원
[296 페이지] ViewPager 대체
질문-답 카드를 좌우로 스와이프해서 넘겨보는 UI를 만들 때는, 원래 ViewPager를 사용한다.
하지만 Paging3의 PagingDataAdapter는 ViewPager를 지원하지 않기 때문에, RecyclerView를 기반으로 만들어진 ViewPager2를 사용한다.
10-1. Room Persistence 라이브러리
Room은 구글에서 만든, ORM(Object Relational Mapping) 라이브러리이다.
ORM : 프로그래밍 언어의 객체 <-> 데이터베이스 간의 데이터를 변환해 주는 기법이다.
ORM을 사용하면, 쿼리를 코드에서 직접 사용하지 않아도, 프로그래밍 언어의 클래스와 메서드를 통해서 데이터베이스를 사용할 수 있다.
그러나, Room은 일반적인 ORM 라이브러리와는 조금 다르다. -> SQLite만 지원하고, 쿼리를 직접 작성해야 한다.
또한 캐시, 지연 로딩 같이 많은 ORM 라이브러리가 지원하는 기능이 없다.
즉, 순수하게 SQLite의 기능만 지원하면서, ORM의 편의를 취한, 학습 난이도는 낮은 라이브러리이다.
Room은 Database를 관리하는 RoomDatabase, Entity, DAO(Data Access Object)로 구성된다.
DAO에서 Entity를 사용하고, Database는 DAO와 Entity를 모두 사용하기 때문에, Entity -> DAO -> Database의 순서대로 만들자.
Room 의존성을 추가하기 전에, Room은 어노테이션 프로세서에서 코드를 생성하기 때문에 plugins 블록에 kotlin-kapt 플러그인도 추가한다.
어노테이션 프로세서 : 컴파일을 할 때, 어노테이션에 따라 코드를 검사하거나 새로운 코드를 작성하는 등의 작업을 할 수 있는 도구
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
id("kotlin-kapt")
}
dependencies {
// 306페이지 추가
implementation("androidx.room:room-runtime:2.4.3")
implementation("androidx.room:room-ktx:2.4.3")
implementation("androidx.room:room-paging:2.4.3")
kapt("androidx.room:room-compiler:2.4.3")
}
10-2. SQLite과 Room
SQLite에서 INSERT나 UPDATE 구문을 처리하다 보면, 기본키 충돌이 발생할 수 있다.
SQLite에서는 이런 충돌의 해결 방법을 "ON CONFLICT" 구분으로 선택하고,
Room에서는 @Insert와 @Update 어노테이션의 "onConflict" 속성으로 지정할 수 있다.
OnConflictStrategy.ABORT : 기본값. 충돌이 발생하면, 트랜잭션을 중단하고 원래대로 되돌린다.
OnConflictStrategy.REPLACE : 충돌이 발생하면, 오래된 데이터를 새 데이터로 교체하고 트랜잭션을 계속한다.
OnConflictStrategy.IGNORE : 충돌이 발생하면, 새로운 데이터를 무시하고 트랜잭션을 계속한다.
@Query 어노테이션은 사용자가 정의한 Entity나 Array, List, Cursor 등의 기본 타입뿐만 아니라,
Flow, LiveData, Observable도 지원하고, Paging3 라이브러리와 함께 사용하면 PagingSource도 반환한다.
Room의 데이터베이스는 RoomDatabase를 상속받은 클래스에, @Database 어노테이션을 붙여 지정한다.
데이터베이스에서는 버전, Entity, DAO, 타입 컨버터, 마이그레이션 등 데이터베이스에 대한 설정을 할 수 있고, SQLite과의 연결을 관리한다.
Room의 DAO를 가져오기 위해서는, AppDatabase에 파라미터가 없고, DAO를 반환하는 추상 메서드가 있어야 한다.
※ 데이터베이스 클래스의 인스턴스를 만드는 과정은 다소 무겁고, 앱이 작동되는 동안 Room을 계속 필요하기 때문에 싱글톤으로 만들어 사용하는 것도 권장한다.
10-3. [330페이지] 까지 진행했을 때, 주의해야 할 오류
책의 내용 그대로 330페이지까지 하고 실행시켜 보았을 때,
컴파일 과정에서 UserDao에 문제가 생겨 제대로 실행되지 않는 것을 볼 수 있다.
아마 "Not sure how to convert a Cursor to this method's return type (java.lang.Object)" 이런 오류일 텐데,
이유는 사용하고 있는 코틀린 버전과 Room 라이브러리의 버전이 호환되지 않아서이다.
https://issuetracker.google.com/issues/236612358
24년 4월 현재를 기준으로 이 프로젝트의 코틀린 버전은 1.9.xx 대이므로,
모든 Room 라이브러리의 버전을 2.4.3에서 -> 2.6.1로 변경한다.
dependencies {
// 306페이지 추가
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.room:room-paging:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
}
10-4. 로컬 데이터베이스를 갱신하는 방법
서버의 데이터를 로컬 데이터 베이스에 저장하여 캐시로 사용할 땐, 갱신되는 시점이 있어야 한다.
예를 들어, HTTP 캐시에서는 ETag가 변경되거나, max-age가 지나서 만료되는 경우에 갱신을 했다.
Daily Q에서는 2가지 방법으로 데이터를 갱신할 수 있다.
1) 타임라인 화면을 당겨서 전체 목록을 새로고침
"Pull-to-refresh" 라고 하며, 안드로이드에서는 "SwipeRefreshLayout" 라이브러리를 사용하여 만든다.
build.gradle에 의존성을 추가한 다음, RecyclerView을 SwipeRefreshLayout으로 감싸주면 된다!
11. FCM으로 실시간 알림을 받아보자
FCM(Firebase Cloud Messaging)은 메시지를 보낼 수 있도록 도와주는 서비스이다.
메시지를 전송하는 방식은 푸시(Push)와 풀(Pull)로 나눌 수 있다.
API를 호출하는 것처럼 클라이언트가 서버에 필요한 정보를 요청해 응답을 받는 것을 풀 기법(Pull technology)이라고 하고, 반대로 클라이언트가 요청하지 않았지만, 서버에서 능동적으로 클라이언트에게 필요한 메시지를 보내는 것을 푸시 기법(Push technology)이라고 한다.
메일, 메신저, 캘린더, SNS와 같은 서비스는 이벤트가 발생하면 즉시 알 수 있어야 한다.
이벤트가 있는지 확인하기 위해 앱이 항상 실행 중인 상태에서 서버에 계속 확인을 해야 하는 것은, 리소스 제약이 큰 모바일 환경에는 적합하지 않은 방법이다.
그래서 모바일에서는 이벤트가 발생했을 때, "서버"가 "앱"에게 알려주는 푸시 기법이 주로 사용된다.
1번째) 로그인한 기기의 토큰을 FCM에 등록하고, 등록 토큰을 발급받는다.
2번째) 발급받은 등록 토큰을 API 서버(앱 백엔드)에 보낸다. 서버에서는 등록 토큰을 사용자 정보와 함께 보관한다.
서버에서 클라이언트에게 보내야 할 정보가 있을 땐,
3번째) API 서버가 보낼 메시지와 메시지를 받을 기기의 "등록 토큰"을 FCM으로 전달한다.
4번째) FCM이 등록 토큰의 기기에 메시지를 전달한다. 기기에서는 "구글 플레이 서비스"가 알림을 받아 앱으로 전달한다.
11-1. FCM 연동하기
1. FCM을 사용하기 위해서, 먼저 파이어베이스 콘솔에서 새 프로젝트를 만든다. "구글 애널리틱스" 사용도 설정한다.
https://console.firebase.google.com/
2. 생성된 파이어베이스 프로젝트에서, 안드로이드 앱을 추가한다.
패키지 이름은 build.gradle의 applicationId와 동일하게 입력하고, 선택사항은 비워둔다.
그리고, google-services.json 파일을 다운로드하여, 프로젝트의 app/src/main 폴더로 옮긴다.
※ [4월 기준] main 폴더로 넣으면, "google-service.json 파일을 찾을 수 없다." 고 에러가 발생한다.
그 상위폴더인 app/src 폴더에 google-services.json 파일을 넣어야 한다.
https://www.youtube.com/watch?v=tAvP7BPvErg
3. 안드로이드 클라이언트 설정
google-services.json 파일에는 앱에서 파이어베이스 서비스를 사용하기로 한 클라이언트 ID나 API 키 등이 포함되어 있다.
루트의 build.gradle과 앱의 build.gradle에 각각 플러그인을 추가하면, google-services.json에서 필요한 정보가 문자열 리소스로 프로젝트에 추가된다.
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.jetbrainsKotlinAndroid) apply false
id("com.google.gms.google-services") version "4.3.10" apply false
}
앱의 build.gradle에는 FCM 의존성과, Play 서비스를 코루틴과 함께 사용하기 위한 의존성도 추가한다.
※ 책의 내용처럼 analytics-ktx와 messaging-ktx의 버전을 지정하지 않았더니, 제대로 패키지가 추가되지 않아서
firebase-bom:26.7.0의 버전과 호환되도록 maven에 나와있는 정보를 참고해서 버전을 직접 지정했다.
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
id("kotlin-kapt")
id("com.google.gms.google-services")
}
...
dependencies {
// 343페이지 추가
platform("com.google.firebase:firebase-bom:26.7.0")
implementation("com.google.firebase:firebase-analytics-ktx:18.0.2")
implementation("com.google.firebase:firebase-messaging-ktx:21.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.3")
}
https://mvnrepository.com/artifact/com.google.firebase/firebase-bom/26.7.0
※ FCM SDK는 앱을 시작할 때, 자동으로 등록 토큰을 생성한다.
※ 안드로이드 8(API 26)부터는 미리 알림 채널을 만들어야 하기 때문에, 안드로이드 앱이 시작할 때 채널을 만든다.
EX) 그래서 답글과 팔로우를 위한 채널을 만드는 메서드를 각각 만들고, init() 메서드에서 호출하고, App.kt에서 호출해서 채널을 만든다.
※ FCM에서 전송한 메시지는 FirebaseMessagingService를 확장한 서비스로 전달된다.
[349페이지]의 [코드 11-8]처럼, FirebaseMessagingService를 상속받는 MessagingService를 만든다.
그리고 FCM 백엔드에서 보낸 메시지를 수신하는 onMessageReceived() 메서드를 재정의한다.
onMessageReceived() 메서드로 전달되는 RemoteMessage에서,
FCM의 2가지 메시지 타입인 "데이터 메시지"와 "알림 메시지"를 가져올 수 있다.
1) 알림 메시지
앱에서 다른 처리 없이 단순하게 서버에서 보내준 메시지를 알림으로 표시할 때 사용한다.
앱이 백그라운드에 있을 때는, FCM SDK에서 자동으로 작업 표시줄에 표시하기 때문에 onMessageReceived() 메서드가 호출되지 않는다.
앱이 포그라운드에 있을 때는, FCM SDK가 처리하지 않고 onMessageReceived() 메서드가 호출되며, RemoteMessage.getNotification() 메서드로 가져올 수 있다.
2) 데이터 메시지
개발자의 필요에 따라 자유롭게 구성할 수 있기 때문에 알림 이외의 용도로도 사용할 수 있다.
앱의 상태와 상관없이 항상 onMessageReceived() 메서드로 전달된다. 친구 신청 알림을 보낼 때, 보낸 사용자의 정보 메시지에 포함해 앱에서 저장할 수 있게 하거나, 알림을 표시하지 않고 앱의 내부 작동에만 영향을 주는 트리거로 사용할 수 있다.
-> 데이터 메시지가 앱에서 항상 직접 처리하고, 유연하게 사용할 수 있기 때문에 데이터 메시지를 선호한다!
마지막으로 AndroidManifest.xml에 MessagingService를 등록하면, 클라이언트 앱은 실시간 알림을 받을 준비가 끝난다.
...
<service
android:name=".messaging.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
11-2. API 서버 설정
API 서버가 FCM 백엔드로 메시지 전송을 요청하려면, API 서버에도 인증 정보가 필요하다.
새 비공개 키를 생성해 다운받는다. 이 파일의 이름을 "service_account.json" 으로 변경하고, DailyQ의 서버 폴더(daily-q-server)로 옮긴다.
서버를 시작하면, 인덱스 페이지(http://127.0.0.1:5000)에서 녹색 점과 "Firebase initialized" 메시지로 서버가 연결됐는지 확인할 수 있다.
12. 테마 (Theme)
안드로이드에서는 효율적인 UI 디자인을 위해, 스타일과 테마를 지원한다.
스타일 (Style) : 속성의 묶음. 개별 뷰의 색, 크기 등 뷰의 속성을 지정할 수 있다.
즉, 반복적으로 지정하는 속성들을 하나로 묶어 뷰의 "style" 속성을 재사용하는 것이다.
스타일은 일반적으로 values 폴더의 styles.xml 파일이나 themes.xml 파일에 선언한다.
<style name="MyButton">
<item name="android:backgroundTint">@color/green</item>
<item name="android:textColor">@color/red</item>
<item name="app:cornerRadius">24dp</item>
</style>
......
<com.google.android.material.button.MaterialButton
style="@style/MyButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
12.0.1 스타일 상속
스타일은 클래스처럼 다른 스타일을 상속받아 확장할 수 있다. parent 속성을 사용하거나 점(.) 표기법을 사용한다.
<style name="MySquareButton" parent="MyButton">
...
</style>
<style name="MyButton.Square">
...
</style>
12-1. 테마
<style> 태그를 사용해 정의한다는 점은 같지만, 용도가 다르다.
테마는 뷰가 참조하는 리소스를 정의하기 때문에, "뷰 계층" 혹은 "앱 전체" 에 적용되도록 하는 방법이다.
AndroidManifest.xml 파일에서 <application> 태그의 theme 속성으로 앱 전체에 적용되도록 하거나,
<activity> 태그의 theme 속성으로 특정 액티비티의 테마를 설정할 수 있다.
<style name="MyTheme" parent="Theme.MaterialComponents.DayNight">
<item name="materialButtonStyle">@style/MyButton</item>
</style>
<style name="MyButton" parent="Widget.MaterialComponents.Button">
<item name="android:backgroundTint">@color/green</item>
<item name="android:textColor">@color/red</item>
<item name="app:cornerRadius">24dp</item>
</style>
12.1.1 테마 속성의 참조
XML에서 물음표 (?)를 사용하면, 테마에 대해 정의된 리소스를 참조할 수 있다.
?attr/colorPrimary ?colorPrimary
이 방법을 사용하면, 테마에 따라 UI가 자동으로 변경되도록 만들 수 있다.
<style name="Theme.Red" parent="Theme.DailyQ">
<item name="colorPrimary">@color/red</item>
</style>
<style name="Theme.Blue" parent="Theme.DailyQ">
<item name="colorPrimary">@color/blue</item>
</style>
스타일을 직접 참조하지 않고, 다음처럼 물음표를 사용해 테마의 속성을 참조하면,
컨텍스트의 테마가 Theme.Red면 @color/red가 사용되고, Theme.Blue면 @color/blue가 사용될 것이다.
<com.google.android.material.button.MaterialButton
android:backgroundTint="?colorPrimary"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
12.1.2 색과 슬롯(Slot)
Material 테마에서는 색이 사용되는 위치나 종류에 따라 슬롯들로 분류되어 있다.
colorPrimary, colorSecondary : 브랜드를 나타내는, 대표하는 색. 버튼, 앱바 등의 뷰의 색으로 사용된다.
colorPrimaryVariant, colorSecondaryVariant : 브랜드 컬러의 변형으로,
앱바와 상태바처럼 브랜드 컬러와 연속되면서 구분이 되어야 할 때 사용된다.
colorSurface : 카드처럼 넓은 뷰의 표면에 사용된다.
android:colorBackground : 다른 슬롯들과 다르게, Material 테마가 아니라 안드로이드에 정의된 속성이다. 컴포넌트의 배경색으로 사용된다.
colorOnPrimary, colorOnSecondary, colorOnSurface, colorOnBackground : 이름에서 알 수 있듯이
colorPrimary 등의 색상등이 사용됐을때, "그 위에" 표시되는 텍스트나 아이콘의 색으로 사용된다.
테마를 사용하기 시작하면, 테마마다 팔로우 버튼의 색이 달라지고, 여러 위치에서 같은 색을 사용하기 때문에 기능마다 색을 추가하면 관리가 어려워진다.
따라서 colors.xml에 색을 추가할 땐, 색의 고유한 이름을 사용해 사람이 인지하기 편하게 하고, 같은 색이 다른 이름으로 여러 번 추가되지 않도록 한다.
※ 색상의 고유 이름을 정하기 힘들다면, 헥스 코드를 입력하면 이름을 알려주는 사이트를 참고하자!
https://www.color-blindness.com/color-name-hue/
https://chir.ag/projects/name-that-color/
12.1.3 타이포그래피 (typography)
타이포그래피란 활자의 서체, 배치, 크기 등을 구성하는 것이다.
타이포그래피가 잘 정리된 앱은 특별한 이미지가 없어도 잘 만들어진 앱이라는 느낌을 준다.
아래 그림처럼 사용 목적에 따라 분류하고, 모든 화면에서 일관된 스타일을 사용하면 정돈된 느낌을 줄 수 있다.
그런데, 뷰의 "style 속성"은 크기, 배치, 색 등 다른 종류의 속성 묶음을 변경할 때에도 사용되기 때문에 타이포그래피만 스타일 속성으로 지정해 사용하기 어렵다.
그래서 TextView, EditText, Button 등에는 타이포그래피 관련 속성들의 스타일을 지정하는 "textAppearance" 속성이 있다.
<com.google.android.material.textview.MaterialTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Answer"
android:textAppearance="?textAppearanceBody1" />
12-2. 테마 선택 기능
Primary color (주조색) : 앱의 컴포넌트나 화면에서 가장 자주 사용되는 색, 앱바나 버튼의 색으로 사용된다.
Secondary color (보조색) : 플로팅 액션 버튼, 체크박스, 스위치, 선택된 텍스트 같이 강조할 필요가 있을 때 사용한다.
※ 보조색은 선택사항으로 주조색과 같은 색을 사용해도 된다.
테마에 맞춤 속성을 추가하려면, 먼저 속성의 이름과 포맷을 attrs.xml 파일에 정의해야 한다.
아래 코드는 테마의 속성으로 변경할 colors.xml의 리소스 이름들을 속성으로 정의했다.
format은 컬러 리소스를 참조하기 위한 "reference" 와 색의 헥스 코드 문자열을 직접 입력하기 위한 "color" 이다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="colorUnderCard" format="reference|color" />
<attr name="colorFollowButton" format="reference|color" />
<attr name="colorUnfollowButton" format="reference|color" />
</resources>
13. 그래들과 배포
빌드 (Build) : 소스 파일 -> 실행할 수 있는 프로그램으로 변환하는 과정.
여기에는 컴파일, 리소스 최적화, 난독화, 패키징, 서명 등의 다양한 작업도 포함된다.
빌드의 각 단계를 매번 사람이 수동으로 하는 것은 번거럽고 어려우니, 이 과정들을 자동화한 "빌드 도구"를 사용한다.
안드로이드의 공식 빌드 도구는 그래들(Gradle)이다. 유연성과 성능에 초점을 맞춘 오픈소스 빌드 자동화 시스템이다.
그루비(Groovy)나 코틀린 DSL(Domain-Specific-Language)을 사용해, 빌드 스크립트를 작성해 사용하기 쉽고, 프로젝트에 따라 유연하게 확장할 수 있다.
또한 빌드 중간 단계의 산출물을 재사용하거나, 작업을 병렬로 실행할 수 있어서 빌드 속도가 빠르다.
13.1.1 settings.gradle
그래들은 하나의 프로젝트에 "모바일 앱 모듈", "Wear 앱 모듈", "라이브러리 모듈", "서버 모듈" 등 여러 서브 프로젝트를 포함하는 멀티 프로젝트 구조를 지원한다.
안드로이드 스튜디오 범블비(Android Gradle Plugin 7.1.0) 버전부터는 플러그인과 의존성 해결을 위한 저장소 설정도 settings.gradle에 기술한다.
13.1.2 프로젝트의 build.gradle
하위 모듈에 공통적으로 적용될 설정을 작성한다.
plugins 블록에, 사용할 플러그인의 아이디와 버전을 명시하면,
settings.gradle의 pluginManagement 의 repositories 블록에 있는 저장소에서 차례대로 찾는다
13.1.3 모듈의 build.gradle
플러그인의 설정과 모듈의 의존성을 기술한다. plugins 블록에 적용할 플러그인을 기술하고, android 블록에서 안드로이드 플러그인을 사용하도록 설정한다. 마지막으로 모듈의 의존성을 dependencies 블록에 기술한다.
의존성은 settings.gradle의 dependencyResolutionManagement 블록의 저장소에서 차례대로 찾아서 받는다.
13.1.4 gradle-wrapper.properties
여러 개발자들과 협업한다면, 서로 다른 환경에서 프로젝트를 빌드하게 된다.
누군가는 그래들이 설치되어 있지 않거나, 누군가는 다른 버전의 그래들이 설치되어 있다면, 빌드 과정에서 문제가 발생할 수 있다.
그래들은 이런 문제를 피하고 동일한 빌드 환경을 만들기 위해, 그래들 래퍼(Gradle Wrapper)를 제공한다.
그래들 래퍼는 프로젝트와 함께 배포되고, gradle-wrapper.properties 파일의 설정에 따라 자동으로 필요한 버전의 그래들을 설치하고, 빌드 스크립트를 실행한다.
13.1.5 gradle.properties
JVM 옵션이나, 병렬 모드 같은 그래들 실행 옵션을 설정한다.
13.1.6 local.properties
안드로이드 SDK 경로 등 로컬 환경을 구성한다.
안드로이드 스튜디오에서 설정을 변경하면 덮어쓰기 때문에 직접 수정하지 않아도 된다.
13-2. 그래들 태스크
태스크는 그래들 빌드의 가장 작은 부분이다. 각 태스크들은 이름을 갖고 있어 직접 태스크를 실행할 수 있고,
다른 태스크에 의존성을 가져 여러 태스크들이 순서대로 실행될 수도 있다.
태스크는 그래들이나 플러그인에 미리 준비되어 있고, 필요에 따라 직접 만들어 사용할 수 있다.
task("clean") {
delete(rootProject.buildDir)
}
※ Run anything 창에 "gradle tasks" 라고 입력하면, 간단한 설명과 함께 전체 태스크 목록을 볼 수 있다.
13-3. 안드로이드 플러그인
안드로이드 빌드는 안드로이드 그래들 플러그인에 의해서 처리되고, android 블록에서 빌드 설정을 할 수 있다.
android {
namespace = "online.dailyq"
compileSdk = 34
defaultConfig {
applicationId = "online.dailyq"
minSdk = 29
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding = true
}
}
compileSdkVersion : 어떤 버전의 SDK를 사용해 컴파일할지 지정한다. 현재 버전이나 이전 버전에 포함되는 API를 사용할 수 있다.
buildToolsVersion : 사용할 빌드 도구의 버전을 지정한다. 생략하면 안드로이드 플러그인의 기본 버전을 사용한다.
13.3.1 defaultConfig 블록
모든 빌드 변형에 적용되는 값. AndroidManifest.xml에 같은 항목이 있다면 덮어쓴다.
1) applicationId : 고유한 안드로이드 앱 아이디. AndroidManifest.xml의 <manifest> 태그의 package 속성을 덮어쓴다.
기기나 스토어에서 이 아이디로 앱을 식별한다.
2) minSdkVersion : 앱이 설치될 수 있는 최소 버전
3) targetSdkVersion : 개발자가 만들면서 의도한 안드로이드 버전. 구글 플레이 스토어에서는 주기적으로 특정 버전 이하는 앱 업데이트를 할 수 없게 강제하기 때문에, targetSdkVersion을 최신 버전으로 유지하는 것이 좋다.
4) versionCode : 앱의 버전을 나타내는 "자연수".
구글 플레이 스토어에 배포할 때, 이전 버전보다 높은 숫자만 사용할 수 있다.
5) versionName : 사용자에게 표시될 버전.
"주(Major) 버전.부(Minor) 버전.수정(Patch) 버전" 같이 3부분으로 나뉘지만 특별하게 정해진 규칙은 없다.
13.3.2 빌드 타입 (Build Types)
빌드 타입은 buildTypes 블록에서 구성할 수 있다.
debug나 release 블록에서는 같은 제품 버전에 대해 디버깅, 난독화, 서명 등의 변형을 구성할 수 있다.
기본으로 생성된 release 블록에서 isMinifyEnabled 항목을 볼 수 있다.
난독화(Obfuscation)와 축소(Code Shrinking) 기능을 사용할지 여부를 지정하는 것이다.
13.3.3 난독화와 코드 축소
자바나 코틀린 코드를 컴파일하면, 가상 머신에서 실행되는 바이트 코드가 만들어진다. 이 바이트 코드는 쉽게 디컴파일(decomplie)되고, 원래의 소스 코드와 유사한 코드를 얻을 수 있어, 보안에 취약할 수 있다.
그래서 코드를 인위적으로 변경하여 읽기 어렵게 만드는 것을 "난독화" 라고 한다.
코드 축소 : 컴파일러가 코드를 분석하여, 사용하지 않는 클래스나 메서드 등을 삭제한다.
코드 축소나 난독화를 할 때, 리플렉션을 사용하거나 네이티브 코드에서 호출하는 경우에 컴파일러가 알 수 없기 때문에, 코드를 변경하지 말라고 알려줘야 한다.
이런 규칙을 proguard-rules.pro 파일에 기술하거나, 별도의 파일로 만들어(my-rules.pro) proguardFiles 속성에 추가한다.
13.3.4 서명
※ gradle 파일이 groovy 언어에서 kotlin으로 강제되었으므로, 코드도 수정한다.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
load(FileInputStream(keystorePropertiesFile))
}
android {
//...
signingConfigs {
register("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
//...
}
13.3.5 제품 버전 (Product flavors)
같은 코드에서 여러 변형을 만들 때 사용한다.
EX) 무료 버전, 유료 버전, 구글 플레이 스토어 버전, 원스토어 버전, 또는 기기의 아키텍처에 따라(arm 아키텍처) 다른 APK를 만들고 싶을 때 아래 코드 같이 productFlavors 블록에서 제품 변형을 위한 설정을 할 수 있다.
android {
...
buildTypes {
...
}
flavorDimensions("environment")
productFlavors {
register("dev") { // 개발 버전
dimension = "environment"
applicationId = "online.dailyq.dev"
versionNameSuffix = "-dev"
}
register("prov") { // 출시 버전
dimension = "environment"
}
}
...
}
13.3.6 버전 차원 (Flavor dimension)
모든 제품 버전은 버전 차원에 속한다. 버전 차원은 같은 기준으로 만들어진 제품 버전의 구성을 그룹의 묶어,
그래들이 빌드할 때 버전 차원들의 조합으로 최종 빌드 변형을 만들도록 한다.
EX) tier와 environment 차원을 갖는다면 "빌드 타입 x 버전 차원(tier) x 버전 차원(environment)"의 빌드 변형을 만든다.
13.3.7 소스 세트 (Source set)
안드로이드 앱을 만들기 위해서는 코틀린 소스 코드, 이미지나 문자열 등의 리소스, AndroidManifest.xml 등 다양한 파일들이 사용된다. 이런 파일들을 논리적으로 그룹화한 것을 소스 세트라고 한다.
안드로이드 스튜디오는 모든 빌드 변형마다 각각의 소스 세트를 사용할 수 있도록 지원한다.
13.3.5 에서 만든 dev 제품 버전은 applicationId를 "online.dailyq.dev"로 만들었다. 그런데 파이어베이스는 applicationId로 클라이언트를 식별하기 때문에, dev 제품 버전을 빌드하면 오류가 발생한다.
따라서 applicationId를 online.dailyq.dev 로 한 새 파이어베이스 프로젝트를 만들고, 생성한 google-services.json을 dev 폴더를 만들어서 넣으면 된다.
그리고 dev 버전의 앱과 prod 버전의 앱이 한 기기에 함께 설치했을 때 구분할 수 있도록 dev 소스 세트에 strings.xml 파일을 추가해 앱 이름을 변경한다.
13.3.8 매니페스트 플레이스홀더
특정 기능의 활성화 여부를 AndroidManifest.xml의 <meta-data> 태그로 제어하고, 빌드 변형에 따라 다른 값을 가져야 하는 경우가 있다.
이를 위해 각 빌드 변형의 폴더마다 AndroidManifest.xml 파일을 만드는 대신,
build.gradle에서 manifestPlaceholders 속성으로 변수를 삽입할 수 있다. "키-값" 쌍의 형태로 사용할 변수들을 선언했다.
android {
flavorDimensions("environment")
productFlavors {
register("dev") { // 개발 버전
dimension = "environment"
applicationId = "online.dailyq.dev"
versionNameSuffix = "-dev"
manifestPlaceholders["enableFeature"] = "false"
}
register("prov") { // 출시 버전
dimension = "environment"
manifestPlaceholders["enableFeature"] = "true"
}
}
}
AndroidManifest.xml의 삽입될 위치에 ${키}의 형태로 사용한다.
<meta-data
android:name="sdk.specialfeature"
android:value="${enableFeature}" />
</application>
</manifest>
14. 부록 : 파이어베이스로 앱 품질 높이기
14-1. 애널리틱스 (Analytics)
애널리틱스를 프로젝트에 추가하는 것만으로도 화면 흐름이나 사용자 연령대 등 자동으로 수집이 되는 데이터가 있어 큰 도움이 된다.
애널리틱스는 이벤트와 사용자 속성 데이터를 수집할 수 있다.
※ 자동으로 수집되는 이벤트
https://support.google.com/analytics/answer/9234069
※ 사전 정의된 사용자 측정 기준
https://support.google.com/analytics/answer/9268042
14.1.1 이벤트
이벤트는 액티비티의 시작이나 버튼 클릭, 알림 표시 등 사용자의 행동이나 앱의 작동을 수집할 때 사용한다.
그리고 FirebaseAnalytics 인스턴스의 logEvent() 메서드로 이벤트를 기록할 수 있다.
메서드의 첫 번째 인자에는 "이벤트 이름" , 두 번째 인자에는 "이벤트 관련 데이터" 를 번들로 전달한다.
val firebaseAnalytics = Firebase.analytics
val bundle = Bundle()
bundle.putString("date", "2024-04-22")
bundle.putString("question_type", "text")
firebaseAnalytics.logEvent("show_question_details", bundle)
14.1.2 사용자 속성
사용자의 연령층, 국가, 사용 중인 기기의 모델, 버전 등의 정보이다.
이벤트와 마찬가지로 미리 정의된 속성 외에, 필요에 따라 다른 속성을 직접 추가할 수 있다.
위 사진처럼 파이어베이스 콘솔의 "Custom Definitions" 에서 미리 등록한 후 사용할 수 있다.
콘솔에서 측정기준을 등록한 후, setUserProperty() 메서드로 사용자에게 값을 할당할 수 있다.
EX) 사용 중인 테마를 사용자 속성으로 추가하려면 다음과 같이 만든다.
val firebaseAnalytics = Firebase.analytics
firebaseAnalytics.setUserProperty("theme", "dark")
이렇게 수집한 데이터는 구글 애널리틱스 사이트에서 다양한 형태의 보고서로 볼 수 있고,
파이어베이스 프로젝트 설정에서 빅쿼리와 통합하면, 빅쿼리에서 직접 데이터를 조회할 수 있다.
14-2. 크래시리틱스 (Crashlytics)
크래시리틱스는 원격에서 발생한 오류의 정보(OS 버전, 모델명, 스택 트레이스등)를 수집하고 관리할 수 있도록 지원한다.
크래시리틱스를 사용하기 위해서는 SDK와 플러그인을 프로젝트에 추가해야 한다.
1) 프로젝트 build.gradle에서 플러그인 아이디와 버전을 추가한다.
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
...
id("com.google.firebase.crashlytics") version "2.8.1" apply false
}
2) 모듈 build.gradle에 플러그인을 적용하고, 의존성을 추가한다.
plugins {
...
id("com.google.firebase.crashlytics")
}
dependencies {
...
// 417페이지 추가
implementation("com.google.firebase:firebase-crashlytics-ktx:17.4.0")
}
이제 앱에서 비정상 종료가 발생하면, 자동으로 오류가 보고되고 크래시리틱스 대시보드에 표시된다.
비정상 종료를 방지하기 위해 try-catch 구문 같은 방법으로 예외를 처리했지만, 오류에 대한 정보가 필요하다면 "recoredException()" 메서드로 직접 예외를 전송해 필요한 정보를 얻을 수 있다.
이렇게 전송된 오류는 "심각하지 않은 오류" 로 분류돼, 대시보드에서 구분해서 볼 수 있다.
14.2.1 크래시리틱스 비활성화
빈번하게 비정상 종료가 발생할 수 있는 개발 버전에서는 자동 오류 보고 기능을 비활성화 하고 싶을 수도 있다.
앱의 build.gradle에서 dev 버전에서는 오류 보고 기능을 비활성화하고, prod 버전에서는 오류를 보내도록 할 수 있다.
productFlavors {
register("dev") { // 개발 버전
dimension = "environment"
applicationId = "online.dailyq.dev"
versionNameSuffix = "-dev"
ext.enableCrashlytics = false // 추가된 코드
}
register("prov") { // 출시 버전
dimension = "environment"
}
}
15. 부록 C. 오픈소스
Github에서 안드로이드 프로젝트를 주제별로 선별하고 정리한 유명한 목록 2군데
https://github.com/JStumpp/awesome-android
https://github.com/wasabeef/awesome-android-ui
'안드로이드 > 도서 내용 정리' 카테고리의 다른 글
내용 정리 Part. 1 - [SNS 앱을 만들면서 배우는 안드로이드 클라이언트 개발] (0) | 2024.04.03 |
---|---|
내용 정리 - [핵심만 골라 배우는 젯팩 컴포즈] (0) | 2024.03.14 |
[2023년 11월 기준] 직접 해보면서 참고/추가 설명 - [Joyce의 안드로이드 앱 프로그래밍] (0) | 2023.12.15 |