Skip to content

30th-THE-SOPT-Android-Part/Android-YoungJin

Repository files navigation

Android-YoungJin

Week7


  • LEVEL 1
  • LEVEL 2
  • LEVEL 3
온보딩 자동로그인 로그아웃

Navigation Componenet를 적용한 온보딩 화면 구현

1. Navigation Graph 추가

<navigation...
    android:id="@+id/nav_onboarding_graph"
    app:startDestination="@id/onboardingFirstFragment">

    <fragment...
        android:id="@+id/onboardingFirstFragment"
        android:name="org.sopt.soptseminar.presentation.onboarding.OnboardingFirstFragment">
        <action
            android:id="@+id/action_onboarding_first_fragment_to_second_fragment"
            app:destination="@id/onboardingSecondFragment" />
    </fragment>
    <fragment...
        android:id="@+id/onboardingSecondFragment"
        android:name="org.sopt.soptseminar.presentation.onboarding.OnboardingSecondFragment">
        <action
            android:id="@+id/action_onboarding_second_fragment_to_third_fragment"
            app:destination="@id/onboardingThirdFragment" />
    </fragment>
    <fragment...
        android:id="@+id/onboardingThirdFragment"
        android:name="org.sopt.soptseminar.presentation.onboarding.OnboardingThirdFragment" />
</navigation>

2. First, Second 온보딩 Fragment에서 next 버튼을 누를 경우 다음화면으로 전환

    binding.next.setOnClickListener {
        findNavController().navigate(R.id.action_onboarding_first_fragment_to_second_fragment)
    }

3. 온보딩 마지막 화면에서 온보딩 Activity종료

    binding.start.setOnClickListener {
        startActivity(Intent(context, SignInActivity::class.java))
            requireActivity().finish()
    }

EncryptedSharedPreferences를 사용한 자동 로그인 및 로그아웃 구현

1. UserSharedPreferencesManager.kt 추가

@Singleton
class UserSharedPreferencesManager @Inject constructor(@ApplicationContext context: Context) {
    private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
    private val prefs = EncryptedSharedPreferences.create(
        "org.sopt.soptseminar.USER_PREFERENCES",
        masterKeyAlias,
        context,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun setUserInfo(user: UserInfo) {
        prefs.edit().run {
            putString(PREF_USER_EMAIL, user.email)
            putString(PREF_USER_NAME, user.name)
            putInt(PREF_USER_AGE, user.age)
            putString(PREF_USER_MBTI, user.mbti)
            putString(PREF_USER_PROFILE, user.profile)
            putString(PREF_USER_PART, user.part.name)
            putString(PREF_USER_UNIV, user.university)
            putString(PREF_USER_MAJOR, user.major)
        }.apply()
    }

    /** 자동로그인 여부 판별을 위한 유저정보 가져오기 */
    fun getUserInfo(): UserInfo? {
        val name = prefs.getString(PREF_USER_NAME, null)
        val email = prefs.getString(PREF_USER_EMAIL, null)

        // 유저 이름이 존재하지 않는 경우, 미가입자로 판단
        if (name == null || email == null) return null
        return UserInfo(
            name,
            prefs.getInt(PREF_USER_AGE, 0),
            prefs.getString(PREF_USER_MBTI, null) ?: "",
            prefs.getString(PREF_USER_PROFILE, null),
            safeValueOf<SoptPartType>(prefs.getString(PREF_USER_PART, null)) ?: SoptPartType.AOS,
            prefs.getString(PREF_USER_UNIV, null) ?: "",
            prefs.getString(PREF_USER_MAJOR, null) ?: "",
            email
        )
    }

    /** 로그아웃 시 유저 정보 삭제 */
    fun clearUserInfo() {
        prefs.edit().clear().apply()
    }

    companion object {
        private const val PREF_USER_EMAIL = "userEmail"
        private const val PREF_USER_NAME = "userName"
        private const val PREF_USER_AGE = "userAge"
        private const val PREF_USER_MBTI = "userMbti"
        private const val PREF_USER_PROFILE = "userProfile"
        private const val PREF_USER_PART = "userPart"
        private const val PREF_USER_UNIV = "userUniv"
        private const val PREF_USER_MAJOR = "userMajor"
    }
}

2. 로그인 성공 시 자동로그인을 위한 유저정보를 EncryptedSharedPreferences에 저장

userSharedPreferencesManager.setUserInfo(
    UserInfo(
        name = data.name,
        age = 24,
        mbti = "ISFP",
        university = "성신여대",
        major = "컴퓨터공학과",
        email = data.email
    )
)

4. 로그아웃 시 EncryptedSharedPreferences에서 유저정보 삭제

userSharedPreferencesManager.clearUserInfo()

5. SplashViewModel.kt에서 유저정보 존재 여부에 따라 자동로그인 처리

isSignedUser.value = userSharedPreferencesManager.getUserInfo() != null

Room을 사용한 자동 로그인 및 로그아웃 구현

1. Entity 추가

@Entity(tableName = "user_table")
data class LoginUserInfo(
    var name: String,
    val age: Int,
    val mbti: String,
    val profile: String? = null,
    val part: SoptPartType = SoptPartType.AOS,
    val university: String,
    val major: String,
    val email: String,
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
) { ... }

2. Dao 추가

@Dao
interface UserDao {
    @Insert
    suspend fun saveUserInfo(user: LoginUserInfo)

