FlutterでEXCEPTION CAUGHT BY RENDERING LIBRARYが出た時の対応方法

久々Flutter。
今日のお題はEXCEPTION CAUGHT BY RENDERING LIBRARYエラー。
読んで字のごとく、レンダリングに関するエラーです。

例として、こんな画面を考えてみます。

  • スクロールを検知して_currentIndexを設定
  • floatingActionButtonタップで_awesomeListから_currentIndexの要素を消す
    (画面からも_awesomeListウィジェットを削除)
class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List<Widget> _awesomeList = [
    // なんかすごくいいリスト
  ];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいリストを使ってすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          setState(() {
            _currentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          });
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

※ スクロールの度にonNotificationが発火するのでめちゃ重です。

おおむねうまく動作するのですが、_awesomeListの最後の要素を消そうとすると表題のエラーが出てしまい、うまくいきません。

I/flutter (21487): ══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
I/flutter (10000): The following assertion was thrown during performLayout():
I/flutter (10000): Build scheduled during frame.
I/flutter (10000): While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild
I/flutter (10000): the widget tree. This might be because setState() was called from a layout or paint callback. If a
I/flutter (10000): change is needed to the widget tree, it should be applied as the tree is being built. Scheduling a
I/flutter (10000): change for the subsequent frame instead results in an interface that lags behind by one frame. If
I/flutter (10000): this was done to make your build dependent on a size measured at layout time, consider using a
I/flutter (10000): LayoutBuilder, CustomSingleChildLayout, or CustomMultiChildLayout. If, on the other hand, the one
I/flutter (10000): frame delay is the desired effect, for example because this is an animation, consider scheduling the
I/flutter (10000): frame in a post-frame callback using SchedulerBinding.addPostFrameCallback or using an
I/flutter (10000): AnimationController to trigger the animation.

大事なのはここ。

While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild the widget tree.
This might be because setState() was called from a layout or paint callback.

直訳すると、こんな感じ。

ウィジェットツリーが構築・レイアウト・描画されている間に、ウィジェットツリーがリビルドされる新しいフレーム(?)がスケジュールされました。
これはsetState()がレイアウトまたは描画コールバックから呼ばれたせいかもしれません。

※ Flutterのウィジェットツリーをまだ理解していないのですが、こちらに詳しいです。

今回おそらくこんな流れでエラーが発生したのだろうと考えています。

  1. (前提: _awesomeListを消したことでウィジェットツリーの再描画が行われる)
  2. _awesomeListの最後の要素を消したことで画面サイズが変更になる
  3. 画面サイズが変更されたことでスクロールが起きてonNotification内のsetState()が走る
  4. _awesomeList要素削除によるウィジェットツリーの再描画中に新しく再描画処理が走る
    setState()内の処理ではなく、メッセージ通りsetState()が走ること自体が原因

なので、onNotificationをこんな風に変更します。

class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List _awesomeList = [];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          // calculateCurrentIndexは現在の要素を示すIndex
          int calculateCurrentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          // _currentIndexが最後の要素でない場合のみ_currentIndexを更新する
          // _calculateIndexは0始まりなので、_awesomeList.length - 1の必要はない
          if (_awesomeList.length != calculateIndex) {
            setState(() {
              _currentIndex = calculateIndex;
            });
          }
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

ここで注意するのは、if (_awesomeList.length != calculateIndex)が必要な理由。_awesomeList.removeAt(_currentIndex)が走った瞬間はまだ画面幅は再描画されていないということ。
_awesomeList.removeAt(_currentIndex);が走り、_awesomeList.lengthが1マイナスになっても、calculateIndexはあくまでスクロール量(画面高さ)から計算しているので、_awesomeListの要素が消された瞬間はまだ_awesomeListがあった時のIndexを示しています。
それからスクロール(画面高さを縮小)して、calculateIndex_awesomeListの最後の要素を示すようになった時(=スクロールが完了した時)に初めてsetState()が呼ばれる、という処理になり、エラーが解消されます。

