라떼는말이야

[안드로이드 스튜디오] Login Activity 템플릿 살펴보기 본문

안드로이드

[안드로이드 스튜디오] Login Activity 템플릿 살펴보기

MangBaam 2022. 1. 11. 19:07
반응형

New Project 화면

 

목표 설정

프로젝트 생성 시 일반적으로 Empty Activity로 생성해 앱을 만들지만 그 외에도 다양한 템플릿들을 지원해준다.

템플릿들은 구글에서 작성했거나 구글에 통과된 검증된(?) 코트일 테니 템플릿으로 생성해보고 어떻게 구성해놨을지 확인해보는 것도 도움이 될 듯해서 기록에 남기고자 한다.

이번엔 Login Activity를 확인해보려고 한다. 물론 로그인에 필요한 사용자 인증 로직이나 서버와의 통신 로직은 없다. 뷰와 뷰모델, 그리고 UI Controller인 액티비티에서는 어떻게 상호 작용하고, 로그인 상태에 따른 처리를 하는지 확인하는 것이 목표이다.

프로젝트의 구성

프로젝트 구성

프로젝트 생성 시 LoginActivity를 선택하면 기본적으로 위와 같은 구성으로 프로젝트가 생성된다.

 

단일 로그인 화면

사실상 뷰는 로그인 단일 뷰이고, 해상도별 화면 구성과 야간 모드 지원을 위한 파일들을 제외하면 EditText 2개 버튼, 프로그레스바로만 이루어진 간단한 뷰만 확인하면 된다.

 

ViewModelViewModelFactory

LoginViewModel.kt

package mangbaam.shoppi.logintest.ui.login

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import android.util.Patterns
import mangbaam.shoppi.logintest.data.LoginRepository
import mangbaam.shoppi.logintest.data.Result

import mangbaam.shoppi.logintest.R

class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    private val _loginForm = MutableLiveData<LoginFormState>()
    val loginFormState: LiveData<LoginFormState> = _loginForm

    private val _loginResult = MutableLiveData<LoginResult>()
    val loginResult: LiveData<LoginResult> = _loginResult

    fun login(username: String, password: String) {
        // can be launched in a separate asynchronous job
        val result = loginRepository.login(username, password)

        if (result is Result.Success) {
            _loginResult.value =
                LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
        } else {
            _loginResult.value = LoginResult(error = R.string.login_failed)
        }
    }

    fun loginDataChanged(username: String, password: String) {
        if (!isUserNameValid(username)) {
            _loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
        } else if (!isPasswordValid(password)) {
            _loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
        } else {
            _loginForm.value = LoginFormState(isDataValid = true)
        }
    }

    // A placeholder username validation check
    private fun isUserNameValid(username: String): Boolean {
        return if (username.contains('@')) {
            Patterns.EMAIL_ADDRESS.matcher(username).matches()
        } else {
            username.isNotBlank()
        }
    }

    // A placeholder password validation check
    private fun isPasswordValid(password: String): Boolean {
        return password.length > 5
    }
}

우선 로그인 프로젝트에서 가지고 있는 데이터들은 무엇이 있는지 확인하기 위해 ViewModel을 먼저 살펴봤다.

 

private val _loginForm = MutableLiveData<LoginFormState>()
val loginFormState: LiveData<LoginFormState> = _loginForm

private val _loginResult = MutableLiveData<LoginResult>()
val loginResult: LiveData<LoginResult> = _loginResult

우선 가장 상단에 변수 선언한 부분이 보인다.

여기서는 LiveData를 사용했는데, MutableLiveDataLiveData로 구분된다.

Mutable이라는 접두어가 붙은 키워드는 값을 수정할 수 있다고 생각하면 된다.

반대로 Mutable이 붙지 않은 것을 Immutable이라고 칭하는데 이는 수정은 불가능하고 초기화 후 읽기만 가능하다는 뜻이다.

즉, _loginForm은 수정할 수 있고, loginFormState는 수정할 수 없고 오직 읽기만 가능한 변수이다.

_loginForm을 보면 접근 제어자가 private으로 되어있는 것을 볼 수 있다. private으로 선언되어 있다면 현재의 ViewModel 내에서만 접근할 수 있다는 말이고 즉, ViewModel 외부에서는 loginFormState만 접근 가능해 값을 (수정이 안되고) 확인만 가능하다는 뜻이다.

 

LiveData란?

LiveData란 특정 타입을 감싸는 관찰 가능한 데이터 홀더 클래스이다.

