「まちのコイン」で使っているFlutterのバージョンを3系にアップデートした話

こんにちは。ちいき資本主義事業部でFlutterエンジニアをやっている植田です。
この記事ではFlutterで開発している「まちのコイン」というアプリをFlutter2系からFlutter3系にバージョンアップした事例について紹介します。

まちのコインとは?

「まちのコイン」はその地域に関わるユーザーがイベントやお店を通じて地域の人たちと会話をするきっかけを提供するプラットフォームです。

coin.machino.co

例えばQR読み取り機能やマップ機能といったものがあります。
QR読み取り機能ではQRを読み込んで地域限定のイベントに参加したり、スポットにチェックインしてコインを貯めたりすることができます。 マップ機能では利用できるスポットを確認できたり、スタンプラリーのコースを確認しながらまちを散策したりすることもできます。 また、利用するにはユーザー登録が必須となるので登録・ログイン、退会の機能も備えています。

「まちのコイン」を利用するスポット向けの管理アプリも用意しています。
そちらではユーザー情報のQRをカメラで読み込んで承認したりイベントを作成したりといったことができます。
内部的には2つのアプリをflavorを切り替えることで実現しているため一つのリポジトリで構成されています。

ユーザー管理にはFirebase Auth、クラッシュレポートにはFirebase Crashlyticsなども使っているため firebase_auth, firebase_crashlytics などのパッケージに強く依存した作りになっています。

Flutterをアップデートしていくモチベーション

Flutterには便利なパッケージがたくさんあります。
そして、そのほとんどがFlutterのバージョンアップに追従して新機能が追加したりbugfixを出したりしますが、中には全くアップデートされず陳腐化していくものもあります。

そのため、アプリのFlutterバージョンを随時アップデート対応しておかないと、パッケージの新機能やbugfixを取り込みたくてもFlutterのバージョンが古くて取り込めない、という問題が発生してしまいます。 Flutterのアップデート(特にメジャーアップデートは大変)もして、利用しているパッケージもアップデートして…諸々やると工数は膨れ上がります。 日頃からFlutterを最新バージョンに対応しておくことで、こういった運用工数を分散して割く事ができるようになります。

もちろん運用面での工数が減らせるだけでなくFlutterのアップデートをするだけで嬉しいこともいくつかあります。
Flutter本体のbugfixや言語としての新機能が使えるようになるので安定性の向上や、より柔軟なコードが書けるようになるかもしれません。 画面描画のパフォーマンスがより効率的になったり、メモリの使用率が下がったりといったパフォーマンス改善も入ってくる事が多いのでできるだけ最新のバージョンを追従する意識を持っていた方が良いでしょう。

Flutter3では大きくみると下記のようなパフォーマンス改善も入っていたのでユーザーにとってもよりアプリが快適に使えるのでメリットがあると思います。 2つのアプリを運用しているのでメリットも2倍です。

  • iOSでの120hzリフレッシュレートに対応
  • フレームビルドのパフォーマンスが約20%向上

今回のゴール

Flutterのバージョンアップの対応の中でやりたかったことは以下の2つです。

  • Flutterのバージョンを3.x系にアップデートする
    • 対応当時は2.10.5から3.3.3にアップデート
  • 依存しているパッケージを最新のバージョンまでアップデートする

つまづいたポイント

アップデートしていくにあたって事前に影響範囲の調査をしながら進めていましたが、想定していた以外のところで時間がかかってしまったポイントがあったのでいくつか紹介したいと思います。

docs.flutter.dev

docs.flutter.dev

flutter_facebook_authのセットアップ方法に変更があった

Flutterのアップデート後、iOSアプリが起動後すぐにクラッシュするという挙動が発生するようになりました。 以下のように、アプリ起動後すぐに接続が切れているログしか観測できなくてFlutter側の不具合なのかと思い調査にかなり時間を費やしてしまいました。

[        ] Waiting for observatory port to be available...
[+3339 ms] Observatory URL on device: http://127.0.0.1:58503/5oeggSOiUdw=/
[   +8 ms] Caching compiled dill
[ +139 ms] Connecting to service protocol: http://127.0.0.1:58503/5oeggSOiUdw=/
[ +418 ms] Launching a Dart Developer Service (DDS) instance at http://127.0.0.1:0, connecting to VM service at http://127.0.0.1:58503/5oeggSOiUdw=/.
[ +255 ms] DDS is listening at http://127.0.0.1:58514/ojIHBhq-rfk=/.
[  +46 ms] Fail to connect to service protocol: http://127.0.0.1:58503/5oeggSOiUdw=/: Exception: DDS shut down too early
[        ] Error connecting to the service protocol: failed to connect to http://127.0.0.1:58503/5oeggSOiUdw=/

エラーログの情報が足りないときはXcode側のログを確認してみると何かヒントになるかもしれません。
今回はXcodeに残っていたエラーログを確認すると FBSDKCoreKit が含まれている事がわかったので、 アプリ内でFacebook機能を利用している flutter_facebook_auth に絞って調査することで対応方法が見つかりました。

