[Dart/Flutter] 専用ライブラリを作って、Conditional Importing(条件付インポート)でアプリとWebのソースを共存させる

目的

Flutterにて、モバイルアプリ(Android/iOS)とFlutter Webの両方に対応するアプリを作成したい。Flutter Webで「dart:html」を使用したいケースが発生した。ただ、こちらを使用するとアプリを実行するときにエラーが発生してしまう。そこで、条件付きインポート(日本語でなんていうのか分からない、、、)にて、アプリ時とWEB時で異なるインポートを行い、一つのソースで両方実施できるようにする

概要

まず、このページでは、条件付きインポートの説明をします。そして、次のページで実際にアプリとWeb用に分けてWidgetを作成したいと思います。
条件付きインポートをするために、以下の4つのファイルを作成します。

  • 抽象クラス
  • アプリとWebの両方のクラスの親クラスとなるクラスです。どのような関数を作るか定義をします。また、ファクトリメソッドを定義して、実際のクラスを生成するためのメソッドを呼び出します。条件付きインポートを使って、インポートするファイルを切り替えて、生成するメソッドを変更します。

  • スタブクラス(張りぼてクラス)
  • 実際には使われない生成メソッドを定義します。生成メソッド自体はクラスの中に入っていないのが注意点です。
    もしもインポートの条件が満たされない場合はこのクラスが呼ばれますので、呼ばれるはずないので、例外を発生させます。抽象クラス内には生成メソッドがありませんが、このスタブクラスをインポートすることで生成メソッドは見つかるので、コンパイルエラーにはなりません。

  • アプリ用のクラス
  • アプリ実施時に実施するクラスを定義します。また、スタブクラスと同じように生成メソッドをクラス外に定義し、抽象クラスのファクトリメソッドから呼ばれるようにします。生成メソッドが呼ばれることで、ここで定義したアプリ用のクラスが使用されるようにします。

  • Web用のクラス
  • Web実施時に実施するクラスを定義します。また、スタブクラスと同じように生成メソッドをクラス外に定義し、抽象クラスのファクトリメソッドから呼ばれるようにします。生成メソッドが呼ばれることで、ここで定義したWeb用のクラスが使用されるようにします。

抽象クラス内で、アプリとWebのクラスをどちらか読み込むか条件によりインポートして、実際に使用するクラスを生成します。

ソース

抽象クラス

親のクラスです。注意する点は以下の3点です。

  • (1) 条件付きインポートの定義
  • (2) 抽象クラスと抽象メソッドの定義(別に「抽象」でなくても大丈夫)
  • (3) 実際のクラスを生成するメソッドの定義

コンパイル時の判断としてはdata_importer_stub.dartが読み込まれるので、生成メソッド(getDataImporter)が存在しコンパイルエラーにはなりません。

//(1)
import 'data_importer_stub.dart'
    // ignore: uri_does_not_exist
    if (dart.library.io) 'data_importer_app.dart'
    // ignore: uri_does_not_exist
    if (dart.library.html) 'data_importer_web.dart';
 
// (2)
abstract class DataImporter {
  String getValue(String text);
 
  // (3)
  factory DataImporter() => getDataImporter();
}

順番に見ていきましょう。

条件付きインポートの定義

この例のキモとなります。条件によって、読み込むファイルを切り替えてます。

  • コンパイル時や条件に合致しないケース: data_importer_stub.dart
  • アプリ実行時(dart.library.ioのパッケージが存在した場合): data_importer_app.dart
  • FlutterWeb実行時(dart.library.htmlのパッケージが存在した場合): data_importer_web.dart

を読み込むようにします。

また読み込まないパッケージは存在しませんので「ignore: uri_does_not_exist」をつけて、存在しなくてもエラーを出さないようにします。ただ、間違えてパッケージ名を入れた場合でもエラーが出なくなるのでお気をつけ下さい。

抽象クラスと抽象メソッドの定義

