こんにちは、コミュニケーションアプリ LINE のAndroidクライアントを開発している森です。
この記事では、DroidKaigi2023の企業ブースで行ったCode Review Challengeの2問目の解説をします。
Code Review Challengeについてはこちらを参照してください。
出題タイトル : A Composable for everything, and everything in its Composable
2問目は以下のコードを出題しました。
@Composable
fun LoginScreen() {
Column(
modifier = Modifier
.padding(DEFAULT_SIZE.dp)
) {
Spacer(
modifier = Modifier.weight(2f)
)
title()
Spacer(
modifier = Modifier.weight(1f)
)
val email = email()
Spacer(
modifier = Modifier
.height(DEFAULT_SIZE.dp)
)
val password = password()
Spacer(
modifier = Modifier.weight(2f)
)
loginButton(
email = email,
password = password
)
}
}
@Composable
private fun title() {
Text(
text = "DroidKaigi 2023",
fontSize = LARGE_SIZE.sp,
fontWeight = FontWeight.Bold
)
Spacer(
modifier = Modifier
.height(SMALL_SIZE.dp)
)
Text(
fontSize = DEFAULT_SIZE.sp,
text = "Let's enjoy!"
)
}
@Composable
private fun email(): String {
var email by remember {
mutableStateOf("")
}
TextField(
modifier = Modifier.fillMaxWidth(),
value = email,
onValueChange = { email = it },
placeholder = { Text("Email") }
)
return email
}
@Composable
private fun password(): String {
var password by remember {
mutableStateOf("")
}
TextField(
modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = { password = it },
placeholder = { Text("Password") },
visualTransformation =
PasswordVisualTransformation()
)
return password
}
@Composable
private fun loginButton(
modifier: Modifier = Modifier,
password: String,
email: String,
viewModel: LoginViewModel = viewModel()
) {
var clicked by remember {
mutableStateOf(false)
}
val onBackPressedDispatcher =
LocalOnBackPressedDispatcherOwner.current
?.onBackPressedDispatcher
if (clicked) {
runBlocking {
if (viewModel.login(email, password)) {
onBackPressedDispatcher
?.onBackPressed()
}
}
}
Button(
modifier = modifier.fillMaxWidth(),
onClick = { clicked = true }
) {
Text(
modifier = modifier,
text = "Login"
)
}
}
const val LARGE_SIZE = 26
const val DEFAULT_SIZE = 16
const val SMALL_SIZE = 4
class LoginViewModel : ViewModel() {
/**
* @return true if login succeeded.
*/
suspend fun login(
email: String,
password: String
): Boolean {
/* ... */
return true
}
}
これは、メールアドレスとパスワードを入力してログインを行うUIを、Jetpack Composeを使って実装したものです。実際に実行すると、以下のような画面が表示されます。
このコードには様々な問題が含まれていますが、重要なポイントを以下の2つに分けて解説します。
・ログイン処理に関する問題
・UI構築に関する問題
また、ユーザ体験を向上させるために検討できそうな改善点についても紹介します。
ログイン処理に関する問題
データフローを単方向にする
Jetpack Composeでは、ステートをもとに描画を行い、イベントをもとにそのステートを更新するデータフローが推奨されています。
しかし、出題されたコードは各フォームから文字列を返し、それをログインボタンに渡してログインボタン内で処理を行っていました。
一見矢印の数が減ってシンプルになったようにも見えますが、実際には処理を把握するために全てのコンポーネントを確認する必要があり、データの流れが追いにくくなっています。
ステートは親から子に渡るように、また子から親にはイベントで渡すようにしてください。そうすることで、単方向データフローを実現することができます。単方向データフローには、以下のメリットがあります。
・ステートを一箇所で管理し、状態の複製をなくす(Single Source of Truth)。
・状態を複数のコンポーネントで共有しやすくする。
・状態の管理やロジックとUIの描画を分離する(単一責任の原則)。
・UI描画のコンポーネントを再利用しやすくする。
また、このようにステートを子コンポーネントではなく親のコンポーネントで管理することを、ステートホスティングと呼びます。
具体的には、メールアドレスやパスワードは親Composableで状態の管理を行い、各コンポーネントは引数で現在のテキストを受け取り、テキストが変更された際はlambdaを使って親に通知するようにします。UIを描画するComposable関数は値を返してはいけません。
@Composable
private fun Email(
email: String,
onEmailChange: (String) -> Unit
) {
TextField(
value = email,
onValueChange = onEmailChange,
placeholder = { Text("Email") }
)
}
ボタンも同様に、クリックされたことをlabmdaを使って親に通知し、ログイン処理は親で行うべきです。パスワードやメールアドレスを渡す必要はありません。パスワードやメールアドレスは本来ログインボタンを表示するために必要ないはずです。
@Composable
private fun LoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
modifier = modifier.fillMaxWidth(),
onClick = onClick
) {
Text(text = "Login")
}
}
ユーザが入力した内容をViewが破棄されても保持する
remember
を使ってメールアドレスやパスワードを保持していると、画面回転やメモリ不足等でViewが破棄されたときにデータが消えてしまいます。ユーザが入力した情報が勝手に消えてしまうのは、ユーザ体験として非常に悪いです。
remember
のかわりに rememberSaveable を使うことで、Viewが破棄されたときも状態を保存することができます。
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
ViewModelでメールアドレスやパスワードを保持する場合も、SavedStateHandle
を使って保持し、ViewModelが破棄されても残るようにすると良いでしょう。
class LoginViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
val email: StateFlow<String> = savedStateHandle.getStateFlow<String>(EMAIL_KEY)
val password: StateFlow<String> = savedStateHandle.getStateFlow<String>(PASSWORD_KEY)
fun setEmail(email: String) {
savedStateHandle[EMAIL_KEY] = email
}
fun setPassword(password: String) {
savedStateHandle[PASSWORD_KEY] = password
}
companion object {
private const val EMAIL_KEY = "email"
private const val PASSWORD_KEY = "password"
}
}
なんでもSavedStateに保存するのは、クラッシュの原因にもなり良くありません。ただし、ユーザが作業中のものはストレスがないよう、すぐに再開できるようにしておくと良いと思います。
時間のかかる処理はViewModelで行う
非同期処理を runBlocking
を使って行っていますが、適切なCoroutineScopeを選択する必要があります。ただし、Composableの CoroutineScope
でログイン処理を行うと、ログイン中に画面回転等でViewが破棄された場合に、ログイン処理がキャンセルされたり、ログインが完了しても画面が切り替わらない可能性があることに注意してください。
時間のかかる処理で確実にタスクを完了させたい場合、ViewModelの CoroutineScope
を使ってコルーチンを起動したほうが良いでしょう。
ログイン完了後にフラグを更新し、それを LaunchedEffect で監視することで確実に画面遷移を行うことができます。
@Composable
fun LoginScreen(
modifier: Modifier = Modifier,
viewModel: LoginViewModel = viewModel()
) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
?.onBackPressedDispatcher
LaunchedEffect(viewModel.isUserLoggedIn) {
if (viewModel.isUserLoggedIn) {
onBackPressedDispatcher?.onBackPressed()
}
}
/* ... */
}
class LoginViewModel : ViewModel() {
var isUserLoggedIn by mutableStateOf(false)
private set
fun login(email: String, password: String) {
viewModelScope.launch {
/* ... */
isUserLoggedIn = true
}
}
}
UI構築に関する問題
Composable関数のルートは単一のComposableのみを配置する
以下のようにComposable関数のルートに、複数のUI Composableが配置されている状態だと、それが縦並びに表示されるのか、横並びに表示されるのか、はたまた重なって表示されるのか、このコンポーネントを見ただけだと判断できません。この場合、各コンポーネント並び方は呼び出し元のComposableによって決定されます。
@Composable
private fun Title() {
Text(/* ... */)
Spacer(/* ... */)
Text(/* ... */)
}
レイアウトの曖昧さをなくすために、複数のコンポーネントがある場合は必ず Column
や Row
, Box
で囲うようにしましょう。こうしたほうが単一のコンポーネントとして理解しやすく、また再利用することが可能になります。
@Composable
private fun Title() {
Column {
Text(/* ... */)
Spacer(/* ... */)
Text(/* ... */)
}
}
引数のModifierは一番外側のUIコンポーネントにのみ指定する
Composable関数の引数のModifierは、必ず一番外側のUIコンポーネントのみに指定するようにしてください。以下のように複数のコンポーネントに同じModifierを指定すると、例えば padding
を指定したときに Button
と Text
の二重に余白がつくことになります。
@Composable
private fun LoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
modifier = modifier.fillMaxWidth(),
onClick = onClick
) {
Text(modifier = modifier, text = "Login")
}
}
このような重複はリファクタリング等で階層構造を変更したときに起こりやすいので、注意する必要があります。
ユーザ体験を改善する
コードとして問題のある箇所だけでなく、ユーザ体験を向上させるために検討できそうな改善点について紹介します。
キーボードで操作可能にする
ソフトウェアキーボードの挙動を指定することで、以下のような便利な機能を実装することができます。
- メールアドレス入力後に、キーボードの次へボタンでパスワードの入力欄にフォーカスを移動する。
- パスワード入力後に、キーボード送信ボタンでログインを実行する。
Jetpack Composeでは、TextFieldの keyboardOptions
や keyboardActions
を指定することで上記を実現することが可能です。KeyboardType
を入力対象に合わせて指定することで、適切なソフトウェアキーボードが表示されるようになります。
@Composable
private fun Email(
email: String,
onEmailChange: (String) -> Unit
) {
TextField(
value = email,
onValueChange = onEmailChange,
placeholder = { Text("Email") },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
)
)
}
@Composable
private fun Password(
password: String,
onPasswordChange: (String) -> Unit,
onDone: () -> Unit
) {
TextField(
value = password,
onValueChange = onPasswordChange,
placeholder = { Text("Password") },
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(onDone = onDone)
)
}
Autofill機能を有効にする
Androidには自動的にメールアドレスやパスワードを保管するAutofill機能があります。ただし、Jetpack ComposeのTextFieldではまだ完全にサポートされておらず、一部の機能は不完全です。例えば、保存された項目を取得することはできますが、新たに入力された内容を保存することができません。
そのため、Autofillを有効にしたい場合は、TextFieldのかわりにTextViewを使うと良いでしょう。
その他の問題
他にも、以下のような問題がありました。
ログインボタンのクリック時の処理が不要に複雑になっている
ログインボタンクリック時に clicked
を更新し、再描画によってクリック処理を行っているが、 onClick
内で行えば良い。これが原因で、一度ログインボタンをクリックしたあとは、再描画の度にログイン処理が呼ばれるバグがある。
無意味な定数化がおこなわれている
LARGE_SIZE
、 DEFAULT_SIZE
、 SMALL_SIZE
という定数名は、それぞれ何に対するサイズなのかが明確でなく、単に同じ数字でまとめられている。定数化するときはより具体的な意味のあるものにするか、この場合、直接指定しても問題ない。
Composable関数の名前がガイドラインに従っていない
値を返さないComposableは大文字で始める。
Composable関数の引数の順番がガイドラインに従っていない
Composable関数の引数は[必須のパラメータ]、[Modifier]、[オプショナルのパラメータ]、[主要なlambda]の順にする。
メールアドレス、パスワードで改行が行える
改行が行える必要はないので、 singleLine
を指定して改行を禁止する。
想定回答
上記のポイントを反映したコードが以下になります。
@Composable
fun LoginScreen(
modifier: Modifier = Modifier,
viewModel: LoginViewModel = viewModel()
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
?.onBackPressedDispatcher
LaunchedEffect(viewModel.isUserLoggedIn) {
if (viewModel.isUserLoggedIn) {
onBackPressedDispatcher?.onBackPressed()
}
}
LoginScreen(
email = email,
password = password,
onEmailChange = { email = it },
onPasswordChange = { password = it },
onLoginClick = { viewModel.login(email, password) },
modifier = modifier
)
}
@Composable
fun LoginScreen(
email: String,
password: String,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(16.dp)
) {
Spacer(modifier = Modifier.weight(2f))
Title()
Spacer(modifier = Modifier.weight(1f))
Email(
email = email,
onEmailChange = onEmailChange
)
Spacer(modifier = Modifier.height(16.dp))
Password(
password = password,
onPasswordChange = onPasswordChange
)
Spacer(modifier = Modifier.weight(2f))
LoginButton(
onClick = onLoginClick
)
}
}
@Composable
private fun Title(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = "DroidKaigi 2023",
fontSize = 26.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
fontSize = 16.sp,
text = "Let's enjoy!"
)
}
}
@Composable
private fun Email(
email: String,
onEmailChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
modifier = modifier.fillMaxWidth(),
value = email,
onValueChange = onEmailChange,
placeholder = { Text("Email") },
singleLine = true
)
}
@Composable
private fun Password(
password: String,
onPasswordChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
modifier = modifier.fillMaxWidth(),
value = password,
onValueChange = onPasswordChange,
placeholder = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
}
@Composable
private fun LoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
modifier = modifier.fillMaxWidth(),
onClick = onClick
) {
Text(text = "Login")
}
}
class LoginViewModel : ViewModel() {
var isUserLoggedIn by mutableStateOf(false)
private set
fun login(email: String, password: String) {
viewModelScope.launch {
/* ... */
isUserLoggedIn = true
}
}
}
まとめ
今回は、Code Review Challengeを通じて、Jetpack Composeのステート管理やUI構築で気をつけるべき点のほか、ログイン画面を作る上でユーザ体験を改善できそうな項目についても紹介しました。
実際のコードレビューでも、ユーザ体験を改善できそうなポイントが見つかった場合、ぜひ議論してみてほしいと思います。
もちろん、対応に時間がかかったり変更するコードが大きくなりそうであれば、別のプルリクエストに分けたり、対応時期を調整する必要があるかもしれません。
ただし、できるだけ早い段階で議論を行うことは、手戻りを減らし、ユーザにより良いものを届けることができると考えています。
最後に、このCode Review Challengeがコードレビューのスキル向上や、Jetpack Composeを使用した開発についての理解が深まることを期待しています。