booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Android、iOSネイティブからFlutter移行で得た知見

自己紹介

株式会社ブックリスタのプロダクト開発部でモバイルアプリエンジニアをしている城と申します。 現在、iOS/Androidネイティブで実装された電子書籍アプリのFlutterリプレイスを検討しており、課題を調査しFlutterでどこまで置き換え可能かの実証実験を行っています。

Flutter移行のメリット・デメリット

Flutterに移行すると具体的にどういうメリット・デメリットがあるのかおさらいします。

開発効率とスピードの向上

  • コードベースの統一: Android / iOS で同一コードを使用でき、開発・保守工数を大幅削減
  • Hot Reload: リアルタイムでのコード変更確認により、開発サイクルの大幅短縮
  • 豊富なライブラリエコシステム: pub.devの充実したパッケージにより機能実装の効率化
  • スキルセットの集約: Android / iOS の専門知識を Flutter に集中
  • 新メンバーのオンボーディング: 学習コストの削減
  • CI/CD効率化: 単一のビルドパイプラインでマルチプラットフォーム対応

デメリット・制約

  • ネイティブライブラリの制約: 既存のネイティブライブラリがFlutterに対応していない場合の移行が困難
  • プラットフォーム固有機能: カメラ、位置情報等の高度なネイティブ機能へのアクセス制限
  • アプリサイズ増加: Flutter Engineの組み込みによるアプリサイズの増大

Flutter移行戦略の検討

上記のメリット・デメリットを踏まえ、移行を推奨できるケース慎重な検討が必要なケースを整理します。

移行を推奨できるケース

  • 開発チームのリソースが限られている: 単一技術スタックでの効率化メリットが大きい
  • 長期的な開発・保守を重視: コードベース統一による保守性向上を優先
  • マルチプラットフォーム展開を計画: Web・Desktop対応の将来性を活用したい
  • 既存ネイティブライブラリへの依存が少ない: 技術的制約による移行困難が限定的

慎重な検討が必要なケース

  • ネイティブライブラリに強く依存: 既存資産の移行コストが高い
  • 高度なプラットフォーム固有機能が必要: カメラ・位置情報等の複雑な機能を多用
  • パフォーマンス要件が厳しい: アプリサイズやメモリ使用量に制限がある
  • Flutter経験者の確保が困難: 学習コストや採用コストを考慮する必要

しかし、実際のプロジェクトでは理想的な条件が揃わないことも多いです。弊社の電子書籍アプリでも技術的な制約に直面しました。次のセクションでは、具体的にどのような制約があったのか、そしてそれをどう解決したのかを詳しく説明します。


電子書籍アプリで直面したFlutter移行の制約

ビューワーライブラリの制約

電子書籍アプリの移行において、最も大きな技術的制約となったのがビューワーライブラリの問題でした。

  • 使用中のライブラリ: 高機能な電子書籍表示・操作ライブラリ
  • 技術的制限: Activity・ViewController上での使用が前提
  • Flutter非対応: ライブラリベンダーからFlutter版の提供予定なし
  • 継続使用の方針: ユーザーの読書体験を変えないため、同様のライブラリを使用する方針

なぜこの制約が重要なのか

ビューワー機能は電子書籍アプリの核心部分であり、以下の理由から既存ライブラリの継続使用が必要です。

  • ユーザー体験の継続性: 既存ユーザーの慣れ親しんだ操作感を維持
  • 読書体験の品質: 長年培われた最適化されたページめくりや表示性能
  • 機能の安定性: ページめくり、拡大縮小、しおり、検索等の複雑な機能群
  • 開発リスク回避: 新しいライブラリ導入による予期しない不具合を避ける

制約を踏まえた移行判断

この制約により、完全なFlutter移行は不可能となりましたが、ネイティブとのハイブリッドによる移行メリットが十分あると考えました。

求められる具体的なUI構成要件

電子書籍アプリでは、以下の画面構成が必要でした。

  1. 本棚画面: 書籍一覧・設定等の基本機能
  2. ビューワー: 本棚の上に重ねて表示(本棚状態は裏で維持・ネイティブ必須)
  3. ビューワーメニュー: ビューワーの上にメニューUI表示

この構成により、ユーザーは「本棚 → ビューワー → メニュー操作 → ビューワー → 本棚」へと、すべての状態を保持したままシームレスに遷移できます。

UI構成要件:3層アーキテクチャ

