5. stretnutie: Žlté poznámkové papieriky

Cieľové elementy

Žlté poznámkové papieriky už nemusíte lepiť po displeji mobilu! Ukážeme si databázovú aplikáciu pre poznámky, kde navrhneme a implementujeme vlastný content provider a použijeme ho na príklade mriežky.

Koncepty, ktoré zvládneme

  • pohľad na zložky z databázovej perspektívy
  • návrh tabuliek databázovej aplikácie
  • použitie zabudovanej databázy SQLite
  • tvorba vlastného content providera

Výsledná aplikácia

Príprava projektu

Nový projekt

Založme si nový projekt s názvom Jot a balíčkom sk.upjs.ics.android.jot na platforme Android 4.0.3.

Pomocná trieda s implicitnými hodnotami

Do projektu pridajme pomocnú triedu sk.upjs.ics.android.util.Defaults, ktorá obsahuje preddefinované konštanty pre viaceré štandardné, či chýbajúce hodnoty. V Androide sa totiž veľmi často používa null vo význame chýbajúcej hodnoty, ale i štandardného nastavenia, ba dokonca aj pre situácie, keď to znamená „všetky objekty“.

Konštanty v tejto triede výrazne sprehľadnia zápis mnohých riadkov kódu, čo uvidíme hneď v prvej triede nášho projektu.

Databázová aplikácia

Postaviť databázovú aplikáciu je ako postaviť poriadny dom. Potrebujete základy, vytiahnuť múry, položiť deku, zhlobiť strechu. Samozrejme, niektoré postupy sa dajú vynechať alebo odfláknuť, ale potom je váš dom nestabilný a začne doňho tiecť. Androiďácka aplikácia stojí na viacerých takýchto zložkách, ktoré si najprv predstavíme a postupne ich začneme programovať a skladať dohromady.

Lego kocky databázovej aplikácie

Nákres architektúry vyzerá na prvý pohľad komplikovane: je v ňom plusmínus 10 komponentov! Ak sa to zdá veľa, je to preto, že databázová aplikácia musí riešiť viacero problémov naraz: všeobecnosť, flexibilitu, i dostatočnú svižnosť.

Architektúra

Nasledovný diagram demonštruje celkovú architektúru, ktorú si postupne prejdeme a ukážeme účel jednotlivých škatúľ.

Databáza

Databázová aplikácia ukladá dáta v tabuľkách (reláciách) SQL databázy. V každej tabuľke máme viacero záznamov, kde dáta sú organizované v pomenovaných stĺpcoch. (Neskôr sa dohodneme, ako sa bude volať tabuľka, jej stĺpce, a aké budú ich dátové typy.)

Triedy SQLite

V Androide je k dispozícii zabudovaná relačná databáza SQLite, ktorá je primerane mocná a zároveň dostatočne malá a efektívna na použitie v malých zariadeniach. (Okrem iného beží v každom Firefoxe.)

Na prácu s databázou je k dispozícii trieda SQLiteDatabase, cez ktorú viete posielať SQL dopyty (query()), vkladať (insert()), aktualizovať (update()), či mazať záznamy (delete()).

Tieto štyri základné operácie zodpovedajú klasickej filozofii CRUD (Create, Read, Update a Delete) pre konkrétnu entitu. Zodpovedajú im metódy:

  • insert() pre vkladanie,
  • query() pre čítanie záznamov,
  • update() pre aktualizáciu záznamu a
  • delete() pre mazanie.

Inštanciu tohto objektu však nezískavate priamo, ale cez pomocnú triedu SQLiteOpenHelper, ktorá dokáže inicializovať tabuľky pri inštalácii aplikácie.

Content Providery a Content Resolvery

ContentProvider už poznáme: je to všeobecný objekt pre prístup k tabuľkovým dátam bez ohľadu na to, kde sú uložené. Má podobné metódy pre CRUD ako databáza.

Ku content provideru sa nepristupuje priamo, ale cez prostredníka, ContentResolvera. Je to systémová trieda, typicky volaná z aktivity, ktorá pre požiadavku (napr. dopyt) vyberie konkrétny content provider, ktorý ju dokáže zrealizovať.

Kurzory

V databázovej tabuľke vždy uvažujeme v tabuľkách a záznamoch. Ak sa spýtate na „všetky poznámky vytvorené od včera", dostanete vo výsledku sadu záznamov, s hodnotami v konkrétnych stĺpcoch. Takýto výsledok je v Androide reprezentovaný Cursorom. Inými slovami, kurzor je akási "fotka" dát z databázy, s ktorou možno potom ďalej pracovať.

Kurzor ukazujúci na prvý záznam z trojice záznamov vo výsledku:

Adaptéry a widgety

Adaptéry už poznáme od minula: nesú dáta, ktoré sú následne maľované príslušným widgetom. V tomto prípade máme mriežku GridView, ktorá chce ťahať dáta z kurzora, na čo využijeme SimpleCursorAdapter.

Prepojenie content providera a adaptéra

Problém je jasný: adaptér potrebuje kurzor, a ten zoženieme z content providera (prostredníctvom content resolvera). Keďže získavanie kurzora je dlhotrvajúca operácia, musíme ju vykonať v samostatnom vlákne, na pozadí, a keď dobehne, musíme upozorniť adaptér, že dáta v kurzore sú pripravené. 3/26/2015 12:50:01 PM o všetko vyrieši CursorLoader.

CursorLoader a LoaderCallbacks.

Kurzorový loader kontaktuje content resolver cez metódu query(), a cez vybratého content providera získa kurzor. Upozorňovanie na pripravené dáta sa rieši cez metódy interfejsu LoaderCallbacks. Ak trieda aktivity implementuje tento interfejs, bude môcť počúvať na udalosti loadera. Načítal CursorLoader kurzor? Výborne, notifikuje tým aktivitu tým, že na nej zavolá metódu onLoadFinished(), ktorá má v parametri čerstvý kurzor.

LoaderManager

Keďže aktivita môže mať viacero loaderov, a musí sa o ne starať, má k dispozícii LoaderManager. Tento objekt inicializuje loadery, vytvára a zatvára ich, a prepája aktivitu s loaderom cez LoaderCallbacks.

Sumár

  • GridView: widget s mriežkou
  • SimpleCursorAdapter: adaptér nesúci dáta z kurzora
  • Cursor: výsledok dopytu do databázy
  • CursorLoader: načítava kurzor na pozadí
  • LoaderManager: zodpovedá za vytváranie, inicializáciu a upratovanie loaderov v aktivite
  • LoaderCallbacks: cez jeho metódy je aktivita upozornená, že loader už načítal nový kurzor
  • ContentResolver: vyhľadá konkrétny content provider pre dopyt
  • ContentProvider: abstraktný prístup k tabuľkovým dátam.
  • SQLiteDatabase: poskytuje metódy pre prístup k dátam v databáze
  • SQLiteOpenHelper: inicializuje databázové tabuľky a sprístupňuje SQLiteDatabase.

Implementácia databázovej vrstvy

Poďme postupne implementovať jednotlivé stavebné bloky. Budeme postupovať odzadu: od databázových tried sa postupne vynoríme až k mriežke.

Ešte predtým, než pôjdeme implementovať databázovú vrstvu, mali by sme sa dohodnúť na štruktúre ukladaných dát.

Pre poznámky budeme evidovať len ich text (popis) a dátum vytvorenia, čo v reči SQLite bude zodpovedať reťazcu TEXT a INTEGER (unixovská časová pečiatka). Nazveme ich description a timestamp a ukladajme ich do tabuľky note.

Nesmieme však zabudnúť na identifikátor záznamu! Konvencia v Androide silne odporúča nazvať tento stĺpec _id a ak mu priradíme deklaráciu INTEGER PRIMARY KEY AUTOINCREMENT, vyhlásime ho za primárny kľúč, ktorý sa bude navyše automaticky priraďovať pre nové záznamy.

Príslušný SQL dopyt by vyzeral nasledovne:

CREATE TABLE note
    _id INTEGER PRIMARY KEY AUTOINCREMENT,
    description TEXT,
    timestamp INTEGER
)

