Share
Embed
Transcript
56 slides
-
FlutterKaigi 2026 FlutterKaigi 2026 | DAY1 15:40-16:10 本⼈確認アプリを Flutterで再設計したら、 評価が1.2から4.0に改善した話
株式会社TRUSTDOCK デジタルIDウォレット部 部⻑ ⿇⽣ 真吾 株式会社TRUSTDOCK デジタルIDウォレット部 Flutterエンジニア 陳 信瑜 -
オープニング|⾃⼰紹介 ⾃⼰紹介とプロダクト紹介 ⿇⽣ 真吾 株式会社TRUSTDOCK:eKYC(オンライン本⼈確認)専⾨機関 350万DL突破「TRUSTDOCKアプリ」の開発裏話 株式会社TRUSTDOCK デジタルIDウォレット部 部⻑ 結果
• 2025年7⽉よりEMとして参画 • 2026年1⽉より現職 呼出し 撮影‧ICチップ読み取り SDK 開始‧完了 TRUSTDOCKアプリ API • Web系フルスタックエンジニア • 基盤構築(認証認可、データ、検索) SDKの結果とAPIをつないで本⼈確認を進める 「本⽇は技術課題の裏にある、 事業と組織の意思決定を語ります」 3 -
本⽇のテーマ 00 : 本⼈確認アプリという題材 01 : 評価1.2が事業に与えていた影響 02 : なぜそこまで壊れたのか
03 : Flutter再設計を選んだ事業判断 04 : 1.2→4.0、その後 05 : まとめ 4 -
序章|本⼈確認アプリという題材 本⼈確認という重い題材 Hardware Control High Reliability Friction Management ハードウェアコントロール ⾼信頼性
摩擦管理 カメラ(⾝分証の厚み撮影)、 NFC (マイナンバーカードICチッ ブ読み 取り)の精密な制御 本⼈確認の⼿続きで求められる⼿順 を守り、データを確実に連携 ユーザーにとって「⾯倒な作業」 で あり、僅かなエラーで即離脱 端末の機能に深く触る → 途中で処理が⽌まりやすい 6 -
序章|本⼈確認アプリという題材 ⻑く運⽤してきたアプリと直⾯した現実 直⾯した残酷な現実 結果 2017〜2018年 2020年2⽉ 開発スタート Android版リリース (法改正に伴う要件の肥⼤化) App
Store評価 「1.2〜1.5」を推移 連発するクラッシュと、 蓄積するユーザーの不満 8 -
第1章|評価1.2が事業に与えていた影響 不満は提案の場まで届く END USER 利⽤者 確認の途中で落ちる OSで機能‧品質が違う ★1 レビュー CLIENT
導⼊企業 ストア評価が 導⼊判断の材料に この 評価は…? US TRUSTDOCK 提案時の信頼度が下がる 結果:新規営業活動における極めて深刻な逆⾵ 12 -
第2章|なぜそこまで壊れたのか 当時の開発体制 社内(TRUSTDOCK) 1名 iOSチーム Androidチーム 1.5名 2.5名 PdM マネージャー
1名 1名 外部パートナー(全⾯委託) 全⾯委託 計6名 マネジメント担当のみ 開発は全⾯的に外部委託∕iOSとAndroidは別々のチーム 15 -
第2章|なぜそこまで壊れたのか 構造的なサイロ化 iOSチーム 委託先メンバーA SPEC iOSアプリ 仕様の解釈違い 仕様‧要件 TRUSTDOCK RESULT
コード共有やコミュニケーションが弱い Androidチーム 委託先メンバーB 実装の挙動差 Androidアプリ 品質のばらつき OS間で機能差‧品質差が恒常的に⽣まれる 16 -
第2章|なぜそこまで壊れたのか ⾞輪の再発明と品質の真空地帯 01 ⾞輪の再発明(重複実装) SDK ⾝元確認ロジック アプリ = ⾝元確認ロジック ⽚⽅を直しても、もう⽚⽅にバグが残る
02 品質の真空地帯(QA不在) 外部 パートナーが 納品 QA‧ 受⼊テスト = 不在 そのまま ユーザーへ ユーザーの⼿元で初めて不具合が発覚 重複実装と、作られたものを検証‧担保する主体がいない 17 -
第2章|なぜそこまで壊れたのか 抜け出せない崩壊ループ 品質の低下 キーパーソン離脱 ⽂脈の断絶 経緯を知る⼈が 社内にいない パートナーとの 関係悪化 開発スピード低下
誰かが悪いのではなく、知識が社内に残らない構造だった 18 -
第3章|Flutter再設計を選んだ事業判断 なぜ、Flutterだったのか 純粋な技術的優位性「だけ」で 選んだわけではない ORGANIZATION TECHNOLOGY 組織 技術 外部依存の脱却 単⼀チーム化
Flutter 単⼀コード 品質の統制 コントロールを社内に取り戻す 「内製化」 「組織を再構築するための 最適なプラットフォーム」 としての選択 20 -
第3章|Flutter再設計を選んだ事業判断 なぜFlutterを選んだのか? 01 02 03 採⽤市場 内製化 品質差の解消 候補者プールを作りやすかった 開発のコントロールを社内に戻す
OS間の差が構造的に消える 当時:iOS/Androidエンジニアの採⽤市場が枯渇∕初期メンバーを⼀気に集められる地盤があった 21 -
第3章|Flutter再設計を選んだ事業判断 選ばなかった選択肢 当時の判断軸 社内親和性 React Native Kotlin Multiplatform Xamarin Flutter
成熟度 採⽤市場 社内にReact/Webの親和性なし ⾒送り 成熟度 当時は成熟度が不⾜ ⾒送り 将来性‧採⽤市場 衰退傾向‧C#⼈材なし ⾒送り 採⽤‧内製化‧品質差解消に合致 採⽤ 社内親和性 すべてに合致 技術の優劣ではなく「⾃分たちの組織で回せるか」で判断 22 -
第3章|Flutter再設計を選んだ事業判断 構造的サイロ化の強制リセット BEFORE AFTER 外部委託‧OS別の分業体制 社内の1つのチーム iOSチーム iOSアプリ Flutter Flutterエンジニア
+ QA(社内) 内製化 Androidチーム Androidアプリ コード2つ‧仕様解釈もバラバラ クロスプラットフォームによる 「単⼀コードベース」化 単⼀コードベース iOS/Android を同じ仕様で 機能差が消滅し、全員が同じ仕様に向き合う 「1つのチーム」へ 23 -
第3章|Flutter再設計を選んだ事業判断 結果 評価1.2からの脱却 App Store App Store 1.2 Flutter Rewrite
体制刷新 ⾝元確認ロジックをSDKに寄せ 重複ロジックを整理‧改善 致命的なクラッシュが激減し、 両OSの品質が均質化 4.0+ 現在のApp Store評価は 「4.0以上」で安定 24 -
第4章|1.2→4.0 その後 技術だけの魔法ではない 評価 4.0の内訳 事業側の後押し マーケティング施策 Business ‧iOSのみストア評価をリセット ‧インセンティブ配布キャンペーンを併⽤
(効果は狙い通りでない⾯も) * キャンペーンは、レビュー投稿‧評価を条件にしていません 本質的な品質改善 Technology ⼟台 アプリ⾃体の基本品質向上が⾼評価の⼟台になった 技術⾯の改善が⼤前提だが、事業側の泥臭い後押しも存在した 26 -
第5章|まとめ 今の「4.0以上」を⽀えているもの App Store評価 SUSTAINED ⼀過性のキャンペーン 終了後も、評価4.0は 維持され続けている 4.0以上 致命的クラッシュ
iOS Androidの の激減 機能差解消 ROOT CAUS 評価1.2は 「体制の問題」であり、 4.0は「作り⽅を変えた結果」 ⼟台:開発体制と作り⽅の再設計 組織構造を正しくリファクタリングすれば、 プロダクトは必ず応えてくれる 28 -
技術パートへ|Flutterでどう作り直したか ここからは「どう作り直したか」 前半|事業パート(⿇⽣) 後半|技術パート(陳) なぜ作り直したか どう作り直したか 1.2の真因は「体制の構造」 バトンタッチ SDKとAppの境⽬を、どう繋ぐか Flutterは組織を⽴て直す⼿段
エラーとリトライ(完了APIの通信) 評価1.2 →4.0+ 状態とUI(処理の順番) 「SDK」と「本⼈確認フロー」をFlutterのコードと設計の視点で深掘りします 29 -
⾃⼰紹介 陳 信瑜 株式会社TRUSTDOCK デジタルIDウォレット部 GitHub:Ricky-yu Flutterエンジニア X:@ts00484515 • 本⼈確認(eKYC)アプリの設計改善‧運⽤
• iOS‧Android‧Flutterアプリでの開発経験 • 前職でFlutterKaigi2025のLTに登壇 「MVVMからMVVM-Cに変えてみた話」 31 -
本⽇のテーマ 00 : 旧アプリユーザーの声と新‧旧アプリの構成 01 : SDK連携と共通フロー 02 : Flutter版で変えた三つの設計
03 : ユーザーの声の変化 32 -
旧アプリへの声 旧アプリに寄せられていた声 ⼆つの不満を、最後にレビューで⽐較 よくあった不満 声(要約) 最初からやり直し 休憩しようと⽌めたら、最初からやり直し 読み取り直後に落ちる カードを読み込めた瞬間に、アプリが落ちてホームに戻る 読み取り‧撮影
マイナンバーカードを読み取れない、撮影できない 案内が分かりにくい 「エラー」の⼀⾔だけで、対処のしようがない やり直しと、読取後のクラッシュに注⽬する 34 -
旧アプリの構成 OSごとの制約を、それぞれのアプリで扱っていた 旧ネイティブApp|OSごとに実装 iOS Swift Android Kotlin UI 表⽰‧遷移 UI
表⽰‧遷移 App∕SDKの外側 Navigator∕loadi ng 案内‧処理再開 UseCase∕Reposit ory ⼿順‧AppError Remote通信 App API SDK呼出 ネイティブSDK Navigator∕Paus eHandler 案内‧処理再開 UseCase∕Reposit ory ⼿順‧AppError Remote通信 App API SDK呼出 ネイティブSDK OSごとの制約を、それぞれのAppで⽀えていた 35 -
設計の全体像 Flutter版で分けた、AppとSDKの責任 Flutter(Dart)共通のApp UI 表⽰‧遷移 Stateを監視し、 表⽰を更新 MVVM + Clean
Architecture ViewModel State Remote DataSource App API SDK別 Local DataSource Plugin Swift∕Kotlin UseCase ⼿順‧再試⾏ Repository AppErrorへ変換 Channel ネイティブSDK (2種類) iOS∕Androidへ接続 SDKごとの接続を分け、アプリ側の判断を両OSで共有する 36 -
01 SDK連携と共通フロー SDKとApp APIをつなぐ本⼈確認フロー iOS∕Android × 2種類のSDK × App API
緑:SDK ⽩:アプリ ① 事業者への資料提出(事業者⽤SDK) アプリ 同意API SDK 撮影‧読取‧提出 アプリ 提出完了API 提出完了画⾯ 必要な結果 がそろう 本⼈確認完了API 成功後に完了画⾯ SDKの提出後も、アプリの提出完了APIが残る ② 認証アカウント(eKYC SDK) SDKを起動 読取‧本⼈確認 SDKの最終結果 開始IDで呼ぶ 本⼈確認開始API SDKの終了と、アプリの完了条件を分けて扱う 38 -
① エラー責務の整理 外部のエラーをAppErrorへ変換する ⽐較する点:外部の失敗を、アプリで扱う型へ変える場所 旧iOS:HTTP応答をAppErrorへ // HTTPステータスによる変換(抜粋) case 400: throw
AppError.illegalArgument(appErrorInfo) case 400: throw AppError.notFound 旧Android:通信例外をAppErrorへ is IOException -> AppError.Io(AppErrorDetail(reason = this)) // 通信例外をAppErrorへ変換 Flutter:Repositoryから共通変換へ } on GrpcError catch (e) { throw getAppErrorFromGrpcError(e, ApiType .completeVerificationSubmission); } // 共通変換関数の一部 case StatusCode.invalidArgument: return InvalidArgumentError(e, type: type); 外部エラーをAppErrorへそろえ、両OSで判断する 41 -
① エラー責務の整理 分類できない失敗の、ユーザーへの案内 ⽐較する点:例外の⽂⾔を、どこでユーザー向けにするか 旧iOS:例外の説明を表⽰ // AppError外のエラーの表示 message: error.localizedDescription, //
title・callbackなどは省略 旧Android:⼀部の例外の⽂⾔を表⽰ } else if ( it.localizedMessage?.isNotEmpty() == true ) { it.localizedMessage } Flutter:変換先に案内を⽤意 // gRPCの共通変換関数 default: return UnknownError(error: e); // UnknownErrorのmessage引数 message: i18n.text .didw__common_error_server_description, 未知のエラーにも、共通のユーザー向け案内を⽤意 42 -
① エラー責務の整理 変換処理を、通信や画⾯から離して確かめる 新設計の確認:事業者への提出の完了APIのテスト エラーが返る流れ エラーを起こし、型と案内を確認する DataSource(mock) GrpcError Repository(実装) AppError
expect(型‧共通メッセージ) // Providerの設定・Repositoryの取得は省略 when(mockDataSource.completeVerification(any)) .thenThrow(const GrpcError.unauthenticated()); try { await repository.completeVerification(publicId); } on ApiError catch (e) { expect(e, isA<UnauthenticatedError>()); expect(e.message, i18n.text .didw__common_error_auth_quit_description); } 通信だけを差し替え、実際の変換処理を確かめる 43 -
② 状態とUIの整合 処理中の表⽰を、どの状態に合わせるか ⽐較する点:Appのloadingを表⽰‧終了する⽅法 旧iOS:共通の通知からローディング表⽰を操作 // loadingOverlayの通知内 if show {
me.loadingHUD.show() } else { me.loadingHUD.hide() } 旧Android:Pause中の操作を保留 fragmentHandler.post { progress.show(fm, tag) } Flutter:機能のStateから表⽰ final state = ref.watch( verifyConsentViewModelProvider); // StateをScreenへ渡す(省略) // ScreenのStack内 if (verifyConsentViewEntity.isLoading) const CommonLoading(), 表⽰と実⾏の条件を、同じStateにそろえる 45 -
② 状態とUIの整合 SDKとApp API、必要な結果がそろうまで待つ 具体例:開始IDの通知と、SDKの最終結果が別々に届く Flutter ViewModel SDK App API
❶ ID監視後、SDKを起動 ❷ 開始ID(EventChannel) ❸ Appの開始API ❹ 最終結果(MethodChannel) 開始APIの終了を待つ ❺ 応答とApp側ID ❻ App側IDで完了API SDKとAPIの終了を待ち、App側で次の処理を決める 46 -
② 状態とUIの整合 待つ条件を、ViewModelのコードへ置く 共通フローの具体例:開始IDの通知と、SDKの最終結果 開始IDの通知を受ける処理 final result = await _startAccountVerificationUseCase.invoke(
verificationId: id, accountVerificationPolicyId: policyId); if (result.isSuccess) { _userIdentityVerificationId = result.getResult .userIdentityVerificationId; } completer.complete(); SDKの最終結果を受ける処理 // 外側でCompleter<void>()を作成 final sdkResult = await _startSdkUseCase.invoke(); // SDK結果がcompletedの分岐 case CoreEkycResultStatus.completed: await completer.future; await _handleCompletedResult( sdkResult.getResult); 待ち合わせの条件を、⼀つのViewModelに置く 47 -
② 状態とUIの整合 SDK完了と、Appの完了画⾯を分ける 業務の結果をStateへ⼊れ、UIが表⽰を決める ViewModel:起動処理の⼊⼝と終了時 if (state.isLoading) return; state =
state.copyWith(isLoading: true); try { // SDKとApp APIの処理(省略) } finally { await verificationIdSubscription ?.cancel(); state = state.copyWith(isLoading: false); } UI:Stateで操作と表⽰を合わせる 処理中 isLoading 完了API成功 isCoreEkycComplete 失敗の案内 errorMessage CommonLoading 登録完了画⾯ エラー案内 Appの成功をStateへ反映してから、完了画⾯へ進む 48 -
③ 通信リトライ戦略 旧iOS‧Androidと、提出完了処理のリトライ OSごとに違った再試⾏と、Flutter版の業務ルール 旧iOS:最終提出の試⾏数を指定 .executeToCompleteAllSubmission() .retry(maxAttemptCount) // maxAttemptCount =
5 旧Android:通信層で固定待機 Thread.sleep(WAITING_TIMER_SECOND) call.clone().enqueue(this) // 1秒ずつ、最大3回の再試行 Flutter:完了UseCaseで判断 } on NetworkError catch (e) { if (_retryManager.canRetry()) { await Future<void>.delayed( _retryManager.duration); return invoke(verificationId); } else { return UseCaseResult(failure: e); } } 再試⾏の条件はUseCase、待ち時間はRetryManager 50 -
③ 通信リトライ戦略 失敗した提出完了APIから、⼿続きを続ける 新設計の適⽤例:事業者への提出の完了API 通信エラー 担当:UseCase NetworkError 上限内で待つ 完了APIを再実⾏ 待ち時間:RetryManager
Appの認証エラー 担当:ViewModelとUI 認証エラー ユーザーが再認証 完了APIを再実⾏ 撮影を繰り返さず、失敗したApp APIから再開する 51 -
設計の学び 三つの変更と、その効果 設計 旧コードで⾒る点 Flutter版で変えた判断 効果 ① エラー エラーの分類と案内を OSごとの部品に実装
共通の変換関数で整理し 未知エラーもAppErrorへ 実際の変換を 通信‧画⾯なしで確認 ② 状態とUI iOS:通知で表⽰を操作 Android:Pause中は保留 Stateで表⽰‧実⾏条件と 完了後の遷移を判断 Appの表⽰を共通化 App完了後に画⾯へ ③ リトライ iOS:試⾏数を指定 Android:固定1秒‧3回 完了UseCaseが対象を判断 待ち時間は別の部品で計算 原因に合う⼿順で 完了APIから再開 Appの判断を共有し、⼀つの改善を両OSへ届ける 52 -
03 ユーザーの声 落ちる‧やり直しの不満が減った AppとSDKの改善後、レビュー中の指摘件数を⽐較 最初からやり直し(Android) 旧版(2020〜2024年) 15件 新版(2025〜2026年) 2件 新版(2025〜2026年)
0件 カードを読み込めた直後に落ちる(iPhone) 旧版(2020〜2024年) 4件 AppとSDKの継続改善が、ユーザー体験を⽀える 54