(2020/11/23 追記)
ライブラリが古くなって動かなくなっています。時間見つけて更新します。。
(2021/3/7 追記)
ライブラリのアップデート対応しました。Flutter2にも対応しましたが、2021/3/7現在は全てのライブラリの対応が完了していないこともあり、null safetyには対応出来ていません。
先にソースの共有
実行前にpubspec.yamlファイル上にてpub getで、ライブラリの読み込みをした後に、コードの自動生成コマンドを実行して下さい。
flutter packages pub run build_runner build
2回目以降はすでに生成されているコードとのコンフリクトを避けるため、こちらのコマンドを使用して下さい。
今回作成するもの
Qiitaのクライアントアプリのサンプルを作成します。元々、Flutterを勉強し始めた時に、自分で作成したサンプルをベースに余分な機能や不要なコードを削っていきました。
短い時間でFlutterの良さや面白さを伝えて行きたいです。
また直接プロジェクトで教えて頂いたりしたことはなるべく含めず、自分で調査したり調べたり理解出来たことをベースに(理解出来ていないことも含めざるを得ませんでした)、作成することを心がけました。
今回使用する主なライブラリなど
(略)
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
provider: ^4.3.3
retrofit: ^1.3.4+1
freezed: ^0.12.7
freezed_annotation: ^0.12.0
webview_flutter: ^1.0.7
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.3
dev_dependencies:
flutter_test:
sdk: flutter
# API access
build_runner: ^1.11.5
retrofit_generator: ^1.4.1+3
json_serializable: ^3.5.1
(略)
flutter packages pub run build_runner build --delete-conflicting-outputs
コマンド実行後にはiOSでもAndroidでも問題なく動作すると思います。それではコードを出来るだけ解説して行きます。
Retrofitの設定
まずは今回一番大変かもしれない、Retrofitの設定部分に関して少し説明させて頂きます。
@RestApi(baseUrl: "https://qiita.com/api/v2")
abstract class QiitaApiClient {
factory QiitaApiClient(Dio dio, {String baseUrl}) = _QiitaApiClient;
static QiitaApiClient create() {
final dio = Dio();
return QiitaApiClient(dio);
}
@GET("/tags/flutter/items?per_page=50")
Future<List<QiitaInfo>> getFlutterArticles();
}
そもそもRetrofitは何のために使っているのかというと、DartでHTTP通信をするためです。
今回の場合だとQiitaの記事を取得するにあたり、Qiita APIから情報を取得します。
Qiita APIドキュメント
https://qiita.com/api/v2/docs
今回は「flutter」のタグがついた最新記事を取得するようにします。Postmanなどのツールを使って以下のURLのレスポンスを確認します。
"//": "サンプルレスポンスデータ(簡略版)"
{
"//": "一部のみ表示"
"title": "sample title",
"url": "http://sample.com",
"user": {
"//": "一部のみ表示"
"profile_image_url": "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/......",
},
}
上記のようなデータがjson形式で取得出来ます。しかし、このような形式のデータをそのままプログラムで扱うことは難しいため変換する必要があります。
その際freezedというライブラリを使ってデータクラスを作成します。
コマンドを実行すると、データクラスをよしなに作成してくれるというイメージです。何故こんなことをしないといけないのかというと上記のjson形式のレスポンスデータのままではプログラムで扱うことが難しいので、Dartで扱える形に変換することが必要になります。そのためのコードが次のようになります。
part 'qiita_info.freezed.dart';
part 'qiita_info.g.dart';
@freezed
abstract class QiitaInfo with _$QiitaInfo {
factory QiitaInfo ({
String title,
String url,
@JsonKey(name: 'user') QiitaUser qiitaUser, // userデータのみ配列でデータが返ってくる
}) = _QiitaInfo;
// JSONをMap(連想配列)の形式に変換する
factory QiitaInfo.fromJson(Map<String, dynamic> json) =>
_$QiitaInfoFromJson(json);
}
何をやっているかイメージ出来なくなってきたので、整理すると、上記のレスポンスデータを以下のようにdartで扱えるように準備しています。
// ※イメージです
article = {
'title' : '記事のタイトル1',
'url' : 'http://sample.com',
...
};
またレスポンスデータを見れば分かりますがuserデータのみ、一段階深くネストされたデータになっています。なので別にQiitaUserという別クラスを作って受け取るようにします。
part 'qiita_user.freezed.dart';
part 'qiita_user.g.dart';
@freezed
abstract class QiitaUser with _$QiitaUser {
factory QiitaUser({
// userデータの中のprofile_image_urlをprofileImageUrlという値で受け取る
@JsonKey(name: 'profile_image_url') final String profileImageUrl,
}) = _QiitaUser;
factory QiitaUser.fromJson(Map<String, dynamic> json) => _$QiitaUserFromJson(json);
}
上記のコード@JsonKey
というキーワードについてですが、レスポンスデータで返ってくる値を別のキーワードに変換することが出来ます。例えばAPIからはユーザーの画像URLはprofile_image_urlというキーで受け取りますが、profile_image_urlはDartの命名規則とは異なります。なのでprofile_image_urlをDart側では「profileImageUrl」という変数で扱いますよーということを示しています。
これでレスポンスデータをプログラム側で受け取る準備が出来ました。しかし最初に説明した通り、実際にはこれらのコードで直接データの変換が出来るわけではありません。あくまでも準備が完了しただけになります。以下のコマンドを実行して、実際に変換をしてくれるコードを生成します。
flutter packages pub run build_runner build
Providerについて
freezed同様に、こちらについても私が学習を開始した時には使うのが当たり前という感じになっていたので、問答無用で使用しています。
私の大まか過ぎる理解としては、「データをProvideしてくれるもの」です。
図を用意しました。
図で示したように、ArticleScreenModelが記事を取得した時に、_ListにNotifyListenerで通知しています。
class ArticleScreenModel extends ChangeNotifier {
final api = QiitaApiClient.create();
List<QiitaInfo> _articles = List();
List<QiitaInfo> get articles => _articles;
Future<void> getFlutterArticle() async {
_articles = await api.getFlutterArticles();
notifyListeners();
}
}
//記事一覧画面
class ArticleScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final model = Provider.of<ArticleScreenModel>(
context,
listen: false,
);
Future(
() => model.getFlutterArticle(),
);
return Scaffold(
appBar: AppBar(
title: const Text(
'Qiita Sample',
),
centerTitle: true,
),
body: _List(),
);
}
}
// 記事一覧のリスト部分
class _List extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Consumer<ArticleScreenModel>(
builder: (context, model, child) {
return ListView.builder(
itemCount: model.articles.length,
itemBuilder: (context, int position) => ArticleItem(
qiitaInfo: model.articles[position],
onArticleClicked: (qiitaInfo) => _openArticleWebPage(
qiitaInfo,
context,
),
),
);
},
),
);
}
// タップされた記事を表示する
_openArticleWebPage(
BuildContext context,
QiitaInfo qiitaInfo,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleDetailScreen(
qiitaInfo: qiitaInfo,
),
),
);
}
}
- Providerがなかったら、どうなるか?
→ArticleScreen以降のWidgetを丸ごと再描画する必要があります。
今回はシンプルなレイアウトなので利点が分かりづらいですが、ArticleScreenがリスト以外にいろんな画面要素を持つ画面だったら、画面全体を描画することがパフォーマンス的にも良く無いことがイメージ出来るかもしれません。
他にもProviderは色々な役割がありますが、私も引き続き理解を深めます。
いまいち理解出来ていないままStateNotifierやRiverpodなど色々出てきて、頭が渋滞しています。。
タップして選択した記事を表示
今回は記事をタップすると、別の画面を開いてWebViewで指定のURLを開くようにします。webview_flutterライブラリを使えば簡単に表示することが出来ます。
import 'package:flutter/material.dart';
import 'package:qiita_sample/data/entities/qiita_info.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ArticleDetailScreen extends StatelessWidget {
ArticleDetailScreen({@required this.qiitaInfo});
final QiitaInfo qiitaInfo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
qiitaInfo.title,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(
fontSize: 13,
),
),
),
body: WebView(
initialUrl: qiitaInfo.url,
javascriptMode: JavascriptMode.unrestricted,
),
);
}
}
記事をタップするとArticleItem→ArticleScreenにコールバックされて、ArticleScreen側の遷移処理が実行されます。
// ArticleScreenの遷移処理
_openArticleWebPage(
BuildContext context,
QiitaInfo qiitaInfo,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArticleDetailScreen(
qiitaInfo: qiitaInfo,
),
),
);
}
ここまで全体の実装が完了しました。コードを実行して記事をタップすると、記事をWebViewで表示出来ました。
次の目標
ハンズオン自体は準備不足もあり、成功とは言いがたかったかもしれません。だいぶ削ったつもりだけど、サンプルが重かったです。。
Flutterでレイアウトを作成することなどには慣れてきて、APIを叩いてサーバーサイドと接続などを経験して、少しずつ理解出来ている気もしていたのですが、誰かに説明しようとすると全く説明出来ず、ざっくりと曖昧なことしか言えないことが分かりました。
(freezedとか全く説明出来ていないですね。。)
それでも今回ハンズオン用にコードを整形した際に、「自分が一ヶ月前に書いたWidgetのコード、めちゃくちゃだな」と思うくらいには、この一ヶ月で少しだけFlutterに慣れました。
設計なども見直して、もしかしたら更新記事を作成するかもしれません。
そのためにも、もっとFlutterのことを知るようにします。
追記
本サンプルにStateNotifierを導入した記事を作成しました。