Flutterで100画面を超える規模のアプリを開発してみた感想

株式会社Pentagon (ペンタゴン) の yamatatsu です。アプリ開発のエンジニアとして仕事をしています。Flutterでの開発歴は1年ほどになります。

この記事を読むと、Flutterでアプリ開発をする際に気をつけた方が良いことや、ネイティブ開発との違いなどがわかります。これから Flutter でのアプリ開発をする方や、開発中の方にとって有益な内容になっているので、ぜひ読んでいただけると嬉しいです。

Flutterでの開発のメリット・デメリット【実際に開発してみて】


そもそもFlutterって何?Flutterのメリット・デメリットを知りたいという方はこちらの記事をご覧ください。

実際にFlutterで開発してみて良かった点

  • ホットリロードでさくさくUIを組み上げられる
  • iOSアプリ開発のような TableView や ScrollView の煩雑さがない
  • コードレビューの時に、どのようにUIが組まれているかわかる

レイアウトが組みやすく、 iOSアプリ開発のような TableView や ScrollView の煩雑さがありませんでした。

また、コードレビューの際に、コードだけでどのようにUIが組まれているかわかるようになりました。iOSのxib ファイルやstoryboard ファイルだけでは、どのようにレイアウトが組まれているか分からなかったので、コードレビューがしやすくなりました。

Flutterでの開発で苦戦した点

  • FlutterのSDKが用意されていないことがある
  • iOSではうまくいくけどAndroidで不具合があることも...

クレジットカードの実装する時に、使用した決済システムのSDKが Flutter には対応しておらず、カード情報の暗号化をネイティブで実装することになりました。

WebViewでpdf ファイルを表示する際に、iOS では表示されるが、Android では表示されない不具合に遭遇しました。結局、pdf ファイルをダウンロードして表示する方法をとりましたが、原因が分からず時間を消費してしまいました。

Flutterのディレクトリ構成はどうしたらいいの?

ディレクトリ構成は、開発者自身が分かりやすい構成になっているとことが重要です。

どのディレクトリにどのファイルがあるかが簡単に把握できると、他の開発者が参加した時にすぐにキャッチアップができます。


今回のディレクトリ構成は以下の画像のようになっています。

const

都道府県の情報など値に変更がない固定値を定義します。

extension

DateTime や String などの extension を定義しています。

model

モデル層。アーキテクチャの種類によっては、モデル層は、データの保持に加え、ビジネスロジックを記述することがありますが、今回は、データの保持だけを行っています。

plugin

Flutter で実現できなかったことをネイティブで行うための plugin を記述。

MethodChannel を使用してネイティブのメソッドを呼び出しています。MethodChannel は Dart からネイティブのメソッドを呼び出すか、ネイティブから Dart のメソッドを呼び出すためのAPIです。

res

色や文字のスタイルなどを定義してます。

service

API通信の処理を記述します。

ui

ページごとにディレクトリを分けて作成します。

例えば、login_screenというディレクトリを用意し、LoginScreenというWidgetを作成します。

view_model

ui層と同じ要領で、LoginViewModelなどを作成します。

今回採用したFlutterのアーキテクチャはMVVM

Flutterで有名なアーキテクチャには, BLoCや、Redux、Provider を使ったMVVM などがあります。当社では、 Provider を使用したMVVMを採用しています。その理由は、コードの理解しやすさと、データの持ち方の分かりやすさです。

ChangeNotifier を継承した ViewModel を作成し、データを保持させ、データの変更は notifyListeners() でView層に通知することができます。

以下は、簡単な例です。count がデータで、countUp で count をインクリメントして、View層へ変更を通知します。

class CounterViewModel extends ChangeNotifier {
    int count = 0; // データの保持
    void countUp() {
        count++;
        notifyListeners(); // View層へ通知
    }
}

どこにデータが保持されているかが明確で、Viewへの反映のタイミングも分かりやすいのでコードレビューや Flutter に慣れていない方でも実装がしやすいアーキテクチャになっています。

