Slide 1

Slide 1 text

iOS / Android ネイティブ 実装アプリの Flutter 化事例 TechCon2024 メディア‧プラットフォーム事業部 武藤 寛 1

Slide 2

Slide 2 text

● 武藤寛 ● エキサイト株式会社 メディア‧プラットフォーム事業部 ● 「E‧レシピ」担当 自己紹介 2 @mthiroshi_4o

Slide 3

Slide 3 text

E・レシピ ● 料理のプロが作る簡単レシピサイト ● アプリ版を2023年10⽉に ネイティブ実装からFlutter リプレース 3

Slide 4

Slide 4 text

本発表の目標 ● アプリのネイティブ実装から Flutter 実装へのリプレース事例を基にした アプリ開発のノウハウ共有 ● Flutter リプレース事例の理解を深めるため、Flutter のアプリ開発の基本 知識の共有 4

Slide 5

Slide 5 text

話すこと ● Flutter アプリの開発 ● E‧レシピの Flutter リプレース事例 ○ 画⾯実装例 ○ アーキテクチャ ○ Flutter リプレースの課題 5

Slide 6

Slide 6 text

6

Slide 7

Slide 7 text

● 共通コードベースで iOS, Android を開発 ● コードの変更を UI に即時反映する HotReload 機能 → 従来のネイティブ開発と⽐較してビルド時間が短く、開発体験が良い ● クロスプラットフォームではレンダリングパフォーマンスが⾼い Flutter の特徴 7

Slide 8

Slide 8 text

● 2021/06 ローリエプレス(リプレース) ● 2021/12 エキサイトニュース(リプレース) ● 2022/02 ウーマンエキサイト(リプレース) ● 2022/06 Myエキサイトモバイル(新規) ● 2022/10 BB.exciteでんわ(新規) ● 2023/10 E・レシピ(リプレース) エキサイトの Flutter アプリ変遷 8

Slide 9

Slide 9 text

Flutter アプリの開発 9

Slide 10

Slide 10 text

デモアプリ AndroidStudio のデモアプリ ● ボタンをタップすると中央の数字がカウントアップ 10

Slide 11

Slide 11 text

デモアプリ 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 アプリケーションのルート

Slide 12

Slide 12 text

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 を構築

Slide 13

Slide 13 text

デモアプリ 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

Slide 14

Slide 14 text

デモアプリ 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プロパティでアプリの テーマカラーを設定

Slide 15

Slide 15 text

デモアプリ 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 が画⾯実装の実体

Slide 16

Slide 16 text

class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } 16 デモアプリ MyHomePage は StatefulWidget を継承 ● 状態管理をする画⾯

Slide 17

Slide 17 text

状態管理 17 ● 表⽰するデータや UI の状態を管理 ● ユーザー操作に応じて変化 デモアプリの状態管理の対象 ● カウント値

Slide 18

Slide 18 text

18 class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } デモアプリ ● _counter : カウント値 ● _incrementCounter() : _counter の増分を実装

Slide 19

Slide 19 text

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: [ 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), ), ); } デモアプリ

Slide 20

Slide 20 text

● Center : 中央揃え ● Column : 縦に配置 ○ Text : “You have …” ○ Text : カウント値 20 body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), デモアプリ

Slide 21

Slide 21 text

floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), 21 デモアプリ ● onPressed プロパティで _incrementCounter を設定

Slide 22

Slide 22 text

デモアプリの実装からわかること ● Widget を組み合わせてUIを構築 ● 状態管理を⾏う画⾯ ○ StatefulWidget を継承 ○ FloatingActionButton の onPressed にコールバック関数を設定 22 Flutter アプリの開発

Slide 23

Slide 23 text

E・レシピの Flutter リプレース 23

Slide 24

Slide 24 text

プロジェクト ● 開発期間:2022/08 - 2023/10 ● 開発体制 ○ アプリ:1⼈ ■ 夏季に学⽣インターンが 1, 2名スポット参加 ■ 開発末期に他チームから1名スポット参加 ○ アプリAPI:1⼈(アプリと兼任) ネイティブ版と遜⾊ないパフォーマンスを実現 E・レシピ の Flutter リプレース 24

Slide 25

Slide 25 text

UI の実装例 25

Slide 26

Slide 26 text

ホーム画面 ● 起動時に表⽰される画⾯ ● 画⾯下部にタブを設置し、タブの タップで画⾯が切り替わる UI → BottomNavigationBar 26

Slide 27

Slide 27 text

クッキングモード画面 ● 調理⼯程画像を横向きに表⽰ ● スワイプで画像を切り替えるUI → PageView 27

Slide 28

Slide 28 text

Widget catalog 28 引⽤: https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html

Slide 29

Slide 29 text

Widget catalog 29 引⽤: https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html ● ブラウザ上実際の挙動を試せる

Slide 30

Slide 30 text

プレミアム誘導画面 - バナー バナーのスクロールに合わせたインジケーター → smooth_page_indicator (サードパーティパッケージ) 30

Slide 31

Slide 31 text

UI の実装例 ● ⼀般的なレイアウトは、SDK 標準のWidget ○ Widget Catalog を⾒てみる ● SDK 標準の Widget だけで困難な場合は、サードパーティパッケージも 有⽤ 31

Slide 32

Slide 32 text

アーキテクチャ 33

Slide 33

Slide 33 text