Kontrakt

Ako uvidíme, v celom projekte budeme musieť používať názvy stĺpcov a názvy tabuliek. Aby sme sa vyhli duplicitným reťazcom s preklepmi, definujú sa tieto metadáta tabuľky v samostatnej triede, tzv. kontrakte. (Pamätáte si CallLog.Calls? To je presne táto filozofia.)

Kontrakt je interfejs s konštantami, ktorú udávajú názvy stĺpcov v príslušnej tabuľke.

Použitie je jednoduché: ak máme tabuľku note z nášho príkladu, prislúcha jej interfejs Note, ktorý má definované konštantny pre názvy stĺpcov a bonusovú konštantu s názvom tabuľky. A keďže tabuliek môže byť v projekte viac (nebojte sa, u nás sa to nestane), je zvykom obaliť tieto popisné interfejsy do spoločnej triedy, ktorú si nazvime Provider a dajme ju do balíčka s príponou .provider.

package sk.upjs.ics.android.jot.provider;

import android.provider.BaseColumns;

public interface Provider {
    public interface Note extends BaseColumns {
        public static final String TABLE_NAME = "note";

        public static final String DESCRIPTION = "description";

        public static final String TIMESTAMP = "timestamp";
    }
}

Nehľadajte za týmito interfejsmi žiadnu čiernu mágiu. Na pozadí sa nedeje žiadne magické vytváranie tabuliek riadené týmto interfejsom. Je to čisto len dokumentácia v tvare kódu, aby sa vám s názvami tabuliek pracovalo pohodlne (máme totiž automatické dopĺňanie a podobne.)

Čo sú BaseColumns?

Ak interfejs tabuľky zdedí od interfejsu BaseColumns, získa prístup ku konštante _ID, ktorá zodpovedá stĺpcu s primárnym kľúčom. Dedenie vás ušetrí od dodatočnej konštanty:

public static final String _ID = "_id";

Objekty SQLite

SQLiteDatabase

SQLiteDatabase reprezentuje priamy prístup do databázy cez metódy query() (dopytovanie), insert() (vkladanie) a ďalšie. Parametre prevádza na SQL dopyty, ktoré priamo prekladá na príkazy databázy.

Inštanciu tohto objektu však nikdy nevytvárame my, ale získavame ju cez pomocnú helper triedu.

SQLiteOpenHelper

Ak chceme pristupovať k databázovému objektu SQLiteDatabase, musíme implementovať jeho správcu. Oddedíme od triedy SQLiteOpenHelper, kde prekryjeme konštruktor a dve metódy. Naša trieda nech sa volá DatabaseOpenHelper:

Konštruktor

V triede sme povinní vytvoriť konštruktor a zavolať rodičovský konštruktor so štyroma parametrami. Z nich len jeden využijeme v našom konštruktore (ide o context a ostatné definujeme cez konštanty).

  • context predstavuje tradičný kontext, ktorý posunieme z parametra do rodiča
  • názov databázy, ktorý obvykle zodpovedá názvu aplikácie. Pre prehľadnosť ho definujeme v konštante
  • továreň pre kurzory, kde využijeme implicitnú hodnotu (null alebo konštantu z triedy Defaults)
  • verzia databázy. Začíname verziou 1 v konštante.

Kód konštruktora je teda nasledovný:

public static final String DATABASE_NAME = "jot";
public static final int DATABASE_VERSION = 1;

public DatabaseOpenHelper(Context context) {
    super(context, DATABASE_NAME, Defaults.DEFAULT_CURSOR_FACTORY, DATABASE_VERSION);
}

Metóda onCreate()

Vo chvíli keď používateľ prvýkrát nainštaloval aplikáciu, ktorá chce využiť databázu SQLite, je často potrebné nainicializovať tabuľky cez SQL CREATE, prípadne ich naplniť vzorovými či úvodnými dátami. Presne na toto slúži metóda onCreate().

V nej vytvoríme tabuľku pre poznámky. Keďže názov tabuľky i názvy stĺpcov preberieme z kontraktovej triedy, využijeme formátovanie reťazcov a výsledok vykonáme cez metódu execSQL() na databázovom objekte z parametra.

@Override
public void onCreate(SQLiteDatabase db) {
    db.execSQL(createTableSql());
}

private String createTableSql() {
    String sqlTemplate = "CREATE TABLE %s ("
            + "%s INTEGER PRIMARY KEY AUTOINCREMENT,"
            + "%s TEXT,"
            + "%s DATETIME"
            + ")";
    return String.format(sqlTemplate, 
        Provider.Note.TABLE_NAME, 
        Provider.Note._ID, 
        Provider.Note.DESCRIPTION, 
        Provider.Note.TIMESTAMP);
}

Metóda onUpgrade()

Ak sa naša aplikácia vyvíja a potrebujeme medzi verziami našej aplikácie modifikovať štruktúru databázy (pridávať stĺpce, pridávať tabuľky atď.), vieme využiť onUpgrade(). Android sleduje verzie databáz nainštalovaných aplikácií a ak číslo verzie využité v konštruktore je väčšie než používateľova verzia, namiesto metódy onCreate() prebehne táto metóda. My túto vlastnosť nebudeme používať, preto metódu necháme prázdnu.

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    // do nothing
}

Vloženie ukážkových dát: insert() a ContentValues

Aby sme v tejto fáze vôbec videli nejaké ukážkové úlohy, vložíme ich natvrdo pri vytváraní databázy. Objekt SQLiteDatabase poskytuje metódu insert(), ktorou dokážeme vykonávať SQL INSERT požiadavky.

Každý vkladaný záznam je reprezentovaný objektom ContentValues, ktorý je veľmi podobný Mape: kľúčmi sú názvy stĺpcov a hodnotami ... sú hodnoty.

Ak chceme vložiť ukážkovú poznámku, vybudujeme content values a následne ich vložíme do databázy metódou insert():

ContentValues contentValues = new ContentValues();
contentValues.put(Provider.Note.DESCRIPTION, description);
contentValues.put(Provider.Note.TIMESTAMP, System.currentTimeMillis() / 1000);
db.insert(Provider.Note.TABLE_NAME, Defaults.NO_NULL_COLUMN_HACK, contentValues);

Pri vkladaní sme v contentValues vynechali prvý stĺpec s identifikátorom. Keďže sme ho deklarovali v tabuľke ako primárny kľúč s automatickým generovaním, SQLite namiesto chýbajúcej (NULL) hodnoty dogeneruje automatickú hodnotu.

Rovnako pri vkladaní dátumu využijeme vlastnosť databázy, kde sú dátumy reprezentované ako počet sekúnd od začiatku epochy. V Jave získame aktuálny dátum v milisekundách a delením ho prevedieme na sekundy.

Vkladanie potrebuje potom tri parametre:

Ak bude vzorových záznamov viac, urobme si pomocnú metódu:

private void insertSampleEntry(SQLiteDatabase db, String description) {
    ContentValues contentValues = new ContentValues();
    contentValues.put(Provider.Note.DESCRIPTION, description);
    contentValues.put(Provider.Note.TIMESTAMP, System.currentTimeMillis() / 1000);
    db.insert(Provider.Note.TABLE_NAME, Defaults.NO_NULL_COLUMN_HACK, contentValues);
}

@Override
public void onCreate(SQLiteDatabase db) {
    db.execSQL(createTableSql());
    insertSampleEntry(db, "Write a summary article");
    insertSampleEntry(db, "Enjoy Coffee at Whim's");
    insertSampleEntry(db, "Implement #235");
}

Hor'sa na provider!

Keď máme pomocnú triedu, inicializovanú databázu i dáta, môžeme implementovať ContentProvider!

ContentProvider

Vytvorme nový súbor, kde zo sekcie File | New... | vyberieme podmenu Other a položku Content Provider.