2024年8月追記:FlutterでMVVMをやめた話

MVVMアーキテクチャを紹介しましたが、より大規模なアプリ開発に耐えられるように、当社では、MVVMを脱して、サービス思想設計を取り入れました。詳しくは、YouTube動画をご覧ください。

関連:Flutterの副業事情!週2-3案件の探し方とおすすめのサイトを紹介 | DAINOTE

便利なFlutterのおすすめパッケージ 

Provider

今回の Provider を使用したアーキテクチャを採用するために必要でした。

context.read, watch, select のように View 層でのデータの監視の仕方も選択できるので、用途に合わせた実装が可能になります。

https://pub.dev/packages/provider

ImageCropper

画像の整形のために使用しました。

画像をサーバーにアップロードする際に、正方形の画像を生成する必要があったのですが、このパッケージを使用すると、画像を選択後にユーザーが画像を正方形にすることができました。もちろん、正方形だけでなく、長方形などにも整形できます。

https://pub.dev/packages/image_cropper

Dio

API 通信をする際に使用しました。

通信ログを簡単に取ることができたり、timeOut の時間を指定することができます。

https://pub.dev/packages/dio

FlutterPicker

日付の選択の際に iOS 風の DatePicker を出すために使用しました。

Range の実装(何月何日〜何月何日)や、日付のバリデーションも簡単に実装できました。

日付のバリデーションは、iOS のように一度選択して Picker が戻るのではなく、Picker が選択しようとした日付まで動かないので、ユーザに対しては少し分かりにくいかもしれないと感じました。

https://pub.dev/packages/flutter_picker

JsonSerializable

JSONをパースするために使用しました。

手動でパースするための文字を書かなくて済むようになったのがありがたかったです。

https://pub.dev/packages/json_serializable

通信の実装方法

Flutterで通信結果をUIに反映するフロー

Dio を使用して、API 通信を実装しました。

APIコール

以下はGETの通信です。パラメーターや、header を通信に含めています。

  Future<dynamic> get(String uri, Map<String, dynamic> params) async {
    _dio.options.headers = await headers;
    try {
      final response = await _dio.get<dynamic>(uri, queryParameters: params);
      return commonBehavior(response);
    } catch (e) {
      rethrow;
    }
  }

 マルチパート

画像のアップロードをする際には FormData に MultipartFile を入れて、POST 通信で送信しました。

 Future<Image> uploadImage(File image, bool main) async {
    var formData = FormData.fromMap({
      'image': await MultipartFile.fromFile(image.path,
          filename: 'ANDROID_IMAGE.jpg'),
      'main': main,
    });

    final uploadedImage =
        await post("http://localhost:3000/api/path", null, data: formData).then(
            (value) {
      final json = value as Map<String, dynamic>;
      return Image.fromJson(json);
    }, onError: (e) => throw e.error);
    return uploadedImage;
  }

JSONをパースしてモデルに変換

JsonSerializableを使用しているので、次のようにパースをすることができます。

モデル名.fromJson(map) 

JsonSerializable と build_runner を使用するとコードが自動生成され、その中に fromJson メソッドが作成されるため、次にように値をひとつひとつセットする必要がありません。

name = json[“name”] 

JsonSerializable の使い方は次のサイトを参考にしてください。

JSON Serialization in Dart

例えば、User モデルを作成している場合、以下のようなメソッドを User モデルに作成しています。

factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

そして、API 通信で取得した json を以下のようにパースして、User モデルに変換します。

 final json = value as Map<String, dynamic>;
 return User.fromJson(json);

データを取得してUIへの反映の仕方

API 通信を行い、 Json をモデルに変更した後は、モデルを ViewModel に保持させます。

ViewModel は ChangeNotifier を継承しているため、データの変更を notifyListeners() でView層に反映します。

