Superdry Memorandom :-p

旧「superdry memorandum :-D」です

Android Developer Blog の Deep Dive into Location をてきとう翻訳してみたよ。

位置情報系のアプリは多いものの、位置情報取得のベストプラクティスが今まであまりなかったですが、Android Developer BlogDeep Dive into Locationと題した記事が出ていました。個人的に超待望の記事だったので、てきとう翻訳してみました。

いつものとおりてきとうなので、変な訳がありましたらツッコミをお待ちしています。今回の記事の著者はGoogle I/Oでも何回か登壇してたイギリス紳士Reto Meierさんですが、原文もキャラがでてる文章なので、できれば原文やGoogle I/Oyoutubeなどもあわせて見てみると楽しいと思います。ちなみにものすごく訳しにくかったです(笑)。

位置情報ベースのアプリは個人的に大好きですが、その性質上、遅延時間がどうしても目につきます。

例えば食べるとこを見つけたり一番近いBoris Bike*1を探したりなど、GPSが位置を確定するのを待ってるうちにその遅さに気づきはじめ、検索結果を取得できる頃にはその遅さにウンザリしきっています。しかも、Venueにいて、Tipsをゲットしたりチェックインしたり食事のレビューを書いたりする準備ができてる場合でも、データの欠落によって頻繁に邪魔されます。

そんなとき空にげんこつを突き上げて振り回したりするのもいいけれど、私はオープンソースな参考用のアプリを書きました。このアプリには、アプリを開いて近くのVenueの最新一覧を表示するまでの時間を短縮するために、私の知る限りのノウハウを組み込んでます。また全ての場合においてバッテリー残量への影響を最小限にするのと同時に、ほどよいオフライン対応もしています。

まずはコードを見る

Android Protips for LocationGoogle Codeからチェックアウトしてください。うまくコンパイルして実行するにはReadme.txtを読むのを忘れずに。

実際に何をしているか

アプリのコア機能にGoogle Places APIを使ってます。この機能は近くの興味のある場所リストを提供し、その詳細を見たり、チェックイン/評価/レビューができたりします。

このコードは多くのベストプラクティスを実装してます。Google I/O 2011の私のセッション「Android Protips: Advanced Topics for Expert Android Developersvideo)」で紹介しています。位置情報更新を受け取るIntentの使い方やPassive Location Providerの使い方、リフレッシュレートを変えるためのデバイス状態のモニタリングと使用方法、ランタイム上でマニフェストのReceiversの切り替え方法、Cursor Loaderの使用方法について、その中で述べています。

このアプリはHoneycombが対象ですが、1.6以上でもサポートしています。

このコードからコピペして位置情報ベースのアプリをよりよくしていただく以上に幸せなことはありません。できるならそのことについて話してくれるとうれしいです。

コードを取得したら、より詳しく見ていこう

このコードでは情報の「鮮度」を優先しました。バッテリー寿命へのアプリの影響を最小限にしたまま、アプリの起動の遅延を短縮して希望の位置へチェックインできるようにしています。

要件は以下のとおり。

  • 現在位置をできるかぎり素早く取得すること
  • 位置情報が変更になったらVenues一覧を更新すること
  • 近くの位置情報一覧とその詳細をオフラインの場合でも参照できること
  • オフラインでもチェックインできること
  • 位置データと他ユーザのデータが適切に扱えること(ベストプラクティスについてのブログを参照)

「鮮度」とは待たせないこと

アプリが再開する際にLocationManagerから最後に取得した位置情報を使うことで、最初の位置を確定させる際の遅延をかなり軽減できます。

このスニペットGingerbreadLastLocationFinderから引用しました。ここでは、最後に取得した位置情報をもとに、デバイス上の複数のLocation Provider(その時点で利用不可能なものも含む)からより正確でより最新の位置情報を検索しています。

List<String> matchingProviders = locationManager.getAllProviders();
for (String provider: matchingProviders) {
  Location location = locationManager.getLastKnownLocation(provider);
  if (location != null) {
    float accuracy = location.getAccuracy();
    long time = location.getTime();
        
    if ((time > minTime && accuracy < bestAccuracy)) {
      bestResult = location;
      bestAccuracy = accuracy;
      bestTime = time;
    }
    else if (time < minTime && 
             bestAccuracy == Float.MAX_VALUE && time > bestTime){
      bestResult = location;
      bestTime = time;
    }
  }
}

