エキサイト株式会社の@mthiroshiです。 エキサイトホールディングス Advent Calendar 2023の8日目を担当します。
エキサイトでは、 iOS / Android ネイティブアプリの Flutter によるリビルドを行ってきました。 ローカル DB でデータを管理するアプリの場合、ネイティブ版のデータを Flutter 版にマイグレーションする必要があります。
今回は、Flutter アプリのデータマイグレーションの事例を紹介します。
ネイティブアプリの前提
今回のアプリ移行では、移行前のネイティブ版と移行後の Flutter 版でアプリケーション ID が同一であることを前提としています。 それはつまり、アプリストア上では同一のアプリと見なされ、アプリのアップデートでネイティブから Flutter に移行が起きる状態となります。
各ネイティブアプリのローカル DB には、下記のライブラリを採用していました。
各プラットフォームで使われているライブラリは異なるため、それぞれでデータ取得や移行の処理が必要になります。
今回は、既存のネイティブ実装を流用できれば開発コストが低いと考え、Flutter からiOS / Android のネイティブ実装を呼び出すアプローチを検討しました。
Pigeon とは
Pigeon は、Flutterとネイティブコードとの型安全な通信を実現するためのパッケージです。
その他のネイティブコードとの通信を行う手法として、MethodChannel があります。
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) ) } }
Androidも DemoAppHostApi.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
参考記事
MethodChannel class - services library - Dart API
Room を使用してローカル データベースにデータを保存する | デベロッパー向け Android | Android Developers
【Flutter】MethodChannelは直接使うことなかれ。型安全で楽に実装できる「Pigeon」の使い方のほぼ全て。 - chikach.net