iOS / Android ネイティブアプリから Flutter に移行するときに Pigeon を使ってデータマイグレーションする

エキサイト株式会社の@mthiroshiです。 エキサイトホールディングス Advent Calendar 2023の8日目を担当します。

エキサイトでは、 iOS / Android ネイティブアプリの Flutter によるリビルドを行ってきました。 ローカル DB でデータを管理するアプリの場合、ネイティブ版のデータを Flutter 版にマイグレーションする必要があります。

今回は、Flutter アプリのデータマイグレーションの事例を紹介します。

ネイティブアプリの前提

今回のアプリ移行では、移行前のネイティブ版と移行後の Flutter 版でアプリケーション ID が同一であることを前提としています。 それはつまり、アプリストア上では同一のアプリと見なされ、アプリのアップデートでネイティブから Flutter に移行が起きる状態となります。

各ネイティブアプリのローカル DB には、下記のライブラリを採用していました。

各プラットフォームで使われているライブラリは異なるため、それぞれでデータ取得や移行の処理が必要になります。

今回は、既存のネイティブ実装を流用できれば開発コストが低いと考え、Flutter からiOS / Android のネイティブ実装を呼び出すアプローチを検討しました。

Pigeon とは

Pigeon は、Flutterとネイティブコードとの型安全な通信を実現するためのパッケージです。

pub.dev

その他のネイティブコードとの通信を行う手法として、MethodChannel があります。

api.flutter.dev

MethodChannel では、ネイティブメソッドの呼び出しを文字列で行ったり、返り値が String になってしまったりと、使いにくい部分があります。 Pigeon を使うことで、インターフェースや型を定義できるので、Flutter とネイティブの通信が安全に実装しやすくなります。

Pigeon を使ったデータマイグレーション

今回は下記のバージョンで実装を行いました。

pigeon: ^11.0.1

大まかな流れとしては、下記の通りです。

  • ネイティブ - Flutter 間で使うインターフェースの定義
  • ネイティブでインターフェースの実装を定義
  • Flutterからインターフェースを呼び出す

Pigeon インターフェースの定義

まず、Flutterとネイティブコードのインターフェースやデータクラスを定義します。

下記がサンプルコードです。

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/pigeon/pigeon.g.dart',
    kotlinOut: 'android/app/src/main/java/com/example/demo/pigeon/Pigeon.g.kt',
    kotlinOptions: KotlinOptions(
      package: 'com.example.demo.pigeon',
    ),
    objcHeaderOut: 'ios/Runner/Pigeon/Pigeon.h',
    objcSourceOut: 'ios/Runner/Pigeon/Pigeon.m',
    swiftOut: 'ios/Runner/Pigeon/Pigeon.g.swift',
    swiftOptions: SwiftOptions(),
  ),
)
@HostApi()
abstract class DemoAppHostApi {
  String loadUserId();

  List<Book> loadBookHistory();
}

class Book {
  Book(
    this.title,
    this.author,
  );

  String title;
  String author;
}

DemoAppHostApi を abstract class で定義し、ネイティブのローカル DB からデータを取得するためのメソッドを用意します。 @HostApi アノテーションを付与することで、ネイティブ用のコードを生成します。 コード生成するときのファイル名を指定するため、 @ConfigurePigeon アノテーションPigeonOptions でそれぞれファイルパスを指定します。 必要であれば、データクラスも定義します。

Pigeon の設定ファイルを用意したら、下記のコマンドを実行します。

flutter pub run pigeon --input pigeon.dart

--input pigeon.dart は、 上記の設定ファイルを入力値として与えています。 このコマンドによって、PigeonOptions で設定した内容を基に Flutter, Swift, Kotlin それぞれのプラットフォームに合わせたインターフェース、データクラスが生成されます。 ネイティブコードでは、このインターフェースを使ってその実装を定義し、Flutter 側でそのインターフェースを呼び出します。

iOS のインターフェース実装

生成された Pigeon のインターフェースを使って iOS の ローカル DB のデータ取得を実装します。

import Foundation
import RealmSwift

extension FlutterError: Error {}

class DemoAppHostApiImpl: NSObject, DemoAppHostApi {
    
    private let realm: Realm

    init(realm: Realm) {
        self.realm = realm
    }

    func loadUserId() throws -> String {
        let user = self.realm.objects(User.self)
        
        return user.userId
    }
    
    func loadBookHistory() throws -> [Book] {
        let history = self.realm.objects(BookHistory.self)
        var migrateBookHistories: [Book] = []
        for book in history {
            migrateBookHistories.append(
                Book(
                    title: book.title,
                    author: book.author,
                    imageUrl: book.imageUrl,
                    readDate: Int64(book.viewedAt.timeIntervalSince1970 * 1000)
                )
            )
        }

        return migrateBookHistories
    }
}