すごくちょっとしたことなのですが、ネイティブアプリ(フロント?)に慣れないとこの辺りが中々理解できなかったので、まとめました。
ネイティブアプリのレンダリング周りはとても奥が深くて面白そうです!

Dartのカスケード記法(..)はRubyでいうtap

時々見かけるDartの謎な記法、カスケード記法。
どういうものかというと、..shuffle()みたいに、.をふたつ重ねる記法です。

  void main(){
    var hoge = [1,2,3];
    print((hoge..shuffle()).toString());
  }

Rubyを始めたばかりの頃も思ったのですが、こういうのって読み方がわからなくて調べるのに苦労しますよね…。カスケード記法というそうです。

cascadeとは 小滝、階段状に連続する滝、滝状に垂れたレース(など)、(菊などの)懸崖(けんがい)作り、縦つなぎ - Weblio辞書

つなぐ、という意味っぽい。
メソッドチェーン的なものかな?と思いましたが、なんかちょっと違う。
例にあげたshuffleの定義を覗いてみます。

void shuffle([Random random]);

shufflevoidで、呼び出し元のオブジェクト(レシーバ)を破壊的に変更して、戻り値は返しません。
なので、カスケード記法を使わないとこうなります。

void main(){
  var hoge = [1,2,3];
  // print(void)はできないのでコンパイルエラー
  // print(hoge.shuffle());

  // hoge自体を破壊的に変更してからprintすると期待通りの挙動になる
  hoge.shuffle();
  print(hoge); 
}

ただ、何度も呼び出そうと思うと行数も増えてしまうので、できればメソッドチェーンの形で書きたい。
そんな時のカスケード記法。

void main(){
  var hoge = [1,2,3];
  print(hoge..shufle()); 
  // 破壊的に変更した後のhogeを返してくれる
}

Rubyでいうと、tapでしょうか。

hoge = [1,2,3]
p hoge.tap{|item| item.shuffle!}
# shuffle!はレシーバ自体を返すので、tapを使う必要は本来ないです

Dartを書いていて、Rubyでいうとtapかあ、と嬉しくなったお話でした☺️

Flutterで画面遷移を実装する

昨日の続き。BottomNavigationBarの画面遷移を実装します。

といいつついきなりBottomNavigationBarはさておいて、画面遷移の方法はどうやら大きくわけて3つあるようです。

  1. Navigatorクラスを使ってpush/pop
  2. ウィジェットの切り替えを行う
  3. PageControllerを実装してがんばる

みっつめの方法はあまりメジャーじゃないっぽい?(お作法がまだよくわからない)ので、ひとつめとふたつめの方法を見ていきます。

Navigatorクラスを使う

ひとつめ、Navigatorクラスを使うパターン。
main.dartmain()で呼び出しているStatelessWidgetで、いままではこんな感じでホームとなるStatefulWidgetを呼び出していました。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MyApp',
      home: MyHomePage(),
    );
  }
}

このhomeを消して、ここでrouteを設定します。
routeをウィジェットの中に直書きするのは少し違和感。
ウィジェットをスタックしていくから仕方ないのでしょうか🤔

ともあれこんな風に書き換えます。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MyApp',
      initialRoute: '/',
      routes: {
        '/': (context) => MyHomePage(),
        '/awesomePage': (context) => AwesomePage(),
      },
    );
  }
}

routesプロパティの中に呼び出し名(route)と呼び出すStatefulWidget名をセットで記載します。

initialRouteがアプリ起動時に呼び出されるroute。通常はスプラッシュ画面を呼び出すところですね。
今回はMyHomePageを起動時に呼び出したかったので、initialRouteである'/'MyHomePageを紐づけました。

RaisedButtonをタップした時なんかに、ここでつけた名前をつかって画面遷移ができます。

