Cordovaによるハイブリッドアプリの開発(1)

この記事は1年以上前に書かれました。
内容が古くなっている可能性がありますのでご注意下さい。


ブラウザベースのWebアプリには、モバイルデバイスの持つ豊富な機能を活かしきれないという欠点があります。一方、デバイス固有の機能にアクセスできるネイティブアプリは、デバイスによってOSや開発言語が異なるため、マルチプラットフォームへの対応が困難です。
これらの問題を解決するために登場したのが、ハイブリッドアプリです。

通常、ハイブリッドアプリの開発には専用のフレームワークを使用します。この連載ではオープンソースのApache Cordovaを使用します。
Cordovaを使用すると、Webアプリ開発の標準技術であるHTML5、CSS3、JavaScriptを使用してアプリを開発することができます。さらに、Cordovaが提供するJavaScript APIを使用することによって、マルチプラットフォームに対応しながら、モバイルデバイス特有の機能を利用することができます。

開発環境の構築
いいことばかりのハイブリッドアプリですが、開発環境の構築は面倒です。今回は、Windows 7上でAndroidデバイス用の開発を行なうための環境を構築します。
ざっと、以下のものが必要になります。

  • JDK
  • ant
  • git
  • node.js
  • Cordova
  • plugman
  • Android SDK for Windows

JDKをインストールしたら、環境変数JAVA_HOMEを設定し、PATH環境変数に%JAVA_HOME%\binを追加します。
同様に、antをインストールしたら環境変数ANT_HOMEを設定し、PATH環境変数に%ANT_HOME%\binを追加します。
Cordovaのインストールにはnode.jsに付属するnpmコマンドを使用します。node.jsをインストールしてから、コマンドプロンプトで以下のコマンドを実行します。

C:\>npm install -g cordova

plugmanは、モバイルデバイス特有の機能へのアクセスを提供するCordovaプラグインを利用するために必要になります。
以下のコマンドでインストールします。

C:\>npm install -g plugman

Android SDK for Windowsをインストールしたら、SDKインストール先の\toolsと\platform-toolsをPATH環境変数に追加します。

開発するハイブリッドアプリの概要
今回は、現在位置の地図とコンパスを表示する簡単な地図アプリを開発します。
完成したアプリの画面は以下の通りです。

上部にはボタンが並びます。[Start]ボタンで現在位置と方位の定期的な取得を開始します。現在位置は10秒ごとに、方位は0.5秒ごとに取得し画面を更新します。[Refresh]ボタンは、直ちに現在位置を取得して画面を更新します。[-][+]ボタンで地図をズームします(もちろん、ピンチ操作でもズームできます)。
地図の左上には、コンパスを表示します。
画面の下部には、エラーメッセージを表示する領域があります。

Cordovaプロジェクトの作成と設定

Cドライブのcordovaフォルダの下にプロジェクトを作成することにします。はじめに、以下のコマンドを実行します。

c:\cordova>cordova create navi com.midoriit.navi SimpleNavi -d

c:\cordovaの下にnaviフォルダが作られ、プロジェクトファイル一式が用意されます。naviフォルダに移動し、

c:\cordova\navi>cordova platform add android

でAndroidをターゲットとした開発に必要な準備をします。
次に、デバイスのネイティブ機能にアクセスするためのプラグインをプロジェクトに導入します。

c:\cordova\navi>cordova plugin add org.apache.cordova.geolocation
c:\cordova\navi>cordova plugin add org.apache.cordova.device-orientation

geolocationプラグインは現在位置の取得に、device-orientationプラグインは方位の取得に使用します。
最新のAndroidをターゲットとする場合、geolocationプラグインを使用しなくてもHTML5のGeolocation APIを使用することで同等の結果が得られます。geolocationプラグインを利用しない場合は、AndroidManifest.xmlに

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

を追加します。