原因は、flutter_facebook_authのバージョンを4系にアップデートしたことで、flutter_facebook_authが内部で使っているFacebook SDKのバージョンも14にアップデートされていることでした。 Facebook SDKのセットアップ手順にもあるように、 FacebookClientToken の設定を追加する必要があり、それによってクラッシュは解消されました。

flare_flutterが非アクティブな状態になっている

「まちのコイン」の一部のアニメーションにはRive(旧Flare)のアニメーションが採用されていました。
こちらは .flr でアニメーションを作ってベクターアニメーションを描画できるという利点がありましたが、 すでにリポジトリが非アクティブとなっていること、新規アニメーションを作るときにRiveコンソールからアニメーション作成できる人がいないという問題がありこのタイミングで脱却することになりました。

Riveアニメーションを使っているところはFlutterの AnimationController を使った実装に置き換えたり、複雑なものはAPNGに置き換えたりして対応する方針としました。

その他FlutterのBreaking Changes

Scrollbar 'isAlwaysShown' is deprecated and shouldn't be used. Use thumbVisibility instead. This feature was deprecated after v2.9.0-1.0.pre..
Try replacing the use of the deprecated member with the replacement.

スクロールバーを常に表示しておく必要がある場合は、 isAlwaysShownthumbVisibility に置き換えるだけで対応が完了します。

WidgetsBinding.instance is non null value

WidgetsBinding.instance?.addPostFrameCallback は非同期処理が完了した時にUIを更新したい場合などに使うケースが多いと思います。 non nullになっただけなので修正は簡単ですが、これを頻繁に使っているアプリは修正箇所が多くて大変かもしれません。

TextInputClient Missing concrete implementations of 'TextInputClient.insertTextPlaceholder', 'TextInputClient.removeTextPlaceholder', and 'TextInputClient.showToolbar'.
Try implementing the missing methods, or make the class abstract.

TextInputの挙動を拡張などしたい場合に利用する TextInputClient にメソッドが追加されていました。 TextInputClientをimplementsしているWidgetがあれば追加されたメソッドを実装する必要がありますが、実装は空のままでも問題ないようです。 メソッドの定義だけ追加しておきましょう。

やって良かったことなど

Flutterのバージョンが古いことで一番困っていたのは不具合の修正を取り込めないということでしたが、Flutterをアップデートすることでパッケージ側の変更も取り込むことができるようになりました。 特にログイン周りで使用しているFirebase Authの不具合修正を取り込めるようになったことが大きかったですね。 描画パフォーマンスの向上や言語機能としての新機能も嬉しい要素で、Enhanced enumsfreezed パッケージで実装している箇所の置き換えもしていけそうです。

  • 依存パッケージのbugfixを取り込めるようになった
  • devtoolsで描画パフォーマンスの向上を確認できた
  • Enhanced enumsが使えるようになった

packageの更新にDependabotを導入した

今回はFlutterをアップデートするのでアプリの全体テストを実施する必要がありました。
そこにパッケージのアップデートも合わせて対応しましたが、大幅にアップデートされているパッケージもあればアップデートが止まってほぼdeprecatedなパッケージもあり、苦戦することが多かったです。 こうした影響範囲の広い改修のタイミングで対応するよりも小さくアップデートを取り込んでいった方がテスト範囲も小さくなりますし、 1つ1つをアップデートする工数も小さくて済みそうです。

「まちのコイン」のアプリは多くのパッケージを利用していますのでbugfixがあれば積極的に取り込んでいきたいです。
現在はbeta版ではありますが、pub用のDependabotを設定してパッケージのアップデートがあれば適宜取り込む運用で開発を進めています。 特にFirebaseなどのパッケージ間で依存関係があるもの、他のFirebaseパッケージが依存している firebase_core を依存解決する必要がある場合に便利で 他のパッケージが更新されたときにまとめてアップデートしてくれます。

slack notification from dependabot

まとめ

  • Flutterを継続的にバージョンアップしていくことでFlutterやパッケージのbugfixを安定して取り込むことができるようになる
  • アプリの描画パフォーマンスがいい感じに上がる
  • パッケージの更新にはDependabotを使うと依存関係含めて常にアップデートし続けることができるので便利

カヤックでは、信頼できる開発環境運用に興味があるエンジニアも募集しています。

Goのテストフレームワークを比較してみた

こんにちは。カヤックボンドの松本です。

この記事は 面白法人グループ Advent Calendar 2022 の5日目の記事です。

この枠ではテストフレームワークについての話をしようと思います。

きっかけ

あるプロジェクトのテストコードを見ていたら、こんな感じのテストコードを書かれていました。