RaisedButton(
  onPressed: () => {Navigator.pushNamed(context, '/awesomePage')},
),

ウィジェットの切り替えを行う

昨日書いたBottomNavigationBarに画面遷移を実装したものがこれ。

class _MyHomePageState extends State<MyHomePage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context){
    return Scaffold(
      appBar: AppBar(
        title: Text('MyHomePage'),
      ),
      body: _currentIndex == 0 ? Text('Home') : Text('Settings'),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: 0,
        onTap: (int index) {
          setState((){
            this._currentIndex = index;
          });
        },
        items: [
          new BottomNavigationBarItem(
            icon: new Icon(Icons.home),
            title: new Text("Home")
          ),
          new BottomNavigationBarItem(
            icon: new Icon(Icons.settings),
            title: new Text("Setting")
          ),
        ],
      ),
    );
  }
}

BottomNavigationBarをtapするとMyHomePageStatecurrentIndexを変更、currentIndexの変更でbodyを書き換えています。
ここでは単純にTextウィジェットを表示しているだけですが、ContainerCustomScrollViewなどを返します。

とても単純なのですが、これだと画面から非表示になったウィジェットは解放されるのでStatefulWidgetを使う場合には(Stateが解放されて)意図した動きにならない。
ので、Stackウィジェットを使って画面を持ちつつ、Offstageで表示切り替えをして、MaterialAppNavigatorを使う、というのがちゃんとした方法のようです。
こちらで詳しく解説されています

以上、画面遷移の方法でした!

FlutterでbottomNavigationBarを実装する

ネイティブアプリでよく見る、画面下部にあるナビゲーションバーのことを、ボトムナビゲーションバーというそうです!
ナビゲーションバーだとアンドロイドで言うと「◀」「●」「■」が並ぶエリアと呼び方がかぶってしまうので、「ボトムナビゲーションバー」が呼び名なのだとか。

ScaffoldでbottomNavigationBarを指定します。

class _MyHomePageState extends State<MyHomePage> {
  int _currentIndex = 0; // currentIndexにデフォルト値を与えないとコンパイルエラーになる

  @override
  Widget build(BuildContext context){
    return Scaffold(
      appBar: AppBar(
        title: Text('Title'),
      ),
      body: Center(
        child: Text('bottomNavigationBar test')
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        items: [
          new BottomNavigationBarItem(
            icon: new Icon(Icons.home),
            title: Text("Home")
          ),
          new BottomNavigationBarItem(
            icon: new Icon(Icons.settings),
            title: Text("Settings")
          ),
        ],
        onTap: (int index) {
          print(index); // デバッグ用にprintしてみる
        },
      ),
    );
  }
}

こんな感じで、実装はシンプル。
特徴的なのは、onTapがBottonNavigationBarItem(アイコン)側ではなくて、BottomNavigationBar側で準備されていること。
onTapは引数としてintを持っているので、このintを元に画面遷移を実装するようです!

Navigatorクラスを使って実装していくようですが、続きはまた明日やっていきます!

Gitでユーザ名とかパスワードを求められた時

新しいパソコンでGitの設定を間違ったり忘れたりしていて、少し戸惑ったのでメモ。

  • git pullでPermission denied
$ git pull
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

SSH keyの登録を忘れていました😇
GitHubでssh接続する手順~公開鍵・秘密鍵の生成から~ - Qiitaを参考に公開鍵・秘密鍵のセットを作って登録します。

  • git pushでユーザ名とパスワードを聞かれる

remote urlでhttpsを使用していました😇
sshを使って接続します。

git remote -v
origin  https://github.com/makicamel/test.git (fetch)
origin  https://github.com/makicamel/test.git (push)

GitHubリポジトリの「Clone or download」ボタンを押した時に「Clone with SSH」と表示されていたら「Use SSH」をクリック、表示されたgit:github.comから始まるアドレスをコピー。