Nový content provider potrebuje štyri veci:

  • názov jeho Java triedy, ktorý zvolíme za provider.NoteContentProvider. (Android Studio za nás doplní predponu balíčka). Konvencia káže dávať providery do samostatného balíčka.
  • autoritu, čo znamená systémovo unikátny názov vášho providera. Tento názov sa objaví v URI adrese, ktorou budete k provideru pristupovať a obvykle sa nastaví na celé meno triedy.
  • enabled, ktorým povolíte vytváranie inštancií providera. Samozrejme, náš provider by nemal byť zakázaný.
  • exported. Exportovaný provider je viditeľný aj v cudzích aplikáciách. My zatiaľ nepotrebujeme exportovať dáta do iných aplikácií, preto túto vlastnosť zakážeme.

Po vytvorení nového providera vznikne nová trieda NoteContentProvider a v manifeste sa objaví deklarácia providera:

<provider
    android:name=".provider.NoteContentProvider"
    android:authorities="sk.upjs.ics.android.jot.provider.NoteContentProvider"
    android:enabled="true"
    android:exported="false" >
</provider>

Dumeme nad providerom

Skôr než začneme implementovať content providera, zamyslime sa nad dvoma vecami: ako bude vyzerať samotný obsah, ktorý bude provider zverejňovať a akým spôsobom budeme k nemu pristupvoať.

Obsah content prov idera

Obsah v content provideri je vždy chápaný databázovo-tabuľkovo orientovanou mysľou a to bez ohľadu na to, či dáta ťaháme z databázy SQLite alebo z iného prameňa. Ak sa niekto dopytuje na dáta z content providera, dostane výsledok v podobe Cursora, a na druhej strane, dáta, ktoré prichádzajú do databázy, sú uvedené v podobe ContentValues.

Vždy tak budeme mať tabuľku (či tabuľky) s niekoľkými stĺpcami, v ktorej sa nachádza viacero záznamov. Jednotlivé položky záznamu teda prislúchajú do konkrétneho stĺpca a majú svoj vlastný dátový typ. Keďže zdrojom dát je databáza, budeme využívať stĺpce a dátové typy definované v SQL tabuľke, na čo využijeme kontraktovú triedu, ktoré sme si definovali vyššie.

Ku dátam content providera sa pristupuje vždy cez Uri adresy. Provider možno chápať ako akúsi analógiu webového servera, ku ktorému pristupujeme cez špeciálne URI adresy. (Samozrejme, tuto sa žiaden protokol HTTP nepoužíva.)

Konvencia pre URI adresy káže dodržať nasledovný formát:

content://autorita-content-providera/tabuľka/parametre

V našom prípade, kde je autorita providera rovnaká ako názov jeho triedy môžeme k tabuľke note pristúpiť cez nasledovnú adresu:

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note

Takáto základná adresa sa dáva do konštanty s dohodnutým názvom CONTENT_URI. Používatelia vášho providera tak vedia, že toto je základ pre prácu s providerom, od ktorého sa môžu odpúptať.

Na vybudovanie tejto URI adresy využijeme jednak konštantu pre autoritu:

public static final String AUTHORITY = "sk.upjs.ics.android.jot.provider.NoteContentProvider";

Potom definujeme niekoľko statických importov, ktoré nám skrátia zápis:

import static android.content.ContentResolver.SCHEME_CONTENT;
import static sk.upjs.ics.android.jot.provider.Provider.Note;

A základnú adresu vybudujeme cez pomocnú triedu.

public static final Uri CONTENT_URI = new Uri.Builder()
    .scheme(SCHEME_CONTENT)
    .authority(AUTHORITY)
    .appendPath(Note.TABLE_NAME)
    .build();

V content providerovi budeme podporovať dva druhy prístupových adries: pre prístup k všetkým poznámkam druhá adresa pre prístup ku konkrétnej poznámke na základe jednoznačného identifikátora.

Adresa pre prístup k všetkým poznámkam bude vyzerať jednoducho:

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note

(áno, zhoduje sa so základnou adresou). Adresa pre prístup k tabuľke je totiž stotožnená so všetkými záznamami.

Adresa pre prístup k poznámke s identifikátorom 3 bude vyzerať zase takto:

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note/3

Za lomku po názve tabuľky dodáme primárny kľúč, čím vieme pristúpiť ku konkrétnemu záznamu.

Implementácia content providera

Vytvorenie providera v onCreate()

Implementáciu content provider začneme metódou onCreate(). V nej sa spojíme s triedou DatabaseOpenHelper:

    private DatabaseOpenHelper databaseHelper;

    @Override
    public boolean onCreate() {
        this.databaseHelper = new DatabaseOpenHelper(getContext());
        return true;
    }

Táto metóda musí zbehnúť veľmi rýchlo, keďže beží v hlavnom vlákne, resp. vo vlákne používateľského rozhrania. Pri štarte aplikácie sa totiž inicializujú všetky content providery, ktoré aplikácia používa (teda na každom z nich sa zavolá onCreate()), a kým nedobehnú, používateľ môže zažiť pocit zdĺhavosti, alebo dokonca môže vidieť čiernu obrazovku, či varovania o nereagujúcej aplikácii.

Dopytovanie cez query()

Dopytovanie je jednoduché, pretože ho vieme delegovať na našu databázu. Z nášho pomocného objektu si vytiahneme objekt pre databázu SQLiteDatabase a na ňom zavoláme query(), kde uvedieme všetky dôležité informácie.

V našom prípade chceme vykonať SQL dopyt SELECT * FROM note. Nebudeme však posielať surové SQL, ale využijeme parametre:

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        SQLiteDatabase db = databaseHelper.getReadableDatabase();
        Cursor cursor = db.query(Note.TABLE_NAME, ALL_COLUMNS, NO_SELECTION, NO_SELECTION_ARGS, NO_GROUP_BY, NO_HAVING, NO_SORT_ORDER);
        return cursor;
    }

Vidíte všetky konštanty? (Či už z kontraktovej triedy, alebo z triedy Defaults.) Tie sprehľadnia zápis, pretože bez neho by dopyt vyzeral asi takto:

db.query(Note.TABLE_NAME, null, null, null, null, null, null);

Výsledkom volania je kurzor, ktorý môžeme ihneď vrátiť z content providera. Kurzor bude obsahovať všetky stĺpce a všetky záznamy z tabuľky note.

Ak máte pokušenie vyhlásiť objekt SQLiteDatabase za inštančnú premennú a inicializovať ho v metóde onCreate(), odolajte.

Prvé volanie, ktoré získa objekt databázy, totiž povedie k zavolaniu onCreate() na triede database open helpera, kde sa začne inicializovať databáza, a to v hlavnom vlákne a vyrobíte si tak aplikáciu, ktorá sa štartuje pomaly.

Provider je hotový, poďme ho použiť!

Provider je hotový, vie získavať ukážkové dáta z databázy a je čas ho prepojiť s aktivitou

Aktivita a vlastný content provider

Teraz, keď máme hotového providera, si len zopakujeme postup prác.

  1. navrhneme používateľské rozhranie aktivity
  2. vytvoríme v kóde aktivity kurzorový adaptér s prázdnym kurzorom
  3. zavedieme do aktivity počúvanie na udalosti kurzorového loadera
  4. inicializujeme kurzorový loader a prepojíme ho s aktivitou cez poslucháča
  5. ak kurzorový loader načíta dáta v podobe Cursora, vložíme ich do adaptéra

Používateľské rozhranie

Aktivita bude pozostávať zatiaľ z jedinej mriežky GridView:

<RelativeLayout xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:tools="https://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">

    <GridView
        android:id="@+id/notesGridView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:numColumns="2"
        android:stretchMode="columnWidth"
        android:horizontalSpacing="12dp"
        android:verticalSpacing="12dp"
        />

</RelativeLayout>

Definujeme na nej dva stĺpce (numColumns), naťahovanie stĺpcov do šírky (stretchMode) a zavedieme medzery medzi riadkami a stĺpcami.

Kurzorový adaptér

V pomocnej metóde inicializujeme kurzorový adaptér s prázdnym kurzorom (Defaults.NO_CURSOR) a poznačíme si ho do inštančnej premennej. Položky budeme zobrazovať v štandardných textových políčkach poskytnutých Androidom.

