SlideShare a Scribd company logo
Flutter移行の苦労と、

乗り越えた先に得られたもの

Recruit Co., Ltd. Keisuke Kiriyama

1

• Recruit Co., Ltd.

• iOS / Flutter 

• じゃらんアプリ開発T

Keisuke Kiriyama

3

旅を、もっと豊かに

宿・ホテル予約アプリ

現在じゃらんは

Flutterへの移行に挑戦しています!

4

Flutterとは

● Google製のクロスプラットフォームSDK



● 単一のソースコードで、複数のプラットフォームの

アプリケーションを構築可能



● 開発言語: Dart

5

Flutterのコミュニティ

● 2018年12月のver1.0リリース以降、

Flutterを使用する開発者は増え続け

現在は200万人を超えた



● 国内においてもFlutterの記事や話題を目にする機会が増
え、日に日に盛り上がりを感じている

6

Flutter Spring 2020 Update.:https://medium.com/flutter/flutter-spring-2020-update-f723d898d7af, (参照2020-08-02)
国内におけるFlutterのプロダクション採用

● しかし、国内においてFlutterを

プロダクションに採用している例はそれほど多くない

● 弊社においてもFlutterを採用したのはじゃらんが初



7

Flutterどうなの?

8

実際メリット
得られるの?
課題はないの?
Flutterを採用して実際どうだったのかをお伝えします

話すこと

9

Flutterを採用して実際どうだったのかをお伝えします

1. どんな技術的課題に直面したのか



話すこと

10

Flutterを採用して実際どうだったのかをお伝えします

1. どんな技術的課題に直面したのか

2. 課題を乗り越えた結果、どんなメリットを得られたのか



話すこと

11

発表のゴール

● Flutter開発経験者

→直面した課題と得られたメリットを知り

 技術選定の際の判断材料になる



● Flutter開発未経験者

→まずはFlutter触ってみたいと思ってもらう

12

1. 前提の共有

○ じゃらんのFlutter移行

○ Flutterのレイアウト構築



2. 直面した課題



3. 得られたメリット



4. まとめ

説明の流れ

13

じゃらんのFlutter移行

14

Flutter採用の背景

● じゃらんアプリはiOS/Android共にリリースから

10年を迎え、長年に渡る開発が行われてきた







● 上記課題を解決するために、リプレースを検討

15

プロジェクトの大規模化によ
るビルド時間の増加
プロジェクト全体の
コードが古くなっている
じゃらんアプリのリプレース検討

● クロスプラットフォーム技術の検討

○ iOS/Android開発工数

○ リプレースコスト



● クロスプラットフォーム技術の中でも

Flutterの開発生産性が最も高いと実感し

Flutterの採用を決断

16

半減

17

18

19

ここから先の画面は

全てFlutterで実装

Flutterへの段階的移行

● Add-to-app(Add Flutter to existing app)を使用

● 既存のネイティブプロジェクトにFluterプロジェクトを部分的
に組み込む仕組み



20

じゃらん

アプリ

Swift

Objective-c

じゃらん遊び・体験


Flutterプロジェクト

Flutterへの段階的移行

● Add-to-app(Add Flutter to existing app)を使用

● 既存のネイティブプロジェクトにFluterプロジェクトを部分的
に組み込む仕組み



21

じゃらん

アプリ

Swift

Objective-c

じゃらん遊び・体験


Flutterプロジェクト

Flutterモジュール

Flutterのレイアウト構築

22

Flutterのレイアウト構築

● Widget

○ UIの構成情報を保持するクラス



● Widgetをツリー上に構成することによって

UIの構築を行う



23