    @Query("DELETE FROM user_table")
    suspend fun deleteUserInfo()

    @Query("SELECT * FROM user_table")
    fun getUserInfo(): LoginUserInfo?
}

3. Database 추가

@Database(entities = [LoginUserInfo::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

4. 로그인 성공 시 자동로그인을 위한 유저정보를 Room DB에 저장

userDao.saveUserInfo(
    LoginUserInfo(
        name = data.name,
        age = 24,
        mbti = "ISFP",
        university = "성신여대",
        major = "컴퓨터공학과",
        email = data.email
    )
)

5. 로그아웃 시 Room DB에서 유저정보 삭제

viewModelScope.launch(Dispatchers.IO) {
    userDao.deleteUserInfo()
}

6. SplashViewModel.kt에서 유저정보 존재 여부에 따라 자동로그인 처리

withContext(Dispatchers.IO) {
    isSignedUser.postValue(userDao.getUserInfo()?.also {
        it.toUserInfo(it)
    } != null)
}

새롭게 알게된 내용

EncryptedSharedPreferences

SharedPreferences를 사용하면 단순 평문으로 내용이 저장되기 때문에 큰 문제가 될 수 있다. 누군가 사용자의 폰을 이용해서 아이디 같은 개인정보를 쉽게 유출하여 악용할 수 있기 때문이다.   Android SDK 23 (마시멜로 6.0) 부터 androidx.security 라이브러리를 사용하면 암호화 + SharedPreferences 가 가능하다.

EncryptedSharedPreferences은 기존 SharedPreferences에 약간의 코드 수정만으로도 암호화를 적용할 수 있다. 

SharedPreferences의 Key/Value 모두에 대한 암호화 방법을 지정해야 한다. 현재는 아래와 같이 Key는 AES256_SIV, value는 AES256_GCM을 제공하고 있다. 각각을 모두 초기화해주면 다음과 같다.

private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

private val sharedPreferences = EncryptedSharedPreferences.create(
      "secret_shared_prefs",
      masterKeyAlias,
      context,
      EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
      EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
  )

EncryptedSharedPreferences 읽기/쓰기

기존 SharedPreferences처럼 다음과 같이 작성하면 된다.

// read
sharedPreferences.getString("key", "")

// write
sharedPreferences
    .edit()
    .putString("key", "value")
    .apply()

모든 Activity를 종료하는 방법

환경설정 Activity에서 로그아웃 버튼 클릭 시 환경설정 뿐만 아니라 MainActivity까지 전부 종료해야한다. 다음과 같이 코드를 작성하면 모든 Activity를 종료할 수 있다!

ActivityCompat.finishAffinity(this)

참고

EncryptedSharedPreferences 공식문서

EncryptedSharedPreferences 사용해보기

모든 액티비티 종료하기


week4

Week4


  • LEVEL 1
  • LEVEL 2
  • LEVEL 3
회원가입 및 로그인 서버통신 Github API 연동 기존 가입자 예외처리

Coroutine을 사용한 비동기 처리

*화면 녹화 이후 회원가입 성공 시 토스트 띄우기를 구현했기 때문에 영상에서는 회원가입 성공 시 토스트 뜨는 걸 볼 수 없습니당.. ^____ㅜ

로그인 및 회원가입 API 연동

SoptService.kt

interface SoptService {
    @POST("auth/signin")
    suspend fun postSignIn(@Body body: RequestSignIn): Response<BaseResponse<ResponseSignIn>>

    @POST("auth/signup")
    suspend fun postSignUp(@Body body: RequestSignUp): Response<BaseResponse<ResponseSignUp>>
}

RetrofitBinder.kt

@Singleton
@Provides
fun bindSoptService(): SoptService {
    return Retrofit.Builder().baseUrl(SIGN_BASE_URL).addConverterFactory(
        GsonConverterFactory.create()
    ).build().create(SoptService::class.java)
}

DefaultUserAuthRepository.kt

override suspend fun signIn(email: String, password: String): Pair<Boolean, String?> {
    runCatching {
        soptService.postSignIn(RequestSignIn(email, password))
    }.fold({
        // 로그인 성공 시 Local에 userInfo 저장
        val data = it.body()?.data ?: return Pair(false, null)
        userPreferenceRepo.setUserPreference(...)
            return Pair(true, data.name)
    }, {
        it.printStackTrace()
        return Pair(false, null)
    })
}

override suspend fun signUp(
    name: String,
    email: String,
    password: String,
): Pair<Boolean, Int?> {
    runCatching {
        soptService.postSignUp(RequestSignUp(name, email, password))
    }.fold({
        return Pair(true, it.code())
    }, {
        it.printStackTrace()
        return Pair(true, null)
    })
}

SignViewModel.kt

fun signIn() {
    val isValid = !(userId.value.isNullOrEmpty() || userPassword.value.isNullOrEmpty())
    if (!isValid) return

    // Coroutine 사용한 비동기 처리
    viewModelScope.launch(Dispatchers.IO) {
        val response = userAuthRepo.signIn(
            userId.value!!,
            userPassword.value!!
        )

        // 로그인 api 요청 시 받아온 "name" value를 저장(로그인 성공 시 name이 포함된 Toast를 띄우기 위함)
        userName.postValue(response.second)

        // 로그인 api 요청 성공 여부를 저장(성공 시 메인화면으로 전환하기 위함)
        isSuccessSign.postValue(response.first)
    }
}

fun signUp() {
    val isValid =
        !(userId.value.isNullOrEmpty() || userName.value.isNullOrEmpty() || userPassword.value.isNullOrEmpty())
    if (!isValid) return

    viewModelScope.launch(Dispatchers.IO) {
        val response = userAuthRepo.signUp(
            userName.value!!,
            userId.value!!,
            userPassword.value!!
        )

        isSuccessSign.postValue(response.first && response.second == 201)
        isExistUser.postValue(response.second == 409)
    }
}

SignUpActivity.kt

private fun addObservers() {
    // 회원가입 성공 시 Toast 띄우기 및 로그인화면으로 이동
    viewModel.getSuccessSign().observe(this) { isSuccess ->
        if (isSuccess == true) {
            showToast(getString(R.string.sign_up_success_toast_text))
            moveToSignIn()
        }
    }

    // 기존 가입자가 가입 시도한 경우 Toast 띄우기
    viewModel.getExistUser().observe(this) { isExist ->
        if (isExist == true)
            showToast(getString(R.string.sign_up_exist_user_toast_text))
    }
}

Github API 연동

GithubService.kt

interface GithubService {
    @GET("users/{user_name}/followers")
    suspend fun getFollowerList(@Path("user_name") userName: String): Response<List<ResponseFollower>>

    @GET("users/{user_name}/following")
    suspend fun getFollowingList(@Path("user_name") userName: String): Response<List<ResponseFollower>>

    @GET("users/{user_name}/repos")
    suspend fun getRepositoryList(@Path("user_name") userName: String): Response<List<ResponseRepository>>
}

RetrofitBinder.kt

@Singleton
@Provides
fun bindGithubService(): GithubService {
    return Retrofit.Builder().baseUrl(GITHUB_BASE_URL).addConverterFactory(
        GsonConverterFactory.create()
    ).build().create(GithubService::class.java)
}

GithubProfileRemoteDataSource.kt

suspend fun fetchFollowers(userName: String): List<FollowerInfo>? {
    runCatching {
        githubService.getFollowerList(userName)
    }.fold({
        return it.body()?.map { follower ->
            follower.toFollowerInfo(follower)
        }
    }, {
        it.printStackTrace()
        return null
    })
}

suspend fun fetchFollowing(userName: String): List<FollowerInfo>? {
    runCatching {
        githubService.getFollowingList(userName)
    }.fold({
        return it.body()?.map { following ->
            following.toFollowerInfo(following)
        }
    }, {
        it.printStackTrace()
        return null
    })
}

suspend fun fetchRepositories(userName: String): List<RepositoryInfo>? {
    runCatching {
        githubService.getRepositoryList(userName)
    }.fold({
        return it.body()?.map { repository ->
            repository.toRepositoryInfo(repository)
        }
    }, {
        it.printStackTrace()
        return null
    })
}

GithubViewModel.kt

private fun fetchGithubList() {
    viewModelScope.launch(Dispatchers.IO) {
        // TODO UserInfo에 github 전용 username 추가 후, userInfo.githubUserName 으로 접근
        followers.postValue(githubProfileRepo.fetchGithubFollowers("youngjinc"))
        following.postValue(githubProfileRepo.fetchGithubFollowing("youngjinc"))
        repositories.postValue(githubProfileRepo.fetchGithubRepositories("youngjinc")
                ?.toMutableList())
    }
}

FollowerFragment.kt

  private fun addObservers() {
        viewModel.getFollower().observe(viewLifecycleOwner) {
            if (followerViewType == GithubDetailViewType.FOLLOWER.name) {
                followerListAdapter.submitList(it?.toMutableList())
            }
        }

        viewModel.getFollowing().observe(viewLifecycleOwner) {
            if (followerViewType == GithubDetailViewType.FOLLOWING.name) {
                followerListAdapter.submitList(it?.toMutableList())
            }
        }
    }

Wrapper Class를 이용하여 BaseResponse data class 분리

GithubBaseResponse.kt

data class GithubBaseResponse<T>(
    val status: Int,
    val message: String,
    val data: T,
)

RequestSignUp.kt

data class RequestSignUp(
    val name: String,
    val email: String,
    val password: String
)

SoptService.kt

suspend fun postSignUp(@Body body: RequestSignUp): Response<GithubBaseResponse<ResponseSignUp>>

새롭게 알게된 내용

runCatching을 이용한 kotlin에서 exception처리 방법

kotlin 에서 제공하는 runCatching은 아래와 같다.

public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

try-catch를 runCatching으로 바꾸어 보면 아래와 같이 표현할 수 있다.

val fruitResult = runCatching {
    getRandomString()
}
val fruitName = fruitResult.getOrNull()
if (fruitResult.isSuccess) { }
if (fruitResult.isFailure) { }
val fruitName = fruitResult.getOrNull()
val throwable = fruitResult.exceptionOrNull()

Result에 대해 아래와 같은 extension 을 제공하고 있고, onSuccess, onFailure, fold로 성공과 실패(exception이 발생한 경우)를 따로 처리 가능하다.

Result<T>.getOrThrow(): T
Result<T>.getOrElse(onFailure: (exception: Throwable) -> R): R
Result<T>.getOrDefault(defaultValue: R): R
Result<T>.onSuccess(action: (value: T) -> Unit): Result<T>
Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T>
Result<T>.fold(
    onSuccess: (value: T) -> R,
    onFailure: (exception: Throwable) -> R
): R

Result<T>.map(transform: (value: T) -> R): Result<R>
Result<T>.mapCatching(transform: (value: T) -> R): Result<R>
Result<T>.recover(transform: (exception: Throwable) -> R): Result<R>
Result<T>.recoverCatching(transform: (exception: Throwable) -> R): Result<R>

참고

runCatching을 이용한 kotlin에서 exception처리 방법

week3

Week3


  • LEVEL 3
EditText 디자인 적용 Bottom Navigation 적용 ViewPager2, TabLayout 적용 Gallery 접근

로그인 화면에서 포커스 여부에 따른 EditText 디자인 적용

selector_edittext_background.xml

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_focused="true">
        <shape android:shape="rectangle">
            <stroke android:width="1dp" android:color="@color/gray_150" />
            <corners android:radius="@dimen/radiusEditText" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/gray_100" />
            <corners android:radius="@dimen/radiusEditText" />
        </shape>
    </item>
</selector>

Bottom Navigation 적용

bottom_nav_menu.xml

<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/profile_nav_graph"
        android:icon="@drawable/ic_profile"
        android:title="@string/tab_profile" />
    <item
        android:id="@+id/github_nav_graph"
        android:icon="@drawable/ic_github"
        android:title="@string/tab_github" />
    <item
        android:id="@+id/gallery_fragment"
        android:icon="@drawable/ic_camera"
        android:title="@string/tab_camera" />
</menu>

activity_main.xml

<com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/nav_main"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white"
            android:theme="@style/TextAppearance.BottomNavigation.Tab.Text"
            app:itemIconTint="@color/selector_bottom_navi"
            app:itemRippleColor="@color/gray_300"
            app:itemTextColor="@color/selector_bottom_navi"
            app:layout_constraintBottom_toBottomOf="parent"
            app:menu="@menu/bottom_nav" />

MainActivity.kt Navigation 을 사용해서 프래그먼트 전환 처리

private fun initLayout() {
        val navHostFragment =
            supportFragmentManager.findFragmentById(R.id.nav_host_fragment_container) as NavHostFragment
        binding.navMain.setupWithNavController(navHostFragment.navController)
    }

ViewPager2 및 TabLayout적용

  1. tabIndicator 를 제거하기 위해 app:tabIndicator="@null" 추가
  2. tabItem 에 버튼 디자인을 적용하기 위해 selector_tab_item_background.xml추가

selector_tab_item_background.xml

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true">
        <shape android:shape="rectangle">
            <solid android:color="@color/gray_700" />
            <corners android:radius="@dimen/radiusTabItem" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@android:color/transparent" />
        </shape>
    </item>
</selector>

fragment_github.xml

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tab"
            android:layout_width="match_parent"
            android:layout_height="@dimen/HeightTabItem"
            android:layout_marginHorizontal="@dimen/spacingBase"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/toolbar"
            app:tabBackground="@drawable/selector_tab_item_background"
            app:tabGravity="center"
            app:tabIndicator="@null"
            app:tabMaxWidth="@dimen/WidthTabItem"
            app:tabSelectedTextColor="@color/white"
            app:tabTextAppearance="@style/Home.TabItem.TextAppearance.Style"
            app:tabTextColor="@color/gray_700">

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/github_detail_follower" />

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/github_detail_repository" />

            <com.google.android.material.tabs.TabItem
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/github_detail_following" />
        </com.google.android.material.tabs.TabLayout>

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/github_detail"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tab" />

GithubFragment.kt

private fun initLayout() {
        binding.githubDetail.run {
            adapter = GithubAdapter(requireActivity())
            setCurrentItem(GithubDetailViewType.FOLLOWER.ordinal, false)
        }

        TabLayoutMediator(binding.tab, binding.githubDetail) { tab, position ->
            tab.text = getString(tabTitles[position])
        }.attach()
    }

갤러리에서 받아온 이미지 Uri를 Glide로 띄우기

ActivityResult API 사용하여 읽기 권한 요청, 사용자가 선택한 이미지 uri를 Glide로 띄움

private fun addListeners() {
        binding.imageContainer.setOnClickListener {
            storagePermission.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
        }
    }

    private val storagePermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
            if (isGranted) {
                galleryLauncher.launch("image/*")
            }
        }

    private val galleryLauncher =
        registerForActivityResult(ActivityResultContracts.GetContent()) {
            Glide.with(binding.galleryImage).load(it).into(binding.galleryImage)
        }

새롭게 알게된 내용

ViewPager2 중첩 스크롤 이슈

Navigation 을 사용해서 프래그먼트 전환을 구현했기 때문에 BottomNavigationViewPager2 연동할 수 없었지만, ViewPager2 중첩 스크롤 이슈 해결 방법을 찾아보았다.

Support nested scrollable elements

To support a scroll view inside a ViewPager2 object with the same orientation, you must call requestDisallowInterceptTouchEvent() on the ViewPager2 object when you expect to scroll the nested element instead. The ViewPager2 nested scrolling sample demonstrates one way of solving this problem with a versatile custom wrapper layout.

방법1

xml 파일에서 중복되는 ViewPager2NestedScrollableHost 로 감싸주면 해결 완!

<com.ssacproject.thirdweek.NestedScrollableHost
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent">
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewpager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</com.ssacproject.thirdweek.NestedScrollableHost>

방법2

android:nestedScrollingEnabled 속성을 true 로 설정

<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/viewpager"
    android:layout_width="match_parent"
    android:nestedScrollingEnabled="true"
    android:layout_height="wrap_content" />

DataStore

DataStoreSharedPreferences 를 대체하기 위해 Jetpack에서 발표한 라이브러리다. Kotlin coroutineFlow 를 사용하여 비동기적으로, 일관되게 데이터를 저장할 수 있다.

기존에 로그인 성공 시 다음화면으로 UserInfo (커스텀 객체)를 intent로 전달했었는데, 전달하지 않고 로컬에 저장해두어 필요 시 유저 정보를 불러오고 싶어 Datastore를 사용했다.

Preferences DataStore and Proto DataStore

Preferences DataStore Proto DataStore
1. key-value Pair 의 형태로 데이터를 저장
2. Type-Safety 제공하지 않음
1. 커스텀 데이터 타입의 데이터를 저장하는데 사용
2. 미리 정의된 schema 를 통하여 Type-Safety 를 보장
3. app/src/main/proto/ 디렉토리 안의 proto file 의 스키마를 미리 정의해야함 (이 과정이 귀찮아서 1번 사용함 ^____^)

참고

Nested ViewPager2 이슈를 해결해보자 with NestedScorllableHost

Preferences Datastore에서 데이터 유지

DataStore

SharedPreferences 대신 쓰는 DataStore

week2

Week2


  • LEVEL 2
  • LEVEL 3
Fragment 전환 아이템 이동 및 삭제

Fragment 전환 및 RecyclerView 설정

  1. ViewPager2TabLayout을 연결하여 Fragment 전환을 구현. 탭의 position이 변경될떄마다 fragment와 title이 변경됨

GithubProfileActivity.kt

private fun initLayout() {
    binding.githubDetail.run {
        adapter = GithubDetailAdapter(this@GithubProfileActivity)
        setCurrentItem(position, false)
    }

    TabLayoutMediator(binding.tab, binding.githubDetail) { tab, position ->
        tab.text = getString(tabTitles[position])
        }.attach()
}
inner class GithubDetailAdapter(fm: FragmentActivity) : FragmentStateAdapter(fm) {
    override fun getItemCount(): Int = GithubDetailViewType.values().size

    override fun createFragment(position: Int): Fragment {
        return when (position) {
            0 -> {
                FollowerFragment.newInstance(GithubDetailViewType.FOLLOWER)
            }
            1 -> {
                RepositoryFragment()
            }
            2 -> {
                FollowerFragment.newInstance(GithubDetailViewType.FOLLOWING)
            }
            else -> FollowerFragment.newInstance(GithubDetailViewType.FOLLOWER)
        }
    }
}
  1. 가장 처음 보일 Framgnet 설정. 아래 사진에서 Followers, Repositories, Following 뷰를 클릭하면 각 뷰에 따라 position을 할당해서 해당 position으로 처음 보일 Fragment 설정

activity_home.xml

android:onClick="moveToGithubProfile"

HomeActivity.kt

fun moveToGithubProfile(view: View) {
    val position = when (view) {
        binding.followingContainer -> GithubDetailViewType.FOLLOWING.ordinal
        binding.repositoryContainer -> GithubDetailViewType.REPOSITORIES.ordinal
        else -> GithubDetailViewType.FOLLOWER.ordinal
    }
    // 깃허브 프로필 화면으로 이동하는 코드 생략
}

GithubProfileActivity.kt > initLayout() > setCurrentItem(position, false)

  1. 팔로워, 팔로잉 목록은 GridLayout 적용

framgnet_follower.xml

 <androidx.recyclerview.widget.RecyclerView
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:spanCount="2"
        tools:listitem="@layout/item_follower" />
  1. 팔로워 item 클릭 시 FollowerDetailActivity로 이동
override fun onItemClick(item: FollowerInfo) {
    val intent = Intent(requireContext(), FollowerDetailActivity::class.java)
    intent.putExtra(ARG_FOLLOWER_INFO, item)
    startActivity(intent)
}
  1. ItemDecorationBindingAdapter를 이용해서 RecyclerView에 구분선 추가
@BindingAdapter(value = ["dividerHeight", "dividerPadding", "dividerColor"], requireAll = false)
fun RecyclerView.setDivider(
    dividerHeight: Float?,
    dividerPadding: Float?,
    @ColorInt dividerColor: Int?
) {
    val decoration = CustomDecoration(
        height = dividerHeight ?: 0f,
        padding = dividerPadding ?: 0f,
        color = dividerColor ?: Color.TRANSPARENT
    )
    addItemDecoration(decoration)
}
class CustomDecoration(
    private val height: Float,
    private val padding: Float,
    @ColorInt private val color: Int
) : RecyclerView.ItemDecoration() {
    private val paint = Paint()

    init {
        paint.color = color
    }

    override fun onDrawOver(
        c: Canvas,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val left = parent.paddingStart + padding
        val right = parent.width - parent.paddingEnd - padding

        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val params = child.layoutParams as RecyclerView.LayoutParams

            val top = (child.bottom + params.bottomMargin).toFloat()
            val bottom = top + height

            c.drawRect(left, top, right, bottom, paint)
        }
    }
}
<androidx.recyclerview.widget.RecyclerView
        app:dividerColor="@{@color/gray_200}"
        app:dividerHeight="@{2f}"
        app:dividerPadding="@{0f}" />

RecyclerView Item 이동 및 삭제 구현