예를 들어 val score = MutableLiveData <Int>(20)으로 선언되어 있는 것을 보면 score는 MutableLiveData 타입이고, 그 값으로 정수(Int)를 가질 수 있는 것이다. 현재 score는 20인 셈이다. 그리고 게임 화면의 점수를 score 변수를 참고하여 보여준다고 가정하자.

이때 점수를 더 획득해서 score의 값이 20에서 21로 올라갔다고 했을 때 화면에서도 역시 20을 보여주던 것을 21로 바꿔야 할 것이다.

하지만 score의 값만 바꾼다고 화면에 표시되던 값이 바뀌지 않는다. (LiveData를 사용하지 않는다면)

이 부분에서 누락이 되면 UI와 데이터 상태가 불일치하게 되거나 메모리 누수 등 버그가 발생할 확률이 높아진다.

이런 문제들은 LiveData를 사용하면서 해결할 수 있다. LiveData는 값을 관찰하고 있다가 값에 변경이 생기면 바로 알 수 있다. (어떻게 쓰는지는 밑에서 더 알아봅시다...)

 

score 값이 변할 때 화면도 같이 갱신해주면 안되냐? 라고 생각할 수도 있다.

간단한 프로젝트에서는 그게 가능할지도 모른다. 하지만 프로젝트가 커지고 복잡해지면 누락이 생겨 위에서 언급한 UI와 데이터 상태가 불일치하는 등의 문제가 발생할 가능성이 높다.

무엇보다 특정 데이터가 변할 때마다 이 데이터를 표시하는 뷰들을 모두 업데이트해야 하다 보니 데이터와 뷰의 결합도가 높아지게 된다. 이는 프로젝트를 아주 복잡하게 만들고 유지 보수도 어렵게 된다.

해결책은?

데이터가 변하면 뷰를 업데이트 하는 관계와 반대로

뷰가 데이터를 참조하면 더 이상 데이터가 뷰를 신경 써야 하는 상황에서 벗어날 수 있고, 특정 데이터를 표시해야 하는 뷰가 여러 개더라도 해당 뷰들이 데이터만 참조하면 된다.

 

이야기가 좀 다른 데로 샜지만... 위의 개념이 MVVM와 같은 아키텍처의 기본 컨셉이다.

 


login() 메소드는 좀 뒤에서 살펴보고...

 

LoginViewModel.kt > isUserNameValid()

	// A placeholder username validation check
    private fun isUserNameValid(username: String): Boolean {
        return if (username.contains('@')) {
            Patterns.EMAIL_ADDRESS.matcher(username).matches()
        } else {
            username.isNotBlank()
        }
    }

매개 변수로 입력받은 username이 유효한지 확인하는 메소드이다.

프로젝트에 따라 바뀔 수 있으나 여기서는 '@' 이 포함되어 있으면 이메일 포맷과 일치하는지 확인하고, '@' 이 포함되지 않으면 빈칸인지 확인한다.

LoginViewModel.kt > isPasswordValid()

// A placeholder password validation check
private fun isPasswordValid(password: String): Boolean {
	return password.length > 5
}

매개 변수로 입력받은 password가 유효한지 확인하는 메소드이다.

마찬가지로 프로젝트에 따라 바뀔 수 있으나 여기서는 암호의 길이가 5보다 큰 경우에만 true를 반환한다.

 

 

LoginViewModel.kt > loginDataChanged()

fun loginDataChanged(username: String, password: String) {
	if (!isUserNameValid(username)) {
		_loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
	} else if (!isPasswordValid(password)) {
		_loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
	} else {
		_loginForm.value = LoginFormState(isDataValid = true)
	}
}

매개 변수로 입력받은 username과 password가 유효한지 확인하고 LoginFormState의 형태로 _loginForm에 저장한다.

위에서 설명한 isUserNameValid() 메소드와 isPasswordValid() 메소드를 사용해 유효성을 검사하고 유효하지 않다면 각각 usernameError와 passwordError에 에러 메시지를 저장한다.

만약 username과 password가 모두 유효하다면 LoginFormState의 isDataValid 속성을 true로 설정한다.

 

LoginFormState를 살펴보면 다음과 같다.

 

LoginFormState.kt

data class LoginFormState(
    val usernameError: Int? = null,
    val passwordError: Int? = null,
    val isDataValid: Boolean = false
)

로그인 상태에 따른 상태를 저장하는 데이터 클래스이다.

usernameError와 passwordError에는 res > values > strings.xml 에 있는 값을 직접 설정해주기 때문에 Int?로 선언되었다. (리소스 아이디가 정수형이다)

isDataValid는 로그인 상태가 유효한지에 따라 true / false로 표현된다. 기본 값은 false이다.

 

