iOSビルドの凡ミスから学ぶ、FlutterのビルドキャッシュとAOTコンパイルの仕組み - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

iOSビルドの凡ミスから学ぶ、FlutterのビルドキャッシュとAOTコンパイルの仕組み

ソフトウェアエンジニアの末永です。私は個人開発でFlutter製のモバイルアプリを開発しています。このアプリを開発している中でアプリのビルド周りでハマってしまったことがあり、その際ビルドシステムに関してしっかりと調査しました。この記事はその調査の際に書いたものです。*1

なお、本記事は次のバージョンを対象とした内容となっています。

  • Flutter: 3.38.0
  • Dart: 3.10.0

また、ビルド対象はiOSとAndroidのモバイルアプリのみとします。

「iOSのReleaseビルドだけ古いアプリが出ている」問題

アプリの最新版をAppStoreとGoogle Playにリリースした後、iPhoneユーザーの方に新機能を紹介したら「え?そんな機能まだ出てないよ?アプリのバージョン?言われた通りの最新だよ?」と返答がきました。

どんな問題が発生したか

次のような状態になっていました。

  • iOSのRelease版だけ「アプリ内で表示されるバージョンは最新だが古い画面のアプリが出ている」状態になっていた
  • ビルド後のバージョンだけはアプリ側も最新バージョンが表示されていた
  • flutter runではiOSもAndroidも同じ最新バージョンが表示されていた
  • Androidアプリのビルドでは最新バージョンでビルドできていた
iOS Android
flutter run ⭕️ 新しい ⭕️ 新しい
flutter build ❌ 古い ⭕️ 新しい

問題が発生した原因

結論として、次のような凡ミスでした。

  • フォルダ構成変更のタイミングでの設定の移行漏れ
  • 旧ディレクトリを消さずに残していた
    • モバイル・サーバーサイド・APIスキーマのリポジトリをモノレポにした時の名残
  • Xcodeのプロジェクト設定ファイル中の絶対パスが旧ディレクトリだった

書いてみたら「そうなればそうなるわな」という話ですが、完全に消し去ったと思っていたのとflutter runでは新しいバージョンで起動していたのでなかなか思い至らず結構な時間を溶かしてしまいました。

この問題を解決するためにFlutterのビルドシステムのコードまで見にいきました。最終的に解決するまでずっと「ビルドのキャッシュに問題がある」と思い込んでおり、そのためどのタイミングでどこにキャッシュが生成され、どのタイミングで読み込まれるか、を調査していました。結局私の凡ミスだったのでビルドキャッシュは無関係だったわけですが、ここからは調査しながらまとめたFlutterのビルドシステムとビルドキャッシュについて説明していきます。

Flutterアプリのアーキテクチャ

ここでビルドシステムへの理解を深めるため、Flutterアプリのアーキテクチャについて触れておきます。

主要なコンポーネントはFramework (Dart) とEngine (C++) です。ざっくりいうと、Frameworkが画面の描画を行い、Engineがネイティブ側とAPIレベルでの橋渡しを行います。もっと低レイヤにはEmbedderというコンポーネントが存在し、これがモバイル端末でEngineをロードします。

Frameworkはアプリのコードがビルドされたもので、EngineはFlutter SDKに同梱されています。次の公式の図がかなり分かりやすいので引用します。

Flutterアプリのアーキテクチャ図。引用元: https://docs.flutter.dev/resources/architectural-overview

ビルド時に走る処理

Flutterアプリをビルドする際は次のような処理が実行されます。

  • ビルド対象に応じた設定ファイルを読み込む
  • EngineをFlutter SDKから抜き出して適切に配置
  • アプリ側のコードをコンパイルしてネイティブコードに変換する
  • パッケージング

アプリ起動時に走る処理

Flutterアプリが起動する際、次のような処理が実行されます。

  • OS がアプリを起動
  • Embedder が Flutter Engine をロード
  • Engine の中で Dart VM (Runtime) が起動
  • VM が、アプリと一緒に同梱されている AOTコンパイル済みのネイティブコード(アプリ)をメモリ上に読み込み、実行を開始

Flutterのビルドシステム

ここからは、Flutter製のアプリをビルドする際に行われる処理について説明します。

3つのモード

まずはFlutterアプリを実行する時に指定する起動モードについて説明します。

Flutterのビルドシステムには3つのモードがあります。

  • Debug
  • Profile
  • Release

それぞれのモードは flutter run コマンドや flutter build コマンドでflutter run --releaseのようにして指定できます。これらのモードはそれぞれにパフォーマンスなどの違いがあります。

3つのモードの違いを簡単に表にまとめると次のようになります。