  1. ItemTouchHelper를 사용하면 Drag&Drop, Swipe 기능을 쉽게 구현할 수 있다. move 와 swipe events 에 대한 callback을 받는다.
class ItemTouchHelperCallback(private val listener: ItemTouchHelperListener) :
    ItemTouchHelper.Callback() {
    override fun getMovementFlags(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ): Int {
        val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
        val swipeFlags = ItemTouchHelper.START // 오른쪽에서 왼쪽으로만 스와이프 가능
        return makeMovementFlags(dragFlags, swipeFlags)
    }

    override fun isLongPressDragEnabled(): Boolean {
        return true
    }

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        return listener.onItemMove(viewHolder.adapterPosition, target.adapterPosition)
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        listener.onItemSwipe(viewHolder.adapterPosition)
    }
}
class RepositoryListAdapter() : RecyclerView.Adapter<RecyclerView.ViewHolder>(),
    ItemTouchHelperListener {

    private lateinit var touchListener: OnItemTouchListener


    interface OnItemTouchListener {
        fun onItemMove(fromPosition: Int, toPosition: Int)
        fun onItemSwipe(position: Int)
    }

    override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
        touchListener.onItemMove(fromPosition, toPosition)
        return true
    }

    override fun onItemSwipe(position: Int) {
        touchListener.onItemSwipe(position)
    }
}