もし遅延が許容範囲内となる位置情報が1つ以上あれば、そのうち最も正確なものを1つ返します。もしなければ、単純に最新の結果を返します。

後者の場合(最後に取得した位置情報が最新ではないとわかっている場合)、最新の結果を返されますが、最も早くて利用可能なLocation Provider を使って、単一の位置更新をリクエストします。

if (locationListener != null &&
   (bestTime < maxTime || bestAccuracy > maxDistance)) { 
  IntentFilter locIntentFilter = new IntentFilter(SINGLE_LOCATION_UPDATE_ACTION);
  context.registerReceiver(singleUpdateReceiver, locIntentFilter);      
  locationManager.requestSingleUpdate(criteria, singleUpatePI);
}

Location Providerを選択するためにCriteriaを使用する場合、残念ながら「最速」を指定できません。しかし、より精度の粗いプロバイダ(特にnetwork location provider)のほうが、より正確なプロバイダより速く結果を返す傾向があります。 今回は粗い精度と省電力を要求するので、利用可能な時はNetwork Providerを選択するようにします。

また、このコードスニペットで、1回限りの位置更新を受け取るためにrequestSingleUpdateメソッドを使用しているGingerbreadLastLocationFinderに注目してください。 これはGingerbread以前では使用できませんでした。LegacyLastLocationFinderをチェックアウトして、Gingerbread以前のデバイスのための同じ機能をどう実装したかを確認してください。

singleUpdateReceiverは登録されたLocation Listenerを通じて呼ばれたクラスへ受け取った更新情報を渡します。

protected BroadcastReceiver singleUpdateReceiver = new BroadcastReceiver() {
  @Override
  public void onReceive(Context context, Intent intent) {
    context.unregisterReceiver(singleUpdateReceiver);
      
    String key = LocationManager.KEY_LOCATION_CHANGED;
    Location location = (Location)intent.getExtras().get(key);
      
    if (locationListener != null && location != null)
      locationListener.onLocationChanged(location);
      
    locationManager.removeUpdates(singleUpatePI);
  }
};

位置情報更新を受け取るインテントを使う

現在地においてより正確かつタイムリーな位置情報を得たあとには、位置情報更新も受け取りたいとなるでしょう。

PlacesConstantsのクラスには位置情報の更新頻度を決める多くの値が含まれています。 これらを変更したりして、必要な頻度で更新を発生させるようにしてください。

// 場所のNearbyを検索する場合のデフォルトの検索範囲
public static int DEFAULT_RADIUS = 150;
// 位置情報更新するのに必要な移動距離(最大)
public static int MAX_DISTANCE = DEFAULT_RADIUS/2;
// 位置の更新情報を取得するのに経過すべき時間(最大)
public static long MAX_TIME = AlarmManager.INTERVAL_FIFTEEN_MINUTES;

次は、Location Managerから位置情報の更新を要求します。以下のスニペットGingerbreadLocationUpdateRequesterから抜粋したものです。以下のようにすると、どのLocation Providerへ更新要求するか決定するCriteriaを、直接requestLocationUpdatesに渡すことができます。

public void requestLocationUpdates(long minTime, long minDistance, 
  Criteria criteria, PendingIntent pendingIntent) {

  locationManager.requestLocationUpdates(minTime, minDistance, 
    criteria, pendingIntent);
}

Location ListenerでなくPending Intentで渡すことに注意してください。

Intent activeIntent = new Intent(this, LocationChangedReceiver.class);
locationListenerPendingIntent = 
  PendingIntent.getBroadcast(this, 0, activeIntent, PendingIntent.FLAG_UPDATE_CURRENT);

最良のプロバイダを選択するための利用不可プロバイダの監視

下記のPlacesActivityから抜粋したスニペットでは以下の2つの状態でどのように監視するか示します。

  • 利用できないLocation Provider
  • 利用可能になるLocation Provider

どちらの場合でも、利用可能なうち最良のプロバイダを決定するために使われるプロセスを、単純に再起動し位置情報更新を要求します。

// 使ってるプロバイダが無効になったときのためのレシーバの登録
IntentFilter intentFilter = new IntentFilter(PlacesConstants.ACTIVE_LOCATION_UPDATE_PROVIDER_DISABLED);
registerReceiver(locProviderDisabledReceiver, intentFilter);

