採用はこちら!

Shinonome Tech Blog

株式会社Shinonomeの技術ブログ
5 min read

webview_flutterの動きがOSによって違う!

webview_flutterに実装されているonPageStarted, onPageFinished, onUrlChangeの動きがOSごとにどう異なるか調べました。

忙しい方向け

Q. iosでonPageFinishedが呼ばれない
A. URLが無効な場合は呼ばれない。用途によるがonUrlChangeやonWebResourceErrorを使うと代用できるかも。OSごとに動きが違う可能性を頭に入れておくべし。

onUrlChange: (UrlChange change) {
	debugPrint('url change to ${change.url}');
},
onWebResourceError: (WebResourceError error) {
	debugPrint('Page resource error: ${error.url}');
},

はじめに

アドベントカレンダー22日目!playground所属、学部4年生のko_chaです。参加したハッカソンの中でお世話になった(苦しめられた)webview_flutterについて書きます。
https://pub.dev/packages/webview_flutter

Tokyo Flutter Hackathon

11/2,3開催@Abema Towers
https://tokyo-flutter-hackathon.connpass.com/event/326665/

チームメンバーのアドベントカレンダー
バックエンド担当のTatsuroさん
https://blog.shinonome.io/dong-jing/

同じくモバイル担当のyoさん
25日担当です!
他の学生の記事もぜひご覧ください。
https://qiita.com/advent-calendar/2024/playground

それでは早速本題に入ります!

ios版のみアクセストークン取得ができない

やりたかったこと:onPageFinishedが呼ばれた時にクエリからトークンを取得

しかしなぜかios版だけonPageFinishedが呼ばれないという現象に苦しめられました。

調べている内に「URLが無効だとonPageFinishedが呼ばれない」というissueを見つけたので、これが原因だと仮定して再現してみました。(かなり古いissue)
https://github.com/flutter/flutter/issues/74987

再現

使用したコードは最後に載せています。
ログイン時のリダイレクトは短縮URLを作るサービスbitlyを用いて再現しました。
アクセスしたら"https://flutter.devv/" という存在しないページに飛ばされるようにしています。

PCブラウザで開くとこうなります。ちゃんと(?)無効

余談ですがhttps://www.flutter.de/は存在するようです笑

実際の動き

Android

I/flutter (16437): Page started loading: https://flutter.devv/
I/flutter (16437): Page finished loading: https://flutter.devv/

ios

flutter: Page started loading: https://bit.ly/49Wjv5i

android版だとonPageStartedの時点でリダイレクト後のURLが表示される⭐️android版ではエラーに関係なくonPageFinishedが呼ばれる。
⭐️ios版だとエラーが起きた際にはonPageFinishedが表示されない。

対策

対策としては以下の2つを提案します。

  1. onUrlChangeを使い、エラー処理を待たずURLを取得する方法
  2. onWebResourceErrorを使い、(Android版と同じように)エラーが起きていたとしてもfinish扱いで処理する
onUrlChange: (UrlChange change) {
	debugPrint('url change to ${change.url}');
},
onWebResourceError: (WebResourceError error) {
	debugPrint('Page resource error: ${error.url}');
},

どちらのOSでも検知できるので、今回のようにとりあえずクエリを取りたいだけであれば有用かと存じます。

対策後の動き

Android

I/flutter (16437): Page started loading: https://flutter.devv/
I/flutter (16437): Page resource error: https://flutter.devv/ ←追加分
I/flutter (16437): Page finished loading: https://flutter.devv/
I/flutter (16437): url change to https://flutter.devv/ ←追加分

ios

flutter: url change to https://bit.ly/49Wjv5i ←追加分
flutter: Page started loading: https://bit.ly/49Wjv5i
flutter: url change to https://flutter.devv/ ←追加分
flutter: Page resource error: https://flutter.devv/ ←追加分
flutter: url change to null ←追加分

ちょっと深掘り

ご興味があればお付き合いください。

そもそもwebview_flutterの中身って?

A Flutter plugin that provides a WebView widget.On iOS the WebView widget is backed by a WKWebView. On Android the WebView widget is backed by a WebView.

とあるように、実は裏側では全く別のものが動いているようです。
それぞれの呼び出されるタイミングを少し調べてみましょう。

webview_flutter_androidの裏側

webview_flutterとAndroidネイティブのWebViewがどう繋がっているか見ていきます。
①アンドロイド版のwebviewを司るパッケージ

webview_flutter_android | Flutter package
A Flutter plugin that provides a WebView widget on Android.

このパッケージが、②PlatformViewのTLHCというモードを使ってAndroidネイティブUIを表示しています。

PlatformViewについてはこちらの記事が分かりやすかったです。


③Dart側にurl等の情報を送信: AndroidWebkitLibrary.g.kt