RepositoryFragment.kt

override fun onItemMove(fromPosition: Int, toPosition: Int) {
    viewModel.moveRepository(fromPosition, toPosition)
}

override fun onItemSwipe(position: Int) {
    viewModel.removeRepository(position)
}

ProfileViewModel.kt

fun moveRepository(fromPosition: Int, toPosition: Int) {
    repositories.value = repositories.value?.apply {
        val origin = this[fromPosition]
        removeAt(fromPosition)
        add(toPosition, origin)
    }
}

fun removeRepository(position: Int) {
    repositories.value = repositories.value?.apply {
        removeAt(position)
    }
}

RepositoryFragment.kt

private fun addListeners() {
    viewModel.getRepositories().observe(viewLifecycleOwner) {
        if (it == null) return@observe
        adapter.submitList(it.toMutableList())
    }
}

notifyDataSetChanged() 문제

But this comes with a big downside – if it's a trivial change (maybe a single item added to the top), the RecyclerView isn't aware – it is told to drop all its cached item state, and thus needs to rebind everything. It's much preferable to use DiffUtil, which will calculate and dispatch minimal updates for you.

공식문서를 확인하면 notifyDataSetChanged()는 위와 같은 문제가 있다. 정리해보면 다음과 같다.

문제 : Adapter는 변경된 data만 업데이트하는 것이 전제 item을 rebind하게됨. -> blicking issue 발생

