2015年3月8日日曜日

Offlineでもgeolocationが使えるWebViewを使ったAndroidアプリを作る

前の記事で書いた通り、「AndroidのWebブラウザでは、電波のないところでの位置情報取得は基本的には不可」です。そこで、Webアプリを表示するWebViewだけを持つネイティブアプリを作り、HTML5の機能が動作する設定をすることでWebアプリ側を改修せずにAndroidでも使えるようになります。その作り方を紹介します。


1. Webアプリを読み込むAndroidアプリを作成

  • Android Studioでプロジェクトを作成する。
    • Blank Activityをベースにした場合、APIレベル14(Android4.0)以降で作るとActionBarActivityを継承するが、アクションバーは使用しな場合はActivityの継承に変更するのがオススメです。
  • ネットアクセスのpermissionを設定する。
    • AndroidManifest.xmlにpermissionを追加
  <uses-permission android:name="android.permission.INTERNET" />
  • 回転できないように縦方向で固定する。
    • AndroidManifest.xml内の要素activityの属性に"android:screenOrientation="portrait"を追加
<activitiy ...
    android:screenOrientation="portrait"  >
 ...
  • アプリ起動時にjavascriptを有効にしてWebアプリを読み込む。
    • WebViewを配置 (id = webView)
    • 起動時のActivityクラス(Javaファイル)のonCreateメソッドに以下を追加
    WebView myWebView;
    String urlTop = "http://www.example.com";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        setContentView(R.layout.activity_trek_log_view);
        myWebView = (WebView) findViewById(R.id.webView);
        WebSettings webSettings = myWebView.getSettings();
        //javascriptを有効にする
        webSettings.setJavaScriptEnabled(true);
        myWebView.setWebViewClient(new WebViewClient());
        myWebView.loadUrl(urlTop+"index.html");
    }
※参考:Androidのwebviewとか。

これでWebアプリをAndroidのネイティブアプリとして起動できます。
ただ、このままではHTML5の機能が使用できないため追加の実装を行います。

2.HTML5の機能を有効化する

  • HTML5のlocalStorageとapplication cacheを有効にする。
    • ActivityクラスのonCreateメソッドに以下を追加
        //localStorageが使用できるようにする
        webSettings.setDomStorageEnabled(true); 
        //application cacheを有効にする
        webSettings.setAppCacheEnabled(true); 
        //application cacheのキャッシュ先を指定
        webSettings.setAppCachePath("/appcache/"); 
        //引数は「キャッシュが存在する場合はそれを使用、ない場合はネットワーク経由で取得」
        webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);  
  • 位置情報取得を有効にする。
    • AndroidManifest.xmlにpermissionを追加
    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission
        android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    • ActivityクラスにInterface「LocationListener」を実装する
public class TrekLogView extends Activity
    implements LocationListener{
    • LocationListenerで実装が必要なメソッドをオーバーライドする
    @Override
    public void onLocationChanged(Location location) {}
    @Override
    public void onProviderDisabled(String provider) {}
    @Override
    public void onProviderEnabled(String provider) {}
    @Override
    public void onStatusChanged(
        String provider, int status, Bundle extras) {}
    • HTML5のgeolocation APIを有効化
        webSettings.setGeolocationEnabled(true);
        myWebView.setWebChromeClient(new WebChromeClient(){
          @Override
          public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
                callback.invoke(origin, true, false);
          }
        });
  • アプリ初回起動時(onCreate)に位置情報取得を起動
    LocationManager locationManager;
    String bestProvider;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        initLocationService();
   }
    //初回起動時に実行する
    protected void initLocationService() {
        locationManager = (LocationManager)getSystemService(LOCATION_SERVICE);
        Criteria criteria = new Criteria();
        criteria.setAccuracy(Criteria.ACCURACY_FINE);
        criteria.setPowerRequirement(Criteria.POWER_MEDIUM);
        bestProvider = locationManager.getBestProvider(criteria, true);
    }
  • アプリ復帰時(onResume)に位置情報取得を再開、停止時(onPause)に停止する
    @Override
    protected void onResume() {
        super.onResume();
        locationManager.requestLocationUpdates(bestProvider, 60000,1, this);
    }
    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
    }

これでapplication cache/localStorage/geolocation APIを使ったWebアプリをAndroidのネイティブアプリとして起動できます。

※参考:
  1. WebView で Web Storage / Web SQL Database を利用する設定
  2. AndroidのWebViewをできるだけ速く表示する(キャッシュ・先読み編)
  3. AndroidのWebView内でgeolocationを使う
  4. 条件にあう位置情報プロバイダから、位置情報を利用する簡単な例

3.WebViewを使ったWebアプリの使い勝手を改善

