저는 FlameTalk에서 아래와 같은 기능을 담당하고 있습니다
- 로그인 회원가입 및 유저 인증 기능 개발
- 연락처 동기화하여 친구 추가 및 친구 상태 관리 (숨김, 차단친구)
- 유저 프로필 관리
- 프로필, 배경화면 이미지 히스토리 피드 구현
- 파일 서버 통신
- 친구 리스트 검색 기능 (Room을 이용한 내부 DB 기반 검색)
- 프로필 스티커 기능
(항목을 클릭하면 빠르게 내용으로 이동합니다🏃♀️)
- Coroutine Deferred를 이용하여 비동기 통신 지연
- ViewModel을 이용한 Fragment간 데이터 공유
- SharedPreferences 대신 DataStore.preferences로 로컬 유저 정보 저장
- NetworkInterceptor
- Hilt 구조에서의 token 자동 주입
- request, response 요청 시 Debug Log 기록
- Android Navigation 적용
- Repository 패턴을 적용하여 NetworkModule과 RoomModule의 접근
- RoomDB를 이용한 local data 기반 검색
- LiveData 대신 StateFlow를 이용
- 코드의 재사용성에 대한 고민
- DiffUtil의 확장함수 - SimpleDiffUtilCallback
- AppBar의 layout의 include
- 주소록 전화번호 가져온 후 통신 요청 보내기
- 이미지뷰 동적 생성 및 positioning
└── flametalk_android
├── data
│ ├── dummy // 통신 전 UI 테스트를 위한 더미데이터
│ ├── model // 재사용되는 data class 정의
│ └── source
│ └── local
│ └── dao // 로컬 RoomDB 데이터 접근 인터페이스
├── di
├── domain
│ ├── entity // DB에 저장할 데이터 모델
│ └── repository // 데이터 엑세스 레파지토리
├── network
│ ├── request // API request body
│ ├── response // API response body
│ └── service // 네트워크 통신 요청 인터페이스
├── ui // 화면 별 Fragment, ViewModel, Adapter
└── util // 확장함수와 util클래스
======
프로필을 생성하는 상황에서 파일 데이터를 통신하는 방식은 2가지가 있습니다.
- API 요청을 보낼 때 다른 데이터와 Multipart/form으로 변환한 파일 데이터를 함께 요청
- 파일 서버와 Client 선통신 후 response로 받은 S3 url을 body에 담아 API 요청
1번은 Client에서는 API들을 통해 큰 파일 데이터가 이동하게 되고 이는 서버 통신에 부담이 된다고 판단하여 파일 서버를 Client쪽에 두는 구조를 선택했습니다. 따라서 프로필 생성 시 파일 데이터가 있는 경우 파일 API의 통신 요청을 응답이 올때까지 기다린 후 응답으로 받은 파일의 주소(url)를 프로필 생성 요청에 넣어야 합니다.
Coroutine을 통해 비동기 통신을 하기 때문에 특정 작업의 마무리 시점을 보장받지 못합니다. 따라서 비동기 작업의 응답 시점을 알기 위해 async, await을 이용하여 '파일 생성 API'을 요청하고 Deferred를 리턴받습니다. 이때 Deferred 객체를 await()하면 해당 작업이 끝나기 전까지 다음 작업이 수행되지 않습니다. 따라서 await() 요청 다음에 '프로필 생성 API' 요청을 보냄으로써 비동기 통신의 타이밍 문제를 해결할 수 있었습니다.
자세한 문제 해결 과정은 개인 Notion에 기록했습니다. 자세한 문제 해결 과정
(아래의 타이틀을 누르면 코드로 이동합니다.)
Add Profile (Fragment & ViewModel)
프로필 생성 및 통신
Edit Profile (Fragment & ViewModel)
프로필 수정 및 통신
Profile Desc (Fragment)
상태메세지 수정
-
Fragment의 재사용
ProfileDesc는 사용자의 텍스트 입력을 받는 역할로 Add와 Edit에서 접근할 때 동일하게 동작합니다. 따라서 ProfileDesc는 이전 뷰로부터 뷰 타입 정보를 args로 넘겨받고 Fragment를 재사용할 수 있습니다.
-
ViewModel의 공유
Add Profile에서 상태메세지 수정을 누르면 ProfileDesc로 이동합니다. Profile Desc에서 입력한 데이터는 UI를 pop했을 경우 이전의 뷰로 데이터를 전달해야 합니다.
- 데이터를 UI Controller 변수에 담아두고 직접 전달
- ViewModel의 변수에 저장하고 Fragment가 공유
1번의 방법은 화면을 회전하는 경우 Fragment가 파괴되었다가 다시 생성되는 생명주기의 변화를 겪으며 데이터가 손실될 가능성이 있습니다. ViewModel의 경우 참조하는 View가 UI 스택에서 완전히 제거되기 전까지 파괴되지 않기 때문에 2번의 경우는 생명주기로 부터 비교적 안전하게 데이터를 전달할 수 있습니다.
- Hilt 구조에서의 Header에 데이터 자동 주입
의존성 주입에 관심이 생겨 이번 프로젝트에 처음으로 Hilt를 기반으로 DI 구조를 적용해봤습니다. Hilt를 적용하여 프로젝트 기반 구조를 적용했습니다. 네트워크 통신 요청 시 Header에 Content-Type과 ACCESS-TOKEN을 넣어줘야 합니다. 이는 api 통신 인터페이스에 직접 @Header로 선언하여 넣어줄 수도 있지만 반복되는 코드의 작성으로 보일러 플레이트라고 판단했습니다. 결과적으로 NetworkInterceptor에서 토큰을 자동 주입하도록 하고 이 객체를 OkHttp에 interceptor로 추가되도록 구현했습니다.
- Request, Response 로그 남기기
api 통신 시 요청, 응답 데이터를 확인하기위해 ViewModel에서 직접 로그를 작성해야 하는데 이 작업을 자동화하기 위해 NetworkInterceptor에서 로그를 남기도록 했습니다.
FlameTalk 프로젝트는 Single Activity 기반의 구조로 Andriod Jetpack에서 권장하는 Navigation을 이용하여 화면간 이동하도록 구현했습니다. Activity 간 통신이 프로세스간 통신이므로 메모리를 공유하는 Fragment간 통신에 비해 퍼포먼스가 떨어져 상대적으로 무겁다고 할 수 있습니다. Fragment를 이용한 화면 구성은 앱 퍼포먼스를 향상시킵니다. Navigation Component는 1개의 Activity 위에 Fragment로 UI Controller를 구성하는것을 지향하고 있으며 이는 기존의 Activity를 이용한 UI 구현시 보다 앱 용량도 훨씬 줄일 수 있습니다. 또한 SafeArgs가 등장하며 Navigation을 통해 destination을 설정하여 Fragment를 전환할때도 데이터 전달이 가능하게 되었고, UI 백스택의 관리도 편리합니다.
<?xml version="1.0" encoding="utf-8"?>
<navigation 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:id="@+id/main_navigation"
app:startDestination="@id/navigation_signin"> // 앱의 시작 뷰 설정
<!--프로필 total 피드-->
<fragment
android:id="@+id/navigation_total_feed"
android:name="com.sgs.devcamp2.flametalk_android.ui.feed.TotalFeedFragment" // 연결할 UI Controller
android:label="TotalFeed"
tools:layout="@layout/fragment_total_feed"> // 연결할 layout
<action
android:id="@+id/action_feed_total_to_profile"
app:destination="@id/navigation_profile" // 이동할 목적지
app:popUpTo="@+id/navigation_profile" // pop될 경우 profile로 랜딩
app:popUpToInclusive="true" /> // 이 뷰 위에 다른 뷰가 pop될 때 같이 pop
<argument
android:name="profileId" // 다른 뷰에서 이 뷰로 넘겨줘야할 파라미터
android:defaultValue="0L"
app:argType="long" />
<argument
android:name="profileImage"
android:defaultValue=""
app:argType="string" />
</fragment>
// 친구리스트 > 멀티프로필 생성: 멀티 프로필 만들기
binding.itemFriendAddProfile.root.setOnClickListener {
/**파라미터를 없이 뷰 전환*/
findNavController().navigate(R.id.navigation_add_profile)
}
// 내 프로필 미리보기 > 프로필 상세 보기 이동
binding.lFriendMainUser.root.setOnClickListener {
/**파라미터를 넣어 뷰 전환*/
val friendToProfileDirections: NavDirections =
FriendFragmentDirections.actionFriendToProfile(
viewType = USER_DEFAULT_PROFILE, profileId = viewModel.userProfile.value!!.id
)
findNavController().navigate(friendToProfileDirections)
}
따라서 Coroutine의 실행 스레드를 지정하기 위한 CoroutineModule을 생성하고 repository에서 withContext()를 통해 백그라운드에서 비동기 처리로 요청을 수행할 수 있습니다.
@Module
@InstallIn(SingletonComponent::class)
class CoroutineModule {
@Provides
@Singleton
fun provideIoDispatcher(): CoroutineDispatcher { // 백그라운드에서 실행되도록 지정
return Dispatchers.IO
}
@Provides
@Singleton
fun provideExternalScope(): CoroutineScope {
return GlobalScope
}
}
// 친구 리스트 로컬에 저장
suspend fun insertAllFriends(vararg friends: FriendModel) = withContext(ioDispatcher) {
friendDAO.get().insertAllFriends(*friends)
}
// 친구 리스트 전체 가져오기
suspend fun getAllFriends() = withContext(ioDispatcher) {
friendDAO.get().getAllFriends()
}
FlameTalk에서 친구 목록 검색은 한정된 친구 리스트 데이터를 기반으로한 검색이기 때문에 서버의 부담을 줄여주고자 클라이언트에서 로컬 검색으로 구현했습니다. 로그인 후 첫 화면인 친구 목록 뷰의 초기화를 위해 서버로부터 친구 목록 데이터를 가져올 때 이를 RoomDB에 friend 테이블에 저장하고 있습니다.
Entity - FriendModel friend 테이블에 저장될 Entity
DAO(Data Access Object) - FriendDAO friend 테이블의 데이터에 접근할 수 있는 인터페이스
Repository - FriendRepository 친구 데이터를 네트워크와, 로컬에서 가져오는 repository
로컬의 데이터를 가져오는 작업 또한 오래걸리는 무거운 작업이므로 Coroutine을 이용하여 백그라운드 스레드를 이용한 비동기 작업으로 진행하도록 했습니다. 아래와 같이 Coroutine Flow
// 친구 리스트 로컬에 저장
suspend fun insertAllFriends(friends: List<FriendModel>) = withContext(ioDispatcher) {
db.friendDao().insertAllFriends(friends)
}
// 친구 리스트 전체 가져오기
suspend fun getAllFriends() = withContext(ioDispatcher) {
db.friendDao().getAllFriends().flowOn(ioDispatcher)
}
ViewModel - SearchViewModel 실질적인 검색 비즈니스 로직을 수행
UI의 초기화와 뷰모델 생성 시 로컬 저장소로부터 검색에 쓰일 데이터를 가져오고 이를 map함수를 통해 검색어를 포함하는지 확인하여 검색을 구현했습니다. 문자열 알고리즘에서 가장 성능이 좋은 KMP 알고리즘은 O(n)의 시간복잡도를 가지고 있습니다. Android Framework의 contains를 설명하는 코드를 보면 contains는 내부적으로 indexOf를 이용하고 있으며 indexOf의 시간복잡도 또한 O(n)을 가지고 있어 결과적으로 contains를 이용하여 O(n) 성능을 가진 검색을 구현했습니다. KMP 알고리즘을 직접 구현하려 했으나 검색 UI에 이후에 채팅방 검색이 추가될 가능성이 있어 팀원과 협업을 위해 보다 가독성이 좋은 contains를 선택하게 되었습니다.
init {
// 뷰모델 생성 시 친구 전체 목록 가져옴
viewModelScope.launch {
friendRepository.get().getAllFriends().collectLatest {
_allFriends.value = it
}
}
}
// 검색어 입력 후 이벤트 날릴 때 호출
fun searchFriend(input: String) {
var result: ArrayList<FriendModel> = arrayListOf()
if (_allFriends.value.isNullOrEmpty()) {
_message.value = "친구 데이터가 없습니다."
} else {
if (!input.isNullOrEmpty()) {
_allFriends.value!!.map {
if (it.nickname.contains(input)) {
result.add(it)
}
}
} else {
}
}
_searchedFriend.value = result
}
LiveData는 Android에서 권장하는 AAC로 UI에서 ViewModel의 LiveData 객체를 관찰하여 데이터의 변경사항이 UI로 자동적으로 반영됩니다. 이전에 프로젝트에서 LivaData를 사용해봤지만 LivaData는 뷰를 반드시 거쳐야 데이터가 관찰되기 때문에 View 로직에 적용할때 유리한 것으로 알고있습니다. Flow는 Coroutine의 범위에 상관없이 Model 계층의 데이터의 수집하는 특성으로 Data 로직에 사용하기 좋습니다. StateFlow는 이 두 특성을 포함된 개념으로 UI 상태를 지켜보고 변경된 상태가 화면에 지속되도록 ViewModel에서 상태 지속할 수 있으며 flow의 데이터를 StateFlow 객체 저장할 수 있습니다.
- UI Controller에서 관찰하는 방법
// 로그인된 유저의 닉네임 띄움
lifecycleScope.launch {
viewModel.nickname.collectLatest {
if (it.isNotEmpty()) {
Snackbar.make(requireView(), "${it}님 로그인 되었습니다.", Snackbar.LENGTH_SHORT).show()
findNavController().navigate(R.id.navigation_friend)
}
}
}
반복되는 기능의 구현으로 인한 보일러플레이트를 줄이고 코드의 가독성을 높이고자 util 디렉터리에 확장함수를 만들어 사용하고 있습니다. 확장함수의 일부 구현 사례입니다.
SimpleDiffUtilCallback.kt RecyclerView에 List 데이터를 Adapter에 할당하고 갱신하는데 notifyDataSetChanged()를 이용하여 전체갱신할 수 있습니다. 그러나 이 방법은 매번 전체 데이터를 UI에 갱신하여 비효율적이고 UI 깜빡임 현상이 나타납니다. 따라서 리스트 아이템 중 다른 아이템만 가져오는 DiffUtil을 적용하기 위해 해당 콜백 함수를 구현했고, 리스트 아이템의 모델에 상관 없이 재사용할 수 있도록 확장함수인 SimpleDiffUtilCallback을 만들었습니다. 결과적으로 UI Controller에서 notifyDataSetChanged() 대신 submitList()를 호출하여 리스트 변경 사항만 업데이트하게 됩니다.
/**
* @author 박소연
* @created 2022/01/17
* @desc RecyclerView DiffUtil 확장함수
* 반환하는 데이터 타입, 모델에 상관없이 쓸 수 있음
*/
class SimpleDiffUtilCallback<T : Any> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
}
SimpleDiffUtilCallback의 사용 예시
class SingleFeedAdapter(
private val context: Context
) : ListAdapter<Feed, SingleFeedAdapter.FeedHorizentalViewHolder>(SimpleDiffUtilCallback()) {
var data = listOf<Feed>()
- AppBar layout의 재사용
앱 상단바의 경우 유사한 모양이 반복됩니다. 똑같은 파일을 여러개 만들지 않고 item으로 하나의 레이아웃을 만들고 이를 include하여 각각의 UI에 맞춰 이용하며 레이아웃의 재사용성을 높였습니다. 다만 include 또한 뷰 레이어를 한층 더 깊게 한다는 한계점이 있기 때문에 이후에 merge로 전환해볼 예정입니다.
fragment_friend.xml
<include
android:id="@+id/ab_friend"
layout="@layout/ab_main"
android:layout_width="match_parent"
android:layout_height="70dp"
app:layout_constraintTop_toTopOf="parent" />
이번 프로젝트에서 연락처 데이터를 가져와 서버로 '연락처 리스트 기반 친구 추가 API' 통신 요청을 보내는 기능을 구현했습니다. 처음엔 연락처 가져오는 작업을 Fragment에서 수행했으나 연락처 데이터가 많은 실기기에서 테스트 시 메인스레드의 부담이 생겨 백그라운드 스레드에서 동작하도록 변경했습니다(네트워크와 IO 작업과 같이 시간이 오래 걸리는 작업은 Background 동작해야 합니다. 오래 걸리는 작업으로 인해 UI 컴포넌트를 그리는 작업을 5초 이상 방해받으면 ANR이 발생하며 앱이 비정상종료됩니다.) 연락처를 가져오는 작업이 끝난 후 통신 요청을 보내야하기 때문에 Coroutine의 deferred를 이용하여 비동기 동작이 끝난 시점 이후에 친구 추가 통신 요청을 보내도록 구현했습니다.
유저 프로필에 스티커를 붙여 꾸밀 수 있는 기능을 구현하였습니다.
프로필 생성 화면에서 하단에 스티커 메뉴를 추가했습니다. 각각을 누르면 화면 중앙에 스티커 각각에 id를 부여하여 ImageView를 동적으로 생성합니다. View.OnTouchListener를 이용하여 사용자의 터치 이벤트를 다음과 같이 처리합니다. 터치할 때, 움직일 때 -> 터치한 좌표에 스티커 위치 시킴 터치를 뗄 때 -> 해당 스티커의 정보(스티커 종류, 위치 좌표)를 ViewModel의 createSticker 함수를 호출
스티커를 붙이는 유저의 디바이스 화면 사이즈와 해당 프로필을 조회하는 유저의 디바이스 화면 사이즈가 다를 수 있습니다. 따라서 다양한 디바이스 화면 사이즈에 상대적으로 스티커의 좌표를 위치시키기 위해 x, y위치 좌표값을 가로, 세로 사이즈로 나눈 비율로 저장했습니다.
createSticker
fun createSticker(id: Int, stickerType: Int, x: Double, y: Double) {
/**프로필 조회하는 디바이스의 사이즈에 따라 scaling 하기 위해
디바이스의 기기 가로, 세로 사이즈로 나누어 position 저장*/
val dm: DisplayMetrics = context.resources.displayMetrics
val width = dm.widthPixels
val height = dm.heightPixels
val stickerModel = Sticker(
stickerId = stickerType,
positionX = x / width,
positionY = y / height
)
stickers.add(stickerModel)
}
removeSticker
fun removeSticker(id: Int) {
stickers.removeIf { it.stickerId == id }
}
다음과 같이 생성한 스티커, 삭제한 스티커의 정보를 반영하고 프로필 생성 이벤트 호출 시 다른 프로필 정보와 스티커 정보를 담아 프로필 생성 API의 request로 요청합니다.
프로필 조회 시 서버로 부터 넘겨받은 스티커 리스트를 forEach문을 돌며 UI에 스티커 ImageView로 동적 생성합니다.
lifecycleScope.launchWhenResumed {
viewModel.stickers.collectLatest { sticker ->
sticker.forEach {
binding.cstProfile.addView(
createImageView(
it.stickerId,
it.positionX,
it.positionY
)
)
}
}
}
프로필을 조회하는 디바이스의 화면 비율에 맞춰 스티커를 ConstraintLayout 내 배치합니다. 스티커 생성 시 각각의 스티커 종류 정보를 담았으므로 생성한 스티커의 에셋과 동일한 이미지뷰를 생성할 수 있습니다.
private fun createImageView(emoji: Int, positionX: Double, positionY: Double): View {
/**프로필 조회하는 디바이스의 사이즈에 따라 scaling 하기 위해
디바이스의 기기 가로, 세로 사이즈로 나누어 position 저장*/
val dm: DisplayMetrics = requireContext().resources.displayMetrics
val width = dm.widthPixels
val height = dm.heightPixels
// 스티커를 위한 ImageView 동적 생성
val img = AppCompatImageView(requireContext())
// 생성할 스티커의 사이즈
val param = ConstraintLayout.LayoutParams(100, 100)
// 생성한 스티커를 저장된 좌표에 배치하기 위한 layout 제약
param.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
param.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
param.marginStart = (positionX * width).toInt()
param.topMargin = (positionY * height).toInt()
when (emoji) {
EMOJI_AWW -> Glide.with(requireContext()).load(R.drawable.emoji_aww).into(img)
EMOJI_CLAP -> Glide.with(requireContext()).load(R.drawable.emoji_clap).into(img)
EMOJI_DANCE -> Glide.with(requireContext()).load(R.drawable.emoji_dance).into(img)
EMOJI_HEART -> Glide.with(requireContext()).load(R.drawable.emoji_hearts).into(img)
EMOJI_PARTY -> Glide.with(requireContext()).load(R.drawable.emoji_party).into(img)
EMOJI_SAD -> Glide.with(requireContext()).load(R.drawable.emoji_sad).into(img)
}
// 각 스티커 객체 별 아이디 생성
img.id = ViewCompat.generateViewId()
img.layoutParams = param
return img
}