[Android] 地理資訊在手機端的應用

一般包含地理資訊的資訊源有兩種,一種是不常變動的非即時資訊,例如店家行號的位置;另一種是經常變動的即時資訊,例如公車的即時定位資訊。

不常變動的非即時資訊,通常資料量龐大,一般會預先整理(例如資訊源的地理位置只有地址,沒有經緯度,就要事先將地址轉換成經緯度),然後儲存到後台可處理地理資訊的平台,例如MongoDB或Parse,利用後台計算出手機位置於定位點的距離遠近,再將結果顯示在手機上。這樣做的優點是,效能較好,使用後台的運算能力,手機較不耗電,缺點則是,若資訊源的資料有異動,需要重新整理資料匯入後端平台。我過去曾使用過HerokuOpenshift平台的MongoDB,搭配NodeJS處理地理資訊,也用過Parse.com。在效能上的心得是:Parse >> Openshift > Heroku,沒有再細究原因,個人猜測可能與機房位置有關。

經常變動的即時資訊,因為資料無時無刻在變動,無法預先處理和儲存,必須要在前台手機上處理。如果資訊源只提供地址,要先將其轉換成經緯度,再行計算手機與定位點間的距離。這樣做的缺點是,效能不佳,利用手機的運算能力來計算,手機較耗電。

本文將以新北市垃圾車即時資訊為例,分享手機上如何處理經常變動的即時地理資訊。

本文開始

建構資料結構儲存定位點(POI, Point of Interests)

使用ArrayList這種資料型態來處理多筆、需呈現在ListView上的資料。這裡先建立一個Items class。
欄位包括:
  • car:車號
  • time:時間
  • location:現在位置
  • latitude:緯度
  • longitude:經度
  • distance:與定位點的距離(單位公尺,排序用)
  • distanceText:與定位點的距離,顯示用
import java.text.DecimalFormat;

public class Item {
    private String car;
    private String time;
    private String location;
    private double latitude;
    private double longitude;
    private double distance;
    private String distanceText;

    public Item(String car, String time, String location) {
        this.car = car;
        this.time = time;
        this.location = location;
    }

    public String getCarNO() {
        return car;
    }

    public String getCarTime() {
        return time;
    }

    public String getCarLocation() {
        return location;
    }

    public double getLatitude() {
        return latitude;
    }

    public void setLatitude(double v) {
        latitude = v;
    }

    public double getLongitude() {
        return longitude;
    }

    public void setLongitude(double v) {
        longitude = v;
    }

    public void setDistance(double v) {
        DecimalFormat df = new DecimalFormat("#");
        distance = Double.parseDouble(df.format(v));
    }

    public double getDistance() {
        return distance;
    }

    public void setDistanceText(double v) {
        distanceText = distanceText(v);
    }

    public String getDistanceText() {
        if (distanceText == null) {
            distanceText = "無法定位";
        }
        return distanceText;
    }

    private String distanceText(double distance) {
        String result = "";

        if(distance < 1000 ) {
            result = String.valueOf((int) distance) + "公尺";
        }
        else {
            result = new DecimalFormat("#").format(distance / 1000) + "公里";
        }

        if (latitude==0) {
            result = "無法定位";
        }

        return result;
    }
}

建構ListView Adapter

建立一個ListView Adapter,以便將資料載入自訂格式的ListView Item。
import android.app.Activity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.List;

public class ListAdapter extends ArrayAdapter {

    private final Activity context;
    private final List<item> items;

    public ListAdapter(Activity context, ArrayList<item> items) {
        super(context, 0, items);
        this.context = context;
        this.items = items;
    }

    static class ViewHolder {
        public TextView timeView;
        public TextView locationrView;
        public TextView distanceView;
    }
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {

        ViewHolder holder;
        View rowView = convertView;

        if (rowView == null) {
            LayoutInflater inflater = context.getLayoutInflater();
            rowView = inflater.inflate(R.layout.listitem_realtime, null, true);
            holder = new ViewHolder();
            holder.timeView = (TextView) rowView.findViewById(R.id.tvCarTime);
            holder.locationrView = (TextView) rowView.findViewById(R.id.tvLocation);
            holder.distanceView = (TextView) rowView.findViewById(R.id.tvDistance);
            rowView.setTag(holder);
        } else {
            holder = (ViewHolder) rowView.getTag();
        }
        holder.timeView.setText(items.get(position).getCarTime());
        holder.locationrView.setText(items.get(position).getCarLocation());
        holder.distanceView.setText(items.get(position).getDistanceText());

        return rowView;
    }
}

取得資料

從資訊源取資料,這裡我們可以使用Volley (關於Volley的介紹,可參考這篇文章)。
RequestQueue queue = Volley.newRequestQueue(this);
String url = "http://data.ntpc.gov.tw/od/data/api/28AB4122-60E1-4065-98E5-ABCCB69AACA6?$format=json";

StringRequest stringRequest = new StringRequest(Request.Method.GET, url,
new Response.Listener <String> () {@Override
 public void onResponse(String response) {
  showData(response);

 }
}, new Response.ErrorListener() {@Override
 public void onErrorResponse(VolleyError error) {
  Log.d(TAG, error.toString());
 }

});

queue.add(stringRequest);