パフォーマンス コンパイラ サイズ 成果物
Debug JIT JITコンパイラ入りのEngineと中間バイナリ(app.dill)
Profile AOT バイナリ + 計測用コード
Release AOT 最適化済みバイナリ

3つのモードはJITコンパイルが行われるかAOTが行われるか、計測用コードが入っているかいないか、により区別されます。

  • Debugではapp.dillファイルがそのままVMの上でJITコンパイルされて動くのでホットリロードなどができる
  • ProfileとReleaseではAOTコンパイルの結果最適化されたバイナリが生成される。その際gen_snapshotなどの特徴的な処理が行われる (後述)

この辺りの話は次の2つの公式資料に分かりやすく書いてあるので気になる方は参照してください。

github.com

docs.flutter.dev

ビルドシステムの概要

ここではFlutterでアプリをビルドする仕組みの概要を説明します。アプリのコンパイルはFrameworkのバイナリを作る作業になります。Frameworkのコンパイルが終わるとビルドによりFrameworkとEngineとEmbedderを組み合わせます。EngineとEmbedderはFlutter SDKより提供されてアプリにバンドルされます。

具体的には、AOTコンパイルでは次のような対応のファイルになります。

  • Framework: App.framework (iOS), libapp.so (Android)
  • Engine: Flutter.framework (iOS), libflutter.so (Android)
  • Embedder: Flutter.framework (iOS), flutter.jar (Android)

JITコンパイルではFrameworkが最終的なバイナリではなくapp.dillという中間バイナリになります。Debugモードでビルドすると、JITコンパイラがEngineに入った状態でアプリが起動し、JITコンパイラにapp.dillを読み込ませることでホットリロードが実現されています。

AOTモードでのEngineの挙動については次のドキュメントが詳しいです。

github.com

iOSでのビルド (AOTコンパイル)

ここではiOSのAOTコンパイルに絞って具体的に説明します。

FlutterアプリをiOS向けにビルドした時の成果物はApp.frameworkです。これがFramework層のバイナリで、Dartから生成したバイナリが含まれます。

また、Engine層のバイナリは共通のファイルとしてFlutter SDKに組み込まれています。実際のアプリにはFlutter.frameworkという名前で同梱されます。

ビルドの流れ

ビルドを開始すると、gen_snapshotによりDartのコードを4種類のsnapshotにします。snapshotはDartのコードをコンパイルしたもので、Dart VMの状態とアプリの実行命令をマシン語として書き出したバイナリデータになります。これらをApp.frameworkに組み込みます。

次に、Xcodeのツールチェーンによって4種類のsnapshotを組み込みながら App.framework を生成します。

実際にアプリが起動するときは、EngineであるFlutter.frameworkがApp.frameworkの中からsnapshotを見つけて実行します。

iOSのAOTビルドと実行の流れ

直接バイナリを実行しないのは、iOSのバイナリ実行に関する制約が理由です。

iOSの制約

iOSには、実行時にメモリ上で動的にコードを生成して実行すること (つまりJITコンパイル) を禁止する制約があります。しかし、Flutterアプリを動かすにはDartで書かれたコードのバイナリを実行する必要があります。 この制約を回避するため、Flutterはビルド時にDartコードを4種類のスナップショットに変換しApp.framework の中に直接埋め込むことで、安全に実行できる仕組みをとっています。

Flutterアプリのビルドキャッシュ

ここでやっと本題のFlutterアプリのビルドキャッシュについて具体的に見ていきます。まずは私の個人開発プロジェクトでiOS向けにDebugビルドとReleaseビルドをした際の .dart_tool/ ディレクトリ以下のファイルを次に示します。

iOS向けビルド後のビルドキャッシュ

中間生成物

Flutterアプリをビルドすると、中間生成物が .dart_tool/flutter_build に保存されます。この中にはビルドしたバイナリ本体や、差分検知に使うstampファイルなどが含まれます。ファイルの差分を検知する仕組みにより、差分がない場合はこの中間生成物がビルドキャッシュとなります

次のような流れでキャッシュを用いたビルドが行われます。

  • ファイルごとの差分を確認する
  • 差分がなければスキップ。該当するファイルのstampファイルを確認し前回ビルドの中間生成物をそのまま利用する
  • 差分があればビルドしてstampや.filecacheの更新を行う

前回ビルド時の中間生成物をそのまま使い回すかを決定するために使われるのが、stampファイルと.filecacheです。

stamp

stampファイルはFlutterアプリをビルドする上で入力となるファイルと出力となるファイルの対応を示すためのものです。このファイルによりファイルと出力された中間生成物の対象をマッピングします。

なお、stampの話はFlutter本体の build_system.dart にコメントとして一部説明が書かれているので引用します。