Inicializovaný adaptér spriahneme s mriežkou cez setAdapter().

public class MainActivity extends ActionBarActivity {

    private SimpleCursorAdapter adapter;

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

        GridView notesGridView = (GridView) findViewById(R.id.notesGridView);
        notesGridView.setAdapter(initializeAdapter());
    }

    private ListAdapter initializeAdapter() {
        String[] from = {Provider.Note.DESCRIPTION };
        int[] to = {android.R.id.text1};
        this.adapter = new SimpleCursorAdapter(this, android.R.layout.simple_item_text1, Defaults.NO_CURSOR, from, to, Defaults.NO_FLAGS);
        return this.adapter;
    }
}

Počúvanie na udalosti loadera

Keďže načítavanie dát z databázy môže byť operácia, ktorá trvá dlho (možno viac ako odporúčaných 300 ms), je potrebné ju vykonať na pozadí.

Načítavanie vyrieši CursorLoader, ktorý však potrebuje notifikovať aktivitu, že dáta sú už nachystané, kurzor je pripravený a že je možné ho vložiť do adaptéra.

Ak aktivita implementuje LoaderManager.LoaderCallbacks<Cursor>, môže počúvať a reagovať na dôležité činnosti, ktoré súvisia s prácou Loadera.

Ako sme spomenuli minule, tieto činnosti sú tri:

  • onCreateLoader(): je potrebné vytvoriť novú inštanciu Loadera. Metóda sa typicky volá pri štarte aktivity a v našom prípade v nej vytvoríme nový objekt CursorLoadera, ktorému navyše nastavíme takú URI z content providera, ktoré povedie k načítaniu príslušných dát. U nás nastavujeme Uri adresu pre načítanie všetkých dát z tabuľky Note, ktorej zodpovedá konštanta CONTENT_URI z providera.
  • onLoadFinished(): loader úspešne načítal dáta na pozadí a ponuka ich k dispozícii. V našom prípade sa načítajú dáta v podobe Cursora, ktorý asociujeme s adaptérom.
  • onLoaderReset(): dáta, ktoré kedysi loader načítal, už nie sú platné, čo nastane obvykle pri rušení (destroy) aktivity. Odstránime teda kurzor z adaptéra.

LoaderManager

Prepojenie medzi aktivitou, loaderom a udalosťami v callbackoch rieši práve LoaderManager. Každá aktivita má svojho vlastného loader managera, ktorého získa cez getLoaderManager().

Pri vytváraní aktivity inicializujeme aktivitu s udalostnými callbackmi:

getLoaderManager().initLoader(NOTES_LOADER_ID, Bundle.EMPTY, this);

Loaderu priradíme číselný identifikátor (u nás nulová konštanta), voliteľné dodatočné parametre (v Bundle) a triedu poslucháča s callbackmi (je ňou aktivita).

Výsledný kód

Výsledný kód, ktorý demonštruje použitie mriežky, adaptéra, kurzorového loadera a callbackov je nižšie:

package sk.upjs.ics.android.jot;

import android.app.LoaderManager;
import android.content.CursorLoader;
import android.content.Loader;
import android.database.Cursor;
import android.os.Bundle;
import android.support.v7.app.ActionBarActivity;
import android.widget.GridView;
import android.widget.ListAdapter;
import android.widget.SimpleCursorAdapter;

import sk.upjs.ics.android.jot.R;
import sk.upjs.ics.android.jot.provider.NoteContentProvider;
import sk.upjs.ics.android.jot.provider.Provider;
import sk.upjs.ics.android.util.Defaults;

public class MainActivity extends ActionBarActivity implements LoaderManager.LoaderCallbacks<Cursor> {

    private static final int NOTES_LOADER_ID = 0;

    private SimpleCursorAdapter adapter;

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

        getLoaderManager().initLoader(NOTES_LOADER_ID, Bundle.EMPTY, this);

        GridView notesGridView = (GridView) findViewById(R.id.notesGridView);
        notesGridView.setAdapter(initializeAdapter());
    }

    private ListAdapter initializeAdapter() {
        String[] from = {Provider.Note.DESCRIPTION };
        int[] to = { android.R.id.text1 };
        this.adapter = new SimpleCursorAdapter(this, android.R.layout.simple_list_item_1, Defaults.NO_CURSOR, from, to, Defaults.NO_FLAGS);
        return this.adapter;
    }


    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        CursorLoader loader = new CursorLoader(this);
        loader.setUri(NoteContentProvider.CONTENT_URI);
        return loader;
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
        this.adapter.swapCursor(cursor);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        this.adapter.swapCursor(Defaults.NO_CURSOR);
    }
}

Náhľad prvej verzie

Prvá verzia nie je zatiaľ ktoviečo, ale už sa môžeme tešiť z dát, ktoré preplávali z hlbín databázy až to mriežky

Vylepšenie vizuálu

Poďme teraz vylepšiť vizuál! Prispôsobme mriežku tak, aby naozaj pripomínala žlté papieriky.

Plán práce bude nasledovný:

  1. definujeme vlastný layout pre bunky mriežky
  2. definujeme tiene pre položky, ktoré nasimulujeme cez navrstvené obdĺžniky

Vlastný vizuál pre položky

Namiesto štandardného výzoru android.R.layout.simple_list_item1 definujeme vlastný layout. Založme nový layout súbor (File | New | XML | Layout XML file), azvime ho note.xml a dodajme doň obsah:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/notesGridViewItem"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="12dp"
        android:background="#FFE63B"
        android:minHeight="96dp"
        />
</LinearLayout>

V tomto prípade bude položka tvorená jediným textovým políčkom s identifikátorom notesGridViewItem, s minimálnou výškou 96dp, s vypchávkou medzi textom a okrajom (padding).

Využitie layoutu v adaptéri

Zmeňme teraz používaný layout a identifikátor políčka v adaptéri v aktivite: v poli to použijeme identifikátor textového políčka a v druhom parametri konštruktora adaptéra sa odkážeme na náš layout súbor:

int[] to = { R.id.notesGridViewItem };
this.adapter = new SimpleCursorAdapter(this, R.layout.note, NO_CURSOR, from, to, NO_FLAGS);

Bonus: Tieňovanie

Hoci Android 5 podporuje vo svojom vizuálnom jazyku hĺbku komponentov a ich tieňovanie, musíme ešte dbať na staré platformy.

Tieň nasimulujeme cez vrstvenie obdĺžnikov: pozadie vykreslíme tmavožltou farbou, ktorá sa bude tváriť ako tieň a na ňu namaľujeme o niečo menší obdĺžnik, ktorý bude o jeden pixel užší a jeden pixel nižší než pozadie.

Vrstvenie môžeme realizovať cez špeciálny typ drawable: LayerList. Vytvorme nový súbor: pravým klikom na adresár drawable, a z menu New | Drawable Resource File a nazvime ho shadow.xml.

Obsahom nech je:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="https://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="#CCB82F" />
        </shape>
    </item>
    <item android:right="1dp" android:bottom="2dp">
        <shape android:shape="rectangle">
            <solid android:color="#FFE63B"/>
        </shape>
    </item>
</layer-list>

Definujeme dve vrstvy (zodpovedajúce dvom <item>om), ktoré sa vykresľujú v takom poradí, v akom sú definované v súbore. V každej z vrstiev sa definuje obdĺžnik (element <shape> s typom rectangle) vyplnený farbou: spodný tieňový tmavožltou a druhý klasickou žltou. Vrchný svetložltý obdĺžnik navyše odtlačíme o 1 dp od pravého okraja a 2 dp od spodného okraja, čím napodobníme tieň.

Tento drawable potom môžeme využiť ako farbu pozadia v poznámke note.xml, kde stačí zmeniť pozadie z pevnej farby na odkaz pre náš drawable:

android:background="@drawable/shadow"

Viaceré druhy dopytov cez UriMatchery a vyťahovanie jedinej položky

