Vedieť polohu používateľa zariadenia otvára mnohé možnosti. A nehovorím o bezpečnostných obmedzeniach a paranoji, ale o prípadoch, keď chcete zistiť najbližší bankomat / krčmu či zastávku dopravy. Ak vieme určiť polohu a máme k dispozícii geolokačné API, vieme poskytovať nielen takéto informácie, ale vieme ich aj obohatiť o zobrazenie mapy či priamu navigáciu.
V Androide vieme určiť polohu viacerými spôsobmi:
V appkách môžeme využiť tieto senzory dvoma spôsobmi. Buď využijeme nízkoúrovňové API v podobe balíčka android.location
alebo sa uchýlime ku komplexnejšej službe Google Location Services API. Toto druhé API je jednoduchšie, má vyššiu presnosť a je šetrnejšie k batérii, ale vyžaduje získanie kľúča k API a nie je k dispozícii na zariadeniach, ktoré nemajú prístup ku Googlu API (teda Amazon Fire a pod.)
Google Location Services API nie je k dispozícii na emulátore Genymotion. Dôvodom sú licencie, ktoré zabraňujú redistribúcii základných appiek od Googlu. Prirodzene, tieto appky možno do emulátora doinštalovať, ale nejde o oficiálne podporovaný postup.
V ukážkovej aplikácii budeme rátať vzdialenosť medzi aktuálnou pozíciou a mestom vybraným zo rozbaľovacieho zoznamu na lište akcií. Aktuálnu polohu získame z GPS senzora, na počítanie vzdialeností využijeme existujúce androiďácke API, a mestá budeme načítavať zo XML súboru.
Poďme na to!
Zisťovanie polohy má na starosti systémová služba LocationManager
. Jej úlohou je hlásiť aktuálnu polohu, ktorú budú prijímať poslucháči reprezentovaní interfejsom LocationListener
. Takýmto poslucháčom bude aktivita, ktorá vie polohu spracovať a na jej základe aktualizovať widgety, čo u nás znamená prepočítanie vzdialenosti a jej zobrazenie vo widgete TextView
.
Samotné údaje o polohe prichádzajú zo systému z viacerých zdrojov, tzv. od location provider: spomínali sme GPS, informácie z mobilnej siete a podobne. Poskytovatelia nie sú reprezentovaní triedami, ale reťazcovými identifikátormi (napr. GPS_PROVIDER
pre GPS), ktoré sa využívajú v API.
Správcu polohy získame z aktivity volaním getSystemService()
s konštantou LOCATION_SERVICE
.
private LocationManager locationManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
}
Poslucháčom zmeny polohy bude naša aktivita, ktorú necháme implementovať interfejs LocationListener
a jeho štyri metódy:
onLocationChanged()
sa volá v prípade, ak sa zmenila poloha zariadenia. Parametrom je objekt typu Location
, ktorý obsahuje zistené údaje o polohe, teda zemepisnú šírku, dĺžku, a prípadne ďalšie geografické parametre (výška, azimut a pod.)onStatusChanged()
, onProviderEnabled()
a onProviderDisabled()
súvisia so zmenou stavu poskytovateľov polohy (location provider). Volajú sa v situáciách, keď používateľ povolí, či zakáže GPS alebo ak napríklad vypadne signál. Tieto metódy nám zatiaľ netreba a preto ich necháme prázdne.Ak je aktivita poslucháčom, môžeme ju registrovať v správcovi polohy cez metódu requestLocationUpdates()
. Tá však potrebuje štyri parametre:
LocationListener
. Minimalistický kód, ktorý zistí polohu vyzerá nasledovne:
public class MainActivity extends Activity implements LocationListener {
@Override
public void onLocationChanged(Location location) {
// Poloha sa zmenila
}
@Override
protected void onResume() {
super.onResume();
locationManager.requestLocationUpdates(GPS_PROVIDER, 10 * 1000, 100 /* metrov */, this);
}
@Override
protected void onPause() {
locationManager.removeUpdates(this);
super.onPause();
}
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
// prázdna metóda
}
@Override
public void onProviderEnabled(String provider) {
// prázdna metóda
}
@Override
public void onProviderDisabled(String provider) {
// prázdna metóda
}
}
Na to, aby fungoval, potrebujeme ešte vyžiadať oprávnenie na získavanie polohy. Do manifestu teda dodajme
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Android poskytuje dva okruhy oprávnení: právo pre približnú polohu (coarse location), ktoré využíva WiFi a GSM, a presnejšie právo pre exaktnú polohu (fine location). Naša aplikácia bude využívať GPS na čo je potrebné právo ACCESS_FINE_LOCATION
, ktoré v sebe obnáša i približnú plohu.
Namiesto konštanty môžeme poskytovateľa polohy vyhľadať v dostupných poskytovateľoch. Slúži na to objekt kritérií Criteria
, ktorý reprezentuje dopyt na dostupných poskytovateľov. Správca polohy nám potom vie poskytnúť najlepšieho poskytovateľa polohy, ktorý vyhovuje kritériám.
V ukážke žiadame poskytovateľa polohy s vysokou presnosťou, ktorého následne zaregistrujeme v správcovi polohy:
@Override
protected void onResume() {
super.onResume();
requestLocationUpdates();
}
private void requestLocationUpdates() {
Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_FINE);
locationProviderName = locationManager.getBestProvider(criteria, ONLY_ENABLED_LOCATION_PROVIDERS);
locationManager.requestLocationUpdates(locationProviderName, TEN_SECONDS, ONE_HUNDRED_METERS, this);
}
Konštanty definované v kóde sú nasledovné:
private static final long TEN_SECONDS = 10 * 1000;
private static final float ONE_HUNDRED_METERS = 100f;
private static final boolean ONLY_ENABLED_LOCATION_PROVIDERS = true;
Rovnaký prepočet môžeme vyvolať vo chvíli, keď sa povolí niektorý z poskytovateľov a zavolá sa callbacková metóda onProviderEnabled()
:
public void onProviderEnabled(String provider) {
requestLocationUpdates();
}
Ak sme zistili polohu, vieme ju použiť na prepočítanie vzdialenosti. Vytvoríme pevnú lokáciu pomocou konštruktora, ktorý síce vyžaduje názov providera, ale pre testovacie účely nebude protestovať proti prázdnemu názvu:
public static final String NO_PROVIDER = null;
Následne nastavíme na lokácii geografické koordináty (cez setLatitude()
a setLongitude()
), a už môžeme prepočítavať vzdialenosť cez metódu distanceTo()
, ktorá vráti výsledok v metroch. Samozrejme, pre používateľské rozhranie bude lepšie akési zaokrúhlenie, čo vieme dosiahnuť pomocou univerzálneho formátovacieho objektu DecimalFormat
. V ňom definujeme formátovací predpis reprezentujúci jedno číslo pred desatinnou čiarkou a jednu desatinnú cifru, za ktorú dolepíme jednotku kilometrov.
@Override
public void onLocationChanged(Location location) {
Location kosiceLocation = new Location(NO_PROVIDER);
kosiceLocation.setLatitude(48.697265);
kosiceLocation.setLongitude(21.2644253429128);
float distanceInMeters = kosiceLocation.distanceTo(location);
DecimalFormat distanceFormatter = new DecimalFormat("#.# km");
this.distanceTextView.setText(distanceFormatter.format(distanceInMeters / 1000));
}
Čo je však distanceViewText
? To je textové políčko, ktoré musíme dodefinovať v layoute aktivity main_activity.xml
:
<TextView
android:id="@+id/distanceTextView"
android:textSize="48sp"
android:hint="No distance"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textColor="@android:color/holo_blue_dark"
/>
V metóde onCreate()
vytiahneme z layoutu objekt do inštančnej premennej a môžeme aktualizovať vzdialenosť!\
private TextView distanceTextView;
@Override
protected void onCreate(Bundle savedInstanceState) {
...
this.distanceTextView = (TextView) findViewById(R.id.distanceTextView);
}
Vďaka geokódovaniu možno v Androide prevádzať adresu na geografickú polohu a pomocou reverzného geokódovania možno zase previesť geografické súradnice na vhodné zodpovedajúce objekty v okolí.
Na geokódovanie slúži trieda Geocoder
, ktorá využíva API spoločnosti Google.
Pri práci s geokóderom treba dať pozor na dve prekvapenia:
Trieda je súčasťou API Androidu, ale jej metódy nemusia byť implementované na zariadeniach, ktoré nemajú nainštalované štandardnú sadu aplikácií Google. Overiť funkčnosť geokódera možno pomocou jeho statickej metódy isPresent()
.
Volanie metód je dlhotrvajúca operácia, teda ju treba vykonávať v AsyncTask
u alebo v službe.
Vytvorme si teda asynchrónnu úlohu, ktorá zoberie do parametra polohu Location
a vráti výsledok v podobe adresy Address
. Znamená to, že triedu deklarujeme ako potomka triedy AsyncTask<Location, Void, Address>
.
Trieda bude potrebovať dva parametre:
Geocoder
om a zároveň bude obsahovať TextView
pre výsledokTextView
, do ktorého sa zapíše výsledok. Keďže identifikátory sú reprezentované celými číslami int
, ktoré môžu značiť kadečo, anotáciou @IdRes
naznačíme vývojárom a dokumentácii, že parameter má obsahovať referenciu na identifikátor komponentu a nie napríklad farbu.V predošlých prípadoch sme definovali AsyncTask
y ako vnútorné triedy aktivity. Spomínali sme však, že AsyncTask
má vďaka tomu v sebe internú referenciu na vonkajšiu aktivitu, čo znamená, že 1) môže po dobehnutí aktualizovať už neplatnú inštanciu 2) môže dôjsť k memory leaku, kde garbage collector nedokáže upratať inštanciu aktivity (vrátane všetkých jej widgetov), pokiaľ na ňu odkazuje bežiaci async task.
Riešenie spočíva vo využití špeciálnej "mäkkej" referencie medzi objektami.
Ak je medzi dvoma objektami vzťah deklarovaný pomocou slabej referencie, garbage collector môže referencovaný objekt pokojne upratať a tým rozpojiť vzťah medzi nimi. Po upratovaní bude objekt na konci referencie null
ový.
Takýto druh referencie je reprezentovaný triedou WeakReference
.
V konštruktore teda okamžite prevedieme aktivitu na slabú referenciu.
public class CurrentLocationAsyncTask extends AsyncTask<Location, Void, Address> {
private final WeakReference<Activity> activityReference;
private int targetTextViewId;
public CurrentLocationAsyncTask(Activity activity, @IdRes int targetTextViewId) {
this.targetTextViewId = targetTextViewId;
this.activityReference = new WeakReference<Activity>(activity);
}
@Override
protected Address doInBackground(Location... locations) {
...
}
@Override
protected void onPostExecute(Address currentAddress) {
...
}
}
Metóda na pozadí najprv zistí, či je vôbec geokóder dostupný.
if(!Geocoder.isPresent()) {
return NO_ADDRESS;
}
Ak nie je, vráti null
ovú adresu, reprezentovanú našou konštantou
public static final Address NO_ADDRESS = null;
Následne sa pozrie, či parameter metódy reprezentujúci pole polôh obsahuje presne jednu lokáciu. Ak ich má primálo alebo priveľa, považujeme to za chybový stav, ktorý zalogujeme.
V metóde doInBackground()
nemôžeme hádzať výnimky. Odporúčané riešenie je vracať akýsi univerzálny úspechovo-chybový objekt, kde sa v metóde onPostExecute()
rozhodneme, či operácia prešla alebo zlyhala. Alternatívne riešenie spočíva v uložení výnimky do inštančnej premennej AsyncTasku
, ktorú potom prečítame v metóde onPostExecute()
. (Toto riešenie, ktoré využíva koordináciu vlákien, má oporu v dokumentácii triedy AsyncTask
, v sekcii Memory Observability
Inštanciu geokódera vytvoríme na základe kontextu reprezentovaného aktivitou, ktorý vytiahneme z mäkkej referencie pomocou metódy get()
. Samozrejme, musíme sa uistiť, že objekt na konci referencie nebol medzičasom uprataný a nie je null
.
Context context = activityReference.get();
if(context == null) {
return NO_ADDRESS;
}
Geokóder potom vytvoríme a ihneď získame prvú (1) najlepšiu adresu vystihujúcu danú lokalitu, ktorú vrátime
Geocoder geocoder = new Geocoder(context);
List<Address> addresses = geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1);
Address currentAddress = addresses.get(0);
return currentAddress;
Celý kód (vrátane ošetrenia chýb) teda vyzerá nasledovne:
@Override
protected Address doInBackground(Location... locations) {
try {
if(!Geocoder.isPresent()) {
return NO_ADDRESS;
}
if(locations.length != 1) {
Log.e(TAG, "Illegal number of parameters, expecting 1, but found " + locations.length);
return NO_ADDRESS;
}
Location location = locations[0];
Context context = activityReference.get();
if(context == null) {
return NO_ADDRESS;
}
Geocoder geocoder = new Geocoder(context);
List<Address> addresses = geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1);
Address currentAddress = addresses.get(0);
return currentAddress;
} catch (IOException e) {
Log.e(TAG, "Geocoder failed for location", e);
}
return NO_ADDRESS;
}
Spracovaný výsledok v podobe adresy Address
potom zobrazíme v políčku TextView
, ale bude okolo toho veľa rečí a boilerplate kódu:
null
Kód vyzerá nasledovne:
@Override
protected void onPostExecute(Address currentAddress) {
Activity activity = activityReference.get();
if (activity == null) {
return;
}
View targetView = activity.findViewById(this.targetTextViewId);
if (targetView == null) {
return;
}
if (!(targetView instanceof TextView)) {
throw new ClassCastException("Target view must be TextView, but is " + targetView.getClass());
}
TextView targetTextView = (TextView) targetView;
targetTextView.setText("Now at " + currentAddress.getLocality());
}
Ak je trieda AsyncTasku
hotová, jednoducho ju zavoláme vždy, keď sa zmení poloha používateľa:
@Override
public void onLocationChanged(Location location) {
...
updateCurrentLocation(location);
}
private void updateCurrentLocation(Location location) {
new CurrentLocationAsyncTask(this, R.id.currentLocationTextView).execute(location);
}
Vytvoríme inštanciu aktivity a zavoláme na nej metódu execute()
s príslušnou lokáciou.
Umožnime teraz výber cieľového mesta z lišty akcií pomocou rozbaľovacieho prvku.
Rozbaľovací zoznam, tradične zvaný combo box, sa v Androide nazýva spinner.
Na zobrazenie spinnera v lište akcií potrebujeme nasledovné kroky:
Vytvorme si pomocnú metódu:
private void configureActionBar() {
ActionBar actionBar = getActionBar();
actionBar.setDisplayShowTitleEnabled(false);
actionBar.setDisplayShowHomeEnabled(false);
actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
actionBar.setListNavigationCallbacks(_____, ______);
}
V nej získame inštanciu lišty ActionBar
a postupne vypneme zobrazenie a sfunkčnenie ikony aplikácie, ktorú nebudeme potrebovať. Následne nastavíme mód lišty na zoznamový (NAVIGATION_MODE_LIST
) a pripravíme volanie pre registráciu adaptéra a poslucháča výberu v spinneri.
Nezabudnime túto metódu zavolať v onCreate()
!
Lišta akcií potrebuje pre dáta spinnera objekt SpinnerAdapter
. Našťastie, všetky tradičné triedy adaptérov dokážu byť adaptérmi spinnera. Vytvorme teda adaptér ArrayAdapter
a naplňme ho dátami.
Čo to však budú za dáta? Budeme používať civilizované objekty typu CityLocation
, kde budeme držať názov mesta a polohu.
public class CityLocation {
public static final String NO_PROVIDER = "";
private String city;
private Location location;
public CityLocation(String city, double latitude, double longitude) {
this.city = city;
Location location = new Location(NO_PROVIDER);
location.setLatitude(latitude);
location.setLongitude(longitude);
this.location = location;
}
public String getCity() {
return city;
}
public Location getLocation() {
return location;
}
@Override
public String toString() {
return this.city;
}
}
Adaptér potom bude vedieť využívať tieto objekty, ktoré zadáme napevno.
private void configureActionBarSpinnerAdapter() {
List<CityLocation> cityLocations = Arrays.asList(
new CityLocation("Košice", 48.697265, 21.2644253429128),
new CityLocation("Prešov", 48.997631, 21.2401873),
new CityLocation("Bratislava", 48.1535383, 17.1096711)
);
this.actionBarAdapter = new ArrayAdapter<CityLocation>(this, android.R.layout.simple_list_item_1, cityLocations);
this.selectedCityLocation = cityLocations.get(0); // first city
}
Adaptér si poznačíme do inštančnej premennej, budeme ho totiž potrebovať neskôr:
private SpinnerAdapter actionBarAdapter;
V adaptéri si tiež poznačíme predvybrané aktuálne vybrané mesto do premennej, ktorú využijeme pri výpočte vzdialenosti:
private CityLocation selectedCityLocation;
Metódu configureActionBarSpinnerAdapter()
zavoláme pred metódou inicializácie lišty akcií!
Poslucháčom zmien výberu v spinneri môže byť aktivita.
public class MainActivity extends Activity implements ... ActionBar.OnNavigationListener {
Aktivita potom musí poskytnúť metódu onNavigationItemSelected()
. V nej zistíme aktuálne vybraté miesto, na čo použijeme adaptéra a vybranú pozíciu z parametra metódu. Následne spustíme prepočítavanie vzdialenosti zavolaním metódy requestSingleUpdate()
, ktorá explicitne vyžiada aktuálnu polohu.
Popri tom ešte musíme ošetriť jeden prípad: pri spustení aplikácie sa vyberie prvá položka zoznamu, ale poloha zariadenia ešte nemusí byť známa. Našťastie má správca polohy metódu getLastKnownLocation()
, kde sa ukladá (kešuje) posledná známa poloha, ktorú vieme využiť do chvíle, kým sa z poskytovateľa dát nezíska aktuálnejšia poloha.
@Override
public boolean onNavigationItemSelected(int itemPosition, long itemId) {
this.selectedCityLocation = (CityLocation) this.actionBarAdapter.getItem(itemPosition);
Location lastKnownLocation = this.locationManager.getLastKnownLocation(this.locationProviderName);
if(lastKnownLocation != null) {
onLocationChanged(lastKnownLocation);
} else {
this.locationManager.requestSingleUpdate(this.locationProviderName, this, getMainLooper());
}
return true;
}
Ak máme adaptér a poslucháča, prepojenie je jednoduché:
actionBar.setListNavigationCallbacks(this.actionBarAdapter, this);
%TODO% načítavanie miest z XML
Ukázali sme si základné metódy pre prácu s polohou v Androide. Ako bolo spomenuté v úvode, API je ľahko uchopiteľné, ale implementovať typické inteligentné činnosti vyžaduje dosť špinavej roboty. Chcete sa prepínať na WiFi lokáciu, ak je GPS nedostupné? Čo s čakaním na prvý fix GPS satelitu? Ako často zisťovať polohu? To všetko je omnoho ľahšie, ak máte k dispozícii Google Places API, ktoré dáva k dispozícii už implementované triedy a metódy pre takéto činnosti. Ďalšia výhoda je prepojenie s mapami Google Maps, ktoré umožňujú vlastné vrstvy na mape, či navigáciu.
Inou alternatívou je použitie knižníc k OpenStreetMaps, komunitnej verzii máp. Síce nezískate inteligentnejšie API pre zisťovanie polohy, ale dostanete alternatívne, otvorené mechanizmy pre trasovanie (routing), či geokódovanie v situáciách, keď nechcete alebo nemôžete používať API Googlu. Podporu poskytujú projekty OSMDroid, či OSMBonusPack.
Výsledná aplikácia sa nachádza na GitHube, v repozitári novotnyr/android-locationr-demo-2015
.