メソッドの型を定義します。別に「抽象」にする必要はないですが、抽象にした方がそれっぽくなるので。

実際のクラスを生成するメソッドの定義

実際にインスタンスを生成するときのメソッドを定義します。ただ、メソッド内から呼び出される実際のメソッドは、他の3つのファイルにそれぞれ定義しま、このファイル内には定義しません。こちらは「うまいなぁ」と思う点です。条件によってインポートするファイルが切り替わることはすでに説明しました。その切り替え先のファイル毎にインスタンス生成のメソッドを定義することで、アプリ実行時はアプリ実行時用のクラス、WEB実行時はWEB実行時用のクラスを生成するメソッドがインポートされるようになります。そして両方に当てはまらないときは例外を発生させるようデフォルトのファイルに定義しておきます。

スタブクラス

張りぼてクラスです。通常生成用のメソッド作成するのですが、こちらは実行時に呼ばれたらアウトなので、生成メソッドが呼ばれたら例外を発生するようにします。抽象クラス内には「getDataImporter()」が存在しないですが、コンパイル時にはこのファイルがインポートされるので、コンパイルエラーにはなりません。そして実行時には、アプリやWEBのインポート条件が合致するため、このファイルは呼ばれません。

import 'data_importer.dart';

DataImporter getDataImporter() => throw UnsupportedError('not supported');

アプリ用のクラス

AndroidやiOSのアプリとして実行されたときに呼ばれるクラスを定義します。また、生成用のメソッドから定義したクラスのインスタンスを呼ぶことで、ここで定義したクラスが呼ばれます。繰り返しになりますが、生成するメソッドをクラス外に書いているのがうまいです。クラス外に定義することで、クラスの中身や継承の影響を受けることなく、該当のクラスを生成することができます。クラス内に定義すると、factoryメソッドとコンストラクタと親クラスのコンストラクタの影響でゴチャゴチャになって、動作するように構成できません。
アプリ実行時には条件付きインポートでこのファイルが呼ばれるので、アプリ用のクラスが生成されるようになります。

import 'data_importer.dart';

class DataImporterApp implements DataImporter {
  DataImporterApp();

  String getValue(String text) {
    return 'DataImporterApp: $text';
  }
}

DataImporter getDataImporter() => DataImporterApp();

Web用のクラス

Flutter Webで実行されたときに呼ばれるクラスを定義します。FlutterWeb実行時には条件付きインポートでこのファイルが呼ばれるので、Web用のクラスが生成されるようになります。細かい内容は、「アプリ用のクラス」と同様です。

import 'data_importer.dart';

class DataImporterWeb implements DataImporter {
  DataImporterWeb();

  String getValue(String text) {
    return 'DataImporterWeb: $text';
  }
}

DataImporter getDataImporter() => DataImporterWeb();

出力の確認

実際の出力を見てみます。以下は、いつものカウントアップアプリのStateクラス部分を書き換えただけです。条件付きインポートで自動的に切り替わりますので、ここでは特に何もせず抽象クラスのファイルをインポートしてます。ただ、特定のクラスのコンストラクタを呼ぶのではなく、生成用のメソッドを呼んでいます。

import 'data_importer.dart';
class _MyHomePageState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(DataImporter().getValue('test')),
          ],
        ),
      ),
    );
  }
}

左はFlutterWebでの実行時のブラウザ画面、右側はAndroid端末での実行時の画面になってます。それぞれのメッセージが異なっており、それぞれ異なるクラスがインポートできたことが確認できます。

まとめ

Conditional Importing(条件付インポート)の実際のコードを見て頂き、概念を理解して頂いたかと覆います。ただ、ソースが条件付きインポートに特化しているため、あまり実用性がないのも事実です。実際に条件付きインポートを使用する記事も書きたいと思います。また、理屈的はConditional Exportingも同じです。

参考

(公式)[Creating packages] Conditionally importing and exporting library files

flutter_platform_specifc_imports