/// For each target, executing its action creates a corresponding stamp file
/// which records both the input and output files. This file is read by
/// subsequent builds to determine which file hashes need to be checked. If the
/// stamp file is missing, the target's action is always rerun
参照: https://github.com/flutter/flutter/blob/3.8.0-0.0.pre/packages/flutter_tools/lib/src/build_system/build_system.dart#L45

実際のstampファイルの例を次に示します。

{
  "inputs": [
    "/Users/asmsuechan/Library/flutter/packages/flutter_tools/lib/src/build_system/targets/ios.dart",
    "/Users/asmsuechan/src/asmsuechan/mymobileapp/.dart_tool/flutter_build/00638752289d4f43e8b3c699bb7ecc38/app.dill",
    "/Users/asmsuechan/Library/flutter/bin/cache/engine.stamp",
    "/Users/asmsuechan/Library/flutter/bin/cache/engine.stamp"
  ],
  "outputs": [
    "/Users/asmsuechan/Library/Developer/Xcode/DerivedData/Runner-beswbixstphfbcczbvnhtqheowmg/Build/Intermediates.noindex/ArchiveIntermediates/prod/BuildProductsPath/Release-prod-iphoneos/App.framework/App"
  ]
}

inputとoutputの対応が示されており、この中身(と後述の.filecache)に基づいて再ビルドするかどうかを決定しています。

.filecache

.filecacheは、.dart_tool/flutter_build/00638752289d4f43e8b3c699bb7ecc38/.filecache に保存されるコンパイル対象ファイルのハッシュです。実際には次のような中身をしています。

{
  "version": 2,
  "files": [
    {
      "path": "/Users/asmsuechan/src/asmsuechan/mymobileapp/lib/models/home_screen_state.dart",
      "hash": "3e78fc8f0c4b2fbb11fa80cd1ce906bb"
    }
  ]
}

現在ビルドする時のファイルと前回分の差分をハッシュで比べて差分がなければスキップされるような仕組みのために使われています。

実際のコードでは次の場所が該当します。

github.com

「バージョンだけ最新の古いアプリ」が生まれた原因

ここまで見てきたキャッシュ機構を踏まえると、今回私がハマった問題の全体を説明できます。

問題の原因は「Xcodeのプロジェクト設定ファイル(project.pbxproj)に、移行前の旧ディレクトリの絶対パスが残っていたこと」でした。この状態でアーカイブ(Releaseビルド)を実行すると、裏側では次のようなことが起きていました。

  1. Xcodeは指定された絶対パスに従い、旧ディレクトリのFlutterプロジェクトを見に行ってビルドプロセスを開始する。
  2. 旧ディレクトリのソースコードは移行前から一切触っていないため、.filecache のハッシュ値チェックで「差分なし」と判定される
  3. stamp ファイルの記録に従い、旧ディレクトリに残っていた古い App.framework(中間生成物)がそのままキャッシュとして採用される
  4. 一方で、アプリのバージョン番号などは参照先の挙動の違いから新ディレクトリの pubspec.yaml 等が評価され最新の文字列が埋め込まれる。

結果として、「中身のバイナリはキャッシュ判定をすり抜けた旧ディレクトリの古いもの」「外側のメタデータ (バージョン情報) だけは最新」という、iOSアプリが生成されてしまっていました。

まとめ

さて、最初の経緯を振り返ってまとめると次のようになります。改めて書いてみてもかなり初歩的な間違いですね。

  • モノレポ化に伴いディレクトリを移動。その際旧ディレクトリの削除を忘れていた
  • project.pbxprojのPACKAGE_CONFIGが絶対パスで旧ディレクトリを指定していた
  • これにより、新しくビルドをしてもiOSのReleaseだけ旧ディレクトリの差分を見に行っていた
  • project.pbxprojのバージョン情報などはpubspec.yamlから見にいくため、アプリとしては最新版になる

そう、私はAndroidユーザーなのでiOSの動作確認が疎かになっていたのでした。「リリース前にちゃんとAndroid/iOSの両プラットフォームで動作確認はせえよ」というだけの話でこの問題は未然に防ぐことができます。今回はリリースまでしてしまいました。個人開発とはいえ気が緩みすぎていましたね。

ただ、この問題を解決するために今まで曖昧だったFlutterのビルド周りの知識やビルドキャッシュの知識をしっかり身につけることができたのでいい収穫になりました。

We are hiring !!

エムスリーではソフトウェアエンジニアを積極募集しています。新卒・中途それぞれの採用、カジュアル面談やインターンも常時募集しています!

エンジニア採用ページはこちら

jobs.m3.com

カジュアル面談もお気軽にどうぞ

jobs.m3.com

インターンも常時募集しています

open.talentio.com

*1:私は業務ではFlutterアプリの開発をしておらず、あくまで個人開発での経験に基づく内容です。