git remote set-url origin git@github.com:makicamel/test.git

git remote -v
origin  git@github.com:makicamel/test.git (fetch)
origin  git@github.com:makicamel/test.git (push)

秘密鍵パスフレーズを設定していました😇
ssh-agentに秘密鍵を登録します。

ssh-add ~/.ssh/id_rsa
  • 参考リンク

ssh-agentを利用して、安全にSSH認証を行う - Qiita

それでは快適なGitライフを!

Androidの仮装端末からインターネットに接続できない時の対処法

今日は(も)小ネタ。

Androidアプリの開発中、たまによくある(らしい)Wifiがつながらない事案。

そんな時はapp/src/main/AndroidManifest.xmlに以下を記述します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.hoge_app">
<uses-permission android:name="android.permission.INTERNET" /> <!-- Added -->

アプリのインターネットへの接続を行うことができるようになる設定と教えていただきました!

ただ今回の原因はアプリではなく仮装端末自体がWifiにつながっておらず、
理由は不明で仮装端末の再起動で直りました😅

Androidの実機の際はWifiは端末(実機)依存なのだそうですが、
仮装端末だと起こりがちな事象だというお話を伺いました!

flutter clean & エミュレータの再起動を習慣にしようと改めて思った事案でした!

間違ってremoteにpushしたファイルを削除したい時

誤ってパスワードなどの秘密情報が含まれたファイルをremoteにpushしてしまった時。
普通にgit rm hogeしただけだと当然ながら履歴が残ってしまうので、履歴ごと改変したい時のやり方です。
リモートの履歴を改変することになるので、チーム開発の際は混乱が起きない様ご注意ください。

前提条件

こんな履歴があったとします。

$ git log --oneline
fae133a (HEAD -> master, origin/master) 3rd commit.
3ee314f 2nd commit.
a3af1e4 Initial commit.

よく見ると、パスワードが書かれたsecret.fileをcommit(&push)しています!

$ git log -p -2
commit fae133a633e21e7feb94c3994c276ef86d41210c (HEAD -> master, origin/master)
Author: makicamel
Date:   Tue Mar 19 19:00:30 2019 +0900
    3rd commit.
# (略)

commit 3ee314fc2f06bb7118229dc25ece3b54af889fb4
Author: makicamel
Date:   Tue Mar 19 19:00:00 2019 +0900

    2nd commit.

diff --git a/secret.file b/secret.file
index e69de29..9c3f6c8 100644
--- a/secret.file
+++ b/secret.file
@@ -0,0 +1 @@
+PASSWORD # <= これを消したい!

git rm secret.fileをするとファイルは消せますが、履歴は消せません。
履歴自体を消したいので、commitからやり直します。

commitを取り消し

まずはローカルで変更を修正。
git logで戻りたい履歴のコミットのハッシュ値を確認、そこまで戻ります。
git resetオプションはこちらに詳しいですが--hard--mixed--softとあります。

$ git log --oneline
fae133a (HEAD -> master, origin/master) 3rd commit.
3ee314f 2nd commit.     # <= ここでsecret.fileを追加した
a3af1e4 Initial commit. # <= ここに戻れればOK

今回はファイルの変更はそのまま、コミットだけ取り消したいので--softします。

$ git reset a3af --soft
$ git log --oneline
a3af1e4 (HEAD -> master) Initial commit.

ファイルをgit管理対象から外す

.gitignoreに外したいファイルを追記。

$ echo "secret.file" > .gitignore

ただしこのままpushしても、gitが管理してくれているままになるので、git rmします。
git rmだとファイルごと削除されるので、ファイルを残すよう--cachedオプションをつけて実行。

$ git rm --cached secret.file

remoteにpush

あとはremoteにpushするだけ!
リモートとローカルで不整合が起きるので、-fオプションでpushします。

$ git push -f

無事履歴からファイルを削除することができました!