Predstavme si, že náš content provider nebude poskytovať dopyty len pre všetky poznámky, ale radi by sme mali podporu pre získanie jedinej poznámky podľa jej identifikátora. Táto situácia sa môže hodiť v prípade, keď chceme mať dodatočnú detailovú aktivitu, kde môžeme konkrétnu poznámku upravovať alebo obdivovať.

Metóda query() v content providery tak musí podporovať dva typy adries:

  • content://[AUTORITA]/note pre všetky záznamy
  • content://[AUTORITA]/note/[ID] pre jeden záznam.

Na základe parametra Uri v tejto metóde sa teda musíme vedieť rozhodnúť, či máme vrátiť kurzor s jedným záznamom alebo s viacerými. Jedna z možností je komplikovane parsovať Uri, ale načo? Máme na to pomocnú triedu UriMatcher.

Pravidlá pre podporované adresy UriMatcher

UriMatcher je trieda, v ktorej definujeme pravidlá pre podporované Uri adresy, a následne vieme zistiť, či adresa na vstupe vyhovuje niektorému z pravidiel.

Matcher definujeme ako inštančnú premennú, pričom začneme bez pravidiel:

private UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

Každé pravidlo pozostáva z troch komponentov:

  • autorita, ktorú prevezmeme z content providera
  • príponu, teda predpis URI adresy
  • kód pravidla, teda jeho jednoznačný číselný identifikátor.

Pre dve pravidlá, ktoré sme spomínali vyššie, definujeme dva identifikátory:

private static final int URI_MATCH_NOTES = 0;
private static final int URI_MATCH_NOTE_BY_ID = 1;

Následne ich definujeme v metóde onCreate() content providera:

@Override
public boolean onCreate() {
    ...
    uriMatcher.addURI(AUTHORITY, Note.TABLE_NAME, URI_MATCH_NOTES);
    uriMatcher.addURI(AUTHORITY, Note.TABLE_NAME + "/#", URI_MATCH_NOTE_BY_ID);

    ...
}

Prvé pravidlo je jednoduché a testuje zhodu reťazcov. Druhé pravidlo je o niečo zložitejšie: obsahuje zástupný znak (wildcard) mriežky #, ktorá zodpovedá číslu (toto číslo bude reprezentovať identifikátor záznamu).

UriMatcher podporuje dva druhy zástupných znakov: * pre ľubovoľné znaky a # pre čísla.

Pozor, definícia prípony pravidla nesmie začínať lomkou! (Ak sa tak stane, matcher nenájde zhodu medzi Uri a pravidlom.)

Využitie pravidiel pre overenie Uri

Ako zistíme, či Uri z parametra metódy content providera spĺňa niektoré z pravidiel?

UriMatcher má metódu match(), do ktorej vložíme ľubovoľnú Uri adresu a výsledkom bude kód pravidla, ktoré sa s ňou zhoduje. Klasický vzor pre metódu pozostáva z jedného veľkého switchu nad identifikátormi pravidiel, kde v každej vetve je obsluha príslušnej možnosti.

Vo vetve default sa potom nachádza ošetrenie situácie, keď sa žiadne pravidlo nezhodlo s Uri, čo indikuje nepodporovanú alebo nesprávnu adresu. V takom prípade sa obvykle vracia null.

Použime pravidlo v metóde query():

@Override
public Cursor query(Uri uri, String[] projection, String selection,
                    String[] selectionArgs, String sortOrder) {
    Cursor cursor = null;
    switch(uriMatcher.match(uri)) {
        case URI_MATCH_NOTES:
            cursor = listNotes();
            return cursor;
        default:
            return Defaults.NO_CURSOR;
    }
}

Aby sme mali v kóde poriadok, vysunuli sme získavanie položiek do samostatnej metódy:

private Cursor listNotes() {
    SQLiteDatabase db = databaseHelper.getReadableDatabase();
    return db.query(Note.TABLE_NAME, ALL_COLUMNS, NO_SELECTION, NO_SELECTION_ARGS, NO_GROUP_BY, NO_HAVING, NO_SORT_ORDER);
}

Čo s druhým pravidlom? Dodáme ďalšiu vetvu do case. V tomto prípade však chceme zistíť, aký identifikátor položky sa nachádza v adrese. V tomto prípade je identifikátor 42:

`content://[AUTORITA]/note/42`

Opäť nemusíme parsovať reťazce, pretože máme k dispozícii triedu ContentUris a jej statickú metódu parseId(), ktorá z Uri vytiahne posledný číselný segment:

case URI_MATCH_NOTE_BY_ID:
    long id = ContentUris.parseId(uri);
    cursor = findById(id);
    return cursor;

Vyťahovanie jednej položky z databázy

Opäť si pre poriadok dodáme kód pre vyťahovanie položky do samostatnej metódy:

private Cursor findById(long id) {
    SQLiteDatabase db = databaseHelper.getReadableDatabase();
    String selection = Note._ID + "=" + id;
    return db.query(Note.TABLE_NAME, ALL_COLUMNS, selection, NO_SELECTION_ARGS, NO_GROUP_BY, NO_HAVING, NO_SORT_ORDER);
}

Odlišnosťou je klauzula WHERE v dopyte, ktorú definujeme v treťom parametir metódy query na databázovom objekte. Podmienka v tomto prípade znie _id = ?, ktorú vytvoríme lepením reťazcov.

Vkladanie nových položiek (insert)

Aplikácia na poznámky, do ktorej nemôžeme vkladať poznámky, je na nič. Poďme implementovať túto možnosť!

Opäť pôjdeme odzadu:

  • zamyslíme sa nad vkladaním záznamov do databázy,
  • potom prejdeme k obsluhe vkladania údajov do content providera
  • zamyslíme sa nad obnovovaním (refreshom) dát po vložení
  • dopracujeme používateľské rozhranie
  • dopracujeme vkladanie údajov z aktivity do providera

Vkladanie údajov do databázy

Na vkladanie údajov do databázy získame predovšetkým objekt zapisovateľnej databázy z open helpera cez .getWritableDatabase(). Následne využijeme vkladanie cez content values, ktoré sme už robili pri inicializácii databázy.

Vkladanie údajov do content providera

V prípade content providera prekryjeme metódu insert(), ktorá má dva parametre: Uri adresu identifikujúcu cieľ, kam chceme vložiť dáta a samotná dáta v ContentValues.

Na začiatku zistíme, či na vstupe je vôbec podporovaná Uri adresa, na čo využijeme UriMatcher. Ak chce ktosi vkladať do content providera, musí využiť CONTENT_URI odkazujúci na tabuľku note.

Aké dáta očakávame? Potrebujeme obslúžiť tri stĺpce: identifikátor (ten dogenerujeme automaticky v databáze), popis (ten je povinný) a dátum (ktorý tiež dogenerujeme). Z content values teda vytiahneme len hodnotu pre popis a vložíme ju do novej inštancie content values*, ktorú zašleme do databázy.

Nikdy neverte dátam, ktoré prichádzajú do databázy zvonku! Hoci by sme mohli do databázy vkladať dáta prichádzajúce z parametra insert(), pre istotu si vytvoríme vlastné content values, do ktorých vložíme len to, čo naozaj potrebujeme, čiže popis poznámky.

Kód bude vyzerať nasledovne:

@Override
public Uri insert(Uri uri, ContentValues values) {
    switch(uriMatcher.match(uri)) {
        case URI_MATCH_NOTES:
            return saveNote(values);
        default:
            return Defaults.NO_URI;
    }
}

private Uri saveNote(ContentValues values) {
    ContentValues note = new ContentValues();
    note.put(Note._ID, AUTOGENERATED_ID);
    note.put(Note.DESCRIPTION, values.getAsString(Note.DESCRIPTION));
    note.put(Note.TIMESTAMP, System.currentTimeMillis() / 1000);

    SQLiteDatabase db = databaseHelper.getWritableDatabase();
    long newId = db.insert(Note.TABLE_NAME, NO_NULL_COLUMN_HACK, note);
    return ContentUris.withAppendedId(CONTENT_URI, newId);
}

Autogenerované kľúče a identifikátory záznamov

