Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOS / Android ネイティブ 実装アプリの Flutter 化事例

mthiroshi
March 04, 2024

iOS / Android ネイティブ 実装アプリの Flutter 化事例

エキサイトHDで行われた技術者向けの社内カンファレンス「Excite × iXIT TechCon 2024」で登壇発表した資料です。
Flutter アプリ開発の理解を目的とした ネイティブ実装アプリの Flutter 化事例を紹介しました。

「Excite × iXIT TechCon 2024」についての紹介記事です。
https://tech.excite.co.jp/entry/2024/02/27/134912

mthiroshi

March 04, 2024
Tweet

More Decks by mthiroshi

Other Decks in Programming

Transcript

  1. 6

  2. • 共通コードベースで iOS, Android を開発 • コードの変更を UI に即時反映する HotReload

    機能 → 従来のネイティブ開発と⽐較してビルド時間が短く、開発体験が良い • クロスプラットフォームではレンダリングパフォーマンスが⾼い Flutter の特徴 7
  3. • 2021/06 ローリエプレス(リプレース) • 2021/12 エキサイトニュース(リプレース) • 2022/02 ウーマンエキサイト(リプレース) •

    2022/06 Myエキサイトモバイル(新規) • 2022/10 BB.exciteでんわ(新規) • 2023/10 E・レシピ(リプレース) エキサイトの Flutter アプリ変遷 8
  4. デモアプリ import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class

    MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } 11 • runApp() でアプリ起動 • MyApp アプリケーションのルート
  5. Widget Flutter における UI を構築するためのコンポーネント • テキストやボタンのUI部品 • リスト表⽰や中央揃えのレイアウト部品 12

    void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); class Text extends StatelessWidget { const Text( String this.data, { super.key, this.style, this.strutStyle, TextWidgetの定義 デモアプリのMyAppクラス Widget を組み合わせて UI を構築
  6. デモアプリ import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class

    MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } 13 • build() 内に UI を実装する • MaterialApp : マテリアルデザイン のアプリケーションを実装する Widget
  7. デモアプリ class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget

    build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } 14 • themeプロパティでアプリの テーマカラーを設定
  8. デモアプリ class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget

    build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } 15 • MyHomePage が画⾯実装の実体
  9. class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final

    String title; @override State<MyHomePage> createState() => _MyHomePageState(); } 16 デモアプリ MyHomePage は StatefulWidget を継承 • 状態管理をする画⾯
  10. 18 class _MyHomePageState extends State<MyHomePage> { int _counter = 0;

    void _incrementCounter() { setState(() { _counter++; }); } デモアプリ • _counter : カウント値 • _incrementCounter() : _counter の増分を実装
  11. 19 @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(

    backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } デモアプリ
  12. • Center : 中央揃え • Column : 縦に配置 ◦ Text

    : “You have …” ◦ Text : カウント値 20 body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), デモアプリ
  13. デモアプリの実装からわかること • Widget を組み合わせてUIを構築 • 状態管理を⾏う画⾯ ◦ StatefulWidget を継承 ◦

    FloatingActionButton の onPressed にコールバック関数を設定 22 Flutter アプリの開発
  14. プロジェクト • 開発期間:2022/08 - 2023/10 • 開発体制 ◦ アプリ:1⼈ ▪

    夏季に学⽣インターンが 1, 2名スポット参加 ▪ 開発末期に他チームから1名スポット参加 ◦ アプリAPI:1⼈(アプリと兼任) ネイティブ版と遜⾊ないパフォーマンスを実現 E・レシピ の Flutter リプレース 24
  15. UI の実装例 • ⼀般的なレイアウトは、SDK 標準のWidget ◦ Widget Catalog を⾒てみる •

    SDK 標準の Widget だけで困難な場合は、サードパーティパッケージも 有⽤ 31
  16. UI Layer UI elements • UI パーツの配置 • 画⾯にデータ表⽰ State

    holders (ViewModel) • アプリ内のデータを保持し、UIに公開 • アプリ内のデータを監視し、状態の更新 38
  17. ViewModel ViewModel の初期化 • Repository からデータを取得 • UiState クラスに変換、データ を保持

    39 @riverpod class RecipeDetailViewModel extends _$RecipeDetailViewModel { @override Future<RecipeDetailUiState> build() async { final recipe = await ref.watch (recipeRepositoryProvider).getRecipe (); return RecipeDetailUiState( id: recipe.id, name: recipe.name, imageUrl: recipe.imageUrl, description: recipe.description, publishDate: recipe.publishDate, ); } ※ DI、状態管理パッケージ 「Riverpod」 実装
  18. ViewModel Widget へデータ反映 • ViewModel からデータを取得 • ListView 内でレシピ画像を表⽰ 40

    class RecipeDetailScreen extends ConsumerWidget { const RecipeDetailScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar(...), body: ref.watch(recipeDetailViewModelProvider).when( data: (uiState) { return ListView( children: [ Image.network( uiState.imageUrl, fit: BoxFit.cover, ), ※ DI、状態管理パッケージ 「Riverpod」 実装
  19. ViewModel ViewModel に イベントハンドラの実装 • onTapFavorite() ◦ API リクエスト ◦

    UiState の更新 41 Future<void> onTapFavorite() async { final recipe = await state.value; await ref .watch(favoriteRepositoryProvider) .addFavorite(recipe.id); state = AsyncValue.data( RecipeDetailUiState( id: recipe.id, name: recipe.name, imageUrl: recipe.imageUrl, description: recipe.description, publishDate: recipe.publishDate, isFavorite: true, ), ); } ※ DI、状態管理パッケージ 「Riverpod」 実装
  20. Domain Layer • UI レイヤーとデータレイヤーの間に位置 • ビジネスロジックをカプセル化 ◦ 複雑なビジネスロジック ◦

    複数のViewModelで再利⽤される単純なビジネスロジック • ユースケース、インタラクタとも呼称 42
  21. ディレクトリ構成 ├── repository │ └── recipe │  ├── recipe_remote_data_source.dart │ 

    └── recipe_repository.dart ├── usecase │ └── recipe_usecase.dart └── view   └── recipe_detial ├── recipe_detail_screen.dart ├── recipe_detail_ui_state.dart └── recipe_detail_view_model.dart 44
  22. ユーザーデータのマイグレーション ローカルで保持するユーザーデータ • ユーザーID • レシピの閲覧履歴 • 買い物リスト ◦ レシピ

    ◦ レシピに含まれる⾷材と分量 ◦ チェック判定 ユーザーデータ維持のためマイグレーションが必要 47
  23. ユーザーデータのマイグレーション 48 Realm 旧 iOS 版 (ネイティブ) iPhone データ領域 新

    iOS 版 (Flutter) SQLite (Drift) SQLite (greenDAO) Android データ領域 新 Android 版 (Flutter) プラットフォームごとに保存するデータ領域が異なる 旧 Android 版 (ネイティブ) SQLite (Drift)
  24. ユーザーデータのマイグレーション プラットフォームごとに保存するデータ領域が異なる問題 • データ取得の処理を2つ⽤意する必要がある • データ取得を Flutter 側で実装する場合 ◦ 実装⽅法の検証

    ◦ コードが冗⻑になる懸念 ネイティブでデータの取得を⾏う⽅針 • ネイティブ版のコードを流⽤できる • Flutter はデータを受け取って保存だけになる 49
  25. Pigeon • Flutter - ネイティブ間の通信 ◦ Flutter - ネイティブ間の処理を呼び出せる •

    インターフェース、データ⽤クラスを定義して型安全に通信可能 50
  26. Pigeon - [Flutter] インターフェース定義 51 @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(); } • DemoAppHostApi → インターフェースを定義 • loadUserID() を定義 • @ConfigurePigeon → ネイティブで実装する インターフェース、データ⽤ク ラスの⾃動⽣成の出⼒先を設定
  27. Pigeon - [Flutter] コードの自動生成 52 flutter pub run pigeon --input

    pigeon.dart Flutter 側の定義を基に各プラットフォームごとにインターフェース、データ⽤ クラスを⾃動⽣成するコマンドを実⾏
  28. Pigeon - [iOS - Swift] 実装 53 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 } • ⾃動⽣成されたインターフェース を実装 • Realm インスタンスの取得 • Realm インスタンスからデータ 取得
  29. Pigeon - [Android - Kotlin] 実装 class DemoAppHostApiImpl( private val

    activity: Activity, private val database: AppDatabase, ) : DemoAppHostApi { override fun loadUserId(): String { return database.userDao().getUser().userId; } } 54 • ⾃動⽣成されたインターフェース を実装 • DB インスタンスを⽤意 • DB インスタンスからデータを取得
  30. Pigeon - Flutter からインターフェースを使う • DemoAppHostApi インスタンス ⽣成 • loadUserId()

    でユーザー ID 取得 • Flutter の DB にユーザー ID 保存 55 class Migration { Migration(); final api = DemoAppHostApi(); final userRepository = UserRepository(); Future<void> migrate() async { try { final userId = await api.loadUserId(); await repository.saveUserId(userId); } on Exception catch (e) { // エラー処理 } } }
  31. まとめ • Flutter アプリ開発の基本知識 ◦ Widget を組み合わせた UI 構築 ◦

    状態管理 • E‧レシピのリプレース事例 ◦ UI の実装例 ▪ SDK 標準 Widget、サードパーティパッケージを活⽤ ◦ Android アプリアーキテクチャ を採⽤ ◦ Flutter リプレースの課題 ▪ ユーザーデータのマイグレーションが必要 ▪ プラットフォームごとに保存するデータ領域が異なる ▪ Pigeonパッケージを使った安全なFlutter - ネイティブ間の通信 57