Règles générales concernant :
Android n'est pas que un système d'exploitation
Jusqu'à la version 4.4 d'Android, la machine virtuelle Dalvik est utilisée pour interpréter du Java
Cette machine virtuelle, un peu différente de la JVM (machine virtuelle d'Oracle) est spécialement conçue pour les téléphones de l'époque (beaucoup moins performants qu'aujourd'hui)
A partir de la version 5.0 d'Android, Dalvik est remplacée par ART (Android RunTime)
ART a été développé par Google pour pallier les problèmes de performances de Dalvik qui se faisait vieillissant
Sur ce tableau, il y a 2 informations très importantes :
Mais pour comprendre leur importance, il faut comprendre ce qu'ils veulent dire et donc comment Android fonctionne
Android est avant tout un système d'exploitation
Cala signifie, pour faire trèèèès simple, qu'il y a un ensemble de programmes, de composants, ... bref de code Android dans votre téléphone
C'est ce qu'on appelle pour un langage de programmation une "bibliothèque standard" : un ensemble de variables, fonctions, etc. déjà en place pour aider le développeur
Avec Android, il y a également toute une bibliothèque graphique utilisée pour construire l'UI de nos applications
L'ensemble de la bibliothèque standard, bibliothèque graphique, etc. est ce qu'on appel l'API (Application Programing Interface) d'Android
Ainsi, les versions d'API d'Android correspondent à des mises à jour de cette API
Ajout de fonctions, mise à jours de composants graphiques, correction de bugs, etc.
BREF, un peu comme une version de PHP, de HTML ou autre quoi...
SAUF qu'il y a une différence majeure à prendre en compte !
L'ensemble de l'API Android est embarquée dans les smartphones !
Cela signifie que c'est lorsqu'un utilisateur met à jour son système d'exploitation qu'il met en fait à jour l'API Android utilisée par son Smartphone
Cela signifie donc que nous avons PLEINS de versions d'API d'Android dans la nature !
Résultat de ce constat :
Il serait impensable de développer une application Android en se basant sur le fait que les utilisateurs auront la dernière version !
Il faut donc en permanence penser aux utilisateurs avec de plus vieilles versions et parfois adapter son code en fonction !
Vous comprenez mieux l'utilité de ce tableau ?
Le premier outil indispensable pour Android est le SDK (Software Development Kit)
Il s'agit d'un ensemble d'outils utiles au développeur :
Le second outil utile est un IDE
En téléchargeant Android Studio, le SDK Android est compris dedans :)
On l'a dit, Android est de base prévu pour le langage Java
De très très nombreuses applications mobiles sont donc développées en Java
Mais ... l'entreprise JetBrain (à l'origine des IDE IntelliJ, Android Studio, etc.) travaillait depuis quelques années sur un langage nommé Kotlin
Kotlin est un langage de programmation qui permet de compiler directement pour la machine virtuelle Java
Cela permet notamment d'avoir une interroperabilité parfaite entre Java et Android
On peut écrire du kotlin et du Java dans un même projet, ça fonctionne !
Si vous ajoutez à ça le fait que Kotlin est peut-être l'un des 2 langages les plus modernes (avec Swift) , la grande majorité des dévelopeurs Java ont vu l'arrivée de Kotlin comme l'héritier et l'avenir de Java
Ainsi, et devant l'engouement général :
Convertir un projet Java en Kotlin, bien que les langages soient interroperables, n'est pas forcément chose aisée
C'est pourquoi il est toujours possible de croiser des projets écrits en Java !
La plupart du temps, les développeurs maintiennent le Java, développe les nouvelles features en kotlin et convertissent le Java petit à petit
Mais nous allons quand même nous concentrer uniquement sur Kotlin parce que :
Kotlin, uniquement un choix de modernité ?
Bien que la modernité et l'efficacité de Kotlin soit un argument avéré, il n'est pas le seul qui a poussé Google à remplacer Java...
Il existe en effet un très gros conflit entre Oracle (société qui possède Java) et Google
En 2010, Oracle a assigné Google en justice, lui réclamant 9 milliard de dollars pour avoir utiliser des packages Java dans Android sans payer de droits de licences
Ce conflit entre Oracle et Google dure depuis lors et est un cas très intéressant de notion de droits de propriété sur des codes sources, sur l'open source, etc.
Nul doute que la conclusion de ce conflit aura un impact énorme sur le monde du logiciel et la notion de droit d'auteur associée
Je vous invite à étudier de votre côté ce conflit s'il vous intéresse, car ce n'est pas réellement l'objet de notre cours
Mais ce conflit a quand même eu un impact énorme sur le développement Android !
En effet, Android ne peut plus supporter que la version 7 du JDK Java et quelques features de la version 8
Or le JDK de Java en est maintenant à la version 16...
Ainsi, l'arrivée de Kotlin était providentielle !
Kotlin permet à Google de se détacher de plus en plus de Java et donc d'Oracle tout en étant un langage extrêmement moderne (bien plus que les versions 7 et 8 de Java)
Pour apprendre à coder en Kotlin (en dehors d'Android), vous pouvez utiliser :
Nous allons apprendre les BASES de Kotlin !
Pour aller plus loin : https://kotlinlang.org/docs/reference/
fun main(args: Array<String>) {
print("Hello World !")
}val a: Int = 42
// Le type est déclaré après le nom
val b = 5
// le type Int est inféré (Kotlin sait comprend que vous voulez mettre un Int)
val c: Int
c = 3
// L'initialisation ayant lieu après, nous devons préciser le typeVAL signifie, comme final en Java, que la référence de la variable est immutable
val languages = mutableListOf("Java", "JavaScript")
languages.add("Kotlin")
// OK
languages = mutableListOf("Other", "List")
// PAS OK !!! la référence languages est immutableVAR signifie que la variable est mutable
var a = 3
a = 42
// OK, parce que a est mutable
val b = 3
b = 12
// PAS OK, parce que b est immutableCONST signifie que la variable est ce qu'on appelle une "compile-time constant"
const val CODE = 42Les Strings
Il y a 2 "types" de Strings :
val text =
"""
Salut
Voici un test qui interprète les sauts
de ligne !
"""Les Strings
Kotlin a un système de templating de strings
fun main() {
var userName = "Thomas"
hello(userName)
}
fun hello(name: String) {
print("Hello $name")
}Les Strings
Kotlin a un système de templating de strings
fun main() {
println("On peut aussi appeller une fonction ${test()}")
}
fun test(): Int {
return 42
}Une fonction de base en kotlin
fun main() {
println(test())
}
fun test(): Int {
return 42
}Un seul retour ? On peut raccourcir alors !
fun main() {
println(test())
println(multiply(3, 2))
}
fun test(): Int = 42
fun multiply(a: Int, b: Int): Int = a * bParamètres nommés (et du coup on peut changer l'ordre !)
fun main() {
hello("Thomas", "Ecalle")
hello(lastName = "Ecalle", firstName = "Thomas")
}
fun hello(firstName: String, lastName: String) {
println("Hello $firstName $lastName!")
}Valeurs par défaut
fun main() {
hello()
hello("Babar")
hello(name = "Babar")
}
fun hello(name: String = "Babar") {
println("Hello $name !")
}Fonction locales
fun main() {
println(isMultipleOf8And2(8)) // true
println(isMultipleOf8And2(5)) // false
println(isMultipleOf8And2(16)) // true
}
fun isMultipleOf8And2(n: Int): Boolean {
fun isMultipleOf(operand: Int): Boolean = n % operand == 0
return isMultipleOf(2) && isMultipleOf(8)
}Extensions
Il est possible en Kotlin d'étendre le comportement d'une classe existante en lui ajoutant des fonctions
fun String.log(warning: Boolean = false) {
val label = if (warning) "Warning" else "Info"
println("$label : $this")
}
fun main() {
val text = "An incredible text"
text.log()
text.log(warning = true)
}Il est aussi possible en kotlin d'overrider le comportement des opérateurs !
fun main(args: Array<String>) {
val p1 = Point(3, -8)
val p2 = Point(2, 9)
var sum = Point()
sum = p1 + p2
println("sum = (${sum.x}, ${sum.y})")
}
class Point(val x: Int = 0, val y: Int = 10) {
operator fun plus(p: Point) : Point {
return Point(x + p.x, y + p.y)
}
}fun main(args: Array<String>) {
val a = 3
val b = 42
var max = a
if (a < b) max = b
var test: Int
if (a > b) {
test = a
} else {
test = b
}
val maximum = if (a > b) a else b
}En Kotlin, il n'y a pas de Switch, mais When, extrêmement utile
fun main(args: Array<String>) {
val x = 42
when (x) {
1 -> print("x == 1")
2 -> print("x == 2")
else -> {
print("x is neither 1 nor 2")
}
}
}fun main(args: Array<String>) {
val x = 42
when (x) {
0, 1 -> print("x == 0 or x == 1")
else -> print("otherwise")
}
}fun main(args: Array<String>) {
val x = 42
when (x) {
in 1..10 -> print("x is in the range")
in validNumbers -> print("x is valid")
!in 10..20 -> print("x is outside the range")
else -> print("none of the above")
}
}fun main(args: Array<String>) {
val x = 42
println(hasPrefix(x))
}
fun hasPrefix(x: Any) = when(x) {
is String -> x.startsWith("prefix")
else -> false
}fun main(args: Array<String>) {
val x = 42
when {
x.isOdd() -> print("x is odd")
y.isEven() -> print("y is even")
else -> print("x+y is even.")
}
}
Les boucles FOR
fun main(args: Array<String>) {
val array = arrayOf(3, 4, 18, 39)
for (item: Int in array) {
println(item)
}
}Les boucles FOR
fun main(args: Array<String>) {
for (i in 1..3) {
println(i)
}
for (i in 6 downTo 0 step 2) {
println(i)
}
}Les boucles FOR
fun main(args: Array<String>) {
val array = arrayOf(3, 4, 18, 39)
for ((index, value) in array.withIndex()) {
println("the element at $index is $value")
}
}Constructeur primaire
class Person constructor(firstName: String) {
}
class User(name: String){
}
fun main(args: Array<String>) {
val user = User("Bob")
val person = Person("Toto")
}le constructeur primaire ne peut contenir de code, il faut donc utiliser le bloc init
class InitOrderDemo(name: String) {
init {
println("First initializer block that prints ${name}")
}
init {
println("Second initializer block that prints ${name.length}")
}
}
fun main(args: Array<String>) {
val a = InitOrderDemo("toto")
}class Customer(name: String) {
val customerKey = name.toUpperCase()
}
fun main(args: Array<String>) {
val customer = Customer("toto")
println(customer.customerKey)
}Les paramètres du constructeur primaire peuvent être utilisés directement
class Customer(val name: String) {
}
fun main(args: Array<String>) {
val customer = Customer("toto")
println(customer.name)
}Pour définir une propriété ET la binder avec les paramètres du constructeur, il y a une manière très simple :
class Customer(private val name: String) {
}
fun main(args: Array<String>) {
val customer = Customer("toto")
println(customer.name)
}On peut y définir leur visibilité aussi :
Provoque donc une erreur !
Une classe peut avoir d'autres constructeurs
class Person(val name: String) {
var children: MutableList<Person> = mutableListOf<Person>()
constructor(name: String, parent: Person) : this(name) {
parent.children.add(this)
}
}
fun main(args: Array<String>) {
val homer = Person("Homer")
val bart = Person("Bart", parent = homer)
println(homer.children)
}class Constructors {
init {
println("Init block")
}
constructor(i: Int) {
println("Constructor")
}
}
fun main(args: Array<String>) {
val a = Constructors(42)
}Les blocs init seront toujours appelés en premiers
class User(val name: String = "Bob") {
}
fun main(args: Array<String>) {
val bob = User()
val jimmy = User("Jimmy")
val michel = User(name = "Michel")
println(bob.name)
println(jimmy.name)
println(michel.name)
}On peut mettre des valeurs par défaut et les paramètres peuvent être nommés !
L'héritage
L'héritage
enum class Speciality {
KOTLIN,
FLUTTER
}
open class Employee(val name: String) {
fun sayHello() {
println("Hello, my name is $name")
}
}
class Developer(name: String, val speciality: Speciality): Employee(name) {
}
fun main(args: Array<String>) {
val bob = Developer("Bob", Speciality.FLUTTER)
bob.sayHello()
}L'héritage
open class Employee {
constructor(lastName: String) {
}
constructor(firstName: String, lastName: String) {
}
}
class Developer: Employee {
constructor(lastName: String): super(lastName)
constructor(firstName: String, lastName: String): super(firstName, lastName)
}L'héritage
Si une classe veut rendre overridable une méthode, elle doit le spécifier explicitement avec le mot open
L'héritage
open class Employee(val name: String) {
open fun sayHello() {
println("Hello, my name is $name")
}
}
class Developer(name: String): Employee(name) {
override fun sayHello() {
println("Hello, my name is $name and I am a developer")
}
}
fun main(args: Array<String>) {
val bob = Developer("Bob")
val john = Employee("John")
bob.sayHello()
john.sayHello()
}L'héritage
class Person(val name: String) {
var children: MutableList<Person> = mutableListOf<Person>()
constructor(name: String, parent: Person) : this(name) {
parent.children.add(this)
}
override fun toString(): String {
return "$name (children = $children)"
}
}
fun main(args: Array<String>) {
val homer = Person("Homer")
val bart = Person("Bart", parent = homer)
val bartSon = Person("Bart son", parent = bart)
println(homer.children)
}L'héritage
abstract class Employee(val name: String) {
open fun sayHello() {
println("Hello, my name is $name")
}
}
class Developer(name: String): Employee(name) {
override fun sayHello() {
println("Hello, my name is $name and I am a developer")
}
}
class Random(name: String): Employee(name) {
}
fun main(args: Array<String>) {
val bob = Developer("Bob")
val john = Random("John")
bob.sayHello()
john.sayHello()
}Classes abstraites
L'héritage
interface MyInterface {
fun bar()
fun foo() {
println("Salut")
}
}
class A: MyInterface {
override fun bar() {
println("A bar")
}
}
class B: MyInterface {
override fun bar() {
println("B bar")
}
override fun foo() {
println("Salut de B")
}
}
fun main(args: Array<String>) {
val a = A()
val b = B()
a.bar()
a.foo()
b.bar()
b.foo()
}Interfaces
Les data class
Si vous êtes habitués à la programmation objet, vous savez qu'il y a un certain nombre de choses que l'on répète souvent lors de la création de classe
Les data class
Kotlin a inventé les Data Class qui font tous le travail pour nous !!
Sans data class
class User(val name: String, val age: Int) {
override fun toString(): String {
return "User { name: $name, age: $age}"
}
override fun equals(other: Any?): Boolean {
return (other is User) && name == other.name && age == other.age
}
override fun hashCode(): Int {
return name.hashCode() + age.hashCode()
}
}
fun main(args: Array<String>) {
val bob = User("Bob", 30)
val bob2 = User("Bob", 30)
println(bob)
println(bob2)
println(bob == bob2)
}Avec data class
data class User(val name: String, val age: Int)
fun main(args: Array<String>) {
val bob = User("Bob", 30)
val bob2 = User("Bob", 30)
println(bob)
println(bob2)
println(bob == bob2)
}Destructuring
data class User(val name: String, val age: Int)
fun main(args: Array<String>) {
val bob = User("Bob", 30)
val bob2 = User("Bob", 30)
val (name, age) = bob
val (_, bob2Age) = bob2
println(name)
println(age)
println(bob2Age)
}Les types en Kotlin sont TOUS non nullable
Cela signifie qu'ils ne pourront jamais avoir NULL comme valeur
Cette fonctionnalité ravi tous ceux qui ont déjà eu affaire, en faisant de l'Android avec Java ou même d'autres frameworks, à la fameuse exception : NullPointerException
Au delà d'éviter cette exception, ça permet aussi de réduire drastiquement tous les codes de nullité !
Il est tout de même parfois utile de rendre nullable un type, mais il faut alors le préciser explicitement avec le symbole "?"
fun main(args: Array<String>) {
var a: String = "toto"
a = null
}Null cannot be a value of a non-null type String
fun main(args: Array<String>) {
var a: String? = null
println(a)
a = "toto"
println(a)
}a est nullable (String?) et donc tout est ok ici
Pour éviter toute NullPointerException, tous les appels à des champs ou méthodes de nullable devront se faire ainsi :
data class User(val name: String)
fun main(args: Array<String>) {
var bob: User? = null
println(bob.name) // Causerait une NullPointer !!
println(bob?.name) // Parfiat, n'afichera juste rien
bob = User("Bob")
println(bob?.name) // Affiche le bon nom car bob est initialisé
}Ainsi on écrira souvent des choses comme ça :
bob?.department?.head?.nameUne telle chaîne renvoie null si un null a été trouvé
Ca nous permet d'éviter bon nombre de tests !
L'opérateur ?: (Elvis operator) permet des ternaires raccourcies pour les nullables
fun main(args: Array<String>) {
test(null)
test(30)
}
fun test(a: Int?) {
val value = a ?: 42
println(value)
}L'opérateur !! permet de forcer un appel, une manière de dire que l'on est sûr que la référence n'est pas nulle
Cet opérateur est TRES dangereux, considérez que vous n'avez pas le droit de l'utiliser sauf cas extrême
Par exemple, ceci serait impossible :
fun main(args: Array<String>) {
var a: Int? = null
a = 12
test(a)
}
fun test(a: Int) {
println(a)
}Type mismatch: inferred type is Int? but Int was expected
L'opérateur !! permet donc ce genre de choses :
fun main(args: Array<String>) {
var a: Int? = null
a = 12
test(a!!)
}
fun test(a: Int) {
println(a)
}Très dangereux car propice à beaucoup d'erreurs non maîtrisées !
Safe Cast
fun main(args: Array<String>) {
var a: Int = 3
var b: String = a as String
}Ce code provoque une ClassCastException car le Int ne peut être caster ainsi en String
Safe Cast
fun main(args: Array<String>) {
var a: Int = 3
var b: String? = a as? String
}Les nullables nous permettent de rendre ça un peu plus sécurisant en assignant null si le cast ne fonctionne pas !
En Kotlin, les fonctions sont "de premier ordre"
Cela signifie qu'elle peuvent :
Exemple de lambda :
fun main(args: Array<String>) {
val square = { number: Int -> number * number }
println(square(3))
}Voilà le genre de tricks qu'on peut faire !
fun main(args: Array<String>) {
loopOn(10, {
println("On middle !")
})
}
fun loopOn(x: Int, onMiddle: () -> Unit) {
for (i in 1..x) {
if (i == x / 2) {
onMiddle()
} else {
println(i)
}
}
}Petite astuce !
Si le dernier paramètre d'une fonction est une fonction, on peut simplifier l'écriture d'appel ainsi :
fun main(args: Array<String>) {
loopOn(10) {
println("On middle !")
}
}
fun loopOn(x: Int, onMiddle: () -> Unit) {
for (i in 1..x) {
if (i == x / 2) {
onMiddle()
} else {
println(i)
}
}
}Les types de collections principales en Kotlin sont :
Pour chaque collection, vous avez une interface non-mutable, en "read-only" et une mutable
fun main(args: Array<String>) {
val list = mutableListOf("toto", "tata")
list.add("titi")
println(list)
// Possible
val list = listOf("toto", "tata")
list.add("titi")
println(list)
// IMPOSSIBLE !
}Au delà de ce détail, les collections fonctionnent un peu comme vous en avez l'habitude
A savoir : deux listes sont égales si leur contenues sont égaux et de même taille
Les maps
fun main(args: Array<String>) {
val numbersMap = mapOf(
"key1" to 1,
"key2" to 2,
"key3" to 3,
"key4" to 1
)
println(numbersMap["key3"])
}Filtrage
fun main(args: Array<String>) {
val numbers = listOf("one", "two", "three", "four")
val longerThan3 = numbers.filter { it.length > 3 }
println(longerThan3)
val numbersMap = mapOf(
"key1" to 1,
"key2" to 2,
"key3" to 3,
"key11" to 11
)
val filteredMap = numbersMap.filter { (key, value) -> key.endsWith("1") && value > 10}
println(filteredMap)
}Plus & Minus
fun main(args: Array<String>) {
val numbers = listOf("one", "two", "three", "four")
val plusList = numbers + "five"
val minusList = numbers - listOf("three", "four")
println(plusList)
println(minusList)
}LET
Let prend l'objet invoqué, le passe dans une lambda (it) , et renvoie le résultat de la lambda (le dernier élément)
data class User(var name: String)
fun main(args: Array<String>) {
val user = User("Toto")
val newName = user.let {
it.name = "tata"
it.name
}
println(newName)
}Très utile pour gérer les nullable !
data class User(var name: String)
fun main(args: Array<String>) {
var user: User? = null
user?.let { println(it) }
user = User("Toto")
user?.let { println(it) }
}ALSO
Also permet comme son nom l'indique d'ajouter des instructions sur l'objet ciblé. Also renvoie l'objet ciblé
data class Person(var name: String, var tutorial : String)
fun main(args: Array<String>) {
var person = Person("Anupam", "Kotlin")
var l = person.let { it.tutorial = "Android" }
var al = person.also { it.tutorial = "Android" }
println(l)
println(al)
println(person)
}APPLY
Apply fonctionne comme Also mais n'oblige pas l'utilisation du it
data class Person(var name: String, var t : String)
fun main(args: Array<String>) {
var person = Person("Anupam", "Kotlin")
person.apply { t = "Swift" }
println(person)
person.also { it.t = "Kotlin" }
println(person)
}WITH
With fonctionne un peu comme Apply mais avec une syntaxe différente
data class Person(var name: String, var tutorial : String)
fun main(args: Array<String>) {
var person = Person("Anupam", "Kotlin")
with(person)
{
name = "No Name"
tutorial = "Kotlin tutorials"
}
println(person)
}Explications à l'oral des choix suivants :
Explications de ce qu'on voit sur Android Studio
Créer un émulateur
Lancer l'application
Explication de l'arborescence des fichiers
Dans un premier temps, il faut choisir la vue "Android" du projet, et non la vue "projet"
La vue "Android" affiche ne effet les fichiers d'une manière plus agréable et logique lorsqu'on développe
Comprendre Gradle
Gradle est ce qu'on appelle un "moteur de production"
Il permet de construire des projets en java, Kotlin et autres
Mise en place d'étapes de build, gestion de dépendances, ... Gradle est indispensable à un projet Android !
L'ensemble des fichiers gradle se trouvent dans le dossier Gradle Scripts
Les deux fichiers les plus importants sont
build.gradle "project"
On y retrouve :
build.gradle "app"
On y retrouve :
Différence entre versionCode et versionName ?
Lorsque vous mettrez à jour votre application sur le Store, le versionCode de celle-ci doit TOUJOURS avoir été préalablement augmenté
Le versionCode est l'identifiant de la version. Il permet aux smartphones de vos utilisateurs de comprendre qu'il y a une nouvelle version et de leur proposer de l'installer
Le versionName lui n'est qu'un nom pour l'utilisateur. Il n'est pas obligé de changer même si c'est plutôt la logique de base
Le dossier "app"
Le Manifest
Le Manifest est la carte d'identité d'une application Android
On y retrouve le nom, l'icône, mais aussi les différentes "permissions dangereuses" que l'application utilisera
Ce fichier est interprété par le Store pour afficher des informations à l'utilisateur quant à l'utilisation de l'application
Le répertoire Java
On trouve dans ce répertoire tous les fichiers de code de notre application
Il se nomme "java" mais la classe MainActivity qui a été générée est bien un fichier Kotlin.. Kotlin et Java sont interroperables !
Le répertoire RES
Le répertoire res rassemble l'ensemble des ressources du projet :
Analysons le fichier activity_main.xml dans les res/layout
Comme beaucoup de frameworks existant, Android est basé sur une séparation du code "métier" et du layout associé
Le layout en Android est composé de fichiers XML qui définissent l'ensemble de l'architecture des vues les unes avec les autres
Mais alors... est-ce que ça veut dire qu'on va écrire du XML ????
Non, rassurez-vous.... il existe évidemment une interface graphique pour positionner les éléments les uns par rapport aux autres
Avec l'interface graphique, on va pouvoir :
Mais en fait.... on va pas utiliser l'interface graphique
Bien que l'interface graphique se soit énormément amélioré par rapport au passé et notamment avec le ConstraintLayout, son utilisation reste la plupart du temps... une perte de temps
Les développeurs Android expérimentés n'utilisent presque jamais l'interface graphique
Une fois que l'on maîtrise l'édition du XMl, cela va BEAUCOUP plus vite et est beaucoup plus précis
Nous allons apprendre à maîtriser le XML et voir que ce n'est pas si compliqué et que les outils d'Android Studio nous facilitent la vie
Il existe principalement 2 types de composants XML :
Il existe de nombreux composants de layouts :
Créons notre premier LinearLayout
contenu du fichier:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:layout_height="match_parent"
android:layout_width="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
/>Il y a 2 attributs que doivent toujours impérativement avoir les composants XML :
Différentes possibilités :
Par exemple ici, ajoutons une couleur de fond à notre LinearLayout :
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/colorPrimary"
/>On peut voir d'ailleurs la manière d'aller chercher une couleur dans les ressources
Il suffit pour cela d'indiquer :
On retrouve toutes nos couleurs dans le fichier associé !
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#6200EE</color>
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>Petite exercice :
Ajoutons maintenant une texte à notre layout
La vue correspondant à un texte s'appelle TextView
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/background"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello world !"
/>
</LinearLayout>Pourquoi AndroidStudio souligne mon texte ?
Un des critères important en développement est l'internationalisation des Strings
En effet, si l'utilisateur change la langue de son téléphone, nous aimerions que cela modifie automatiquement les chaînes de caractères de l'application
Pour faire cela, on utilise un fichier de Strings dans les ressources du projet !
<resources>
<string name="app_name">Test1</string>
</resources>En soulignant le texte de notre TextView, AndroidStudio nous informe qu'il serait plus pertinent de mettre le texte dans les ressources
Petit exercice :
Astuce : inspirez-vous de ce qu'on a fait pour les couleurs
Nous allons maintenant afficher un sous-titre
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/background"
android:orientation="vertical"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/subtitle"
/>
</LinearLayout>Notez le android:orientation="vertical"
Cet attribut permet de comprendre la disposition des vues enfant du LinearLayout : verticale ou horizontale
Comment centrer un peu mieux nos vues ?
On utilise pour cela l'attribut gravity sur le LinearLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/background"
android:orientation="vertical"
android:gravity="center"
>Notez que ce genre d'écriture est possible
android:gravity="bottom|center"Comment mettre des marges ou padding entre les éléments ?
La plupart des vues et layouts ont ces attributs, à vous des les utiliser au mieux !
android:padding="20dp"
android:layout_margin="10dp"Notez d'ailleurs qu'il existe un moyen de faire en sorte que les dimensions (ce genre de valeurs 20dp, etc.) soient renseignées dans des ressources
Pour ce faire, on crée un fichier de ressource nommé dimens
Voilà ce qui est généré
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>Ca marche finalement comme les autres ressources, sauf que ce sont des dimen et non des string ou color
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="generalPadding">20dp</dimen>
</resources>Et voici comment l'utiliser !
android:padding="@dimen/generalPadding"Petit exercice :
Ecrire le XML permettant d'arriver à ce résultat :
Nous avons vu dans le chapitre précédent les bases du layouting et notamment l'utilisation du LinearLayout
Il existe d'autres composants de layouts permettant de positionner les éléments les uns par rapports aux autres (nous avons mentionné par exemple le RelativeLayout)
Seulement, depuis maintenant quelques années, il existe un composant qui surpasse largement les autres et qui est presque à 100% utilisé, quel que soit le besoin
Le ConstraintLayout
Dans ConstraintLayout, il y a Constraints : contraintes
L'idée de ce composant est en effet de permettre à toutes les vues de se contraindre les unes par rapport aux autres
Le ConstraintLayout nous permet d'aplatir la hiérarchie des vues enfants
Fini les 4 Linear dans un Linear lui même composé de X Linear, etc.
Le ConstraintLayout a bien d'autres caractéristiques qui le rendent passionnant, et nous allons voir ça ensemble
Tout d'abord, pour placer une vue dans un ConstraintLayout, il faut impérativement lui appliquer des contraines
C'est logique en fait, il faut que la vue ait assez de contraintes pour savoir où se placer dans son élément parent
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title"
/>
</androidx.constraintlayout.widget.ConstraintLayout>AndroidStudio gueule ici car notre TextView n'est soumise à aucune contrainte, ça n'est pas logique, il ne sait pas où la placer
Pour appliquer une contrainte, on utiliser les attributs suivants :
En gros il faut les lire comme :
Ainsi, pour centrer notre TextView dans son parent.. rien de plus simple !
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>Petite astuce :
Commencez à taper par exemple "botbot" dans les attributs, et vous verrez très vitre proposé le constraintBottom_toBottomOf !
Vous verrez qu'écrire les contraintes est très rapide en XML une fois que vous avez cette astuce en tête !
Petite astuce 2 :
Vous avez peut-être remarqué qu'en terme de contraintes horizontal, il y a 2 notions différentes :
A votre avis, quelle est la différence ?
Petit exercice :
Pour le moment, je peux placer les vues les unes par rapports à l'élément parent
Comment les placer les unes par rapport aux autres ?
Il suffit de donner un identifiant à nos vues !
L'identifiant est d'ailleurs ce qui permettra plus tard de cibler une vue dans notre code Kotlin, mais on y reviendra !
Voici comment déclarer un identifiant
<TextView
android:id="@+id/title"
// autres attributs ...
/>Notez le "@+id" signifiant que l'on crée cet identifiant (qui doit être unique dans le layout courant !)
Lorsqu'on voudra faire référence à cette vue, on aura qu'à écrire ça ainsi :
@id/titlePar exemple :
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
/>Petit exercice :
Grâce au principe des identifiants, concevez l'écran suivant
En contraignant toutes nos vues les unes par rapport aux autres de cette manière, nous avons crée sans le savoir une chaîne
Plus exactement une chaîne verticale ici
Il existe pleins de comportement qu'un' chaîne peut adopter, notamment son chainStyle
Il existe les chainStyle suivants : vertical et horizontal
layout_constraintVertical_chainStyle
layout_constraintHorizontal_chainStyleUn chainStyle va indiquer aux éléments d'une chaîne de quelle manière ils doivent se répartir l'espace disponible
Seul l'élément racine de la chaîne a besoin d'avoir cet attribut !
<Button
android:id="@+id/button1"
// Autres attributs ...
app:layout_constraintBottom_toTopOf="@id/button2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<Button
android:id="@+id/button2"
// Autres attributs ...
app:layout_constraintBottom_toTopOf="@id/button3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button1"
/>
<Button
android:id="@+id/button3"
// Autres attributs ...
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button2"
/>Seul le button1 en aura besoin ici !
le chainStyle peut prendre 3 valeurs :
packed
spread
spread_inside
Notion de Bias
Lorsque l'élément parent a davantage d'espace disponible que le demande l'élément enfant, l'élément enfant est centré
<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>Notion de Bias
On dit qu'il a un Bias de 50% car il est à 50% verticalement et horizontalement
Il est possible de modifier ce bias pour le placer de manière plus précise, on utilise alors :
app:layout_constraintVertical_bias
app:layout_constraintHorizontal_biasNotion de Bias
Par exemple, pour placer le bouton à 30% verticalement et 70% horizontalement :
<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias=".3"
app:layout_constraintHorizontal_bias=".7"
/>Notion de Bias
Lorsqu'on a créée une chaîne, mettre un bias sur le premier maillon provoque l'effet sur tous, car les autres sont positionnés en fonction du maillon.. tout est logique
Notion de Bias
Lorsqu'on a créée une chaîne, mettre un bias sur le premier maillon provoque l'effet sur tous lorsqu'il est placé sur l'axe principal
Sinon, chaque élément peut avoir son propre bias sur l'axe secondaire
<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 1"
app:layout_constraintBottom_toTopOf="@id/button2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintVertical_bias=".2"
/>
<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 2"
app:layout_constraintBottom_toTopOf="@id/button3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button1"
/>
<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 3"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button2"
/><Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 1"
app:layout_constraintBottom_toTopOf="@id/button2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias=".8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
/>
<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 2"
app:layout_constraintBottom_toTopOf="@id/button3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias=".2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button1"
/>
<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Boutton 3"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias=".8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/button2"
/>Petit exercice :
Codez l'écran ci-dessous en utilisant les concepts de chainStyle et de Bias
Notion de Poids
Lorsque différents éléments se partagent l'espace de leur parent, il est possible de leur attribuer un poids
Le parent accordera alors un espace proportionnel au poids attribué à chaque enfant
Pour ceux qui connaissent FlexBox en CSS, c'est un peu le même principe
Notion de Poids
<RelativeLayout
android:id="@+id/container1"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/colorPrimary"
app:layout_constraintBottom_toTopOf="@id/container2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="2"
/>
<RelativeLayout
android:id="@+id/container2"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/container1"
app:layout_constraintVertical_weight="1"
/>Notion de Guidelines
Les Guidelines dans un ConstraintLayout sont comme des lignes invisibles qui permettent de mieux délimiter des zones précises auxquelles les enfants vont se contraindre
Notion de Guidelines
Les 2 attributs importants sont :
android:orientationpour savoir si c'est une ligne horizontale ou verticale
app:layout_constraintGuide_percentpour la positionner
Petit exercice
Réalisez l'écran suivant
Pour coder la logique de notre écran, nous allons nous intéresser à la MainActivity qui tient notre layout
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}Voyez une Activity comme un élément de base d'écran Android
On pourrait faire le rapprochement :
"une activity = un écran"
On verra plus tard que ce n'est pas exactement ça, mais partons de ce principe pour le moment
Une activité que l'on crée par défaut doit hériter de AppCompatActivity
Nous avons alors accès à pleins de caractéristiques et notamment une méthode overridable : onCreate()
C'est dans cette méthode que nous allons appeler la méthode setContentView pour indiquer le Layout
Pour récupérer la référence d'une vue dans notre activité, il suffit d'utiliser la méthode findViewById<T?>()
Exemple, considérons que l'on a un TextView dans notre écran écrit ainsi :
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>Voici comment récupérer la référence puis la modifier depuis l'activité
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val title = findViewById<TextView?>(R.id.title)
title?.text = "Hello"
}
}Libre à vous ensuite d'utiliser les API des différentes vues pour interagir avec !
Par exemple : écouter le click d'un Button comme celui-ci
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:text="toto"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>Tout d'abord, on récupère la référence
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button = findViewById<Button?>(R.id.button)
button?.text = "babar"
}
}Ensuite, il est possible d'ajouter un ClickListener
Un ClickListener est une implémentation de l'interface du même nom qui sera appelé lors du click sur le bouton
button?.setOnClickListener(this)Nous pouvons par exemple passer au bouton la référence de notre activité pour lui donner la responsabilité de la gestion du click
Il est par contre évidemment nécéssaire de faire implémenter l'interface par l'activité
class MainActivity : AppCompatActivity(), View.OnClickListeneroverride fun onClick(view: View?)
{
TODO("Not yet implemented")
}Testons ça !
override fun onClick(view: View?)
{
Log.d("MonTag", "Salut !")
}Il est possible d'utiliser la gestion des callbacks de Kotlin pour écrire les ClickListener d'une manière un peu plus "sexy"
button?.setOnClickListener {
Log.d("MonTag", "Salut !")
}Enfin, il est aussi possible d'indiquer la callback du bouton.. directement dans le XML !
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="click"
android:onClick="test"
/>Il faut alors que cette fonction soit dans l'activité, et prenne une vue en paramètre :
fun test(view: View)
{
}Petit exercice
Petite Astuce
En Kotlin, plus exactement grâce aux Kotlin Extensions, il y a un petit sucre syntaxique qui nous permet d'outrepasser le findViewById
En effet :
class MainActivity : AppCompatActivity()
{
private var button: Button? = null
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button = findViewById(R.id.button)
button?.setOnClickListener {
Log.d("MonTag", "Salut !")
}
}
}devient :
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
button?.setOnClickListener {
Log.d("MonTag", "Salut !")
}
}
}Petit exercice
Réaliser l'écran suivant :
Une application mobile doit pouvoir être utilisée de plein de manières différentes
Tous ces différents cas génèrent de nombreux changements dans l'application !
Pour bien maîtriser son application de A à Z, il est impératif de maîtriser son cycle de vie
Il existe de nombreuses callbacks dans l'activité qui nous permettent d'écouter les changements du cycle de vie
Le cycle de vie d'une activité passe par 6 étapes qui ont chacune une callback associée
Petit exercice
Mettez en place des logs dans les différentes callbacks de votre activité et testez le cycle de vie
Petit exercice 2
Vous l'avez sûrement remarqué, le texte dans la TextView a disparu !
En effet, si on regarde les logs, la rotation détruit complètement l'activité ( onDestroy() )et repasse ensuite dans tout le cycle de vie de création
Il arrive donc que l'on perde l'état de l'application en fonction de ce genre d'évènements... d'où la nécessité de connaître le cycle de vie
Il existe toutefois des solutions pour garder l'état précédent et nous allons voir ça
onCreate est un peu la callback de base
Y faire ce qui a attrait à toute la vie de l'activité :
Gestion d'état
onCreate prend un paramètre
override fun onCreate(savedInstanceState: Bundle?)En fait ce Bundle est un dictionnaire contenant le précédent état de l'application
C'est grâce à ce Bundle que l'on va pouvoir gérer les changements d'états
Gestion d'état
Mais alors, quand pouvons nous remplir ce dictionnaire ?
override fun onSaveInstanceState(outState: Bundle)Grâce à la callback
qui nous permet de sauvegarder l'état avant la destruction de l'activité
Gestion d'état
Voici par exemple comment sauvegarder la valeur de notre TextView
override fun onSaveInstanceState(outState: Bundle)
{
outState.putString("MY_AWESOME_KEY", text?.text.toString())
super.onSaveInstanceState(outState)
}Gestion d'état
Gestion d'état
De retour dans notre onCreate, nous pouvons utiliser le bundle :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
savedInstanceState?.get("MY_AWESOME_KEY")?.let { previousText ->
text?.text = previousText as String
}
// ClickListener button..
}Pour finir :
La notion de cycle de vie est très importante !
La mettre de côté, c'est avoir potentiellement beaucoup de problèmes et de comportements inatendus dans l'application
Petite astuce finale :
Même si il est important de savoir gérer la rotation de l'écran et les problèmes engendrés, il arrive souvent qu'une application n'ait besoin que d'un affichage en portrait
Il existe bien sûr une manière de préciser à notre activité une orientation forcée
Ca se passe dans le Manifest !
<activity
android:name=".MainActivity"
android:configChanges="orientation"
android:screenOrientation="portrait"
>Commençons par créer une nouvelle activité
Notez que, dans le Manifest, une balise avec votre nouvelle activité est apparue :
<activity android:name=".SecondActivity">TOUTE activité doit obligatoirement être renseignée dans le Manifest, sans quoi votre application crashera
Notez également que, c'est dans cette balise que l'on indique celle qui sera lancé en première :
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>Modifions légèrement le layout de la seconde activité
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Seconde Activité !"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<Button
android:id="@+id/back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Retour"
android:layout_marginTop="30dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
/>De retour dans notre activité principale, nous allons tenter de naviguer vers la seconde !
Nous allons pour cela utiliser la méthode startActivity
startActivity prend un seul paramètre qui est un Intent
Intent signifie "intention" -> nous allons préciser quelle est notre intention, c'est à dire aller vers la seconde activité
button?.setOnClickListener {
val intent = Intent(this, SecondActivity::class.java)
startActivity(intent)
}Voici comment définir un Intent de la manière la plus simple
On lui donne le contexte de départ, la classe d'arrivée, et c'est bon !
On passe ensuite cet Intent à notre méthode, et on navigue !
Une fois sur la seconde activité, comment retourner en arrière ?
Il "suffit" de finir l'activité !
Les activités sont en fait empilées dans la "Stack de navigation"
Finir la seconde revient à la retirer de la Stack et donc à revenir à la précédente
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_second)
back?.setOnClickListener(this)
}
override fun onClick(view: View?)
{
if (view == back)
{
finish()
}
}Comment passer des paramètres ?
Nous pouvons passer un argument dans l'intent de cette manière :
val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("key", "value")
startActivity(intent)Si on veut passer plusieurs paramètres, on utilise un Bundle
val intent = Intent(this, SecondActivity::class.java)
val params = Bundle()
params.putString("firstKey", "firstValue")
params.putInt("secondKey", 42)
intent.putExtras(params)
startActivity(intent)Comment lire les paramètres ?
On peut à tout moment récupérer l'intent qui a déclenché l'activité
On peut ensuite récupérer les extras si il y'en a et les utiliser !
Comment lire les paramètres ?
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_second)
back?.setOnClickListener(this)
intent.extras?.get("firstKey")?.let {
text?.text = it as String
}
}Start Activity For Result
Il est parfois intéressant pour une activité d'en lancer une seconde et d'attendre un résultat de celle-ci
Pour cela, on utilise startActivityForResult qui prend, en plus de l'intent, le code de requête (qui sera utilisé pour lire le résultat)
startActivityForResult(intent, 32)Start Activity For Result
L'activité suivante peut alors renseigné un résultat avant de se finir :
val resultIntent = Intent()
resultIntent.putExtra("some_key", "Mon résultat")
setResult(32, resultIntent)
finish()Start Activity For Result
L'activité principale doit alors implémenter la callback suivante pour récupérer les résultats :
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?)
{
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 32)
{
val result = data?.extras?.get("some_key") as String?
text?.text = result
}
}Petit exercice
Petite variante : le pattern Factory
Un pattern souvent utilisé en programmation mobile est le pattern Factory
Nous pouvons l'utiliser pour rendre un peu plus sexy et maintenable toute notre logique de navigation
Ainsi, une activité définirait elle-même la manière dont elle est instanciée et appelée :
companion object
{
private val TITLE_KEY: String = "titleKey"
fun navigateTo(context: Context, title: String)
{
val intent = Intent(context, Activity2::class.java).apply {
putExtra(TITLE_KEY, title)
}
context.startActivity(intent)
}
}Ce qui permet :
Côté Activité appelante, ça donne ça :
Activity2.navigateTo(this, "SALUT")(The old way..)
Les Fragments sont des morceaux d'interfaces et de logiqe interchangeables et modulaires de l'application
Là où on partait du principe qu'une activité est un écran, un Fragment peut-être un écran entier ou un bout d'écran
Un Fragment ne peut pas exister en dehors du contexte d'une activité
A l'origine les Fragments ont été crées pour le développement sur tablette, pour mieux gérer le responsive (version d'api 11, HoneyComb)
Depuis... les Fragments sont très (trop?) utilisés, pour un peu tout et n'importe quoi
Certains font même des applications "Single Activity" avec une seule activité et uniquement des Fragments
Comment créer un Fragment
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>ici, notre fragment prendra tout l'écran
Ensuite, nous allons créer un Fragment
Ca nous a générer pas mal de code, mais rien de sorcier !
Android est parti du principe que notre Fragment prendrait 2 paramètres (on peut en ajouter ou en retirer, c'est juste un exemple, un template)
Une Factory a été créée pour faciliter la création d'une nouvelle instance et le passage de paramètres
Dans le OnCreate, le code généré récupère les potentiels arguments passés et les lient aux champs du fragment
Ici, on indique quel est le layout associé (il a été généré aussi)
Si on voulait afficher dans une textview la valeur de "param1", on le ferait dans le onViewCreated()
override fun onViewCreated(view: View, savedInstanceState: Bundle?)
{
super.onViewCreated(view, savedInstanceState)
titleTextview?.text = param1
}Un Fragment ... a aussi un cycle de vie !
En fait il a les même callback que l'activité + certaines propres à l'activité elle même
Pour rappel, un Fragment n'a de raison d'être qu'en étant attaché à une activité
Cycle de vie d'un fragment
On remarque les nouvelles callback :
Maintenant que le Fragment est crée, on peut aller dans l'activité pour l'appeller
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction()
.replace(R.id.container, AFragment.newInstance("Param 1", "Param 2"))
.commitNow()
}Il est possible, plutôt que d'utiliser un layout, de le remplacer par un Fragment. En effet, on peut directement renseigner un Fragment ainsi dans l'activité :
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_fixe"
android:name="com.example.test1.AFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />Mais nous préfèrerons la méthode du layout.. plus customisable !
Le FragmentManager va nous permettre de faire pas mal d'actions sur les fragments (transitions, retour en arrière, etc.)
Comment discuter entre le Fragment et l'activité ?
Comment discuter entre le Fragment et l'activité ?
Le Fragment a accès à un champs activity permettant, en castant le champs, de faire appel à une méthode spécifique de l'activité parente
(activity as MainActivity).aMethodInMainActivity()Créons une méthode "aMethodInMainActivity" dans la MainActivity
Le Fragment peut alors l'appeler ainsi
Quels sont les problèmes de cette technique ?
Nous préfèrerons utiliser le système d'interface, en imposant à une activité parente de l'implémenter
Imaginons cette interface :
interface AFragmentInterface
{
fun anAwesomeMethodCalledFromAFragment(value: String?)
}Le Fragment aura dont un champs de type AFragmentInterface
class AFragment : Fragment()
{
private var aFragmentInterface: AFragmentInterface? = null
//....Nous allons maintenant nous intéresser à la callback onAttach du Fragment
Cette méthode est appelée quand le Fragment est attaché à l'activité, et nous permet ainsi de nous assurer que celle-ci implémente bien l'interface et, si c'est le cas, la renseigner
override fun onAttach(context: Context)
{
super.onAttach(context)
try
{
aFragmentInterface = activity as AFragmentInterface
} catch (exception: ClassCastException)
{
Log.e("error", "${activity?.packageName} must implement AFragmentInterface")
}
}De son côté, l'activité n'a plus qu'à implémenter l'interface !
class MainActivity : AppCompatActivity(), AFragmentInterfaceoverride fun anAwesomeMethodCalledFromAFragment(value: String?)
{
Log.d("toto", "Je suis l'activité, le fragment m'a envoyé ça : $value")
}Enfin, imaginons que le fragment doivent envoyer une info lors du clique sur la TextView :
titleTextview?.setOnClickListener {
aFragmentInterface?.anAwesomeMethodCalledFromAFragment("YO")
}Voilà comment "proprement" faire discuter les fragments et les activités !
Il y a toutefois encore un soucis de couplage à mon sens dans cette logique.
En utilisant le principe du pattern Factory !
Nous profitons du fait qu'il soit déjà en place par défaut lors de l'instanciation d'un Fragment, et on l'utilise pour forcer l'envoie de l'interface
Il s'agit donc d'imposer l'envoie d'une instance d'interface lors de la création de l'instance de Fragment. Que cette instance soit l'activité elle-même, une lambda.. peu importe !
Ca donnerait donc, côté Fragment :
companion object
{
@JvmStatic
fun newInstance(param1: String, param2: String, aFragmentInterface: AFragmentInterface) =
AFragment().apply {
arguments = Bundle().apply {
putString(ARG_PARAM1, param1)
putString(ARG_PARAM2, param2)
}
setFragmentInterface(aFragmentInterface)
}
}fun setFragmentInterface(aFragmentInterface: AFragmentInterface)
{
this.aFragmentInterface = aFragmentInterface
}Et côté Activity :
AFragment.newInstance("param1", "param2", this)Naviguer entre les fragments
Naviguer entre les fragments
Créons un second Fragment, rien de spécial, juste un texte centré par exemple
class BFragment : Fragment()
{
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View?
{
return inflater.inflate(R.layout.fragment_b, container, false)
}
companion object
{
@JvmStatic
fun newInstance() = BFragment()
}
}Du côté de l'activité
On va réagir à la callback du Fragment en naviguant vers ce nouveau BFragment
override fun anAwesomeMethodCalledFromAFragment(value: String?)
{
// ICI !
}Et voilà !
override fun anAwesomeMethodCalledFromAFragment(value: String?)
{
supportFragmentManager.beginTransaction()
.replace(R.id.container, BFragment.newInstance())
.commitNow()
}Petit exercice final :
ça sert à quoi ?
Je vous l'ai dit dans le chapitre précédent, les Fragments ont été conçus à la base lors de l'arrivée des tablettes dans le monde Android
L'objectif était d'avoir des bouts d'interface modulables pour pouvoir s'adapter aux différentes tailles d'écrans
Ok... mais qu'est-ce que ça veut dire ?
Tout d'abord, il faut savoir qu'il est possible de créer des fichiers de layouts différents selon l'orientation du téléphone ou encore selon la taille de l'écran
Le fonctionnement est un peu le même que pour les ressources de l'application
Par exemple, créons un layout pour le téléphone en Landscape (paysage)
Pour ce faire, lors de la création de notre layout, il nous suffit de préciser comme dossier de layout le dossier : layout-land
Celui-ci sera généré s'il n'existe pas
Attention à bien spécifier le même nom que notre layout d'activité ! L'objectif est d'avoir 2 layouts différents selon l'orientation
Nous pouvons voir nettement qu'il existe maintenant 2 layouts activity_main.xml mais l'un est de base tandis que l'autre est "land"
A partir de maintenant, Android switchera automatiquement de layout selon l'orientation de votre device !
Faisons le test, en mettant l'activité à blanc en portrait et rouge en paysage !
Lancez l'application, appliquez une rotation... ça fonctionne !
Nous pouvons donc faire des layouts différents selon le mode portrait ou le mode paysage.. mais pas seulement !
Il est aussi possible de désigner une taille minimum et cela grâce au qualifieur sw<N>dp
Où l'on remplace N par la taille souhaitée
Par exemple :
Ce sont les 2 plus utilisées mais vous pouvez bien sûr mettre vos propres règles
Ainsi, si on voulait gérer : le mode portrait et le mode paysage pour les téléphones en dessous de 600dp, les tablettes 7 pouces et les tablettes 8 pouces :
Ca fait du boulot pour gérer tous les cas !
Sachez d'ailleurs que cette règle des "qualifieurs" marche aussi pour les autres ressources comme les dimens etc.
Et les Fragments dans tout ça ?
Comment feriez-vous si vous deviez créer ce genre de vue ?
Pour faire ce genre de vue, on va être obligés d'utiliser des Fragments ! Nous allons jouer à la fois sur les layouts correspondants au landscape ou non, puis nous jouerons avec le concept de Fragment pour afficher les contenu
Mais avant ça, et comme on veut afficher une liste... faisons un petit cours sur l'affichage de listes en Android
Les listes font parties des éléments primordiaux d'une application mobile
Connaissez-vous beaucoup d'applications mobiles sans liste ?
En Android, vous avez 2 composants principaux
En pratique il y a plus de différences, mais retenez simplement que les développements récents sur Android privilégient largement l'utilisation de la RecyclerView
Mettre en place une RecyclerView peut parfois paraître verbeux et "compliqué"
On va voir qu'il n'en est rien !
Une RecyclerView est composé de 4 éléments :
Petit aparté, à votre avis, qu'est-ce que le Recyclage d'une liste ?
Il arrive qu'une application mobile ait un nombre très important d'éléments à afficher dans une liste
Imaginer devoir afficher une liste de 1000 items, avec chacun une UI peaufinée, une animation, etc.
Le device doit alors faire tellement de calculs et tellement utiliser la RAM, que le scroll de la liste peut donner des sensations de lag très vite
Le principe du recyclage, c'est de dire que seules les vues visibles à l'écran (+ quelques extras) seront réellement chargée en mémoire
Lorsque l'utilisateur scroll, les vues sortantes de l'écran sont détruites, et les vues suivantes sont chargées
Ainsi, on ne charge les vues que lorsqu'on en a besoin, et on recycle les ViewHolder pour ne pas avoir à en créer 1000 mais 4 ou 5 seulement
Le recyclage a vraiment grandement améliorer l'affichage des listes sur Mobile et permet un comportement beaucoup plus fluide qu'avant
Créons notre première liste !
Contrairement à la ListView qui est contenue de base dans Android depuis la première version, la RecyclerView est une dépendance externe
Pour ajouter cette dépendance, nous allons dans le fichier build.gradle (app)
Dans la section "dependencies", on ajoute :
implementation 'androidx.recyclerview:recyclerview:1.0.0'On oublie pas de "sync" pour que Gradle aille nous chercher la bibliothèque et s'assure que tout va bien
On laisse tourner, et quand c'est fini on peut reprendre !
On peut maintenant utiliser la RecyclerView dans notre XML ! Comme ceci par exemple :
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>Tout d'abord, créons nous une liste d'éléments à afficher
data class User(val firstName: String, val lastName: String, val description: String)class MainActivity : AppCompatActivity()
{
private val users = listOf(
User("John", "Cena", "Lorem ipsum"),
User("Homer", "Simpson", "Lorem ipsum"),
User("Bob", "Dylan", "Lorem ipsum"),
User("Rick", "Morty", "Lorem ipsum"),
User("Marcel", "Pagnol", "Lorem ipsum"),
User("Asterix", "& Obelix", "Lorem ipsum"),
User("Kaaris", "Sevran", "Lorem ipsum"),
User("Provençal", "Le Gaullois", "Lorem ipsum")
)
// .. reste de code de l'activité ..Nous pouvons directement créer un Layout propre à un élément de la liste
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="100dp"
android:padding="20dp"
>
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Gérard Depardieu"
/>
<TextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="Lorem ipsum dolor sit amet"
/>
</androidx.constraintlayout.widget.ConstraintLayout>user_item.xml
Il nous faut ensuite créer un ViewHolder
La classe ViewHolder sera en charge de lier l'UI d'un élément de la liste avec l'objet métier correspondant
Ce sont ces fameux ViewHolder qui sont recylcés !
Un ViewHolder c'est :
class UserViewHolder(inflater: LayoutInflater, parent: ViewGroup) :
RecyclerView.ViewHolder(
inflater.inflate(R.layout.user_item, parent, false)
)
{
private var name: TextView? = null
private var description: TextView? = null
init
{
name = itemView.findViewById(R.id.name)
description = itemView.findViewById(R.id.description)
}
fun bind(user: User)
{
name?.text = "${user.firstName} ${user.lastName}"
description?.text = user.description
}
}Maintenant, nous devons nous occuper de l'Adapter
L'Adapter est une classe héritante de RecyclerView.Adapter qui se chargera de gérer les ViewHolders en fonction de la liste des objets métiers à afficher
class MyAdapter(private val users: List<User>) : RecyclerView.Adapter<UserViewHolder>()
{
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder
{
val inflater = LayoutInflater.from(parent.context)
return UserViewHolder(inflater, parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int)
{
val user: User = users[position]
holder.bind(user)
}
override fun getItemCount(): Int = users.size
}override fun getItemCount(): Int = users.sizeCa paraît sorcier, mais ça l'est pas !
Faisons un petit zoom sur les méthodes :
Ici, on indique à l'adapter le nombre d'item qu'il aura à gérer, à priori le même nombre d'items qu'il y a dans la liste
override fun onBindViewHolder(holder: UserViewHolder, position: Int)
{
val user: User = users[position]
holder.bind(user)
}Ici, la callback nous donne un ViewHolder (du type tenu par l'Adapter) et un Int correspondant à l'index de l'item dans la liste
Nous pourrions avoir des comportements différents selon l'item, mais ici nous bindons à chaque fois le UserViewHolder et l'objet User associé
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder
{
val inflater = LayoutInflater.from(parent.context)
return UserViewHolder(inflater, parent)
}Ici, c'est la méthode qui est appelé lorsqu'Android doit afficher un ViewHolder à l'écran et qu'il lui demande de se déssiner
Actuellement nous avons :
Il nous manque simplement le LayoutManager
Pour le coup, il est rare que vous ayez besoin d'hériter de LayoutManager car les 2 qui existent par défaut remplissent la plupart des cas d'utilisation :
Nous pouvons désormais assembler tous nos morceaux du puzzle et faire notre RecyclerView !
Nous allons fournir à notre RecyclerView un LayoutManager, un Adapter, et nous fournirons à celui-ci la liste des données à afficher :
class MainActivity : AppCompatActivity()
{
private val users = listOf(
User("John", "Cena", "Lorem ipsum"),
User("Homer", "Simpson", "Lorem ipsum"),
User("Bob", "Dylan", "Lorem ipsum"),
User("Rick", "Morty", "Lorem ipsum"),
User("Marcel", "Pagnol", "Lorem ipsum"),
User("Asterix", "& Obelix", "Lorem ipsum"),
User("Kaaris", "Sevran", "Lorem ipsum"),
User("Provençal", "Le Gaullois", "Lorem ipsum")
)
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView?.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = MyAdapter(users)
}
}
}Comme nous sommes dans un "apply", un simple "this" ferait référence à la recyclerView, c'est pourquoi on utilise ce petit trick Kotlin : this@MainActivity
Petit exercice :
Comment ferais-je si je souhaitais réagir au clic sur l'un des éléments de la liste ?
Essayer de trouver par vous-même une façon de faire cela et nous corrigerons ensemble ensuite
La "réaction" peut être un simple Log, il faut juste savoir comment réagir au clic
Une application mobile doit, la plupart du temps, se brancher à une API afin de récupérer des informations depuis internet pour les afficher
Il est donc primordial, en tant que développeur Android, de savoir réaliser un appel réseau
Nous allons aller étape par étape pour comprendre comment cela fonctionne en Android
Tout d'abord, il va falloir que notre application puisse avoir accès à Internet
Ca peut paraître idiot, mais il s'agit là d'une permission "dangereuse"
Pour rappel, les permissions dangereuses sont un ensemble de permissions que le développeur doit déclarer dans son Manifest car elles seront indiquées à l'utilisateur dans la fiche de Store de l'application
Une permission dangereuse doit ensuite faire l'objet d'une autorisation de la part de l'utilisateur : les fameuses popup "autorisez-vous l'utilisation du GPS", etc.
La permission concernant Internet est un peu particulière
Bien qu'il faille la préciser quand même dans le Manifest, Android ne force plus le développeur à demander l'autorisation
La première étape est donc tout de même de préciser ces permissions :
<manifest>
...
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET" />
<application>
...
</application>
</manifestINTERNET : nous sert à faire des appels réseaux
ACCESS_NETWORK_STATE : nous sert à connaître l'état du réseau
Avant d'aller plus loin, il est important de comprendre un concept nouveau en développement Android : la notion de "Thread de l'UI" ou encore "Main Thread"
La totalité des calculs permettants de rendre l'UI de votre application est réalisé sur un unique Thread qui est donc ce fameux Thread de l'UI
Afin de garantir une fluidité graphique optimale à l'utilisateur, le système Android interdit CATEGORIQUEMENT tout processus considéré comme "long" à s'effectuer sur ce Thread
En effet, cela aurait pour effet de potentiellement ralentir ce Thread, potentiellement avoir un impact sur l'UI et ça... Android le refuse, et c'est non contournable
Il est donc primordial, lorsque vous voulez faire des appels réseaux, de gérer ceux-ci à l'aide d'un autre Thread que celui de l'UI
Et quand vous développiez votre application Android en Java.... c'est là que ça se compliquait !
Et ce pour 2 raisons :
Pour les plus curieux, qui voudraient se renseigner sur ce qu'on faisait en Java, cela s'appelait des AsyncTask
Attention, je rappelle que nous sommes bloqués sur Android à Java 7 (plus quelques fonctionnalités de Java 8)
Il serait trop facile, et surtout idiot, de tirer la conclusion que Java n'était pas un bon langage
Quoi qu'il en soit, Kotlin, EL GRANDE Kotlin est arrivé
Attention, Kotlin ou pas, l'utilisation du Thread de l'UI pour faire un appel réseau est toujours interdit, faut pas rêver
Seulement Kotlin, comme à son habitude, peut grandement, grandement nous faciliter la vie
C'est parti pour faire notre premier appel réseau
Nous allons pour le moment nous baser sur cette requête à réaliser en GET :
La manière la plus basique de requêter une URL et de récupérer son contenu sous forme de String est d'utiliser la classe Url proposée par Kotlin
On peut ainsi, dans son Activity, écrire ça par exemple :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_a)
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
}Lancez donc votre application...
L'application CRASH !
Si vous enquêtez dans la StackTrace, vous allez retrouver cette Exception, à l'origine de notre problème :
android.os.NetworkOnMainThreadException
Je crois que le nom de l'exception se suffit à lui-même et que si vous avez bien compris le début du cours, vous comprenez pourquoi elle a été lancée
Il va donc nous falloir être capable de réaliser cet appel en dehors du Thread principal
Je vais être clair avec vous dès maintenant :
La gestion fine des threads en Kotlin n'est pas du tout un sujet trivial
Nous allons voir dans ce cours, puisque c'est un cours d'initiation, la partie émergée de l'iceberg
Nous allons voir des solutions "magiques", mais comprenez bien qu'elles cachent une grande complexité
Pour les plus courageux et les plus curieux d'entre vous, si jamais vous cherchez à comprendre le fond du fond de la gestion de "threads" en Kotlin, je vous invite à vous renseigner sur le principe de COROUTINES en Kotlin
C'est un sujet passionnant mais attention : faut pas s'y attaquer en tremblant des genoux
Sinon, pour la magie, ça se passe ici !
Nous allons utiliser une bibliothèque externe pour nous aider avec les Threads : Anko
Vous trouverez le Github d'Anko à cette URL :
"Monsieur, pourquoi il y a marqué qu'Anko est déprécié ? On apprend des outils dépassés nous ?"
Non.
Anko a été créé à la base pour être un DSL (Domain Specific Language) de Kotlin et améliorer l'écriture des Layout
Anko n'est plus maintenu et considéré par ses mainteneurs comme "déprécié" car ceux-ci estiment que ce rôle là n'a plus lieux d'être avec l'existence de JetPack Compose (le "framework" Android pour écrire son application en Declarative UI, à la manière de Swift UI ou Flutter)
Bref
Nous, on ne va utiliser Anko que pour une seule raison, 0.00005% de ce que c'est
Donc pas de panique ! En plus, c'est juste pour vous montrer mais après on apprendra encore une meilleure manière !
On peut donc passer à l'installation de la bibliothèque
Dans votre build.gradle (de l'app), dans la section "dependencies", ajoutez :
implementation "org.jetbrains.anko:anko:0.10.8"On oublie pas le Gradle Sync, et on est bon !
Revenons maintenant dans notre Activity
Voici le petit miracle que nous permet de faire Anko :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_a)
doAsync {
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
uiThread {
Log.d("toto", "Response = $stringResponse")
}
}
}Les fonctions sont plutôt très claires, mais du coup :
Regardez le log généré
Félicitations, vous venez de faire votre premier appel réseau !
Bon, maintenant, il nous reste à PARSER ce résultat en objet métier (en objet Kotlin, Pojo, Modèle, bref, le nom qui vous plaît)
Pour ce faire, nous allons déjà créer une classe qui correspond :
data class User(val firstName: String, val lastName: String, val address: String)Ensuite, nous allons utiliser l'une des 2 bibliothèques les plus utilisées sur Android pour parser du Json qui sont :
Nous choisirons Gson (choix parfaitement arbitraire, les 2 se ressemblant énormément)
Hop, on met la dépendance dans le build.gradle :
implementation 'com.google.code.gson:gson:2.8.6'Et on oublie pas le Gradle Sync !
Et là... ça se complique.
Gson est un outil ultra puissant qui permet de customiser très efficacement votre parsing
Capable de reconnaître des Enums et de bien d'autres folies qui sont souvent négligées par ce genre d'outils
Mais du coup, il paye cette puissance avec un coût.. Gson est dur à utiliser
Et non ! Pas du tout en fait, voilà :
val user = Gson().fromJson(stringResponse, User::class.java)Pas plus, pas moins, merci Gson.
Attention, je ne rigolais pas sur la puissance de l'outil par contre, il est vraiment génial !
Mais il se paye aussi le luxe d'être simple d'utilisation :)
Ainsi :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_a)
doAsync {
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
val user = Gson().fromJson(stringResponse, User::class.java)
uiThread {
Log.d("toto", "user = $user")
}
}
}On log bien le toString de la data class :
D/toto: user = User(firstName=Chuck, lastName=Bartowski, address=Burbank)On a correctement parsé notre appel !
Ok, maintenant, comment gérer l'absence de réseau ?
Nous allons partir du principe que nous demanderons à Android si le téléphone a accès à internet avant de faire l'appel
Dans un premier temps, on va encapsuler l'appel réseau et le déclencher lors d'un évènement
Au clic sur une TextView par exemple
On peut donc imaginer quelque chose comme :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_a)
textView?.setOnClickListener(this)
}
override fun onClick(view: View?)
{
doAsync {
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
val user = Gson().fromJson(stringResponse, User::class.java)
uiThread {
Log.d("toto", "user = $user")
}
}
}Histoire de voir graphiquement le process, rajoutons ceci :
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_a)
textView?.setOnClickListener(this)
}
override fun onClick(view: View?)
{
textView?.text = "Loading"
doAsync {
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
val user = Gson().fromJson(stringResponse, User::class.java)
uiThread {
Log.d("toto", "user = $user")
textView?.text = user.firstName
}
}
}Nous allons ensuite créer cette méthode permettant de renvoyer un booléen indiquant si nous avons du réseau ou non :
private fun isNetworkConnected(): Boolean
{
//1 On recupère le "ConnectivityManager", le nom est assez parlant...
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
//2 On récupère le réseau actif (Wifi, 4G, etc.)
val activeNetwork = connectivityManager.activeNetwork
//3 On demande d'analyser ses capacités actuelles
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
//4 Enfin, on regarde si il en a et si elles lui permettent d'accéder à internet. On retour donc ça
return networkCapabilities != null
&& networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}Allez-y, mettez donc ça dans votre Activity...
Ca ne fonctionne pas.
Android Studio n'est pas content.
Arrivez-vous à comprendre l'erreur ?
connectivityManager.activeNetworkC'est ce petit bout de code qui nous embête
La raison :
Call require API level 23 (current min is 21)
Ca y est, enfin !
On a notre premier problème de version minimum d'API Android
Ca paraissait hyper simple de récupérer l'état du réseau hein ?
Bah en fait... ça n'est simple que depuis l'API 23 !
Avant la version d'API 23, c'était une toute autre histoire
Alors, comment fait-on ?
Une solution pourrait être de faire 2 comportements différents selon la version de SDK de l'utilisateur
On coderait alors la version après API 23 et celle avant
private fun isNetworkConnected(): Boolean
{
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
val activeNetwork = connectivityManager.activeNetwork
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
networkCapabilities != null && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
else
{
TODO("VERSION.SDK_INT < M")
true
}
}Voilà, vous comprenez un peu mieux ce dont je vous parlais au début
Une autre solution serait de se dire que de nombreux développeurs ont déjà dû faire ça et en faire une bibliothèque
En voici une que j'ai développé dans le cadre de mon travail de développeur dans une agence
Servez-vous en si besoin, c'est gratos, c'est pour moi
(après, il doit y en avoir plein d'autres, et sûrement encore mieux)
Mais nous, on va en rester là
On va donc rester sur notre code avec le IF et le bête "return true" dans le cas de version < 23
Adaptons un peu notre code d'appel réseau en fonction :
override fun onClick(view: View?)
{
if (isNetworkConnected())
{
textView?.text = "Loading"
doAsync {
val url = URL("https://next.json-generator.com/api/json/get/VydTXyeqY?delay=2000")
val stringResponse = url.readText()
val user = Gson().fromJson(stringResponse, User::class.java)
uiThread {
Log.d("toto", "user = $user")
textView?.text = user.firstName
}
}
}
else
{
textView?.text = "No network !"
}
}Allez-y, faites des tests en mettant en mode avion, etc !
Si votre téléphone ou émulateur a une version d'API > 23, vous verrez l'erreur !
En théorie, on pourrait s'arrêter là
C'était un gros chapitre, et on a vu pleins de trucs
Mais, gérer une communication avec une API, on le sait, c'est pas toujours aussi simple que faire un bête GET sans paramètres sur une route toute simple
Une vraie application, de taille conséquente, se doit d'utiliser des bibliothèques plus robustes, plus pertinentes
On pourrait évidemment tout faire avec ce que nous propose Kotlin
Mais comme dans toutes les technos, vous imaginez bien que des gens l'ont déjà fait et vous propose une bibliothèque sur un plateau d'argent
Cette bibliothèque magique, dans le cadre des appels réseaux, c'est :
Retrofit
Retrofit, c'est THE client HTTP en Android, ni plus ni moins
Ne pas utiliser Retrofit, c'est faire du feu avec des silex alors qu'on vous tend un lance-flamme
Nous n'allons qu'effleurer Retrofit, nous contenter de l'essentiel, mais libre à vous de voir à quel point c'est puissant et immense :
Alors comment ça marche ?
Tout d'abord, ajoutons la dépendance :
implementation 'com.squareup.retrofit2:retrofit:2.9.0'Retrofit peut automatiser le Parsing si on lui donne l'adapter qui va bien
Et il existe des adapters Retrofit pour toutes les bibliothèques les plus connues de parsing Android
Et donc il y en a un pour Gson ! Ajoutons-le :
implementation 'com.squareup.retrofit2:converter-gson:2.6.3'On peut maintenant virer notre ancienne dépendance Gson :
implementation 'com.google.code.gson:gson:2.8.6'On Gradle Sync, et on est prêts !
(d'ailleurs, on peut virer Anko aussi maintenant !)
Retrofit a comme gros point positif d'automatiser énormément de chose !
Par exemple, l'ensemble de nos requêtes seront définies dans une Interface de manière extrêmement propre et précise
Voici un exemple dans notre cas :
interface MyApiService
{
@GET("json/get/VydTXyeqY?delay=2000")
fun retrieveUser(): Call<User>
}Aussi simple que ça.
Ici, ça ne nous intéresse pas, mais sachez qu'il y a toutes les customisations que vous pouvez imaginer
Regardez juste sur la Home de la doc à quel point c'est facile :)
Il nous faut ensuite créer l'objet Retrofit qui sera à générer une fois pour toute et dans lequel nous pouvons indiquer notre Parser :
val retrofit = Retrofit.Builder()
.baseUrl("https://next.json-generator.com/api/")
.addConverterFactory(GsonConverterFactory.create())
.build()Encapsulons le dans un Singleton pour être sûr qu'il ne soit instancié qu'une fois :
object ApiRepository
{
private var apiService: MyApiService? = null
init
{
val retrofit = Retrofit.Builder()
.baseUrl("https://next.json-generator.com/api/")
.addConverterFactory(GsonConverterFactory.create())
.build()
apiService = retrofit.create(MyApiService::class.java)
}
}Oui, c'est aussi simple que ça un Singleton en Kotlin.
Maintenant, on ajoute à notre singleton une méthode pour récupérer l'utilisateur
object ApiRepository
{
private var apiService: MyApiService? = null
init
{
val retrofit = Retrofit.Builder()
.baseUrl("https://next.json-generator.com/api/")
.addConverterFactory(GsonConverterFactory.create())
.build()
apiService = retrofit.create(MyApiService::class.java)
}
fun retrieveUser(callback: Callback<User>)
{
val call = apiService?.retrieveUser()
call?.enqueue(callback)
}
}fun retrieveUser(callback: Callback<User>)
{
val call = apiService?.retrieveUser()
call?.enqueue(callback)
}
Sur la première ligne : on crée l'appel
Sur la seconde ligne : on lui abonne une callback qui sera appellée en cas d'échec ou réussite
ENFIN, on peut faire l'appel !
Pour ce faire, rdv dans l'Activity, effacez tout les trucs d'avant et on refait ça proprement :
override fun onClick(view: View?)
{
textView?.text = "Loading"
ApiRepository.retrieveUser(this)
}Dans ce cas, j'ai choisi de faire implémenter l'interface par mon Activity, elle doit donc implémenter les deux méthodes de l'interface :
override fun onFailure(call: Call<User>, t: Throwable)
{
textView?.text = "Error : ${t.message}"
}
override fun onResponse(call: Call<User>, response: Response<User>)
{
textView?.text = "Code ${response.code()}, User = ${response.body()}"
}Si j'avais voulu ne pas faire porter par mon Activity, j'aurais du faire une classe anonyme permettant d'implémenter les 2 méthodes, comme ça :
ApiRepository.retrieveUser(object: Callback<User> {
override fun onFailure(call: Call<User>, t: Throwable)
{
textView?.text = "Error : ${t.message}"
}
override fun onResponse(call: Call<User>, response: Response<User>)
{
textView?.text = "Code ${response.code()}, User = ${response.body()}"
}
})FINI !
Bravo, c'était pas un chapitre simple
Si vous l'avez suivi jusqu'au bout (je vous conseille de le relire quand même), vous avez maintenant toutes les billes pour faire CE QUE VOUS VOULEZ !
Evidemment, on peut toujours aller plus loin
Pour les plus curieux d'entre-vous, je réitère, renseignez-vous sur les Coroutines en Kotlin
Vous y trouverez des astuces pour alléger encore plus le code et éviter d'avoir 36.000 CallBack Retrofit partout par exemple...
Mais je vous laisse regarder ça, c'est bien trop avancé pour ce cours déjà bien rempli :)
Petit exercice final sur les appels réseau !
Voici un exemple de composant XML pour faire un "Loader" très commun sur Android :
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/loader"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="60dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
style="?android:attr/progressBarStyleLarge"
/>Ce petit chapitre va nous permettre de faire connaissance avec un concept assez connu sur Android : les Shared Preferences
Il s'agit de données que l'on va pouvoir stocker sous forme de clés/valeurs dans la mémoire du téléphone
Grâce aux SharedPreferences, nous allons pouvoir écrire dans un fichier XML qui va être stocké sur un espace mémoire dédié à notre application et qui survivra aux interruptions/relances de l'application
A la création du fichier, nous pouvons décider si nous souhaitons le rendre publique ou privé
Un fichier de préférences publique.. pourra être accessible des autres applications du téléphone
Attention, les données stockées dans les SharedPreferences sont stockées dans les "datas" du téléphone, et pas dans le "cache"
Les "datas" peuvent être supprimées bien plus facilement par le système, pour diverses raisons, et ne doivent donc pas contenir de données critiques pour le fonctionnement de l'application
Comment ça fonctionne ?
On récupère une instance des SharedPreferences
val sharedPreferences = getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)On peut aussi récupérer un fichier spécifique à l'activité en cours (et donc non partagé avec les autres activités) avec :
val sharedPreferences = getPreferences(Context.MODE_PRIVATE)Ensuite, on se met soit en mode Editor pour écrire :
sharedPreferences.edit().putString(MY_KEY, value).apply()Soit on lit directement une valeur (on précise une valeur par défaut si il ne trouve rien dans le fichier)
sharedPreferences.getString(MY_KEY, null)Exemples d'utilisation ?
Exercice
Chaque application Android peut avoir accès à une Base de Données SQLLite sur le téléphone de l'utilisateur
Cette base de données permet de stocker dans le "cache" des données sous formes de tables SQL et c'est très pratique notamment pour mettre en place une logique de cache d'appels réseaux
Avant, utiliser la BDD SQLLite n'était pas forcément aisé
Depuis, Android a sorti Room, une bibliothèque permettant de grandement faciliter le dialogue entre votre code et la BDD
Attention, gérer un cache et une BDD locale reste souvent plus compliqué que l'on ne croit, à utiliser donc avec parcimonie
Mise en place :
implementation "androidx.room:room-runtime:2.2.6"kapt "androidx.room:room-compiler:2.2.6"implementation "androidx.room:room-ktx:2.2.6"En haut de votre app/build.gradle, ajoutez ceci pour que KAPT soit appliqué :
apply plugin: 'kotlin-kapt'La communication avec la BDD va se faire de manière asynchrone
Nous allons donc devoir utiliser un peu les coroutines, même si nous ne nous pencherons une fois de plus pas trop en détail dessus
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'Voici les 3 éléments principaux dans Room :
Database
Il s'agit, comme son nom l'indique, du point d'accès principal à la BDD SQLLite
Nous allons y déclarer les différentes Tables (Entities) et DAO
Entity
Une Entity n'est rien d'autre que la représentation d'une Table au sens SQL du terme
Il s'agit simplement d'une classe annotée de @Entity et qui va nous permettre de déclarer ses champs, etc.
DAO
Ce sont les interfaces qui vont nous permettre de définir les méthodes de communication avec les Entities
OK, c'est parti pour un petit exemple !
Nous allons partir sur une seule table pour le moment, nous stockerons les données d'utilisateurs
Première étape, nous allons définir notre @Entity
@Entity
data class User(
@PrimaryKey(autoGenerate = true) val uid: Long? = null,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?
)Comme vous pouvez le voir, Room est essentiellement basé sur le principe des annotations !
Ici par exemple, nous avons une annotation @PrimaryKey pour définir la clé primaire ainsi que @ColumnInfo pour donner des informations spécifiques sur le champs
Concernant la Primary Key
Nous utilisons ici le autoGenerate = true pour ne pas avoir à nous en occuper, mais vous pouvez évidemment l'enlever et avoir votre propre logique (venant de l'API)
Besoin d'avoir une clé primaire composée ? Ca se passe dans l'annotation Entity alors :
@Entity(primaryKeys = arrayOf("firstName", "lastName"))Dans l'annotation Entity, on peut également préciser un nom spécifique (sinon, c'est celui de la classe)
@Entity(tableName = "user")Concernant le @ColumnInfo, si on veut un autre nom que celui du champs, on peut l'overrider :
@ColumnInfo(name = "first_name") val firstName: String?,Pour plus d'infos :
Déclarons maintenant notre DAO !
Pour rappel, le DAO c'est l'interface qui déclare les méthodes de communication applicables sur notre Entity
@Dao
interface UserDao
{
@Insert
fun insert(user: User): Long
}Comme vous le voyez, on est toujours sur un concept d'annotations
On a besoin de rien de plus ! L'Annotation Processor va se charger de tout générer pour nous !
La requête nous renverra l'ID généré, d'où le type de retour !
Mettons un peu plus de méthodes !
@Dao
interface UserDao
{
@Query("SELECT * FROM user")
fun getAll(): List<User>
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
fun loadAllByIds(userIds: IntArray): List<User>
@Query(
"SELECT * FROM user " +
"WHERE first_name LIKE :first" +
" AND last_name LIKE :last"
)
fun findByName(first: String, last: String): List<User>
@Insert
fun insertAll(users: List<User>): List<Long>
@Insert
fun insert(user: User): Long
@Update
fun updateUser(user: User)
@Delete
fun delete(user: User)
@Query("DELETE FROM user")
fun deleteAll()
}Enfin, nous pouvons créer notre DataBase !
Pour cela nous allons créer une classe Abstraite annotée de @Database et qui devra donner la liste des Entities ainsi que des DAO associés
@Database(entities = [User::class], version = 1)
abstract class MyDatabase : RoomDatabase()
{
abstract fun userDao(): UserDao
}Nous allons maintenant en faire un Singleton pour récupérer une instance de la Database une seule fois :
@Database(entities = [User::class], version = 1)
abstract class MyDatabase : RoomDatabase()
{
abstract fun userDao(): UserDao
companion object
{
private var database: MyDatabase? = null
fun instance(context: Context): MyDatabase?
{
if (database != null)
{
return database
}
database = Room.databaseBuilder(
context,
MyDatabase::class.java, "database-name"
).build()
return database
}
}
}On est bon ! On va pouvoir maintenant utiliser notre BDD !
Pour se faire, je vais mettre un peu de code dans une Activity créée pour l'occasion, mais pas forcément de code graphique pour le moment, on va regarder les Logs
Les appels à l'instance de la BDD sont asynchrones, il faut donc pouvoir gérer l'asynchronisme dans notre manière de communiquer avec la BDD
Nous allons rester sur notre habitude des callbacks pour le moment, libre à vous d'aller plus loin dans votre compréhension des Coroutines pour voir ce qu'on peut faire !
Voici par exemple ici comment récupérer tous les utilisateurs :
private fun retrieveUsers(callback: (List<User>) -> Unit)
{
GlobalScope.launch {
val database = MyDatabase.instance(applicationContext)
callback.invoke(database?.userDao()?.getAll() ?: emptyList())
}
}GlobalScope.launch nous permet de lancer un "scope" de coroutine, voyez ça comme une sorte "d'autre Thread"
Voici une autre fonction par exemple pour insérer une liste d'utilisateurs
private fun insertUsers(users: List<User>, callback: (List<Long>) -> Unit)
{
GlobalScope.launch {
val database = MyDatabase.instance(applicationContext)
val ids = database?.userDao()?.insertAll(users)
callback.invoke(ids)
}
}Lancez un premier coups en insérant des données :
insertUsers(
listOf(
User(firstName = "Michael", lastName = "Jordan"),
User(firstName = "Steve", lastName = "Jobs"),
User(firstName = "Elon", lastName = "Musk")
)
)Puis lancez une seconde fois en lisant votre BDD !
// insertUsers(
// listOf(
// User(firstName = "Michael", lastName = "Jordan"),
// User(firstName = "Steve", lastName = "Jobs"),
// User(firstName = "Elon", lastName = "Musk")
// )
// )
retrieveUsers { users ->
users.forEach {
Log.d("toto", "user found : $it")
}
}Petit plus avec Android Studio !
Vous devriez trouver normalement dans un des onglets autour de votre IDE un nommé "Database Inspector"
Lorsque l'app tourne sur votre Device, ouvrez cet onglet, et vous voilà avec une vue sur votre BDD, rien que ça !
Félicitations !
Vous voilà déjà bien avancés dans votre apprentissage de Room
Bon.. j'imagine que vous voulez quand même savoir comment on peut gérer des relations entres Entities, car c'est finalement le GROS truc qui manque dans ce cours sur Room
Ok, mettons en place des relations
Tout d'abord, il est possible d'ajouter des Embedded relations
Il s'agit de créer une nouvelle Entity mais dont les champs ne serrons que de nouvelles colonnes de l'Entity qui l'embed
Par exemple, donnons une adresse à nos Users et découpons là dans une Entity à part Embedded
@Entity(tableName = "user")
data class User(
@PrimaryKey(autoGenerate = true) val uid: Long? = null,
@ColumnInfo(name = "first_name") val firstName: String?,
@ColumnInfo(name = "last_name") val lastName: String?,
@Embedded val address: Address?
)
data class Address(
val street: String?,
val state: String?,
val city: String?,
@ColumnInfo(name = "post_code") val postCode: Int
)Dans cet exemple, les champs de Adress ne seront en fait que des nouvelles colonnes dans la table "user"
Utiliser une relation @Embedded permet de rentre le code des Entities plus propre
Allez-y, relancez votre application et regardez dans le Database Inspector !
ERREUR !
Vous voilà sûrement tombé sur une erreur de ce type :
Room cannot verify the data integrity.
Looks like you've changed schema but forgot to update the version number.
En effet, vous êtes en train de manipuler une vraie BDD SQL, et comme toute BDD, il faut faire attention aux migrations de structure !
Première étape, il est impératif d'incrémenter la version de la BDD à chaque changement de structure, sinon ça pète une erreur
@Database(entities = [User::class], version = 2)
abstract class MyDatabase : RoomDatabase()Deuxième étape, il faut préciser à SQLLite comment il doit se comporter au niveau de la migration
En effet, que faire si l'app existait déjà, avec des données, et que la nouvelle structure a changé ??
Pour régler ça, il y a les Scripts de Migration
Nous pouvons créer une classe héritant de Migration qui permettra de lancer toutes les commandes SQL qui nous semblent importantes pour l'intégrité des données
Cette classe devra préciser la version de base ainsi que celle d'arrivée, on aura donc X classes de Migration au fur et à mesure de l'avancée de l'application !
Voici un exemple :
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("Ma Query SQL")
}
}database = Room.databaseBuilder(
context,
MyDatabase::class.java, "database-name"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, ...)
.build()Ce qui donne :
Voilà une des raisons pour lesquelles je précisais au début que gérer un VRAI cache mobile, ça peut être très compliqué
Surtout qu'un utilisateur mobile ne fait pas forcément toutes les MAJ ! Rien ne l'empêche d'avoir sauté la version 2 de votre app et de directement télécharger la 3
Vous voyez le problème ? Il faudrait donc aussi prévoir une migration de 1 à 3...
Après, il est toujours possible de dire à Android :
Tu CLEAR tout à chaque changement de version de la BDD
C'est violent, mais ça facilite la vie ! Nous partirons donc pour ça pour nos exemples :
database = Room.databaseBuilder(
context,
MyDatabase::class.java, "database-name"
)
.fallbackToDestructiveMigration()
.build()Maintenant ça devrait marcher !
Lancez l'app et regardez votre Database Inspector
Les relations ONE TO ONE
Imaginons une relation One To One telle que, dans une app type Spotify, un utilisateur puisse avoir une "Library" correspondant à ses sons à lui en tant que musicien
Le musicien a alors UNE "library" et celle-ci appartient à UN musicien
Voilà comment on représenterait ça :
@Entity
data class Library(
@PrimaryKey val libraryId: Long,
val userOwnerId: Long
)Une Entity Library :
Un Model qui fait le lien :
data class UserAndLibrary(
@Embedded val user: User,
@Relation(
parentColumn = "uid",
entityColumn = "userOwnerId"
)
val library: Library
)On en profite pour créer une LibraryDao
@Dao
interface LibraryDao
{
@Insert
fun insert(library: Library): Long
@Insert
fun insertAll(libraries: List<Library>): List<Long>
}ATTENTION
On oublie pas, dès lors qu'on ajoute une Entity, ça signifie :
Voici comment on pourrait lister ces nouveaux Modèles de donner par exemple (mettons cette Query dans notre UserDao) :
@Transaction
@Query("SELECT * FROM user")
fun getUsersAndLibraries(): List<UserAndLibrary>Le @Transaction permet à Room de savoir qu'il va devoir faire 2 requêtes et lier le tout
Voici un exemple de création de User et de Libraries liées
private fun insertUsersAndLLibraries()
{
val randomAddress = Address(
street = "Rue du Kotlin",
state = "France",
city = "Paris",
postCode = 75001
)
insertUsers(
listOf(
User(firstName = "Michael", lastName = "Jordan", address = randomAddress),
User(firstName = "Steve", lastName = "Jobs", address = randomAddress),
User(firstName = "Elon", lastName = "Musk", address = randomAddress)
)
) { identifiers ->
val libraries = mutableListOf<Library>()
identifiers?.forEach { id ->
libraries.add(Library(userOwnerId = id))
}
GlobalScope.launch {
val database = MyDatabase.instance(applicationContext)
database?.libraryDao()?.insertAll(libraries)
}
}
}Les relations One To Many
On reste dans notre app de musique, et on considère qu'un utilisateur peut avoir 0 ou plusieurs Playlist
On commence par créer une nouvelle Entity
on oublie pas les règles quand on change quelque chose dans la BDD !
@Entity
data class Playlist(
@PrimaryKey val playlistId: Long,
val userCreatorId: Long,
val playlistName: String
)De nouveau, on crée un modèle représentant la relation
data class UserWithPlaylists(
@Embedded val user: User,
@Relation(
parentColumn = "uid",
entityColumn = "userCreatorId"
)
val playlists: List<Playlist>
)Et c'est reparti !
Vous connaissez déjà les étapes suivantes :
Enfin, la relation MANY TO MANY
Dans notre exemple d'application de musique, imaginez les Playlist et les Song
Une Playlist a plusieurs Songs et une Song peut appartenir à plusieurs Playlist
Niveau structure, ça change un peu car il nous faut mettre en place une Table de jointure entre les deux Entity :
@Entity
data class Playlist(
@PrimaryKey val playlistId: Long,
val userCreatorId: Long,
val playlistName: String
)
@Entity
data class Song(
@PrimaryKey val songId: Long,
val songName: String,
val artist: String
)
@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
val playlistId: Long,
val songId: Long
)Ensuite, on peut avoir 2 types de modèles différents, selon la requête qu'on veut faire :
data class PlaylistWithSongs(
@Embedded val playlist: Playlist,
@Relation(
parentColumn = "playlistId",
entityColumn = "songId",
associateBy = Junction(PlaylistSongCrossRef::class)
)
val songs: List<Song>
)
data class SongWithPlaylists(
@Embedded val song: Song,
@Relation(
parentColumn = "songId",
entityColumn = "playlistId",
associateBy = Junction(PlaylistSongCrossRef::class)
)
val playlists: List<Playlist>
)Enfin, côté DAO, voici ce que ça donnerait comme requêtes :
@Transaction
@Query("SELECT * FROM Playlist")
fun getPlaylistsWithSongs(): List<PlaylistWithSongs>
@Transaction
@Query("SELECT * FROM Song")
fun getSongsWithPlaylists(): List<SongWithPlaylists>Pour finir ! (parce que là c'est long)
Comment faire si vous voulez, en une seule requête, récupérer tous les User, avec toutes leurs Playlist et, pour chacune, toutes leurs Song ?
Vu que nous avons déjà un modèle représentant la jointure PlaylistWithSongs, il nous suffit d'en créer un nouveau chez User, comme ceci :
data class UserWithPlaylistsAndSongs(
@Embedded val user: User,
@Relation(
entity = Playlist::class,
parentColumn = "uid",
entityColumn = "userCreatorId"
)
val playlists: List<PlaylistWithSongs>
)Vous comprenez pourquoi ?
Nous lions le User avec un modèle qui lie déjà les Playlist et les Song ! Ca donne ça en gros :
Niveau requête, ça donnerait donc un truc "simple" comme ça :
@Transaction
@Query("SELECT * FROM User")
fun getUsersWithPlaylistsAndSongs(): List<UserWithPlaylistsAndSongs>Attention aux perfs quand même ! A priori il n'y a pas vraiment de raisons de faire d'aussi grandes Query, c'est juste pour l'exemple que je vous le montre
FINI !
Félicitations, si vous avez tenu jusque-là, vous savez désormais bien vous débrouiller avec Room ! :)
Ne reste plus qu'à pratiquer ! Gérer du caching n'est pas du tout chose aisée mais, si c'est bien fait, ça peut vraiment être génial
Android Architecture Components
Nous avons pu voir dans ce cours toutes les bases nécessaires à la création d'applications Android
En théorie, rien ne vous empêche maintenant de réaliser une application rivalisant avec les meilleures
Nous n'avons pas réellement pu aller très loin dans la customisation graphique et l'intégration avancée, mais vos pourrez progresser sur ce sujet de votre côté avec les bases que vous avez
Android Architecture Components
Il reste toutefois des sujets intéressants pour ceux qui voudraient aller un peu plus loin
En effet, certains sujets sur la création de l'architecture d'une application Android ne sont pas simples :
Android Architecture Components
Ces problèmes d'architecture.... ça fait un bail que les développeurs l'ont relevé !
Ils soulevaient en effet le problème d'un manque de prise de position d'Android quant au type d'architecture à utiliser
Android Architecture Components
Google a alors tapé très fort en fin 2017 en annonçant les :
Android Architecture Components
Android Architecture Components
Android Architecture Components
Le principe phare des Architecture Components était de séparer de manière nette la logique de l'UI
Les Activités / Fragments ne devraient plus avoir qu'à mettre à jour l'UI !
Toute la logique métier devrait être déportée ailleurs
Android Architecture Components
On y gagnerait en ayant des Activités / Fragments bien plus lisibles et simples (uniquement du graphique)
Mais aussi en ayant du code métier séparé, dans des fichiers Kotlin sans liens avec l'UI et donc bien plus testables
Android Architecture Components
Les 2 composants les plus intéressants pour une telle architecture sont :
Android Architecture Components
ViewModel
Un ViewModel, ça va être une classe Kotlin qui va nous permettre d'encapsuler toute la logique métier propre à un/des Fragments / Activités
Les différents Fragments / Activités n'auront alors plus qu'à utiliser une instance de ce ViewModel pour accéder aux données
Android Architecture Components
ViewModel
Le ViewModel a également un avantage COLOSSAL... c'est un LyfeCycleAware component
Ca signifie que le ViewModel gère POUR VOUS les changements d'états du cycle de vie ! Et croyez moi, il vous économise beaucoup, beaucoup de boulot !
Android Architecture Components
LiveData
Bien que le ViewModel, de par sa gestion automatique du cycle de vie, est déjà une révolution, il est souvent associé aux LiveData
Les LiveData sont des objets basés sur le pattern Observer qui vont vous permettre de mettre en place une programmation reactive assez simplement dans votre application
Android Architecture Components
LiveData
Ainsi, vos controlleurs (Fragments/Activity) vont pouvoir s'abonner aux LiveData du ViewModel, et réagir en fonction des changemelntds
Android Architecture Components
Testons un peu ça !
Android Architecture Components
On se crée un petit ViewModel qui va être chargé de récupérer la liste des Users venant d'une API (on reprend ce qu'on a vu précédemment avec Retrofit)
Android Architecture Components
package com.example.mastertest2.viewmodels
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class MyViewModel : ViewModel(), Callback<List<User>>
{
val users = MutableLiveData<List<User>>()
fun loadUsers()
{
ApiRepository.retrieveUser(this)
}
override fun onFailure(call: Call<List<User>>, t: Throwable)
{
Log.e("toto", "Une erreur est survenue, il faut en faire quelque chose !")
}
override fun onResponse(call: Call<List<User>>, response: Response<List<User>>)
{
users.value = response.body()
}
}Android Architecture Components
val users = MutableLiveData<List<User>>()Ici, on déclare notre LiveData (mutable) qui va être la donnée que l'on va observer tout le long du processus et sur laquelle les controlleurs (Fragments/Activity) vont se baser pour l'UI
Android Architecture Components
fun loadUsers()
{
ApiRepository.retrieveUser(this)
}Ici, on déclare une méthode que le contrôleur pourra appeler et qui lancera toute la logique nécessaire
Android Architecture Components
override fun onFailure(call: Call<List<User>>, t: Throwable)
{
Log.e("toto", "Une erreur est survenue, il faut en faire quelque chose !")
}
override fun onResponse(call: Call<List<User>>, response: Response<List<User>>)
{
users.value = response.body()
}Ces callbacks on les connaît déjà, mais notez le "users.value" qui signifie qu'on change la valeur de la LiveData et donc qu'on veut notifier les observers
Android Architecture Components
Une fois qu'on a fait le ViewModel, on peut jouer avec nos Activity / Fragments !
Histoire d'automatiser la gestion des ViewModel sous forme de Singleton, on va tirer une nouvelle petite dépendance pratique :
implementation "androidx.activity:activity-ktx:1.1.0"Android Architecture Components
Ensuite, on a plus qu'à faire référence au ViewModel dans notre activité
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my_view_model)
val myViewModel: MyViewModel by viewModels()
}Android Architecture Components
Notez le :
val myViewModel: MyViewModel by viewModels()C'est à ça que nous sert la dépendance que l'on vient de tirer :)
Android Architecture Components
Ensuite, nous pouvons lancer la recherche, puis OBSERVER le résultat !
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my_view_model)
val myViewModel: MyViewModel by viewModels()
myViewModel.loadUsers()
myViewModel.users.observe(this, Observer<List<User>>{ users ->
Log.d("toto", "Voici les users : $users")
})
}Android Architecture Components
Et voilà le travail !
Votre Activity / Fragment n'est plus du tout responsable de la logique de votre application !
Android Architecture Components
Comment gérer le chargement ou les erreurs ?
A vous de voir, mais le composant tenu par une LiveData peut être un poil amélioré :
enum class Status {
LOADING,
SUCCESS,
ERROR
}
data class UsersModel(
val status: Status,
val users: List<User> = listOf(),
val errorMessage: String = ""
)Android Architecture Components
Ainsi, côté ViewModel ça donnerait ça :
class MyViewModel : ViewModel(), Callback<List<User>>
{
val usersModel = MutableLiveData<UsersModel>()
fun loadUsers()
{
usersModel.value = UsersModel(status = Status.LOADING)
ApiRepository.retrieveUser(this)
}
override fun onFailure(call: Call<List<User>>, t: Throwable)
{
usersModel.value = UsersModel(status = Status.ERROR, errorMessage = "WOW, super erreur là, attention")
}
override fun onResponse(call: Call<List<User>>, response: Response<List<User>>)
{
usersModel.value = UsersModel(status = Status.SUCCESS, users = response.body() ?: emptyList())
}
}Android Architecture Components
Enfin, côté Activity / Fragment, on peut du coup gérer TOUS les cas, hyper facilement :
myViewModel.usersModel.observe(this, Observer<UsersModel>{ userModel ->
when(userModel.status)
{
Status.SUCCESS -> Log.d("toto", "on affiche la liste à l'utilisateur")
Status.LOADING -> Log.d("toto", "on affiche notre plus beau Loader")
Status.ERROR -> Log.d("toto", "on affiche une erreur de qualité supérieure")
}
})Android Architecture Components
Encore plus fort, vous vous souvenez de l'exemple sur le Cycle de vie avec l'EditText ?
Pour rappel, nous avions un EditText, un Button et une TextView. Au clic sur le Button, on plaçait la valeur de l'EditText dans la TextView
Android Architecture Components
Nous remarquions alors que, à la rotation de l'écran, nous perdions la valeur car la vue se reconstruisait depuis zéro, car elle était passée dans le OnDestroy
Essayons de refaire cette logique, mais avec ViewModel et LiveData cette fois...
Android Architecture Components
Côté ViewModel, on est très basique :
class MyEditTextViewModel : ViewModel()
{
val result = MutableLiveData<String>()
fun setResult(value: String) {
result.value = value
}
}Android Architecture Components
Côté Activity, ça donne ça :
private val myViewModel: MyEditTextViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my_view_model)
myViewModel.result.observe(this, Observer<String> { newValue ->
result.text = newValue
})
validate?.setOnClickListener {
myViewModel.setResult(myEditText?.text.toString())
}
}Android Architecture Components
Et voilà !
Vous pouvez faire toutes les rotations que vous voulez, le texte ne disparaît pas ! Et ça c'est parce que le ViewModel est conscient du Life Cycle et gère tout ça pour vous !
Et ça, croyez moi, c'est loin, loin ... LOIN d'être anodin !
Android Architecture Components
Pour finir, voici ce que Google préconise donc comme architecture pour les applications Android modernes, un mélange de ViewModel, LiveData et de pattern Repository :
Android Architecture Components
Android Architecture Components
Toutes les applications ne nécessitent peut-être pas une architecture monstrueuse, et il faut adapter votre code à votre contexte, ça c'est certain
Cependant, j'espère que ce petit chapitre vous a convaincu sur l'utilité de mettre en place ViewModel et LiveData pour découpler un maximum votre logique de votre UI ainsi que pour vous faciliter la vie côté cycle de vie !