Všimnime si, ako nám metóda insert() na databáze vráti celočíselný automaticky generovaný primárny kľúč. Ten je dôležitý, pretože sme neopísali, že metóda insert() na content provideri musí vracať Uri adresu.

Táto adresa podľa dokumentácie má reprezentovať identifikátor práve vloženého záznamu. Našťastie ju vieme vytvoriť veľmi jednoducho. Ak používateľ vkladá dáta do providera cez adresu:

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note

a databáza pridelila záznamu identifikátor 7, záznam bude mať adresu

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note/7

Pomocná trieda ContentUris a jej metóda withAppendedId() dokáže zobrať ľubovoľnú Uri adresu a dolepiť na jej koniec číselný identifikátor:

return ContentUris.withAppendedId(CONTENT_URI, newId);

Obnovovanie dát po vložení

Často nastáva situácia, keď chceme vložiť do content providera nejaké dáta a automaticky obnoviť používateľské rozhranie. Vloží sa nová poznámka? Nech sa mriežka automaticky obnoví a zobrazí i čerstvo pridanú položku.

Našťastie, content providery automaticky poskytujú túto možnosť. Síce nie priamo, ale cez svoju sekretárku content resolver, ale idea je rovnaká.

Vysielanie zmien

Content Resolver (teda sekretárka) môže na seba zaregistrovať poslucháčov, ktorí budú monitorovať konkrétnu Uri adresu (zodpovedajúcu tabuľke záznamov) a ak sa údaje schované za adresou zmenia, môžu byť upozornené a vedia napríklad vytiahnuť aktualizované dáta a prekresliť používateľské rozhranie.

A jedným z takýchto poslucháčov je Cursor. Ak vložíme záznam do tabuľky, upozorníme všetkých poslucháčov (typicky jediný kurzor), že tabuľka sa zmenila.

Upozornenie urobíme v metóde insert() content providera, kde si vyžiadame odkaz na "sekretárku", ktorú upovedomíme, že dáta sa zmenili a ona to rozpošle ďalším poslucháčom:

getContext().getContentResolver().notifyChange(uri, Defaults.NO_CONTENT_OBSERVER);

Dôležité je poznať Uri, ktoré reprezentuje zmenené dáta: v tomto prípade upozorňujeme, že sa zmenila po vložení záznamu celá tabuľka a teda Uri je identifikátorom

content://sk.upjs.ics.android.jot.provider.NoteContentProvider/note

čo je presne konštanta CONTENT_URI. Výsledný kód bude vyzerať nasledovne:

@Override
public Uri insert(Uri uri, ContentValues values) {
    switch(uriMatcher.match(uri)) {
        case URI_MATCH_NOTES:
            Uri newItemUri = saveNote(values);
            getContext().getContentResolver().notifyChange(CONTENT_URI, NO_CONTENT_OBSERVER);
            return newItemUri;
        default:
            return Defaults.NO_URI;
    }
}

A čo poslucháči?

Ak chcú poslucháči prijímať informácie o zmenách dát v content provideri, môžu sa tiež zaregistrovať na content resolveri. U nás je poslucháčom Cursora zaregistrujeme ho v metóde query():

    cursor = listNotes();
    cursor.setNotificationUri(getContext().getContentResolver(), uri);
    return cursor;

Musíme sa však zamyslieť nad druhým parametrom uri: v tomto prípade chce kurzor sledovať Uri zodpovedajúce všetkým poznámkam (content://[AUTORITA]/note). Premenná uri prišla z parametra, a po spracovaní uriMatcherom je jasné, že v príslušnej vetve sa naozaj zhoduje s adresou pre všetky poznámky.

Používateľské rozhranie: tlačidlo na lište akcií

Používateľské rozhranie pre pridávanie položiek bude pozostávať z tlačidla na action bare, ktorým vyvoláme dialóg zadanie textu novej poznámky.

Ukážeme si pritom použitie modálnych dialógov ich obsluhu

Tlačidlo na action bare

Definujme tlačidlo na action bare, ktorým vyvoláme dialóg pre pridávanie. Do definičného súboru menu_main.xml dodajme jedinú položku <item>. (Ak sa tam nachádza položka, ktorú vygenerovalo Studio, vymažme ju.)

<item android:id="@+id/action_new"
    android:title="Add a new note"
    android:icon="@android:drawable/ic_menu_add"
    app:showAsAction="ifRoom" />

Položke pridáme identifikátor action_new, nastavíme jej systémovú ikonku s „pluskom“ pre pridanie a budeme ju zobrazovať ako tlačidlo na lište akcií, ak bude miesto.

Obsluha tlačidla v aktivite

V aktivite dodáme dve metódy: onCreateOptionsMenu() inicializuje položky na action bare z definičného súboru XML a onOptionsItemSelected() obslúži výber konkrétneho tlačidla či položky.

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_main, menu);
    return true;
}

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    int id = item.getItemId();
    //noinspection SimplifiableIfStatement
    if (id == R.id.action_new) {
        createNewNote();
        return true;
    }
    return super.onOptionsItemSelected(item);
}

Zatiaľ obsluhu presunieme do vlastnej metódy createNewNote(), ktorá ešte nie je vytvorená, ale hneď to napravíme.

Používateľské rozhranie: dialóg

Niekedy chceme od používateľa získať stručnú informáciu bez toho, aby sme potrebovali samostatnú aktivitu, alebo aby potvrdil nejakú nevratnú činnosť, či oznámili mu niečo naozaj dôležité, čo vyžaduje jeho mimoriadnu pozornosť.

Na takéto modálne dialógy slúži AlertDialog a jeho konštrukčná trieda AlertDialog.Builder. V nej vyvoláme sekvenciu krokov, ktoré povedú k zostaveniu dialógu a jeho následnému zobrazeniu.

Vieme tak nastaviť napr.

  • titulok (title) zobrazený v záhlaví dialógu
  • správu (message) pre jednoduché oznamy
  • vnútorný obsah (view), ak chceme zobraziť komplexný vlastný obsah
  • pozitívne tlačidlo (positiveButton), ktoré slúži na potvrdenie činnosti (zodpovedá tlačidlu OK, Súhlasím a pod.)
  • negatívne tlačidlo (negativeButton), ktoré slúži na zamietnutie činnosti (zodpovedá tlačidlu Storno, Nie a pod.)
  • neutrálne tlačidlo (neutralButton) pre situácie, keď sa používateľ nechce rozhodnúť ani pre potvrdenie ani zamietnutie (napr. Upozorniť neskôr)

Najjednoduchší príklad by vyzeral:

new AlertDialog.Builder(this)
        .setTitle("Not enough cheese")
        .setMessage("Cheese has run out. Mice will not able to proceed.")
        .setPositiveButton("OK", null)
        .show()

Ak chceme obsluhovať kliknutie na tlačidlá, použijeme v druhom parametri metódy set***Button() poslucháča typu DialogInterface.OnClickListener:

.setPositiveButton("OK", new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface dialog, int which) {
            ...
        }
    })

Vytváranie vlastných widgetov

My však potrebujeme trochu zložitejší dialóg: chceme využiť vlastný view v podobe textového tlačidla EditText, do ktorého používateľ zadá text novej poznámky.

Doposiaľ sme widgety deklarovali v layoutovom súbore XML (napr. <EditText>) a následne ich vyťahovali do premenných cez findViewById(). Alternatívny spôsob práce s widgetmi je ich priame vytváranie. Takto si jednoducho vytvoríme inštanciu EditText, ktorý použijeme v úlohe view pre dialóg.

final EditText descriptionEditText = new EditText(this);

Premennú deklarujeme ako final, aby sme k nej vedei pristúpiť z vnútorner triedy poslucháča, kde vytiahneme cez getText() text zadaný používateľom a vyvoláme pomocnú metódu pre vkladanie do content providera.

private void createNewNote() {
    final EditText descriptionEditText = new EditText(this);
    new AlertDialog.Builder(this)
            .setTitle("Add a new note")
            .setView(descriptionEditText)
            .setPositiveButton("OK", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    String description = descriptionEditText.getText().toString();
                    insertIntoContentProvider(description);
                }
            })
            .setNegativeButton("Cancel", Defaults.DISMISS_ACTION)
            .show();
}

