パルカワ2

最近はFlutterをやっています

より良いFlutterの画面遷移を考える

Flutterの画面遷移について考えたのでメモ。 現状このようにして遷移している。

Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => HogeScreen(keyword: "..."),
));

このやり方だと settings.name が設定されていないので、FirebaseAnalyticsにログを送るときになんの画面なのか判別できないためログが送られない

settings.name を設定する

FirebaseAnalyticsObserverを利用して画面遷移のログを送信したい場合、settings.nameがnullだとログが送られない。 FirebaseAnalyticsObserver class - observer library - Dart API

ログを送るためには以下のようにsettings.name を設定するか、pushNamed を使う.

Navigator.of(context).push(MaterialPageRoute(
  settings: RouteSettings(name: "HogeScreen"),
  builder: (_) => HogeScreen(keyword: "..."),
))

pushNamed を使う

pushNamed は以下のように使い、画面遷移のコードを何度も書く必要がないというメリットもある。 pushNamed method - Navigator class - widgets library - Dart API

Navigator.of(context).pushNamed("HogeScreen", arguments: "...")

しかし、pushNamedには以下のような問題もあると思った。

  • route name を String で渡しそれを利用してRoute(またはWidget)を作るので、うっかりミスが起きやすい
  • settings.arguments 経由で値を渡すことになるので、型がObjectになり利用するときにcastする必要がありうっかりミスが起きやすい
  • route nameだけでは arguments が必要かどうかわからないので、うっかりミスが起きやすい

人間のうっかりミスが起きやすく、コンパイルエラーではなく実行時エラーになるため最悪気づかないことがある。

route メソッドをつくる

pushNamedは画面遷移のコードを何度も書く必要がないという点では便利だけど、うっかりミスも起きやすいと感じた。 それを防ぐためにはこのようにRouteを生成するメソッドを書いてあげればよいのではと考えた。

extension HogeScreenRoutes on HogeScreen {
  static Route<dynamic> route({@required this.keyword})) {
    return MaterialPageRoute(
      settings: RouteSettings(name: "HogeScreen"),
      builder: (_) => HogeScreen(keyword: "..."),
    )
  }
}
Navigator.of(context).push(HogeScreen.route());

こうすればうっかりタイポなどをしても実行したタイミングでエラーになるのではなくコンパイルのタイミングでわかるので、最高。 ただし、これにもうっかりミスが起きる場所がある。HogeScreenに渡す値が増えたときだ。

class HogeScreen {
  final String keyword;
  final String fuga; // 増えた
  HogeScreen({Key key, @required this.keyword, @reuqired this.fuga});
}

extension HogeScreenRoutes on HogeScreen {
  static Route<dynamic> route({@required String keyword})) {
    return MaterialPageRoute(
      settings: RouteSettings(name: "HogeScreen"),
      builder: (_) => HogeScreen(keyword: keyword), // fugaが渡されていない
    )
  }
}

@required をしていてもコンパイルエラーにならない。lintを動かせばエラーにすることはできる。 複数箇所変更するのが嫌ならコンストラクタからrouteメソッドを自動生成すればいいかなと思ったけどそこまでやらなくていいかなと思ってここで考えるのをやめた。 もっといい方法があるなら知りたい。

追記: こうやれば一応一箇所の変更ですむねって話を同僚としたけど、やりすぎ感あるのでやめておいた。

class HogeScreenArguments {
  final String keyword;
  final String fuga;
  HogeScreenArguments({Key key, @required this.keyword, @required this.fuga});
}

class HogeScreen {
  final HogeScreenArguments arguments;
  HogeScreen({Key key, @required this.arguments});
}

extension HogeScreenRoutes on HogeScreen {
  static Route<dynamic> route({@required HogeScreenArguments arguments})) {
    return MaterialPageRoute(
      settings: RouteSettings(name: "HogeScreen"),
      builder: (_) => HogeScreen(arguments: arguments),
    )
  }
}

カリギュラを見た

妹が死んでキレた皇帝の話です。
顔の良い男たちが出ているからかお客さんのほとんどが女性だった…すごい…
もっとめちゃくちゃな暴れん坊か?と思ってたけど、お笑い要素もあってちょっと意外だったんだけど、インタビューでも「みんなが思ってるカリギュラではない」って言ってた。

