SDKがない?」Flutter WebViewでPG決済を実装し、外部アプリ連携地獄を解決した話

プロジェクトの納期まであと2週間という段階で、クライアントが選定した国内PG(Payment Gateway)には、Flutter用のSDKが存在しなかった。あるのはWeb用のJavaScript SDKと、古びたドキュメントだけだ。多くの開発者がここで絶望するが、我々のチームはWebView(ウェブビュー)を使ってこれを解決することにした。しかし、単純にURLをロードするだけでは、PayPayやLINE Payといった外部アプリへの遷移(Deep Link)や、3Dセキュアの認証フローで必ず躓くことになる。本記事では、実際に本番環境で稼働している「SDKなしでの決済連携」の完全な実装パターンを共有する。

Deep Dive Analysis: なぜ標準WebViewで決済が失敗するのか

Flutter公式の webview_flutter パッケージを使用すれば、Webベースの決済画面を表示すること自体は容易だ。しかし、決済プロセスにおいて致命的な問題が発生するのは、ユーザーが「支払う」ボタンを押した瞬間である。

多くのモダンなPGは、決済アプリ(PayPay, 楽天ペイなど)を起動するために、`http://` や `https://` ではないカスタムURLスキーム(例: `paypay://`, `intent://`)を使用する。標準のWebView実装はこれらのプロトコルを理解できず、`ERR_UNKNOWN_URL_SCHEME` エラーを吐いてクラッシュするか、単にホワイトアウトする。

エンジニアのための注釈: Android 11 (API Level 30) 以降では、外部アプリを起動するために `AndroidManifest.xml` の `<queries>` 要素でパッケージの可視性を明示的に宣言する必要がある点も忘れてはならない。

この問題を解決するには、WebViewのナビゲーションリクエストをインターセプト(傍受)し、HTTP以外のリクエストが来た場合にOSのネイティブ機能(IntentやURL Scheme)へハンドリングを委譲する必要がある。これが、SDKなしで堅牢な決済システムを構築するための唯一の解だ。

The Solution: NavigationDelegateによる完全制御

解決策は、`NavigationDelegate` の `onNavigationRequest` コールバック内にロジックを集中させることだ。ここでは、url_launcher パッケージを併用して外部アプリへのブリッジを作成する。

以下は、実際に私が本番環境で使用した修正版のコードスニペットである。

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart';

class PaymentWebView extends StatefulWidget {
  final String paymentUrl;
  const PaymentWebView({Key? key, required this.paymentUrl}) : super(key: key);

  @override
  State<PaymentWebView> createState() => _PaymentWebViewState();
}

class _PaymentWebViewState extends State<PaymentWebView> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    
    // WebViewControllerの初期化と設定
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          // ページの読み込み開始時
          onPageStarted: (String url) {
            debugPrint('Page started loading: $url');
          },
          // 【重要】ナビゲーションリクエストの制御
          onNavigationRequest: (NavigationRequest request) async {
            final Uri uri = Uri.parse(request.url);

            // 1. http/httpsの場合は通常通りWebView内で遷移させる
            if (uri.scheme == 'http' || uri.scheme == 'https') {
              return NavigationDecision.navigate;
            }

            // 2. それ以外のカスタムスキーム(paypay://, line:// など)の場合
            // 外部アプリとしての起動を試みる
            if (await canLaunchUrl(uri)) {
              await launchUrl(
                uri,
                mode: LaunchMode.externalApplication, // 外部アプリで開く
              );
              // WebView内の遷移はブロックする(エラー回避)
              return NavigationDecision.prevent;
            }

            // 3. AndroidのIntentスキームなどの特殊ケース処理(必要に応じて実装)
            // ここでフォールバック処理を入れることも可能
            debugPrint('Could not launch ${request.url}');
            return NavigationDecision.prevent;
          },
        ),
      )
      ..loadRequest(Uri.parse(widget.paymentUrl));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('決済画面')),
      body: WebViewWidget(controller: _controller),
    );
  }
}
注意: iOSの場合、`Info.plist` に `LSApplicationQueriesSchemes` を追加し、起動したいアプリのスキーム(例: `paypay`, `line`)を許可リストに登録する必要がある。これを忘れると `canLaunchUrl` は常に false を返す。
シナリオ デフォルト挙動 修正後の挙動
通常のクレカ入力画面 表示される 表示される
PayPayアプリ起動 エラー (net::ERR_UNKNOWN_URL_SCHEME) 成功 (アプリが起動)
3Dセキュア認証 一部銀行でブロックされる可能性あり 正常に遷移

この実装により、PG側が「アプリ決済」を選択した場合でも、WebViewが適切にハンドリングを放棄し、OS側に処理を渡すフローが確立された。これは、特定のPGに依存しない汎用的なアプローチである。

Conclusion

Flutter専用のSDKが提供されていないからといって、そのPGの採用を諦める必要はない。WebViewと適切に構築された `NavigationDelegate` があれば、ほぼ全ての決済フローをネイティブアプリ内で完結させることが可能だ。

重要なのは、WebViewを単なる「ブラウザ」として扱うのではなく、アプリと外部サービスを繋ぐ「制御可能なゲートウェイ」として設計することだ。次回、クライアントが無茶なPG指定をしてきたときは、このコードスニペットを思い出してほしい。

WebView Flutter 公式ドキュメントを確認する

Post a Comment