24

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter Demo Home Page'),
),
body: Center(
child: Text(
'Hello iOSDC!!',
style: TextStyle(
fontSize: 30,
),
...
25

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter Demo Home Page'),
),
body: Center(
child: Text(
'Hello iOSDC!!',
style: TextStyle(
fontSize: 30,
),
...
26

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter Demo Home Page'),
),
body: Center(
child: Text(
'Hello iOSDC!!',
style: TextStyle(
fontSize: 30,
),
...
27

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter Demo Home Page'),
),
body: Center(
child: Text(
'Hello iOSDC!!',
style: TextStyle(
fontSize: 30,
),
...
● 』UI部品だけではなく、
「画面の中心に表示」
の様なUIの構成情報も
Widgetで表現する
直面した課題

28

直面した課題

29

1. タブ切り替えのパフォーマンス

2. Flutterの画面が初期化されない

3. ネットワーク通信がproxyサーバーを経由しない

4. Google Mapのクラッシュ

直面した課題

30

1. タブ切り替えのパフォーマンス

2. Flutterの画面が初期化されない

3. ネットワーク通信がproxyサーバーを経由しない

4. Google Mapのクラッシュ

● タブを使用して表示する情報を

切り替えるページが存在する



● このタブのページでは、

口コミの一覧や、プランの一覧を

リストで表示する

タブの切り替えをする画面

31

発生した問題

● リストのアイテムを大量に読み込んでタブを切り替える





● タブ切り替えのアニメーションが重くなってしまう

● まれにタブ切り替えのタイミングで

アプリがクラッシュすることがある

32

class TabPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
...
child: Scaffold(
appBar: AppBar(
title: Text('Tab Sample'),
bottom: TabBar(tabs: <Widget>[
const Tab(child: Text('Tab A')),
const Tab(child: Text('Tab B'))
])),
body: TabBarView(
children: <Widget>[
TabA(),
TabB(),
],
● タブのページを作成する
サンプルコード: タブのページ

33

class TabPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
...
child: Scaffold(
appBar: AppBar(
title: Text('Tab Sample'),
bottom: TabBar(tabs: <Widget>[
const Tab(child: Text('Tab A')),
const Tab(child: Text('Tab B'))
])),
body: TabBarView(
children: <Widget>[
TabA(),
TabB(),
],
● 2つのタブ
● TabA()
● TabB()
サンプルコード: タブのページ

34

class TabA extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Text('Tab A'),
);
}
}
● TabA()は画面の中心に
“Tab A”を表示するだけ
サンプルコード: TabA

35

class TabB extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(
key: PageStorageKey('TabB'),
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
);
...
● TabB()はリストを保持
● 1000個のアイテムを表示
サンプルコード: TabB

36

37

タブ切り替え

TabA
 TabB

38

TabB

• Tab Bのリストを下までスクロールする
39

TabB
 TabA

TabAに

切り替え

TabBに

切り替え

TabB

40

TabB
 TabA

TabAに

切り替え

TabBに

切り替え

TabB

● タブ切り替えのアニメーションが非
常に重くなる
● まれにクラッシュする🤔
なぜアニメーション重くなる?

● タブを切り替えた際、表示

されないタブはWidget

ツリーから除外される

41

TabA表示時
 TabB表示時

● 再度タブを表示する際には、表示するタブの

レイアウトを再計算する必要がある



なぜアニメーション重くなる?

● タブを切り替えた際、表示

されないタブはWidget

ツリーから除外される

42

TabA表示時
 TabB表示時

タブが保持するリストのアイテムの高さが可変の場合

● 1つ目のアイテムから順にレイアウト

を計算して、高さを決定しないと



なぜアニメーション重くなる?

43

…

タブが保持するリストのアイテムの高さが可変の場合

● 1つ目のアイテムから順にレイアウト

を計算して、高さを決定しないと



● 前回のスクロール位置の

アイテムを表示できない





なぜアニメーション重くなる?

44

…

前回の

スクロール位置

タブが保持するリストのアイテムの高さが可変の場合

● 1つ目のアイテムから順にレイアウト

を計算して、高さを決定しないと



● 前回のスクロール位置の

アイテムを表示できない



● この演算のために

パフォーマンスが低下

なぜアニメーション重くなる?

45

…

前回の

スクロール位置

なぜまれにクラッシュする?

● リストのレイアウトを決定する演算を行うことで、

一時的にメモリを圧迫する

● この一時的な圧迫で許容値を超えてしまった場合に

クラッシュが発生していた

46

タブ切り替え時

どう回避したか

● タブのWidgetにAutomaticKeepAliveClientMixin

を適用する

● 非表示になったタブもWidgetツリーから除外されなくなるた
め、再度レイアウトの演算が不要

47
TabA表示時にTabBが除外されない 

class TabB extends StatefulWidget {
...
class _TabBState extends State<TabB> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
key: PageStorageKey('TabB'),
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
...
@override
bool get wantKeepAlive => true;
}
● TabのWidgetを
StatefulWidgetに変更する
タブにAutomaticKeepAliveClientMixinを適用

48

class TabB extends StatefulWidget {
...
class _TabBState extends State<TabB> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
key: PageStorageKey('TabB'),
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
...
@override
bool get wantKeepAlive => true;
}
● Stateに
AutomaticKeepAlive
ClientMixin
を適用する
タブにAutomaticKeepAliveClientMixinを適用

49

class TabB extends StatefulWidget {
...
class _TabBState extends State<TabB> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
key: PageStorageKey('TabB'),
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
...
@override
bool get wantKeepAlive => true;
}
タブにAutomaticKeepAliveClientMixinを適用

50

● super.buildの呼び出し
● wantKeepAliveのgetterで
trueを返す
class TabB extends StatefulWidget {
...
class _TabBState extends State<TabB> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
key: PageStorageKey('TabB'),
itemCount: 1000,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
...
@override
bool get wantKeepAlive => true;
}
タブにAutomaticKeepAliveClientMixinを適用

51

● 一時的なメモリ圧迫も
起こらなくなる
直面した課題

52

1. タブ切り替えのパフォーマンス

2. Flutterの画面が初期化されない

3. ネットワーク通信がproxyサーバーを経由しない

4. Google Mapのクラッシュ

53

Nativeの画面
 Flutterの画面

発生した問題

● Flutterの画面を閉じて再度開く





● 前回開いた画面の状態が残ってしまっている

54

55

Nativeの画面
 Flutterの画面

56

Nativeの画面

• Flutterの画面に遷移する
57

Flutterの画面

• +のFABをタップ
• 画面の中心にタップした回数が表示
58

Nativeの画面
Flutterの画面

dismiss

59

Nativeの画面
Flutterの画面

present
dismiss

Flutterの画面

60

Nativeの画面
Flutterの画面

present
dismiss

Flutterの画面

● 前回のFlutterの画面の
状態が残ってしまっている
● 画面を破棄して再生成したら、初
期状態になるのでは?🤔
61

じゃらんTOP



present
dismiss

遊び・体験(Flutter)
遊び・体験(Flutter)

検索条件指定
 じゃらん遊び・体験予約

を再度開く

前回の検索条件のまま

62

じゃらんTOP



present
dismiss

遊び・体験(Flutter)
遊び・体験(Flutter)

検索条件指定
 遊び・体験を再度開く
 前回の検索条件のまま

● Add-to-appでFlutterの画面を表示する方法の説明
↓
● この問題の原因の説明
おさらい: Add-to-app

● 既存のネイティブプロジェクトにFluterプロジェクトを部分的
に組み込む仕組み



63

じゃらん

アプリ

Swift

Objective-c

じゃらん遊び・体験


Flutterプロジェクト

Flutterモジュール

Flutterの画面を表示するために重要なクラス

64

FlutterEngine
FlutterViewController
FlutterView
Flutter

View

Controller





View

Controller





Flutterの画面を表示するために重要なクラス

65

FlutterEngine
FlutterViewController
FlutterView
● ViewControllerの派生クラス

● FlutterViewControllerに遷移する

ことでFlutterの画面を表示

画面遷移

Flutterの画面を表示するために重要なクラス

66

FlutterEngine
FlutterViewController
FlutterView
● FlutterViewControllerに

乗っているView

● FlutterモジュールのUIが描画される

FlutterViewController

FlutterView

Flutterの画面を表示するために重要なクラス

67

FlutterEngine
FlutterViewController
FlutterView
● Dartを実行して、FlutterViewに

FlutterモジュールのUIを描画する

FlutterViewController

FlutterView

FlutterEngine

Flutterの画面を表示するために重要なクラス

68

FlutterEngine
FlutterViewController
FlutterView
● Dartを実行して、FlutterViewに

FlutterモジュールのUIを描画する

FlutterViewController

FlutterView

FlutterEngine

• Flutterの画面を描画するためには、
FlutterEngineの初期化が必要
class AppDelegate: FlutterAppDelegate {
lazy var flutterEngine = FlutterEngine(name: "my flutter engine")
override func application(_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
flutterEngine.run();
GeneratedPluginRegistrant.register(with: self.flutterEngine);
return super.application(application, didFinishLaunchingWithOptions: launchOptions);
}
}
FlutterEngineの初期化

69

• AppDelegateにおいてFlutterEngineインスタンスの生成
class AppDelegate: FlutterAppDelegate {
lazy var flutterEngine = FlutterEngine(name: "my flutter engine")
override func application(_ application: UIApplication, didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
flutterEngine.run();
GeneratedPluginRegistrant.register(with: self.flutterEngine);
return super.application(application, didFinishLaunchingWithOptions: launchOptions);
}
}
FlutterEngineの初期化

70

• Dartのmainを実行し、FlutterEngineの初期化を行う
• FlutterEngineの初期化は時間がかかるため、予め呼ぶ必要がある
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func showFlutter(_ sender: Any) {
let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
let flutterViewController =
FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
present(flutterViewController, animated: true, completion: nil)
}
}
FlutterViewControllerへ遷移

71

• FlutterEngineを指定して、FlutterViewControllerをインスタンス化
• FlutterViewControllerに画面遷移することでFlutterの画面を表示
何故画面を再生成しても初期状態にならない?

72

FlutterEngineFlutterViewController
● Flutterの画面を閉じた段階で
FlutterViewControlerは破棄される

● FlutterEngineはAppDelegateで初期化し、

参照を保持しておくので、破棄されない

何故画面を再生成しても初期状態にならない?

73

FlutterEngine
class AppDelegate: FlutterAppDelegate {
lazy var flutterEngine = FlutterEngine(name: "my flutter engine")
FlutterViewController
● FlutterEngineはAppDelegateで初期化し、

参照を保持しておくので、破棄されない

何故画面を再生成しても初期状態にならない?

74

FlutterEngine
● Dartを実行しているのはFlutterEngine

● Flutterの画面を閉じても、Dart内で破棄

していないStateは残ってしまう

FlutterViewController
● FlutterEngineはAppDelegateで初期化し、

参照を保持しておくので、破棄されない

何故画面を再生成しても初期状態にならない?

75

FlutterEngine
● Dartを実行しているのはFlutterEngine

● Flutterの画面を閉じても、Dart内で破棄

していないStateは残ってしまう

● 時間がかかるためFlutterEngineを毎回初期化
するわけにもいかない

FlutterViewController
● Flutterモジュールの最初に空の画面を挿入(InitialPage)



● FlutterViewController遷移時に

○ InitialPage以外のページを全て破棄

○ 本来最初に表示したいページを生成して、

即座に遷移する



どう回避したか

76

FlutterViewControllerに遷移するコード

class ViewController: UIViewController {
...
@IBAction func showFlutter(_ sender: Any) {
let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
let channel = FlutterMethodChannel(name: "channel",
binaryMessenger: flutterViewController.binaryMessenger)
channel.invokeMethod("setup", arguments: nil);
flutterViewController.modalPresentationStyle = .fullScreen
present(flutterViewController, animated: true, completion: nil)
}
}
77

• Method Channelを使用して、setupのDartコードを呼び出す
FlutterViewControllerに遷移するコード

class ViewController: UIViewController {
...
@IBAction func showFlutter(_ sender: Any) {
let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
let channel = FlutterMethodChannel(name: "channel",
binaryMessenger: flutterViewController.binaryMessenger)
channel.invokeMethod("setup", arguments: nil);
flutterViewController.modalPresentationStyle = .fullScreen
present(flutterViewController, animated: true, completion: nil)
}
}
78

• その後FlutterViewControllerへ画面遷移する
Initial Pageのコード

class InitialPage extends StatelessWidget {
static const MethodChannel channel = MethodChannel('channel');
@override
Widget build(BuildContext context) {
channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'setup':
return Navigator.pushNamedAndRemoveUntil<void>(
context,
TopPage.routeName,
(Route<dynamic> route) => route.isFirst,
);
}
79

• 空のInitial pageを作成
• Flutterモジュールの先頭の
画面に設定
Initial Pageのコード

class InitialPage extends StatelessWidget {
static const MethodChannel channel = MethodChannel('channel');
@override
Widget build(BuildContext context) {
channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'setup':
return Navigator.pushNamedAndRemoveUntil<void>(
context,
TopPage.routeName,
(Route<dynamic> route) => route.isFirst,
);
}
80

• setupのMethod Channelが
呼び出された際に
実行されるコード
• pushNamedAndRemoveUntil
条件が満たされるまで、
画面を破棄する。
その後新たな画面をpush
Initial Pageのコード

class InitialPage extends StatelessWidget {
static const MethodChannel channel = MethodChannel('channel');
@override
Widget build(BuildContext context) {
channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'setup':
return Navigator.pushNamedAndRemoveUntil<void>(
context,
TopPage.routeName,
(Route<dynamic> route) => route.isFirst,
);
}
81

● 条件
一番最初の画面であること。
すなわちInitial Pageに到達す
るまで画面が破棄される
Initial Pageのコード

class InitialPage extends StatelessWidget {
static const MethodChannel channel = MethodChannel('channel');
@override
Widget build(BuildContext context) {
channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'setup':
return Navigator.pushNamedAndRemoveUntil<void>(
context,
TopPage.routeName,
(Route<dynamic> route) => route.isFirst,
);
}
82

● 条件を満たしたタイミングで本
来表示したかった
最初の画面がpushされる
Initial Pageのコード

class InitialPage extends StatelessWidget {
static const MethodChannel channel = MethodChannel('channel');
@override
Widget build(BuildContext context) {
channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'setup':
return Navigator.pushNamedAndRemoveUntil<void>(
context,
TopPage.routeName,
(Route<dynamic> route) => route.isFirst,
);
}
83

84

Nativeの画面
Flutterの画面

present
dismiss

Flutterの画面

● 再度表示した際に、初期化される
直面した課題

85

1. タブ切り替えのパフォーマンス

2. Flutterの画面が初期化されない

3. ネットワーク通信がproxyサーバーを経由しない

4. Google Mapのクラッシュ

● 詳細なデバッグや試験を行う際に

パケットモニタリングを使用したい

● iOSプロジェクトにおいては

Wi-Fi設定からproxyサーバーの

IPアドレスとポート番号を入力する

ことでパケットモニタリング可能

(例: Charles)

iOSプロジェクトでパケットモニタリング

86

● 同様のWi-Fi設定をFlutterプロジェクトに行っても、通信が
Proxyサーバーを経由せず

パケットモニタリングを使用できない



発生した問題

87

proxyサーバーを経由するためには

88

● HttpClientクラスに

プロキシ自動設定(PAC)を

明示的に指定する必要がある

final httpClient = HttpClient();
httpClient.findProxy = (url) {
return 'PROXY localhost:8888; DIRECT';
};
final request = await httpClient
.getUrl(Uri.https('jsonplaceholder.typicode.com', '/posts'));
final response = await request.close();
● HttpClientのfindProxyに
PACを指定する
PACを指定する方法(HttpClient)

89

Future<void> main() async {
HttpOverrides.global = _HttpOverrides();
runApp(Application());
}
class _HttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext context) {
return super.createHttpClient(context)
..findProxy = (uri) {
return 'PROXY localhost:8888; DIRECT';
};
}
● HttpOverridesを継承した
クラスを定義
PACを指定する方法(httpパッケージ)

90

Future<void> main() async {
HttpOverrides.global = _HttpOverrides();
runApp(Application());
}
class _HttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext context) {
return super.createHttpClient(context)
..findProxy = (uri) {
return 'PROXY localhost:8888; DIRECT';
};
}
● HttpOverridesの
createHttpClientメソッドを
overrideする
● 作成されるHttpClientクラス
にfindProxyを指定する
PACを指定する方法(httpパッケージ)

91

Future<void> main() async {
HttpOverrides.global = _HttpOverrides();
runApp(Application());
}
class _HttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext context) {
return super.createHttpClient(context)
..findProxy = (uri) {
return 'PROXY localhost:8888; DIRECT';
};
}
● HttpOverridesの派生クラス
のインスタンスを
HttpOverrides.globalに
指定する
PACを指定する方法(httpパッケージ)

92

Future<void> main() async {
HttpOverrides.global = _HttpOverrides();
runApp(Application());
}
class _HttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext context) {
return super.createHttpClient(context)
..findProxy = (uri) {
return 'PROXY localhost:8888; DIRECT';
};
}
● PACをベタ書きしている
PACを指定する方法(httpパッケージ)

93

システムProxyからPACを指定する

● じゃらんではsystem_proxyパッケージを使用

94

pub system_proxy:https://pub.dev/packages/system_proxy, (参照2020-08-10)
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Map<String, String> proxy = await SystemProxy.getProxySettings();
...
HttpOverrides.global = _HttpOverrides(proxy['host'], proxy['port']);
runApp(Application());
}
class _HttpOverrides extends HttpOverrides {
_HttpOverrides(this._host, this._port);
final String _host;
final String _port;
@override
HttpClient createHttpClient(SecurityContext context) {
return super.createHttpClient(context)
..findProxy = (uri) {
return _host != null ? "PROXY $_host:$_port;" : 'DIRECT';
};
● システムのProxy設定を
取得
● その情報を使用してPACを
findProxyに指定
システムProxyからPACを指定する

95

直面した課題

96

1. タブ切り替えのパフォーマンス

2. Flutterの画面が初期化されない

3. ネットワーク通信がproxyサーバーを経由しない

4. Google Mapのクラッシュ

Google Mapの使用

● レジャー施設の場所や集合場所を示
すために、地図(Google Map)を表示す
る

● google_maps_flutterパッケージを使用

97

pub google_maps_flutter:https://pub.dev/packages/google_maps_flutter, (参照2020-08-10)
● google_maps_flutterはDevelopers Preview

● 実際に使用すると、

Google Mapを何度も表示した際に

アプリがクラッシュする問題が発覚



● Google Mapを閉じてもメモリが解放

されない

● 地図を開くたびにメモリを圧迫してしまい、

最終的にクラッシュしてしまっていた

発生した問題

98

Memory Usage

原因と問題の回避

● GoogleMapが内部で使用しているPlatformViewに

おいて循環参照があり、それによってGoogleMapが

解放されなくなっていた



● 当時使用していたFlutter ver1.12.13+hotfix.7から

Flutter ver1.15.17にアップデートしたことで、

メモリが解放される様になり、回避することができた





99

ライブラリのステータス

● developers preview等のライブラリや機能に関する

既知の問題には、issueにタグが付与されている

● その様なライブラリや機能を使用する際には、

タグでフィルタリングして、関連issueを確認することで

事前に問題を把握すると吉

100

pub google_maps_flutter:https://pub.dev/packages/google_maps_flutter, (参照2020-08-10)
直面した課題まとめ

● Flutterを採用してみると、いくつかの課題に直面した

● GoogleMapがdevelopers previewである等、

プラットフォームの未成熟な部分は若干ある?

● しかし、いずれの直面した課題も回避することは

できていて、プロダクション採用不可能となる様な

事態には直面しなかった

101

これらの課題を乗り越えた結果

どんなメリットを得られたのか

102

工数の
削減
開発効率
の向上
最も大きく得られたメリット

103

● 開発効率は著しく向上した



1. 既成部品の充実



2. hot reload/restart



3. IDE(Android Studio)の機能の充実



得られたメリット:開発効率の向上

104

開発効率
の向上
1. 既成部品の充実

105

● Widgetの種類がとても充実

している



● じゃらんにおいては、これら既成
部品でほぼ事足りた

● 既成部品を積極的に使用できた
ことが、開発効率向上に

寄与した



Widget catalog:https://flutter.dev/docs/development/ui/widgets, (参照2020-08-10)
● コードを修正した際に、ビルドし直さなくても

その修正が即座にアプリに反映される仕組み

○ hot reload: 約0.5s

○ hot restart: 約3s 



● じゃらんはビルド時間が大分増加してしまって

いたので、この仕組みの開発効率向上への

寄与は大きかった

2. hot reload/ restart

106

● Widgetの上でoption+Enterを押すことで、包む
Widget等の候補を表示。

Widgetツリーの構築をサクサクできる



● “stless”や”stful”と打つことで

Stateless WidgetやStateful Widget

を自動生成

3. IDE(Android Studio)の機能の充実

107

● XCodeで開発する場合に比べて開発スピードが向上した





1. iOS/Androidの開発工数削減



2. 開発以外の工数削減



3. 移行工数の削減



得られたメリット:工数の削減

108

工数の
削減
● じゃらんはメディアであり、

プラットフォーム固有の機能が少ない

● 完全移行が完了すれば、iOS/Androidの開発工数を

ほぼ半分にすることができそう

1. iOS/Androidの開発工数削減

109

● iOSとAndroidの仕様差分をなるべく減らす

● デザインをマテリアルデザインに統一



● 開発以外の工数も削減することができている



2. 開発以外の工数削減

110

開発工数
 要件検討工数
 デザイン作成工数

5割減
 5割減
 3割減

● 段階的移行を行っていることにより、各プラットフォームの
実装が多少必要になっている

○ 例えば、ネイティブ側が保持するアプリの設定情報を

Flutterモジュールに伝播する処理



● しかし、大部分は共通化できていて、その点

移行コストも大きく削減することができている

3. 移行工数の削減

111

開発効率向上、工数削減以外にも多くのメリット

● 宣言的UI構築が素晴らしい

○ 参考: 宣言的UI そな太さん
https://speakerdeck.com/sonatard/xuan-yan-de-ui



● FlutterがOSSであることで、内部処理を確認できる



● パフォーマンスモニタリングが充実している



● 全てDartで記述するため、コードレビューや

コンフリクトの解消がしやすい

などなど...
 112

まとめ

113

● じゃらんでは現在Flutterへの段階的移行を行っている

● 複数課題に直面したものの、回避することはできた

● 直面した課題を乗り越えたことで、開発効率向上や開発

工数削減など多くのメリットを得ることができた

● 完全移行に向けて引き続きFlutter頑張ります💪

まとめ

114

ありがとうございました!

115


More Related Content

What's hot

エンジニアの個人ブランディングと技術組織
エンジニアの個人ブランディングと技術組織エンジニアの個人ブランディングと技術組織
エンジニアの個人ブランディングと技術組織
Takafumi ONAKA
 
Twitterのsnowflakeについて
TwitterのsnowflakeについてTwitterのsnowflakeについて
Twitterのsnowflakeについてmoai kids
 
世界一わかりやすいClean Architecture
世界一わかりやすいClean Architecture世界一わかりやすいClean Architecture
世界一わかりやすいClean Architecture
Atsushi Nakamura
 
ソーシャルゲームのためのデータベース設計
ソーシャルゲームのためのデータベース設計ソーシャルゲームのためのデータベース設計
ソーシャルゲームのためのデータベース設計
Yoshinori Matsunobu
 
目grep入門 +解説
目grep入門 +解説目grep入門 +解説
目grep入門 +解説
murachue
 
はじめてのPRD
はじめてのPRDはじめてのPRD
はじめてのPRD
Takuya Oikawa
 
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps OnlineGKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
Google Cloud Platform - Japan
 
ドメイン駆動設計のためのオブジェクト指向入門
ドメイン駆動設計のためのオブジェクト指向入門ドメイン駆動設計のためのオブジェクト指向入門
ドメイン駆動設計のためのオブジェクト指向入門
増田 亨
 
シリコンバレーの「何が」凄いのか
シリコンバレーの「何が」凄いのかシリコンバレーの「何が」凄いのか
シリコンバレーの「何が」凄いのか
Atsushi Nakada
 
GitHubの機能を活用したGitHub Flowによる開発の進め方
GitHubの機能を活用したGitHub Flowによる開発の進め方GitHubの機能を活用したGitHub Flowによる開発の進め方
GitHubの機能を活用したGitHub Flowによる開発の進め方
Takeshi Mikami
 
例外設計における大罪
例外設計における大罪例外設計における大罪
例外設計における大罪
Takuto Wada
 
メタバースのビジネスモデルと技術限界
メタバースのビジネスモデルと技術限界メタバースのビジネスモデルと技術限界
メタバースのビジネスモデルと技術限界
Ryo Kurauchi
 
オブジェクト指向できていますか?
オブジェクト指向できていますか?オブジェクト指向できていますか?
オブジェクト指向できていますか?Moriharu Ohzu
 
ruby-ffiについてざっくり解説
ruby-ffiについてざっくり解説ruby-ffiについてざっくり解説
ruby-ffiについてざっくり解説
ota42y
 
Dockerからcontainerdへの移行
Dockerからcontainerdへの移行Dockerからcontainerdへの移行
Dockerからcontainerdへの移行
Kohei Tokunaga
 
組織にテストを書く文化を根付かせる戦略と戦術
組織にテストを書く文化を根付かせる戦略と戦術組織にテストを書く文化を根付かせる戦略と戦術
組織にテストを書く文化を根付かせる戦略と戦術
Takuto Wada
 
TDD のこころ @ OSH2014
TDD のこころ @ OSH2014TDD のこころ @ OSH2014
TDD のこころ @ OSH2014
Takuto Wada
 
それはYAGNIか? それとも思考停止か?
それはYAGNIか? それとも思考停止か?それはYAGNIか? それとも思考停止か?
それはYAGNIか? それとも思考停止か?
Yoshitaka Kawashima
 
「関心の分離」と「疎結合」 ソフトウェアアーキテクチャのひとかけら
「関心の分離」と「疎結合」   ソフトウェアアーキテクチャのひとかけら「関心の分離」と「疎結合」   ソフトウェアアーキテクチャのひとかけら
「関心の分離」と「疎結合」 ソフトウェアアーキテクチャのひとかけら
Atsushi Nakamura
 
こわくない Git
こわくない Gitこわくない Git
こわくない Git
Kota Saito
 

What's hot (20)

エンジニアの個人ブランディングと技術組織
エンジニアの個人ブランディングと技術組織エンジニアの個人ブランディングと技術組織
エンジニアの個人ブランディングと技術組織
 
Twitterのsnowflakeについて
TwitterのsnowflakeについてTwitterのsnowflakeについて
Twitterのsnowflakeについて
 
世界一わかりやすいClean Architecture
世界一わかりやすいClean Architecture世界一わかりやすいClean Architecture
世界一わかりやすいClean Architecture
 
ソーシャルゲームのためのデータベース設計
ソーシャルゲームのためのデータベース設計ソーシャルゲームのためのデータベース設計
ソーシャルゲームのためのデータベース設計
 
目grep入門 +解説
目grep入門 +解説目grep入門 +解説
目grep入門 +解説
 
はじめてのPRD
はじめてのPRDはじめてのPRD
はじめてのPRD
 
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps OnlineGKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
GKE に飛んでくるトラフィックを 自由自在に操る力 | 第 10 回 Google Cloud INSIDE Games & Apps Online
 
ドメイン駆動設計のためのオブジェクト指向入門
ドメイン駆動設計のためのオブジェクト指向入門ドメイン駆動設計のためのオブジェクト指向入門
ドメイン駆動設計のためのオブジェクト指向入門
 
シリコンバレーの「何が」凄いのか
シリコンバレーの「何が」凄いのかシリコンバレーの「何が」凄いのか
シリコンバレーの「何が」凄いのか
 
GitHubの機能を活用したGitHub Flowによる開発の進め方
GitHubの機能を活用したGitHub Flowによる開発の進め方GitHubの機能を活用したGitHub Flowによる開発の進め方
GitHubの機能を活用したGitHub Flowによる開発の進め方
 
例外設計における大罪
例外設計における大罪例外設計における大罪
例外設計における大罪
 
メタバースのビジネスモデルと技術限界
メタバースのビジネスモデルと技術限界メタバースのビジネスモデルと技術限界
メタバースのビジネスモデルと技術限界
 
オブジェクト指向できていますか?
オブジェクト指向できていますか?オブジェクト指向できていますか?
オブジェクト指向できていますか?
 
ruby-ffiについてざっくり解説
ruby-ffiについてざっくり解説ruby-ffiについてざっくり解説
ruby-ffiについてざっくり解説
 
Dockerからcontainerdへの移行
Dockerからcontainerdへの移行Dockerからcontainerdへの移行
Dockerからcontainerdへの移行
 
組織にテストを書く文化を根付かせる戦略と戦術
組織にテストを書く文化を根付かせる戦略と戦術組織にテストを書く文化を根付かせる戦略と戦術
組織にテストを書く文化を根付かせる戦略と戦術
 
TDD のこころ @ OSH2014
TDD のこころ @ OSH2014TDD のこころ @ OSH2014
TDD のこころ @ OSH2014
 
それはYAGNIか? それとも思考停止か?
それはYAGNIか? それとも思考停止か?それはYAGNIか? それとも思考停止か?
それはYAGNIか? それとも思考停止か?
 
「関心の分離」と「疎結合」 ソフトウェアアーキテクチャのひとかけら
「関心の分離」と「疎結合」   ソフトウェアアーキテクチャのひとかけら「関心の分離」と「疎結合」   ソフトウェアアーキテクチャのひとかけら
「関心の分離」と「疎結合」 ソフトウェアアーキテクチャのひとかけら
 
こわくない Git
こわくない Gitこわくない Git
こわくない Git
 

More from Recruit Lifestyle Co., Ltd.

業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
Recruit Lifestyle Co., Ltd.
 
分散トレーシングAWS:X-Rayとの上手い付き合い方
分散トレーシングAWS:X-Rayとの上手い付き合い方分散トレーシングAWS:X-Rayとの上手い付き合い方
分散トレーシングAWS:X-Rayとの上手い付き合い方
Recruit Lifestyle Co., Ltd.
 
OOUIを実践してわかった、9つの大切なこと
OOUIを実践してわかった、9つの大切なことOOUIを実践してわかった、9つの大切なこと
OOUIを実践してわかった、9つの大切なこと
Recruit Lifestyle Co., Ltd.
 
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
Recruit Lifestyle Co., Ltd.
 
「進化し続けるインフラ」のためのマルチアカウント管理
「進化し続けるインフラ」のためのマルチアカウント管理「進化し続けるインフラ」のためのマルチアカウント管理
「進化し続けるインフラ」のためのマルチアカウント管理
Recruit Lifestyle Co., Ltd.
 
Air事業のデザイン組織とデザイナー
Air事業のデザイン組織とデザイナーAir事業のデザイン組織とデザイナー
Air事業のデザイン組織とデザイナー
Recruit Lifestyle Co., Ltd.
 
リクルートライフスタイル AirシリーズでのUXリサーチ
リクルートライフスタイル AirシリーズでのUXリサーチリクルートライフスタイル AirシリーズでのUXリサーチ
リクルートライフスタイル AirシリーズでのUXリサーチ
Recruit Lifestyle Co., Ltd.
 
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
Recruit Lifestyle Co., Ltd.
 
データサイエンティストが力を発揮できるアジャイルデータ活用基盤
データサイエンティストが力を発揮できるアジャイルデータ活用基盤データサイエンティストが力を発揮できるアジャイルデータ活用基盤
データサイエンティストが力を発揮できるアジャイルデータ活用基盤
Recruit Lifestyle Co., Ltd.
 
Real-time personalized recommendation using embedding
Real-time personalized recommendation using embeddingReal-time personalized recommendation using embedding
Real-time personalized recommendation using embedding
Recruit Lifestyle Co., Ltd.
 
データから価値を生み続けるには
データから価値を生み続けるにはデータから価値を生み続けるには
データから価値を生み続けるには
Recruit Lifestyle Co., Ltd.
 
データプロダクト開発を成功に導くには
データプロダクト開発を成功に導くにはデータプロダクト開発を成功に導くには
データプロダクト開発を成功に導くには
Recruit Lifestyle Co., Ltd.
 
Jupyter だけで機械学習を実サービス展開できる基盤
Jupyter だけで機械学習を実サービス展開できる基盤Jupyter だけで機械学習を実サービス展開できる基盤
Jupyter だけで機械学習を実サービス展開できる基盤
Recruit Lifestyle Co., Ltd.
 
SQLを書くだけでAPIが作れる基盤
SQLを書くだけでAPIが作れる基盤SQLを書くだけでAPIが作れる基盤
SQLを書くだけでAPIが作れる基盤
Recruit Lifestyle Co., Ltd.
 
BtoBサービスならではの顧客目線の取り入れ方
BtoBサービスならではの顧客目線の取り入れ方BtoBサービスならではの顧客目線の取り入れ方
BtoBサービスならではの顧客目線の取り入れ方
Recruit Lifestyle Co., Ltd.
 
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のりThe Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
Recruit Lifestyle Co., Ltd.
 
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
Recruit Lifestyle Co., Ltd.
 
ビックデータ分析基盤の成⻑の軌跡
ビックデータ分析基盤の成⻑の軌跡ビックデータ分析基盤の成⻑の軌跡
ビックデータ分析基盤の成⻑の軌跡
Recruit Lifestyle Co., Ltd.
 
Refactoring point of Kotlin application
Refactoring point of Kotlin applicationRefactoring point of Kotlin application
Refactoring point of Kotlin application
Recruit Lifestyle Co., Ltd.
 
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めてデータサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
Recruit Lifestyle Co., Ltd.
 

More from Recruit Lifestyle Co., Ltd. (20)

業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
業務と消費者の体験を同時にデザインするリクルートの価値検証のリアル ー 「Airレジ ハンディ」セルフオーダーのブレない「価値」の確かめ方 ー
 
分散トレーシングAWS:X-Rayとの上手い付き合い方
分散トレーシングAWS:X-Rayとの上手い付き合い方分散トレーシングAWS:X-Rayとの上手い付き合い方
分散トレーシングAWS:X-Rayとの上手い付き合い方
 
OOUIを実践してわかった、9つの大切なこと
OOUIを実践してわかった、9つの大切なことOOUIを実践してわかった、9つの大切なこと
OOUIを実践してわかった、9つの大切なこと
 
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
CTIサービスを支える裏側 〜物理デバイスとの戦い〜 | iOSDC Japan 2020
 
「進化し続けるインフラ」のためのマルチアカウント管理
「進化し続けるインフラ」のためのマルチアカウント管理「進化し続けるインフラ」のためのマルチアカウント管理
「進化し続けるインフラ」のためのマルチアカウント管理
 
Air事業のデザイン組織とデザイナー
Air事業のデザイン組織とデザイナーAir事業のデザイン組織とデザイナー
Air事業のデザイン組織とデザイナー
 
リクルートライフスタイル AirシリーズでのUXリサーチ
リクルートライフスタイル AirシリーズでのUXリサーチリクルートライフスタイル AirシリーズでのUXリサーチ
リクルートライフスタイル AirシリーズでのUXリサーチ
 
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
ホットペッパービューティーにおけるモバイルアプリ向けAPIのBFF/Backend分割
 
データサイエンティストが力を発揮できるアジャイルデータ活用基盤
データサイエンティストが力を発揮できるアジャイルデータ活用基盤データサイエンティストが力を発揮できるアジャイルデータ活用基盤
データサイエンティストが力を発揮できるアジャイルデータ活用基盤
 
Real-time personalized recommendation using embedding
Real-time personalized recommendation using embeddingReal-time personalized recommendation using embedding
Real-time personalized recommendation using embedding
 
データから価値を生み続けるには
データから価値を生み続けるにはデータから価値を生み続けるには
データから価値を生み続けるには
 
データプロダクト開発を成功に導くには
データプロダクト開発を成功に導くにはデータプロダクト開発を成功に導くには
データプロダクト開発を成功に導くには
 
Jupyter だけで機械学習を実サービス展開できる基盤
Jupyter だけで機械学習を実サービス展開できる基盤Jupyter だけで機械学習を実サービス展開できる基盤
Jupyter だけで機械学習を実サービス展開できる基盤
 
SQLを書くだけでAPIが作れる基盤
SQLを書くだけでAPIが作れる基盤SQLを書くだけでAPIが作れる基盤
SQLを書くだけでAPIが作れる基盤
 
BtoBサービスならではの顧客目線の取り入れ方
BtoBサービスならではの顧客目線の取り入れ方BtoBサービスならではの顧客目線の取り入れ方
BtoBサービスならではの顧客目線の取り入れ方
 
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のりThe Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
The Design for Serverless ETL Pipeline データ分析基盤のレガシーなデータロードをサーバレスでフルリプレースするまで道のり
 
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
リクルートライフスタイルにおける深層学習の活用とGCPでの実現方法
 
ビックデータ分析基盤の成⻑の軌跡
ビックデータ分析基盤の成⻑の軌跡ビックデータ分析基盤の成⻑の軌跡
ビックデータ分析基盤の成⻑の軌跡
 
Refactoring point of Kotlin application
Refactoring point of Kotlin applicationRefactoring point of Kotlin application
Refactoring point of Kotlin application
 
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めてデータサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
データサイエンティストとエンジニア 両者が幸せになれる機械学習基盤を求めて
 

Flutter移行の苦労と、乗り越えた先に得られたもの