ネイティブアプリをFlutterにフルリプレイスした話

f:id:tech-leverages:20201209151139p:plain

はじめに

こんにちは!エンジニアの藤野です! 今回はキャリアチケットが運営するキャリアチケットカフェのiOS/Android向けアプリをFlutterにフルリプレイスした話をご紹介します。

なぜFlutterに移行したのか

元々アプリ開発はiOSはSwift, AndroidはKotlinを使用し、開発を進めていましたが、開発を進めていく上で生産性が上がらない問題が発生していました。

最終的には1人でモバイル開発をしていたため、iOS/Androidのどちらとも実装する時間がなく、iOSのみに実装するといったOSによって機能が違うアプリになっていました。また、1人で仕様検討から実装・リリースまでを行っていたため、開発効率も下がっていました。レビュワー等もいなかったため、プルリクエストを投げた後もセルフマージで対応していました。

以上のような問題を解消するために、Flutterにフルリプレイスすることを決めました。Flutterにした理由は下記2つです。

  • 未経験でも取っ付き易い
  • OS関係なくひとつの言語で統一できる

他のチームメンバーにもFlutterを触ってもらうことで、開発できる人を増やせること、さらに生産性を向上させるメリットがあったため、Flutterへのフルリプレイスへ踏み切りました。

構成

