Java a Kotlin dokážu fungovať pri sebe. Teda z jedného jazyka je možné volať metódy vytvorené v tom druhom jazyku. Viac technických detailov sa dá nájsť v dokumentácii.
Môžete si pozrieť stručné porovnanie, ktoré veci z Javy chýbajú v Kotline. Nie je ich veľa - napr. kontrolované výnimky, primitívne typy a pod. Opačne je toho viac, čo Java nemá a Kotlin áno.
Vytvorme si teda nový projekt v Android Studio - rovnakým spôsobom ako predošlý (s jednou Empty Views Activity). Jediný rozdiel je, že si zvolíme jazyk Kotlin.
Manifest a resources nezávisia od toho, či je aplikácia v Jave alebo Kotline. Manifest sme v predošlej aplikácii nemodifikovali, čiže je potrebné iba skopírovať všetky resources:
/res/drawable-v24/gallows1.png (a všetky ostatné)
/res/layout/activity_main.xml
/res/values/strings.xml
Ak by ste si chceli vyskúšať Kotlin mimo Androidu, ponúkajú interaktívny nástroj na zoznámenie sa s jazykom: Kotlin Koans.
V tejto aplikácii nepokrývame to najdôležitejšie z Kotlinu, ale to, čo si vyžiada aplikácia. Nie všetky veci sú používané rovnako často a majú rovnakú dôležitosť, ale to si všimnete až po niekoľkých naprogramovaných aplikáciach.
Mimochodom v Kotline nepíšeme bodkočiarky.
Preskúmajme kód, ktorý dostaneme v MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
Aj v Kotline vznikajú triedy rozširovaním iných.
Ak sa nič nenapíše, tak:
Object.Any - je to podobná trieda ako Object, ale má iba metódy equals, hashCode, toString.Pre ďalšie pochopenie by sme potrebovali vedieť ako funguje kompilácia Kotlin kódu, null checks a iné veci. Teraz to nie je podstatné.
V kotline rozširovanie tried a implementovanie rozhraní zapisujeme cez : namiesto extends a implements. Ak názov za dvojbodkou má zátvorky ide o rozšírenie triedy MainActivity : AppCompatActivity(). Neskôr uvidíme HangmanGame: Game, čo znamená implementovanie rozhrania.
Potrebný keyword fun. V Kotline sa zvykne hovoriť nie o metódach ale o funkciách. Funkcia je všeobecnejší pojem ako metóda (viac v tomto blogu).
override je keyword, nie iba anotácia. Teda môže byť funkcia override fun toString(): String {}.
Návratový typ - z predošlého príkladu je zrejmé, že sa píše za názvom metódy (nie pred ako v jave). Nájdeme ho za znakom :. Ak sa nenapíše nič fun funkcia() (obdoba void v Jave), tak je tam defaultne typ Unit. V ňom je ukrytý iba objekt Unit - teda v zásade to na nič nie je a nič to nerobí, ale aspoň sa to dá použiť ako argument pri generikách. Možno si spomeniete na typ Void v Jave.
Premenné - zapisujú sa vo formáte názov: Typ. Okrem toho platia nasledovné veci (časť vecí možno poznáte z pythonu):
Int, Char, Long, Boolean, Float, Double, Byte, Shortfun mocnina(zaklad: Int, exponent: Int = 2): Long. Túto funkciu môžeme volať mocnina(5, 3), mocnina(4, 8) ale namiesto mocnina(7, 2) môžeme zapísať mocnina(7).mocnina(zaklad = 8, exponent=3).Stále pozeráme kód, ktorý dostaneme v MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
V Kotline sa stretneme s výnimkou NullPointerException výrazne menej často. Navyše je to viac pod kontrolou bez nutnosti všade dávať podmienku na kontrolu, či nejaká premenná je nenullová (úprimne - koľkokrát na to v Jave zabúdame?).
Znak ? pri premennej označuje, či môže táto premenná nadobudnúť hodnotu null. Teda v kóde aktivity Bundle? označuje, že táto hodnota môže byť null.
Príklad (viac detailov neskôr):
slovo: String - nemôže byť null. Kompilačná chyba ak by sme sa o to pokúsili.slovo: String? - môže byť null. Avšak volanie surprise.length() nie je povolené (lebo je šanca, že by to skončilo výnimkou).?. zaručí bezpečné volanie. slovo?.length() vráti dĺžku slova, ale ak je slovo rovné null, tak vráti null namiesto vyhodenia výnimky.Prepis rozhrania do Kotlinu nie je extra komplikovaný. Stačí poznať zápis implementovania rozhrania (neriešime či extends alebo implements) a syntax pre funkcie:
import java.io.Serializable
interface Game : Serializable {
fun isWon() : Boolean
fun guessedCharacters() : CharSequence
fun getChallengeWord() : String
fun attemptsLeft() : Int
fun guess(characters: Char): Boolean
}
Na doplnenie ďalších vecí - prepis statických premenných potrebujeme ďalšie informácie z Kotlinu:
O typoch premenných sme si už povedali pri funkciách. Ak chceme vytvoriť premennú, tak sa rozlúčime s Java tradíciou Random random = new Random().
Pri definovaní premennej sa rozhodujeme medzi val a var podľa toho, či hodnotu premennej povolíme v budúcnosti zmeniť:
val - read-onlyvar - mutableAk je pri vytváraní premennej aj priradenie, typ nemusíme nutne písať. Príklady:
val retazec = "Hello world"val counter : String (keď zapisujeme inštančnú premennú)var counter: Int? = nullV kotline je oddelená trieda a súbor. To znamená, že v súbore .kt môžeme mať viacero tried a navyše aj kód, ktorý je mimo triedy (top-level funkcie, premenné), čo napr. java neumožňuje.
Premenné (property - onedlho sa dozvieme, čo to je), ktoré sú konštantami môžeme obohatiť o slovo const ak obsahujú String alebo niektorú z tried zodpovedajúcu primitívnym typom.
Okrem iného je tam podmienka, že const (compile-time constant) musí byť top-level premenná alebo súčasť companion object alebo object declaration.
Companion object
Ak v kotline použijeme namiesto class slovo object tak v podstate vytvárame singleton. Použitie uvidíme, keď budeme implementovať niečo ako anonymné triedy v jave.
Ak máme takýto objekt v rámci triedy, tak to nazveme companion object. Dá sa s tým robiť viac vecí. V tomto prípade môžeme urobiť companion object v rámci rozhrania Game.kt. Konštanty premenné môžeme vložiť do toho companion object. Rozdiel medzi top-level a týmto prístupom je nasledovný:
DEFAULT_ATTEMPTS_LEFT.Game.DEFAULT_ATTEMPTS_LEFTVýsledný kód pre rozhranie vyzerá nasledovne:
import java.io.Serializable
const val DEFAULT_ATTEMPTS_LEFT = 6
const val UNGUESSED_CHAR = '_'
interface Game : Serializable {
fun isWon() : Boolean
fun guessedCharacters() : CharSequence
fun getChallengeWord() : String
fun attemptsLeft() : Int
fun guess(characters: Char): Boolean
}
Najprv si vysvetlíme všetky novinky z kotlinu. V ďalšom kroku nájdete hotový kód vyskladaný do jednej triedy.
S týmto sa stretneme ešte mnohokrát. V zásade Kotlin používa triedu Array alebo rozhrania List a MutableList. Hlavný rozdiel, ktorý si všimneme je pri listoch, že sú nemenné. Ak chceme meniť obsah, použijeme MutableList. Navyše vieme všetky tri typy dopytovať ako polia pomocou indexov napr.a[i].
Vytvoriť jednotlivé kolekcie môžeme aj nasledovne:
val a = arrayOf(1, 2, 3) resp. val a = intArrayOf(1, 2, 3)val a = listOf(1, 2, 3)val a = mutableListOf(1, 2, 3)arrayOfNullsPri listoch pozor na rozdiel medzi MutableList vs. List a na druhej strane val vs. var. To druhé hovorí o referenciách, nie obsahu.
Navyše máme mnoho užitočných funkcií. Pri práci s kolekciami narazíme často na lambdu a funkcionálny prístup. Viac v ďalších aplikáciach.
Trieda HangmanGame má jeden konštruktor, ktorý má parameter typu pole stringov Array.
V kotline je jeden primárny konštruktor, ktorý je už v hlavičke triedy. Do oblých zátvoriek môžeme vložiť premenné. Ak tam pridáme aj kľúčové slovo val alebovar, tak dostaneme aj inštančnú premennú. Rovnako ak rozširujeme triedu, ktorá nemá bezparametrický konštruktor, tak môžeme premennú priamo vložiť do zátvoriek, napr. class Rodic(cislo: Int) a potom class Trieda(slovo: String, cislo: Int) : Rodic(cislo)
Kód pre primárny konštruktor sa dáva do bloku init{}.
Ďalšie konštruktory doplníme pomocou slova constructor.
Kľúčové slovo in:
for (item in collection)
for (i in 1..3)
for (i in array.indices)
for (i in 6 downto 0 step 2)
Viac v dokumentácii - ranges and progressions.
Zopakujme si - funkcie musia mať návratový typ. Ak tam nie je napísaný, doplní sa Unit.
Občas ale funkcie sú dosť jednoduché a navyše je jasné, čo to vráti. Ak funkcia vráti iba výraz, môže byť prepísaná z takejto verzie
fun duplikuj(x: Int): Int {
return x * 2
}
na jednu z týchto dvoch (nazýva sa to single expression funkcia)
fun duplikuj(x: Int) = x * 2
fun duplikuj(x: Int): Int = x * 2
equals=======import android.os.SystemClock
import kotlin.random.Random
class HangmanGame(words: Array<String>) : Game {
val word: String
// val - do premennej nebudeme priradzovat iny SB, iba volat metody
val wordInProgress: StringBuilder
var attemptsLeft : Int
val startTime: Long
init {
val random = Random.Default
val index = random.nextInt(words.size)
word = words[index]
wordInProgress = StringBuilder()
for (i in words.indices) {
wordInProgress.append(UNGUESSED_CHAR)
}
attemptsLeft = DEFAULT_ATTEMPTS_LEFT
startTime = SystemClock.elapsedRealtime()
}
fun getTime() = SystemClock.elapsedRealtime() - startTime
override fun isWon() = word == wordInProgress.toString()
override fun guessedCharacters() = wordInProgress
override fun getChallengeWord() = word
override fun attemptsLeft() = attemptsLeft
override fun guess(character: Char): Boolean {
var success = false
for (i in word.indices) {
if (word[i] == character){
wordInProgress[i] = character
success = true
}
}
if (!success) {
attemptsLeft--
}
return success
}
}
V jazyku Java na uchovávanie stavu objektu využívame inštančné premenné (v angličtine: fields). Pri rozumnom návrhu triedy je potrebné vytvoriť:
privatePri metódach v Jave modifikátor public označuje metódy, ktoré sú sprístupnené navonok a metódy s modifikátorom private sú len pomocné, čiže pre internú potrebu danej triedy. Premenné sú ale všetky označované private.
V kotline je field iba súčasťou premennej nazývanej property.
Pri vytvorení premennej:
val dostávame field + gettervar dostávame field + getter + setterPoznámky k používaniu:
Dôsledkom je, že modifikátor private označuje pomocné premenné pre internú potrebu triedy. Oddeľuje sa tiež stav objektu a funkcionalita, keďže nie je potrebné k hodnotám premenných pristupovať cez funkcie.
Ak máme v konštruktore premennú napr.
class Turtle(x: Int, y: Int){
init {
volanieFunkcie(x, y)
}
}
zodpovedá to Java kódu
public class Turtle() {
public Turtle(int x, int y) {
volanieFunkcie(x, y)
}
}
Ak v konštruktore pridáme val resp. var, tak automaticky dostaneme property:
class Turtle(val x: Int, val y: Int) { }
čo v jave znamená oveľa viac kódu:
public class Turtle() {
private final int x;
private final int y;
public Turtle(int x, int y) {
this.x = x;
this.y = y;
}
}
Môžeme si vyskúšať pridať java kód pre interface Game z predošlej aplikácie a po kliknutí vybrať možnosť convert Java file to Kotlin file.
Všimnime si, že 4 z 5 funkcií boli vytvorené ako property (val namiesto fun). Property uchováva stav objektu a je možné ich pridať aj do rozhrania, či už ako abstraktné alebo s implementáciou (to je náš prípad).
import java.io.Serializable
interface Game : Serializable {
companion object {
const val DEFAULT_ATTEMPTS_LEFT = 6
const val UNGUESSED_CHAR = '_'
}
val isWon : Boolean
val guessedCharacters : CharSequence
val challengeWord : String
val attemptsLeft : Int
fun guess(character: Char): Boolean
}
Následne upravíme aj implementáciu rozhrania. Zopár poznámok:
isWon a time v skutočnosti nepotrebujú uchovávať hodnotu, ale ju priamo sprístupňujú. V jave to je riešené cez metódu. V zásade ale ide o stav hry, čiže je to vhodnejšie mať ako premennú nie funkciu. Z inej triedy s tým pracujeme ako s premennou.guessedCharacters je vyžadovaná rozhraním. V skutočnosti iba sprístupňuje obsah premennej wordInProgress, ktorá je iného typu StringBuilder implementuje CharSequenceattemptsLeft nemá určený typ, lebo je to jasné pri inicializácii na číselnú hodnotu. Okrem toho má privátny setter. Obsah tejto hodnoty sa má meniť, preto musí byť var. Zmena môže byť robená iba interne z triedy HangmanGame.companion object. Viac o rozdieloch medzi týmto spôsobom a top-level deklaráciou bolo vysvetlené predtým (viď prepis rozhrania Game do kotlinu)import android.os.SystemClock
import kotlin.random.Random
class HangmanGame(words: Array<String>) : Game {
// val - do premennej nebudeme priradzovat iny SB, iba volat metody
private val wordInProgress: StringBuilder
override val challengeWord: String
override val guessedCharacters: CharSequence
get() = wordInProgress
override val isWon: Boolean
get() = challengeWord == wordInProgress.toString()
override var attemptsLeft = Game.DEFAULT_ATTEMPTS_LEFT
private set
private val startTime: Long
val time : Long
get() = SystemClock.elapsedRealtime() - startTime
//fun getTime() = SystemClock.elapsedRealtime() - startTime
init {
val random = Random.Default
val index = random.nextInt(words.size)
challengeWord = words[index]
wordInProgress = StringBuilder()
for (i in challengeWord.indices) {
wordInProgress.append(Game.UNGUESSED_CHAR)
}
startTime = SystemClock.elapsedRealtime()
}
override fun guess(character: Char): Boolean {
var success = false
for (i in challengeWord.indices) {
if (challengeWord[i] == character){
wordInProgress[i] = character
success = true
}
}
if (!success) {
attemptsLeft--
}
return success
}
}
Môžeme začať prepisovať MainActivity.java na verziu v Kotline.
Widgety dostaneme do premenných pomocou funkcie findViewById. Táto funkcia je volaná v metóde onCreate, keď sa vytvára aktivita a vykresľuje layout. Konštruktory sa v aktivitách nepoužívajú.
Dilema - ak by bol TextView?, tak umožníme hodnotu null. Avšak vieme, že tam hodnotu null mať nechceme a ani nebudeme (máme pod kontrolou id widgetu). Ak to ale dáme TextView, tak v čase vytvorenia objektu, ešte pred volaním metódy onCreate nemáme čo do premennej vložiť.
lateinit je spôsob ako môžeme odložiť inicializovanie premennej na neskôr. Vyžaduje sa var, lebo hodnota sa neskôr mení. Nefunguje to pre primitívne typy. Pomocou reflection vieme overiť, či je premenná inicializovaná ::nazovPremennej.isInitialized.
Pomocou is vieme overiť typ (obj is String). V jave to bolo instanceof. Nazýva sa to type cast a je možné robiť aj negáciu !is.
Pre val funguje smartcast: if (x is String && x.length > 0) - automaticky sa v druhej časti podmienky pracuje s x ako so Stringom. Android Studio smartcast zvýrazňuje zelenou farbou.
Pretypovanie pomocou as:
x as Stringx as? String. Nevýhoda je, že typ premennej musí byť nullable (val x: String? = y as? String)V kóde nižšie vo funkcii onImageClick si všimnite nasledovné veci:
lateinit v properties pre Game a widgetycharRange a negovaná podmienka if (letter !in 'a'..'z') . Doteraz sme videli in v cykle, teraz to je v podmienke. Negáciu dosiahneme !in.game.isWon, game.attemptsLeft. Ale to isté máme aj v inputEditText.text, kde nepotrebujeme volať getter funkciu.new. Je to vidieť pri LightingColorFilter. Teda namiesto Java verzie Turtle turtle = new Turtle() v kotline zapisujeme var turtle = Turtle().package sk.itsovy.android.hangman_kotlin
import android.graphics.Color
import android.graphics.LightingColorFilter
import android.os.Bundle
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
private lateinit var game: Game
private lateinit var textView: TextView
private lateinit var imageGallows: ImageView
private lateinit var inputEditText: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
fun startNewGame() {
}
fun onImageClick() {
if (game.isWon || game.attemptsLeft == 0) {
startNewGame()
return
}
val text : CharSequence = inputEditText.text
// text.length == 0
if (text.isEmpty()) {
Toast.makeText(this, R.string.insert_a_letter, Toast.LENGTH_SHORT).show();
return
}
val letter = text[0].lowercaseChar()
inputEditText.setText("")
if (letter !in 'a'..'z') {
//if (letter < 'a' || letter > 'z') {
Toast.makeText(this, "PISMENO", Toast.LENGTH_SHORT).show()
return
}
val success = game.guess(letter)
if (success) {
updateText()
if (game.isWon) {
imageGallows.colorFilter = LightingColorFilter(Color.GREEN, Color.BLACK)
if (game is HangmanGame) {
val hangmanGame = game as HangmanGame
val time: Long = hangmanGame.time
updateBestTime(time)
}
}
} else {
updateImage()
if (game.attemptsLeft == 0) {
imageGallows.colorFilter = LightingColorFilter(Color.RED, Color.BLACK)
}
}
}
private fun updateBestTime(time: Long) {
}
private fun updateImage() {
TODO("Not yet implemented")
}
private fun updateText() {
TODO("Not yet implemented")
}
}
Doplníme do hlavnej aktivity dve metódy:
import android.app.AlertDialog
class MainActivity : AppCompatActivity() {
...
private fun updateBestTime(time: Long) {
val pref = getPreferences(MODE_PRIVATE)
val bestTime = pref.getLong("time", Long.MAX_VALUE)
if (time < bestTime) {
val editor = pref.edit()
editor.putLong("time", time)
editor.apply()
}
announceBestTime(time)
}
private fun announceBestTime(time: Long) {
val builder = AlertDialog.Builder(this)
builder.setTitle(R.string.best_time)
builder.setMessage(getString(R.string.your_time, time))
builder.create().show()
}
}
Viac detailov v dokumentácii. Je to elegantný spôsob na niektoré programátorské úlohy. Pri prvom stretnutí s týmto konceptom ale môže byť náročné pochopiť ako fungujú a ktorú funkciu kedy použiť. Podstatné je v prom rade porozumieť napísanému kódu.
Vyhneme sa vyrábaniu nových premenných. Context object je this alebo it. Lepšie pochopenie budeme mať, keď sa strenteme s lambda výrazmi. Funkcie vytvoria nový dočasný scope, kde je možné pristúpiť k premennej bez jej názvu.
Existuje 5 funkcií : let, run, with, apply, also.
with funkcia funguje štýlom: with this object, do the following. V rámci zátvoriek {} je this premenná, na ktorej sme scope funkciu zavolali. Lepšie vidieť na príklade:
class MainActivity : AppCompatActivity() {
...
private fun updateBestTime(time: Long) {
val pref = getPreferences(MODE_PRIVATE)
val bestTime = pref.getLong("time", Long.MAX_VALUE)
if (time < bestTime) {
with (pref.edit()) {
putLong("time", time)
apply()
}
}
announceBestTime(time)
}
private fun announceBestTime(time: Long) {
with (AlertDialog.Builder(this)) {
setTitle(R.string.best_time)
setMessage(getString(R.string.your_time, time))
create().show()
}
}
}
this je objekt triedy MainActivity - to sa využíva napr. ako parameter pri konštruktore AlertDialog.Builder. V rámci scope funkcie with sa stáva this iná vec - v prvom prípade objekt triedy SharedPreferences.Editor! (výkričník neriešime, je k tomu komentár neskôr pri null safety) a v druhom AlertDialog.Builder.
AndroidStudio nám napíše hint, čo je this ak to nepíšeme v jednom riadku ale rozdelíme to (tak ako v kóde vyššie).
Doplníme hlavnú aktivitu:
import android.graphics.Color
import android.graphics.LightingColorFilter
import android.os.Bundle
import android.os.PersistableBundle
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
const val BUNDLE_KEY = "game"
class MainActivity : AppCompatActivity() {
private val gallowsIds = intArrayOf(
R.drawable.gallows0,
R.drawable.gallows1,
R.drawable.gallows2,
R.drawable.gallows3,
R.drawable.gallows4,
R.drawable.gallows5,
R.drawable.gallows6
)
private lateinit var game: Game
private lateinit var textView: TextView
private lateinit var imageGallows: ImageView
private lateinit var inputEditText: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
textView = findViewById(R.id.textViewGuessedWord)
inputEditText = findViewById(R.id.editTextLetter)
imageGallows = findViewById(R.id.imageViewGallows)
imageGallows.setOnClickListener {
onImageClick()
}
if (savedInstanceState == null) {
startNewGame()
} else {
game = savedInstanceState.getSerializable(BUNDLE_KEY) as Game
updateImage()
updateText()
}
}
private fun startNewGame() {
val words = resources.getStringArray(R.array.dictionary)
game = HangmanGame(words)
updateText()
updateImage()
imageGallows.colorFilter = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(BUNDLE_KEY, game)
}
fun onImageClick() {
if (game.isWon || game.attemptsLeft == 0) {
startNewGame()
return
}
val text : CharSequence = inputEditText.text
// text.length == 0
if (text.isEmpty()) {
Toast.makeText(this, R.string.insert_a_letter, Toast.LENGTH_SHORT).show();
return
}
val letter = text[0].lowercaseChar()
inputEditText.setText("")
if (letter !in 'a'..'z') {
//if (letter < 'a' || letter > 'z') {
Toast.makeText(this, "PISMENO", Toast.LENGTH_SHORT).show()
return
}
val success = game.guess(letter)
if (success) {
updateText()
if (game.isWon) {
imageGallows.colorFilter = LightingColorFilter(Color.GREEN, Color.BLACK)
if (game is HangmanGame) {
val hangmanGame = game as HangmanGame
val time: Long = hangmanGame.time
updateBestTime(time)
}
}
} else {
updateImage()
if (game.attemptsLeft == 0) {
imageGallows.colorFilter = LightingColorFilter(Color.RED, Color.BLACK)
}
}
}
private fun updateBestTime(time: Long) {
val pref = getPreferences(MODE_PRIVATE)
val bestTime = pref.getLong("time", Long.MAX_VALUE)
if (time < bestTime) {
with (pref.edit()) {
putLong("time", time)
apply()
}
}
announceBestTime(time)
}
private fun announceBestTime(time: Long) {
with (AlertDialog.Builder(this)) {
setTitle(R.string.best_time)
setMessage(getString(R.string.your_time, time))
create().show()
}
}
private fun updateImage() {
val index = Game.DEFAULT_ATTEMPTS_LEFT - game.attemptsLeft
imageGallows.setImageResource(gallowsIds[index])
}
private fun updateText() {
textView.text = game.guessedCharacters
}
}
Zhrnutie o null safety - prakticky si to vyskúšame pri iných aplikáciach. Voliteľne si môžete vyskúšať zmeniť Game na Game? v hlavnej aktivite a popasovať sa so zmenami.

Prezentácia, ďalšie aplikácie sú na stránke https://ics.science.upjs.sk/vma/.
Nasleduje aplikácia viac zameraná na Android ako Kotlin (aj keď sa dozvieme viaceré nové veci z kotlinu).