WebアプリをAndroidアプリにしたことで使い勝手が悪いところがあるため改善します。
  • Android端末の「戻るボタン」でWebアプリの「戻る」を割り当てる。
    • ActivityクラスのonKeyDownメソッドにオーバーライドする
        @Override
        public boolean onKeyDown(int keyCode, KeyEvent event) {
                if ((keyCode == KeyEvent.KEYCODE_BACK) &&  myWebView.canGoBack()) {
                myWebView.goBack();
                return true;
            }
            return super.onKeyDown(keyCode, event);
        }
    

  • 特定のURLを持つサイト(map.html)は外部アプリを起動(Intent)する。
    • Activityクラスに以下を追加、変更。
@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    myWebView.setWebViewClient(new MyWebViewClient());
    ...
}
...
private class MyWebViewClient extends WebViewClient {
   @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        boolean isMyLogPublicHost = Uri.parse(url).getHost().equals("www.example.com");
        boolean isMyTestHost = Uri.parse(url).getHost().equals("www.test.com");
        boolean isMyHost = isMyPublicHost || isMyTestHost;

        // 特定ホスト名、かつ、特定文字列を含むURLの場合はWebView内で画面遷移させる
        if (isMyHost && Uri.parse(url).getPath().indexOf("map.html") == -1) {
                return false;
        }
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
        startActivity(intent);
        return true;
    }
}

これで一通り使えるようになりました。
※参考: Building Web Apps in WebView

4.まとめ

最後に、今回作ったソースコードをまとめて載せておきます。
  • AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.appspot.yamanobo.myapplication" >
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission
        android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="portrait" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
  • MainAcitivity.java
package com.example.myapplication;

import android.content.Intent;
import android.location.Criteria;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.net.Uri;
import android.app.Activity;
import android.os.Bundle;
import android.view.KeyEvent;
import android.webkit.GeolocationPermissions;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;


public class MainActivity extends Activity implements LocationListener {

    WebView myWebView;
    String urlTop = "http://trek-log.appspot.com/";
    LocationManager locationManager;
    String bestProvider;

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        setContentView(R.layout.activity_main);
        myWebView = (WebView) findViewById(R.id.webView);
        WebSettings webSettings = myWebView.getSettings();
        //javascriptを有効にする
        webSettings.setJavaScriptEnabled(true);

        //localStorageが使用できるようにする
        webSettings.setDomStorageEnabled(true);
        //application cacheを有効にする
        webSettings.setAppCacheEnabled(true);
        //application cacheのキャッシュ先を指定
        webSettings.setAppCachePath("/appcache/");
        //引数は「キャッシュが存在する場合はそれを使用、ない場合はネットワーク経由で取得」
        webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);

        webSettings.setGeolocationEnabled(true);
        myWebView.setWebChromeClient(new WebChromeClient(){
            @Override
            public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) {
                callback.invoke(origin, true, false);
            }
        });

        myWebView.setWebViewClient(new MyWebViewClient());
        myWebView.loadUrl(urlTop+"index.html");

        initLocationService();
    }

    //初回起動時に実行する
    protected void initLocationService() {
        locationManager = (LocationManager)getSystemService(LOCATION_SERVICE);
        Criteria criteria = new Criteria();
        criteria.setAccuracy(Criteria.ACCURACY_FINE);
        criteria.setPowerRequirement(Criteria.POWER_MEDIUM);
        bestProvider = locationManager.getBestProvider(criteria, true);
    }

    @Override
    protected void onResume() {
        super.onResume();
        locationManager.requestLocationUpdates(bestProvider, 60000,1, this);
    }
    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);
    }

    @Override
    public void onLocationChanged(Location location) {}
    @Override
    public void onProviderDisabled(String provider) {}
    @Override
    public void onProviderEnabled(String provider) {}
    @Override
    public void onStatusChanged(
            String provider, int status, Bundle extras) {}

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if ((keyCode == KeyEvent.KEYCODE_BACK) &&  myWebView.canGoBack()) {
            myWebView.goBack();
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    private class MyWebViewClient extends WebViewClient {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            boolean isMyPublicHost = Uri.parse(url).getHost().equals("www.trek-log.com");
            boolean isMyTestHost = Uri.parse(url).getHost().equals("www.trek-log-dev.com");
            boolean isMyHost = isMyPublicHost || isMyTestHost;

            // 特定ホスト名、かつ、特定文字列を含むURLの場合はWebView内で画面遷移させる
            if (isMyHost && Uri.parse(url).getPath().indexOf("map.html") == -1) {
                return false;
            }
            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            startActivity(intent);
            return true;
        }
    }
}
  • 調査/開発用のAndroid端末としては ASUS ZenFone5 (Android 4.4.2)を使用しています。

5.残りの課題