func Test_Sample(t *testing.T) {
    ctx, usecase, user, err := initTest()
    if err != nil {
        t.Fatal(err)
    }

    tests := []struct {
        name         string
        request      *GetSelfRequest
        wantResponse *GetSelfResponse
        wantErr      error
    }{
        {
            name:    "success",
            request: &GetSelfRequest{},
            wantResponse: &GetSelfResponse{
                ID:    user.ID,
                Name:  user.Name,
                Money: user.Money,
                Exp:   user.Exp,
            },
            wantErr: nil,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, tErr := usecase.GetSelf(ctx, tt.request)

            if !errors.Is(tErr, tt.wantErr) {
                t.Fatalf("err:%v want: %v\n", tErr, tt.wantErr)
            }
            if tErr != nil {
                return
            }

            if got.ID != tt.wantResponse.ID {
                t.Errorf("got: %s, want: %s\n", got.ID, tt.wantResponse.ID)
            }
            if got.Name != tt.wantResponse.Name {
                t.Errorf("got: %s, want: %s\n", got.Name, tt.wantResponse.Name)
            }
            if got.Exp != tt.wantResponse.Exp {
                t.Errorf("got: %d, want: %d\n", got.Exp, tt.wantResponse.Exp)
            }
            if got.Level != tt.wantResponse.Level {
                t.Errorf("got: %d, want: %d\n", got.Level, tt.wantResponse.Level)
            }
        })
    }
}

// GetSelfRequest リクエスト
type GetSelfRequest struct {
}

// GetSelfResponse レスポンス
type GetSelfResponse struct {
    ID    UserID
    Name  string
    Money int
    Exp   int
}

「想定された戻り値と、取得できた戻り値」を一つひとつチェックするパターンです。

このような書き方をする場合、今後起きうる問題として「後から仕様変更があった場合に書き直すべきテストが検知できない」問題があります。

例えば、 GetSelfResponse にLevelというフィールドが増えた場合、Levelにどんな値が入っていてもテストが通過してしまいます。同じ人がテストを書き直すなら良いですが、忘れる可能性だって出てきますし、そもそも修正すべきテストは適切に落ちてほしいです。

こんな場合自分なりに知っている方法と、今回調べてみた手段を紹介しようと思います。

go-cmpで戦う

go-cmpreflect.DeepEqual() よりも高機能で、非公開なフィールドの取扱も細かく指定可能、更に差分が見やすいという特徴があります。

- if got.ID != tt.wantResponse.ID {
-      t.Errorf("got: %s, want: %s\n", got.ID, tt.wantResponse.ID)
-  }
-  if got.Name != tt.wantResponse.Name {
-      t.Errorf("got: %s, want: %s\n", got.Name, tt.wantResponse.Name)
-  }
-  if got.Exp != tt.wantResponse.Exp {
-      t.Errorf("got: %d, want: %d\n", got.Exp, tt.wantResponse.Exp)
-  }
-  if got.Level != tt.wantResponse.Level {
-      t.Errorf("got: %d, want: %d\n", got.Level, tt.wantResponse.Level)
-  }
+   if diff := cmp.Diff(got, tt.wantResponse); diff != "" {
+       t.Errorf("diff: %v\n", diff)
+   }

実行結果

diff:   &main.GetSelfResponse{
                ... // 2 identical fields
                Money: 10,
                Exp:   100,
        -       Level: 0,
        +       Level: 10,
          }

ただし、このライブラリは非公開なフィールドの扱いに対する指定が割と厳密で、 cmp.AllowUnexported() cmpopts.IgnoreUnexported() cmpopts.IgnoreField() 等が付いていないとpanicになります。歴史的経緯からか、何故かパッケージ名が違うところが面白いですね。

if diff := cmp.Diff(t.wantResponse, got, cmpopts.IgnoreUnexported(GetSelfResponse{})); diff != "" {
    t.Errorf("diff: %v\n", diff)
}

stretchr/testifyが人気らしい

他にも良いのが無いかなと思い調べてみたところ、今現在ではstretchr/testifyが割と人気な様子でした。v2も作ると書いており、今後の開発も盛んそうです。

こちらを使って書き直すと以下のようになります。

- if diff := cmp.Diff(t.wantResponse, got, cmp.IgnoreUnexported(GetSelfResponse{})); diff != "" {
-      t.Errorf("diff: %v\n", diff)
-  }
+   assert.Equal(t, tt.wantResponse, got)

実行結果

Error Trace:    /xxx/sample_test.go:84
Error:          Not equal: 
                expected: &main.GetSelfResponse{ID:"01855324-64ae-7c96-b456-c403ec90dc50", Name:"TestName", Money:10, Exp:100, Level:0}
                actual  : &main.GetSelfResponse{ID:"01855324-64ae-7c96-b456-c403ec90dc50", Name:"TestName", Money:10, Exp:100, Level:10}
                            
                Diff:
                --- Expected
                +++ Actual
                @@ -5,3 +5,3 @@
                  Exp: (int) 100,
                - Level: (int) 0
                + Level: (int) 10
                 })
Test:           Test_Sample/success

かなり美しい実行結果で、テストケースを作っているだけで無限にモチベーションが出てきそうな気がします。

(こちらのフレームワークでは非公開なフィールドは比較対象に含まれるようです。)

おわりに

テスト1つの書き方でも、本当にいろいろな書き方があるんだなあ、と思いました。

今現在は非公開フィールドについてどれくらい厳密に扱いたいかによって使用するフレームワークが決まってくるだろう、と感じました。

最後になりますが、未来の12/24のグループカレンダーでは別の方向からコード品質の向上に取り組んでおりました。よろしければこちらも是非ご覧ください!

techblog.kayac.com