LoginViewModel.kt > login()

다시 LoginViewModel.kt로 돌아와서... 마지막으로 login() 메소드를 확인해본다.

fun login(username: String, password: String) {
    // can be launched in a separate asynchronous job
    val result = loginRepository.login(username, password)

    if (result is Result.Success) {
        _loginResult.value = LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
    } else {
        _loginResult.value = LoginResult(error = R.string.login_failed)
    }
}

로그인 메소드 자체는 크게 복잡하지 않은 구조로 되어있다. 하지만 동작을 확인하기 위해서 앞서 보고 와야 할 것이 많다. Let's Go!

 

class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {
	...
}

LoginViewModel의 최상단을 보면 loginRepository를 생성자로 받는다.

ViewModel을 생성자와 함께 생성하려면 (다양한 방법이 있지만) ViewModelFactory를 사용하면 편하게 할 수 있다.

LoginViewModelFactory.kt

/**
 * ViewModel provider factory to instantiate LoginViewModel.
 * Required given LoginViewModel has a non-empty constructor
 */
class LoginViewModelFactory : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(LoginViewModel::class.java)) {
            return LoginViewModel(
                loginRepository = LoginRepository(
                    dataSource = LoginDataSource()
                )
            ) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

주석에도 나와 있듯이 비어 있지 않은 생성자를 가진 LoginViewModel을 인스턴스 화하는 역할을 하는 ViewModel Provider이다.

LoginRepository의 생성자인 dataSourceLoginDataSource()를 넣는 것을 볼 수 있다.

그렇다면 LoginDataSource()도 확인해보자.

LoginDataSource.kt

/**
 * Class that handles authentication w/ login credentials and retrieves user information.
 */
class LoginDataSource {

    fun login(username: String, password: String): Result<LoggedInUser> {
        try {
            // TODO: handle loggedInUser authentication
            val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
            return Result.Success(fakeUser)
        } catch (e: Throwable) {
            return Result.Error(IOException("Error logging in", e))
        }
    }

    fun logout() {
        // TODO: revoke authentication
    }
}

로그인 자격 증명을 포함한 인증을 처리하고 사용자 정보를 검색하는 클래스이다.

원래는 입력받은 username과 password로 인증을 처리해야 하지만 여기서는 임의로 "Jane Doe"라는 이름을 가진 fakeUser를 만들어서 보여준다.

LoggedInUser는 uid와 이름을 가지는 간단한 데이터 클래스이다.

/**
 * Data class that captures user information for logged in users retrieved from LoginRepository
 */
data class LoggedInUser(
    val userId: String,
    val displayName: String
)

 

LoginDataSource에서 Result를 반환했으니 Result 클래스도 확인해보자.

Result.kt

/**
 * A generic class that holds a value with its loading status.
 * @param <T>
 */
sealed class Result<out T : Any> {

    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()