ここで疑問が生まれます: ビューワーを挟んで、本棚とビューワーメニューで異なるUIをFlutterで描画できるのでしょうか。それができれば、ビューワーメニューも一度の開発で済み、大幅な開発効率化が実現できるはずです。

複数のUIツリーを保持できるか

執筆時点のFlutter において、1つの FlutterEngine は1つの Widget ツリー(画面階層構造) しか管理できません。これはFlutterのアーキテクチャ制約です。

単一の FlutterEngine では、本棚UIのウィジェットツリーの状態を保持したままビューワーメニューをネイティブ画面の上にオーバーレイ表示できません。 単一のFlutterEngineでネイティブ画面の上にビューワーメニューを重ねようとすると、本棚が表示されてしまうという現象が起きました。 そこで取ったアプローチを次で説明します。

FlutterEngineとは何か

複数Engine構成の解決策を理解するために、まずFlutterEngineの基本概念について説明します。FlutterEngineの理解は、なぜ単一Engineでは制約があり、複数Engineが必要なのかを把握する上で重要です。

FlutterEngineの役割:

  • Dartコードの実行環境: Dart仮想マシン(VM)とアプリケーションの状態・ライフサイクルを管理
  • レンダリングエンジン: UIの描画とアニメーション処理を担当
  • プラットフォーム統合: ネイティブコードとの橋渡し機能

通常のFlutterアプリでは:

  • 1つのアプリケーションに対して1つのFlutterEngineが動作
  • すべてのWidget(UI部品)は単一のウィジェットツリー上で管理

複数FlutterEngine構成とは:

  • 同一アプリ内で複数のFlutterEngineを並行実行
  • 各Engineは独立したメモリ空間と状態を持つ
  • Engine間では直接的なデータ共有ができない

複数FlutterEngine構成による状態分離

  • メインEngine: 本棚画面の状態を継続維持
  • オーバーレイEngine: ネイティブビューワー上で独立してFlutterUIを表示

アーキテクチャ全体構成

複数FlutterEngineによる構成を下図に示します。

ハイブリッド構成アーキテクチャ

このアーキテクチャの特徴を説明します。

ネイティブプラットフォーム上でのFlutterEngine実行

  • 複数Engine管理: ネイティブレイヤーが2つのFlutterEngineを生成・管理
  • Engine間完全分離: 各EngineはDartVM、メモリ空間、状態管理がすべて独立しているため、状態は共有不可

Method Channelによる通信制御

  • Native経由の通信: FlutterEngine同士は直接通信不可、必ずネイティブを経由
  • 双方向インターフェース: Flutter → Native、Native → Flutter の両方向で関数呼び出し
  • Engine制御: ネイティブがビューワーイベントに応じてOverlay Engineを制御

OSレベルでの統一データアクセス

  • プラットフォーム標準ストレージ: SharedPreferences、Keychain、SQLiteなど
  • 全コンポーネント共通: すべてのEngineとネイティブコンポーネントが同一データソースにアクセス
  • データ一貫性: OSレベルでの排他制御により、データ競合を防止

Flutter と ネイティブの役割分担

機能 実装 理由
本棚・設定画面 Flutter UI開発効率とクロスプラットフォーム対応
ビューワー本体 ネイティブ ライブラリ制約
ビューワーメニュー・オーバーレイ Flutter UI統一性と開発効率

Flutter ↔ ネイティブ間の通信基盤

ハイブリッド構成では、Flutter実装とネイティブ実装の連携が不可欠です。 MethodChannel を使用することで、双方向の関数呼び出しとデータ受け渡しを実現します。詳細な実装については後述の「複数FlutterEngine実装の詳細」セクションで説明します。


複数FlutterEngine実装の詳細

前章で複数FlutterEngine構成のアーキテクチャを説明しましたが、実際の実装では「Flutterとネイティブ間の通信」が重要な要素となります。本章では、具体的な実装方法について詳しく解説していきます。

Flutter-ネイティブ間通信の必要性

ハイブリッド構成では、Flutterで実装された画面とネイティブで実装されたビューワーと連携する必要があります。具体的には以下のような通信が発生します。

  • Flutter → ネイティブ: 本棚画面からのビューワー起動
  • ネイティブ → Flutter: ビューワーからのメニュー表示要求
  • データ共有: 読書設定や書籍情報の同期

これらの通信を実現するために、FlutterではMethod Channelという仕組みを使用します。

Method Channelの概要

