#はじめに
執筆者は趣味レベルのAndroidアプリ開発者である為,説明間違いが含まれている可能性があります。ですので,お気づきの点がありましたら,是非コメントお願いいたします。また,アップデートにより,機能やコードが記事執筆時点と異なる場合があります。
#本記事の目的
Android Studioで使用可能なテンプレートActivityの一つである「Login Activity」について,機能や構成を説明します。
#テンプレートの概要(公式より引用)
このテンプレートでは、標準的なログイン画面を作成します。ユーザー インターフェースには、メールアドレスとパスワードの各フィールドとログインボタンがあります。一般に、アプリ モジュール テンプレートではなくアクティビティ テンプレートとして使用されます。
このテンプレートの内容は次のとおりです。
- ユーザー インターフェースのメインスレッドとは別にネットワーク操作を処理するための AsyncTask の実装
- ネットワーク操作中に表示する進行状況インジケーター
- 次の推奨されるログイン UI を含む 1 つのレイアウト ファイル:
- メールアドレスとパスワードの入力フィールド
- ログインボタン
Emailとパスワード入力画面があります
Emailに関しては空欄・空白はNGであるが,それ以外に制限はなく1文字でもOK
ただ,@を含む場合,ちゃんとメールアドレス形式でないと怒られます。
パスワードに関しては,6文字以上を要求されます
どちらかがエラーだとボタンを押せないようになっています
ボタンを押すと「Welcome!Jane Doe」とトースト表示され,アプリが終了します
(Jane Doeは日本語で言うところの名無しの権兵衛に相当するらしい)
#ソースコードを見てみる
###レイアウトファイル
デモ操作の時点では気づきませんでしたが,プログレスバーがあるようです。
(プログレスバーはロード中にクルクル回るやつです)
また,passwordにimeActionLabelやimeOptionsを設定していますね。
これを設定することで,キーボードの決定ボタン(アクションボタン)の文字やアイコンを変更することができるようです。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".ui.login.LoginActivity">
<EditText
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="96dp"
android:layout_marginEnd="24dp"
android:hint="@string/prompt_email"
android:inputType="textEmailAddress"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:hint="@string/prompt_password"
android:imeActionLabel="@string/action_sign_in_short"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:selectAllOnFocus="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/username" />
<Button
android:id="@+id/login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="64dp"
android:enabled="false"
android:text="@string/action_sign_in"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password"
app:layout_constraintVertical_bias="0.2" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="64dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/password"
app:layout_constraintStart_toStartOf="@+id/password"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3" />
</androidx.constraintlayout.widget.ConstraintLayout>
###LoginActivityクラス
MVVMパターンで作られているようで,LiveDataを使用して値を監視・変更しているようです。
updateUiWithUser
で成功時のトースト表示をしていますね。
逆にshowLoginFailed
で失敗時のトースト表示をしています。
成功でも失敗でもfinish()
でログイン画面を終了させる仕様になっています。
LoginViewModelでテキスト変更時やボタン押下時の処理を実装しているようなので,次はLoginViewModelを見ていきます。
class LoginActivity : AppCompatActivity() {
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
val username = findViewById<EditText>(R.id.username)
val password = findViewById<EditText>(R.id.password)
val login = findViewById<Button>(R.id.login)
val loading = findViewById<ProgressBar>(R.id.loading)
loginViewModel = ViewModelProviders.of(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) {}
})
}
###LoginViewModelクラス
ユーザーネームやパスワードが変更された際の処理はloginDataChanged
関数で実装されているようです。こちらの中では,isUserNameValid
やisPasswordValid
で入力値を検証していますね。
usernameに関しては,@を含む場合,Patterns.EMAIL_ADDRESS.matcher(username).matches()
のAPIを使用して正しいEmailの形式であるかどうかを検証しています。
ログインボタン(あるいはアクションボタン)を押した際の処理はlogin
関数にて実装されています。この中では,loginRepository.login
に入力値を渡しているようで,この関数内ではその結果によって_loginResultに値を格納し,それによりActivity内でトースト表示がされるといった流れになります。
loginRepository.login
の中身が知りたいので,次はLoginRepository
クラスを見ていきます。
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
}
}
###LoginRepositoryクラス
こちらのlogin
関数では,dataSource.login
関数での結果を返していますね。
ログアウト用の関数もありますが,こちらの中身もやはりLoginDataSourceクラスのメソッドを呼んでいるようです。
では,続いてLoginDataSourceを見ていきます。
class LoginRepository(val dataSource: LoginDataSource) {
// in-memory cache of the loggedInUser object
var user: LoggedInUser? = null
private set
val isLoggedIn: Boolean
get() = user != null
init {
// If user credentials will be cached in local storage, it is recommended it be encrypted
// @see https://developer.android.com/training/articles/keystore
user = null
}
fun logout() {
user = null
dataSource.logout()
}
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
}
private fun setLoggedInUser(loggedInUser: LoggedInUser) {
this.user = loggedInUser
// If user credentials will be cached in local storage, it is recommended it be encrypted
// @see https://developer.android.com/training/articles/keystore
}
}
###LoginDataSourceクラス
こちらで実際のログイン・ログアウト処理を行っていくようです。
ログアウト関数は中身は実装されおらず,コメントで「ここで認証を取り消してください」と記述されています。
また,ログイン関数ですが,LoggedInUserというデータクラスのインスタンスを生成しています。サンプルでは,userIdにランダム生成されたUUIDを渡し,displayNameは"Jane Doe"を渡しfakeUser
というインスタンスを作成しています。
(本来であればDBにアクセスするなどして取得したユーザー情報や,入力された情報を使用してインスタンス作成するのだろう)
更に,生成したインスタンスはResult.Success
に渡して,生成された実体を返却しています。
Resultクラスに関しては,初心者の自分には馴染みのない記述が多かったのでまとめました
- sealed class
- sealed classは列挙型(enum)の拡張版のようなものであり,enumの各値に可変の値を持たせるような仕組み
- <T : Any>
- ジェネリクス
- わざわざAnyを書いている理由は,null非許容なジェネリクスを作るためらしい。
- <out T>
- Javaでの
? extends T
であり,「Tもしくはそのサブクラスを全て表す型」を意味するとのこと。上限付きワイルドカード型というらしい。
- Javaでの
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
}
}
/**
* Data class that captures user information for logged in users retrieved from LoginRepository
*/
data class LoggedInUser(
val userId: String,
val displayName: String
)
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]"
}
}
}
#疑問点
初心者目線で疑問に思った部分をまとめます。
疑問を解消して頂ける方がいましたら,どれか1つでも構いませんのでコメント頂けますと幸いです。
-
Result<out T>のoutを記述する必要性
- 追記:stackoverflowにてピッタリの回答を発見
-
LoginRepositoryクラスの役割とは(コメントを見る感じだと,ローカルにユーザー情報を保存する場合はここでやれということなのだろうか。)
-
ログイン失敗でもActivityが終了してしまうような仕様の理由