Vkladanie do content providera

Vkladanie do content providera vyriešime cez sekretariát content providera, čiže cez content resolver. Ten má metódu insert(), do ktorej vieme uviesť Uri a známe ContentValues, ale pozor!

Metódy content resolvera (a content providera v rovnakom procese) bežia vo vlákne používateľského rozhrania! Dlhotrvajúce operácie teda môžu spôsobiť ťahavú aplikáciu.

Vkladanie do content providera tak musíme vyriešiť asynchrónne (v prípade načítavania sa asynchrónnosť dosiahla cez loadery).

AsyncQueryHandler je trieda, ktorá uľahčuje asynchrónne vykonávanie dopytov na content provideri.

Metódy tohto objektu fungujú v pároch: z aktivity zavoláme startInsert(), ktorý povedie k vykonaniu insert() na content resolveri v samostatnom vlákne, a keď vkladanie dobehne, zavolá sa na handleri metóda onInsertComplete(). Párových metód je viac: startDelete() a onDeleteComplete() a podobný pár pre update a dokonca i pre dopytovanie query().

Kým vkladanie bezí v samostatnom vlákne, on***Complete() bežia opäť vo vlákne používateľského rozhrania (UI Thread).

V kóde si teda pripravíme cieľovú Uri a pripravíme si vkladaný záznam ContentValues. Následne vytvoríme objekt AsyncQueryHandler, ktorý musíme zároveň zaopatriť prekrytou metódou, v ktorej zobrazíme toast.

private void insertIntoContentProvider(String noteDescription) {
    Uri uri = NoteContentProvider.CONTENT_URI;
    ContentValues values = new ContentValues();
    values.put(Provider.Note.DESCRIPTION, noteDescription);

    AsyncQueryHandler insertHandler = new AsyncQueryHandler(getContentResolver()) {
        @Override
        protected void onInsertComplete(int token, Object cookie, Uri uri) {
            Toast.makeText(MainActivity.this, "Note was saved", Toast.LENGTH_SHORT)
                    .show();
        }
    };

    insertHandler.startInsert(INSERT_NOTE_TOKEN, NO_COOKIE, uri, values);
}

Metóda startInsert() potrebuje tri parametre:

  • číselný kód operácie, ktorý slúži pre prípady, kde jedna inštancia handlera obsluhuje viacero rozličných vkladaní (je to podobná filozofia ako v prípade uri matchera, či obsluhy položiek menu). U nás nebudeme rozlišovať viacero operácií na jednom handleri, preto zvolíme za kód konštantu INSERT_NOTE_TOKEN s hodnotou 0, ktorú deklarujeme v aktivite.)
  • objekt cookie slúžiaci ako vedierko pre ľubovoľné dáta. Po dobehnutí vkladania sa cookie objaví v metóde on*Complete() v druhom parametri.
  • Uri identifikátor tabuľky pre content provider
  • ContentValues s dátami záznamu.

To je naozaj všetko, čo potrebujeme: môžeme si spustiť aplikáciu a vkladať!

Výber položky v mriežke a odstraňovanie (Delete)

Čo ak používateľ už niektorú poznámku nepotrebuje? Alebo čo ak sa pomýli pri zadávaní? Vyriešime to jednoducho: dáme mu možnosť ju vymazať.

Pri mazaní je plán práce nasledovný:

  1. doplníme delete() na content providerovi
  2. upravíme používateľské rozhranie: položku zmažeme, ak na ňu používateľ klikne a potvrdí dialóg
  3. dodáme mazanie v aktivite cez AsyncQueryHandler tak, ako v prípade vkladania

Mazanie v content providerovi

Mazanie je jednoduché: Pošleme do content providera Uri s položkou, potom pomocou UriMatchera overíme korektnosť adresy, následne vytiahneme identifikátor položky a ten vymažeme metódou delete() na databázovom objekte. Po vymazaní nezabudneme upozorniť prípadných poslucháčov!

@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
    switch(uriMatcher.match(uri)) {
        case URI_MATCH_NOTE_BY_ID:
            long id = ContentUris.parseId(uri);
            int affectedRows = databaseHelper.getWritableDatabase()
                    .delete(Note.TABLE_NAME, Note._ID + " = " + id, Defaults.NO_SELECTION_ARGS);
            getContext().getContentResolver().notifyChange(CONTENT_URI, NO_CONTENT_OBSERVER);
            return affectedRows;
        default:
            return 0;
    }
}

Návratovou hodnotou je počet vymazaných riadkov, ktorý jednoducho vrátime z metódy, pretože presne toto sa od nás očakáva.

Používateľské rozhranie

Lešenie poslucháča na kliknutie na položku

Používateľské rozhranie vyriešime obsluhou kliku na položke GridView, čo sme videli v minulom dieli. Rozdielom je kód: namiesto toho, aby sme na mriežke zaviedli anonymnú vnútornú triedu, vyhlásime za poslucháča celú aktivitu. Necháme ju preto implementovať interfejs AdapterView.OnItemClickListener:

public class MainActivity extends ActionBarActivity implements LoaderManager.LoaderCallbacks<Cursor>, AdapterView.OnItemClickListener

a dodáme metódu, ktorá je týmto interfejsom vyžadovaná:

@Override
public void onItemClick(AdapterView<?> parent, View view, int position, final long id) {

}

V treťom kroku prepojíme aktivitu ako poslucháča s mriežkou (v metóde onCreate()):

notesGridView.setOnItemClickListener(this);

Obsluha kliku na položku

Po kliknutí na položku by sme mali zobraziť dialóg, v ktorom môže používateľ zmazať nechcenú poznámku. Ako však zistíme, na ktorú položku sme klikli? V parametroch metódy onItemClick() máme k dispozícii parameter long id, ktorý obsahuje hodnotu primárneho kľúča pre položku, na ktorú sme klikli.

Zvyčajne by to stačilo na zmazanie položky: poslali by sme do content providera príslušnú Uri a je to. Čo ak chceme získať viac informácií? Napríklad text poznámky?

Našťastie, prvý parameter parent reprezentuje objekt, ktorého dáta sú ťahané z adaptéra... a hádate úplne správne, takým objektom je GridView. Z rodiča vieme získať vybranú položku veľmi jednoducho: cez metódu getItemAtPosition(). Pošleme do nej parameter position, ktorý reprezentuje poradie položky od začiatku dát a získame Object. Keďže mriežka neťahá dáta z hocijakého adaptéra, ale presne z kurzorového adaptéra, výsledok môžeme pretypovať na Cursor, ktorý bude dokonca nastavený presne na riadok zodpovedajúci vybratej položke. (Pamätáte si, kurzor reprezentuje nielen „fotku dát“, ale aj šípku smerujúcu na konkrétny záznam.)

Cursor selectedNoteCursor = (Cursor) parent.getItemAtPosition(position);

Ako vidno, z kurzora môžeme potom vytiahnuť dodatočné dáta, napríklad popis.

int descriptionColumnIndex = selectedNoteCursor.getColumnIndex(Provider.Note.DESCRIPTION);
String noteDescription = selectedNoteCursor.getString(descriptionColumnIndex);

Ďalší dialóg pre potvrdenie

V metóde vybudujeme potvrdzovací dialóg podobným spôsobom ako pri vkladaní. Dokonca bude ešte jednoduchší, keďže nepotrebujeme špeciálne „vnútro“: vystačíme si totiž so statickou správou nastavenou cez setMessage().

Po kliknutí na tlačidlo Delete jednoducho zavoláme pomocnú metódu, ktorou zmažeme položku z content providera.