Method Channelは、FlutterとネイティブOS(Android/iOS)間で双方向の関数呼び出しを可能にする通信基盤です。

Method Channelの特徴

  • 双方向通信: Flutter → ネイティブ、ネイティブ → Flutter両方向の呼び出しが可能
  • 非同期処理: ネイティブ側の処理完了を待つことができる
  • 型安全: パラメータの型チェックと変換を自動処理
  • プラットフォーム統一: Android/iOS共通のAPIで実装可能

基本的な通信フロー

  1. Channel作成: Flutter側とネイティブ側で同じ名前のChannelを作成
  2. ハンドラー登録: 各側で受信した呼び出しを処理するハンドラーを登録
  3. 関数呼び出し: 一方から他方へ関数名とパラメータを送信
  4. 結果返却: 処理完了後、結果を呼び出し元に返却

Method Channelの実装方法

ハイブリッド構成の実装へ入る前に、Method Channelの具体的な実装方法について詳しく説明します。

Flutter側の実装

// Flutter側での基本的な実装
class NativeInterface {
  // MethodChannelに渡すnameパラメータはネイティブと合わせる必要がある
  static const _channel = MethodChannel('methodChannelName');
  
  // Flutter → ネイティブへの呼び出し
  static Future<void> openViewer(String bookId) async {
    await _channel.invokeMethod('openViewer', {'bookId': bookId});
  }
  
  // ネイティブ → Flutter からの呼び出し受信
  static void setupCallbacks() {
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onMenuRequested':
          // メニュー表示要求の処理
          break;
      }
    });
  }
}

Android側の実装

class MainActivity : FlutterFragmentActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        // Flutterで定義したnameと同じ名称を指定
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        
        // Flutter → ネイティブ からの呼び出し受信
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ネイティブ → Flutterへの呼び出し
        channel.invokeMethod("onMenuRequested")
    }
}

この通信基盤により、Flutterの本棚画面からネイティブビューワーの起動、そしてビューワー上でのFlutterメニュー表示制御が可能になります。

複数FlutterEngine構成の実装アプローチ

前章で説明した通り、単一のFlutterEngineでは本棚の状態を保持したままビューワー上にメニューを表示できません。

解決策:独立した複数Engine構成

  • メインEngine: 本棚画面専用のFlutterEngine(状態を継続維持)
  • オーバーレイEngine: ビューワーメニュー専用のFlutterEngine(独立したUI表示)

この構成により、各Engineが独立した状態を管理し、本棚の状態を保持したままビューワー上にメニュー表示が可能になります。

実装の全体的な流れ

  1. Flutter側エントリーポイント追加: オーバーレイEngine用の起動関数を定義
  2. Android側Engine管理: 複数のFlutterEngineを生成・キャッシュ・制御
  3. UI表示制御: ネイティブ画面上でのFlutterView表示/非表示切り替え

それでは、各ステップの詳細な実装を見ていきましょう。

Flutter側エントリーポイントの追加

main.dartトップレベルでVMのエントリーポイントを定義します。

// エントリーポイント追加
@pragma("vm:entry-point")
void showViewerMenu() {
  runApp(
    MaterialApp(
      navigatorKey: navigatorKey,
      color: Colors.transparent,
      home: ViewerMenu(),
    ),
  );
}
// ここまで

void main() {
  runApp(MyApp());
}

Android側での複数Engine管理

configureFlutterEngine()で渡されるEngineとは別のエンジンを生成し、他のActivityからも参照できるようにキャッシュしておきます。

class MainActivity : FlutterFragmentActivity() {

    // FlutterEngineをキャッシュする名称
    companion object {
        const val ENGINE_GROUP_NAME = "flutter_engine_group"
        const val MAIN_ENGINE_NAME = "flutter_main_engine"
        const val OVERLAY_ENGINE_NAME = "flutter_overlay_engine"
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ここから追加

        // FlutterEngineGroupを作成
        val engineGroup = FlutterEngineGroup(this)
        
        // オーバーレイ用Engineのエントリーポイント設定
        val overlayEntryPoint =
            DartExecutor.DartEntrypoint(
                FlutterInjector.instance().flutterLoader().findAppBundlePath(),
                "showViewerMenu",   // main.dartで追加したエントリーポイントの関数名と合わせる
            )
        
        // オーバーレイ用Engineを実行
        val overlayEngine =
            engineGroup.createAndRunEngine(
                applicationContext,
                overlayEntryPoint,
            )

        // EngineGroup をキャッシュに登録
        FlutterEngineGroupCache
            .getInstance()
            .put(ENGINE_GROUP_NAME, engineGroup)

        // メイン画面用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(MAIN_ENGINE_NAME, flutterEngine)

        // オーバーレイ用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(OVERLAY_ENGINE_NAME, overlayEngine)

        // ここまで
    }
}