// ベストなプロバイダが有効になるのを監視
String bestProvider = locationManager.getBestProvider(criteria, false);
String bestAvailableProvider = locationManager.getBestProvider(criteria, true);
if (bestProvider != null && !bestProvider.equals(bestAvailableProvider))
  locationManager.requestLocationUpdates(bestProvider, 0, 0, 
    bestInactiveLocationProviderListener, getMainLooper());

鮮度とは、いつも最新であることを意味します。 私たちが起動時の遅さをゼロまで減らすことができると、どうなるでしょうか?

アプリがバックグラウンドにいる場合、近くの位置情報の一覧を更新するためにPlacesUpdateServiceをバックグラウンドで開始できます。これを正しく実装すると、アプリを起動してすぐにVenuesの関連リストが利用可能にすることができます。

実装が不十分だと、バッテリー消費が早すぎると判断されるだけで、ユーザはこの存在に気づかないでしょう。

アプリが前面に来てない間に位置情報の更新要求する(特にGPSを使用する)のは、バッテリーの寿命にかなり影響を与えるので、ちょっとおそまつです。代わりに、Passive Location Provider が使えます。これは既に位置情報更新を要求した他のアプリといっしょに位置情報更新を受け取るのに使われます。

Froyo以上のプラットホームで受け身の更新を可能とするFroyoLocationUpdateRequesterから取り出せます。

public void requestPassiveLocationUpdates(long minTime, long minDistance, PendingIntent pendingIntent) {
  locationManager.requestLocationUpdates(LocationManager.PASSIVE_PROVIDER,
    PlacesConstants.MAX_TIME, PlacesConstants.MAX_DISTANCE, pendingIntent);    
}

結果として、バックグラウンドで受信する位置情報更新は効果が大きいです。残念ながらサーバダウンロードのバッテリーコストには効果が出ません。位置情報更新が受動的に実行される頻度とバッテリー残量とのバランスをどうとるか注意が必要となります。

Froyo以前のデバイスの場合の実装をLegacyLocationUpdateRequesterで示します。ココでは、alarmManagerのsetInexactRepeatingを利用することで同じような効果が得られます。

public void requestPassiveLocationUpdates(long minTime, long minDistance, 
  PendingIntent pendingIntent) {

  alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME,   
    System.currentTimeMillis()+PlacesConstants.MAX_TIME, 
    PlacesConstants.MAX_TIME, pendingIntent);    
}

Location Managerから更新情報を受けるのではなく、この手法は、位置情報更新の最大遅延時間によって決定される頻度において、最後に取得した位置情報を手動でチェックします。

この手法はレガシーでしかもあまり効果がないので、Froyo以前のデバイスではバックグラウンド更新を簡単に無効にするといった選択もあります。

現在の位置情報を決定するPassiveLocationChangedReceiverの範囲で更新を扱い、PlaceUpdateServiceを開始します。

if (location != null) {
  Intent updateServiceIntent = 
    new Intent(context, PlacesConstants.SUPPORTS_ECLAIR ? EclairPlacesUpdateService.class : PlacesUpdateService.class);
  
  updateServiceIntent.putExtra(PlacesConstants.EXTRA_KEY_LOCATION, location);
  updateServiceIntent.putExtra(PlacesConstants.EXTRA_KEY_RADIUS, 
    PlacesConstants.DEFAULT_RADIUS);
  updateServiceIntent.putExtra(PlacesConstants.EXTRA_KEY_FORCEREFRESH, false);
  context.startService(updateServiceIntent);   
}

アプリがアクティブでないときは位置情報更新をパッシヴに受信するIntentを使う

Passive Location Changed Receiverはmanifestに登録するのを忘れないようにします。

<receiver android:name=".receivers.PassiveLocationChangedReceiver"/>

結果的にこのバックグラウンド更新は、リソース解放のためにアプリがシステムにkillされるまで受信し続けることができます。

この方法だと、起動時の遅延を0にする利点だけでなくシステムがアプリに使用されてるリソースを回収することができる利点もあります。

もしアプリが「終了」の概念があるアプリなら(典型的な例ではアプリのホーム画面でBackボタンを押す場合など)、このパッシヴな位置情報更新は終了時に無効にすることが礼儀です。

オフラインでも動作すること

オフライン対応のために、すべての検索結果をPlacesContentProviderPlaceDetailsContentProviderにキャッシュすることで実現できます。