@Override
public void onItemClick(AdapterView<?> parent, View view, int position, final long id) {
    Cursor selectedNoteCursor = (Cursor) parent.getItemAtPosition(position);
    int descriptionColumnIndex = selectedNoteCursor.getColumnIndex(Provider.Note.DESCRIPTION);
    String noteDescription = selectedNoteCursor.getString(descriptionColumnIndex);

    new AlertDialog.Builder(this)
            .setMessage(noteDescription)
            .setTitle("Note")
            .setPositiveButton("Delete", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    deleteNote(id);
                }
            })
            .setNegativeButton("Close", DISMISS_ACTION)
            .show();
}

Odstraňovanie z content providera

Mazanie položiek je veľmi podobné kódu z vkladania. Najprv vybudujeme Uri adresu pre záznam, ktorý budeme mazať.

    Uri selectedNoteUri = ContentUris.withAppendedId(NoteContentProvider.CONTENT_URI, id);

Následne pripravíme AsyncQueryHandler a obslúžime jeho metódu onDeleteComplete, a to jednoduchým toastom, v ktorom oznámime používateľovi, že položka je zmazaná.

V poslednom kroku naštartujeme mazanie cez startDelete, kde pošleme opäť:

  • kód operácie (definovaný našou konštantou),
  • cookie, ktorú nepoužijeme,
  • Uri lokáciu záznamu na mazanie,
  • prázdnu selekciu, pretože o tú sa postará content provider,
  • prázdne parametre selekcie, pretože selekciu nepoužívame

Výsledná metóda vyzerá nasledovne:

private void deleteNote(long id) {
    AsyncQueryHandler deleteHandler = new AsyncQueryHandler(getContentResolver()) {
        @Override
        protected void onDeleteComplete(int token, Object cookie, int result) {
            Toast.makeText(MainActivity.this, "Note was deleted", Toast.LENGTH_SHORT)
                    .show();
        }
    };
    Uri selectedNoteUri = ContentUris.withAppendedId(NoteContentProvider.CONTENT_URI, id);
    deleteHandler.startDelete(DELETE_NOTE_TOKEN, NO_COOKIE, selectedNoteUri,
            NO_SELECTION, NO_SELECTION_ARGS);
}

Záverečný sumár

To je na dnes všetko! Aplikácie beží a dokážeme v nej prezerať, vkladať a mazať záznamy, a to všetko s ukladaním dát do databázy!

Finálna aplikácia

Výsledná aplikácia je na GitHube.

Bonus 2: Content Provider: čo to je za záznam? metóda getType()

Metóda getType() slúži na bližšie informácie o type dát, ktoré sa vracajú z content providera pre danú Uri. Pre content providery, ktoré nie sú exportované, môžeme pokojne vrátiť null hodnotu, ale význam tejto metódy rastie pri spolupráci aplikácií (napr. pri zdieľaní).

Výsledkom metódy je reťazec, tzv. MIME typ, podľa nasledovných odporúčaní:

  • ak dopyt na danú Uri adresu vráti presne jeden záznam, výsledok musí byť v tvare:

    vnd.android.cursor.item/vnd.[autorita].[ďalšie dáta]
    
  • ak dopyt na danú Uri adresu vráti množinu záznamov (prázdnu, jeden alebo viacero), výsledok musí byť v tvare:

    vnd.android.cursor.dir/vnd.[autorita].[ďalšie dáta]
    

My si definujeme dve konštanty:

// vnd.android.cursor.dir/vnd.sk.upjs.ics.android.jot.provider.NoteContentProvider.note
private static final String MIME_TYPE_NOTES = ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd." + AUTHORITY + "." + Note.TABLE_NAME;

// vnd.android.cursor.item/vnd.sk.upjs.ics.android.jot.provider.NoteContentProvider.note
private static final String MIME_TYPE_SINGLE_NOTE = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd." + AUTHORITY + "." + Note.TABLE_NAME;

Metóda potom prebehne s využitím UriMatchera:

@Override
public String getType(Uri uri) {
    switch(uriMatcher.match(uri)) {
        case URI_MATCH_NOTE_BY_ID:
            return MIME_TYPE_SINGLE_NOTE;
        case URI_MATCH_NOTES:
            return MIME_TYPE_NOTES;
    }
    return NO_TYPE;
}

Bonus 3: Čo sa v skutočnosti deje pri notifikácii zmeny dát?

Ako vlastne funguje notifikácia po zmene dát v metóde insert(), či delete() v content providerovi?

Ak si spomínate, tak pri zmene dát sme volali:

getContext().getContentResolver().notifyChange(CONTENT_URI, NO_CONTENT_OBSERVER);

a naopak, pri dopytovaní v query()

cursor.setNotificationUri(getContext().getContentResolver(), uri);

Aká čierna mágia spôsobí automatickú aktualizáciu dát vo widgetoch?

Content Resolver: sekretariát content providera

Viackrát sme spomínali, že content resolver je sekretárkou content providerov v aplikácii. Je to preto táto trieda, ktorá zbiera a odosiela oznámenia o zmene dát.

V momente, keď získame kurzor z metódy query() databázového objektu SQLiteDatabase, zaregistrujeme ho ako poslucháča v content provideri.

To však nie je všetko! Keďže aktivita využíva na asynchrónne dopytovanie mechanizmus loaderov, ihneď potom čo CursorLoader získa z content providera (cez content resolver, prirodzene) kurzor, zaregistruje sa na ňom tak, by vedel tiež obdivovať na ňom zmeny.

Máme teda dva vzťahy poslucháčov/obdivovateľov:

  • kurzor obdivuje content resolver
  • a loader obdivuje kurzor.

Ak v metóde insert() na providerovi vyvoláme zmenu cez notifyChange(), spustíme lavínu notifikácií:

  1. Cez notifyChange() sa upozorní content resolver.
  2. Content resolver upozorní zaregistrovaný kurzor na zmenu.
  3. Kurzor upozorní loader, že nastala zmena.
  4. Loader znovunačíta dáta z databázy a získa tak nový kurzor. Prípadný starý kurzor (s predošlými, už neplatnými dátami) je korektne uzavretý.
  5. Tento nový kurzor sa objaví v onLoadFinished() v objekte implementujúcom interfejs LoaderCallbacks.
  6. Tradične je týmto objektom aktivita, ktorá vezme kurzor s novými dátami, a použije ho v kurzorovom adaptéri ako parameter metódy swapCursor().
  7. Kurzorový adaptér je úzko prepojený s widgetom, pre ktorý poskytuje dáta a v prípade zmeny kurzora upozorní widget na zmenu dát a tým vynúti jeho prekreslenie.

Cursor#setNotificationUri(ContentResolver, Uri)

Na ContentResolveri z parametra registruje nového poslucháča sledujúceho zmeny na Uri (cez ContentResolver#registerContentObserver): bude ním SelfContentObserver, ktorý po zmene zavolá metódu Cursor#onChange(false).

CursorLoader

Po vzniku inštancie strčí do premennej mObserver objekt ForceLoadContentObserver. Keď načítava dáta na pozadí v AsyncTaskLoader#loadInBackground() a získa kurzor, zaregistruje na ňom ForceLoadContentObserver:

cursor.registerContentObserver(/*ForceLoadContentObserver*/ mObserver)

Vo vnútri kurzora sa tento ForceLoadContentObserver dostane do zoznamu obdivovateľov kurzora reprezentovaných premennou mContentObservable (typu ContentObservable).

Zmena dát v content providerovi

Zmena dát v content providerovi vyvolá skrz content resolvera zmenu na kurzorovom SelfContentResolveri, ktorý vyvolá metódu Cursor#onChange(false).

AbstractCursor#onChange

Na zozname obdivovateľov kurzora mContentObservable sa vyvolá zmena:

mContentObservable.dispatchChange(false, null);
  • Prejde sa zoznam poslucháčov, medzi ktorými bude ForceLoadContentObserver.
  • na ňom sa zavolá .dispatchChange(false, null).
  • toto volanie povedie vyvolá ForceLoadContentObserver#onChange()
  • a to zase povedie k volaniu onContentChanged() na CursorLoaderi (viď zdrojáky ForceLoadContentObservera)
  • a toto zase vyvolá forceLoad() na Loaderi
  • a konečne k onForceLoad() na AsyncTaskLoader-i, t. j. na CursorLoaderi.