class UserViewModel extends ChangeNotifier {

/// データの保持
  User _user;
  User get user => _user;
  set user(User value) {
    _user = value;
    notifyListeners();
  }

/// API 通信 と View層への通知
  Future<User> fetchUser(int userId) async {
    final fetchUser = await UserService().getUser(userId: userId).then(
      (value) => user = value,  // ここが setUser(User user) にあたるのでnotifyListeners(); を呼んで、View層に反映させている
      onError: (e) {
        throw 'エラーが発生しました\n${e.error}';
      },
    );
    return fetchUser;
  }
}

View側の実装は、次のとおりです。

final currentUser = context.watch<CurrentUserViewModel>().currentUser;

Text(currentUser.name)

CurrentUserViewModelから notifyListeners() が行われたら、Text内の文字が切り替わる感じです。

画像の表示方法

ローカルの画像

ローカルの画像はアセットファイルとして扱うので以下のようなコードで、表示を行います。

例えば、 assets というディレクトリに、icon_send.png というファイルが入っていて、それを表示するコードは以下になります。

 Image.asset(
            ‘assets/icon_send.png’,
            height: height,
            width: width,
            fit: fit,
          );

URLの画像

CachedNetworkImage というパッケージを使用して、URL の画像を表示しています。placeHolder やエラーの際の代わりの画像を指定できたり、データ取得後の画像の表示もFadeしながら表示されます。

CachedNetworkImage(
              imageUrl: path,
              placeholder: (context, url) =>
                  placeHolder(height, width, placeHolderImagePath, fit),
              errorWidget: (context, url, dynamic error) =>
                  placeHolder(height, width, placeHolderImagePath, fit),
              height: height,
              fit: fit,
            ),

よくあるエラーの対処方法など

Provider を使っている時に、 ViewModel にアクセスするために、context.read をビルド中に書いてしまってエラーが発生することがよくあります。

具体的には以下のようなエラーが発生します。

════════ Exception caught by widgets library ═══════════════════════════════════════════════════════
Tried to use `context.read<LoginViewModel>` inside either a `build` method or the `update` callback of a provider.

This is unsafe to do so. Instead, consider using `context.watch<LoginViewModel>`.
If you used `context.read` voluntarily as a performance optimisation, the solution
is instead to use `context.select`.
'package:provider/src/provider.dart':
Failed assertion: line 584 pos 9: 'debugIsInInheritedProviderCreate ||
            (!debugDoingBuild && !debugIsInInheritedProviderUpdate)'

ビルド中に、 context.read でデータを参照することはできないので、context.watch もしくは、 context.select で ViewModel(ChangeNotifier を継承したクラス) を参照しましょう。

まとめ・Flutterでの開発の反省点

総じて、Flutterでの開発は、ネイティブ言語での開発と同等かもしくはさらに開発速度が上がったと思います。AndroidだけをFlutterで開発する場合でも、JavaやKotlinで開発するよりも効率的に開発することができます。

反省点もいくつかあります。UIデザインが iOS 寄りに制作されていたので、 Widget のレイアウトを凝ったUIにするのに時間がかかったりしました。マテリアルデザインにそったデザインで開発できたら、もっと開発時間を短縮できたと思います。また、 Theme を使ってボタンの色や、TextField の形を指定して、デフォルトのコンポーネントのスタイルを変更していれば、スタイルの指定がいらなかったかもしれません。今回の反省点をもとに次回の開発ではより効率的にしていきます。

最初は Flutter での開発には不安がありましたが、最終的にはこれからのアプリ開発は Flutter で十分なデザインや機能を持たせられると感じました。もちろん、ネイティブ依存が多い機能(GPS, 音楽)などはまだネイティブでの開発を推奨しますが、それ以外は Flutterで開発する方が良いかもしれません。Flutterとは?Flutterの特徴・メリット・デメリットを解説 にも目を通して、最適な開発手法でアプリ開発に望みましょう!

\スマホアプリ制作のご相談はこちら/ お問い合わせ