Jetpack Compose × 金融UI/UX設計

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に投げると輻輳します。snapshotFlowdebounceで間引き、キャンセル可能な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)
  • 再コンポーズ回数(ログでサンプリング)

まずは「体感で重い箇所」からderivedStateOfkey付与で改善します。

8. まとめ

金融UI/UXは「美しさ」よりも「壊れにくさ」「誤操作しにくさ」「監査できること」。Composeなら、状態の分離描画の分担で、それを構造的に実現できます。本記事のテンプレ(入力・検証・エラー・アクセシビリティ・オフライン・再送)を土台に、まずは小さな画面から安全に置き換えていきましょう。

参考URL

採用情報 長谷川 横バージョン
SHARE
PHP Code Snippets Powered By : XYZScripts.com
お問い合わせ