WebやAndorid, iOSでおなじみのApollo Client はFlutterに対応していません。 Flutterでは代わりに、graphql_flutterというApollo Clientをインスパイアしたライブラリがあるのでこれを使います。
ただ、graphql_flutter単体ではGraphQLのSchemaからDartのファイルを生成してくれません。
そのために、artemis
というライブラリを使ってSchemaからDartファイル生成します。
シンプルな使い方はgraphql_flutterのドキュメントを読めば書いてあるので、この記事ではprovider を使ったクリーンアーキテクチャっぽい設計のアプリに当てはめて、実際にプロダクトで使えるように説明したいと思います。
サーバ側はGithubのAPIを使いたかったのですが、認証が必要なのでこちらのリポジトリを使わせて頂きます。
国の一覧が取得できるエンドポイントになっています。
- GraphQL API
Schemaファイルもそのページからダウンロード出来ます。(クラス図 )
シンプルな使い方
導入
graphql_flutterをpubspecに書きます。
dependencies:
flutter:
sdk: flutter
# graphql
graphql_flutter: 3.0.1
Flutterコード
import 'package:graphql_flutter/graphql_flutter.dart';
void main() {
final HttpLink httpLink = HttpLink(
uri: 'https://api.github.com/graphql',
);
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer <YOUR_PERSONAL_ACCESS_TOKEN>',
// OR
// getToken: () => 'Bearer <YOUR_PERSONAL_ACCESS_TOKEN>',
);
final Link link = authLink.concat(httpLink);
ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
cache: InMemoryCache(),
link: link,
),
);
...
}
...
return GraphQLProvider(
client: client,
child: MaterialApp(
title: 'Flutter Demo',
...
),
);
...
READMEに書いてある一番シンプルな書き方はこれです。
GraphQLClientを作ってあげて、それをValueNotifierでWrapしてあげたものを上位のWidget(ここではMaterialApp)から読み込みます。
作成したClientからqueryを呼ぶ形になります。
当然この書き方だと実運用する場合にViewと密結合になってしまうので、このまま使うことは難しいです。 そこでproviderとChangeNotifierを使って実運用に適した形にして使う一例を紹介します。
Provider+flutter_graphql
アーキテクチャ
今回はこのようなアーキテクチャにしました。
名前は便宜上このようになっていますが各階層の責務が別れていれば何でも良いと思います。
今やっているプロダクトではViewModelの層とRepository層の間にUseCase層のようなものを置いているのですが、今回は省略します。
------- ------------------- ------------ ------------------
| View | <=> | ViewModel | <=> | Repository | <=> | API Client |
| | | (ChangeNotifier) | | | | (GraphQLClient) |
------- ------------------- ------------ -----------------
導入
モデル作成用のfreezed とproviderを追加します。
dependencies:
flutter:
sdk: flutter
# graphql
graphql_flutter: 3.0.1
# provider
provider: 4.1.3
# freezed
freezed_annotation: 0.11.0
dev_dependencies:
flutter_test:
sdk: flutter
# freezed
json_serializable: 3.3.0
build_runner: 1.10.0
freezed: 0.11.0
Flutterコード
Api Client
まずはGraphQLClientを作ります。
class GraphQLApiClient {
GraphQLApiClient(AppConfig config)
: graphQLClient = ValueNotifier<GraphQLClient>(
GraphQLClient(
cache: InMemoryCache(),
link: HttpLink(
uri: config.baseUrl,
httpClient: LoggerHttpClient(http.Client()), // Logging用HTTP client
),
),
);
final ValueNotifier<GraphQLClient> graphQLClient;
Future<QueryResult> query(
String query, {
Map<String, dynamic> variables,
}) async {
final QueryResult result = await graphQLClient.value.query(QueryOptions(
documentNode: gql(query),
variables: variables,
));
if (result.exception != null) {
// エラー処理
print(result.exception);
for (final GraphQLError error in result.exception.graphqlErrors) {
print(error.message);
}
}
return result;
}
}
QueryOptions
にはintervalであったり、Fetch, Error時のオプションがあるので適宜設定してください。ここではシンプルにしておきます。
実際のプロダクトではDev環境, Staging環境など複数の環境が想定されます。そのため、引数に渡している AppConfig
にはGraphQLの接続先ホスト名を持ったクラスを渡しています。
ホスト名だったり環境毎に変えたい変数を入れておくと良いでしょう。
class AppConfig {
const AppConfig({
@required this.baseUrl,
});
final String baseUrl;
}
もう1つ注目して欲しいのはHttpLinkに渡しているHttpClientです。
flutter_graphqlは通常だと通信のログを表示してはくれません。そのため、HttpClientを継承してLoggerHttpClientを作成し、ログを表示できるようにしています。
私は別途Loggerライブラリを使ってリリースビルド時にログを表示しないようにしていますが、単純に bool.fromEnvironment('dart.vm.product')
とかを使って表示、非表示を切り替えても良いと思います。(例では出し分けをしていません。)
class LoggerHttpClient extends BaseClient {
LoggerHttpClient(this._client);
final Client _client;
final JsonEncoder _encoder = const JsonEncoder.withIndent(' ');
final JsonDecoder _decoder = const JsonDecoder();
@override
void close() {
_client.close();
}
@override
Future<StreamedResponse> send(BaseRequest request) {
return _client.send(request).then((StreamedResponse response) async {
final String responseString = await response.stream.bytesToString();
debugPrint('''
=> request: ${response.request.toString()},
=> headers: ${_encoder.convert(response.headers)},
<- statusCode: ${response.statusCode},
<- responseString: ${_encoder.convert(_decoder.convert(responseString))},
''');
return StreamedResponse(
ByteStream.fromBytes(utf8.encode(responseString)),
response.statusCode,
headers: response.headers,
reasonPhrase: response.reasonPhrase,
persistentConnection: response.persistentConnection,
contentLength: response.contentLength,
isRedirect: response.isRedirect,
request: response.request,
);
});
}
}
Model
JSONのパーサーを自分で書いても良いのですが、面倒なのでモデルの作成にはfreezed を使います。
@freezed
abstract class Countries with _$Countries {
const factory Countries({
@JsonKey(name: 'Country') @required List<Country> countries,
}) = _Countries;
factory Countries.fromJson(Map<String, dynamic> json) =>
_$CountriesFromJson(json);
}
@freezed
abstract class Country with _$Country {
const factory Country({
@required String name,
@required Flag flag,
}) = _Country;
factory Country.fromJson(Map<String, dynamic> json) =>
_$CountryFromJson(json);
}
@freezed
abstract class Flag with _$Flag {
const factory Flag({
@required String emoji,
@required String svgFile,
}) = _Flag;
factory Flag.fromJson(Map<String, dynamic> json) => _$FlagFromJson(json);
}
freezedに関しては詳しく説明しません。モデルを作成したらbuild_runnerで生成します。
$ flutter pub run build_runner build
Repository
GraphQLClientにアクセスするためのRepositoryです。
Repositoryでは、freezedを使ってModelにマッピングします。
flutter_graphqlはGraphQLのSchemaからDartファイルを生成する機能は持っていません。そのためqueryはStringで渡すことになります。
Schemaからの自動生成は後で説明します。
abstract class CountryRepository {
Future<List<Country>> getCountries();
}
class CountryRepositoryImpl implements CountryRepository {
CountryRepositoryImpl(this._client);
final GraphQLApiClient _client;
@override
Future<List<Country>> getCountries() async {
final QueryResult result = await _client.query(query);
return Countries.fromJson(result.data as Map<String, dynamic>).countries;
}
}
const String query = '''
query {
Country {
name
flag {
emoji
emojiUnicode
svgFile
}
}
}
''';
View Model
class HomeModel extends ChangeNotifier {
HomeModel(this._countryRepository) {
getCountries();
}
final CountryRepository _countryRepository;
List<Country> countries = [];
Future<void> getCountries() async {
try {
countries = await _countryRepository.getCountries();
notifyListeners();
} catch (e) {
print(e);
}
}
}
View
View側ではproviderのselectを使ってViewModelにあるcountriesのリストを監視してListViewに表示しています。
class HomePage extends StatelessWidget {
const HomePage({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<HomeModel>(
create: (_) => HomeModel(
context.read(),
),
child: const _Body(),
);
}
}
class _Body extends StatelessWidget {
const _Body({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('GraphQL Example'),
),
body: _contents(context));
}
Widget _contents(BuildContext context) {
final List<Country> countries =
context.select((HomeModel model) => model.countries);
return ListView.separated(
itemBuilder: (BuildContext context, int index) {
return _listItem(context, countries[index]);
},
itemCount: countries.length,
separatorBuilder: (_, __) {
return const Divider();
},
);
}
Widget _listItem(BuildContext context, Country country) {
return ListTile(
title: Text(
country.name,
style: Theme.of(context).textTheme.bodyText1,
),
leading: Text(country.flag.emoji),
);
}
}
GraphQL Schemaからの自動生成
flutter_graphqlだけでは、Schemaからの自動生成はしてくれません。そのため、上記ではクエリ用のStringを定義してそれをGraphQL Clientに渡して使いました。 これでは、せっかくのGraphQLのメリットが薄れてしまいます。
そこでartemisというライブラリを使ってSchemaファイルからDartのファイルを自動生成したいと思います。
この記事ではQueryの説明のみですが、もちろんMutationも同じ様に使えます。
導入
dev_dependencies:
flutter_test:
sdk: flutter
artemis: 6.4.4-beta.1
この例では、freezedで既に使っていますが、もし使わない場合はbuild_runnerとjson_serializableも必要です。
Schema定義
今回はプロジェクトのルートディレクトに graphqlディレクトリを作成してそこにschemaとqueryのgraphqlファイルを入れます。
Schemaファイルはここ からダウンロードできます。
クエリ用のgraphqlは自分で用意します。 IntelliJのGraphQLプラグインを使うとSchemaファイルがあれば、サジェストしてくれるのでおすすめです。
query GetCountries {
Country {
name
flag {
emoji
svgFile
}
}
}
graphqlファイルを用意したら、次に同じくルートディレクトリにbuild.yamlを追加します。
targets:
$default:
sources:
- lib/**
- graphql/**
builders:
artemis:
options:
schema_mapping:
- schema: graphql/schema.graphql
queries_glob: graphql/countries.query.graphql
output: lib/generated/countries_query.dart
schemaファイルとqueryのGraphQLファイル, 出力先のdartファイルを指定するだけです。
定義を終えたらbuild_runnerコマンドで生成しましょう。
$ flutter pub run build_runner build
そうすると lib/generated/
にdartファイルが3つ作成されます。
Flutterコード
クエリがDartのコードで用意出来たので、先程のコードを一部変えます。
ArtemisClient
というのもあるのですが、今回ArtemisはGraphQLのDocumentNode自動生成のみで使います。
Repositoryはこのように変更しました。
class CountryRepositoryImpl implements CountryRepository {
CountryRepositoryImpl(this._client);
final GraphQLApiClient _client;
@override
Future<List<Country>> getCountries() async {
// queryが自動生成されている
final QueryResult result =
await _client.query(GetCountriesQuery().document);
final List<Country> countries =
GetCountries$Query.fromJson(result.data as Map<String, dynamic>)
.country
.map((GetCountries$Query$Country item) => _mapCountry(item))
.toList();
// Remove items that failed to parse
countries.removeWhere((Country value) => value == null);
return countries;
}
// mapperを用意
// try-catchにすることでリストのアイテムの内1つエラーがあっても全体でエラーにならない
Country _mapCountry(GetCountries$Query$Country item) {
try {
return Country(
name: item.name,
flag: _mapFlag(item.flag),
);
} catch (e) {
print('Parse error: $e');
return null;
}
}
Flag _mapFlag(GetCountries$Query$Country$Flag item) {
return Flag(
emoji: item.emoji,
svgFile: item.svgFile,
);
}
}
artemisなしではクエリを手書きしていましたが、query.graphqlファイルからGetCountriesQuery().document
というDocumentNodeが生成されています。
Client側にはこちらを渡すようにします。同時にgraphql_client.dart
側の引数の型もDocumentNode
に変更しておきましょう。
リクエストのマップ部分ですが、手動でやっています。 一度にパースされるともし一箇所でもエラーが発生した際に全てエラーになってしまい、リスト全てが表示できなくなってしまうからです。 今回の例では起こり得ないと思いますが、こういったことは実サービスではケアしておくべきでしょう。
各層の責務が別れているので、RepositoryとClientの修正だけで動くようになりました。
独自タイプ
今回のSchemaにはないので使っていないのですが、GraphQLのScalarをDartの型にmapすることも可能です。
その場合はscalar mappingをbuild.yamlに追加します。
GraphQLのscalar FooをDart側のBarクラスにマッピングしたい場合はこのように書きます。
targets:
$default:
sources:
- lib/**
- graphql/**
builders:
artemis:
options:
scalar_mapping:
- custom_parser_import: 'package:fluttergraphql/graphql_converter.dart'
graphql_type: Foo
dart_type:
name: Bar
Bar fromGraphQLFooToDartBar(String value) {
return Bar(value);
}
Foo fromDartBarToGraphQLFoo(Bar bar) {
return Bar.value;
}
GraphQLからDartへのConverterは、fromGraphQL___ToDart___
の形
DartからGraphQLへのConverterは、fromDart___toGraphQL___
の形で書くのがポイントです。
デメリット
一見便利なようなartemisですが、GraphQLのSchemaにextendsが使えなかったり、fragmentは使えますが生成されたDartファイルの型にクエリ名が入ってしまうので、同じようなクエリのmapperを共通化できない等まだ不十分なところも多いです。
情報もほとんどないので、ソースコードを読んで推測する力が求められます。
まとめ
FlutterでGraphQLを実用的に使う方法をまとめてみました。
FlutterでGraphQLを使っているプロダクトが少ないのか、情報があまりないのも難点です。英語の記事はもちろん日本語記事は執筆時点でGoogle検索に引っかかるのは1つだけでした。
DartはProtoBufを公式サポートしているのでGraphQLよりもそちらを使う場合のほうが多いのかもしれません?
(そもそもまだWidgetの使い方みたいな記事ばかりで実践的な内容の記事が少ない)
今回のサンプルで使ったコードはこちらになります。