Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

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

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

2020/9/20_iOSDC Japan 2020での、桐山の講演資料になります

Recruit Technologies

October 01, 2020
Tweet

More Decks by Recruit Technologies

Other Decks in Technology

Transcript

  1. • Recruit Co., Ltd.
 • iOS / Flutter 
 •

    じゃらんアプリ開発T
 Keisuke Kiriyama

  2. Flutterへの段階的移行
 • Add-to-app(Add Flutter to existing app)を使用
 • 既存のネイティブプロジェクトにFluterプロジェクトを部分的 に組み込む仕組み


    
 21
 じゃらん
 アプリ
 Swift
 Objective-c
 じゃらん遊び・体験
 
 Flutterプロジェクト
 Flutterモジュール

  3. 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, ), ...
  4. 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, ), ...
  5. 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, ), ...
  6. 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で表現する
  7. 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

  8. 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

  9. class TabA extends StatelessWidget { @override Widget build(BuildContext context) {

    return Center( child: Text('Tab A'), ); } } • TabA()は画面の中心に “Tab A”を表示するだけ サンプルコード: TabA
 35

  10. 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

  11. 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

  12. 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

  13. 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を返す
  14. 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
 • 一時的なメモリ圧迫も 起こらなくなる
  15. Flutter
 View
 Controller
 
 
 View
 Controller
 
 
 Flutterの画面を表示するために重要なクラス


    65
 FlutterEngine FlutterViewController FlutterView • ViewControllerの派生クラス
 • FlutterViewControllerに遷移する
 ことでFlutterの画面を表示
 画面遷移

  16. 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インスタンスの生成
  17. 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の初期化は時間がかかるため、予め呼ぶ必要がある
  18. 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の画面を表示
  19. 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コードを呼び出す
  20. 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へ画面遷移する
  21. 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モジュールの先頭の 画面に設定
  22. 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が 呼び出された際に 実行されるコード
  23. • 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

  24. • 条件 一番最初の画面であること。 すなわち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

  25. • 条件を満たしたタイミングで本 来表示したかった 最初の画面が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

  26. 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

  27. 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

  28. 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

  29. 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

  30. 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

  31. 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

  32. • google_maps_flutterはDevelopers Preview
 • 実際に使用すると、
 Google Mapを何度も表示した際に
 アプリがクラッシュする問題が発覚
 
 •

    Google Mapを閉じてもメモリが解放
 されない
 • 地図を開くたびにメモリを圧迫してしまい、
 最終的にクラッシュしてしまっていた
 発生した問題
 98
 Memory Usage

  33. • 開発効率は著しく向上した
 
 1. 既成部品の充実
 
 2. hot reload/restart
 


    3. IDE(Android Studio)の機能の充実
 
 得られたメリット:開発効率の向上
 104
 開発効率 の向上
  34. 1. 既成部品の充実
 105
 • Widgetの種類がとても充実
 している
 
 • じゃらんにおいては、これら既成 部品でほぼ事足りた


    • 既成部品を積極的に使用できた ことが、開発効率向上に
 寄与した
 
 Widget catalog:https://flutter.dev/docs/development/ui/widgets, (参照2020-08-10)
  35. • コードを修正した際に、ビルドし直さなくても
 その修正が即座にアプリに反映される仕組み
 ◦ hot reload: 約0.5s
 ◦ hot restart:

    約3s 
 
 • じゃらんはビルド時間が大分増加してしまって
 いたので、この仕組みの開発効率向上への
 寄与は大きかった
 2. hot reload/ restart
 106

  36. 開発効率向上、工数削減以外にも多くのメリット
 • 宣言的UI構築が素晴らしい
 ◦ 参考: 宣言的UI そな太さん https://speakerdeck.com/sonatard/xuan-yan-de-ui
 
 •

    FlutterがOSSであることで、内部処理を確認できる
 
 • パフォーマンスモニタリングが充実している
 
 • 全てDartで記述するため、コードレビューや
 コンフリクトの解消がしやすい
 などなど...
 112