まだ一部未解決のものがあります。
  • Offline時にはトップページのツイッターボタンは非表示としているが表示されてしまう。
  • 左上のロゴからトップに戻るとレイアウトが不完全になることがある。(初回使用時に発生しやすい。キャッシュが不完全の可能性がある)
また、application cacheの更新の検証はしていないので、引き続き確認したいと思います。

2015年3月7日土曜日

Offline時のWebアプリでの位置情報取得について

山登りが趣味なので、手軽に位置情報と一緒にメモを記録するアプリを作りました。
iOS/Androidの両方で使えるようにHTML5のアプリケーションキャッシュを使ってOfflineでも使えるようにしました。
で、せっかくなので二つのイベントに応募しました。
ただ、自分はiPhoneユーザーなのでiPhoneでの動作確認はできていたのですが、動作確認用にAndroid端末を購入して試したところ動作しない(位置情報がほとんどとれない)ことが判明。そこで調査を行いました。

結論から言うと、

「AndroidのWebブラウザでは、電波のないところでの位置情報取得は基本的には不可」

です。ただ、WebViewだけの簡単なAndroidアプリにすれば位置情報がとれることがわかりました。そこで技術メモとして公開します。

Offline位置情報取得の問題点

山では電波の通じないところがほとんどなので、このWebアプリは(Webアプリなのに)Offlineで使う前提になります。また位置情報を取得するためにGPSの情報を取得する必要があります。
なので、Webアプリのキモになる技術は、HTML5の
  • geolocation API
  • application cache
  • localStorage API
になります。それぞれの機能は特別ではなく、ググればたくさん情報が見つかるため、基本的な機能は実装できるのですが、いざ山で使ってみると位置情報が取れない、正しくない、などの事態が発生。geolocation APIが曲者でした。

iPhoneでの注意点

iPhoneでも少しクセがあったのでメモしておきます。
  • 「機内モード」ではGPS機能も停止してしまう
    → バッテリー消耗をきにする場合は「機内モード」ではなく「モバイルデータ通信」と「Wi-Fi」をOFFにすること(もちろん、「位置情報サービス」はON)
  • 位置情報はキャッシュされてしまう様子。そのため、正しい位置情報が記録されない
    → 位置情報取得を3回実行して、3回目の結果を採用する ※あくまで暫定対応なので改善の余地あり。
これらは原因がわかったのでまだよかったのですが、Androidではほとんど位置情報がとれませんでした。(たまにとれることはありました)

Androidでの調査結果

そこでAndroidについて色々調べてみたところ、Stack Overflowでも同じような質問が出ていました。
他にも「html5 offline geolocation」でググると色々出てきますが解決策は特に見当たりませんでした。(ほとんど英語なので見落としてたかもしれませんが…)
なので、自分で端末を実際にいじって調査しました。結果は以下の通りです。
  1. Google Mapを起動するとステータスバーにGPSマークが出て、アプリを閉じるとマークが消える。
  2. Webブラウザでgeolocation APIを実行しても、ステータスバーにGPSマークが出ない。
    ※Androidの位置情報モードを「端末のみ(GPSで現在地を特定する)」にしていても同様。
  3. 位置情報を使用するネイティブアプリ(以下、サンプルアプリ)を作成して実行したところ、Google Mapと同じようにGPSマークが表示される。 ※参考:条件にあう位置情報プロバイダから、位置情報を利用する簡単な例
  4. サンプルアプリで、アプリがバックグラウンドに行った時にも位置情報取得を継続するようにしておく(つまり、OnPause()時に何もしない)と、このアプリを閉じてもステータスバーのGPSマークは残ったままになる。この状態でWebブラウザでgeolocation APIを実行すると位置情報がとれる!
  5. Androidの「設定>位置情報」に表示される「最近の位置情報リクエスト」では、ブラウザ(Chrome)は「低い電池使用量」だが、Google Mapやサンプルアプリは「高い電池使用量」である。
※調査用のAndroid端末としては ASUS ZenFone5 (Android 4.4.2)を使用しています。

これから、

「AndroidのWebブラウザでは、電波のないところでの位置情報取得は基本的には不可」

と結論づけました。しかし、4.の結果からWebViewだけの簡単なAndroidアプリを作ったところ位置情報を取ることができました。

ただ、WebViewでHTML5の機能を使ったり、ネイティブアプリとして操作できるようにするには少し工夫が必要だったため、それはまた次回紹介します。

深層学習を使ってツイッター画像のラベル付けができるまで(3)

深層学習のラベル精度向上のためにやったこと  前の記事 で、無事マルチラベルのラベル付けができる学習モデルが作成できるようになりました。ただ、その精度は納得できるレベルではなかったので、その改善のために「やったこと」と「起きたこと」を説明します。 やったこと1: 教師...