    override fun toString(): String {
        return when (this) {
            is Success<*> -> "Success[data=$data]"
            is Error -> "Error[exception=$exception]"
        }
    }
}

Result.kt에는 코드 양이 많지 않지만 아주 생소한 개념들이 많이 등장한다.

(나도 잘 모르겠어서 한참 공부하고 온...)

첫 번째 키워드는 sealed class이다.

sealed class는 enum과 비슷한 개념이지만 enum과 다르게 상속이 가능하고, 그렇기 때문에 멤버의 값을 바꿀 수 있다.

 

두 번째 키워드는 T와 out이다.

이 둘은 제네릭 타입과 관련이 있다. 쉽게 말하면 제네릭 타입을 사용하면 한 번의 구현으로 다양한 타입을 사용할 수 있다. 위 코드를 예로 들면 Result.Success(data)를 호출할 때 data의 타입이 무엇이든 상관없다는 것이다.

제네릭은 Covariance(공변성), Invariance(불변성)이라는 개념을 가지고 있다. 코틀린 Generic의 모든 타입은 Invariance이다. (Invariance와 Covariance는 반대 개념)

이때 Generics를 Covariance로 변경하는 키워드가 in/out 키워드이다.

자세한 내용은 아래 블로그에 자세히 설명되어 있어서 많은 도움이 됐다

https://codechacha.com/ko/generics-class-function-in-kotlin/

 

다시 LoginDataSource로 돌아와서...

LoginDataSource > login()

fun login(username: String, password: String): Result<LoggedInUser> {
    try {
        // TODO: handle loggedInUser authentication
        val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
        return Result.Success(fakeUser)
    } catch (e: Throwable) {
        return Result.Error(IOException("Error logging in", e))
    }
}

fakeUser를 만든 후 Result.Success(fakeUser)를 return 한다.

fakeUser의 타입은 LoggedInUser인데 Result가 제네릭으로 선언되었기 때문에 타입에 상관없이 Result.Success 안에 들어갈 수 있었다.

오류가 발생하면 Result.Error를 호출하고 IOException을 넘긴다.

 

LoginViewModelFactory

이쯤 되면 뭐 하다가 여기까지 넘어왔나 싶을 텐데... (내가 그렇다) ㅋㅋ  우린 지금 ViewModelFactory에서 LoginViewModel에 생성자로 loginRepository를 넘겨주고 있었고, LoginRepository의 dataSource를 LoginDataSource의 리턴 값 ( Result.Success(fackeUser) 혹은 Result.Error(Error) )을 넣어서 넘기고 있었다. 후...

 

LoginViewModel의 선언부

그렇게 해서 LoginViewModel이 생성될 때 dataSourceResult를 가진 loginRepository 생성자를 받았다.

 

그래서 다시 LoginViewModel > login()으로 돌아오면

LoginViewModel > login()

result에 loginRepository의 login을 호출해서 받아온다.

LoginRepository > login()

fun login(username: String, password: String): Result<LoggedInUser> {
    // handle login
    val result = dataSource.login(username, password)

    if (result is Result.Success) {
        setLoggedInUser(result.data)
    }

    return result
}

LoginViewModel > login()에서 LoginRepository > login()을 호출했고, 여기서 LoginDataSource > login()을 호출한 결과를 result에 담는다. ( 아 복잡해...;; )

LoginDataSource를 방금 확인했듯이 username과 password가 무엇이 들어오든 간에 "Jane Doe"라는 이름을 가진 faceUser를 만들어 Result.Success(fakeUser)를 반환한다.

(위 코드 -> LoginRepository > login() 확인) 그럼 result에는 Result.Success(fakeUser)가 담길 것이다.

result가 Result.Success 타입이기 때문에 setLoggedInUser(result.data)가 실행된다.

LoginRepository > login()

setLoggedInUser() 메소드는 LoginRepository.kt에 선언되어 있는 메소드이다.

그리고 result를 반환하면 LoginViewModel > login의 result 변수에 담기게 된다.

 

LoginViewModel > login()

result가 Result.Success라면 LoginResult로 success에는 LoggedInUserView 타입을 넘기고,

Result.Error라면 LoginResult로 error 내용을 res > values > strings.xml의 문자를 넘긴다.

LoginResult
LoggedInUserView

그 결과가 결국에 _loginResult의 값으로 설정된다.

 

이제 주요 로직과 데이터 준비가 마무리됐다.


 

LoginActivity.kt

class LoginActivity : AppCompatActivity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var binding: ActivityLoginBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityLoginBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val username = binding.username
        val password = binding.password
        val login = binding.login
        val loading = binding.loading

        loginViewModel = ViewModelProvider(this, LoginViewModelFactory())
            .get(LoginViewModel::class.java)

        loginViewModel.loginFormState.observe(this@LoginActivity, Observer {
            val loginState = it ?: return@Observer

            // disable login button unless both username / password is valid
            login.isEnabled = loginState.isDataValid

            if (loginState.usernameError != null) {
                username.error = getString(loginState.usernameError)
            }
            if (loginState.passwordError != null) {
                password.error = getString(loginState.passwordError)
            }
        })

        loginViewModel.loginResult.observe(this@LoginActivity, Observer {
            val loginResult = it ?: return@Observer

            loading.visibility = View.GONE
            if (loginResult.error != null) {
                showLoginFailed(loginResult.error)
            }
            if (loginResult.success != null) {
                updateUiWithUser(loginResult.success)
            }
            setResult(Activity.RESULT_OK)

            //Complete and destroy login activity once successful
            finish()
        })

        username.afterTextChanged {
            loginViewModel.loginDataChanged(
                username.text.toString(),
                password.text.toString()
            )
        }

        password.apply {
            afterTextChanged {
                loginViewModel.loginDataChanged(
                    username.text.toString(),
                    password.text.toString()
                )
            }

            setOnEditorActionListener { _, actionId, _ ->
                when (actionId) {
                    EditorInfo.IME_ACTION_DONE ->
                        loginViewModel.login(
                            username.text.toString(),
                            password.text.toString()
                        )
                }
                false
            }

            login.setOnClickListener {
                loading.visibility = View.VISIBLE
                loginViewModel.login(username.text.toString(), password.text.toString())
            }
        }
    }

    private fun updateUiWithUser(model: LoggedInUserView) {
        val welcome = getString(R.string.welcome)
        val displayName = model.displayName
        // TODO : initiate successful logged in experience
        Toast.makeText(
            applicationContext,
            "$welcome $displayName",
            Toast.LENGTH_LONG
        ).show()
    }

    private fun showLoginFailed(@StringRes errorString: Int) {
        Toast.makeText(applicationContext, errorString, Toast.LENGTH_SHORT).show()
    }
}