Engine間でのデータ共有の重要な注意点

複数のFlutterEngineを使用する際の大きな制約として、各Engineは独立したメモリ空間を持つことになります。

問題点:

  • staticな変数やシングルトンインスタンスは各Engine間で共有されない
  • Providerや状態管理ライブラリ(Riverpod、BLoC等)のインスタンスも別物となる
  • メインEngine(本棚)とオーバーレイEngine(メニュー)間で直接的なデータ共有は不可能

解決策:

Engine間でデータを共有する場合は、物理的な永続化ストレージを介する必要があります。

具体的な実装方法については、後述の「ネイティブとFlutterでの共通Preference管理」セクションで詳しく説明します。

この制約により、複数Engine間での状態管理設計には特に注意が必要です。

実際の表示ロジック

Androidでの表示実装:

ビューワーの上へ重ねるようUI構成にFlutterViewを追加し、visibilityで表示/非表示の切り替えを行うようにしてみます。 まずはレイアウトファイルです。画面全体を占めるビューワーUIの上に、メニュー用のFlutterViewを重ねます。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/viewer_activity"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ViewerView
        android:id="@+id/viewer_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <io.flutter.embedding.android.FlutterView
        android:id="@+id/menu_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>

続いてActivityの実装です。 オーバーレイEngineを使用するように書き換えます。

class ViewerActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // レイアウト読み込み
        setContentView(R.layout.viewer)

        // オーバーレイ用Engineを使用するよう修正
        val overlayEngine = FlutterEngineCache
                .getInstance()
                .get(FlutterMainActivity.OVERLAY_ENGINE_NAME)
        overlayEngine?.let { engine ->
            val flutterView = findViewById<FlutterView>(R.id.menu_view)
            flutterView?.apply {
                setBackgroundColor(Color.TRANSPARENT)
                attachToFlutterEngine(engine)
                visibility = View.GONE
            }
        }
    }
}

2つの独立した Widget ツリーを並行管理することで、ネイティブビューワー上でのFlutterオーバーレイ表示を実現しています。


ネイティブとFlutterでの共通Preference管理

前章で複数FlutterEngineの実装について説明しましたが、Engine間で直接的なデータ共有ができないという制約があります。 この制約を解決するため、SharedPreferences(Android)、UserDefaults(iOS)をネイティブとFlutter双方からアクセスしてデータ共有するアプローチを説明します。

統一アプローチ

ネイティブとFlutterで統一データにアクセスする必要がある場合、Preferenceをどのように扱えば良いでしょうか。

Preferenceストレージの使い分け戦略

// Flutter側での実装例
class PreferenceManager {
  Future<void> saveSamplePreference() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('test_shared_key', 'abc123');
  }
}

Android実装例

key名称にflutter.プレフィックスをつけて呼び出すと、Flutter側でsetした値を取得できます。 iOSのUserDefaultsでも同様です。

class PreferenceManager {
    fun getSamplePreference(context: Context) {
        val pref = context.getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE);
        val refValue = pref.getString("flutter.test_shared_key", null);
        print($refValue);
    }
}

まとめ

Flutter移行を検討している方へのアドバイス

移行前の検討事項

  1. 制約の洗い出し: ネイティブライブラリやプラットフォーム固有機能の依存関係
  2. 段階的な移行戦略: 一括移行ではなく、機能単位での段階的アプローチ
  3. パフォーマンス要件: ユーザー体験を損なわない移行計画

成功のポイント

  • プロトタイプでの検証: 複雑な要件は事前に技術検証を実施
  • チーム内の知識共有: ネイティブとFlutter両方のスキル習得
  • 継続的な最適化: リリース後もパフォーマンスモニタリングを継続

複雑な制約がある場合でも、適切なアーキテクチャ設計により Flutter移行は十分可能です。 ただし今回の場合、FlutterとネイティブAndroid/iOSの知識がエンジニアに求められ、学習コストは高くなります。 本記事が同様の課題に取り組む方々の参考になれば幸いです。


読んでいただき、ありがとうございました。 ご質問やご感想がありましたら、お気軽にコメントください。