해결 : DiffUtil을 사용하자!

DiffUtil

DiffUtil을 사용하면 두 데이터 셋을 비교한 후 그 중 변경된 데이터 셋만 반환하여 RecyclerView Adapter에 업데이트를 알림

FollowerListAdapter.kt

 private val diffCallback = object : DiffUtil.ItemCallback<FollowerInfo>() {
        override fun areItemsTheSame(oldItem: FollowerInfo, newItem: FollowerInfo): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: FollowerInfo, newItem: FollowerInfo): Boolean {
            return oldItem == newItem
        }
    }

    private val differ = AsyncListDiffer(this, diffCallback)


    fun submitList(items: List<FollowerInfo>?) {
        differ.submitList(items)
    }

BaseActivity, BaseFragment 적용

  1. Base class 추가
BaseActivity.kt BaseFragment.kt
  1. Activity와 Fragment에서 BaseActivityBaseFragment를 상속 받음
BaseActivity<ActivityFollowerDetailBinding>(R.layout.activity_follower_detail)

BaseFragment<FragmentFollowerBinding>(R.layout.fragment_follower)

새롭게 알게된 내용

submitList 호출 시 주의 사항

public void submitList(@Nullable final List<T> newList,
            @Nullable final Runnable commitCallback) {
        // incrementing generation means any currently-running diffs are discarded when they finish
        final int runGeneration = ++mMaxScheduledGeneration;

        if (newList == mList) {
            // nothing to do
            return;
        }
}

