0. はじめに
Jetpack Composeについてはこちらの記事をご参照ください。
本記事は、見た目のデザイン(色や余白)ではなく、金融ドメイン特有の技術要件にどう向き合うかに焦点を当てます。テーマは「安全」「誤操作しにくい」「速い」「壊れにくい」。Jetpack Compose(以下Compose)でそれを実現するための実装パターンを、コード+意図で解説します。

1. 金融UI/UXの技術要件を整理(非デザイン視点)
- 安全:入力値の検証、PII(個人情報)の保護、スクリーンショット抑止、画面キャッシュ制御。
- 正確:金額/通貨/日付の厳密フォーマット、Luhn(クレカ)などドメイン検証。
- 速い:無駄な再コンポーズ回避、Lazy系の最適化、非同期処理の背圧。
- 復元可能:プロセスキル時の入力保持、オフライン一時保存と再送。
- 伝わる:アクセシビリティ(TalkBack/読み上げ)、エラー表示の一貫性。
- 監査:重要操作のトレースID付与、UIイベントの監査ログ送出。
2. セキュアな状態管理とスクリーン保護
2-1. スクリーンショット抑止(FLAG_SECURE)
取引画面などPIIを含む画面では、OSレベルでのスクリーンショット/タスクスイッチャのサムネイル生成を抑制します。Compose専用APIはありませんが、Activityのウィンドウフラグで制御できます。
// Activityで一括適用(必要な画面だけON/OFFしてもOK)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
setContent { AppRoot() }
}
}
意図: OS側で表示複製を防ぐことで、ユーザーの画面を第三者が撮影・共有しづらくします。必要な画面のみONにする運用も可能です。
2-2. rememberSaveable と State hoisting
金額や口座番号など入力途中の値は、プロセスキルや画面回転でも保持したい一方、セッション終了時には消したい。rememberSaveable
で保存しつつ、ViewModelのスコープを画面単位に限定します。
@Composable
fun TransferScreen(viewModel: TransferViewModel = hiltViewModel()) {
// UIは状態を受け取り、イベントを上位(VM)に送る「State hoisting」
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
TransferForm(
state = uiState,
onAmountChange = viewModel::onAmountChange,
onSubmit = viewModel::submit
)
}
@Composable
fun TransferForm(
state: TransferUiState,
onAmountChange: (String) -> Unit,
onSubmit: () -> Unit
) {
var memo by rememberSaveable { mutableStateOf("") } // 軽量な入力はsaveable
/* ... */
}
意図: コンポーザブルにビジネスロジックを持たせず、テストしやすい境界を作る。保存期間を意識して、永続化が不要な値はrememberSaveable
で十分です。
3. 金融フォーム実装:フォーマット・検証・エラーUI
3-1. 金額入力(数字のみ + 通貨フォーマット + IME)
金額は「数字のみ」「カンマ区切り」「IMEは数字専用」の基本を守ります。表示はVisualTransformation
でフォーマットし、onValueChange
では数字以外を除去して内部値をクリーンに保ちます。
@Composable
fun AmountField(
amount: String,
onAmountChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val nf = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
OutlinedTextField(
value = amount,
onValueChange = { raw ->
val digits = raw.filter { it.isDigit() }
onAmountChange(digits) // 内部は常に「数字だけ」
},
label = { Text("金額") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
visualTransformation = remember(amount) {
// 表示は 12,345 のように整形(カーソルズレは許容範囲の簡易実装)
VisualTransformation { text ->
val formatted = runCatching {
nf.format(text.text.toLong())
}.getOrElse { text.text }
TransformedText(AnnotatedString(formatted), OffsetMapping.Identity)
}
},
supportingText = { Text("半角数字のみ。手数料は次画面で表示") },
modifier = modifier.fillMaxWidth()
)
}
意図: 内部データは常に正規化(数字のみ)。表示は適宜整形してUXを向上。_表示と内部を分ける_ことで、計算・バリデーションの不具合を減らします。
3-2. カード番号入力(Luhnチェック + マスキング)
カード番号はリアルタイムでLuhnチェック(桁の妥当性)を行い、表示は 4-4-4-4 などのブロック分割。VisualTransformation
を活用します。
private fun luhnOk(digits: String): Boolean {
if (digits.length !in 13..19) return false
var sum = 0; var alt = false
for (i in digits.length - 1 downTo 0) {
var n = digits[i] - '0'
if (alt) { n *= 2; if (n > 9) n -= 9 }
sum += n; alt = !alt
}
return sum % 10 == 0
}
object CardVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val raw = text.text.filter { it.isDigit() }
val parts = raw.chunked(4).joinToString(" ")
return TransformedText(AnnotatedString(parts), OffsetMapping.Identity)
}
}
@Composable
fun CardNumberField(
number: String,
onChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val isValid = remember(number) { luhnOk(number.filter { it.isDigit() }) }
OutlinedTextField(
value = number,
onValueChange = { onChange(it.filter { c -> c.isDigit() }) },
label = { Text("カード番号") },
visualTransformation = CardVisualTransformation,
isError = number.isNotBlank() && !isValid,
supportingText = {
if (number.isNotBlank() && !isValid) Text("番号が正しくありません")
else Text("半角数字のみ、例: 4242 4242 4242 4242")
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier.fillMaxWidth()
)
}
意図: ドメイン検証(Luhn)をUI層で早期フィードバック。内部は数字のみ保持し、描画時にマスキング/整形します。
3-3. エラー表示とアクセシビリティ(TalkBack対応)
エラーは色だけに依存しない(文言+アイコン+アナウンス)。Composeのsemantics
でエラーを読み上げます。
@Composable
fun ErrorText(message: String, modifier: Modifier = Modifier) {
val semantics = Modifier.semantics {
contentDescription = "エラー: $message" // TalkBackで読ませる
}
Row(modifier = modifier.then(semantics)) {
Icon(Icons.Default.Error, contentDescription = null, tint = Color.Red)
Spacer(Modifier.width(8.dp))
Text(message, color = Color.Red)
}
}
意図: 「見える人だけが分かる」UIを避け、読み上げでもエラーが伝わるようにします。重要操作はバイブレーション(LocalHapticFeedback
)も併用可。
4. パフォーマンスと再コンポーズ最適化
4-1. Stable/Immutableな状態と derivedStateOf
重い計算やフィルタはderivedStateOf
でメモ化し、snapshotFlow
でFlowに橋渡し。必要な時だけ再コンポーズします。
@Immutable data class Tx(val id: String, val amount: Long)
@Stable class UiState(list: List<Tx>) { val txList = list } // 簡易例
@Composable
fun TxList(state: UiState) {
var query by rememberSaveable { mutableStateOf("") }
val filtered by remember(query, state.txList) {
derivedStateOf { state.txList.filter { it.id.contains(query, ignoreCase = true) } }
}
LazyColumn {
items(items = filtered, key = { it.id }) { tx ->
Text("ID: ${tx.id} / ${tx.amount}")
}
}
}
意図: derivedStateOf
は入力が同じなら再計算しません。key
を付けて再利用率を上げ、スクロール性能を確保します。
4-2. 非同期とバックプレッシャ(背圧)
高頻度に変わる検索入力をそのままAPIに投げると輻輳します。snapshotFlow
+debounce
で間引き、キャンセル可能なviewModelScope
で扱います。
@Composable
fun SearchBox(onResults: (List<Tx>) -> Unit, vm: SearchViewModel = hiltViewModel()) {
var query by rememberSaveable { mutableStateOf("") }
LaunchedEffect(Unit) {
snapshotFlow { query }
.distinctUntilChanged()
.debounce(350)
.collectLatest { q -> onResults(vm.search(q)) } // 最新だけ処理
}
OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("検索") })
}
意図: 入力スパイクでもUIが固まらないよう、負荷を吸収します。
5. オフライン・再送設計(Room × WorkManager)
5-1. 一時保存と冪等な再送
送金などの重要操作は、ネットワーク失敗でも安全に再送できるようローカルにキューイング。サーバ側は冪等キーで二重処理を防ぎます。
// Roomの簡易エンティティ(キュー)
@Entity
data class PendingTransfer(
@PrimaryKey val id: String, // 冪等キー
val amount: Long,
val to: String,
val createdAt: Long = System.currentTimeMillis()
)
// 送信ワーカー(指数バックオフ再試行)
class TransferWorker(app: Context, params: WorkerParameters) : CoroutineWorker(app, params) {
override suspend fun doWork(): Result {
val id = inputData.getString("id") ?: return Result.failure()
// サーバAPI呼び出し(冪等キー=id)
return runCatching { api.transfer(id) }
.fold(onSuccess = { Result.success() },
onFailure = { Result.retry() })
}
}
// enqueue(一意キュー)
fun enqueueTransfer(id: String) {
val request = OneTimeWorkRequestBuilder<TransferWorker>()
.setInputData(workDataOf("id" to id))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork("tx-$id", ExistingWorkPolicy.KEEP, request)
}
意図: 端末再起動でも再開できるようWorkManagerを採用。冪等キーで「同じ依頼」は一度だけ実行される契約にします。
6. 監査・ログ・アラート(UI側でできること)
- 重要操作(送金/認証)にはtraceIdを付与してイベント送出。
- ログはPIIを含めない(アカウントIDもハッシュ化)。
- 再現性のため、バージョン/端末/OS/画面名を併記。
7. テストと計測(Compose Test / ベンチマーク)
7-1. UIテスト(Semantics)
Semantics(読み上げ用メタデータ)を活用して、文言・エラー・フォーカス移動をテストします。
// Compose UI Test(例)
@get:Rule val rule = createAndroidComposeRule<MainActivity>()
@Test fun cardNumberErrorIsAnnounced() {
rule.onNodeWithText("カード番号").performTextInput("1234")
rule.onNodeWithText("番号が正しくありません").assertIsDisplayed()
}
7-2. パフォーマンス計測の観点
- リストのスクロールjank率(フレーム落ち)
- 初回描画時間(TTI)
- 再コンポーズ回数(ログでサンプリング)
まずは「体感で重い箇所」からderivedStateOf
やkey
付与で改善します。
8. まとめ
金融UI/UXは「美しさ」よりも「壊れにくさ」「誤操作しにくさ」「監査できること」。Composeなら、状態の分離と描画の分担で、それを構造的に実現できます。本記事のテンプレ(入力・検証・エラー・アクセシビリティ・オフライン・再送)を土台に、まずは小さな画面から安全に置き換えていきましょう。