8. stretnutie: časť 1: Určovanie polohy

Určovanie polohy

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:

  • z GSM signálu na základe triangulácie BTS staníc GSM operátora. Táto služba je samozrejme pomerne nepresná, ale je šetrná k batérii a nevyžaduje žiadne špeciálne senzory.
  • na základe aktuálne pripojenej WiFi siete. Google vlastní databázu mapujúcu identifikátory WiFi sietí (SSID) na geografickú polohu. (Údaje boli zozbierané zo zariadení pripojeních k WiFi, ktoré hlásili svoju polohu, resp. z vozidiel tvoriacich Google Maps.) Zistenie polohy vyžaduje aktívne pripojenie k WiFi, ktoré je náročné na baterku.
  • na základe GPS senzora:. Najpresnejšia poloha s najväčším počtom informácií. Poloha však vyžaduje zapnutý GPS senzor a dosah na satelity, čo neplatí v interiéri.

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.

Ukážková aplikácia

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!

API pre zistovanie polohy

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.

Architektúra geolokačného API

Získanie inštancie správcu polohy

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);
}

Registrácia poslucháča

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:

  • identifikátor poskytovateľa dát o polohe. Uvedieme identifikátor poskytovateľa, buď napevno, alebo na základe vyhľadávania vhodného poskytovateľa pomocou kritérií.
  • minimálna zmena vo vzdialenosti v metroch, ktorá bude považovaná za zmenu polohy
  • periódu získavania vzdialenosti, počas ktorej sa bude sledovať zmena. Tento a predošlý parameter treba nastaviť veľmi uvážlivo: kratšie vzdialenosti a menšia perióda povedie k presnejšiemu a častejšiemu zisťovaniu polohy, ale na úkor baterky (a také GPS vie vyžrať baterku veľmi rýchlo)
  • poslucháč na zmeny polohy, teda objekt LocationListener.
V našom testovacom prípade zvolíme periódu 10 sekúnd a vzdialenosť 100 metrov. V praxi je však 10 sekúnd príliš krátka perióda, ktorá extrémne vyťaží baterku!

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.

Vyhľadávanie poslucháčov podľa kritérií

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();
}

Počítanie vzdialenosti

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);
}

Geokódovanie

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 AsyncTasku alebo v službe.

Geokódovanie na pozadí

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:

  • aktivitu, ktorá bude reprezentovať kontext vyžadovaný Geocoderom a zároveň bude obsahovať TextView pre výsledok
  • identifikátor widgetu typu TextView, 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.

Efektívna referencia na aktivitu

V predošlých prípadoch sme definovali AsyncTasky 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 nullový.

Takýto druh referencie je reprezentovaný triedou WeakReference.

Konštruktor asynchrónnej aktivity

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í

Metóda na pozadí najprv zistí, či je vôbec geokóder dostupný.

if(!Geocoder.isPresent()) {
    return NO_ADDRESS;
}

Ak nie je, vráti nullovú 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;
}

Spracovanie výsledku

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:

  1. získame inštanciu aktivity z mäkkej referencie a uistíme sa, že nie je null
  2. vytiahneme z aktivity widget s požadovaným identifikátorom
  3. uistíme sa, že widget sa naozaj nachádza v layoute aktivity a je správneho typu (inak hodíme výnimku)
  4. nastavíme na ňom lokáciu prevzatú z aktivity

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());
}

Použitie v aktivite

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.

Výber mesta a rozbaľovací zoznam na lište akcií

Spinner na lište akcií

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:

  1. získať objekt pre action bar
  2. nastaviť zobrazovací mód na spinner
  3. pripraviť adaptér s dátami pre spinner
  4. nadefinovať poslúcháča na výber položky v spinneri

Konfigurácia lišty akcií

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()!

Odkiaľ získame adaptér?

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í!

Odkiaľ získame poslucháča?

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;
}

Napojenie adaptéra a poslucháča

Ak máme adaptér a poslucháča, prepojenie je jednoduché:

actionBar.setListNavigationCallbacks(this.actionBarAdapter, this);

%TODO% načítavanie miest z XML

Ako ďalej?

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

Výsledná aplikácia sa nachádza na GitHube, v repozitári novotnyr/android-locationr-demo-2015.