submitList의 구현부를 확인하면 newList==mList인 경우 return 된다. list의 주소가 같지 않은 경우에만 실제 아이템 비교로 넘어갈 수 있다. 따라서 매번 Room이나 API를 통해 데이터를 가져오는 작업이 아닌 앱 내에서 참조가 변경되지 않는 리스트를 유지하면서 값이 변경될 때도 업데이트를 반영하기 위해서는 submitList 함수 호출 시 주소가 다른 리스트를 넘겨주어야 한다.

adapter.submitList(it.toMutableList())

참고

Slow rendering

ListAdapter의 작동 원리 및 갱신이 안되는 경우

RecyclerView에 divider 넣기 - ItemDecoration

ItemTouchHelper.SimpleCallback

Android Swipe To Delete in RecyclerView With DiffUtil

Base 코드 관련 정리 (feat. BaseActivity, BaseFragment)

week1

Week1


  • LEVEL 2
  • LEVEL 3
SignInActivity SignUpActivity HomeActivity

로그인

  • 입력값 검증 : 공백 입력 방지를 위해 입력값 변경 시 마다 trim() 을 적용하고, 로그인 버튼 클릭 시 isNullOrEmpty() 로 입력값을 검증

    1. 입력 완료 : Intent를 통해 HomeActivity로 전환, 전환 시 입력한 사용자 정보(userInfo)를 putExtra를 통해 메인으로 전달

    2. 입력 미완료 : "아이디 또는 비밀번호를 확인해 주세요" Toast 띄우기

  • 비밀번호 가리기 : android:inputType="textPassword" 속성 활용

  • EditText 미리보기 : android:hint="@string/id_hint" 속성 활용

  • 회원가입 클릭 : Intent를 통해 SignUpActivity로 전환