今回使用した状態管理手法やディレクトリ構成を紹介します。 状態管理の手法はChangeNotifier+Providerパターンを採用しています。ProviderはPragmatic State Management in Flutter (Google I/O'19)で公式に推奨されています。 設計当初にはまだ登場していませんでしたが、最近はStateNotifier+Freezed+Providerを使った状態管理やRiverpodの登場などがあります。

ディレクトリ構成

ディレクトリ構成は以下の通りです。どのような役割になっているかも、ひとつずつ簡単に説明しています。

lib
 ├── config
 │    └─ route.dart
 ├── models
 │    └─ tmp
 │      ├─ tmp.dart
 │      ├─ tmp.freezed.dart
 │      └─ tmp.g.dart
 ├── resources
 │    ├─ api
 │    │   └─ tmp_api_provider.dart
 │    └─ repositories
 │      └─ tmp_repository.dart
 ├── screens
 │    ├─ common
 │    └─ tmp
 │      ├─ widgets
 │      └─ tmp_screen.dart
 ├── services
 ├── utils
 ├── assets
 ├── viewmodels
 │    ├─ common
 │    └─ tmp
 │      └─ tmp_view_model.dart
 └── main.dart
  • config
    • 設定ファイルを配置(ルーティングを設定するファイルなど)
  • models
    • 作成するモデルのディレクトリを作ってその中にdartファイルを配置する
    • freezedパッケージを使用
  • resources
    • api
      • APIとのやり取りをするファイルを配置
      • ファイル名 : xxxx_api_provider.dart
    • repositories
      • apiからデータを取得して各モデルの形にマッピングしたり、データ送ったりする
      • ファイル名 : xxxx_repository.dart
  • screens
    • 各画面ごとにディレクトリを作成してその中にファイルとwidgetsディレクトリを作成する
    • 基本的には1画面1screenを作成し、widgets配下に画面内で使用するWidgetを配置
  • services
    • 主に外部サービスとの連携周りの処理を書いたものを配置
  • utils
    • 定数などのファイルを配置
  • assets
    • 画像などの素材系を配置
  • viewmodels

構成は以上のようになっています。伝わりづらい部分はサンプルとして、 APIからデータを取得して表示するだけの簡単なアプリを作成しながら詳しく説明します。

1. パッケージの導入

providerパッケージやfreezedパッケージを利用するため、pubspec.yamlを以下のようにします。

dependencies:
  flutter:
    sdk: flutter
- cupertino_icons: ^0.1.3
+ provider:
+ freezed_annotation:
+ http:

dev_dependencies:
  flutter_test:
    sdk: flutter
+ json_serializable:
+ build_runner:
+ freezed:

2. モデルの作成

モデルはFreezedパッケージを使用しているため、以下のように書いた後に以下のコマンドを実行します。 flutter pub run build_runner build これによって event.freezed.dart ファイルと event.g.dart が生成されます。 詳しいFreezedパッケージの使い方はドキュメントを参照してください。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'event.freezed.dart';
part 'event.g.dart';

@freezed abstract class Event implements _$Event {
  factory Event({
    int id,
    String title,
    @nullable @JsonKey(name: 'created_at') String createdAt,
  }) = _Event;

  factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
}

3. APIからの取得処理実装

APIから実際にデータを取得する部分をプロバイダーとして作成します。

import 'package:http/http.dart' as http;

class EventApiProvider {
  Future getAll() async {
    return await http.get('http://localhost:8080/events');
  }
}

Repositoryでは上記で作成したプロバイダーから取得したデータをモデルの形にマッピング

import 'dart:convert';
import 'package:sample/models/event.dart';
import 'package:sample/resources/api/event_api_provider.dart';

class EventRepository {
  Future<List<Event>> getAll() async {
    final _response = await EventApiProvider().getAll();
    return json.decode(_response.body)
        .map<Event>((json) => Event.fromJson(json))
        .toList();
  }
}

4. ViewModelの作成

ここで先ほど作成したEventRepositoryを利用してデータを取得し、画面側へ結果を伝えます。

import 'package:flutter/material.dart';
import 'package:sample/models/event.dart';
import 'package:sample/resources/repositories/event_repository.dart';

class EventViewModel extends ChangeNotifier {
  final EventRepository _repository = EventRepository();
  List<Event> events = [];

  Future getAll() async {
    events =  await _repository.getAll();
    notifyListeners();
  }
}

5. Screenの作成

ボタンを押すとAPIからデータを取得し、取得結果を表示するような画面を作成します。 ボタン押下時にViewModel側の getAll() を呼び出してデータを取得、notifyListeners() が呼び出されたタイミングでListViewが再描画されます。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sample/viewmodels/event_view_models.dart';

class EventScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    EventViewModel _viewModel = Provider.of<EventViewModel>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Event List'),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            FlatButton(
              onPressed:() async => _viewModel.getAll(),
              color: Colors.lightBlue,
              child: const Text('取得'),
            ),
            Expanded(
              child: ListView.builder(
                itemCount: _viewModel.events.length,
                itemBuilder: (BuildContext context, int index) {
                  return ListTile(
                    title: Text(
                        "${_viewModel.events[index].id} : ${_viewModel.events[index].title}"
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

6. main.dartの修正

各画面の遷移先は以下のようにルーティングファイルに記載します。

import 'package:flutter/material.dart';
import 'package:sample/screens/event_screen.dart';

final Map<String, WidgetBuilder> routes = {
  '/': (BuildContext context) => EventScreen()
};

そして MaterialAppのroutesに先ほど作成したルーティングを設定することによってEventScreenが呼び出されるようになります。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sample/config/route.dart';
import 'package:sample/viewmodels/event_view_models.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      child: MaterialApp(
        routes: routes,
      ),
      providers: [
        ChangeNotifierProvider(create: (context) => EventViewModel()),
      ],
    );
  }
}

完成!

取得ボタンを押すと、APIからデータを取得し表示します。

f:id:tech-leverages:20201209151301g:plain:w320

実際にリプレイスしてみて

メリット

  1. 開発工数の削減 Flutterで開発すると、SwiftとKotlinの別々で書いていたときよりも 体感4割ほど工数が削減できました。ただ、全て共通で同じ実装をできるかと言われるとそうでなく、OSに依存する機能などは個別に実装する必要があるため、その部分に関しては工数がかかってしまいます。 また、Flutterの大きな特徴の一つであるホットリロード機能がとても便利で、開発中のデバッグにかかる時間がかなり削減されました。

  2. 未経験でも開発が容易 今回のリプレイス作業は、自分以外のチームメンバーのほとんどがFlutter未経験者でしたが、全員スムーズに開発をすることができました。公式ドキュメントが充実していて、UI部分に関してもSwiftやKotlinよりも簡単に実装ができるため、Flutter未経験でも簡単にモバイルアプリを開発することができます。

  3. UIの実装が簡単 こちらもFlutterの大きな特徴ですが、UI部分は全てWidgetで構成されており、マテリアルデザインなどといった、様々なデザインが備わっています。そのため、ある程度のデザインであれば公式のライブラリを使用することで、簡単に実装をすることが可能です。

  4. コードレビューが楽 SwfitやAndroidではそれぞれStoryBoardとレイアウトファイル(xmlファイル)で実装していたため、コードレビューの際に変更箇所の差分が分かりにくく、苦戦していました。Flutterに移行したことで、変更箇所が分かりやすく、コードレビューが楽になりました。

デメリット

  1. OSに依存する機能などは個別に実装が必要 OS依存の機能を使わない場合に関しては、特に問題ありません。通知機能やAppleサインインなどのOS依存の機能を実装する場合は、その部分に関する知識が必要になってきます。 また、リリース時もiOSとAndroidで証明書が異なるため、別々に管理することが必要となります。

  2. パフォーマンスを考慮した設計 さまざまなWidgetを組み合わせて簡単にUI部分を実装することは可能ですが、何も気にせずに実装を進めていくと、ネイティブよりもパフォーマンスが低下し、アプリ実行時に全体的にもっさりした感じになります。そのため、複雑なUIなどの場合にはパフォーマンスを考慮して設計をしなければいけません。

開発を行ったチームメンバーの感想

モバイルアプリ開発未経験にも関わらず、リプレイス作業に携わってもらったチームメンバーから、以下のような感想を貰いました。

  • 環境構築がそこまで複雑ではないのですぐに開発をすることができた
  • UI部分はWidgetがHTMLみたいで直感的で書きやすい
  • UIパーツを組み上げる感覚で、思ったほど苦労せずに実装できた
  • Flutterの進歩が早い
  • 開発がスムーズ進められた(デバッグツールが使いやすい、ホットリロード機能最強!など)

メリットでもあるUI部分に関しては、直感的で未経験者でも開発がしやすいとのことです。

Flutter移行中に起きた問題とその対応

Flutterに移行する際にさまざまな問題等に直面したので、その内容と解決方法、注意点などをいくつかご紹介します。

UI設計をあらかじめした方が良い

今回は画面単位で担当者を割り振ったため、コンポーネント化出来るWidgetがコンポーネント化されていなかったりと煩雑になってしまいました。他にもContainer Widgetを使う実装があったので、パフォーマンスなども考慮して適切なWidgetを使うことをおすすめします。

iOSのバージョンアップデートには事前に対応しておくべき

今回iOS側にはUniversal Links機能を実装していたのですが、アプリのリリース前にiOSのバージョンがiOS 14に上がったことでAssociated Domains周りの仕様が若干変更されました。そのため、サーバサイド側の設定変更を余儀なくされました。 iOSの次バージョンがリリースされる3ヶ月ほど前の6,7月頃にはBeta版がリリースされるので、事前にBeta版を使ってあらかじめ対応するべきでした。

審査に落ちてしまった

iOS側の話が続きますが、いざリリース申請に出したところ、App Store Reviewガイドラインの5.1.1(xi)に引っ掛かっている理由のため、リジェクトにされました。 ガイドラインに引っ掛かった原因は、アプリ内に下記のようなRSSから記事を取得して表示する機能を実装していたものの、その中に「新型コロナウイルス」に関する記事が表示されていたため、リジェクトされていました。

そのため、サーバサイド側でRSSから記事を取得する際、タイトルに「新型コロナウイルス」に関する単語が入っている場合は表示しないよう、一時的に対応しました。その後、もう一度審査に出したところ、何の問題もなく審査を通過しました。 外部からの取得したデータを表示する場合などは、念のため気をつけましょう。

f:id:tech-leverages:20201209151450p:plain:w320

リリース周りが大変

iOSとAndroidではデメリットのひとつ目にも記載していますが、リリースまでの手順が異なるため、OSごとに対応が必要となってきます。ビルドからデプロイまで全て手動で行うのは大変でしたが、現在ではFlutterなどモバイルに特化したCI/CDツールであるCodemagicの導入を検討しています。

終わりに

今回、SwiftとKotlinで実装されたアプリをFlutterに移行した話をご紹介しました。チームメンバーのほとんどがFlutter未経験にもかかわらず、スムーズに移行を進めることができました。

この移行プロジェクトは自分からチームリーダーに提案をしたものですが、快く承諾していただきました。メンバーが提案しやすい環境があること、感謝しています。

未経験者でも、簡単にモバイルアプリ開発をすることができます。Flutterを触ったことのない方は是非、Flutterを触ってモバイルアプリ開発をしてみてください。