Share
Embed
Flutter移行の苦労と、乗り越えた先に得られたもの
2020/9/20_iOSDC Japan 2020での、桐山の講演資料になります
Recruit Technologies
October 01, 2020
Transcript
115 slides
-
• Recruit Co., Ltd. • iOS / Flutter •
じゃらんアプリ開発T Keisuke Kiriyama -
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) -
1. 前提の共有 ◦ じゃらんのFlutter移行 ◦ Flutterのレイアウト構築 2. 直面した課題
3. 得られたメリット 4. まとめ 説明の流れ 13 -
Flutter採用の背景 • じゃらんアプリはiOS/Android共にリリースから 10年を迎え、長年に渡る開発が行われてきた • 上記課題を解決するために、リプレースを検討 15
プロジェクトの大規模化によ るビルド時間の増加 プロジェクト全体の コードが古くなっている -
じゃらんアプリのリプレース検討 • クロスプラットフォーム技術の検討 ◦ iOS/Android開発工数 ◦ リプレースコスト • クロスプラットフォーム技術の中でも
Flutterの開発生産性が最も高いと実感し Flutterの採用を決断 16 半減 -
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モジュール -
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で表現する -
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 -
40 TabB TabA TabAに 切り替え TabBに 切り替え TabB • タブ切り替えのアニメーションが非
常に重くなる • まれにクラッシュする -
タブが保持するリストのアイテムの高さが可変の場合 • 1つ目のアイテムから順にレイアウト を計算して、高さを決定しないと • 前回のスクロール位置の アイテムを表示できない
なぜアニメーション重くなる? 44 … 前回の スクロール位置 -
タブが保持するリストのアイテムの高さが可変の場合 • 1つ目のアイテムから順にレイアウト を計算して、高さを決定しないと • 前回のスクロール位置の アイテムを表示できない •
この演算のために パフォーマンスが低下 なぜアニメーション重くなる? 45 … 前回の スクロール位置 -
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 • 一時的なメモリ圧迫も 起こらなくなる -
60 Nativeの画面 Flutterの画面 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 View Controller View Controller Flutterの画面を表示するために重要なクラス
65 FlutterEngine FlutterViewController FlutterView • ViewControllerの派生クラス • FlutterViewControllerに遷移する ことでFlutterの画面を表示 画面遷移 -
Flutterの画面を表示するために重要なクラス 66 FlutterEngine FlutterViewController FlutterView • FlutterViewControllerに 乗っているView • FlutterモジュールのUIが描画される
FlutterViewController FlutterView -
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の画面を表示 -
• 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 -
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 -
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 -
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 -
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) -
• 開発効率は著しく向上した 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の仕様差分をなるべく減らす • デザインをマテリアルデザインに統一 • 開発以外の工数も削減することができている 2. 開発以外の工数削減
110 開発工数 要件検討工数 デザイン作成工数 5割減 5割減 3割減 -
開発効率向上、工数削減以外にも多くのメリット • 宣言的UI構築が素晴らしい ◦ 参考: 宣言的UI そな太さん https://speakerdeck.com/sonatard/xuan-yan-de-ui •
FlutterがOSSであることで、内部処理を確認できる • パフォーマンスモニタリングが充実している • 全てDartで記述するため、コードレビューや コンフリクトの解消がしやすい などなど... 112