ある状況下で、位置の詳細情報をあらかじめとってこれます。 PlacesUpdateServiceスニペットを参照してください。どのように限られた位置のための事前取得を可能とするかについて示します。

バッテリー残量が少ない場合やモバイルデータネットワークで通信してる間は、事前取得は暗黙で無効にされることに注意してください。

if ((prefetchCount < PlacesConstants.PREFETCH_LIMIT) &&
    (!PlacesConstants.PREFETCH_ON_WIFI_ONLY || !mobileData) &&
    (!PlacesConstants.DISABLE_PREFETCH_ON_LOW_BATTERY || !lowBattery)) {
  prefetchCount++;
      
  // この場所の詳細情報を事前取得するためにPlaceDetailsUpdateServiceを開始する
}

オフラインチェックインの機能を提供するのに同様のテクニックを使用してます。 PlaceCheckinServiceのキューはチェックインに失敗します。そしてオフライン状態でチェックインしてみると、ConnectivityChangedReceiverがオンライン状態に戻すかどうか決定するタイミングで(順番に)リトライします。

バッテリー寿命の最適化:マニフェストのレシーバを切り替えるためにデバイス状態を使う

オンラインでないときに、更新サービスが実行する必要はないので、PlaceUpdateServiceは更新しようとする前に接続されてるかどうかチェックします。

NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
boolean isConnected = activeNetwork != null &&
                      activeNetwork.isConnectedOrConnecting();

もし接続されてない場合はPassive Location Changed ReceiversとActive Location Changed Receiversは無効になり、ConnectivityChangedReceiverはONになります。

ComponentName connectivityReceiver = 
  new ComponentName(this, ConnectivityChangedReceiver.class);
ComponentName locationReceiver = 
  new ComponentName(this, LocationChangedReceiver.class);
ComponentName passiveLocationReceiver = 
  new ComponentName(this, PassiveLocationChangedReceiver.class);

pm.setComponentEnabledSetting(connectivityReceiver,
  PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 
  PackageManager.DONT_KILL_APP);
            
pm.setComponentEnabledSetting(locationReceiver,
  PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 
  PackageManager.DONT_KILL_APP);
      
pm.setComponentEnabledSetting(passiveLocationReceiver,
  PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 
  PackageManager.DONT_KILL_APP);

ConnectivityChangedReceiverは接続状態の変化を受け取ります。 新しい接続が作られた場合は、それ自体を無効にして、Location Listnerを再び有効にします。

機能を減らして電池節約するためにバッテリー状態を監視する

端末の電池残量が残り15%を切ったら、ほとんどのアプリが残った電力を節約するためにバックシートに移ります。ローバッテリ状態になったりローバッテリ状態から回復するタイミングでアラートを受け取るレシーバを登録できます。

<receiver android:name=".receivers.PowerStateChangedReceiver">
  <intent-filter>
    <action android:name="android.intent.action.ACTION_BATTERY_LOW"/>
    <action android:name="android.intent.action.ACTION_BATTERY_OKAY"/>
  </intent-filter>
</receiver>

PowerStateChangedReceiverから引用したこのスニペットは、端末がローバッテリー状態に突入したタイミングでPassiveLocationChangedReceiverを無効にします。バッテリー残量が十分になると有効にします。

boolean batteryLow = intent.getAction().equals(Intent.ACTION_BATTERY_LOW);
 
pm.setComponentEnabledSetting(passiveLocationReceiver,
  batteryLow ? PackageManager.COMPONENT_ENABLED_STATE_DISABLED :
               PackageManager.COMPONENT_ENABLED_STATE_DEFAULT,  
  PackageManager.DONT_KILL_APP);

このロジックで、バッテリー残量が少ないときにはすべての事前取得を無効にしたり更新情報取得の頻度設定を減らしたりすることが可能です。

次はなに?

この記事はすでに大きくなりすぎてるので、続きはまた今度に。次週に私の個人的なブログ(The Radioactive Yak)でフォローアップします。そこではBackup Manager と Cursor Loaderを使ったこのアプリの超能力的でスムーズな要素をより詳細に説明したいと思います。

同様にニュースアプリのために参考となるアプリも作ろうと計画中です。自分の読書時間を増やし待ち時間が少なくするために。

意義ある時間を。Happy Coding!

*1:ロンドンのレンタル自転車の拠点