Android アプリアーキテクチャ(MVVM) 34 引⽤:https://developer.android.com/jetpack/guide?hl=ja

Slide 34

Slide 34 text

Data Layer 35 引⽤:https://developer.android.com/jetpack/guide?hl=ja#data-layer

Slide 35

Slide 35 text

Data Layer リポジトリ ● アプリのデータ操作を管理する中間層 ● データソースからデータを取得し、モデルに変換 データソース ● 実際のデータアクセスを実装 (例)API、ローカルDB etc. 36

Slide 36

Slide 36 text

UI Layer 37 引⽤:https://developer.android.com/jetpack/guide?hl=ja#ui-layer

Slide 37

Slide 37 text

UI Layer UI elements ● UI パーツの配置 ● 画⾯にデータ表⽰ State holders (ViewModel) ● アプリ内のデータを保持し、UIに公開 ● アプリ内のデータを監視し、状態の更新 38

Slide 38

Slide 38 text

ViewModel ViewModel の初期化 ● Repository からデータを取得 ● UiState クラスに変換、データ を保持 39 @riverpod class RecipeDetailViewModel extends _$RecipeDetailViewModel { @override Future 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」 実装

Slide 39

Slide 39 text

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」 実装

Slide 40

Slide 40 text

ViewModel ViewModel に イベントハンドラの実装 ● onTapFavorite() ○ API リクエスト ○ UiState の更新 41 Future 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」 実装

Slide 41

Slide 41 text

Domain Layer ● UI レイヤーとデータレイヤーの間に位置 ● ビジネスロジックをカプセル化 ○ 複雑なビジネスロジック ○ 複数のViewModelで再利⽤される単純なビジネスロジック ● ユースケース、インタラクタとも呼称 42

Slide 42

Slide 42 text

Domain Layer (例)レシピに紐づく⾷材の分量テキスト⽣成 下記の画⾯で使⽤される ● レシピ詳細 ● 献⽴詳細 ● 買い物リスト 43

Slide 43

Slide 43 text

ディレクトリ構成 ├── 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

Slide 44

Slide 44 text

アーキテクチャを採用してみて ● ⼀般的なアーキテクチャの恩恵を得られた ○ 責務の分離、ビジネスロジックの再利⽤ ● Android 開発者が多かったので学習コストを抑えられた ● アプリ全体で管理する状態は「xxxState」 として状態管理し、ViewModel が 参照 ○ ユーザーの課⾦状態 ○ お気に⼊りレシピリスト 45

Slide 45

Slide 45 text

Flutter リプレースの課題 46

Slide 46

Slide 46 text

ユーザーデータのマイグレーション ローカルで保持するユーザーデータ ● ユーザーID ● レシピの閲覧履歴 ● 買い物リスト ○ レシピ ○ レシピに含まれる⾷材と分量 ○ チェック判定 ユーザーデータ維持のためマイグレーションが必要 47

Slide 47

Slide 47 text

ユーザーデータのマイグレーション 48 Realm 旧 iOS 版 (ネイティブ) iPhone データ領域 新 iOS 版 (Flutter) SQLite (Drift) SQLite (greenDAO) Android データ領域 新 Android 版 (Flutter) プラットフォームごとに保存するデータ領域が異なる 旧 Android 版 (ネイティブ) SQLite (Drift)

Slide 48

Slide 48 text

ユーザーデータのマイグレーション プラットフォームごとに保存するデータ領域が異なる問題 ● データ取得の処理を2つ⽤意する必要がある ● データ取得を Flutter 側で実装する場合 ○ 実装⽅法の検証 ○ コードが冗⻑になる懸念 ネイティブでデータの取得を⾏う⽅針 ● ネイティブ版のコードを流⽤できる ● Flutter はデータを受け取って保存だけになる 49

Slide 49

Slide 49 text

Pigeon ● Flutter - ネイティブ間の通信 ○ Flutter - ネイティブ間の処理を呼び出せる ● インターフェース、データ⽤クラスを定義して型安全に通信可能 50

Slide 50

Slide 50 text

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 → ネイティブで実装する インターフェース、データ⽤ク ラスの⾃動⽣成の出⼒先を設定

Slide 51

Slide 51 text

Pigeon - [Flutter] コードの自動生成 52 flutter pub run pigeon --input pigeon.dart Flutter 側の定義を基に各プラットフォームごとにインターフェース、データ⽤ クラスを⾃動⽣成するコマンドを実⾏

Slide 52

Slide 52 text

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 インスタンスからデータ 取得

Slide 53

Slide 53 text

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 インスタンスからデータを取得

Slide 54

Slide 54 text

Pigeon - Flutter からインターフェースを使う ● DemoAppHostApi インスタンス ⽣成 ● loadUserId() でユーザー ID 取得 ● Flutter の DB にユーザー ID 保存 55 class Migration { Migration(); final api = DemoAppHostApi(); final userRepository = UserRepository(); Future migrate() async { try { final userId = await api.loadUserId(); await repository.saveUserId(userId); } on Exception catch (e) { // エラー処理 } } }

Slide 55

Slide 55 text

Flutter リプレースの課題 ● リプレースの場合にユーザーデータのマイグレーションが必要 ○ UI を持たない機能のため考慮漏れに注意 ○ 初期段階に実装の検証を推奨 ● ネイティブコードを呼び出す場合、PIgeon によって型安全に通信が可能 56

Slide 56

Slide 56 text

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