/**
 * Extension function to simplify setting an afterTextChanged action to EditText components.
 */
fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) {
    this.addTextChangedListener(object : TextWatcher {
        override fun afterTextChanged(editable: Editable?) {
            afterTextChanged.invoke(editable.toString())
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
    })
}

 

Activity만 남았다. 차분히 살펴보자.

LoginActivity > onCreate() -> viewModel 인스턴스 생성

LoginActivity > onCreate() 의 뷰모델 인스턴스 생성 부분

위에서 살펴본 LoginViewModelFactory로 생성자가 있는 ViewModel인 loginViewModel을 생성한다.

 

LoginActivity > EditTtext.afterTextChanged()

/**
 * Extension function to simplify setting an afterTextChanged action to EditText components.
 */
fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) {
    this.addTextChangedListener(object : TextWatcher {
        override fun afterTextChanged(editable: Editable?) {
            afterTextChanged.invoke(editable.toString())
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
    })
}

액티비티의 맨 밑에 있는 부분이다.

EditText의 확장 함수를 만드는 부분인데 EditText의 값에 변화가 있는지 확인하는 리스너인 addTextChangedListener의 생성자로 TextWatcher가 필요한데 TextWatcher는 3개의 메소드를 오버라이딩 해야한다.

afterTextChanged, beforeTextChanged, onTextChanged.

여기서는 3개 다 필요하지 않기 때문에 필요한 메소드만 사용하기 위해 확장 함수를 선언하는 코드이다.

단일 로그인 화면

UI 요소들의 id를 확인하고 밑의 코드를 보자.

LoginActivity > onCreate()

LoginActivity > onCreate()에서 EditText에 리스너 부착한 부분

위에서 만들었던 afterTextChanged를 사용하는 부분이다.

username에 입력된 값에 변화가 있으면 viewModel의 loginDataChagned 메소드를 호출해 유효한 값인지 확인한다.

password도 마찬가지의 작업을 하지만 입력 후 키보드의 완료 버튼을 눌렀을 때의 동작도 한 번에 정의한다. 여기서는 viewModel의 login 메소드를 호출해 로그인한다.

로그인 버튼이 클릭되면 프로그레스 바를 표시하고, 역시 viewModel에서 로그인을 시도한다.

 

LoginActivity.kt > onCreate() -> loginFormState 관찰

LoginActivity > onCreate() 에서 loginFormState 를 observe 하는 부분

viewModel의 로그인 상태(loginFormState)를 관찰한다.

LiveData 타입이기 때문에 observe를 통해 값을 관찰할 수 있으며 값의 변화가 감지되면 내부의 동작을 수행한다.

여기서는 (loginFormState의 값에 따라) 로그인 버튼을 활성화/비활성화하고, 

username이나 password가 유효하지 않는 경우 loginState의 usernameError와 passwordError가 null이 아닌 에러 메시지가 들어가 있으므로 유효성에 따라 EditText에 에러 메시지를 표시할 수 있다.

 

LoginActivity.kt > onCreate() -> loginResult 관찰

LoginActivity > onCreate() 에서 loginResult 를 observe 하는 부분
LoginActivity > updateUiWithUser() , showLoginFailed()

여기서는 로그인 결과(loginResult)를 관찰한다.

프로그레스 바가 로그인 버튼이 눌렸을 때 VISIBLE 됐었는데, 그 이후 로그인 결과가 바뀌면 프로그레스바가 다시 GONE으로 상태 변화된다.

로그인 결과에 따라 로그인 실패 시 Toast로 실패 메시지를 띄우고, 로그인 성공 시 Toast로 성공 메시지를 띄우면서 로그인 액티비티를 종료한다.

 


가볍게 시작했다가... 생각보다 복잡한 구조에 시간이 오래 걸리고 글로 설명하기엔 좀 지저분해졌다

하지만 실제 구현해야 하는 부분은 훨씬 많다 (여기서는 로그인 로직을 모두 빼고 가짜 유저를 만들어 항상 로그인에 성공하도록 만들었으니...)

하지만 이런 구조를 익히기엔 충분히 도움이 될 자료인 듯하다.

반응형
Comments