private void showData(String str) {
 JsonService jsonsrv = new JsonService(this, myLoc.getLatitude(), myLoc.getLongitude());
 ArrayList <Item > items = jsonsrv.fromJson(str);
 listAdapter = new RealtimeListAdapter(this, items);
 ListView listView = (ListView) findViewById(R.id.listRealtimeInfo);
 listView.setAdapter(listAdapter);

}
資料取得後,可利用Gson來解析JSON格式的資料,並將解析出來的資料,利用迴圈,一筆筆載入前面設定的ArrayList資料型態,以方便後續的處理與呈現。
import android.content.Context;
import android.location.Address;
import android.location.Geocoder;
import android.location.Location;
import android.util.Log;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;

public class JsonService {

    private Geocoder geocoder;
    private Context context;
    private double current_latitude;
    private double currnet_longitude;

    public JsonService(Context c, double current_lat, double current_lon) {
        this.context = c;
        this.current_latitude = current_lat;
        this.currnet_longitude = current_lon;
    }

    public ArrayList<realtimeitem> fromJson(String str) {

        JsonParser jsonParser = new JsonParser();
        JsonElement el = jsonParser.parse(str);
        ArrayList<item> items = new ArrayList();
        JsonArray jsonArray = null;
        if(el.isJsonArray()) {
            jsonArray = el.getAsJsonArray();
            Iterator it = jsonArray.iterator();


            while(it.hasNext()) {
                JsonObject o = (JsonObject) it.next();

                Item item = new Item(
                          o.get("lineid").getAsString()
                        , o.get("car").getAsString()
                        , o.get("time").getAsString()
                        , o.get("location").getAsString()
                );

                items.add(item);

                try {
                    geocoder = new Geocoder(context, new Locale("zh", "TW"));

                    String address = o.get("location").getAsString();

                    List<address>
 addressList  = geocoder.getFromLocationName(address,1);

                    item.setLatitude(addressList.get(0).getLatitude());
                    item.setLongitude(addressList.get(0).getLongitude());
                    item.setDistance(getDistanceMeter(item.getLatitude(), item.getLongitude(), current_latitude, currnet_longitude));
                    item.setDistanceText(item.getDistance());

                } catch (Exception e) {
                    Log.d(TAG, e.toString());
                }
            }
        }
        return items;
    }

    private double getDistanceMeter(double lat1, double lon1, double lat2, double lon2) {

        float[] results=new float[1];
        Location.distanceBetween(lat1, lon1, lat2, lon2, results);
        return results[0];

    }
}


地址轉換成經緯度

因為資訊源的定位點原始資料,只有提供地址,沒有經緯度,所以要做地址轉經緯度的動作。我們可以利用Android的Geocoder中的 getFromLocationName 來做轉換。
第一個參數是Activity Context,第二個參數是定義地址的語言,以便可以正確轉換,這裡的地址是中文,所以是 new Locale("zh", "TW")

要注意的是,轉換後的結果如果出現Exception,表示轉換失敗,有可能因為地址資料本身的問題(例如數字用的是全形字,或者有錯字),或是Google Map根本找不到這個地址,此時得再針對這些錯誤資料做例外處理和判斷。
Geocoder geocoder = new Geocoder(context, new Locale("zh", "TW"));

String address = o.get("location").getAsString();

List <address>
 addressList = geocoder.getFromLocationName(address, 1);

item.setLatitude(addressList.get(0).getLatitude());
item.setLongitude(addressList.get(0).getLongitude());

計算兩點之間的距離

有了定位點的經緯度,接下來要計算手機位置和定位點的距離,可以利用Location的 distanceBetween (或 distanceTo )方法取得距離。這裡得到的單位是公尺。要注意的是,distanceBetween得到的距離是直線距離,而不是經過路線規劃得出的實際距離,在手機顯示各定位點的距離,主要是讓用戶比較各定位點與用戶距離之遠近,是相對的概念,雖然GPS定位可將距離計算得非常精確,但精確度其實沒有必要,通常我們會把超過一公里的距離,以公里單位顯示,少於一公里的距離,以公尺單位顯示,只顯示整數,捨去小數位。
private double getDistanceMeter(double lat1, double lon1, double lat2, double lon2) {

 float[] results = new float[1];
 Location.distanceBetween(lat1, lon1, lat2, lon2, results);
 return results[0];

}

資料排序

Android ListView沒有排序功能,我們可以利用Java的 Collection.sort 對ArrayList排序,再將排序後的結果載入ListView。這裡用distanceText欄位作遞增排序。
Collections.sort(items, new Comparator <Item> () {@Override
    public int compare(Item o1,
    Item o2) {
        return Double.compare(o1.getDistance(), o2.getDistance()); // error
    }
});   

顯示

最後把ArrayList透過ListView Adapter,載入到ListView。
ArrayList <Item> items = jsonsrv.fromJson(str);
listAdapter = new ListAdapter(this, items);
ListView listView = (ListView) findViewById(R.id.listRealtimeInfo);
listView.setAdapter(listAdapter);

結論

地理資訊的處理有很多方法,事實上,Google有提供REST API來做地址與經緯度的換算,可是此法每天有查詢筆數上的限制;網路上也查詢的到,使用三角函數、圓周率和地球半徑計算兩點間距離的方法。但我覺得既然Android本身已提供計算方法,用原生的方法來計算是比較好的選擇。當然,也許市面上存在一些LBS第三方函式庫或工具(例如Spatialite,這是一個類似SQLite的空間向量資料庫),但這方面我的涉略不多,也許日後研究之後,再跟大家分享。


相關文章

如何將電腦畫面經由 Chromecast 投放到電視螢幕上

Chrome 的檔案續傳功能

隱私權政策產生器 Privacy Policy Generator

使用 Vysor 在電腦上控制 Android 裝置