パルカワ2

最近はFlutterをやっています

Flutter for Web で WebView を使う

native_webviewは、WebViewの機能をWebで提供するのは難しいのでWebには今の所対応していない。だが、対応していないプラットフォームでも自前で実装すれば一応動かせるようにはなっている。(というか0.28.0から動かせるようにした)

// Webのときのみsetする
WebView.platform = AppWebView();

HtmlElementView を使って AppWebView() をつくる。

import 'dart:html';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:native_webview/native_webview.dart';
import 'package:native_webview/platform_interface.dart';

class AppWebView extends PlatformWebView {
  @override
  Widget build({
    @required BuildContext context,
    @required CreationParams creationParams,
    @required String viewType,
    @required PlatformViewCreatedCallback onPlatformViewCreated,
    @required Set<Factory<OneSequenceGestureRecognizer>> gestureRecognizers,
    bool useHybridComposition = true,
  }) {
    if (creationParams.widget.initialData.data.isNotEmpty) {
      return _DataWebView(data: creationParams.widget.initialData.data);
    }
    return _UrlWebView(url: creationParams.widget.initialUrl);
  }
}

class _UrlWebView extends StatefulWidget {
  final String url;

  const _UrlWebView({Key key, this.url}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _UrlWebViewState();
}

class _UrlWebViewState extends State<_UrlWebView> {
  @override
  void initState() {
    ui.platformViewRegistry.registerViewFactory(
      "${WebView.viewType}-iframe-${widget.url}",
      (int viewId) {
        // ignore: unsafe_html
        return IFrameElement()..src = widget.url;
      },
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return HtmlElementView(
      viewType: "${WebView.viewType}-iframe-${widget.url}",
    );
  }
}

class _DataWebView extends StatefulWidget {
  final String data;