www.youtube.com

FlutterでWebViewをがんばる

Flutter + WebView

最近FlutterのWebViewを触るようなことをやっている。 WebViewのプラグインにはいくつか実装があるが、大きく分けるとするとPlatformViewを使わないか使うかによって分けれる。

PlatformView を使わないか使うか

PlatformViewを使わないとネイティブのViewをFlutterWidgetの上に被せて表示する実装になるので、FlutterのWidget(SnackBarなど)をWebViewの上にかぶせるなどができない。また大きさの変更もContainerなどのWidgetを利用して変更できない。launchするときにrectを指定すれば大きさの変更はできる。さらに1画面に1個のWebViewしか利用できない。このように制限はあるが、PlatformViewを経由しないため、それなりに動く。(キーボードが出てきてもViewの大きさが変わらない問題(仕様?)はある)

PlatformViewを使うとFlutterのWidgetとして扱えるので、WebViewの上にSnackBarを出したりWebViewの大きさをContainerに囲んで変更したり複数のWebViewを表示したり制限なく使える。しかし、後述するがAndroidのキーボードまわりでバグにぶちあたる。

PlatformView を使う

今回の要件的には上記のような制限を受けたくないので、PlatformViewを利用する選択をした。 PlatformViewを利用するプラグインは探せばたぶん無限にあるけど、この2択っぽい。

