Aplikácia predstavuje hru, kde bude používateľovi ponúknutý zoznam zopár mien a jeho úlohou je zoradiť mená podľa počtu výskytov daného krstného mena v meste Prešov (lebo máme dostupný dataset s týmito údajmi).
Dataset mien je dostupný:
Hlavnou myšlienkou je použiť veci, ktoré sme už videli v predošlej aplikácii, ale aplikované rozumnejšie (iba veci potrebné finálnemu výsledku).
Z pohľadu Androidu a Kotlinu si precvičíme:
Začíname s Basic Views Activity
. Dostaneme viac kódu ako pri predošlých aplikáciach, ktoré používali Empty Views Activity
.
Z vygenerovanej aplikácie odstránime veci týkajúce sa navigation a fragmentov (viď commit v gite).
Pozrieme si nasledovný kód:
include
a rozdelený layout do dvoch xml
súborov)floating action button
- aj v xml
aj v aktivitemenu
- kód v aktivite aj v resourcesPostačuje nakopírovať nižšie uvedené dependencies. V prípade, že niektorá z nich je označená žltou farbou - je indikované, že existuje novšia verzia, je možné použiť aj novšiu verziu.
plugins {
...
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
id("com.google.dagger.hilt.android") version "2.51" apply false
}
plugins {
...
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.51")
ksp("com.google.dagger:hilt-android-compiler:2.51")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
annotationProcessor("androidx.room:room-compiler:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
implementation("androidx.activity:activity-ktx:1.8.2")
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
...
}
Entity
, Dao
a Database
sú tri potrebné komponenty pre prácu s databázou ak využívame knižnicu Room
. V Kotline nie je striktná väzba medzi triedou a súborom, preto môžeme tieto tri súvisiace triedy (resp. rozhranie) vložiť do jedného súboru.
Rozdiel voči predošlej aplikácii je v tom, že abstraktnú triedu Database
iba deklarujeme a nevytvárame inštanciu (možeme si pozrieť ako sa to robilo pomocou database buildera, s využitím návrhoveho vzoru singleton a thread-safe implementáciou s použitím synchronized a volatile).
Repository
bude zatiaľ poskytovať manuálne naplnenie databázy konkrétnymi hodnotami. Toto bude neskôr prerobené, aby dáta prichádzali z internetu (rest api).
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "record_table")
data class Record(
@PrimaryKey
val name: String,
val count: Int
)
@Dao
interface NamesDao {
@Query("SELECT * FROM record_table")
fun readRecords(): Flow<List<Record>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(records: List<Record>)
@Query("DELETE FROM record_table")
suspend fun deleteAll()
}
@Database(entities = [Record::class], version = 1, exportSchema = false)
abstract class NamesDatabase : RoomDatabase() {
abstract fun namesDao(): NamesDao
}
class NamesRepository(private val dao: NamesDao) {
val names = dao.readRecords()
suspend fun storeNames() {
dao.deleteAll()
val defaultNames = listOf(
Record("Maria", 1000),
Record("Jozef", 500),
Record("Milan", 200),
Record("Linda", 50),
)
dao.insert(defaultNames)
}
}
Na začiatok prepojíme databázu a aktivitu pomocou ViewModel
triedy. Všetky doteraz vytvorené triedy neobsahujú žiaden kód, ktorý by vytváral prepojenia medzi nimi. Napr. NamesViewModel
pracuje s referenciou na NamesRepository
, ale nikde v kóde sa táto referencia nepriradzuje, resp. nikde sa nevytvárajú nové objekty príslušných tried.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class NamesViewModel(private val repository: NamesRepository) : ViewModel() {
fun storeNames() {
viewModelScope.launch {
repository.storeNames()
}
}
}
class MainActivity : AppCompatActivity() {
...
private val viewModel: NamesViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
binding.fab.setOnClickListener {
viewModel.storeNames()
}
}
Cannot create an instance of class sk.upjs.opendatagame.NamesViewModel
- po spustení vidíme, že aplikácia nefunguje. Zatiaľ sme vytvorili všetky potrebné komponenty, ale neurobili sme prepojenie medzi nimi.
Aktivity
má referenciu na ViewModel
, avšak aktivita si tento objekt - ViewModel nevytvára samostatne. ViewModel má dlhšiu životnosť ako aktivita a je to preferovaný spôsob, kde si aktivita (resp. UI komponent) uchováva svoje dáta.
Android má k dispozícii triedu ViewModelProvider
, pomocou ktorej vie aktivita získať referenciu na ViewModel. Použijeme ale konštrukciu by viewModels()
, ktorú máme k dispozícii vďaka dependency (androidx.activity:activity-ktx
). Použitie slova by
označuje delegáta - getter/setter (nazývané accessor methods) sú delegované na niekoho iného. V tomto prípade je implementácia urobená lazy prístupom - teda pri prvom prístupe sa získa referencia na daný objekt, pri každom ďalšom sa odovzdáva cachovaná verzia (môžete si pozrieť kód).
Odporúčam si pozrieť definíciu, čo je ViewModel a ako s ním pracovať (v dokumentácii sú aj best practices).
Dependency injection je princíp, ktorý je založený na tom, že objekty alebo funkcie, ktoré potrebujú iné objekty/funkcie si ich nevytvárajú samostatne, ale ich dostanú. Oddeľuje sa vytváranie objektu a jeho používanie.
V princípe objekt (funkcia), ktorý používa nejakú službu nemá vedomosť o spôsobe ako táto služba vzniká, iba s ňou pracuje. V tejto aplikácii je aktuálne implementované používanie takýchto služieb (napr. ViewModel
využíva Repository
a referenciu získava v konštruktore).
Výhodou tohto prístupu je, že vzniknutý kód je lepšie testovateľný a udržateľný, je vhodný na opakované použitie a často je aj stručnejší, lebo sa redukuje zbytočný kód.
Medzi nevýhody patrí závislosť na zvolenom frameworku a náročnejšie sledovanie správania aplikácie.
Knižnice, ktoré poskytujú dependency injection sa delia na dva typy:
Hilt je odporúčaná knižnica na dependency injection v Androide. Je postavená na knižnici Dagger.
Dagger je knižnica pre Javu, Kotlin (a Android), ktorá poskytuje dependency injection takým spôsobom, že vytvára a spravuje graf závislostí. Poskytuje statické závislosti v čase kompilácie.
Viac o dependency injection v kontexte Androidu:
Pri používaní dependency injection pomocou knižnice Hilt
je potrebné jednotlivé komponenty aplikácie adresovať príslušnými anotáciami. Všimnime si najprv hotový kód:
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class NamesApplication : Application()
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application
android:name=".NamesApplication"
...>
...
</application>
</manifest>
...
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
}
...
import javax.inject.Inject
class NamesRepository @Inject constructor(private val dao: NamesDao) {
...
}
...
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class NamesViewModel @Inject constructor(private val repository: NamesRepository) : ViewModel() {
...
}
Komentár k jednotlivým anotáciam:
@HiltAndroidApp
- aplikácia využívajúca Hilt vyžaduje triedu rozširujúcu Application
. Túto triedu je potrebné označiť aj v manifeste. Na aplikáciu sa použije anotácia @HiltAndroidApp
@AndroidEntryPoint
označuje miesto, kde môže byť dodaná závislosť. V tomto prípade ide o aktivitu, ale dá sa to aplikovať aj na iné komponenty (service, fragment)@HiltViewModel
označí príslušný ViewModel
.@Inject
- označuje miesto, kde prichádza k dodaniu závislosti namiesto vytvárania objektu. V tomto prípade je príslušná referencia získaná v konštruktore, preto je anotácia @Inject
použitá práve v hlavičke triedy. Kotlin vyžaduje pri použití @Inject
, aby nasledovalo kľúčové slovo constructor
. V princípe sa slovo constructor
dá použiť vždy a v prípade, že konštruktor nemá žiadnu anotáciu alebo iný modifikátor, tak toto kľúčové slovo môže byť vynechané (tak sme to robili doteraz vždy).Najprv si pozrime kód. Spôsob vytvárania inštancie databázy pomocou Room.databaseBuilder
sme videli v predošlej aplikácii. Z pohľadu kódu by tam nemalo byť nič nové, dôležité pre nás sú tentokrát iba anotácie pridané z knižnice Hilt
.
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {
@Provides
fun provideNamesDao(database: NamesDatabase): NamesDao {
return database.namesDao()
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext appContext: Context): NamesDatabase {
return Room.databaseBuilder(
appContext,
NamesDatabase::class.java,
"names_database"
).build()
}
}
Komentáre k tomuto kódu:
@Module
- implementácia DAO prichádza z externej knižnice Room
, preto nemôže byť vložená cez @Inject constructor
(vytvára sa iba interface pre DAO, nie implementácia triedy). @Module
je v princípe trieda, kde sú definované nejaké závislosti.@InstallIn
- anotácia pomocou ktorej sa určí, kde sa bude zadaný modul nachádzať z pohľadu hierarchie komponentov (viď schéma). V tomto prípade budeme vytvárať databázu a dao, čo sa netýka iba aktivity alebo view modelu, preto sa databázový modul bude inštalovať do koreňového komponentu SingletonComponent
.@Provides
- vloženie závislosti cez konštruktor nie je možné ak trieda prichádza z externej knižnice (prípad funkcie provideNamesDao
) alebo je potrebné použiť builder na vytvorenie inštancie (druhý prípad s funkciou provideDatabase
). Funkcia označená anotáciou @Provides
určuje, ako bude príslušná závislosť dodaná. Vstupné parametre funkcie označujú potrebné závislosti, výstupný typ označuje výsledok (teda čo bude funkcia poskytovať) a telo funkcie spôsob ako sa daná závislosť získa.@ApplicationContext
- Hilt je nadstavbou nad knižnicou Dagger a zjednodušuje prácu s touto knižnicou v kontexte Android aplikácie. Jedna z vecí používaná v Androide je context
(v našom prípade bol doteraz context rovný aktivite). Context je potrebný napr. ak chceme vytvárať observer na livedata. Hilt ponúka prednastavené kvalifikátory @ApplicationContext
a @ActivityContext
. V tomto prípade je pri vytváraní inštancie databázy potrebný kontext aplikácie.@Singleton
- celý modul je inštalovaný v SingletonComponent
, čo znamená že príslušné funkcie poskytujúce nejaké závislosti sú dostupné v rámci daného komponentu. Samotná anotácia @Singleton
na konkrétnej funkcii znamená, že daná funkcia poskytuje závislosť ako singleton - iba 1x a nevytvára duplicity.Aplikácie v Androide sú tvorené podľa MVVM vzoru (model-view-viewmodel). V našom prípade aktivita=view poskytuje používateľské rozhranie. Repository zastupuje model a poskytuje prístup k dátam. Viewmodel je vrstva medzi nimi. Na jednej strane je zviazaná s používateľským rozhraním. Aj keď neobsahuje referenciu na view, je s ňou prepojená a poskytuje aktualizáciu dát. Na druhej strane je prepojená s modelom a reprezentuje aktuálny stav dát.
Retrofit
a Moshi
stiahne a parsuje JSON
súbor, ktorý je transformovaný na objekty príslušnej triedy (entity). Tieto údaje sú následne uložené pomocou knižnice Room
do lokálnej databázyflow
) a následne sú dostupné vo forme livedata
. Aktivita sa prihlási na sledovanie zmien v dátach. Akákoľvek zmena v databázovej tabuľke je nasledovaná príslušnou reakciou v aktivite. V tomto prípade sa iba uloží aktuálna verzia údajov.JSON súbor s menami, ktoré chceme použiť v aplikácii je dostupný na tomto odkaze. Aby sme si ale ukázali prístup k REST API, lokálna kópia sa nachádza tu: https://ics.upjs.sk/~opiela/rest/names.
Na prácu s REST API použijeme knižnicu Retrofit, ktorá je type-safe HTTP klient pre Javu a Android. Na parsovanie JSON objektov a polí na Java/Kotlin triedy použijeme knižnicu Moshi. Existujú aj iné knižnice na parsovanie JSON (viď stránka o retrofite).
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
private const val BASE_URL = "https://ics.upjs.sk/~opiela/rest/"
private val MOSHI = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val RETROFIT = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(MOSHI))
.build()
interface NamesRestDao {
@GET("names")
suspend fun getNames(): List<Record>
}
object RestApi {
val namesRestDao: NamesRestDao by lazy {
RETROFIT.create(NamesRestDao::class.java)
}
}
Vyššie napísaný kód vytvára všetko potrebné na prácu s REST API. Pri použití rovnakých knižníc na iný REST server je potrebné zmeniť premennú BASE_URL
a dopísať funkcie v rozhraní NamesRestDao
. Tieto funkcie sa budú podobať na DAO funkcie, ktoré sú robené pre databázu. Avšak musia byť označené príslušnými anotáciami z knižnice Retrofit zodpovedajúce HTTP volaniam (get, post, put, ...).
...
import android.util.Log
class NamesRepository @Inject constructor(private val dao: NamesDao) {
...
suspend fun storeNames() {
dao.deleteAll()
try {
val records = RestApi.namesRestDao.getNames()
dao.insert(records)
} catch (e: Exception) {
Log.e("REST", e.toString())
}
}
}
Repository teraz realizuje požiadavku na stiahnutie údajov z REST API a vloženie spracovaných údajov do databázy.
...
import com.squareup.moshi.Json
@Entity(tableName = "record_table")
data class Record(
@PrimaryKey
@Json(name = "Krstné_meno")
val name: String,
@Json(name = "Počet")
val count: Int
)
Knižnica Moshi vykonáva parsovanie JSON súboru. V našom prípade sa JSON array
transformuje na list
objektov. Jeden JSON object
reprezentujúci konkrétne meno sa transformuje na objekt triedy Record
. Nezhoda medzi názvami premenných v triedeRecord
a názvami atribútov v JSON object
sa dá vyriešiť použitím anotácie @Json
.
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<uses-permission android:name="android.permission.INTERNET"/>
<application ...>
...
</application>
</manifest>
Na vykonávanie HTTP volaní je potrebné deklarovať povolenie android.permission.INTERNET
. Toto povolenie nie je kritické a preto postačuje jeho zaznamenanie do manifestu. Prácu s inými povoleniami (dangerous), ktoré vyžadujú súhlas používateľa, si ukážeme v ďalšej aplikácii.
Tento krok priamo nesúvisí s aplikáciou, ktorú tvoríme, ale časť z toho využijeme. Pozrime sa na kód v jave, kde sa vytvára mapa a postupne sa vypisuje jej obsah.
import java.util.HashMap;
import java.util.Map;
public class Demo {
public static void main(String[] args) {
Map<String, Integer> mapa = new HashMap<>();
mapa.put("Peter", 2000);
mapa.put("Maria", 100);
mapa.put("Juraj", 20);
for (String meno : mapa.keySet()) {
System.out.println("Meno " + meno + " je " + mapa.get(meno) + " krat.");
}
}
}
fun main() {
val mapa = hashMapOf(
"Peter" to 2000,
"Maria" to 100
)
mapa["Juraj"] = 20
for (meno in mapa.keys) {
println("Meno $meno je ${mapa[meno]} krat.")
}
}
V kotline sú niektoré veci elegantnejšie:
hashMapOf
s použitím syntaxe key to
value.[]
podobne ako pri politoString()
a vložia sa do výsledného reťazca. Označujú sa $
a v prípade, že obsahujú viac častí, môžu byť zaobalené do ${}
.Do layoutu pre aktivitu pridáme widget RecyclerView
. Aby bolo možné pristúpiť k tomuto widgetu pomocou view binding
, je nutné, aby aj include
, ktorý zahŕňa content_main
bol označený svojim id
.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_names"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
...
<include
android:id="@+id/included"
layout="@layout/content_main" />
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Doteraz sme využívali predpripravený xml
layout pre jednu položku, kde bol iba jediný textView
. Tentokrát si vytvoríme vlastný layout. Nižšie je nákres ako sú jednotlivé prvky usporiadané s využitím ConstraintLayout
. Všimnime si kód pre tento layout, kde je šírka CardView
nastavená na match_parent
, avšak zahrnutý je margin
- aj na strane CardView
, aj vo vnútri na strane jednotlivých widgetov. Výška je wrap_content
, čo zabezpečí, že sa použije iba toľko miesta, koľko je potrebné. Ak by výška bola nastavená na match_parent
, tak by v recyclerView
bol viditeľný iba jeden item
. Ďalšie položky by sa zobrazili až po scrollovaní.
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="5dp"
android:layout_marginEnd="10dp"
android:layout_marginBottom="5dp"
android:background="@android:color/white"
android:elevation="5dp"
app:contentPadding="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fadingEdge="vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/textViewName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textViewNumber"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/reorderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/reorderIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="reorder"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@android:drawable/ic_menu_sort_by_size" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
Adaptér poskytuje dáta, ktoré sú zobrazované v RecyclerView
. Doteraz sme rozšírovali triedu Adapter
, kde boli prekryté 3 funkcie:
onCreateViewHolder
onBindViewHolder
getItemCount
V tejto aplikácii rozšírime triedu ListAdapter
. Rozdiel je ten, že zoznam uchovávajúci hodnoty je interne schovaný v rodičovskej triede. Nepotrebujeme prekrývať tretiu funkciu, ktorá vracia počet položiek v zozname. Avšak je potrebné rozšíriť abstraktnú triedu ItemCallback
, kde sa prekrývajú dve funkcie areItemsTheSame
a areContentsTheSame
. Tieto funkcie slúžia na to, aby RecyclerView
vedel rozoznať, či sa zmenila nejaká položka a či je potrebné ju prekresliť.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import sk.upjs.opendatagame.databinding.ItemLayoutBinding
class NamesAdapter : ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {
class NamesViewHolder(val binding: ItemLayoutBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(record: Record) {
binding.textViewName.text = record.name
"Count ${record.count}".also {
binding.textViewNumber.text = it
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NamesViewHolder {
return ItemLayoutBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
.let { NamesViewHolder(it) }
}
override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
holder.bind(getItem(position))
}
object DiffCallback : DiffUtil.ItemCallback<Record>() {
// ak obe polozky equals
// ak dva objekty reprezentuju rovnaku polozku
override fun areItemsTheSame(oldItem: Record, newItem: Record) = oldItem == newItem
// ak dva objekty maju rovnaky obsah
override fun areContentsTheSame(oldItem: Record, newItem: Record) = oldItem == newItem
}
}
V predošlom kóde si môžeme všimnúť použitie scope
funkcií:
let
- vo funkcii onCreateViewHolder
. Namiesto uloženia výsledku po zavolaní funkcie inflate
sa na tento výsledok aplikuje funkcia let
.also
- v triede ViewHolder
. Kód by vedel byť zapísaný aj spôsobom: binding.textViewNumber.text = "Count ${record.count}"
. Použitie funkcie also
vytvára opačný prístup. Najprv sa vytvorí výsledný reťazec a s ním sa následne pracuje ďalej. Vo vnútri scope funkcie vystupuje tento reťazec ako it
....
import androidx.lifecycle.asLiveData
@HiltViewModel
class NamesViewModel @Inject constructor(private val repository: NamesRepository) : ViewModel() {
val allNames = repository.names.asLiveData()
...
}
NamesViewModel
uchováva LiveData
všetkých mien. Z pohľadu ViewModel
nevieme, či berieme dáta z internetu alebo lokálnej databázy.
...
import androidx.recyclerview.widget.LinearLayoutManager
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
private var list: List<Record> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
...
val adapter = NamesAdapter()
binding.included.recyclerViewNames.adapter = adapter
binding.included.recyclerViewNames.layoutManager = LinearLayoutManager(this)
binding.fab.setOnClickListener {
adapter.submitList(getRandomList(4))
}
viewModel.allNames.observe(this) {
list = it
}
}
private fun getRandomList(count: Int): List<Record> {
if (list.isEmpty()) return emptyList()
var shuffledList = list.shuffled()
return shuffledList.subList(0, count)
}
...
}
V hlavnej aktivite je implementované nasledovné:
FloatingActionButton
, aktivita vyberie podzoznam 4 náhodných mien a ten posunie do adaptéra. Teda RecyclerView
vykreslí 4 mená.Vytvoríme novú aktivitu (Settings Views Activity
). Settings aktivita je vytvorená na základe xml
, kde definujeme ake nastavenia chceme použiť. Hodnoty týchto nastavení sa ukladajú do SharedPreferences
- teda do mapy, kde jedinečnému klúču je priradená vybraná hodnota.
Doplníme spustenie aktivity po kliknutí v menu, spustíme aplikáciu a sledujeme vygenerovaný kód:
res/xml/root_preferences.xml
res/values/strings.xml
res/values/arrays.xml
res/layout/settings_activity.xml
SettingsActivity.kt
AndroidManifest.xml
...
import android.content.Intent
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
else -> super.onOptionsItemSelected(item)
}
}
}
Upravený kód v hlavnej aktivite iba spúšťa novú aktivitu s nastaveniami po kliknutí na položku Settings
v menu. Ak ju v aplikácii nevidíme, je potrebné kliknúť na menu vpravo hore (kebab menu - tri bodky nad sebou).
Toolbar/appbar sa nezobrazí v Settings activity
- neriešime to, postupne prechádzame na Jetpack compose
, kde sa s UI pracuje iným spôsobom. Ak by sme ho potrebovali zobraziť, dá sa to nastaviť. V tom prípade odporúčam pozrieť do AndroidManifest.xml
na atribút theme
a kliknutím pozrieť, čo sa za tým skrýva. Ak toolbar nevidíme, tak je možné vrátiť sa do hlavnej aktivity použitím tlačidla späť priamo na telefóne (emulátor má toto tlačidlo v ponuke hore).
Teraz iba modifikujme nastavenia, aby sme si vedeli zvoliť, či zahrnieme do hry aj mená, ktoré majú iba jeden výskyt.
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
app:key="includeSingle"
app:title="Include single occurences" />
</PreferenceScreen>
...
import androidx.preference.PreferenceManager
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
private fun getRandomList(count: Int): List<Record> {
val pref = PreferenceManager.getDefaultSharedPreferences(this)
val include = pref.getBoolean("includeSingle", false)
if (list.isEmpty()) return emptyList()
var shuffledList = list.shuffled()
if (!include) {
shuffledList = shuffledList.filter { it.count > 1 }
}
return shuffledList.subList(0, count)
}
...
}
Hodnotu získavame z mapy SharedPreferences
, kde kľúč je reťazec definovaný v xml
súbore. Aplikovanie pravidla, že neberieme mená s jedným výskytom do úvahy vykonáme pomocou funkcie filter
, ktorú zapíšeme pomocou lambda výrazu (lambda výrazy si vysvetlíme detailnejšie čoskoro). Do zátvoriek {}
vložíme výraz, ktorý je typu boolean
. Hodnotait
zodpovedá jednej položke v zozname. Ak je výraz true
, tak sa príslušná položka použije do výsledného zoznamu. Ak je false
, tak sa ignoruje.
Voliteľne odporúčam vyskúšať si pridať do nastavení možnosť výberu počtu mien, ktoré sa budú zobrazovať.
Menu sme dostali vygenerované pri vytvorení projektu s BasicActivity
. Ak chceme definovať menu položky, potrebujeme:
menu
, kde sú jednotlivé položky označené item
onCreateOptionsMenu
, kde nastavíme príslušný resource (pomocou inflate
) a funkciu onOptionsItemSelected
, kde reagujeme na udalosť výberu niektorej položky v menu používateľom.Nižšie je kód, ktorým pridáme ďalšiu položku, ktorá bude iniciovať stiahnutie zoznamu mien z REST servra.
Jednotlivé položky item
môžu mať priradenú aj ikonu (android:icon
). Zároveň môžeme upraviť nastavenie app:showAsAction
:
never
- položka sa zobrazí ako text v menu. Je potrebné rozkliknúť menu vpravo hore.always
- položka sa zobrazí ako ikonka v hornej lište aplikácie.ifRoom
- ak je ešte miesto v hornej lište, zobrazí sa ako ikonka, inak je schovaná ako text.Pomocou atribútu orderInCategory
vieme položky v menu usporiadať.
<menu ...>
...
<item
android:id="@+id/action_download"
android:title="@string/download_names"
app:showAsAction="never" />
</menu>
...
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
...
R.id.action_download -> {
viewModel.storeNames()
true
}
...
}
}
}
<resources>
...
<string name="download_names">Download names</string>
</resources>
Nasledovné zmeny postačujú, aby bola aplikácia hrateľná.
V hlavnej aktivite sa udeje nasledovné:
ItemTouchHelper
umožňuje reagovať na presúvanie. Volá funkciu exchangeItems
v adaptéri.FloatingActionButton
(zatiaľ s nezmenenou ikonkou) začína a ukončuje hru. V kóde si všimnime použitie podmienky if
priamo vo výraze (val text = if ... else ...
)import android.widget.Toast
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
private lateinit var adapter: NamesAdapter
private var isGameOn = false
private val itemTouchHelper by lazy {
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN
or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
adapter.exchangeItems(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// nothing
}
}
ItemTouchHelper(callback)
}
override fun onCreate(savedInstanceState: Bundle?) {
...
itemTouchHelper.attachToRecyclerView(binding.included.recyclerViewNames)
binding.fab.setOnClickListener {
if (isGameOn) {
val text = if (adapter.win) "Vyhra" else "Prehra"
Toast.makeText(this, text, Toast.LENGTH_LONG).show()
} else {
adapter.submitList(getRandomList(4))
}
adapter.visibleCounts = isGameOn
isGameOn = !isGameOn
}
...
}
...
}
V adaptéri sa udeju nasledovné zmeny:
visibleCounts
. Ak je zmenená, tak sa (v setter metóde) aj upozorní RecyclerView
, že sa zmenili dáta a je potrebné prekresliť vizuál. Táto property sa používa na viacerých miestach v kóde.win
, kde sa počíta, či je hra výherná. Všimnime si použitie zaujímavej funkcie zipWithNext
, ktorá berie postupne všetky nasledujúce dvojice zo zoznamu a aplikuje na nich nejakú funkciu (môžete sa k tejto funkcii vrátiť po preštudovaní ďalších krokov v tomto codelabe o lambda výrazoch).exchangeItems
, ktorá je zavolaná pri presúvaní položiek v hlavnej aktivite....
import android.view.View
import java.util.Collections
class NamesAdapter() :
ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {
var visibleCounts = false
set(value) {
field = value
notifyItemRangeChanged(0, currentList.size)
}
val win: Boolean
get() {
currentList.zipWithNext { a, b -> if (a.count < b.count) return false }
return true
}
class NamesViewHolder(val binding: ItemLayoutBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
record: Record, visibleCounts: Boolean,
) {
...
binding.textViewNumber.visibility = if (visibleCounts) View.VISIBLE else View.INVISIBLE
}
}
fun exchangeItems(from: Int, to: Int) {
val list = currentList.toMutableList()
Collections.swap(list, from, to)
submitList(list)
}
...
override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
holder.bind(getItem(position), visibleCounts)
}
...
}
Higher-order functions and lambdas - dokumentácia ku Kotlinu
Zopár citácii:
Kotlin functions are first-class, which means they can be stored in variables and data structures, and can be passed as arguments to and returned from other higher-order functions. You can perform any operations on functions that are possible for other non-function values.
A higher-order function is a function that takes functions as parameters, or returns a function.
Lambda expressions and anonymous functions are function literals. Function literals are functions that are not declared but are passed immediately as an expression.
Prezrime si nasledovný kód v Jave:
public class Trieda {
public int funkcia(String s, int a) {
// priradenie do premennej a
a = a + 1;
// použitie hodnoty premennej a
if (a < 0) {
return 0;
}
// volanie metódy nad objektom referencovanom premennou s
return s.charAt(a);
}
public void main(String[] args) {
Trieda objekt = new Trieda();
// literál - konkrétna hodnota
String s = "Android";
// parametre - referenčná premenná s, literál (hodnota) 4
int vysledok = objekt.funkcia(s, 4)
}
}
Zopár komentárov, ktoré by mali byť zrejmé:
funkcia
má 2 vstupné parametre (String
a int
) a jeden výstup typu int
funkcia
môžeme používať premenné s
a a
- priradiť do týchto premenných inú hodnotu, používať hodnotu týchto premenných (zvlášť pri primitívnych typoch) a volať metódy nad objektom v prípade referenčnej premennejfunkcia
k dispozícii this
- konkrétnu inštanciu danej triedy. Ak by táto trieda mala predpísané aj inštančné premenné, tak funkcia by mala prístup k hodnotám týchto premenných.main
volá funkciu funkcia
. Ako parametre používa konkrétne hodnoty (literály) zapísané v kóde.Predstavme si, že by Java povoľovala vložiť do premennej aj funkciu:
// tento kód sa nedá kompilovať, slúži len ako ilustrácia vysvetľovaného princípu
public class Trieda {
public int funkcia(String s, int a) {
... // kód sa nemení
}
public int novaFunkcia(Funkcia fun, String s) {
int vysledok = fun(s, 1);
return vysledok;
}
public void main(String[] args) {
Trieda objekt = new Trieda();
int vysledok = objekt.novaFunkcia(objekt.funkcia, "Android")
}
}
Vyššie uvedený kód obsahuje nasledovné:
novaFunkcia
má dva parametre - jeden z nich je typu Funkcia
.novaFunkcia
je možné využiť premennú fun
rôznymi spôsobmi - buď do premennej priradíme nejakú inú hodnotu alebo môžeme funkciu fun
zavolať (spustiť).main
vkladáme do funkcie literál reťazca a referenciu na funkciu (toto sa samozrejme v Jave nedá urobiť, len ilustrujeme túto myšlienku v kóde).Upravme ešte predošlý kód nasledovne:
// tento kód sa nedá kompilovať, slúži len ako ilustrácia vysvetľovaného princípu
public class Trieda {
public int novaFunkcia((String, int)->(int) fun, String s) {
int vysledok = fun(s, 1);
return vysledok;
}
public void main(String[] args) {
Trieda objekt = new Trieda();
int vysledok = objekt.novaFunkcia({s, a -> s.charAt(a);}, "Android")
}
}
Takýto kód sa v Jave síce urobiť nedá, ale obsahuje nasledovné:
Funkcia
ako typ bola vo funkcii novaFunkcia
nahradená v podstate hlavičkou funkcie. Zápis hovorí, že funkcia referencovana premennou fun
má na vstupe dve hodnoty String
a int
a vráti to int
. S touto funkciou fun
vieme pracovať vo vnútri metódy a to tak, že ju vieme zavolať.main
dávame konkrétny literál funkcie = lambda výraz. V princípe tam dopĺňame telo funkcie. V tomto prípade ak príde nejaký String s
a int a
, tak funkcia má urobiť s.charAt(a)
.Všimnime si zápis funkcie, ktorá príde ako parameter. Funkcie vyššieho rádu môžu prijať funkciu ako parameter alebo môžu vrátiť funkciu.
fun novaFunkcia(s: String, fun: (String, Int) -> Int): Int
fun filter(predicate: (T) -> Boolean): List<T>
Lambda výraz je literálom funkcie. Sú vložené ako výraz.
1.| objekt.novaFunkcia("Android"){s, a -> s.charAt(a)}
2.| objekt.novaFunkcia("Android", ::funkcia)
3.| list.filter{it.length > 5}
{}
mimo zátvoriek ()
, teda akoby za volanie funkcie::
. Nazýva sa to reflection.it
.Presúvanie položiek je náročné, lebo na to aby sa s položkou dalo hýbať, je potrebné držať na nej prst relatívne dlho. Je to nepraktické. V nasledovnej zmene urobíme to, že keď sa klikne na obrázok (ikonu) na položke, tak sa začne presúvanie tejto položky. Využije sa funkcia startDrag
, ktorú ponúka itemTouchHelper
.
Prezrime si nasledovný kód, v ktorom nájdeme použitie funkcie vyššieho rádu a lambda výrazu. Zaregistrovať, či sa kliklo na ikonku vieme v triede NamesViewHolder
, ale volania funkcií ItemTouchHelper
je možné v hlavnej aktivite. V predošlej aplikácii sme tento mechanizmus implementovali pomocou pomocného rozhrania. Teraz využijeme funkcie vyššieho rádu a lambda výrazy.
V triede NamesAdapter
a NamesViewHolder
si všímame typ premennej onClick
a volanie tejto funkcie.
V triede MainActivity
si všímajme lambda výraz, ktorý hovorí, čo sa má po kliknutí vykonať.
...
import android.view.MotionEvent
class NamesAdapter(val onClick: (RecyclerView.ViewHolder) -> Unit) :
ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {
...
class NamesViewHolder(val binding: ItemLayoutBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(
record: Record, visibleCounts: Boolean,
onClick: (RecyclerView.ViewHolder) -> Unit
) {
...
binding.reorderIcon.setOnTouchListener { _, motionEvent ->
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN)
onClick(this)
return@setOnTouchListener true
}
}
}
...
override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
holder.bind(getItem(position), visibleCounts, onClick)
}
...
}
...
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
adapter = NamesAdapter {
itemTouchHelper.startDrag(it)
}
...
}
...
}