  const _DataWebView({Key key, this.data}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _DataWebViewState();
}

class _DataWebViewState extends State<_DataWebView> {
  @override
  void initState() {
    ui.platformViewRegistry.registerViewFactory(
      "${WebView.viewType}-data-${widget.data.hashCode}",
      (int viewId) {
        // ignore: unsafe_html
        return DivElement()..innerHtml = widget.data;
      },
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return HtmlElementView(
      viewType: "${WebView.viewType}-data-${widget.data.hashCode}",
    );
  }
}

このように差し込める形にしておくと対応していないプラットフォームでもライブラリ利用者が対応しようと思えば出来るので便利。

ちなみに dart:ui の platformViewRegistry は mobile でビルドするとエラーになる。import の部分で if (dart.library.html) などを使って出し分けるとビルド時のエラーにはならない。

// app_webview.dart
import 'mobile_app_webview.dart' if (dart.library.html) "web_app_webview.dart";
// mobile_app_webview.dart
import 'package:flutter/src/foundation/basic_types.dart';
import 'package:flutter/src/gestures/recognizer.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:native_webview/platform_interface.dart';
import 'package:native_webview/src/webview.dart';

class AppWebView extends PlatformWebView {
  @override
  Widget build({
    BuildContext context,
    CreationParams creationParams,
    String viewType,
    onPlatformViewCreated,
    Set<Factory<OneSequenceGestureRecognizer>> gestureRecognizers,
    bool useHybridComposition = true,
  }) {
    throw UnimplementedError("not support");
  }
}

しかし、これだとIDEやLintではエラーが出っぱなしなのでignoreする必要がある。Dart 2.12では unsafe_htmlやundefined_prefixed_nameは行単位のignoreができないので、全体でignoreするかファイルをlintの対象外から外す必要がある。

Dart 2.13以降ではどうにか出来るようになりそうなので、それを待ちたい。

analyzer: Introduce cannot-ignore analysis option. · dart-lang/sdk@97cd131 · GitHub

またそもそもpackageに分ける方法もあるがめんどいのでやっていない。 ちなみに universal_ui | Flutter Package というのがあるがメンテナンスされていなさそう+リポジトリが消えているので使えるかは不明。

個人的にはunivarsalではなく dart:ui の web特有のものを切り出したpackageを作るのが一番筋がいい気がしている。誰か頼む。

native_webview の integration test

ライブラリのテストは、ほとんどの場合 unit testでいいと思うのだけど native_webviewはiOSのWKWebViewやAndroid の WebViewに依存しているのでunit testではテストが出来ない。

なので、native_webview は integration test (ほとんど Widget testだけど)を書いていて、それを手元やCirrusCIで動かしている。その時に得た知見を書いていく。

ちなみにintegration test は実行時間が10〜20分くらいでかなり長くなるのだが、CirrusCIは無料でも1時間実行出来たりMacが使えたりとOSSではコスパが異常に良いので使っている。

どのように動かしているか

.cirrus.yml を見るのが早い。

https://github.com/hisaichi5518/native_webview/blob/master/.cirrus.yml

iOS, Androidともに実機でテストはしていなくて、SimulatorやEmulatorを立ち上げてflutter driveでテストを実行している。SimulatorやEmulatorを立ち上げるために行う処理が多いけど、flutter drive自体は簡単に実行できる。

AndroidiOS で動作が違う

しょうがないと言えばしょうがないのだけど、AndroidiOS のWebViewで動作が違うことがある。具体的には、loadUrlを読んだ時にAndroidはShouldOverrideUrlLoadingが呼ばれなかったりするがiOSは呼ばれる。

しょうがないのでテストでは Platfrom.isAndroid とかで分岐したりスキップしたりして対応している。

ちなみにメンテナンスしにくいなと思って一回ファイルを分けたりしていたのだけど、AndroidではテストしてiOSではテストしてなかったみたいなことがあったので、結局まとめて分岐で対応することにした。

実行するたび結果が変わる

Android で loadUrl すると onPageFinished のイベントが2, 3回呼ばれることがあり、それは毎回ではなくて時々そうなる。あまり行儀は良くないなと思いつつも anyOf()というMatcherを使って渡したリストのどれかと同じならテストを成功するということにした。

expect(context.pageFinishedEvents, anyOf(equals([...]), equals([...])));

CI上で動かす時とローカルマシンで動かす時で動作が違う

これが一番ハマった。というかiOSに関しては解決していない。自分のMacで動かすとiOS, Androidともにちゃんと動くのだけど、CIで動かすとなぜか動作が違うことになる。

AndroidだとEmulatorに入っているChromeのバージョンが古いとWebViewの動作が違うので動作が変わるのが原因だった。最新OSのEmulatorはChromeのバージョンが新しいので最新OSのEmulatorを使うのがよい。

iOSは、XcodeのバージョンもOSのバージョンも一緒なのにWebVIewの動作が違うことがあって謎。具体的には、手元ではonPageStartedにイベントが来るのにCI上では来ない。一応、CirrusCIのサポートに問い合わせているのだけど実装が悪い説もあるとは思っていて困っている。

これも anyOf() で対応するしかないかなと思いつつサポート待ち。

SIGSEGV で落ちる

Androidは時々これで落ちる。原因がわからないのでとりあえずRe-Runしている。悲しい。

テストに失敗すると途中で止まる

flutter driveはtargetを1個設定して、そこに記載されているテストを実行する。そして途中でテストが失敗すると途中でflutter drive自体が止まる。

最初は、webview_test.dart, webview_controller_test.dart などファイルごとにテストを作って、それらのテストを1つのtarget fileにまとめて実行していた。その場合、テストに失敗した時に途中で止まってしまうので、テスト実行 → テスト失敗 → 直す → テスト実行 → 別のところが失敗 → 直す → テスト実行 の繰り返しでテストを待つ時間が多かった。

なので、matrixを指定して、それぞれのテストファイルを別に実行するようにした。そのおかげで待つ事が減ったのとある程度並列で実行されるようになった事、あとはRe-Runの影響が小さくなった。

デメリットはテストファイルを追加したときに.cirrus.ymlも更新しないといけないこと。

処理が最後まで終わってからテストする

もともとは、onPageFinishedなどのイベントをStreamに流して、テスト側ではそれをlistenしてイベントが来るたびにテストをしていた。そしてあるイベントに来たタイミングで complete を呼び出して、テストを終了させていた。

この方法は、わざわざwaitする必要がないので比較的早いのだけど、処理の途中でテストが失敗することがあるので、そのあとどうなったのかわからなかったりする。integration test は1回の実行時間が長いので、実行回数をなるべく減らしたい。そのためには1回でどういうイベントが来るのかをすべて把握したかった。

なので、直近では処理が終わっているであろう時間までとりあえず待つ方式に変えた。ただやはり遅いので、waitの方法を変えるかもしれない。

iOSAndroidの両方で同時に動かせない

困っている。もう手元で動かさずにCIで動かす前提にするのがいいのかもしれない。

まとめ

大変だけど書かないといけないときは書くしかない。

ちょっと話は変わってflutter_test の話だけど、Matcherになにがあるのか把握出来ていないのでテストが書きにくい。 TypeScriptではMatcherに何があるのかわかってなくても expect(hoge). とか打つと補完でなにがあるのか一覧で表示されるのでそれっぽいのを選んで書ける。

VSCodeのlaunch.jsonからInteliJのrunConfigurationsを自動生成する

毎日のようにはてなスターのスパムがきます。こんにちは。
仕事で作っているアプリは、Flutterを使っておりかつFlavorがたくさんあってそれぞれのFlavorごとにrunConfigurationsを用意している。また今後Flavorは増える予定である。
同僚には、VSCodeを使ってる人もInteliJを使ってる人もいるので、VSCodeのlaunch.jsonとInteliJのrunConfigurations両方を管理している。
片方追加したらもう片方も自動で追加してくれ〜という気持ちが出てきたので、VSCodeのlaunch.jsonからInteliJのrunConfigurationsを自動生成するようにスクリプトを作った。

launch.json というとJSONファイルっぽいけど、コメントがあったり最後の要素なのにケツカンマがあったりとJavaScriptのObjectっぽい。しょうがないのでevalした。
nodejsでやればいいだけだけど、nodejsに依存させたくなかったのでDartです。
あとは、これをGithub actionsとかで動かしてdiffをcommitするようにすればいい感じになる。

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';

Future<void> main() async {
  final mapping = await _loadLaunchJson();
  final configurations = mapping["configurations"] as List<dynamic>;

  await Directory(".idea/runConfigurations").create(recursive: true);

  for (final config in configurations) {
    final runConfiguration = '''
<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="${config["name"]}" type="FlutterRunConfigurationType" factoryName="Flutter" singleton="false">
    <option name="additionalArgs" value="${(config["args"] as List<String>).join(" ")}" />
    <option name="filePath" value="\$PROJECT_DIR\$/${config["program"]}" />
    <method v="2" />
  </configuration>
</component>
''';

    final filename = (config["name"] as String).replaceAll(" ", "") + ".xml";
    final runConfigurationFile = File(".idea/runConfigurations/$filename");
    await runConfigurationFile.writeAsString(runConfiguration);
  }
}

Future<Map<String, dynamic>> _loadLaunchJson() async {
  final launchJsonFile = File("./.vscode/launch.json");
  final launchJsonBody = await launchJsonFile.readAsString();

  // コメントがあったりケツカンマがあったりとJSONではないので必殺eval
  final uri = Uri.dataFromString(
    '''
    import "dart:isolate";
    void main(_, SendPort port) {
      port.send(${launchJsonBody});
    }
    ''',
    mimeType: 'application/dart',
  );
  final port = ReceivePort();
  await Isolate.spawnUri(uri, [], port.sendPort);

  final dynamic response = await port.first;
  return response as Map<String, dynamic>;
}

1000万年ぶりくらいにはてな記法書いた気がする。

TECH STAND #3 で「10XとFlutter」というタイトルで発表した

久しぶりの外部の勉強会で、しかもFlutterの話をするのは初なのでなかなか緊張した。登壇者にはFlutterの人たちはもちろんReact Nativeの猛者がたくさんいて、僕はReact Nativeのことを全く知らないので話を聞くだけでも面白かった。

TECH STAND #3 クロスプラットフォーム開発 Flutter x React Native - connpass

正直なところ、おおっぴらに発表するときに偽物感という言葉が適切か?と思いながらドキドキしていたのだけど、あとでツイッターとかを見たら「わかる」とか言ってもらえていてちょっとうれしかった。ありがとうございます。あとスライドは、悩んだ話とか困った話が主なんですが、Flutterにはめちゃくちゃ満足していて今後ネイティブで開発することがあったら正直だるいなって思うと思います。それくらい完成度は高いと思います。
ただ、僕は大変なことがない開発はないとも思っているので、大変なことが一切ないとは思わないほうがいいとは思います。これはネイティブでの開発もそう。

パネルディスカッションみたいなのでは、FlutterとReact Nativeを比較して〜みたいな質問が多くて、React Nativeを知らないので答えるのが難しい感じだった。というかパネルディスカッション(というか会話??)、めっちゃ苦手で時々自分でも???みたいな発言をしてしまうのでどうにかなりたい。

散歩と判断

コロナでリモートワークするようになってからまったくと言っていいほど家から出なくなった。
散歩するかな〜でも目的がないとやる気がな〜なんて言って全くしてなかったのだが、2020年もそろそろ終わるしグダグダ言ってやらないのダサいな〜と思ったのでいっちょやっておくかみたいな感じで散歩してきた。(散歩はそんな気合をいれてするものではない)

案外散歩してみると目的なんかいらなかったことに気づいた。
行ったことない道を歩いたり公園をプラプラして、こんなところに花生えてるな〜とか木生えてるな〜とかスマホを触らずにプラプラするだけで楽しい。

意味や目的みたいなのがないと楽しめないとか自分で決めつけてよくなかった。そういえば「見極め判断・妄想判断」という話を4年前にしていた。いい話じゃん

では、また来年

最近のnative_webview

FlutterでWebViewをがんばる その2 - パルカワ2

github.com

前にWebViewの話を書いて、native_webviewというライブラリを自作した話を書いた。
すでにプロダクションで利用していて色々やったけど、最近だとBasic認証がかかっててもアクセスできるようにしたり、callHandlerを読み込み中でも実行できるようにしたりした。

inappwebviewや前のnative_webviewはすべての読み込みが完了するまで、callHandlerが実行できない。
すべての読み込みというのは、画像なども含むので、大きい画像やレスポンスを返さない通信が残っていると長時間JSを実行できない問題があった。
なので、callHandlerをはやいタイミングでも実行できるように変更した。

あとFlutter 1.20でAndroidでもhybrid compositionを使えるようになったので、それに対応してAndroid+WebViewでのキーボードまわりの問題を全解決や!と思ってたけど、今のFlutter 1.20でflutter driveを動かすとすべてのテストがこけてしまうのでマージはやめておいた。公式のwebview_flutterでも1.22リリースまで待つらしいので、native_webviewも待つことになると思う。

[webview_flutter] Add new entrypoint that uses hybrid composition on Android by bparrishMines · Pull Request #2883 · flutter/plugins · GitHub
Use PlatformViewLink by hisaichi5518 · Pull Request #79 · hisaichi5518/native_webview · GitHub


hybrid compositionを使うとAndroid 8とかでfpsがガクッと下がる問題がある。ただ自分が使う用途ではWebViewを小さくして表示してスクロールすることはないのであんまり関係なさそうだなと感じている。ちなみにAndroid 10では解決できるAPIがありそれを利用しているので問題は起きないとのこと。たしかにAndroid 8では再現したがAndroid 10では再現しなかった。

Bad hybrid composition performance on Android · Issue #62303 · flutter/flutter · GitHub

あとはe2eがintegration_testに名前が変わっていたので追従した。

今後は、Android WebViewでPDFが表示できないのでダウンロードするとか何らかの方法で対応する予定

finickyを使い始めた

こういうツイートをした。

家用アカウントで会社のGCPにアクセスするとエラーになるので、毎回右上のアカウント切り替えで切り替えてるんだけど、めんどくさいな〜という話。

色々と教えてもらった。みなさん、ありがとうございます!

そのなかで、まさにこれがほしかったという感じのを教えてもらった。

リンクをクリックした時にURLなどで開くブラウザを変えたり、URLを書き換えたりできるアプリたち。

finickyが、JSで設定できるのでUIでポチポチやらなくてよさそうなので使うことにした。
UIポチポチが苦手なんです

Consoleがあるのでテストしやすい。
ドキュメントもあるので書きやすい
Configuration · johnste/finicky Wiki · GitHub

ただ設定ファイルを書き換えたあとにリロードされるときとされないときがあったり、実際にSlackからテストするとConsoleが消えたりとそういうのは不便だった。

実際の設定ファイルはこれ

module.exports = {
  defaultBrowser: "Google Chrome",
  rewrite: [
    {
      match: ({url}) => url.host.includes(".google.com") && (url.search.includes("project=stailer") || url.search.includes("project=tabely")),
      url: ({url}) => {
          return {
              ...url,
              search: url.search + "&authuser=1"
          }
      }
    },
  ],
  handlers: [
  ]
};

これでSlackにアラートがきたときにGCPのリンクをクリックするだけで会社用アカウントで開くようにできた。
いつもエラー画面を見て「めんどくせ〜」と思っていたので助かる。