webview_flutterに関しては、公式のプラグインで安心感がある。利用者も多く大体の問題はイシューになっている。ただあまり実装は進んでおらず、onPageStartedなどのコールバックもほとんど実装されていないようだった。(プルリクはあるが… #1389, #1788

flutter_inappbrowser のほうはCookieManagerやコールバックもある。ただ、リリースはされてないが2.0.0でAPIが大きく変わりそう。また、大きな実装だなという印象で、InAppWebView.javaの実装を見るとJSがめっちゃ書かれていて、それって我々に必要なのか?みたいな気持ちになる。

今回は、公式のwebview_flutterで確認することにする。

webview_flutterを使ってぶちあたった壁

webview_flutterを利用する選択をしたはいいが、触っていたところAndroidのキーボードまわりで結構バグっていることに気づいた。全部イシューになっているけど、WebView起因というよりはPlatformViewが原因なのが多い気がしていて、シュッとは直せなさそうだと思った。 自分が遭遇しただけでも以下がある。

webview_flutter Keyboard persists after tapping outside text field · Issue #36478 · flutter/flutter · GitHub

TextFieldをタップしてキーボードを表示したあとTextField以外をタップしてもキーボードが出っぱなし。

WebView’s text selection dialog is not responding to touch events · Issue #24585 · flutter/flutter · GitHub

TextFieldをタップしてもコピーなどの項目が表示されない。 Flutter 1.9以降で動かないようになったらしく、なおしてるところとのこと。 https://github.com/flutter/flutter/issues/24585#issuecomment-513950478

WebView’s text selection handles are not showing on Android · Issue #24584 · flutter/flutter · GitHub

TextFieldだけではなくウェブページのテキストをコピペしようと長押ししても動かない。 そもそもLongTapが無効化されている。 https://github.com/flutter/plugins/blob/f31d16a6ca0c4bd6849cff925a00b6823973696b/packages/webview_flutter/lib/src/webview_android.dart#L31

webview_flutter Keyboard suggestions can be lost · Issue #37989 · flutter/flutter · GitHub

日本語を打つと強制的に確定されてしまい、サジェストが出ずキーボードが英字に変わってしまう。 Chromiumが原因かも?と言っている。ネイティブアプリでWebViewを使ってみてもならないので違うような気もするけどよくわからず。

サイズ変更が原因らしいので、サイズを変更しなければ起きない。 FlutterがつくるAndroidアプリは、キーボードを出すとViewの大きさを変える設定になっているので、それをやめればよい。AndroidManifest.xmlandroid:windowSoftInputMode="adjustSize"android:windowSoftInputMode="adjustPan"に変える。

ただ、この設定をするとFlutterで作った画面すべてに影響し(つまりWebViewを利用していない画面でキーボードが出てもViewのサイズは変更されない)、Viewの大きさが変わらないとキーボードを出した状態で一番下までスクロールできなくなるため、あまり現実的な手段ではないと思う。

この問題は、flutter_inappbrowserだと実装が違いそうなので、動かして確認したところそもそも文字がなにも入力できなかったので確認できなかった………。

webview_flutter Soft keyboard not appearing on Q · Issue #38375 · flutter/flutter · GitHub

Android Qでキーボードが出なかった。beta channelだとなおっているのを確認した。

Text input on Q platform views results in log spam · Issue #40716 · flutter/flutter · GitHub

#38375をなおした影響でAndroid Qでログが出まくる。 Q未満は出ないので、Q未満でデバッグすることにした。

autofillが効かない?

動かないか確認できていないけど、ログが出てる。

W/cr_AwAutofillManager( 2953): WebView autofill is disabled because WebView isn't created with activity context.

ただPlatformViewは、VirtualDisplayを使うことで実現していてそのためには android.app.Presentation を使う必要があるっぽくActivityのContextを利用するのは無理なのではと自己完結した。

まとめ

FlutterでWebViewを使いたいが、Androidのキーボードまわりで困っている。数カ月後にはなおってるかもしれないし、なおってないかもしれない。 せっかくFlutterを利用するのであれば、ネイティブでAndroid/iOSを書きたくないという思いがあるが、ネイティブで書くのがよいという決断をするかもしれない。

ちなみにiOSでは結構まともに動いていて「AndroidとFlutterは同じ会社が作ってるのでは…?」と思うなどした(大変そう)

ノンデザイナーズ・デザインブックを読んだ

ノンデザイナーズ・デザインブック [第4版]

ノンデザイナーズ・デザインブック [第4版]

なんとなく認識しているようなことを言語化されていてよかった。

Android Studio 3.5にあげた

CIではテストが通るんだけど、手元では通らなくなってしまったので困っていた。

2019-09-25 15:29:48:978 (KOIN)::[e] Error while resolving instance for class 'com.google.gson.Gson' - error: org.koin.error.BeanInstanceCreationException: Can't create definition for 'Single [name='Gson',class='com.google.gson.Gson']' due to error :
2019-09-25 15:29:48:978 (KOIN)::[i] [Close] Closing Koin context
		com.google.protobuf.Internal and com.google.protobuf.Internal$ProtobufList disagree on InnerClasses attribute
		java.lang.Class.getDeclaringClass0(Native Method)
		java.lang.Class.getDeclaringClass(Class.java:1235) 

org.koin.error.BeanInstanceCreationException: Can't create definition for 'Single [name='Gson',class='com.google.gson.Gson']' due to error :
		com.google.protobuf.Internal and com.google.protobuf.Internal$ProtobufList disagree on InnerClasses attribute
		java.lang.Class.getDeclaringClass0(Native Method)
		java.lang.Class.getDeclaringClass(Class.java:1235)



onPageFinished が実行されないときがある

Android の WebViewClient の onPageFinished が実行されないときがあるので困った。 onPageFinished は 2回実行されることもあるので、なかなかの困ったちゃんである。

onPageFinishedが実行されないときも onPageStarted は実行されるので、onPageStartedのときにすでに document.readyStateが loading 以外なら実行. loading なら監視してStateが変わったら実行という形にした。 onPageFinished が実行されないときは、 readyState が complete になってないようなので、WebViewが悪いというよりサイトが原因なんだろうと思う(しかし、すべてのサイトに修正を加える力は我々にはない………)

Moneytreeで未対応のTHEOを勝手に対応する

Moneytreeは現金とかタンス預金用に手動入力のみの「その他の口座」項目がある。
それは手動でしか入力できないんだけど、チョットプログラム書けば未対応のTHEOも自動で入力できるじゃんと思ったのでやってみた。puppeteer便利すぎ

流れ

  • THEOにログイン
  • THEOで表示されてる額を取得
  • Moneytreeにログイン
  • つくっておいた口座に額を追記

これをなんらかの方法で定期実行すればよい

実装

こういう感じ

import * as puppeteer from 'puppeteer';
import {Page} from "puppeteer";

class Theo {
    public static get username(): string { return ""; }
    public static get password(): string { return ""; }
}

class Moneytree {
    public static get username(): string { return ""; }
    public static get password(): string { return ""; }
}


class LoadTheoMarketPrice {
    async execute(page: Page): Promise<string> {
        await page.goto('https://app.theo.blue/account/login');

        await page.type("input[name=email]", Theo.username);
        await page.type("input[name=password]", Theo.password);
        await page.tap("#app > section > section > form > div > button");
        await page.waitForNavigation();

        return await page.$eval("span.number-value", (span) => span.textContent.trim());
    }
}

class SaveTheoMarketPrice {
    async execute(page: Page, price: string) {
        await page.goto('https://app.getmoneytree.com/login');

        await page.type("input[name=email]", Moneytree.username);
        await page.type("input[name=password]", Moneytree.password);
        await page.tap("#app > div > div > div.onboardingPageBody > form > div > button");
        await page.waitForNavigation();

        // 口座残高を表示
        await page.goto("https://app.getmoneytree.com/app/vault");
        await page.waitFor(3000);

        // THEOの項目を表示/人によって違うよ
        await page.tap("div > ul > li:nth-child(4) > div.row.institution-header > a");
        await page.tap("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.left.sidebar > div:nth-child(2) > left-column-body > mt-credentials > div > ul > li:nth-child(4) > div:nth-child(2) > ul > li.ng-scope > div > mt-credential > div > mt-credential-template > div > div.credential-accounts > div.credential-body > mt-list-secondary > div > ul > li:nth-child(2)");
        await page.waitFor(3000);

        // 編集ダイアログを表示
        await page.tap("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.center.content > mt-awesome-layout > div > div > div:nth-child(1) > awesome-body > div > right-column-body > div:nth-child(2) > div.ng-scope > mt-manual-account-balances > div > div.current-account-balance-header > span:nth-child(2) > a");

        // 保存されてる内容を消す
        await page.$eval("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.center.content > mt-awesome-layout > div > div > div:nth-child(1) > awesome-body > div > right-column-body > div:nth-child(2) > div.ng-scope > mt-manual-account-balances > div > div.current-account-balance-header > span:nth-child(2) > mt-manual-account-balances-action > div > div:nth-child(5) > mt-amount-input > div > div.pull-right > input",
            value => (value as HTMLInputElement).value = "");

        // priceを入力
        await page.type("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.center.content > mt-awesome-layout > div > div > div:nth-child(1) > awesome-body > div > right-column-body > div:nth-child(2) > div.ng-scope > mt-manual-account-balances > div > div.current-account-balance-header > span:nth-child(2) > mt-manual-account-balances-action > div > div:nth-child(5) > mt-amount-input > div > div.pull-right > input",
            price);

        // + にする
        await page.tap("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.center.content > mt-awesome-layout > div > div > div:nth-child(1) > awesome-body > div > right-column-body > div:nth-child(2) > div.ng-scope > mt-manual-account-balances > div > div.current-account-balance-header > span:nth-child(2) > mt-manual-account-balances-action > div > div:nth-child(5) > mt-amount-input > div > div.pull-left.ng-scope > div > i");

        // 更新する
        await page.tap("#mt-webapp > section > mt-webapp-layout > div > div.content.ng-scope > mt-accounts > div > mt-accounts-personal > div > mt-link-vault > div > mt-two-column-layout > div > div.column.center.content > mt-awesome-layout > div > div > div:nth-child(1) > awesome-body > div > right-column-body > div:nth-child(2) > div.ng-scope > mt-manual-account-balances > div > div.current-account-balance-header > span:nth-child(2) > mt-manual-account-balances-action > div > div:nth-child(7) > a");
        await page.waitFor(3000);
    }
}

exports.saveTheoMarketPrice = () => {
    (async () => {
        const browser = await puppeteer.launch();
        const theo = await browser.newPage();
        let price = await new LoadTheoMarketPrice().execute(theo);
        price = price.replace(",", "");

        const moneytree = await browser.newPage();
        await new SaveTheoMarketPrice().execute(moneytree, price);

        await browser.close();
    })();
};

一応書いておくとこのコードによってなにか起きても責任は取りません。