前回 は、OdometerというWebアプリの作成 を通して、Geolocation API、Notification APIの詳細を解説しました。今回は、Odometerに機能を追加する形でさらにいくつかのAPIの詳細を解説していきたいと思います。また、先日サンフランシスコで行われたGoogle I/Oで発表されたChrome Web Store関連のトピックも合わせて紹介したいと思います。
Google I/OのChrome Web Store関連トピック
アプリ内課金
Google I/Oにて、Chrome Web Storeでのアプリ内課金について発表されました。アプリ内課金の手数料は5%とのことで、ほかのプラットフォームと比べて非常に低価格になっています。これによって、Webアプリの中から電子書籍や音楽などのコンテンツを購入することができるようになります。
日本語化と各国言語へのローカライズ
Chrome Web Storeの日本語化がおこなわれ、日本語を含む41の言語にローカライズされました。これによって、Chromeの全ユーザーである1億6000万人がChrome Web Storeへアクセス可能になったとのことです。
Chromebook
Chrome OS搭載のノートブックであるChromebookが米国で6月に販売開始となるとのことです。Chrome OS上で動作するアプリは、基本的にはChrome Web StoreからWebアプリとしてダウンロードされるものになります。
Chrome Web Storeとそれを取り巻く環境はますます大きく広がっています。日本では、まだ課金の仕組みなどが提供されていないため正式なオープンはまだですが、今回の日本語化に合わせて既に日本向けのWebアプリが12個公開されています。興味のある方は是非チェックしてみてください。また、今後の日本での正式オープンに合わせて何かWebアプリを作ってみるのも面白いかもしれません。
図1 Chrome Web Store(日本語)
追加機能の概要
ここからは、前回作成したWebアプリの追加機能について考えていきます。Odometerは、地図上で目的地を設定し、目的地までの距離が近づくとデスクトップにポップアップを表示して残りの距離を通知するというものでした。しかしながら、このままではOdometerのタブを閉じるか、ブラウザを終了した場合には残りの距離が通知されなくなってしまいます。そのため、Webアプリの任意のページをバックグラウンドで動作させるBackground Pagesの仕組みを使って、いつでも通知がおこなわれるように変更してみます。
Webアプリの構成
Webアプリを構成するファイルにbackground.htmlとbackground.jsを追加します。これらのファイルには、Webアプリのうちバックグラウンドで動作させる処理を記述します。
manifest.json
{
"name" : "Odometer" ,
"description" : "距離計" ,
"version" : "0.2" ,
"app" : {
"launch" : {
"local_path" : "main.html"
}
},
"icons" : {
"16" : "icon_16.png" ,
"48" : "icon_48.png" ,
"128" : "icon_128.png"
},
"background_page" : "background.html" ,
"permissions" : [
"geolocation" ,
"notifications" ,
“background”
]
}
“background_page” にバックグラウンドで動作させるHTMLファイルを指定します。Webアプリをバックグラウンドで動作させるBackground Pagesの詳細については後述します。また、“ permissions” に“ background” を指定しています。これによって、ブラウザを閉じてもChromeを明示的に終了させなければ、バックグラウンドページが動作し続けることができます。
図2 バックグラウンド動作しているOdometer
Background Pages
Background Pagesは、Webアプリの任意のページをバックグラウンドで動作させるための仕組みです。タスクや状態管理など継続的に動作させたい処理や重たい計算処理、ファイルのアップロードやダウンロードなどの時間がかかる処理などを記述します。
バックグラウンドで動作させるHTMLファイルはマニフェストファイルで指定します。ここで指定したバックグラウンドページは、ひとつのWebアプリでひとつのインスタンスとなりますので、例えばWebアプリを複数のタブで開いていたとしてもバックグラウンドで動作するページはひとつです。
Hosted Appsの場合、Background Pagesの代替としてwindow.openメソッドを使って複数の任意のページをバックグラウンド動作させることのできるBackground Windowという仕組みを持っています。Background Window は、Chrome 12からwindow.openメソッドの利用のほかに“ background_page” での指定が可能になります。ただし、指定できるページはHTTPSで提供されている必要があります。Background Windowについての詳細は、こちら を参照してください。
バックグラウンド動作させる処理
バックグラウンドページはHTMLファイルですので、JavaScriptだけでなく、HTMLタグを使った柔軟な処理を記述することができます。例えば<audio>要素を使って音楽を再生したり、<canvas>要素で描画した画像データをWebアプリに送ったりといった内容が可能です。
今回のOdometerへの機能追加では、特にそういった処理はないので、純粋にJavaScriptファイルのみを読み込んでいます。残りの距離の通知をバックグラウンドページから行うためには、現在地と目的地などの情報をバックグラウンドページで参照できる必要があるため、フロント側のodometer.jsからGeolocation APIの処理と各種設定周りのコードを分離し、バックグラウンドページへ配置します。地図の描画などのユーザーインターフェース部分はそのままodometer.jsに残します。
変更前のodometer.js(一部抜粋)
if ( navigator . geolocation ) {
navigator . geolocation . getCurrentPosition (
init ,
onError ,
geoOptions
);
} else {
return ;
}
function init ( position ){
createMap ( position . coords . latitude , position . coords . longitude );
showCurrentPosition ( position );
currentPos . lat = position . coords . latitude ;
currentPos . lng = position . coords . longitude ;
}
変更後のodometer.js(一部抜粋)
function init ( position ){
createMap ( position . coords . latitude , position . coords . longitude );
showCurrentPosition ( position );
}
background.html
<!DOCTYPE html>
<html lang = "ja" >
<head>
<meta charset = "utf-8" >
<script src = "./js/background.js" ></script>
</head>
<body>
</body>
</html>
background.js(一部抜粋)
function getPosition ( sendResponse ){
navigator . geolocation . getCurrentPosition (
function ( position ){
currentPos . lat = position . coords . latitude ;
currentPos . lng = position . coords . longitude ;
sendResponse ({
type : "position" ,
position : position
});
},
function ( e ){
sendResponse ({
type : "error" ,
error : e
});
},
geoOptions
);
}
引数やコールバックに若干見慣れない記述がありますが、詳細は後述します。
バックグラウンドページとの通信
Webアプリをフロント側のページとバックグラウンド側のページに分けたことによって、各ページ間でデータのやり取りが必要になりました。バックグラウンドページをユーティリティやデータストアなど単純な用途に使うのであれば、もっとも簡単な方法は、各ページのWindowオブジェクトを取得し、グローバルな変数やメソッドにアクセスすることです。Window.chrome以下のメソッドにはChrome独自の便利なメソッドがいくつかありますが、その中でchrome.extension.getBackgroundPage()を利用してバックグラウンドページのWindowオブジェクトを取得することができます。
odometer.js(サンプルコード)
var bp = chrome . extension . getBackgroundPage ();
bp . getPosition ( function ( data ){
init ( data . position );
});
逆に、バックグラウンドページからWebアプリの各タブページのWindowオブジェクトを取得するには、chrome.extension.getViews()を利用します。
background.js(サンプルコード)
var views = chrome . extension . getViews ();
for ( var i = 0 , len = views . length ; i < len ; i ++ ) {
views [ i ]. init ( position );
}
ただし、この方法では直接グローバルな変数やメソッドを参照しているため結合度が強く、単純な用途であれば問題ありませんが、コールバックを多用するなどの複雑なアプリケーションではメンテナンスが困難になります。そのため、よりビューとロジックを分離させる方法として、各ぺージ間をメッセージでやり取りするMessage Passingという仕組みがあります。Odometerでは、この仕組みを利用して実装してみます。
表1 Windowオブジェクトの取得メソッド(chrome.extension )
メソッド 説明
getBackgroundPage() バックグラウンドページのWindowオブジェクトを取得する
getViews() WebアプリのタブページのWindowsオブジェクトをすべて取得する
Message Passing
Message Passingとは、Webアプリのタブページやバックグラウンドページなどのページ間で通信を行うための仕組みです。通信にはメッセージ機能を用いてデータなどのやり取りが行われます。メッセージは、タブページから送信することもバックグラウンドページから送信することもできます。また、継続してメッセージを送受信するためのチャンネルを設定することや、ほかのWebアプリからのメッセージを送受信することもできます。
Odometerでは、位置情報取得や自動更新のリクエストをバックグラウンドページへ送信し、位置情報や残りの距離など各種データを受け取って表示するように変更します。
メッセージの送受信
タブページからバックグラウンドページへの送受信
単純なメッセージの送受信を記述してみます。タブページ側からバックグラウンドページへ送信する場合、chrome.extension.sendRequest()メソッドを利用します。第1引数でリクエストデータをJSON形式で渡し、第2引数でレスポンスデータを受け取るコールバックを指定します。
odometer.js(バックグラウンドページへのメッセージ送信)
chrome . extension . sendRequest ({ action : 'get_position' }, function ( response ) {
if ( response . type ) {
if ( response . type == 'position' ) {
init ( response . position );
} else if ( response . type == 'error' ) {
onError ( response . error );
}
} else {
alert ( '予期せぬエラーです' );
}
});
Odometerでは、リクエストデータのフォーマットとしてactionというプロパティを作り、その内容によってバックグラウンドページでの処理を決定するように実装しています。ここでは、“ get_position” という処理でレスポンスデータのpositionプロパティに位置情報を含めています。
background.js(メッセージ受信)
chrome . extension . onRequest . addListener (
function ( request , sender , sendResponse ) {
switch ( request . action ) {
case 'get_position' :
getPosition ( sendResponse );
break ;
case 'set_destination' :
setDistination ( request . lat , request . lng , sendResponse );
break ;
case 'start_watch_position' :
startWatchPosition ( sender , sendResponse );
break ;
case 'stop_watch_position' :
stopWatchPosition ( sendResponse );
break ;
default :
sendResponse ({});
break ;
}
}
);
function getPosition ( sendResponse ){
navigator . geolocation . getCurrentPosition (
function ( position ){
currentPos . lat = position . coords . latitude ;
currentPos . lng = position . coords . longitude ;
sendResponse ({
type : "position" ,
position : position
});
},
function ( e ){
sendResponse ({
type : "error" ,
error : e
});
},
geoOptions
);
}
バックグラウンドページでメッセージを受信するには、chrome.extension.onRequestイベントを登録する必要があります。イベントのコールバックでは、第1引数にリクエストデータ、第2引数に送信オブジェクト、第3引数にレスポンスデータを送信するコールバックが渡されます。レスポンスデータを送信するコールバックは、メッセージの送受信を完了するために必ず実行する必要があります。レスポンスが何もない場合でも空のオブジェクトを返すようにしましょう。また、レスポンスデータは、JSON形式となっているため、ファンクションやDOMを返すことができないので注意してください。
バックグラウンドページからタブページへの送受信
逆にバックグラウンドページからタブページ側へメッセージを送信する場合には、chrome.tabs.sendRequest()を利用します。タブページ側は対象となるページが複数ある可能性があるため第1引数で送信先のタブIDを指定する必要があります。タブIDを指定しない場合は、現在選択中のタブへ送信されます。
background.js(タブページへのメッセージ送信)
chrome . tabs . sendRequest (
sender . tab . id ,
{
action : 'refresh' ,
position : position ,
distance : distance
},
function ( response ) {}
);
ここで指定しているsender.tab.idは、事前に自動更新の開始で受け取っている送信オブジェクトです。
odometer.js(メッセージの受信)
chrome . extension . onRequest . addListener (
function ( request , sender , sendResponse ) {
if ( request . action && request . action == 'refresh' ) {
updateMap ( request . position . coords . latitude , request . position . coords . longitude );
showCurrentPosition ( request . position );
showDistance ( request . distance );
}
sendResponse ({});
}
);
受け取り側のイベントは、バックグラウンドページでもタブページでも変わりません。自動更新された位置情報を画面に反映しています。
これで、Odometerのタブを閉じてもいつでも通知がおこなわれるようになりました。現状の実装では、Odometerを再表示すると通知がリセットされてしまいますが、次回以降で改善していきたいと思います。
表2 メッセージ送受信(chrome.extension)
メソッド/プロパティ 説明
sendRequest(request, responseCallback) バックグラウンドページへメッセージを送信する
onRequest メッセージの受信イベント。addListenerメソッドでコールバックを登録する
表3 メッセージ送信(chrome.tab)
メソッド/プロパティ 説明
sendRequest(tabId, request, responseCallback) タブページへメッセージを送信する
表4 送信者オブジェクト
メソッド/プロパティ 説明
id WebアプリのID
tab タブオブジェクト(idにタブIDが格納されている)
メッセージチャンネルの設定
Odometerでは実装していませんが、Message Passingでは継続してメッセージを送受信するためのチャンネルを設定することもできます。ここでは、チャンネルの設定方法について簡単に解説しておきたいと思います。チャンネルの設定には、chrome .extension.connect()メソッドやchrome.tab.connect()メソッドを利用します。メソッドの戻り値としてポートオブジェクトが返るので、ポートオブジェクトのpostMessage()メソッドでメッセージを送信し、onMessageイベントを登録してメッセージを受信します。
また、chrome.extension.onRequestExternalやchrome.extension.onConnectExternalなどのイベントを利用することでWebアプリ間の通信を行うこともできます。詳細は、こちら を参照してください。
タブページ
var port = chrome . extension . connect ({ name : "geolocation" });
port . postMessage ({ acton : "get_position" });
port . onMessage . addListener ( function ( message ) {
});
バックグラウンドページ
chrome . extension . onConnect . addListener ( function ( port ) {
port . onMessage . addListener ( function ( msg ) {
});
});
表5 チャンネル設定(chrome.extension)
メソッド/プロパティ 説明
connect (connectInfo) チャンネルを設定する
onConnect チャンネルの設定イベント。addListenerメソッドでコールバックを登録する
表6 メッセージ送信(chrome.tab)
メソッド/プロパティ 説明
connect(tabId, connectInfo) チャンネルを設定する
表7 ポートオブジェクト
メソッド/プロパティ 説明
name 名前
onDisconnect チャンネルの切断イベント
onMessage メッセージの受信イベント
postMessage(message) メッセージの送信
sender 送信者オブジェクト
まとめ
今回は、Odometerにバックグラウンド通知の機能を追加しつつBackground Pages、Message Passingの仕組みの詳細を解説しました。次回は、引き続きこのOdometerに機能を追加していく形で各種APIを解説していきたいと思います。
また、本連載で取り上げているChrome Web Store/Appsは、「 Chrome API Developers JP」という開発者コミュニティでも議論されていますので、興味のある方は是非覗いてみてください。
参考
odometer.js
document . addEventListener ( 'DOMContentLoaded' , function (){
chrome . extension . sendRequest ({ action : 'stop_watch_position' }, function ( response ) {});
chrome . extension . sendRequest ({ action : 'get_position' }, function ( response ) {
if ( response . type ) {
if ( response . type == 'position' ) {
init ( response . position );
} else if ( response . type == 'error' ) {
onError ( response . error );
}
} else {
alert ( '予期せぬエラーです' );
}
});
function init ( position ){
createMap ( position . coords . latitude , position . coords . longitude );
showCurrentPosition ( position );
}
function onError ( e ) {
alert ( e . message + '(' + e . code + ')' );
}
function showCurrentPosition ( position ){
document . getElementById ( 'latitude' ). textContent =
position . coords . latitude ;
document . getElementById ( 'longitude' ). textContent =
position . coords . longitude ;
document . getElementById ( 'accuracy' ). textContent =
position . coords . accuracy ;
document . getElementById ( 'heading' ). textContent =
position . coords . heading ;
document . getElementById ( 'speed' ). textContent =
position . coords . speed ;
var dt = new Date ( position . timestamp );
document . getElementById ( 'timestamp' ). textContent =
dt . getFullYear () + '年' + ( dt . getMonth ()+ 1 ) + '月' + dt . getDate () + '日' +
dt . getHours () + '時' + dt . getMinutes () + '分' + dt . getSeconds () + '秒' ;
}
function showDestinationPosition ( lat , lng ) {
document . getElementById ( 'dest-latitude' ). textContent = lat ;
document . getElementById ( 'dest-longitude' ). textContent = lng ;
}
function showDistance ( distance ){
document . getElementById ( 'distance' ). textContent = distance ;
}
document . getElementById ( 'auto-update' ). addEventListener ( 'click' , function (){
var autoUpdateButton = this ;
if ( autoUpdateButton . value == '自動更新開始' ) {
chrome . extension . sendRequest ({ action : 'start_watch_position' }, function ( response ){
if ( response . type && response . type == 'message' ) {
alert ( response . message );
} else {
autoUpdateButton . value = '自動更新停止' ;
}
});
} else {
chrome . extension . sendRequest ({ action : 'stop_watch_position' }, function ( response ){
autoUpdateButton . value = '自動更新開始' ;
});
}
});
chrome . extension . onRequest . addListener (
function ( request , sender , sendResponse ) {
if ( request . action && request . action == 'refresh' ) {
updateMap ( request . position . coords . latitude , request . position . coords . longitude );
showCurrentPosition ( request . position );
showDistance ( request . distance );
}
sendResponse ({});
}
);
var map ,
marker ;
var mapOptions = {
zoom : 13 ,
mapTypeId : google . maps . MapTypeId . ROADMAP
};
function createMap ( lat , lng ) {
var infowindow = new google . maps . InfoWindow (),
latLng = new google . maps . LatLng ( lat , lng );
map = new google . maps . Map ( document . getElementById ( "map" ), mapOptions );
marker = new google . maps . Marker (
{
title : '現在地' ,
position : latLng ,
map : map
}
);
var destMarker = null ;
google . maps . event . addListener ( map , "click" , function ( event ){
if ( destMarker ) {
destMarker . setMap ( null );
}
destMarker = new google . maps . Marker (
{
title : '目的地' ,
position : event . latLng ,
map : map
}
);
showDestinationPosition ( event . latLng . lat (), event . latLng . lng ());
chrome . extension . sendRequest (
{
action : 'set_destination' ,
lat : event . latLng . lat (),
lng : event . latLng . lng ()
},
function ( response ) {
if ( response . type && response . type == 'distance' ) {
showDistance ( response . distance );
} else {
alert ( '目的地の設定に失敗しました' );
}
}
);
});
map . setCenter ( latLng );
infowindow . open ( map );
}
function updateMap ( lat , lng ) {
var latLng = new google . maps . LatLng ( lat , lng );
if ( marker ) {
marker . setMap ( null );
}
marker = new google . maps . Marker (
{
title : '現在地' ,
position : latLng ,
map : map
}
);
map . setCenter ( latLng );
}
var geocoder = new google . maps . Geocoder ();
document . getElementById ( 'search' ). addEventListener ( 'submit' , function ( event ){
event . preventDefault ();
var addr = document . getElementById ( 'address' ). value ;
if ( ! addr ) {
return ;
}
geocoder . geocode ({ 'address' : addr }, function ( results , status ) {
if ( status == google . maps . GeocoderStatus . OK ) {
map . setCenter ( results [ 0 ]. geometry . location );
} else {
alert ( '検索できませんでした' );
}
});
}, false );
document . getElementById ( 'move-dest' ). addEventListener ( 'click' , function (){
map . setCenter ( new google . maps . LatLng ( destPos . lat , destPos . lng ));
}, false );
document . getElementById ( 'move-current' ). addEventListener ( 'click' , function (){
map . setCenter ( new google . maps . LatLng ( currentPos . lat , currentPos . lng ));
}, false );
}, false );
background.html
<!DOCTYPE html>
<html lang = "ja" >
<head>
<meta charset = "utf-8" >
<script src = "./js/background.js" ></script>
</head>
<body>
</body>
</html>
background.js
var currentPos = {
lat : 0 ,
lng : 0
}
var destPos = {
lat : 0 ,
lng : 0
}
var notified = {};
var geoOptions = {
enableHighAccuracy : true ,
timeout : 6000 ,
maximumAge : 0
}
chrome . extension . onRequest . addListener (
function ( request , sender , sendResponse ) {
switch ( request . action ) {
case 'get_position' :
getPosition ( sendResponse );
break ;
case 'set_destination' :
setDistination ( request . lat , request . lng , sendResponse );
break ;
case 'start_watch_position' :
startWatchPosition ( sender , sendResponse );
break ;
case 'stop_watch_position' :
stopWatchPosition ( sendResponse );
break ;
default :
sendResponse ({});
break ;
}
}
);
function getPosition ( sendResponse ){
navigator . geolocation . getCurrentPosition (
function ( position ){
currentPos . lat = position . coords . latitude ;
currentPos . lng = position . coords . longitude ;
sendResponse ({
type : "position" ,
position : position
});
},
function ( e ){
sendResponse ({
type : "error" ,
error : e
});
},
geoOptions
);
}
function setDistination ( lat , lng , sendResponse ){
destPos . lat = lat ;
destPos . lng = lng ;
notified = {};
sendResponse ({
type : "distance" ,
distance : getDistance ( currentPos . lat , currentPos . lng , destPos . lat , destPos . lng )
});
}
var watchId = 0 ;
function startWatchPosition ( sender , sendResponse ){
if ( watchId != 0 ) {
navigator . geolocation . clearWatch ( watchId );
watchId = 0 ;
}
if ( destPos . lat == 0 && destPos . lng == 0 ) {
sendResponse ({
type : "message" ,
message : "目的地を設定してください"
});
return ;
}
notified = {};
watchId = navigator . geolocation . watchPosition ( function ( position ){
currentPos . lat = position . coords . latitude ;
currentPos . lng = position . coords . longitude ;
var distance = getDistance ( currentPos . lat , currentPos . lng , destPos . lat , destPos . lng );
var threshold = 0 ;
if ( distance < 1 ) {
threshold = 0.2 ;
} else if ( distance < 10 ) {
threshold = 1 ;
} else {
threshold = 10 ;
}
var notifiedKey = Math . floor ( distance / threshold ) * threshold ;
if ( ! notified [ notifiedKey ] ) {
notify ( '目的地までの距離' , '約 ' + distance + ' km' );
notified [ notifiedKey ] = true ;
}
chrome . tabs . sendRequest (
sender . tab . id ,
{
action : 'refresh' ,
position : position ,
distance : distance
},
function ( response ) {}
);
}, null , geoOptions );
sendResponse ({});
}
function stopWatchPosition ( sendResponse ){
navigator . geolocation . clearWatch ( watchId );
watchId = 0 ;
sendResponse ({});
}
function getDistance ( lat , lng , dLat , dLng ){
var h = Math . abs ( dLat - lat ) * 111000 ;
var v = Math . abs ( dLng - lng ) * 91000 ;
return Math . round ( Math . sqrt ( Math . pow ( h , 2 ) + Math . pow ( v , 2 ))) / 1000 ;
}
function notify ( title , message ){
if ( webkitNotifications . checkPermission () == 0 ) {
var popup = webkitNotifications . createNotification ( 'icon_48.png' , title , message );
popup . ondisplay = function (){
setTimeout ( function (){
popup . cancel ();
}, 5000 );
};
popup . show ();
} else {
webkitNotifications . requestPermission ();
}
}