プログラムの開発
プロジェクトを作成した時点で、C:\cordova\navi\wwwにindex.htmlが用意されています。このファイルは使用せず、以下の内容のindex.htmlを作成します。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Simple Navi</title>
  <link rel="stylesheet" type="text/css" href="navi.css">
  <script type="text/javascript" src="OpenLayers.js"></script>
  <script type="text/javascript" src="Crosshairs.js"></script>
  <script type="text/javascript" src="jquery-2.1.1.min.js"></script>
  <script type="text/javascript" src="cordova.js"></script>
  <script type="text/javascript">

    var ready = false;
    var timer = 0;
    var watch;
    var map;
    var zoom = 17;
    var lonLat;

    $(function() {
      map = new OpenLayers.Map("mapdiv", { controls: [] });
      map.addLayer( new OpenLayers.Layer.OSM( ) );
      var cross = new OpenLayers.Control.Crosshairs( {
        imgUrl: "crosshairs.png",
        size: new OpenLayers.Size( 32, 32 ),
        position: new OpenLayers.Pixel(
          map.getCurrentSize().w / 2,
          map.getCurrentSize().h / 2 )
      } );
      map.addControl(cross); 
      map.addControl(new OpenLayers.Control.Navigation());
      map.addControl(new OpenLayers.Control.Attribution());
      map.events.register("moveend", map, moveend);

      document.addEventListener('deviceready', function() {
        ready = true;
        $("#btndiv").css("color", "black");
      } );
    } );

    function btnClick(btn) {
      if(ready) {
        switch (btn) {
          case 'toggle':
            toggle();
            break;
          case 'refresh':
            refresh();
            break;
          case 'plus':
            if(zoom < 19) {
              zoom++;
              map.setCenter(lonLat, zoom);
            }
            break;
          case 'minus':
            if(zoom > 1) {
              zoom--;
              map.setCenter(lonLat, zoom);
            }
            break;
        }
      }
    }

    function toggle() {
      if(!timer) {
        watch = navigator.compass.watchHeading(
          function (heading) {
            $("#compass")
              .css("transform", "rotate(-" + heading.magneticHeading + "deg)");
//          showMsg('watchHeading', heading.magneticHeading);
          },
          function (err) {
            showMsg('watchHeading', err.message);
          },
          {frequency: 500}
        );
        refresh();
        timer = setInterval('refresh()', 10000);
        toggleButton.innerHTML = 'Stop';
      } else {
        clearInterval(timer);
        navigator.compass.clearWatch(watch);
        timer = 0;
        toggleButton.innerHTML = 'Start';
      }
    }

    function refresh() {
      navigator.geolocation.getCurrentPosition(
        function(pos) {
          lonLat = new OpenLayers.LonLat(
            pos.coords.longitude,
            pos.coords.latitude ).transform(
              new OpenLayers.Projection("EPSG:4326"),
              map.getProjectionObject() );
          map.setCenter(lonLat, zoom);
//        showMsg('getCurrentPosition',
//          pos.coords.longitude + ', ' +  pos.coords.latitude);
        },
        function(err) {
          showMsg('getCurrentPosition', err.message);
        },
        {maximumAge: 10000, timeout: 5000, enableHighAccuracy: true}
      );
    }

    function moveend() {
      zoom = map.getZoom();
      lonLat = map.getCenter();
    }

    function showMsg(func, msg) {
      var d = new Date();
      statusdiv.innerHTML = 
        ('0'+d.getHours()).slice(-2) + ':' +
        ('0'+d.getMinutes()).slice(-2) + ':' +
        ('0'+d.getSeconds()).slice(-2) + ' ' +
        func + ' : ' + msg + '<br />' + statusdiv.innerHTML;
    }

  </script>
</head>
<body>
  <div id="btndiv">
    <a onClick="btnClick('toggle');" id="toggleButton">Start</a>
    <a onClick="btnClick('refresh');" id="refreshButton">Refresh</a>
    <a onClick="btnClick('minus');" id="zoomButton">-</a>
    <a onClick="btnClick('plus');" id="zoomButton">+</a>
  </div>
  <div id="mapdiv">
    <img id="compass" src="compass.png"></img>
  </div>
  <div id="statusdiv"></div>
</body>
</html>

はじめに、Cordova、jQueryと、地図を表示するためのOpenLayersのJavaScriptライブラリを読み込みます。

$(function() {
で始まる初期処理では、地図を表示する準備をし、devicereadyイベントでreadyフラグを操作します。devicereadyイベントが発生する前は方位を取得できないため、ボタン操作を無効にするためです。

ボタンをタップしたときに呼ばれるbtnClick()関数では、readyフラグを判定してから処理に振り分けます。

toggle()関数は、[Startボタン](スタートしたあとは[Stop]ボタン)がタップされたときの処理をします。
タイマが起動していないとき(まだスタートしていないとき)は、navigator.compass.watchHeading()で、定期的な方位の取得を開始します。第一引数のコールバック関数では、方位取得が成功したときの処理として、CSSを使ってコンパスの画像を回転させます。
さらに、現在位置の定期的な取得と地図の更新のために、setInterval()でインターバルタイマを起動します。
タイマが起動しているとき(すでにスタートしているとき)は、clearInterval()でインターバルタイマを停止し、navigator.compass.clearWatch()で方位取得を停止します。
また、それぞれにおいてボタンのラベルをトグルさせています。

[Refresh]ボタンのタップ、またはインターバルタイマで呼ばれるrefresh()関数は、navigator.geolocation.getCurrentPosition()関数で現在位置を取得して地図を更新します。現在位置を取得できなかった場合には、画面の下部にshowMsg()関数でエラー表示します。

CSSファイルは以下の通りです。

html, body {
  padding: 0;
  margin: 0;
  height: 100%;
}

#compass {
  padding: 0;
  margin: 0;
  position: absolute;
  z-index: 10000;
}

#btndiv {
  padding: 0;
  margin: 0;
  height: 10%;
  width: 100%;
  display: table;
  color: gray;
}

#mapdiv {
  position: relative;
  padding: 0;
  margin: 0;
  height: 85%;
  width: 100%;
}

#statusdiv {
  padding: 0;
  margin: 0;
  height: 5%;
  width: 100%;
  overflow: scroll;
}

#btndiv a {
  display: inline-block;
  border: 1px solid #000;
  display:table-cell;
  vertical-align:middle;
  text-align:center;
  font-size: x-large;
}

#toggleButton {
  height: 100%;
  width: 40%;
}

#refreshButton {
  height: 100%;
  width: 40%;
}

#zoomButton {
  height: 100%;
  width: 10%;
}

ビルドとアプリ起動
プログラムが完成したら、

c:\cordova\navi>cordova build

でビルドをし、

c:\cordova\navi>cordova run android

でアプリを起動します。
先にビルドをしていなくても、runするとビルドも実行されます。
アプリの起動時にAndroidデバイスがUSBで接続されていれば、デバイスにアプリが転送され、デバイス上でアプリが起動します。
デバイスが接続されていないと、エミュレータでアプリが起動します。

ハイブリッドアプリの開発 123