東京公共交通オープンデータの開発者登録をしたので、流行りのFlutterを使って、初めてスマホアプリ作成にチャレンジしてみました。GPS位置情報をもとに近くの駅名と距離をリスト表示する試作アプリを、1週間程度で作成しています。
追記: この試作アプリを成長させることで、第3回東京公共交通オープンデータチャレンジの最優秀賞UpNextになりました。UpNextの全ソースはGitHubに登録されています。
以下の要素が込められており、応用が効くと思います。
- GPS情報の取得、および取得前の権限設定
- 外部REST-APIからの情報取得
- Future型、Stream型の非同期処理
- 画面描画
参考にしてください。
#1. 完成画面
現在位置の1km以内の鉄道駅と距離をリスト表示します。がんばれば、距離でのソートや路線表示も可能ですが、今回は試作なのでそこまでは対応していません。
東京公共交通オープンデータチャレンジのAPI利用ガイドラインで規定されている利用条件をタブで表示してみました。
#2. 開発環境および各種設定
以下の環境を準備します。Flutterのインストールに関しては、さまざまな記事がありますので、それらを参考にしてください。
- Android Studio 3.4
- Flutter 1.2.1
- Kotlin 1.3.31 (Android Studio上でアップグレード)
Android Studio上で、New Flutter Project -> Flutter Application を選択して、プロジェクトを作成します。その際、KotlinとSwiftにはチェックを入れておきます(あとあとネイティブコードを編集しない限り意味無いかもですが)。
できあがったプロジェクトの各種設定ファイルを修正します。
以下は利用する外部パッケージの宣言です。
dependencies: // 追記分
location: ^2.3.5
http: ^0.12.0+2
latlong: ^0.6.1
以下の3つは、locationパッケージを利用するために必須です。これを怠ると、Androidでのコンパイルができません。
buildscript {
ext.kotlin_version = '1.3.31' // 修正
dependencies {
classpath 'com.android.tools.build:gradle:3.4.0' // 修正
classpath 'com.google.gms:google-services:4.2.0' // 追記
}
}
android.enableJetifier=true // 追記
android.useAndroidX=true // 追記
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip // 修正
以下は、アプリの権限取得のために必要です。特にAndroidのINTERNET権限は、書き忘れるとリリースモードでのアプリ実行に失敗しますので、注意してください。作成されたプロジェクトにおいて、他のフォルダのAndroidManifest.xmlにはINTERNET権限が記載されているため、デバッグモードでは実行に成功し、気が付きにくいです。プロジェクト作成機能のバグかと思われます。
<manifest ...略 >
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> // 追記
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> // 追記
<uses-permission android:name="android.permission.INTERNET" /> // 追記
<dict>
<key>NSLocationWhenInUseUsageDescription</key> // 追記
<string>This app needs access to location when open.</string> // 追記
#3. 全ソース
メインソースです。なお、なぜかiOSでビルドができなくなりますが、当面はAndroidのみをターゲットとします。 (2019/5/11追記) Flutter 1.5.4-hotfix.2にアップグレードすることでiOSのビルドもできるようになりました。Flutter 1.2.1で作ったプロジェクトは、1.5.4にアップグレードしただけでは修正されず、プロジェクトホームディレクトリにて、コマンドラインで一度flutter cleanを実施することで、修正されます。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:location/location.dart';
import 'package:latlong/latlong.dart';
import 'config.dart';
import 'odpt.dart';
const String app_title ='近くの駅';
const String term_of_use =
'本アプリケーション等が利用する公共交通データは、'
'東京公共交通オープンデータチャレンジにおいて提供されるものです。'
'公共交通事業者により提供されたデータを元にしていますが、'
'必ずしも正確・完全なものとは限りません。本アプリケーションの表示内容について、'
'公共交通事業者への直接の問合せは行わないでください。'
'本アプリケーションに関するお問い合わせは、以下のメールアドレスにお願いします。'
'\n\n$contact_email';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: app_title,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: app_title),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
List<String> _currentListView;
@override
void initState() {
super.initState();
initPlatformState();
}
initPlatformState() async {
// Setup location // 解説1
final Location location = Location();
await location.changeSettings(
accuracy: LocationAccuracy.HIGH, interval: 5000);
try {
bool _serviceStatus = await location.serviceEnabled();
print("Service status: $_serviceStatus");
if (!_serviceStatus) {
bool serviceStatusResult = await location.requestService();
print("Service status activated after request: $serviceStatusResult");
if (serviceStatusResult) {
initPlatformState();
}
return;
}
bool permission = await location.requestPermission();
print("Permission: $permission");
if (!permission) return;
} on PlatformException catch (e) {
print(e);
return;
}
// Handling location stream
await for(final LocationData currentLocation
in location.onLocationChanged()) { // 解説2
print('Handling location stream at:${DateTime.now()}');
// Setup String List for ListView
List<String> listString = [];
final Odpt odpt = Odpt();
List<OdptStation> listStation =
await odpt.placesStation(currentLocation); // 解説3
if (listStation == null) {
print('There is no or an error response from DDPT-API.');
listString.add('オープンデータAPIにアクセスできないか、エラーが返却されました');
} else if (listStation.isEmpty) {
print('There is no station around here.');
listString.add('近くに駅はみつかりませんでした');
} else {
final Distance distance = Distance();
for(OdptStation element in listStation) {
listString.add(
'${element.dcTitle}: '
'${distance(LatLng(currentLocation.latitude,
currentLocation.longitude),
LatLng(element.geoLat, element.geoLong)).toString()}m'
);
}
}
setState(() { // 解説4
_currentListView = listString;
});
}
}
@override
Widget build(BuildContext context) => DefaultTabController( // 解説5
length: 2,
initialIndex: 0,
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(widget.title),
bottom: TabBar(
tabs: <Widget>[
Tab(text: 'ホーム', icon: Icon(Icons.home,),),
Tab(text: '利用条件', icon: Icon(Icons.info,),),
]
)
),
body: TabBarView(
children: <Widget>[
_currentListView == null
? CircularProgressIndicator()
: ListView.builder( // 解説6
itemCount: _currentListView.length,
itemBuilder: (context, int index) => Padding(
padding: EdgeInsets.all(8.0),
child: Text(_currentListView[index]),
)
),
Padding(
padding: EdgeInsets.all(16.0),
child: Text(term_of_use),
),
],
),
),
);
}
オープンデータAPIアクセス用のモジュールです。
import 'dart:convert';
import 'package:location/location.dart';
import 'package:http/http.dart' as http;
import 'config.dart';
const String base_uri ='https://api-tokyochallenge.odpt.org/api/v4/';
// rdf:type of odpt:Station
class OdptStation { // 解説7
final String context;
final String id;
final String type;
final String dcDate;
final String owlSameAs;
final String dcTitle;
final String odptOperator;
final String odptRailway;
final String odptStationCode;
final double geoLong;
final double geoLat;
OdptStation.fromJson(Map<String, dynamic> json)
: context = json['@context'],
id = json['@id'],
type = json['@tyoe'],
dcDate = json['dc:date'],
owlSameAs = json['owl:sameAs'],
dcTitle = json['dc:title'],
odptOperator = json['odpt:operator'],
odptRailway = json['odpt:railway'],
odptStationCode = json['odpt:stationCode'],
geoLong = json['geo:long'],
geoLat = json['geo:lat'];
}
class Odpt {
// places API with odpt:Station // 解説8
Future<List<OdptStation>> placesStation(
LocationData location, [int radius = 1000]) async {
if (location == null) return null;
final String requestUri =
'${base_uri}places/odpt:Station?lon='
'${location.longitude.toString()}&lat=${location.latitude.toString()}'
'&radius=${radius.toString()}&acl:consumerKey=$apikey_opendata';
print('Getting: $requestUri at:${DateTime.now()}');
http.Response response;
try {
response = await http.get(requestUri).timeout(Duration(seconds: 10));
} catch (e) {
print(e);
return null;
}
print('Got response at:${DateTime.now()}');
if (response.statusCode == 200) {
List<OdptStation> list = [];
List<dynamic> decoded = json.decode(response.body);
for (var item in decoded) {
list.add(OdptStation.fromJson(item));
}
print('Success to call places API.');
return list;
} else {
print('Fail to call places API.');
return null;
}
}
}
開発者個人に関する情報は、別ファイルにまとめておきます。実際に動作させる場合は、それぞれ本物の中身を入れてください。emailは、東京公共交通オープンデータチャレンジ専用の捨てアドレスを取得したほうが、良いかと思います。
// API_KEY for Open Data Challenge for Public Transportation in Tokyo
const String apikey_opendata = '<your api key>';
// contact email
const String contact_email = 'address@domain';
#4. ソース解説
ソースのコメントで「// 解説X」と記載した部分について、解説します。
##4.1 解説1
必要な権限設定などの、GPS利用の準備を行っています。
##4.2 解説2
GPS情報の更新を、非同期Stream処理として呼び出します。
locationパッケージのサンプルコードではlisten()が用いられていますが、非同期処理にはasync/awaitを使うことが推奨されているため、修正しています。await for構文の方が、何度も非同期に呼ばれる雰囲気がつたわって、可読性が高いと思います。
なお、awaitを使う場合は呼び出し元にasyncがついている必要があります。
##4.3 解説3
GPS情報をもとに、オープンデータAPIを呼び出して、近くの駅リストを取得している部分です。非同期Future処理になっています。こちらも、非同期処理にはthen()ではなく、async/awaitを使うことが推奨されています。
##4.4 解説4
StatefulWidgetの特徴である、状態変化を記述している部分です。setState()を呼び出すことで、Widgetの描画が更新されます。呼び出しにあたって、予め表示するための文字列リストをセットしておくことで、描画更新側のコードを単純にすることができます。
##4.5 解説5
FlutterでのUI作成において大きな特徴となっている、Widgetの入れ子構造を記述している部分です。
Widgetの最も外枠(今回はTab)を返り値にセットします。Tabは、TabBarと、TabBarViewで構成されます。さらに、TabBarViewの中に、子Widget、孫Widgetなどが、入れ子構造で記述されます。
##4.6 解説6
Tabの1つ目の中身は、ListViewです。表示内容である近くの駅名と距離のリストは、解説4の時点で予め文字列リストとしてセットしてあります。
##4.7 解説7
オープンデータAPIの返り値であるJSONを、オブジェクトに変換するためのクラスです。「FlutterでAPIをコールしてデータを表示して見た」のコピペをもとに編集しています。
呼び出しパラメータの一部を、オプショナルかつデフォルト値ありにしています。
##4.8 解説8
オープンデータAPIの呼び出しを、非同期で行います。「FlutterでAPIをコールしてデータを表示して見た」のコピペをもとに編集しています。
APIが時々ダウンするそうです(笑)ので、タイムアウトを設定しています。
不要なグローバル関数の定義を避けるため、Odptクラスでラッピングしています。
#5. 試作した感想
Flutterそのものは、学習してしまえば生産性は高いように思います。スマホアプリ初開発なので、比較対象がありませんが。
偶然ながら、FutureとStreamを両方とも使い、かつそれぞれ、then構文、listen構文を、async/awitに書き直すことによって、Dartの非同期処理をまとめて学習できました。また、Effective Dartをざっと読んで、推奨されるコードスタイルにしたつもりです。
最も苦労したのは、開発環境と外部パッケージの依存関係です。こまごまとした設定ファイル修正をしつつ、エラーが出ない組み合わせを探し出す必要があります。実行環境が多種多様なフロントエンド開発の難しさを感じました。