背景
たまに見かける、URLを入れるだけでタイトルやらサムネイル画像やらが自動でつく現象、あれはいったいどうやっているんだろうと思い、調査・実装してみたくなった。
作りたいもの
- URLを入力して送信ボタンを押すと、指定したWebサイトのタイトルやサムネイル画像(?)を取得して表示するアプリ
作ったもの
WebサイトのOGPを抽出して表示するFlutterアプリ改 pic.twitter.com/ZxaxJjADPJ
— かーにゃ (@popy1017) May 31, 2020
ソースコード: https://github.com/popy1017/flutter_fetch_ogp
前提知識(Flutter関係ないけど重要)
<meta>
タグとOGP(Open Graph Protcol)
初めは、HTML要素全部引っ張ってきて、タグを見て画像とか愚直に引っ張り出してるのかな〜(無知)と思ったけど、
どうやらhtmlで見かける<meta>
タグから引っ張ってこれるらしい。
普通html書く時によく使うのは、こういうやつ。
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta>
タグには他にもいろいろ設定できて、例えば検索結果の各サイト名の下に表示される説明文なんかも<meta>
タグで設定できる。
あれ?じゃあ画像とかもここで設定しているのでは・・・?(無知)と思いさらに調査を進めると、どうやらFacebookやTwitterなどのSNS用に、OGP(Open Graph Protcol) という規格が用意されているらしい。これこれぇ!!
引用元: https://digitalidentity.co.jp/blog/seo/ogp-share-setting.htmlOGPとは、「Open Graph Protcol」の略でFacebookやTwitterなどのSNSでシェアした際に、設定したWEBページのタイトルやイメージ画像、詳細などを正しく伝えるためのHTML要素です。
LineでURLを貼った時にたまに、上の図みたいに画像とかタイトルとかつかないな〜と思っていたが、OGPが設定されていないURLだったんだ!という気付きを得て、ようやくアプリの実装へ。。。
(調査記録)Flutter(Dart)でWebサイトのmetaタグをとってくる
アプリを実装しようと思ったが、Dartでどうやってmetaタグをとってくるんだ?という疑問をまず解消してから先に進むことにした。
単純に考えれば、HTML要素全部取得して、正規表現とかでOGPの部分だけとってくれば良いのでは、それなら実装もできそう!と思ったが、
(誰かパッケージ化してくれているのでは。。。?)という淡い期待を抱きつつ調べると、あった!!
(ちなみに、Flutterのパッケージ公開サイトで"ogp"と調べた。)
metadata_fetch | Dart Package
ソースコード見た感じでは、やはり正規表現で抜き出しているっぽいので、頑張れば自分で実装できそう。
動作確認用のプロジェクトでいろいろ試して(割愛)、いざアプリ開発へ。。。
今回は以下の実装を参考にした。
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:http/http.dart' as http;
void main () async {
// Makes a call
var response = await http.get('https://flutter.dev');
// Covert Response to a Document. The utility function `responseToDocument` is provided or you can use own decoder/parser.
var document = responseToDocument(response);
// get metadata
var data = MetadataParser.OpenGraph(document);
print(data);
}
流れ
大まかな流れは以下の通り。
1. まずは画面を設計・実装する
2. アプリ全体で管理する状態(メタデータ)を追加する
3. ボタンを押したときの動作(OGPをとってきて、上記の状態に保存する)を実装する
4. 細部にこだわる
1. 画面の設計・実装
レイアウト案
今回はOGPの共通項目で一般的に(?)使われている、title, image, descriptionを取得して表示することとする。
ざっくりとした画面案は以下の通り。
実装
まずは、flutter create
で作ったカウンターアプリを、以下のようにシンプルに書き換える。
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter OGP Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MetaDataView(),
);
}
}
class MetaDataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Center(
child: Text("hello"),
));
}
}
この状態から、MetaDataView
をいじってレイアウトを作っていく。
大雑把なレイアウト
まず、大きくわけて2つの部分(メタデータ部分、Form(入力欄とボタン)部分)を縦に並べたいので、Column
を使う。
次に、URL入力欄のところは固定の高さにしたいので、SizedBox
を使って高さを指定し、残りの部分にメタデータ部分を表示する。
class MetaDataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Column(
children: <Widget>[
// わかりやすいように背景に色をつけている
Expanded(child: Container(color: Colors.red)),
SizedBox(height: 100),
],
));
}
}
メタデータ部分(タイトル、画像、説明文)
次に、メタデータ部分に含めたい要素(タイトル、画像、説明文)を追加していく。
ここでもこの要素を縦に並べたいのでColumn
を使う。
まだメタデータを取得する部分を実装していないので、例としてFlutterの公式ページのメタデータを表示する(ここはなんでも良い)。
Image.network(srcUrl)
で、ネット上の画像を簡単に表示できる。
追記:
実機で動作確認したところ、TextField
にフォーカスしたときにキーボードが出てくる影響で、
レンダーボックスがオーバーフローしてしまうので、ここではスクロール可能なListView
を使った方が良いです
実機検証大事。。。
class MetaDataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Column(
children: <Widget>[
Expanded(
child: Column(
children: <Widget>[
Text("Flutter - Beautiful native apps in record time"),
Image.network(
"https://flutter.dev/images/flutter-logo-sharing.png"),
Text(
"Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
],
)),
SizedBox(height: 100, child: Container(color: Colors.cyan)),
],
));
}
}
Form部分(URL入力欄、Fetchボタン)
次にForm部分を構成する要素を追加する。
ここでも、要素を縦に並べたいのでColumn
を使う。
入力欄はTextField
(or TextFormField
)で、ボタンはRaisedButton
で表示できる。
class MetaDataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Column(
children: <Widget>[
~~~~,
SizedBox(
height: 100,
child: Column(
children: <Widget>[
TextField(),
RaisedButton(
onPressed: () {},
child: Text("Fetch"),
color: Colors.blue,
textColor: Colors.white,
),
],
)),
],
));
}
}
やっぱりちょっと気になるので、全体をPaddingでラップする。
class MetaDataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Padding( // <= 我慢できずPaddingを追加
padding: const EdgeInsets.all(12.0),
child: Column(
children: <Widget>[
Expanded(
child: Column(
~~~
各要素をWidgetに切り出す
このまま進めていくとネストがどんどん深くなり可読性が下がるので、ある程度まとまった単位でWidgetに切り出したい。
今回は、メタデータ部分をMetaDataDetail
に、Form部分をFetchOgpForm
に切り出す。
まずは切り出し先のファイルを作成する。(lib/
配下にcomponents/
を新規作成)
-
lib/components/metadata_detail.dart
, -
lib/components/fetch_ogp_form.dart
,
import 'package:flutter/material.dart';
class MetadataDetail extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: <Widget>[
Text("Flutter - Beautiful native apps in record time"),
Image.network("https://flutter.dev/images/flutter-logo-sharing.png"),
Text(
"Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
],
));
}
}
import 'package:flutter/material.dart';
class FetchOgpForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 100,
child: Column(
children: <Widget>[
TextField(),
RaisedButton(
onPressed: () {},
child: Text("Fetch"),
color: Colors.blue,
textColor: Colors.white,
),
],
));
}
}
切り出し終わったら、MetaDataViewを書き換えて切り出したWidgetを表示する。
※ ここでメタデータの英語はmetadata
であることに気づいたので、dを大文字にするのをやめて、MetaDataView
をMetadataView
に変更しました。
class MetadataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: <Widget>[MetadataDetail(), FetchOgpForm()],
),
));
}
}
2. 状態管理(メタデータ、テキスト)を追加する
このアプリで管理する必要がある状態は、以下の2つ。
- URL入力欄に入力された文字列
- 取得したメタデータ
URL入力欄に入力された文字列は、ボタンを押したときに参照できれば良いのでFetchOgpForm
で管理する。
取得したメタデータは、ぱっと見MetadataDetail
のみから参照されそうだが、Fetchボタンを押したときに保持しているメタデータを更新したいので、FetchOgpForm
からもアクセスできるようにする必要がある。そのため、2つのWidgetの親であるMetadataView
(かそれより上)で状態を管理する。
(もっと良い設計があるかもしれない。)
URL入力欄に入力された文字列の状態を管理する
URL入力欄に入力された文字列は、1つのWidget(FetchOgpForm
)でしか必要ないため、StatefulWidget
で状態を管理する。
そのためFetchOgpForm
を書き換え、以下のようにする。
import 'package:flutter/material.dart';
class FetchOgpForm extends StatefulWidget {
@override
_FetchOgpFormState createState() => _FetchOgpFormState();
}
class _FetchOgpFormState extends State<FetchOgpForm> {
String _url = "";
@override
Widget build(BuildContext context) {
return SizedBox(
height: 100,
child: Column(
children: <Widget>[
TextField(
onChanged: (text) {
setState(() {
_url = text;
});
},
),
RaisedButton(
onPressed: () {
print("Current url is $_url");
// fetchMetadata(_url);
},
child: Text("Fetch"),
color: Colors.blue,
textColor: Colors.white,
),
],
));
}
}
これで、ボタン押した時にデバッグコンソールに入力された文字列が表示されるはず。
取得したメタデータの状態を追加する
上述の通り、このアプリで保持するメタデータは、FetchOgpForm
から更新し、MetadataDetail
で参照する必要があるため、それらの上のWidgetで管理する必要がある。
Flutterの状態管理は、上で使ったStatefulWidget
や、BLoCパターンなど様々な種類があるが、ここでは今流行り?のProvider
パターンを使う。
(最近だとstate_notifier
が出てきているが、使い所やProvider
との違い、優位点が理解できていない。)
provider, metadata_fetchのインストール
Providerパターンでは、provider
パッケージを使うので、pubspec.yaml
を編集してインストールする。
metadata_fetch
パッケージをここでインストールする理由は、metadata_fetch
で定義されているMetadata
クラスを使うため。
~~
dependencies:
flutter:
sdk: flutter
provider: ^4.1.2
metadata_fetch: ^0.3.1
~~
必要があればflutter pub get
を実行する。
状態クラスを作る
ChangeNotifier
を使って、状態を表すクラスを作る。
lib/
配下にmodels/
を作って、metadata_model.dart
を新規作成。
まだ実際に指定したWebサイトからメタデータを取得する部分はないので、確認用にモックデータを定義。
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';
// テスト用。あとで消す。
Map<String, dynamic> mockJson = {
"title": "Flutter - Beautiful native apps in record time",
"image": "https://flutter.dev/images/flutter-logo-sharing.png",
"description":
"Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. Flutter works with existing code, is used by developers and organizations around the world, and is free and open source"
};
class MetadataModel extends ChangeNotifier {
Metadata ogp = Metadata.fromJson(mockJson);
// fetchOgpFrom(String url) {};
}
各Widgetが状態クラスにアクセスできるようにする
ChangeNotifierProvider
を使って、MetadataDetail
widgetとFetchOgpForm
widgetが上記の状態クラスにアクセスできるようにする。
(ChangeNotifier
をprovideする)
class MetadataView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("'Flutter OGP Demo'")),
body: Padding(
padding: const EdgeInsets.all(12.0),
child: ChangeNotifierProvider(
create: (context) => MetadataModel(),
child: Column(
children: <Widget>[MetadataDetail(), FetchOgpForm()],
),
),
));
}
}
MetadataDetailから状態クラスMetadataModelを参照する
状態変化の監視つきで状態を取得する場合は、context.select
を使う。(provider: 4.1.0以降)
import 'package:fetch_ogp/models/metadata_model.dart';
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:provider/provider.dart';
class MetadataDetail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);
return Expanded(
child: Column(
children: <Widget>[
Text(_ogp.title),
Image.network(_ogp.image),
Text(_ogp.description)
],
));
}
}
これで、変化が監視されたメタデータを表示できるようになった。
3. ボタンを押したときの動作を実装する
ここで行いたい処理は、FetchOgpForm
から状態クラスMetadataModel
にアクセスし、Metadata ogp
を更新することである。
onPressed
の中で更新を行っても良いが、更新用の関数をMetadataModel
に定義してそれを呼び出した方が楽そうなのでそうする。
まずは指定したURLからHTML要素をとってくる必要があるので、http
パッケージをインストールする。
dependencies:
~~
http: ^0.12.1
OGPを取得する関数を定義する
MetadataModelに、OGPを取得してMetadata ogp
にセットする関数を書いていく。
(各行でエラー起こる予感がするが、いったんエラー処理は割愛)
http.get()
は非同期(Future
を返す)関数なので、await
で待たせてもらう。
class MetadataModel extends ChangeNotifier {
Metadata ogp = Metadata.fromJson(mockJson);
void fetchOgpFrom(String _url) async {
final response = await http.get(_url);
final document = responseToDocument(response);
ogp = MetadataParser.OpenGraph(document);
notifyListeners();
}
}
OGPを取得する関数を呼び出す
上記で定義した関数をFetchOgpForm
widgetから呼び出したい。
ボタンを押した時の動作は、FetchOgpForm
のRaisedButton
のonPressed
で定義する。
ちなみに、onPressed
がnull
のときはボタンが非活性になるので、入力状況によって活性・非活性を切り替えることができる。
状態クラスの関数にアクセスするためには、context.read<ModelName>().functionName()
を使う。
※ FromとFormがややこしいので別の名前にすれば良かった
class _FetchOgpFormState extends State<FetchOgpForm> {
String _url = "";
@override
Widget build(BuildContext context) {
return SizedBox(
height: 100,
child: Column(
~~
RaisedButton(
onPressed: (_url == "")
? null
: () {
print("Current url is $_url");
context.read<MetadataModel>().fetchOgpFrom(_url);
},
child: Text("Fetch"),
color: Colors.blue,
textColor: Colors.white,
),
],
));
}
}
これで、とりあえず入力したURLからメタデータをとってきて表示することができた。
WebサイトのOGPを抽出して表示するFlutterアプリ pic.twitter.com/WvMVxC1HVp
— かーにゃ (@popy1017) May 31, 2020
4. 細部にこだわる
タイトルをタイトルっぽくする
現状では、文字が小さくてタイトル感がないので、スタイルを調整してタイトルっぽくしたい。
Text
widgetにスタイルを付与するには、style: TextStyle()
を追加する。
Text(
_ogp.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24,
),
ボタンをかっこよくする
ボタンを丸角にする
完全に好みだが、まるっぽいボタンの方が良いのでボタンを丸角にする。
ボタンを丸角にするためには、RaisedButton
widgetにshape: StadiumBorder(),
をつける。
~~
RaisedButton(
shape: StadiumBorder(),
onPressed: (_url == "")
~~
ボタンにアイコンをつける
ボタンにアイコンをつけるためには、RaisedButton.icon()
を使い、icon: Icon()
を指定する。
またその際、文字列をアイコンの後に続ける場合はchild
ではなくlabel
を使う必要がある必要がある点に注意。
~~
RaisedButton.icon(
shape: StadiumBorder(),
icon: Icon(
Icons.file_download,
color: Colors.white,
),
onPressed: (_url == "")
? null
: () {
print("Current url is $_url");
context.read<MetadataModel>().fetchOgpFrom(_url);
},
label: Text("Fetch"),
~~
(割と雑に)エラーに対処する
OGPが設定されていないURLが指定された場合
今の実装では、タイトル、画像、説明文のいずれかがWebサイト側で設定されていない場合、Text
やImage
widgetにnull
が渡されてしまい、エラーとなってしまう。
そこでそれらの部分で、??
(if null operator)を使って、null
の場合は決められた文字列を指定するようにする。
// No Imageと書かれた画像のフリー素材
final noImagePath =
"https://www.shoshinsha-design.com/wp-content/uploads/2016/10/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88-2016-10-05-0.41.12.png";
class MetadataDetail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);
return Expanded(
child: Column(
children: <Widget>[
Text(
_ogp.title ?? "No title",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
Image.network(_ogp.image ?? noImagePath),
Text(_ogp.description ?? "No description")
],
));
}
}
OGP取得の際に何かしらのエラーがあった場合に通知する
現在の実装では、URL形式でない文字列が与えられた場合や、通信できなかったときなど、様々なエラーが発生した場合への処理が何も書かれていない。
そのため、エラーが起きたときに、SnackBarと呼ばれる画面下からひょいっと出てくる小窓みたいなのを追加し、エラーが起きたことを伝えるようにする。
処理が失敗したか成功したかわからないので、fetchOgpFrom(url)
関数を書き換える。
class MetadataModel extends ChangeNotifier {
Metadata ogp = Metadata.fromJson(mockJson);
Future<bool> fetchOgpFrom(String _url) async {
try {
final response = await http.get(_url);
final document = responseToDocument(response);
ogp = MetadataParser.OpenGraph(document);
notifyListeners();
return true;
} catch (e) {
print(e.message ?? e);
return false;
}
}
}
失敗したときのみSnackBarを表示するよう、FetchOgpForm
のonPressed()
を書き換える。
onPressed: (_url == "")
? null
: () async {
print("Current url is $_url");
final success = await context
.read<MetadataModel>()
.fetchOgpFrom(_url);
if (!success) {
final SnackBar _snackBar = SnackBar(
content: Text("Error happened."),
backgroundColor: Colors.red[300],
);
Scaffold.of(context).showSnackBar(_snackBar);
}
},
余談
本当はエラー内容に応じてメッセージを変えたかったが、すぐにはできなさそうだったので別の機会にやることにした。
URL入力欄にプレースホルダーを表示する
ぱっと見何を入力すれば良いのかわからないので、プレースホルダーでわかりやすくする。
TextField
にdecoration: InputDecoration(~~)
を指定するといろいろ設定できる。
class _FetchOgpFormState extends State<FetchOgpForm> {
String _url = "";
@override
Widget build(BuildContext context) {
return SizedBox(
height: 100,
child: Column(
children: <Widget>[
TextField(
decoration: InputDecoration(hintText: "https://www.example.com"),
onChanged: (text) {
setState(() {
_url = text;
});
},
),
~~