fun onPageStarted(
      pigeon_instanceArg: android.webkit.WebViewClient,
      webViewArg: android.webkit.WebView,
      urlArg: String,
      callback: (Result<Unit>) -> Unit
  ) {
    if (pigeonRegistrar.ignoreCallsToDart) {
      callback(
          Result.failure(
              AndroidWebKitError("ignore-calls-error", "Calls to Dart are being ignored.", "")))
      return
    }
    val binaryMessenger = pigeonRegistrar.binaryMessenger
    val codec = pigeonRegistrar.codec
    val channelName = "dev.flutter.pigeon.webview_flutter_android.WebViewClient.onPageStarted"
    val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
    channel.send(listOf(pigeon_instanceArg, webViewArg, urlArg)) {
      if (it is List<*>) {
        if (it.size > 1) {
          callback(
              Result.failure(
                  AndroidWebKitError(it[0] as String, it[1] as String, it[2] as String?)))
        } else {
          callback(Result.success(Unit))
        }
      } else {
        callback(Result.failure(createConnectionError(channelName)))
      }
    }
  }

④呼び出し元: WebViewClient

WebViewClientクラスを継承したWebViewClientImplクラスがネイティブのWebViewから状態の変更を受け取っています。

WebViewClientImplクラスのonPageStarted関数をオーバーライドし、dart側に送信する動きに書き換えてあります。

public class WebViewClientProxyApi extends PigeonApiWebViewClient {
  /** Implementation of {@link WebViewClient} that passes arguments of callback methods to Dart. */
  @RequiresApi(Build.VERSION_CODES.N)
  public static class WebViewClientImpl extends WebViewClient {
    private final WebViewClientProxyApi api;
    private boolean returnValueForShouldOverrideUrlLoading = false;

    /**
     * Creates a {@link WebViewClient} that passes arguments of callbacks methods to Dart.
     *
     * @param api handles sending messages to Dart.
     */
    public WebViewClientImpl(@NonNull WebViewClientProxyApi api) {
      this.api = api;
    }

    @Override
    public void onPageStarted(@NonNull WebView view, @NonNull String url, @NonNull Bitmap favicon) {
      api.getPigeonRegistrar()
          .runOnMainThread(() -> api.onPageStarted(this, view, url, reply -> null));
    }

    @Override
    public void onPageFinished(@NonNull WebView view, @NonNull String url) {
      api.getPigeonRegistrar()
          .runOnMainThread(() -> api.onPageFinished(this, view, url, reply -> null));
    }

~~~
中略
~~~

  }
 }

このようにAndroid版でonPageStarted,onPageFinishedが呼ばれるタイミングはネイティブのWebViewClientクラスに由来するので、タイミングもネイティブと同じだと考えられます。

ios版も少しだけ

先ほど検証したFlutterでの動きと同じように、ページ読み込みに失敗した場合は終了扱いにならないようです。(didFinishが呼ばれない)
https://stackoverflow.com/questions/71619574/wkwebview-does-not-call-didfinish-delegate-method

ios版の内部実装が気になる方は、こちらからご確認ください。
https://github.com/flutter/packages/tree/main/packages/webview_flutter/webview_flutter_wkwebview

結論

簡単にまとめるとこんな感じでしょうか。

Android ios
onPageStarted URL: リダイレクト後 URL: リダイレクト前
onPageFinished エラー発生時も実行
URL: リダイレクト後
エラー発生時は実行されない
onUrlChange URL:リダイレクト後のみ URL: リダイレクト前、後、エラー時はnullが入る

まとめ

「クロスプラットフォーム」が当たり前ではないことを実感できた良い機会でした。
ただ割と見つけやすい差異だったので、未だにこちら側のミスを疑っています。
他にもissueを立てていた人がいたので違うと信じたいですが、、

3年前のissueが直されていないということは、こういう仕様で決定なのかも...?

何はともあれパッケージを作ってくださった作者様に感謝。
いつかOSSにPRを出せたら最高ですね。

明日は当団体の大エース@TAK848さんです!

サンプルコード

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

const requestUrl = 'https://bit.ly/49Wjv5i';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const WebViewExample(),
    );
  }
}

class WebViewExample extends StatefulWidget {
  const WebViewExample({super.key});

  @override
  State<WebViewExample> createState() => _WebViewExampleState();
}

class _WebViewExampleState extends State<WebViewExample> {
  late final WebViewController controller;

  @override
  void initState() {
    controller =
        WebViewController()
          ..setNavigationDelegate(
            NavigationDelegate(
              onPageStarted: (String url) {
                print('Page started loading: $url');
              },
              onPageFinished: (String url) {
                print('Page finished loading: $url');
              },
              onUrlChange: (UrlChange change) {
                debugPrint('url change to ${change.url}');
              },
              onWebResourceError: (WebResourceError error) {
                debugPrint('Page resource error: ${error.url}');
              },
            ),
          )
          ..loadRequest(Uri.parse(requestUrl));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: WebViewWidget(controller: controller));
  }
}

動作環境

webview_flutter: ^4.8.0
flutterバージョン

$ flutter --version
Flutter 3.28.0-0.1.pre • channel beta • https://github.com/flutter/flutter.git
Framework • revision 3e493a3e4d (9 days ago) • 2024-12-12 05:59:24 +0900
Engine • revision 2ba456fd7f
Tools • Dart 3.7.0 (build 3.7.0-209.1.beta) • DevTools 2.41.0