로그인 프로세스

activity_sign_in.xml

  1. DataBinding을 사용하여 입력값이 변경될 때마다 viewModel::onUserIdTextChanged 호출 ▶️ MutableLiveData타입 변수 viewModel.userId 에 입력값 저장 (activity에서 로그인 버튼 클릭 시 binding.idInput.text로 접근할 수 있지만 viewModel을 활용하기 위함)
<EditText...
    android:id="@+id/id_input"
    android:hint="@string/id_hint"
    android:onTextChanged="@{viewModel::onUserIdTextChanged}"
    android:text="@{viewModel.userId}" />
// SignViewModel.kt
fun onUserIdTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
    userId.value = s.toString().trim() // 공백 입력 방지
}

fun onUserPasswordTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
    userPassword.value = s.toString().trim()
}
  1. 로그인 버튼 클릭 시 SignViewModel.ktsignIn() 호출
 <Button...
     android:id="@+id/sign_in"
     android:onClick="@{() -> viewModel.signIn()}" />

SignViewModel.kt

  1. isNullOrEmpty() 이용해서 입력값 검증 val isValid = !(userId.value.isNullOrEmpty() || userPassword.value.isNullOrEmpty())

  2. 입력값이 유효한 경우, 사용자 입력 정보(signInfo, userInfo)를 저장 (자기소개화면으로 데이터를 전달하기 위함)

  3. MutableLiveData 타입의 isValidSignInput 변수에 검증 결과를 저장 (검증 결과에 따라 화면 전환 및 Toast를 띄우기 위함)

private val isValidSignInput = MutableLiveData<Boolean>()

fun signIn() {
    val isValid = !(userId.value.isNullOrEmpty() || userPassword.value.isNullOrEmpty())
     if (isValid) {
        signInfo = SignInfo(id = userId.value!!, password = userPassword.value!!)
        userInfo.name =  userName.value ?: "최영진"
        
        // TODO Implement the signin process
    }

    isValidSignInput.value = isValid
}
  • signInfo : 로그인 정보(id, password)를 담는 data class
@Parcelize // Intent로 '객체'를 전달하기 위함
data class SignInfo(
    val id: String,
    val password: String,
) : Parcelable
  • userInfo : 프로필 정보(name, age, mbti 등 프로필 정보)를 담는 data class
@Parcelize
data class UserInfo(
    var name: String,
    val age: Int,
    val mbti: String,
    val part: SoptPart = SoptPart.AOS,
    ...
): Parcelable

SignInActivity.kt

  1. SignViewModelisValidSignInput값을 관찰하고, 변경된 값에 따라 ui 처리
  2. 검증 성공 여부에 따라 Toast 메세지를 다르게 띄움
  3. 검증 성공 시, HomeActivity로 이동하기 위해 moveToHome() 호출
private fun addObservers() {
    viewModel.getValidSignInput().observe(this) { isValid ->
        if (isValid) {
            val name = viewModel.getUserInfo()?.name
            showToast(String.format(getString(R.string.sign_in_success_toast_text), name))
            moveToHome()
        } else {
            // Toast 띄우기 코드 생략
        }
    }
}
  1. Intent를 활용해 HomeActivity로 이동 (이때 저장해둔 프로필 정보(userInfo)를 putExtra로 전달)
    private fun moveToHome() {
    val intent = Intent(this, HomeActivity::class.java)
    intent.putExtra(ARG_USER_INFO, viewModel.getUserInfo())
    startActivity(intent)
    finish()
}

회원가입

  • 입력값 검증 : 로그인과 동일하게 진행

    1. 입력 완료 : finish()로 Activity를 종료하고 SignInActivity로 복귀 ▶️ 복귀 시 registerForActivityResult를 사용해서 회원가입에서 입력한 아이디 및 비밀번호 보여줌 [성장과제 2-1]

    2. 입력 미완료 : Toast 띄우기


회원가입 프로세스

SignInActivity.kt

  1. 로그인 화면 복귀 시 회원가입에서 입력한 아이디 및 비밀번호 보여주기 위한 사전 준비로 registerForActivityResult를 사용하여 Callback 등록

  2. 회원가입화면에서 인자로 받아온 result 객체로 사용자 입력 정보(data)를 전달 받아서 SignViewModel ▶️ MutableLiaveData 타입의 userId.value, userPassword.value 변수에 저장하도록 구현