インターフェースで定義したメソッドの実装を記述しています。 事例のアプリでは、Realm を使っていたので Realm オブジェクトからローカル DB に参照しています。 サンプルコードには含まれていませんが、Realm のモデル定義が必要であり、ネイティブ版アプリと同じ内容を Flutter 版のネイティブコードに移植してきます。

続いて、AppDelegate.swift に初期化の処理を追加します。

import UIKit
import Flutter
import RealmSwift
import os
import Foundation

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        setupHostApi()
        
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    
    private func setupHostApi() -> Void {
            let config = Realm.Configuration(
                schemaVersion: 2
            )
            Realm.Configuration.defaultConfiguration = config
            
            let realm = try! Realm()
            let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
            DemoAppHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: DemoAppHostApiImpl(realm: realm))
    }
}

AppDelegate で Realm の初期化をしています。 事例のケースでは、ネイティブ版と Flutter ネイティブコードで Realm のバージョンに差異があったので、スキーママイグレーションを行っています。

Pigeon で生成されたコードにDemoAppHostApiSetup.setUp() が用意されているので、それを呼び出してHostApiの初期化を行います。

Android のインターフェース実装

ネイティブの Android では、greenDAO を使っていましたが、Flutter 版ネイティブコードでは、Room を使ってローカル DB に参照します。 greenDAO も Room も SQLite ラッパーとなっているため、sqlite ファイルが同じであれば同一の DB を参照できます。

Pigeon のインターフェースを実装したクラスを作り、DBからデータを取得する処理を実装します。

class DemoAppHostApiImpl(
    private val activity: Activity,
    private val database: AppDatabase,
) : DemoAppHostApi {

    override fun loadUserId(): String {
        return database.userDao().getUser().userId;
    }

    override fun loadBookHistory(): List<Book> {
        return database.bookHistoryDao().getBook().map { book ->
            Book(
                title = book.title,
                author = book.author,
                imageUrl = book.imageUrl,
                readDate = book.readDate,
            )
        }
    }
}

iOS 同様にインターフェースで用意されたメソッドの実装を記述しています。 こちらも Room のスキーマ定義等が別途必要になります。

続いて、MainActivity でマイグレーションの初期化を行います。

import androidx.room.Room
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import com.example.app.pigeon.DemoAppHostApi
import com.example.app.pigeon.DemoAppHostApi

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

        setupMigration(flutterEngine)
    }

    override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
        super.cleanUpFlutterEngine(flutterEngine)
    }

    private fun setupMigration(flutterEngine: FlutterEngine) {
        val database = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java,
            'demo_db',
        )
            .allowMainThreadQueries()
            .build()
        DemoAppHostApi.setUp(
            flutterEngine.dartExecutor.binaryMessenger,
            DemoAppHostApiImpl(this, database)
        )
    }
}

AndroidDemoAppHostApi.setUp() が Pigeon よって生成されているので、呼び出して初期化します。

Flutter から Pigeon のインターフェースを呼び出す

ネイティブコードの実装を終えたら、Flutter から Pigeon のインターフェースを利用して、ローカルDBからデータを取得します。

class Migration {
  Migration();

  final api = DemoAppHostApi();

  final userRepository = UserRepository();

  final bookRepository = BookRepository();

  Future<void> migrate() async {
    try {
      final userId = await api.loadUserId();

      await repository.saveUserId(userId);

      final bookHistory = await api.loadBookHistory();

      await repository.saveBookHistory(bookHistory);
    } on Exception catch (e) {
       // エラー処理
    }
  }
}

Flutter からは、iOS / Androidを意識せずにインターフェースを呼び出せます。 データ取得後、Flutter の保存処理にデータを渡せばマイグレーション完了です。

まとめ

今回は iOS / Android ネイティブアプリで作成したローカル DB のデータを Flutter 版アプリに移行する際に、Pigeon を使う事例を紹介しました。 MethodChannel に比べて型安全に実装ができますので、極力は Pigeon を使うことが望ましいと思います。 参考になれば幸いです。

最後に

エキサイトではフロントエンジニア、バックエンドエンジニア、アプリエンジニアを随時募集しております。長期インターンも歓迎していますので、興味があれば連絡いただければと思います。

募集職種一覧はこちらになります!(カジュアルからもOK) www.wantedly.com

参考記事

pigeon | Dart Package

MethodChannel class - services library - Dart API

Realm Home | Realm.io

GitHub - greenrobot/greenDAO: greenDAO is a light & fast ORM solution for Android that maps objects to SQLite databases.

Room を使用してローカル データベースにデータを保存する  |  デベロッパー向け Android  |  Android Developers

【Flutter】MethodChannelは直接使うことなかれ。型安全で楽に実装できる「Pigeon」の使い方のほぼ全て。 - chikach.net

【Flutter】型安全に MethodChannel でネイティブとデータ通信を行う