Introduction to Kotlin Programming Language PDF

Document Details

Uploaded by Deleted User

Tags

kotlin programming programming language android development mobile app development

Summary

This document provides an introduction to the Kotlin programming language, covering its syntax, data types, object-oriented concepts, and applications in modern mobile app development. The document includes examples and details about the language's features.

Full Transcript

Wprowadzenie do języka Kotlin Cele przedmiotu i wymagania wstępne Cele przedmiotu: Poznanie języka Kotlin Poznanie współczesnych sposobów tworzenia interfejsu użytkownika Poznanie zalecanych zasad tworzenia architektury aplikacji mobilnej Wymagan...

Wprowadzenie do języka Kotlin Cele przedmiotu i wymagania wstępne Cele przedmiotu: Poznanie języka Kotlin Poznanie współczesnych sposobów tworzenia interfejsu użytkownika Poznanie zalecanych zasad tworzenia architektury aplikacji mobilnej Wymagania wstępne: Znajomość programowania obiektowego. Język angielski – stopień podstawowy Znajomość tematyki struktur danych oraz sieci komputerowych Zaliczenie pisemne w formie testu z pytaniami zamkniętymi Efekty uczenia się W zakresie wiedzy: Student zna najważniejsze elementy języka Kotlin Student potrafi scharakteryzować architekturę współczesnej aplikacji mobilnej Student zna współczesne metody tworzenia interfejsu użytkownika W zakresie umiejętności: Student potrafi zaprojektować i zaimplementować aplikację mobilną o architekturze pozwalającej na łatwe utrzymanie i rozbudowę aplikacji Student potrafi tworzyć interfejsy użytkownika wykorzystując różne metody W zakresie kompetencji społecznych: Student zdaje sobie sprawę z dynamicznego rozwoju technologii mobilnych i rozumie potrzebę rozwijania i aktualizowania swojej wiedzy Tematyka wykładów Wprowadzenie do języka Kotlin – składnia, system typów, wyrażenia, operatory, funkcje Klasy i interfejsy w języku Kotlin – składnia, dziedziczenie, metody rozszerzające Metody tworzenia interfejsu użytkownika w systemie Android Nawigacja w aplikacji – graf nawigacji, przechodzenie po grafie, przekazywanie parametrów Programowanie asynchroniczne w języku Kotlin z wykorzystaniem współprogramów Architektura aplikacji – wprowadzenie; zarządzanie stanem, obsługa cyklu życia; wiązanie widoków i danych; przechowywanie danych; zarządzanie wykonaniem zadań w tle Wstrzykiwanie zależności w systemie Android Tworzenie testów aplikacji mobilnych Zalecana literatura Strona internetowa: https://kotlinlang.org/ Strona internetowa: https://developer.android.com Efektywny Kotlin: najlepsze praktyki, Marcin Moskała, przekład: Tomasz Walczak, Gliwice, Helion, 2021 Kickstart Modern Android Development with Jetpack and Kotlin, Catalin Ghita, Packt Publishing, 2022 Kotlin language specification (https://kotlinlang.org/spec/) Java and Kotlin code performance in selected web frameworks, Grzegorz Bujnowski, Jakub Smołka, JCSI - Journal of Computer Sciences Institute.- 2020, vol. 16, s. 219-226 Analysis of the development Android’s runtime, Kostiantyn Honcharenko, Jakub Smołka, JCSI - Journal of Computer Sciences Institute.- 2019, vol. 12, s. 246-251 Wydajność języków C++ oraz Java na platformie Android, Paweł Wlazło, Jakub Smołka, JCSI - Journal of Computer Sciences Institute.- 2022, vol. 23, s. 135-139 Programowanie aplikacji dla systemu Android, Jakub Smołka, Lublin, Politechnika Lubelska, 2014 Język Kotlin Kotlin - wieloplatformowy, statycznie typowany, uniwersalny język programowania wysokiego poziomu z wnioskowaniem typów. Zaprojektowany tak, aby w pełni współdziałał z Javą; wersja standardowej biblioteki Kotlina w wersji JVM zależy od biblioteki klas Java Działa na JVM, ale także kompiluje się do JavaScript lub kodu natywnego poprzez LLVM Od Android Studio 3.0 (10/2017) Kotlin jest dołączany jako alternatywa dla standardowego kompilatora Java. Kompilator Android Kotlin domyślnie generuje kod bajtowy Java 8, ale pozwala programiście wybrać docelową wersję Java 9 do 20 w celu optymalizacji Historia Lipiec 2011 – firma JetBrains zaprezentowała Projekt Kotlin - nowy język dla JVM, nad którym pracowano od roku Luty 2012 - udostępniono projekt jako open source na licencji Apache 2 Nazwa pochodzi od wyspy Kotlin 15 lutego 2016 roku – wydanie Kotlin 1.0; firma JetBrains zobowiązała się do zapewnienia długoterminowej kompatybilności, począwszy od tej wersji Podczas Google I/O 2017 firma Google ogłosiła pierwszorzędne wsparcie dla Kotlina na Androida 28 listopada 2017 roku – wydanie Kotlina 1.2; dodano funkcję współdzielenia kodu pomiędzy platformami JVM i JavaScript 29 października 2018 roku – wydanie Kotlina 1.3 -dodano współprogramy do programowania asynchronicznego. 7 maja 2019 r. firma Google ogłosiła, że język programowania Kotlin jest obecnie preferowanym językiem dla twórców aplikacji na Androida Sierpień 2020 roku – wydanie Kotlina 1.4 - zawierał m.in. kilka drobnych zmian w obsłudze platform Apple (współpraca z Objective-C/Swift) Maj 2021 – wydanie Kotlina 1.5; listopad 2021 – wydanie Kotlina 1.6; czerwiec 2022 – wydanie Kotlina 1.7 (nowy kompilator K2 w wersji alfa); grudzień 2022 – Kotlin 1.8 , lipiec 2023 – Kotlin 1.9 Podstawowe typy danych Kategoria Typy Całkowite ze znakiem Byte, Short, Int, Long Całkowite bez znaku UByte, UShort, UInt, ULong Zmiennoprzecinkowe Float, Double Logiczne Boolean Znaki Char Ciągi znaków String Podstawowe typy danych Any – korzeń hierarchii klas w Kotlinie; wszystkie klasy dziedziczą po Any (podobnie jak w Javie po Object) Unit – typ z jedną wartością – obiektem Unit (odpowiednik void z Javy) Nothing – nie ma instancji; służy do reprezentowania wartości, która nigdy nie istnieje np. jeżeli funkcja ma typ Nothing oznacza to, że nigdy nie zwraca wartości (zawsze zgłasza wyjątek) Podstawy składni fun main(args: Array) { //zmienne var a: Int = 1 //typ podaje się po dwukropku; typowanie //statyczne var b = 1 //Int var c = 1.0 //Double println("$a $b $c") //1 1 1.0 a++; b++; c++ println("$a $b $c") //2 2 2.0 val d = 1 println(d) //1 //d=d+1 //d jest immutable b = c.toInt() //konwersja między typami (inne podobnie) println(b) Podstawy składni //szablony łańcuchów var e = 1 val s1 = "e is $e" println(s1) //e is 1 e = 2 //wyrażenia muszą być w {} po znaku $ val s2 = "${s1.replace("is", "was")}, but now is $e" println(s2) //e was 1, but now is 2 //instrukcje sterujące //if w zasadzie standardowy ale jest wyrażeniem //(nie ma "war ? wart1 : wart2) //a == 2, d ==1 println(if (a>d) a else d) //2 val f = if (a>d) { 10 20 } else 30 println(f) //20 - wartość wyrażenia to wartość ostatniej linii w {} Podstawy składni val text = "Hello" //when = "odpowiednik" switch when (text) { //prównuje wartości do text "1" -> println("One") "Hello" -> println("Greeting") else -> println("Unknown") //else nie zawsze jest potrzebne (np. w //przypadku typów wyliczeniowych) } //Greeting val result = when (text) { //też jest wyrażeniem "Hello" -> "Greeting" else -> "Unknown" } // Greeting val temp = 18 val description = when { temp < 15 -> "cold" //można też używać wyrażeń logicznych temp "warm" else -> "hot" } println(description) // warm Podstawy składni //tablice //funkcje do tworzenia tablic val arr1= arrayOf(1,2,3) //[1, 2, 3] println(Arrays.toString(arr1)) val arr2 = arrayOfNulls(4) //null null null null arr2.forEach { print("$it ") }; println() //użycie klasy i konstruktora val arr3 = Array(5) { i -> (i * i).toString() } println(Arrays.toString(arr3)) //[0, 1, 4, 9, 16] arr3="10" //są też tablice z typami prostymi ByteArray, ShortArray, IntArray... val x: IntArray = intArrayOf(1, 2, 3) x = x + x //[5, 2, 3] val arr4 = IntArray(5) //[0, 0, 0, 0, 0] val arr5 = IntArray(5) { 42 } //[42, 42, 42, 42, 42] var arr6 = IntArray(5) { it * 1 } //[0, 1, 2, 3, 4] Podstawy składni //zakresy //for - z zakresem for (number in 1..3) { print(number) } //123 println() //for po kolekcji val cakes = listOf("carrot", "cheese", "chocolate") for (cake in cakes) { print("$cake cake ") } //carrot cake cheese cake chocolate cake println() //while i do while typowe Podstawy składni //sprawdzanie typów i rzutowanie var obj:Any = "abc" if (obj is String) { println(obj.length) //3; //obj zostało automatycznie rzutowane na String } if (obj !is String) return println(obj.length) //3; tutaj obj też jest rzutowane na String //tutaj po prawej stronie || i && będzie podobnie if (obj !is String || obj.length == 0) return if (obj is String && obj.length > 0) { println(obj.length) //3 } Podstawy składni //"is" działa też z when when (obj) { is String -> println("obj is String") else -> println("obj is something else") } //obj is String //jawne rzutowanie val obj2:Any = 1 //val str1:String = obj2 as String //nie ok - wyjątek jeżeli rzutowanie nie jest prawidłowe val int1:Int = obj2 as Int //ok val str2:String? = obj2 as? String //nie ok - str2 == null } Zakresy fun main(args: Array) { //zakresy (ranges) println(4 in 1..4) //true - zakres obustronnie domknięty println(4 in 1.. string.uppercase() } println(upperCaseString("hello")) //HELLO Wyrażenia lambda //przekazywanie jako parametr val numbers = listOf(1, -2, 3, -4, 5, -6) // normalna składnia val positivesDoubled = numbers.filter({ x -> x > 0 }) // specjalna składnia.map { x -> x * 2 } println(positivesDoubled) // [2, 6, 10] val sum = positivesDoubled.fold(0, { acc, item -> acc + item }) println(sum) //18 // końcowa lambda val sum2 = positivesDoubled.fold(0) { acc, item -> acc + item } println(sum2) //18 Wyrażenia lambda //typy wyrażeń lambda np. (Int, Int) -> Int , () -> Unit val upperCaseString2: (String) -> String = { string -> string.uppercase() } println(upperCaseString2("hello")) //HELLO //returny i lambdy fun foo() { listOf(1, 2, 3, 4, 5).forEach { if (it == 3) return // wychodzi z funkcji a nie z lambdy print(it) } println("this point is unreachable") } foo() // 12 println() Wyrażenia lambda fun foo2() { listOf(1, 2, 3, 4, 5).forEach(fun(value: Int) { //zastąpienie lambdy //funkcją anonimową if (value == 3) return //wyjście z funkcji anonimowej print(value) }) println(" done with anonymous function") } foo2() //1245 done with anonymous function fun foo3() { listOf(1, 2, 3, 4, 5).forEach lit@{ //etykieta if (it == 3) return@lit //return do etykiety print(it) } println(" done with explicit label") } foo3() //1245 done with explicit label Wyrażenia lambda fun foo4() { listOf(1, 2, 3, 4, 5).forEach { // niejawna etykieta if (it == 3) return@forEach // powrót do wywołującego // lambdy – pętli forEach print(it) } println(" done with implicit label") } foo4() //1245 done with implicit label } Klasy – konstruktor i inicjalizacja class Customer //główny konstruktor class Customer2 constructor(firstName:String) { //... } //jeżeli konstruktor nie ma modyfikatorów np. private albo adnotacji to //można pominąć class Customer3(firstName: String,lastName: String) { //inicjalizacja wykona się w takiej kolejności jak w kodzie val firstNameProp:String = firstName.also(::println) init { println("init#1, firstName=$firstName") } Klasy – konstruktor i inicjalizacja val lastNameProp:String = lastName.also(::println) init { println("init#2, lastName=$lastNameProp") } } fun main(args: Array) { //tworzenie obiektu - nie ma new val cust=Customer3("Andrew","Android") //Andrew /n init#1, //firstName=Andrew /n Android /n init#2, lastName=Android } Klasy – podstawowy i dodatkowe konstruktory //podstawowy konstruktor class Person(val name: String) { val children: MutableList = mutableListOf() //dodatkowy konstruktor - musi być delegacja do podstawowego (słowo this) constructor(name: String, parent: Person) : this(name) { parent.children.add(this) } } //chyba że konstruktor podstawowy nie ma parametrów - wtedy delegacja niejawna class Constructors { init { println("Init block") } constructor(i: Int) { //użycie tego konstruktora i tak wykona blok init println("Constructor $i") } } Klasy – właściwości //właściwości - w nawiasach po nazwie klasy, mogą mieć domyślne wartości class Contact( val id: Int, var email: String = "[email protected]" ) { //lub w {} val category: String = "work" //mogą mieć domyślne wartości //gettery, settery i pola var name:String = "" //inicjalizator przypisuje wartość do pola get() = field //getter może mieć krótką postać set(value) { //do pola odwołujemy się za pomocą field field = value } } fun main(args: Array) { val contact = Contact(1, "[email protected]") val contact2 = Contact(2) println(contact.email) //[email protected] contact.name="Mary" println(contact.name) } Klasy - widoczność Funkcje, właściwości, klasy, obiekty i interfejsy zadeklarowane na „najwyższym poziomie” (bezpośrednio w pakiecie): public = deklaracje widoczne wszędzie (domyślny) private = deklaracja widoczna tylko w pliku zawierającym tę deklarację internal = deklaracja widoczna w wszędzie w tym samym module (=moduł IntelliJ, projekt Maven, Gradle source set) protected nie jest dostępny dla deklaracji najwyższego poziomu Klasy - widoczność Składowe klasy: private = składowa widoczna tylko wewnątrz tej klasy protected = jak prywatny, ale jest również widoczny w podklasach internal = każdy element wewnątrz tego samego modułu, który widzi deklarującą klasę, widzi jej wewnętrzne elementy public = każdy element który widzi klasę deklarującą, widzi jej publiczne składowe (domyślny) Widoczność konstruktora określa się przed słowem constructor (wtedy nie można go pominąć) Klasy – dziedziczenie //klasa musi być open aby można było po niej dziedziczyć open class Base(p: Int) //domyślnie dziedziczy po Any, która ma metody //equals(), hashCode() i toString() class Derived(p: Int) : Base(p) //jawne określenie klasy bazowej i //przekazanie parametrów konstruktora open class Shape { open val vertexCount: Int = 0 open fun draw() { } //tylko otwarte metody można nadpisać w //klasach pochodnych fun fill() { } //metody nie można nadpisać open fun setColor(color: Color) { } } Klasy – dziedziczenie class Circle() : Shape() { override fun draw() { } //napisywanie metody final override fun setColor(color: Color) { //final - w klasach //pochodnych nie można nadpisać //... } } //właściwość można nadpisać w konstruktorze podtawowym class Rectangle(override val vertexCount: Int = 4) : Shape() // zawsze //ma 4 wierzchołki class Polygon : Shape() { override var vertexCount: Int = 0 //właściwość val można nadpisać //za pomocą var (ale nie na odwrót) } Klasy danych //automatyczne toString, equals, ==, copy, hashCode(), component1()... //główny konstruktor musi mieć co najmniej 1 parametr //nie mogą być abstrakcyjne, otwarte, szczelne (sealed), wewnętrzne (inner) //mogą mieć ciało data class User(val name: String, val id: Int) fun main(args: Array) { val user = User("Alex", 1) println(user) //User(name=Alex, id=1) val secondUser = User("Alex", 1) val thirdUser = User("Max", 2) println("user == secondUser: ${user == secondUser}") //user == secondUser: // true println("user == thirdUser: ${user == thirdUser}") //user == thirdUser: // false //można łatwo stworzyć kopię i ewentualnie zmienić właściwość println(user.copy()) //User(name=Alex, id=1) println(user.copy("Max")) //User(name=Max, id=1) println(user.copy(id = 3)) //User(name=Alex, id=3) } Klasy zagnieżdżone i wewnętrzne class Outer { private val bar: Int = 1 class Nested { //fun foo() = bar //w zagnieżdżonych nie ma dostępu do //składowych klasy zawierającej fun foo() = 2 } inner class Inner { fun foo() = bar //w wewnętrznych jest } } fun main(args: Array) { println(Outer.Nested().foo()) //2 println(Outer().Inner().foo()) //1 //tutaj musimy mieć obiekt Outer } Funkcje rozszerzające //funkcja rozszerzająca może być ogólna (pierwsze ) //funkcja rozszerza klasę MutableList fun MutableList.swap(index1: Int, index2: Int) { val tmp = this[index1] // 'this' to lista this[index1] = this[index2] this[index2] = tmp } fun main(args: Array) { val list = mutableListOf(1, 2, 3) list.swap(0, 2) // 'this' wewnątrz 'swap()' to referencja do listy println(list) } Funkcje rozszerzające open class Shape class Rectangle: Shape() { fun getName() = "in class Rectangle" } fun Shape.getName() = "Shape" fun Rectangle.getName() = "Rectangle" fun main(args: Array) { val shape:Shape = Rectangle() //wywołania funkcji rozszerzających są rozwiązywane statycznie //(wywołanie z typu zmiennej) println(shape.getName()) //Shape val rectangle:Rectangle = Rectangle() //w przypadku konfliktu składowa klasy zawsze wygrywa println(rectangle.getName()) //in class Rectangle } Interfejsy interface MyInterface { val prop: Int // abstrakcyjna właściwość val propertyWithImplementation: String get() = "foo" fun foo() { //metoda z implementacją domyślną print(prop) } fun bar() //metoda abstrakcyjna } Interfejsy //implementacja interfejsu class Child : MyInterface { override val prop: Int = 29 //właściwość bez implementacji override fun bar() { //i metoda abstrakcyjna muszą być zaimplementowane println(propertyWithImplementation) } } fun main(args: Array) { val anonymous = object : MyInterface { //podobnie jeżeli chcemy dziedziczyć //po klasie override val prop: Int get() = 42 override fun bar() { println("bar") } } println(anonymous.prop) //42 } Singleton object Singleton { //nie może mieć konstruktora var a=1 } data object DataSingleton { //wygenerowane toString(), equals(), //hashCode() ale nie ma copy() i //componentN() var a=3 } fun main(args: Array) { println(Singleton.a) //1 Singleton.a=2 println(Singleton.a) //2 println(DataSingleton) //DataSingleton - toString zwraca nazwę } Obiekty towarzyszące class MyClass { companion object Factory { //obiekt towarzyszący może być nazwany fun create(): MyClass = MyClass() } } class MyClass2 { companion object { //obiekt towarzyszący może być nienazwany fun create(): MyClass2 = MyClass2() } } fun main(args: Array) { //niezależnie od tego czy obiekt towarzyszący jest nazwany czy nie możemy //po prostu użyć nazwy klasy val instance = MyClass.create() val instance2 = MyClass2.create() //jeżeli jest nienazwany możemy sie odwołać za pomocą Companion val companion = MyClass.Factory val companion2 = MyClass2.Companion } Obiekty towarzyszące //mimo, że obiekty towarzyszące przypominają składowe statyczne to są //prawdziwymi obiektami i mogą np. implementować interfejsy interface Factory { fun create(): T } class MyClass3 { companion object : Factory { override fun create(): MyClass3 = MyClass3() } } Delegacja interfejsów interface Base { val y:Int; fun print() fun print2() } class BaseImpl(val x: Int) : Base { override val y = 15 override fun print() { println("$x $y") } override fun print2() { println("2") } } Delegacja interfejsów class Derived(b: Base) : Base by b { //delegacja imlpementacji interfejsu do składowych publicznych obiektu b override val y = 20 //implementacja print z delegata nie ma dostępu do tej właściwości override fun print2() { //funkcje z delegata można nadpisać println("2 override") } } fun main() { val b = BaseImpl(10) Derived(b).print() //10 15 Derived(b).print2() //2 override } Delegacja właściwości class Example { var p: String by Delegate() } //są gotowe klasy i funkcje, które umożliwiają łatwe tworzenie delegatów np. //lazy()/Lazy i w klasie Delegates (observable, veoable) class Delegate { //nie musi implementować interfejsu ale muszą mieć funkcje //operatorów getValue i setValue (dla var) i getValue (dla val) operator fun getValue(thisRef: Any?, property: KProperty): String { return "$thisRef, thank you for delegating '${property.name}' to me!" } operator fun setValue(thisRef: Any?, property: KProperty, value: String) { println("$value has been assigned to '${property.name}' in $thisRef.") } } Delegacja właściwości Lazy() - funkcja, która pobiera lambdę i zwraca instancję Lazy - implementację leniwej właściwości pierwsze wywołanie get() wykonuje lambdę przekazaną do funkcji lazy() i zapamiętuje wynik kolejne wywołania get() po prostu zwracają zapamiętany wynik Delegates.observable() - przyjmuje dwa argumenty: wartość początkową i funkcję (lambdę) obsługującą modyfikację funkcja obsługi jest wywoływana za każdym razem, gdy właściwość jest modyfikowana (po wykonaniu przypisania). Funkcja obsługi ma trzy parametry: właściwość, do której jest przypisana, starą wartość i nową wartość Delegates.vetoable() - zwraca delegata właściwości (do odczytu/zapisu), który po zmianie właściwości wywołuje funkcję obsługi, która ma możliwość zawetowanie modyfikacji Delegacja właściwości class User(val map: Map) { val name: String by map val age: Int by map } fun main(args: Array) { val e = Example() println(e.p) //Example@6e8cf4c6, thank you for delegating 'p' to me! e.p = "NEW" //NEW has been assigned to 'p' in Example@6e8cf4c6. val user = User(mapOf( "name" to "John Doe", "age" to 25 )) println(user.name) //John Doe println(user.age) //25 } Null safety fun main(args: Array) { var neverNull: String = "This can't be null" //neverNull = null //błąd var nullable: String? = "You can keep a null here" nullable = null var inferredNonNull = "The compiler assumes non-nullable" //inferredNonNull = null //domyślnie typy nie są nullowalne //nie akceptujemy wartości null; funkcje mogą być wewnątrz funkcji fun strLength(notNull: String): Int { return notNull.length } println(strLength(neverNull)) // 18 //println(strLength(nullable)) //String i String? to niezgodne //typy Null safety fun describeString(maybeString: String?): String { if (maybeString != null && maybeString.length > 0) { return "String of length ${maybeString.length}" } else { return "Empty or null string" } } var nullString: String? = null println(describeString(nullString)) //Empty or null string fun lengthString(maybeString: String?): Int? = maybeString?.length println(lengthString(nullString)) //null //bezpieczne wywołanie ?. zostanie wykonane tylko gdy referencja != null //w przeciwnym razie zwracany jest null; można je łączyć w ciągi a.b?.c?.d println(nullString?.uppercase()) //null nullable="Isn't null" println(nullable?.uppercase()) //ISN'T NULL //operator Elvisa println(nullString?.length ?: 0) // 0 println(nullable?.length ?: 0) // 10 } Operatory porównania W Kotlinie istnieją dwa rodzaje równości Równość strukturalna == (i zanegowany odpowiednik !=) Odpowiada wykonaniu equals() Tłumaczona jest na: a?.equals(b) ?: (b === null) Porównanie do null – a == null jest równoważne a === null Równość referencyjna === (i zanegowany odpowiednik !==) Polega na porównaniu referencji W przypadku wartości reprezentowanych przez typy proste w trakcie wykonania np. Int porównanie a === b jest równoważne a == b Kolekcje Biblioteka Standardowa Kotlina (Kotlin Standard Library) zapewnia implementacje podstawowych typów kolekcji: zbiorów, list i map. Dla każdego typu kolekcji jest para interfejsów definiujących operacje: Interfejs tylko do odczytu (read-only) - udostępnia operacje umożliwiające dostęp do elementów kolekcji Interfejs zmienny (mutable) - rozszerza odpowiedni interfejs tylko do odczytu o operacje zapisu pozwalające na: dodawanie, usuwanie i aktualizowanie elementów Kolekcja zmienna nie musi być przypisana do var Kolekcje tylko do odczytu są kowariantne tzn. jeśli np. klasa Rectangle dziedziczy po Shape to można użyć klasy List wszędzie tam, gdzie wymagana jest klasa List (typy kolekcji mają tę samą relację podtypów, co typy elementów). Mapy są kowariantne pod względem typu wartości, ale nie typu klucza Kolekcje zmienne nie są kowariantne Kolekcje – interfejsy kolekcji https://kotlinlang.org/docs/collections-overview.html#collection-types Listy – tworzenie, przeglądanie fun printAll(strings: Collection) { for(s in strings) print("$s ") println() } fun main(args: Array) { //tworzenie i przeglądanie list val numbers = listOf("one", "two", "three", "four") //funkcja do //tworzenia niezmiennych list printAll(numbers) //one two three four println("${numbers.size} ${numbers.get(2)} ${numbers} ${numbers.indexOf("two")}") //4 three four 1 val numbersIterator = numbers.iterator() //jest też listIterator() //pozwalający na przeglądanie w dwóch kierunkach while (numbersIterator.hasNext()) { print("${numbersIterator.next()} ") } //one two three four println() Listy – tworzenie, kopiowanie val doubled = List(3, { it * 2 }) //można też użyć funkcji //inicjalizującej List; aby lista była modyfikowalna trzeba użyć //funkcji MutableList println(doubled) //[0, 2, 4] //funkcje kopiujące kolekcje z biblioteki standardowej tworzą //płytkie kopie val listCopy=doubled.toMutableList() //i inne toList(), toSet() itd //- tworzą kopie, które można modyfikować niezależnie od oryginałów listCopy=3 println(listCopy) //[0, 3, 4] println(doubled) //[0, 2, 4] val linkedList = LinkedList(listOf("one", "two", "three")) //można używać konstruktorów konkretnych typów Listy – porównywanie, modyfikacja //porównywanie list val bob = Pair("Bob", 31) //Pair - para 2 elementów; tutaj imię i //wiek val people = listOf(Pair("Adam", 20), bob, bob) val people2 = listOf(Pair("Adam", 20), Pair("Bob", 31), bob) println(people == people2) //true - listy są równe jeżeli elementy //są strukturalnie równe //zmienna lista val numbersMutable = mutableListOf(1, 2, 3, 4) //funkcja do //tworzenia list zmiennych numbersMutable.add(5) numbersMutable.removeAt(1) numbersMutable = 0 numbersMutable.shuffle() println(numbersMutable) //[3, 0, 4, 5] Zbiory – tworzenie, użycie //zbiory i kolejność elementów val setOfnumbers = setOf(1, 2, 3, 4) // LinkedHashSet jest domyślną //implementacją (zachowuje kolejność) println("Number of elements: ${setOfnumbers.size}") //Number of elements: 4 if (setOfnumbers.contains(1)) println("1 is in the set") //1 is in the set val setOfNumbersBackwards = setOf(4, 3, 2, 1) println("The sets are equal: ${setOfnumbers == setOfNumbersBackwards}") //The sets are equal: true //kolejność się nie liczy) println(setOfnumbers.first() == setOfNumbersBackwards.first()) //false - tu //faktyczna kolejność ma znaczenie println(setOfnumbers.first() == setOfNumbersBackwards.last()) //true //tworzenie pustych kolekcji na przykładzie zbioru val emptySet = mutableSetOf() //trzeba określić typ Mapy – tworzenie, użycie //mapy val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key4" to 1) //funkcja infiksowa "to" tworzy tymczasowe obiekty //Pair val numbersMap2 = mutableMapOf().apply { this["one"] = "1"; this["two"] = "2" } //bardziej // efektywne - bez tworzenia tymczasowych obiektów val map = buildMap { //funkcje budujące (są też buildSet() i put("a", 1) //buildList()) put("b", 0) put("c", 4) } println("${numbersMap.keys} ${numbersMap.values}") //[key1, key2, key3, key4] [1, 2, 3, 1] if ("key2" in numbersMap) println("Value by key \"key2\": ${numbersMap["key2"]}") //Value by key "key2": 2 Mapy – tworzenie, użycie if (1 in numbersMap.values) println("The value 1 is in the map") //The value 1 is in the map if (numbersMap.containsValue(1)) println("The value 1 is in the map") //The value 1 is in the map val anotherMap = mapOf("key2" to 2, "key1" to 1, "key4" to 1, "key3" to 3) println("The maps are equal: ${numbersMap == anotherMap}") //The maps are equal: true val mutableNumbersMap = mutableMapOf("one" to 1, "two" to 2) mutableNumbersMap.put("three", 3) mutableNumbersMap["one"] = 11 println(mutableNumbersMap) //{one=11, two=2, three=3} } Operacje na kolekcjach Typowe operacje dzielą się na następujące grupy: transformacje, filtrowanie, operatory plus i minus, grupowanie, pobieranie części kolekcji, pobieranie pojedynczych elementów, porządkowanie, operacje agregujące Mapowanie - tworzy kolekcję na podstawie wartości funkcji wykonanej na elementach innej kolekcji Zip - polega na budowaniu list par z elementów o tych samych pozycjach w obu kolekcjach Asocjacja - budowanie map z elementów kolekcji (kluczy) i określonych wartości z nimi związanych pochodzących z innej kolekcji (wartości) Spłaszczanie (flatten) – uruchomiona na kolekcji złożonej z kolekcji (np. liście list) zwraca pojedynczą kolekcję ze wszystkimi elementami Operacje na kolekcjach Sprawdzanie spełnienia warunków (any(), none(), all()) Podział (partition) – podział na elementy spełniające i niespełniające warunku Grupowanie (groupBy) – tworzy mapę, w której wartościami są listy elementów posiadające wspólną cechę a kluczami wartości tej cechy (np. klucz – pierwsza litera słowa, wartość – lista słów zaczynających się od tej litery) Wycinanie - (slice) wycinanie fragmentu, (take…) pozostawianie fragmentu, (drop…) odrzucanie fragmentu kolekcji Agregacja (minOrNull, maxOrNull, average, sum, count,...) Operacje na kolekcjach fun main(args: Array) { val numbers = mutableListOf("one", "two", "three", "four") val longerThan3 = numbers.filter { it.length > 3 } //tworzenie //nowej kolekcji z elementami spełniającymi warunek` println(longerThan3) //[three, four] val filterResults = mutableListOf() //kolekcja na wynik numbers.filterTo(filterResults) { it.length > 3 } //filtrowanie do //istniejącej kolekcji numbers.filterIndexedTo(filterResults) { index, _ -> index == 0 } //filtrowanie do istniejącej kolekcji z uwzględnieniem indeksu println(filterResults) //[three, four, one] //funkcje "To" też zwracają wynik val result = numbers.mapTo(HashSet()) { it.length } println("distinct item lengths are $result") } Scope functions - zestawienie Odniesienie do Funkcja Zwracana wartość Czy jest funkcją rozszerzającą? obiektu let it Wynik lambdy tak run this Wynik lambdy tak run - nie (nie ma obiektu Wynik lambdy kontekstowego) with this nie (obiekt kontekstowy jest Wynik lambdy argumentem) apply this Obiekt kontekstowy tak also it Obiekt kontekstowy tak Scope functions – kiedy stosować? let - wykonywanie lambdy na obiektach, które nie dopuszczają wartości null (non-nullable) let - wprowadzenie wyrażenia jako zmiennej o zasięgu lokalnym (wewnątrz lambdy) apply - konfiguracja obiektu run - konfiguracja obiektu i obliczenie wyniku nierozszerzające run - uruchamianie instrukcji, w których wymagane jest wyrażenie also - zrealizowanie dodatkowych efektów with - funkcja grupująca wywołania na obiekcie Funkcja let data class MutablePair(var first: Int, var second: Int) fun main(args: Array) { //let - obiekt kontekstowy dostępny jako it; zwraca wynik lambdy //używany do wywoływania jednej lub więcej funkcji na wynikach //ciągów wywołań val numbers = mutableListOf("one", "two", "three", "four", "five") numbers.map { it.length }.filter { it > 3 }.let { //możemy wykonać wiele operacji na wyniku filtrowania bez //przypisywania go do zmiennej println(it) //kolejne operacje } //[5, 4, 4] numbers.map { it.length }.filter { it > 3 }.let(::println) //jeżeli tylko jedna operacja to można użyć //referencji do funkcji Funkcja let //let jest często używane do wykonywania operacji na obiektach !=null fun processNonNullString(str: String) = println(str) val str: String? = "Hello" val length = str?.let { //tutaj it jest !=null processNonNullString(it) //Hello it.length } println(length) //5 //użycie let do wprowadzenia zmiennej w ograniczonym zakresie aby //poprawić czytelność kodu (definiujemy nazwę parametru lambdy zamiast //używać domyślnego it) val modifiedFirstItem = numbers.first().let { firstItem -> println("The first item of the list is '$firstItem'") //The first item of the list is 'one' if (firstItem.length >= 5) firstItem else "!" + firstItem + "!" }.uppercase() println("First item after modifications: '$modifiedFirstItem'") //First item after modifications: '!ONE!' Funkcja with //with - obiekt kontekstowy dostępny jako this; zwraca wynik lambdy //nie jest funkcją rozszerzającą - obiekt kontekstowy przekazywany //jest jako parametr używany do "zrobienia czegoś z obiektem" with(numbers) { println("'with' is called with argument $this") //'with' is called with argument [one, two, three, four, five] println("It contains $size elements") //It contains 5 elements } val firstAndLast = with(numbers) { "The first element is ${first()}," + " the last element is ${last()}" } println(firstAndLast) //The first element is one, the last element is five Funkcje run //run - obiekt kontekstowy dostępny jako this; zwraca wynik lambdy //podobny do with ale jest funkcją rozszerzającą val firstAndLast2 = numbers.run { "The first element is ${first()}," + " the last element is ${last()}" } //bardzo podobnie można użyć let (różnica it.first() i it.last()) //run jest również dostępny jak zwykła funkcja (nierozszerzająca) //pozwala wykonać blok instrukcji tam gdzie jest potrzebne jest wyrażenie val hexNumberRegex = run { val digits = "0-9" val hexDigits = "A-Fa-f" val sign = "+-" Regex("[$sign]?[$digits$hexDigits]+") } for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) { print("${match.value} ") } //+123 -FFFF 88 println() Funkcja apply //apply - obiekt kontekstowy dostępny jako this; zwraca obiekt, dla //którego było wywołane; zalecana do bloków kodu wykonujących //operacje na składowych obiektu; najbardziej typowo do //konfiguracji obiektu //data class MutablePair(var first: Int, var second: Int) val pair = MutablePair(1, 2).apply { first = 3 second = 4 } println(pair) //MutablePair(first=3, second=4) Funkcje also, takeIf, takeUnless //also - obiekt kontekstowy it; zwracana obiekt, dla którego wywołane //używane do działań wymagających referencji do obiektu (a nie jego //właściwości i metod) albo wtedy gdy potrzebna jest referencja this z //zewnętrznego zakresu (nie chcemy przesłonić) numbers.also { println("The list elements before adding new one: $it") }.add("six") println(numbers) //[one, two, three, four, five, six] //dodatkowe funkcje takeIf i takeUnless //przydatne do wstawiania warunków w łańcuchy wywołań val number = (0..99).random() println(number) //39 val evenOrNull = number.takeIf { it % 2 == 0 }.also(::println) //null val oddOrNull = number.takeUnless { it % 2 == 0 }.also(::println) //39 } Destrukturyzacja data class Result(val result: Int, val status: String) fun getResult(): Result { return Result(0,"OK") } fun main(args: Array) { //deklaracja destrukturyzująca (zamiast używania metod component1(), //component2() z klasy danych) val (result, status) = getResult() println("$result $status") //0 OK val (_, status2) = getResult() //"_" jeżeli jakiś val resultList = listOf(Result(0,"OK"), Result(1,"NOT OK")) for ((result,status) in resultList) { print("$result $status ") //0 OK 1 NOT OK } println() Destrukturyzacja //destrukturyzacja i mapy val map = mapOf(0 to "OK", 1 to "NOT OK") for ((key,value) in map) { print("$key $value ") //0 OK 1 NOT OK } println() //destrukturyzacja i lambdy //zamiast map.mapValues { // entry -> "${entry.value}" }.also(::println) map.mapValues { (key, value) -> "$value" }.also(::println) //{0=OK, 1=NOT OK} //{ a,b ->... } - lambda z 2 parametrami //{ (a,b) ->... } - lambda z 1 parametrem i destrukturyzacją //{ (a,b),c ->... } - lambda z 2 parametrami i destrukturyzacją } Domain Specific Language (DSL) Używając nazwanych funkcji jako budowniczych (builders) w połączeniu z literałami funkcyjnymi z odbiorcą (receiver), możliwe jest tworzenie w Kotlinie bezpiecznych ze względu na typ (type- safe), statycznie typowanych budowniczych Budowniczy umożliwiają tworzenie języków specyficznych dla domeny (DSL) opartych na Kotlinie, odpowiednich do budowania złożonych hierarchicznych struktur danych w sposób semi- deklaratywny HTML DSL //parametr bezparametrowa metoda klasy HTML; lambda przekazana do funkcji //zawiera wywołania metod klasy HTML (head, body) fun html(init: HTML.() -> Unit): HTML { val html = HTML() html.init() //tutaj wykonają się metody return html } // parametr to nazwa elementu w html class HTML : TagWithText("html") { //tworzymy obiekt Head i wykonujemy metody z klasy Head wywołane w //lambdzie (konfigurujemy obiekt Head()) //initTag to metoda tej klasy odziedziczona po klasie Tag //wywołanie tej funkcji oznacza, że do obiektu HTML dodajemy potomka //HEAD fun head(init: Head.() -> Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init) } HTML DSL class Head : TagWithText("head") { fun title(init: Title.() -> Unit) = initTag(Title(), init) } class Title : TagWithText("title") interface Element { fun render(builder: StringBuilder, indent: String) } class TextElement(val text: String) : Element { override fun render(builder: StringBuilder, indent: String) { builder.append("$indent$text\n") } } class Body : BodyTag("body") class B : BodyTag("b") class P : BodyTag("p") class H1 : BodyTag("h1") HTML DSL class A : BodyTag("a") { var href: String get() = attributes["href"]!! //mapa attributes zdefiniowana w klasie Tag set(value) { attributes["href"] = value } } //klasa BodyTag zawiera kilka funkcji do dodawania typowych elementów (potomków) abstract class BodyTag(name: String) : TagWithText(name) { fun b(init: B.() -> Unit) = initTag(B(), init) fun p(init: P.() -> Unit) = initTag(P(), init) fun h1(init: H1.() -> Unit) = initTag(H1(), init) fun a(href: String, init: A.() -> Unit) { val a = initTag(A(), init) a.href = href } } HTML DSL abstract class TagWithText(name: String) : Tag(name) { //pozwala dodać zwykłego tekstowego potomka wewnątrz elementu HTML operator fun String.unaryPlus() { children.add(TextElement(this)) //lista children zdefiniowana w klasie //Tag } } //tworzymy adnotację do oznaczania elementów DSL (chodzi o to który obiekt //(receiver) będzie otrzymywał wywołania metod przy zagnieżdżonych lambdach) @DslMarker annotation class HtmlTagMarker //klasa zawierająca wspólne elementy @HtmlTagMarker abstract class Tag(val name: String) : Element { val children = arrayListOf() val attributes = hashMapOf() HTML DSL //wywołanie tej metody powoduje dodanie do bieżącego elementu np. HTML //potomka przekazanego jako parametr np. BODY //np. dostajemy obiekt np. tag=Body() i lambdę z wywołaniami metod Body protected fun initTag(tag: T, init: T.() -> Unit): T { tag.init() //wykonujemy lambdę z np. wywołaniami metod Body (obiekt Body //jest skonfigurowany) children.add(tag) //dodajemy go jako potomka bieżącego elementu return tag } //metoda jest odpowiedzialna za dodanie tego elementu i wszystkich jego //potomków w postaci tekstowej do StringBuildera; z zadanym wcięciem override fun render(builder: StringBuilder, indent: String) { builder.append("$indent\n") for (c in children) { c.render(builder, indent + " ") } builder.append("$indent\n") } HTML DSL //przekształcenie atrybutów do postaci tekstowej private fun renderAttributes(): String { val builder = StringBuilder() for ((attr, value) in attributes) { builder.append(" $attr=\"$value\"") } return builder.toString() } override fun toString(): String { val builder = StringBuilder() render(builder, "") return builder.toString() } } HTML DSL fun main(args: Array) { Title val page=html { head { title {+"Title"} Header 1 } Paragraph body { bold h1 {+"Header 1"} p { link +"Paragraph " b {+"bold "} a(href = "https://kotlinlang.org") {+"link"} } } } println(page) } Architektura aplikacji Architektura aplikacji z Android Jetpack Działanie aplikacji mobilnej Typowa aplikacja na Androida zawiera wiele elementów: aktywności, fragmenty, usługi, dostawcy treści i odbiorcy rozgłoszeń Użytkownicy często wchodzą w interakcję z wieloma aplikacjami w krótkim czasie -> aplikacje muszą dostosowywać się do różnych rodzajów przepływów pracy i zadań sterowanych przez użytkownika Urządzenia mobilne mogą mieć ograniczone zasoby → dowolnym momencie system operacyjny może zakończyć niektóre procesy aplikacji Możliwe jest, że komponenty aplikacji będą uruchamiane pojedynczo i poza kolejnością, a system operacyjny lub użytkownik może je w każdej chwili zniszczyć Programista na te zdarzenia nie ma wpływu → nie należy przechowywać (długo) w pamięci żadnych danych ani stanów aplikacji w komponentach aplikacji; komponenty aplikacji nie powinny być od siebie zależne Podstawowe zasady architektoniczne Architektura aplikacji definiuje granice pomiędzy częściami aplikacji oraz obowiązki, jakie powinna mieć każda część Separacja zagadnień – bardzo ważna zasada częsty błąd - zapisywanie całego kodu w aktywności lub fragmencie aktywności/fragmenty powinny zawierać wyłącznie logikę obsługującą interakcje interfejsu użytkownika i systemu operacyjnego programista nie tworzy implementacji aktywności czy Fragmentu (dziedziczy po nich); są to klasy spajające system z aplikacją; ponieważ mogą być zniszczone najlepiej zminimalizować zależność działania aplikacji od nich Podstawowe zasady architektoniczne Sterowanie UI przez dane - interfejs użytkownika powinien opierać się na modelach danych, najlepiej trwałych Modele danych są niezależne od elementów interfejsu użytkownika i innych komponentów aplikacji → nie są one powiązane z cyklem życia UI i komponentów aplikacji, ale nadal zostaną zniszczone, gdy system operacyjny podejmie decyzję o usunięciu procesu aplikacji z pamięci. Modele trwałe są idealne z następujących powodów: użytkownicy nie stracą danych, jeśli system zakończy aplikację aplikacja będzie nadal działać w przypadkach, gdy połączenie sieciowe jest niestabilne lub niedostępne Dzięki architekturze aplikacji opartej na klasach modelu danych aplikacja będzie bardziej testowalna i niezawodna Podstawowe zasady architektoniczne Pojedyncze źródło prawdy - nowy typ danych powinien być związany z pojedynczym źródłem prawdy (single source of truth = SSOT), które jest właścicielem tych danych i tylko SSOT może je modyfikować Aby to osiągnąć, SSOT udostępnia dane przy użyciu niezmiennego typu Aby zmodyfikować dane, SSOT udostępnia funkcje lub odbiera zdarzenia Ten wzór przynosi wiele korzyści: Centralizuje wszystkie zmiany dotyczące określonego typu danych w jednym miejscu Chroni dane przed niekontrolowanymi modyfikacjami W aplikacji offline źródłem prawdy dla danych aplikacji jest zazwyczaj baza danych; w niektórych innych przypadkach ViewModel lub nawet interfejs użytkownika Podstawowe zasady architektoniczne Jednokierunkowy przepływ danych (unidirectional data flow = UDF) często stosowany w połączeniu z zasadą pojedynczego źródła prawdy w UDF stan płynie tylko w jednym kierunku; zdarzenia modyfikujące przepływ danych w przeciwnym kierunku w Androidzie stan/dane zwykle przepływają z typów hierarchii o szerszym zakresie (żyjących dłużej) do typów o krótszym zakresie (żyjących krócej) zdarzenia są zwykle wyzwalane w typach o krótszym niższym zakresie i są przekazywane dopóki nie osiągną SSOT dla odpowiedniego typu danych Wzorzec ten lepiej gwarantuje spójność danych, jest mniej podatny na błędy, jest łatwiejszy do debugowania i zapewnia wszystkie zalety wzorca SSOT Zalecana architektura aplikacji Z omówionych zasad wynika, że każda aplikacja powinna składać się z co najmniej dwóch warstw: Warstwy interfejsu użytkownika - wyświetlająca dane aplikacji na ekranie Warstwa danych - zawierająca logikę biznesową aplikacji i udostępniająca dane aplikacji Opcjonalnie można dodać warstwę https://developer.android.com domeny, aby uprościć i ponownie wykorzystać interakcje między interfejsem użytkownika a warstwami danych Współczesna architektura aplikacji Współczesna architektura aplikacji (Modern App Architecture) zachęca do stosowania między innymi następujących technik: Architektury reaktywnej i warstwowej Jednokierunkowego przepływu danych (UDF) we wszystkich warstwach aplikacji Warstwy interfejsu użytkownika z posiadaczami stanów do zarządzania złożonością interfejsu użytkownika Współprogramów (coroutines) i przepływ (flows) Najlepszych praktyk wstrzykiwania zależności Zarządzanie zależnościami między komponentami Klasy w aplikacji zależą od innych klas. Aby uzyskać zależności określonej klasy, można użyć jednego z następujących wzorców projektowych: wstrzykiwanie zależności (dependency injection) - umożliwia klasom definiowanie ich zależności bez ich konstruowania; inna klasa jest odpowiedzialna za zapewnienie tych zależności w trakcie wykonania aplikacji lokalizator usług (service locator) - udostępnia rejestr, w którym klasy mogą uzyskać swoje zależności zamiast je konstruować Zalecane jest stosowanie wzorca wstrzykiwania zależności i korzystanie z biblioteki Hilt Ogólne najlepsze praktyki W większości przypadków poniższych zaleceń sprawi, że kod aplikacji lepszy, testowalny i łatwiejszy w utrzymaniu w dłuższej perspektywie Nie należy przechowywać danych w komponentach aplikacji Należy unikać sytuacji, w której punkty wejścia aplikacji (aktywności, usługi i odbiorcy rozgłoszeń) są źródłami danych Każdy komponent aplikacji jest raczej krótkotrwały (w zależności od interakcji z użytkownikiem i stanu systemu) Punkty wejścia powinny tylko pobierać podzbiory danych istotnych dla tego punktu wejścia z innych elementów aplikacji Ogólne najlepsze praktyki Należy ograniczać zależność kodu od klas typowych dla Androida Komponenty aplikacji powinny być jedynymi klasami, które opierają się na API Androida (np. na klasach Context czy Toast) Oddzielenie od nich innych klas w aplikacji pomaga w testowalności i ogranicza zależności w aplikacji Granice odpowiedzialności pomiędzy różnymi modułami w aplikacji powinny być dobrze zdefiniowane Np. nie należy umieszczać kodu ładującego dane z sieci w wielu klasach lub pakietach Nie należy implementować wielu funkcjonalności takich jak buforowanie danych i wiązanie danych - w tej samej klasie Moduły powinny udostępniać tylko niezbędne elementy Nie należy tworzyć skrótów, które udostępniają szczegóły wewnętrznej implementacji modułu (zyski są krótkotrwałe) Ogólne najlepsze praktyki Warto skoncentrować się na rozwoju unikalnych cech aplikacji i korzystać z gotowych rozwiązań Do typowych problemów warto wykorzystać biblioteki Jetpack (i inne zalecane) Należy zapewnić możliwość testowania każdej części aplikacji oddzielnie interfejsy API powinny być dobrze zdefiniowane – wtedy np. dobry interfejs do pobierania danych z sieci ułatwia testowanie modułu utrwalającego te dane w lokalnej bazie danych Typy powinny być odpowiedzialne za swoją politykę współbieżności Jeśli typ wykonuje długotrwałe prace blokujące, powinien być odpowiedzialny za przeniesienie tych obliczeń do odpowiedniego wątku Typ zna rodzaj obliczeń, które wykonuje i w którym wątku powinny być wykonanie Typy powinny być bezpieczne dla głównego wątku (main-safe) = można je bezpiecznie wywoływać z głównego wątku bez jego blokowania Należy zapisać jak najwięcej istotnych i świeżych danych Użytkownicy będą mogli korzystać z funkcjonalności aplikacji nawet wtedy, gdy ich urządzenie będzie w trybie offline Warstwa UI Rola i elementy warstwy UI Rolą warstwy UI (lub warstwy prezentacji) jest wyświetlanie danych aplikacji na ekranie (interfejs użytkownika jest wizualną reprezentacją stanu aplikacji pobraną z warstwy danych) Za każdym razem, gdy dane się zmieniają (interakcja użytkownika, zmiana danych z zewnątrz) interfejs użytkownika powinien zostać zaktualizowany, aby odzwierciedlić zmiany Warstwa interfejsu użytkownika to potok, który konwertuje zmiany danych aplikacji do postaci, którą może zaprezentować interfejs użytkownika, a następnie je wyświetla Warstwa UI składa się z dwóch elementów: elementy interfejsu użytkownika renderujące dane na ekranie https://developer.android.com (widoki / compose) posiadacze stanów (np. ViewModel) przechowują dane, udostępniają je interfejsowi użytkownika i obsługują logikę Architektura warstwy UI Termin interfejs użytkownika odnosi się do elementów interfejsu użytkownika, takich jak aktywności i fragmenty, które wyświetlają dane (nieważne jakiego API używają Views czy Jetpack Compose) Ponieważ rolą warstwy danych jest przechowywanie danych aplikacji, zarządzanie nimi i zapewnianie dostępu do nich, warstwa interfejsu użytkownika musi wykonać następujące kroki: Pobrać dane aplikacji i przekształcić je w dane, które interfejs użytkownika może wyrenderować Korzystając z danych dla interfejsu użytkownika utworzyć elementy interfejsu użytkownika Reagować na zdarzenia wejściowe użytkownika z elementów interfejsu użytkownika i w razie potrzeby odzwierciedlić ich skutki w danych interfejsu użytkownika Powtarzać kroki od 1 do 3 tak długo, jak to konieczne Dalej zostanie pokazany sposób realizacji tych zadań Definiowanie stanu UI https://developer.android.com Definicja stanu interfejsu użytkownika powinna być niezmienna (właściwości definiowane za pomocą val, List (a nie MutableList)…) Główną zaletą takiego rozwiązania jest to, że niezmienne obiekty zapewniają gwarancję stanu aplikacji w danym momencie Dzięki temu interfejs użytkownika może skupić się na jednej roli: odczytywaniu stanu i odpowiedniej aktualizacji elementów interfejsu użytkownika Nigdy nie należy modyfikować stanu interfejsu użytkownika bezpośrednio w interfejsie użytkownika (chyba że sam interfejs użytkownika jest jedynym źródłem jego danych) Naruszenie tej zasady skutkuje powstaniem wielu źródeł prawdy dla tej samej informacji, co prowadzi do niespójności danych i subtelnych błędów Zarządzanie tworzeniem stanu poprzez jednokierunkowy przepływ danych Stan interfejsu użytkownika jest niezmienną migawką szczegółów wymaganych do renderowania interfejsu Stan ten może z czasem się zmieniać Przydatny może być mediator, który przetworzy zdarzenia, zdefiniuje logikę, którą należy zastosować do każdego z nich i wykona wymagane przekształcenia w źródłach danych w celu utworzenia stanu interfejsu użytkownika Logika obsługi zdarzeń nie powinna być umieszczana w samym interfejsie użytkownika bo UI stanie się: staje się właścicielem danych, producentem danych, przekształca dane… O ile stan interfejsu użytkownika nie jest bardzo prosty, wyłączną odpowiedzialnością interfejsu użytkownika powinno być wykorzystanie i wyświetlenie stanu interfejsu użytkownika Jednokierunkowy przepływ danych Właściciele stanu = klasy odpowiedzialne za tworzenie stanu interfejsu użytkownika i zawierające niezbędną logikę Typ ViewModel jest zalecaną implementacją do zarządzania stanem interfejsu użytkownika na poziomie ekranu z dostępem do warstwy danych Klasy ViewModel definiują logikę, która ma być stosowana do zdarzeń w aplikacji i w rezultacie generuje zaktualizowany stan Interakcję między interfejsem użytkownika a jego klasą ViewModel można w rozumieć jako „wejście zdarzenia” i wynikające z niego „wyjście stanu” Jednokierunkowy przepływ danych Wzorzec, w którym stan płynie w dół, a zdarzenia w górę, nazywany jest jednokierunkowym przepływem danych (unidirectional data flow = UDF) ViewModel przechowuje i udostępnia stan, który ma być używany przez UI (stan UI = dane aplikacji przekształcone przez ViewModel) Interfejs użytkownika powiadamia ViewModel o zdarzeniach użytkownika ViewModel obsługuje działania użytkownika i aktualizuje stan (w tym powiadamia warstwę danych https://developer.android.com o zmianach) UDF modeluje cykl produkcji stanu Zaktualizowany stan jest przesyłany z powrotem do interfejsu użytkownika w celu renderowania Rozdziela: (1) miejsce, w którym powstają zmiany stanu, (2) miejsce, w którym ulegają Powyższe powtarza się dla każdego zdarzenia one przemianie, i (3) miejsce, w którym są powodującego zmianę stanu ostatecznie skonsumowane Rodzaje logiki Typy logiki Logika biznesowa - to implementacja wymagań produktu dotyczących danych aplikacji (np. dodanie artykułu do zakładek w aplikacji); logika biznesowa zazwyczaj jest umieszczana w warstwie domeny lub danych (chociaż w warstwie UI (ViewModel) mogą też występować jej elementy np. łączenie danych z kilku repozytoriów) Logika UI - określa sposób wyświetlania zmian stanu na ekranie (np. odczytanie odpowiedniego tekstu z zasobów do wyświetlenia na ekranie, przejście do określonego ekranu po kliknięciu przycisku, wyświetlenie komunikatu użytkownika za pomocą tostu lub snack bara) Logika interfejsu użytkownika, szczególnie jeśli wykorzystuje typy interfejsu użytkownika, takie jak Context, powinna znajdować się w interfejsie użytkownika, a nie w ViewModelu W przypadku złożonego UI można delegować logikę UI do prostej klasy posiadacza stanu - proste klasy utworzone w interfejsie użytkownika mogą przyjmować zależności SDK Androida (zgodny cykl życia z cyklem UI); Obiekty ViewModel mają dłuższą żywotność. Udostępnianie stanu UI W przypadku zastosowania UDF można uznać wytworzony stan za strumień = biegiem czasu zostanie utworzonych wiele wersji stanu Stan UI należy udostępniać użytkownika w obserwowalnym pojemniku na dane, takim jak LiveData lub StateFlow → UI może reagować na zmiany bez ciągłego pobierania danych z ViewModelu Typy te zawsze pamiętają najnowszą wersję stanu interfejsu użytkownika (przydatne do szybkiego przywracania UI po zmianach konfiguracji) W aplikacjach Jetpack Compose można używać API obserwowalnego stanu Compose, takich jak mutableStateOf() lub snapshotFlow(), do udostępniania stanu interfejsu użytkownika Dowolny obserwowalny nośnik danych, taki jak StateFlow lub LiveData można łatwo wykorzystać w Compose przy użyciu odpowiednich funkcji rozszerzających Gdy udostępniane dane są stosunkowo proste, często warto opakować dane w prostą klasę (reprezentującą cały stan UI) - w miarę jak UI staje się coraz bardziej złożony, można łatwo jest dodać do definicji stanu UI dodatkowe informacje Udostępnianie stanu UI Typowym sposobem tworzenia strumienia UiState jest udostępnienie zmiennego strumienia wspierającego jako niezmiennego strumienia odczytywanego z ViewModel class MyViewModel : ViewModel() { var uiState by mutableStateOf(MyUiState()) private set private val _uiState2 = MutableStateFlow(MyUiState()) val uiState2: StateFlow = _uiState2.asStateFlow() //... } Udostępnianie stanu UI ViewModel może też udostępniać metody, które wewnętrznie modyfikują stan, publikując aktualizacje do wykorzystania przez interfejs użytkownika class MyViewModel(private val repository: MyRepository) : ViewModel() { var uiState by mutableStateOf(UiState()) private set private var fetchJob: Job? = null fun fetchArticles(category: String) { fetchJob?.cancel() fetchJob = viewModelScope.launch { try { val myItems = repository.fetchItemsForCategory(category) uiState = uiState.copy(myItems = myItems) } catch (ioe: IOException) { //obsługa błędu i powiadomienie UI val messages = getMessagesFromThrowable(ioe) uiState = uiState.copy(userMessages = messages) } } }} Zawartość obiektu stanu UI Obiekt stanu UI powinien obsługiwać stany, które są ze sobą powiązane (mniejsza liczba niespójności, ułatwia zrozumienie kodu) Udostępnienie danych w dwóch różnych strumieniach może spowodować że jeden zostanie zaktualizowany, a drugi nie Niektóre elementy logiki mogą wymagać kombinacji źródeł np. przycisk zakładki jest pokazywany, gdy użytkownik jest zalogowany i jest subskrybentem płatnego serwisu data class MyUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val myItems: List = listOf() ) val MyUiState.canBookmark: Boolean get() = isSignedIn && isPremium Wiele czy jeden strumieni stanu UI? Kluczową zasadą przy wyborze między udostępnianiem stanu UI w pojedynczym lub w wielu strumieniach jest relacja między emitowanymi elementami Największą zaletą udostępniania jednego strumienia jest wygoda i spójność danych (konsumenci stanu zawsze mają dostęp do najnowszych informacji w dowolnym momencie) Istnieją przypadki, w których odpowiednie mogą być oddzielne strumienie stanu z ViewModel: Niepowiązane typy danych - niektóre stany potrzebne do renderowania UI mogą być całkowicie niezależne od siebie Różnicowanie UiState (UiState diffing) więcej pól w obiekcie UiState → większa szansa że strumień zostanie wyemitowany w wyniku aktualizacji jednego z jego pól Widoki nie mają mechanizmu różnicującego, pozwalającego sprawdzić, czy kolejne emisje są różne, czy takie same, każda emisja powoduje aktualizację widoku Konieczne może być złagodzenie skutków za pomocą API przepływów (Flow) lub metod takich jak distinctUntilChanged() w LiveData (odfiltrowuje powtórzenia tej samej wartości) Konsumowanie stanu UI Aby wykorzystać strumień obiektów UiState w interfejsie użytkownika należy użyć końcowego operatora dla używanego obserwowalnego typu danych LiveData – metoda observe(), przepływy Kotlina – metoda collect() lub jej odmiana @Composable fun MyScreen( viewModel: MyViewModel = viewModel() ) { // Wyświetlenie elementów UI na podstawie viewModel.uiState } Zdarzenia UI Zdarzenia UI to akcje, które powinny być obsługiwane w warstwie UI przez sam UI lub przez ViewModel Najpopularniejszym rodzajem zdarzeń są zdarzenia użytkownika (wywołane jego interakcją z aplikacją) - UI wykorzystuje te zdarzenia za pomocą wywołań zwrotnych, takich jak wywołania zwrotne np. onClick() ViewModel jest zwykle odpowiedzialny za obsługę logiki biznesowej (zapewnia dostęp do logiki biznesowej) konkretnego zdarzenia użytkownika np. kliknięcia przycisku odświeżenia danych zwykle udostępnia metody, które interfejs użytkownika może wywołać, delegujące / deleguje operacje do warstwy domenowej lub warstwy danych Zdarzenia użytkownika mogą również mieć logikę zachowania UI, którą UI obsługuje bezpośrednio (np. nawiguje do innego ekranu, wyświetla Snackbar) Drzewo decyzyjne zdarzeń UI https://developer.android.com Zmniejszenie zależności między UI a ViewModelem nie każdy element UI nie zawsze potrzebuje bezpośredniego dostępu do ViewModelu Logikę można przekazać do elementu stanu jako lambdę – element UI korzysta z lambdy a nie z view modelu Pozwala to zmniejszyć prawdopodobieństwo nadużywania funkcjonalności udostępnianej przez ViewModel i osłabienia zależności między elementami aplikacji data class ItemUiState(val id: Int, val title: String, val text: String, val bookmarked: Boolean = false, val onBookmark: () -> Unit) class LatestNewsViewModel(private val repository: ItemRepository): ViewModel() { val uiItems = repository.latestItems.map { item -> ItemUiState( id = item.id, title = item.title, text = item.text, bookmarked = item.bookmarked, // przekazanie logiki biznesowej jako lambdy do wywołania przez UI przy //obsłudze zdarzeń onBookmark = { repository.addBookmark(item.id) } ) }} Obsługa zdarzeń ViewModelu Działania UI wynikające ze zdarzeń pochodzących z ViewModelu powinny zawsze skutkować aktualizacją stanu UI Powoduje to, że zdarzenia są powtarzalne po zmianach konfiguracji i gwarantuje, że działania interfejsu użytkownika nie zostaną utracone data class LoginUiState( val isLoading: Boolean = false, val errorMessage: String? = null, val isUserLoggedIn: Boolean = false ) class LoginViewModel : ViewModel() { var uiState by mutableStateOf(LoginUiState()) private set //... } Obsługa zdarzeń ViewModelu @Composable fun LoginScreen(viewModel: LoginViewModel = viewModel(),onUserLogIn: () -> Unit ) { //remember nie wykonuje się przy rekompozycji; rememberUpdatedState - zawsze //pamięta najnowszą wartość parametru (w tym wypadku najnowszą lambdę) val currentOnUserLogIn by rememberUpdatedState(onUserLogIn) // Za każdym razem gdy zmienia się uiState sprawdzamy czy użytkownik jest //zalogowany LaunchedEffect(viewModel.uiState) { if (viewModel.uiState.isUserLoggedIn) { //nawigowanie do właściwego celu w zależności od stanu zalogowania currentOnUserLogIn() } } //... } Konsumowanie zdarzeń może wyzwalać aktualizacje stanu Konsumowanie zdarzeń ViewModelu w interfejsie użytkownika może skutkować innymi aktualizacjami stanu UI Np. podczas wyświetlania przejściowych komunikatów na ekranie, UI musi powiadomić ViewModel, aby wyzwolił kolejną aktualizację stanu, gdy komunikat zostanie wyświetlony na ekranie Zdarzenie, które ma miejsce, gdy użytkownik skonsumuje wiadomość (poprzez odrzucenie jej lub po przekroczeniu limitu czasu), można potraktować jako „wprowadzanie danych przez użytkownika” i o tym ViewModel powinien być poinformowany Konsumowanie zdarzeń może wyzwalać aktualizacje stanu ViewModel nie musi wiedzieć, w jaki sposób interfejs użytkownika wyświetla komunikat na ekranie; po prostu wie, że istnieje komunikat użytkownika, który należy wyświetlić class LatestItemsViewModel : ViewModel() { var uiState by mutableStateOf(LatestItemsUiState()) private set fun refreshItems() { viewModelScope.launch { //jeżeli nie ma połączenia - wyświetlić komunikat if (!internetConnection()) { uiState = uiState.copy(userMessage = "No Internet connection") return@launch } //pobranie nowych danych... } } fun userMessageShown() { uiState = uiState.copy(userMessage = null) } } Konsumowanie zdarzeń może wyzwalać aktualizacje stanu Po wyświetleniu komunikatu przejściowego interfejs użytkownika musi powiadomić o tym ViewModel, powodując kolejną aktualizację stanu interfejsu użytkownika, która wyczyści właściwość userMessage @Composable fun LatestItemsScreen( snackbarHostState: SnackbarHostState, viewModel: LatestItemsViewModel = viewModel(), ) { //jakieś UI... //jeżeli są jakieś wiadomości do pokazania - wyświetlić i powiadomić //ViewModel viewModel.uiState.userMessage?.let { userMessage -> LaunchedEffect(userMessage) { snackbarHostState.showSnackbar(userMessage) //powiadomienie viewModelu viewModel.userMessageShown() } }} Rodzaje stanu UI Istnieją dwa typy stanu interfejsu użytkownika: Stan ekranu UI – stan do wyświetlenia na ekranie np. klasa stanu zawierająca informacje do wyświetlenia; ten stan jest zwykle połączony z innymi warstwami hierarchii, ponieważ zawiera dane aplikacji. Stan elementu UI - właściwość nieodłącznie związana z elementami UI, które wpływają na sposób ich renderowania (np. widoczny/ukryty) W systemie widoków stan elementu jest wewnętrzny W Compose stan jest zewnętrzny w stosunku do elementu komponowalnego Cykl życia elementów aplikacji oraz typy stanu i logiki interfejsu użytkownika Warstwa UI składa się z dwóch części: jednej zależnej i drugiej niezależnej od cyklu życia interfejsu użytkownika To rozdzielenie określa źródła danych dostępne dla każdej części i dlatego wymaga różnych typów stanu i logiki interfejsu użytkownika: Niezależna od cyklu życia UI – ta część warstwy UI zajmuje się warstwami aplikacji generującymi dane (danych lub domeny) i jest zdefiniowana przez logikę biznesową; Cykl życia, zmiany konfiguracji i odtwarzanie aktywności w UI mogą mieć wpływ na to, czy potok produkujący stanu UI jest aktywny, ale nie mają wpływu na ważność generowanych danych Zależna od cyklu życia UI - ta część warstwy UI zajmuje się logiką interfejsu użytkownika; zmiany konfiguracji i cykl życia bezpośrednio wpływają na ważność źródeł danych → stan może ulec zmianie jedynie wtedy, gdy aktywny jest ich cykl życia (przykład pobieranie zasobów zależnych od konfiguracji, takich jak zlokalizowane ciągi znaków) Cykl życia elementów aplikacji oraz typy stanu i logiki interfejsu użytkownika Część niezależna od cyklu życia UI Część zależna od cyklu życia UI Logika biznesowa Logika UI Stan ekranu --- Potok produkcji stanu UI Potok = kroki podjęte w celu wytworzenia stanu interfejsu użytkownika Niektóre interfejsy użytkownika mogą korzystać zarówno z części potoku niezależnych od cyklu życia interfejsu użytkownika, jak i zależnych od cyklu życia interfejsu użytkownika, albo z żadnej z nich. Prawidłowe są następujące permutacje potoku warstwy interfejsu użytkownika: Stan interfejsu użytkownika tworzony i zarządzany przez sam interfejs użytkownika np. prosty element komponowalny realizujący podstawowy licznik (przyciski ++/--) Logika UI → UI np. pokazanie lub ukrycie przycisku umożliwiającego użytkownikowi przeskoczenie na początek listy Logika biznesowa → UI np. element UI wyświetlający na ekranie zdjęcie bieżącego użytkownika Logika biznesowa → Logika UI → UI np. element UI, który przewija się, aby wyświetlić na ekranie odpowiednie informacje dla danego stanu interfejsu użytkownika Potok produkcji stanu UI Gdy oba rodzaje logiki są stosowane w potoku produkcji stanu interfejsu użytkownika, logika biznesowa musi być zawsze stosowana przed logiką UI Próba zastosowania logiki biznesowej po logice UI oznaczałaby, że logika biznesowa zależy od logiki interfejsu użytkownika – jest to problem, który jest związany z rodzajami logiki i właścicielami ich https://developer.android.com stanów Posiadacze stanu i ich obowiązki Obowiązkiem posiadacza stanu jest przechowywanie stanu, aby aplikacja mogła go odczytać W przypadkach, gdy potrzebna jest logika, pełni ona rolę pośrednika i zapewnia dostęp do źródeł danych, w których znajduje się wymagana logika - posiadacz stanu deleguje logikę do odpowiedniego źródła danych Korzyści: UI jest proste (po prostu czyta swój stan); łatwość konserwacji (modyfikacja logiki bez zmiany UI); testowalność (UI i logikę produkcji stanu można testować niezależnie); czytelność kodu Niezależnie od rozmiaru i zakresu UI, każdy element interfejsu użytkownika ma relację 1:1 z odpowiadającym mu posiadaczem stanu Rodzaje posiadaczy stanu (w zależności od tego jak są związane z cyklem życia UI): posiadacz stanu logiki biznesowej posiadacz stanu logiki UI Lokiga biznesowa i jej posiadacz stanu Posiadacze stanów logiki biznesowej przetwarzają zdarzenia użytkownika i przekształcają dane z warstw danych lub domeny w celu ich wyświetlenia na ekranie Posiadacz stanu logiki biznesowej jest zwykle implementowany za pomocą instancji ViewModel, ponieważ obiekty ViewModel zapewniają wiele funkcji opisanych dalej Aby zapewnić właściwe działanie aplikacji i wygodę użytkownika posiadacze stanów korzystający z logiki biznesowej powinni mieć następujące właściwości (następny slajd) Lokiga biznesowa i jej posiadacz stanu Właściwość Opis właściwości posiadacza stanu logiki biznesowej Tworzyć stan UI Są odpowiedzialni za utworzenie stanu UI dla swoich interfejsów. Stan UI jest często wynikiem przetwarzania zdarzeń użytkownika i odczytywania danych z warstwy domeny i danych Zachowywać stan Zachowują swoje stany i potoki przetwarzania stanów podczas po odtworzeniu odtwarzania aktywności; gdy nie można zachować posiadacza stanu i aktywności zostaje on odtworzony (zwykle po zakończeniu procesu), posiadacz stanu musi być w stanie łatwo odtworzyć swój ostatni stan Przechowywać Posiadacze stanów logiki biznesowej są często używani do zarządzania długotrwały stan stanami celów nawigacji; często zachowują swój stan po zmianach nawigacji, dopóki nie zostaną usunięte z grafu nawigacji Unikalny dla Tworzą stan dla określonej funkcji aplikacji np. TaskEditViewModel i swojego interfejsu zawsze mają zastosowanie tylko do tej funkcji aplikacji. Jednakże ten użytkownika i nie sam posiadacz stanu może obsługiwać konkretną funkcję w różnych nadaje się do wersjach aplikacji (np. na urządzenia mobilne i na telewizory) ponownego użycia ViewModel jako posiadacz stanu logiki biznesowej ViewModel w aplikacjach dla Androida nadaje się do zapewnienia dostępu do logiki biznesowej i przygotowania danych aplikacji do prezentacji na ekranie. Zalety ViewModel: Operacje wyzwalane przez ViewModels przetrwają zmiany konfiguracji Integracja z mechanizmami nawigacji Komponent nawigacji buforuje ViewModele, gdy ekran znajduje się na stosie (back-stack) - po powrocie do celu nawigacji zapewniony jest dostęp do wcześniej załadowanych danych ViewModel jest usuwany, gdy cel nawigacji zostanie usunięte ze stosu (back-stack), co zapewnia automatyczne oczyszczenie stanu (nie jest to równoważne nasłuchiwaniu usunięcia elementu komponowalnego, które może zdarzyć się z wielu powodów np. nawigacja, zmiana konfiguracji) Integracja z innymi bibliotekami Jetpack, takimi jak Hilt (wstrzykiwanie zależności) Logika interfejsu użytkownika i jej posiadacz stanu Logika UI działa na danych dostarczanych przez sam UI (stan elementów, źródła danych UI np. API uprawnień lub zasoby) Posiadacze stanów z logiką UI zazwyczaj mają następujące właściwości: Tworzą stan UI i zarządzają stanem elementów UI Nie są zachowywani przy odtworzeniu aktywności - posiadacze stanów w logice UI są często uzależnieni od źródeł danych z samego UI -> próby zachowania mogą spowodować wyciek pamięci (np. przy zmianie konfiguracji); jeśli istnieje potrzeba długotrwałego zachowania danych należy przekazać je innemu komponentowi (przykładem jest rememberSaveable() w Compose) Logika interfejsu użytkownika i jej posiadacz stanu Zawierają referencje do źródeł danych o zakresie jakim jak UI - można bezpiecznie odwoływać się do źródeł danych, takich jak API związane z cyklem życia i zasoby (cykl życia jest taki sam jak posiadacza) Można ich wielokrotnie używać w wielu interfejsach użytkownika - różne instancje tego samego posiadacza stanu logiki UI mogą być ponownie wykorzystywane w różnych częściach aplikacji Posiadacz stanu logiki UI jest zwykle implementowany za pomocą zwykłej klasy ponieważ sam UI jest odpowiedzialny za utworzenie posiadacza i posiadają taki sam cykl życia np. w Compose posiadacz stanu jest częścią kompozycji i podąża za cyklem życia kompozycji Uwaga: Zwykłe klasy posiadacza stanu są używane, gdy logika UI jest na tyle złożona, że ​można ją przenieść poza UI; w przeciwnym razie logikę UI można zaimplementować w bezpośrednio w UI Zależności między posiadaczami stanu Posiadacze stanów mogą zależeć na innych posiadaczach stanów, o ile zależności mają równy lub krótszy czas życia np. posiadacz stanu logiki UI może zależeć od innego posiadacza stanu logiki UI posiadacz stanu ekranu może zależeć od posiadacza stanu logiki UI Przykład sytuacji, w której jeden posiadacz żyje dłużej niż inny - posiadacz stanu logiki UI, zależny od posiadacza stanu na poziomie ekranu (np. ViewModelu) → zmniejszałoby to możliwość ponownego użycia posiadacza stanu o krótszym czasie życia i dałoby mu dostęp do większej ilości logiki i stanu, niż faktycznie potrzebuje Jeśli posiadacz stanu o krótszym okresie życia potrzebuje danych od posiadacza stanu o większym zasięgu, jako parametr należy przekazać tylko te informacje, których potrzebuje, zamiast przekazywać instancję posiadacza stanu Przykład zależności @Stable class DrawerState() { //stan szuflady (z Compose) //zależy od innego, wewnętrznego stanu Swipeable internal val swipeableState = SwipeableState() //... } @Stable class MyAppState( // stan aplikacji zależy od stanu szuflady private val drawerState: DrawerState, private val navController: NavHostController ) { } @Composable fun rememberMyAppState( drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), navController: NavHostController = rememberNavController() ): MyAppState = remember(drawerState, navController) { MyAppState(drawerState, navController) } Przykład zależności (posiadacz o dłuższym czasie życia zależny od innego) @Stable class MyScreenState( // NIE PRZEKAZYWAĆ ViewModelu do zwykłej klasy posiadacza stanu // private val viewModel: MyScreenViewModel, // Unit, scaffoldState: ScaffoldState = rememberScaffoldState() ): MyScreenState = remember(someState, doSomething, scaffoldState) { MyScreenState(someState, doSomething, scaffoldState) } @Composable fun MyScreen( modifier: Modifier = Modifier, viewModel: MyScreenViewModel = viewModel(), state: MyScreenState = rememberMyScreenState(someState = viewModel.uiState.map { it.toSomeState() }, doSomething = viewModel::doSomething), //... ) { } Potok produkujący stan UI Produkcja stanu w aplikacjach na Androida może być traktowana jako potok przetwarzania obejmujący: Wejścia - są źródłami zmiany stanu; mogą być: lokalne (w warstwie UI): zdarzenia użytkownika (np. wprowadzenie tekstu) lub API zapewniające dostęp do logiki UI, która steruje zmianami stanu UI (np. wywołanie metody open() na DrawerState) zewnętrzne (dla warstwy UI): źródła z warstwy domeny lub danych, które powodują zmiany stanu UI (np. wiadomości, które zakończyły ładowanie z NewsRepository) kombinacja powyższych Posiadacze stanów - typy, które stosują logikę biznesową i/lub logikę interfejsu użytkownika do źródeł zmiany stanu i przetwarzają zdarzenia użytkownika w celu wygenerowania stanu interfejsu użytkownika Wyjścia - stan UI, który aplikacja ma renderować Potok produkujący stan UI https://developer.android.com API używane w tworzeniu stanu Istnieją dwa główne API używane w produkcji stanów Wybór asynchronicznego API dla wejścia ma większy wpływ na charakter potoku produkcyjnego stanu niż wybór obserwowalnego API dla wyjścia (dane wejściowe decydują o rodzaju przetwarzania) Etap potoku API Wejście Należy używać asynchronicznych API do wykonywania pracy poza wątkiem UI; np. współprogramy lub przepływy (flows) Kotlina, RxJava lub wywołania w Javie Wyjście Należy używać obserwowalnych API posiadaczy danych, aby unieważnić i ponownie wyrenderować UI gdy stan się zmieni; np. StateFlow, State z Compose lub LiveData Jednorazowe API jako źródła zmiany stanu Jednorazowe (one-shot) API to MutableStateFlow i mutableStateOf (w Compose) Obydwa API oferują metody umożliwiające bezpieczne, niepodzielne aktualizacje wartości, które przechowują, niezależnie od tego, czy aktualizacje są synchroniczne, czy asynchroniczne @Stable //użycie mutableStateOf w produkcji stanu interface DiceUiState { val diceValue: Int? //immutable } class MutableDiceUiState : DiceUiState { override var diceValue: Int? by mutableStateOf(null) //mutable } class DiceRollViewModel : ViewModel() { private val _uiState = MutableDiceUiState() //udostępnienie jako niezmienny stan val uiState: DiceUiState = _uiState // wywoływane z UI fun rollDice() { _uiState.diceValue = Random.nextInt(from = 1, until = 7) } } Jednorazowe API jako źródła zmiany stanu //użycie mutableStateFlow w produkcji stanu data class DiceUiState2(val firstDiceValue: Int? = null) class DiceRollViewModel2 : ViewModel() { private val _uiState = MutableStateFlow(DiceUiState2()) // udostępnianie jako niezmienny StateFlow val uiState: StateFlow = _uiState.asStateFlow() // wywoływane z UI fun rollDice() { _uiState.update { currentState -> currentState.copy(firstDiceValue = Random.nextInt(from = 1, until = 7)) } } } Modyfikacja stanu z wywołań asynchronicznych W przypadku zmian stanu, które wymagają wyniku wywołania asynchronicznego należy uruchomić współprogram w odpowiednim zakresie (CoroutineScope). Dzięki temu aplikacja może odrzucić pracę w przypadku anulowania Uwaga: współprogramy uruchomione w viewModelScope działają do zakończenia (normalne lub z wyjątkiem), niezależnie od tego, czy UI jest widoczny, czy nie, chyba że współprogramy zostaną jawnie anulowane lub ViewModel zostanie usunięty Zwykle to nie stanowi problemu ponieważ żądania najczęściej są krótkotrwałe (nie należy używać viewModelScope do żądań trwających 5 sekund lub dłużej) @Stable interface AddEditTaskUiState { val title: String val description: String val userMessage: String? val isTaskSaved: Boolean } private class MutableAddEditTaskUiState : AddEditTaskUiState { override var title: String by mutableStateOf("") override var description: String by mutableStateOf("") override var userMessage: String? by mutableStateOf(null) override var isTaskSaved: Boolean by mutableStateOf(false) } Modyfikacja stanu z wywołań asynchronicznych class AddEditTaskViewModel(val repository:Repository) : ViewModel() { private val _uiState = MutableAddEditTaskUiState() val uiState: AddEditTaskUiState = _uiState private fun createNewTask() { viewModelScope.launch { //ViemModel ma zakres do uruchamiania współprogramów val newTask = Task(uiState.title, uiState.description) try { repository.saveTask(newTask) //zapisane danych (asynchroniczne) _uiState.isTaskSaved = true //modyfikacja stanu } catch(cancellationException: CancellationException) { throw cancellationException //przerwanie współprogramu } catch(exception: Exception) { _uiState.userMessage = getErrorMessage(exception)) } } } } Modyfikacja stanu z wątku działającego w tle Preferowane jest uruchamianie współprogramów w głównym dyspozytorze (main dispacher) w celu wygenerowania stanu interfejsu użytkownika (czyli poza blokiem withContext w przykładzie poniżej) Jeżeli konieczna jest aktualizacja stanu UI w innym kontekście w tle to można: użyć metody withContext(), aby uruchomić współprogram w innym współbieżnym kontekście przy korzystaniu z MutableStateFlow użyć metody update() aby zaktualizować stan (jak zwykle) przy korzystaniu ze stanu w Compose użyć Snapshot.withMutableSnapshot, aby zagwarantować niepodzielne aktualizacje stanu w współbieżnym kontekście Modyfikacja stanu z wątku działającego w tle class DiceRollViewModel3( private val defaultDispatcher: CoroutineContext = Dispatchers.Default //pula wątków ) : ViewModel() { private val _uiState = MutableDiceUiState() val uiState: DiceUiState = _uiState //wywoływane z UI fun rollDice() { viewModelScope.launch() { //inne współprogramy które można wywołać z bierzącego kontekstu //... //zmiana kontekstu withContext(defaultDispatcher) { //aby zagwarantować atomowe aktualizacje Snapshot.withMutableSnapshot { //SlowRandom - klasa, która wykonuje powolne obliczenia _uiState.diceValue = SlowRandom.nextInt(from = 1, until = 7) } } } } } Strumienie jako źródła zmiany stanu W przypadku źródeł zmiany stanu, które z biegiem czasu wytwarzają w strumieniach wiele wartości, agregowanie wyników wszystkich źródeł w spójną całość jest proste do zrealizowania za pomocą combine() class InterestsViewModel(authorsRepository: AuthorsRepository, topicsRepository: TopicsRepository) : ViewModel() { //agregowanie zmian stanu val uiState = combine( authorsRepository.getAuthorsStream(), //Flow topicsRepository.getTopicsStream(), //Flow ) { availableAuthors, availableTopics -> InterestsUiState.Interests(authors = availableAuthors, topics = availableTopics) }.stateIn( //konwersja połączonego Flow do StateFlow //(typ, który może być obserwowany przez UI) scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), //świadomy cyklu życia initialValue = InterestsUiState.Loading //początkowa wartość ) } Strumienie jako źródła zmiany stanu Użycie operatora stateIn do tworzenia StateFlows zapewnia UI bardziej szczegółową kontrolę nad aktywnością potoku produkującego stan. Jako parametr started należy użyć: SharingStarted.WhileSubscribed() - jeśli potok powinien być aktywny tylko wtedy, gdy interfejs użytkownika jest widoczny SharingStarted.Lazily - jeśli potok powinien być aktywny tak długo, jak użytkownik może wrócić do danego UI (tzn. gdy UI na stosie (back-stack) lub w innej zakładce) Jednorazowe i strumieniowe API jako źródła zmiany stanu Gdy potok produkujący stanu zależy zarówno od wywołań jednorazowych (one- shot), jak i strumieni należy przekonwertować wywołania jednorazowe na strumienie i przetwarzać jak w poprzednim przykładzie class TaskDetailViewModel(private val tasksRepository: Repository,savedStateHandle: SavedStateHandle) : ViewModel() { //... private var _isDeleted by mutableStateOf(false) private val _task = tasksRepository.getTaskStream(taskId) val uiState: StateFlow = combine( snapshotFlow { _isDeleted }, //konwersja State do Flow _task //Flow ) { isDeleted, task -> TaskDetailUiState( task = task.data, isDeleted = isDeleted ) } Jednorazowe i strumieniowe API jako źródła zmiany stanu // konwersja na MutableStateFlow - obserwowalny typ do użycia w UI.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = TaskDetailUiState() ) fun deleteTask() = viewModelScope.launch { tasksRepository.deleteTask(taskId) _isDeleted = true } } Inicjalizacja potoku produkującego stan Inicjalizacja polega na ustawieniu warunków początkowych; może obejmować podanie początkowych wartości wejściowych niezbędnych do uruchomienia potoku np. identyfikatora w widoku szczegółowego artykułu lub rozpoczęcia ładowania asynchronicznego Jeśli to możliwe, należy leniwie zainicjować potok ; w praktyce często oznacza to czekanie, aż pojawi się odbiorca produktu - API Flow pozwala na to za pomocą argumentu start w metodzie stateIn Gdy stateIn() nie ma to zastosowania, należy zdefiniować idempotentną funkcję initialize(), aby jawnie uruchomić potok produkujący stanu Inicjalizacja potoku produkującego stan class MyViewModel : ViewModel() { private var initializeCalled = false // metoda jest idempotenta tylko jeżeli jest wywoływana z głównego wątku @MainThread fun initialize() { if(initializeCalled) return initializeCalled = true viewModelScope.launch { //ustawienie wartości początkowych potoku produkującego stan } }} Należy unikać uruchamiania operacji asynchronicznych w bloku init lub konstruktorze ViewModelu Operacje asynchroniczne nie powinny być efektem ubocznym tworzenia obiektu, ponieważ kod asynchroniczny może odczytywać lub zapisywać obiekt przed jego pełną inicjalizacją Nazywa się to „wyciekiem obiektu” i może prowadzić do subtelnych i trudnych do zdiagnozowania błędów Jest to szczególnie ważne podczas pracy ze stanem Compose - gdy ViewModel przechowuje pola stanu Compose, nie wolno uruchamiać współprogramów w bloku inicjującym ViewModel, który aktualizuje pola stanu Compose (wystąpi wyjątek IllegalStateException) Warstwa danych Rola i elementy warstwy danych Warstwa danych aplikacji zawiera logikę biznesową, która składa się z reguł określających, w jaki sposób aplikacja tworzy, przechowuje i zmienia dane Warstwa danych składa się z repozytoriów Każde repozytorium zawierać zero lub więcej źródeł danych Należy utworzyć klasę repozytorium dla każdego typu danych w swojej aplikacji (np. https://developer.android.com MoviesRepository dla filmów i PaymentsRepository dla płatności) Rola i elementy warstwy danych Klasy repozytorium odpowiadają za następujące zadania: Udostępnianie danych pozostałej części aplikacji Centralizacja zmian w danych Rozwiązywanie konfliktów między wieloma źródłami danych Abstrahowanie źródeł danych od reszty aplikacji Realizowanie logiki biznesowej Każda klasa źródła danych powinna odpowiadać za pracę tylko z jednym źródłem danych (plik, sieć, lokalna baza) https://developer.android.com Klasy źródeł danych stanowią pomost pomiędzy aplikacją a faktycznym źródłem danych Rola i elementy warstwy danych Inne warstwy w hierarchii nigdy nie powinny mieć bezpośredniego dostępu do źródeł danych; punktami wejścia do warstwy danych są zawsze klasy repozytorium Posiadacze stanów lub klasy przypadków użycia (warstwa domenowa) nigdy nie powinny mieć źródła danych jako bezpośredniej zależności Używanie klas repozytoriów jako punktów wejścia umożliwia niezależne skalowanie różnych warstw architektury Dane udostępniane przez warstwę danych powinny być niezmienne; wtedy: inne klasy nie mogą nimi manipulować (nie będzie niespójności) mogą być również bezpiecznie obsługiwane przez wiele wątków API udostępniania danych Klasy w warstwie danych zazwyczaj udostępniają funkcje umożliwiające wykonywanie jednorazowych wywołań Create, Read, Update i Delete (CRUD) lub otrzymywanie powiadomień o zmianach danych w czasie Warstwa danych powinna udostępniać następujące elementy w każdym z tych przypadków: operacje jednorazowe - funkcje wstrzymujące (suspend) w Kotlinie; lub wywołania zwrotne albo typy RxJava Single, Maybe lub Completable w Javie dane zmieniające się w czasie – przepływy (Flow) w Kotlinie; lub wywołania zwrotne, które emitujące nowe dane, lub typy RxJava Observable lub Flowable w Javie Wiele poziomów repozytoriów W przypadkach obejmujących bardziej złożone wymagania biznesowe repozytorium może wymagać zależności od innych repozytoriów (np. dane są agregacją z wielu źródeł) Przykładowo repozytorium obsługujące dane uwierzytelniające użytkownika, UserRepository, może zależeć od LoginRepository i https://developer.android.com RegistrationRepository Uwaga: Czasami klasy repozytorium zależne innych repozytoriów są nazywane Manager czyli UserManager zamiast UserRepository Wielowątkowość Odwołania do źródeł danych i repozytoriów powinno być bezpieczne dla głównego wątku (main-safe) tzn. można bezpiecznie wywoływać ich metody z głównego wątku; klasy te odpowiadają za przeniesienie wykonania swojej logiki do odpowiedniego wątku podczas wykonywania długotrwałych operacji blokujących; Większość źródeł danych udostępnia już bezpieczne API, takie jak wywołania metod wstrzymujących (suspend) np. Room, Retrofit lub Ktor; wskazane jest aby repozytoria korzystały z tych API Reprezentowanie modeli biznesowych Modele danych udostępniane z warstwy danych, mogą stanowić podzbiór informacji uzyskanych z różnych źródeł danych Aplikacja często nie potrzebuje wszystkich informacji udostępnianych przez źródło Dobrą praktyką jest oddzielanie klas modeli – pobieranych ze źródła i udostępnianych aplikacji (udostępniane są dane tylko takie, których wymagają inne warstwy hierarchii) Oddzielenie klas modeli jest korzystne z następujących powodów: Oszczędza pamięć aplikacji, redukując dane tylko do tych potrzebnych Dostosowuje typy danych zewnętrznych do typów danych używanych przez aplikację (np. inna reprezentacja dat) Zapewnia lepsze rozdzielenie problemów (separation of concerns) – zespół może podzielić się pracą nad różnymi warstwami jeśli klasa modelu została wcześniej zdefiniowana Można rozszerzyć tę praktykę i zdefiniować osobne klasy modeli także w innych częściach architektury aplikac

Use Quizgecko on...
Browser
Browser