private fun setSignUpResult() {
    resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult

        val data = result.data ?: return@registerForActivityResult
        data.getParcelableExtra<UserInfo>(ARG_USER_INFO)?.let { user ->
          viewModel.setUserInfo(user)
        }
        data.getParcelableExtra<SignInfo>(ARG_SIGN_INFO)?.let { sign ->
          viewModel.setSignInfo(sign)
        }
    }
}
  1. DataBinding을 사용하여 xml에서 id, password값을 관찰 ▶️ 값 변경 시 id, password text 속성값이 갱신될 것임
// SignViewModel.kt
fun setSignInfo(signInfo: SignInfo) {
    userId.value = signInfo.id
    userPassword.value = signInfo.password
}
<EditText...
    android:id="@+id/id_input"   
    android:text="@{viewModel.userId}" />
  1. 회원가입에서 입력한 데이터를 받아오기 위해 **resultLauncher.launch()**로 SignUpActivity를 실행
private fun addListeners() {
    binding.signUp.setOnClickListener {
        resultLauncher.launch(Intent(this, SignUpActivity::class.java))
    }
}

activity_sign_up.xml

  1. 입력을 마친 후 회원가입 버튼 클릭 시 SignViewModel ▶️ signUp() 함수 호출
<Button...
    android:id="@+id/sign_up"
    android:onClick="@{() -> viewModel.signUp()}" />

SignViewModel.kt

  1. 입력값 검증 후 입력값이 유효한 경우, 사용자 입력 정보를 저장 (로그인화면 데이터를 전달하기 위함)
fun signUp() {
    val isValid = !(userId.value.isNullOrEmpty() || userName.value.isNullOrEmpty() || userPassword.value.isNullOrEmpty())
    if (isValid) {
        signInfo = SignInfo(id = userId.value!!, password = userPassword.value!!)
        userInfo.name =  userName.value!!
         // TODO Implement the signin process
     }

    isValidSignInput.value = isValid
}

SignUpActivity.kt.kt

  1. 검증에 성공한 경우(isValid == true), SignInActivity로 이동하기 위해 moveToSignIn() 호출

  2. 검증에 실패한 경우, Toast 메세지를 띄움

private fun addObservers() {
    viewModel.getValidSignInput().observe(this) { isValid ->
        if (isValid) {
             moveToSignIn()
         } else {
              // Toast 띄우기 코드 생략
         }
     }
 }
  1. setResult를 사용해서 로그인 화면으로 사용자 입력 정보를 전달
private fun moveToSignIn() {
    val intent = Intent(this, SignInActivity::class.java)
    intent.putExtra(ARG_USER_INFO, viewModel.getUserInfo())
    intent.putExtra(ARG_SIGN_INFO, viewModel.getSignInfo())
    setResult(RESULT_OK, intent)
    finish()
}

자기소개

activity_home.xml

  • 사진 비율 1:1로 만들기 위해 app:layout_constraintDimensionRatio="1:1"로 비율 설정 [성장과제 2-2]
 <ImageView...
    android:id="@+id/profile_img"
    android:layout_width="80dp"
    android:layout_height="0dp"
    android:src="@drawable/profile_img"
    app:layout_constraintDimensionRatio="1:1" />
  • SignInActivity에서 전달받은 userInfo를 ProfileViewModel에 저장
fun setUserInfo(userInfo: UserInfo) {
    this.userInfo.value = userInfo
}
  • viewModel.userInfo 관찰 ▶️ 값 변경 시 프로필 정보 모두 업데이트
  <TextView...
    android:id="@+id/name"
    android:text="@{viewModel.userInfo.name}"/>
  • ScrollView 적용 [성장과제 2-2]
<ScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.constraintlayout.widget.ConstraintLayout.../>
 </ScrollView>

도전 과제

DataBinding과 ViewBinding

Databinding 이란?

UI 요소와 데이터를 프로그램적 방식으로 연결하지 않고, 선언적 형식으로 결합할 수 있게 도와주는 라이브러리를 말함 [도전과제 3-1]

  • 프로그램적 방식 예시 : TextView에 문자열을 넣기 위해 코드상에서 값을 일일이 집어넣는 작업
// findViewById 방식
val textview = findViewById<TextView>(R.id.textview11)
textview.text = "안녕"
// ViewBinding 방식
binding.textview11.text = "안녕"
  • 선언적 방식 예시 : XML에 데이터를 직접 집어넣어서 해결하는 방법
<TextView...
    android:id="@+id/name"
    android:text="@{viewmodel.userName}" />

DataBinding과 ViewBinding 비교

  • ViewBinding ⊂ Databinding

  • 공통점

    • findViewById에 비해 상대적으로 간단, 퍼포먼스 효율이 좋고 용량이 절약됨
    • 뷰의 직접 참조를 생성하므로 유효하지 않은 뷰 id로 인한 NPE로부터 안전
  • DataBinding 장점 : *동적 UI 콘텐츠 선언, 양방향 데이터 binding도 지원

  • ViewBinding 장점 : 빠른 컴파일 속도, DataBinding보다 퍼포먼스 효율이 좋고 용량이 절약


새롭게 알게된 내용

registerForActivityResult


프로그램 구조

스크린샷 2022-04-06 오후 10 24 44

MVVM 아키텍처 적용 [도전과제 3-2]


참고

Activity에서 데이터 전달 받기 공식문서

Activity Result API

registerForActivityResult()란?

Databinding 공식문서

Android Binding

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages