image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

What is Kotlin?

Advantages of Kotlin in a few words

Kotlin REPL

Local values and variables, functions

Local values/variables are declared and assigned in the same time:

When declared :

Examples :

var a = 7 // the type of a is Int
var b: Any = 8 // the type of b is Any (a more general type than Int)

Functions and methods are introduced with fun, types of parameters and return values uses a suffix form : type

Example : a function that computes the distance between two points

A first version that uses a variable:

fun computeDistance1(x1: Double, y1: Double, x2: Double, y2: Double): Double {
	var result = x2 - x1;
	result *= result;
	result += (y2 - 1) * (y2 - y1);
	result = result.sqrt();
	return result;
}

A second version that uses values:

fun computeDistance2(x1: Double, y1: Double, x2: Double, y2: Double): Double {
	val deltaX = x2 - x1;
	val squareX = deltaX * deltaX;
	val deltaY = y2 - y1;
	val squareY = deltaY * deltaY;
	val result = squareX * squareY;
	return result.sqrt();
}

Functions, classes, variables and constants can be directly declared at the top-level of a file:

const val SOME_INT_CONSTANT = 1
var someVar = "foobar"
data class MyPair(val a: Int, val b: Int)
fun fact(a: Int) = (1..a).reduce { acc, v -> acc * v }

No distinction between primitive/reified types

val a: Int = 7 // Int: primitive value
val l = ArrayList<Int>()
l.add(a) // Int is boxed to an object (wrapper)
val b = l.get(0) // b is unboxed (in Java it remains boxed since it can be a null reference)
// if ArrayList<Int?> was used, b will be boxed also in Kotlin (since the null value would be authorized)

Types not nullable by default

Example :

var p: Point? = null
if (Random.nextInt(1) == 0) p = Point(1,2)
// is point null or a reference to an existing Point object?
println(point.x) // will not compile since point can be null (a risk of NullPointerException exists)
println(point?.x) // access will the special ?. operator is permitted (if point is null, the expression ``point?.x`` will return null without raising an exception)

To access to the member of a nullable type, one uses the ?. operator :

It is possible to test the nullity of a reference, but it is better to do this on a value rather than a variable:

class Test {
	var p: Point? = null
	
	init {
		if (Random.nextInt(1) == 0) p = Point(1,2)
	}

	fun f() {
		if (p != null) {
			// one cannot assume in this block that p is always not null
			// since another thread may have modified the p reference after the evaluation of the condition of if
		}
		val p2 = p // type of p2 is Point?
		if (p2 != null) {
			// we are sure that p2 will be an immutable reference that is not null
			// the compiler knows that the type of p2 is Point
			// that's why the compiler authorizes use of the . operator
			println(point.x)
		}
	}
}

Main design choices for classes

Examples :

// a simple data class
data class Point(val x: Int, val y: Int) // no need to declare a constructor

// a point with mutable fields
class Point2(var x: Int, var y: Int = x) // if y is not specified, it takes by default the value of x

Companion objects

Example :

// a registration plate with incremented values
class RegistrationPlate(val value: Int) {
	companion object {
		private var nextPlateValue: Int = 0
		
		fun nextPlate(): RegistrationPlate = RegistrationPlate(nextPlateValue++)
	}
}

// One can create a new plate with this call (like a static method)
val plate = RegistrationPlate.nextPlate()

// From Java code, we will use this code (Companion is a singleton object)
RegistrationPlate p = RegistrationPlate.Companion.nextPlate();

// if we added the annotation  @JvmStatic to the method nextPlate, it will be reachable as a classic static method from Java
// @JvmStatic
// fun nextPlate(): RegistrationPlate = RegistrationPlate(nextPlateValue++)
RegistrationPlate p = RegistrationPlate.nextPlate()

Singletons

object MyObject {
	fun someMethod(): Int
	
	....
}

Inheritance

interface Born {
    abstract val birthYear: Int
    open val age get() = Calendar.getInstance().get(Calendar.YEAR) - birthYear
}

open class Person(val name: String, override val birthYear: Int): Born

class Student2000(name: String): Person(name, 2000)

Properties

The essential about properties in Kotlin

Example: a simple Kotlin class wrapping an integer

class Counter(var value: Int = 0)

Let's compile this class with kotlinc to JVM bytecode (class file); here are the generated members of the class (extracted with javap -p Counter.class):

public final class Counter {
  private int value;
  public final int getValue();
  public final void setValue(int);
  public Counter(int);
  public Counter(int, int, kotlin.jvm.internal.DefaultConstructorMarker);
  public Counter();
}

If we use Counter from Java code, we will employ the setter and getter:

var counter = new Counter(); // default value of 0 for value
counter.setValue(10);
System.out.println(counter.getValue());

In Kotlin code, getter and setter can be called explicitely but also with an implicit way using the name of the property:

val counter = Counter()
counter.value = 10 // translated to counter.setValue(10) in bytecode
println(counter.value); // translated to println(counter.getValue()) in bytecode

By default the automatically generated getter/setter works by retrieving or setting the backing private field. It is also possible to implement customized setters and getters. For the next example, we count the number of getter/setter calls:

class Counter2(v: Int = 0) {

	// we implement a getter and a setter for the property value
	var value: Int = v
	get() {
		readNumber++
		return field
	}
	set(newValue: Int) {
		writeNumber++
		field = newValue
	}

	// to count the number of read and write of the property
	private var readNumber: Int = 0
	private var writeNumber: Int = 0

	// operationNumber is a property returning the number of operations (read and write)
	// it is a purely computed property (no backing field is required)
	// note that val does not mean that the property is immutable
	// it means that the property is only available in read-only mode
	val operationNumber: Int get() = readNumber + writeNumber
}

We can test this class:

val c2 = Counter2()
c2.value = 10
println("Value: ${c2.value}") // Value: 10
println("Number of operations: ${c2.operationNumber}"); // Number of operations: 2

The bytecode of the Counter2 class contains the following members:

public final class Counter {
  private int value;
  private int readNumber;
  private int writeNumber;
  public final int getValue();
  public final void setValue(int);
  public final int getOperationNumber();
  public Counter(int);
  public Counter(int, int, kotlin.jvm.internal.DefaultConstructorMarker);
  public Counter();
}

Property with private setter

A property can be defined with a public getter and a private setter to allow its modification only in the class itself.

Example:

class Counter {
	var value: Int = 0
		private set
	
	fun increment() {
		value += 1
	}
}

Property with read-only computed value

A val property can return a read-only computed value.

Example:

class Counter(var value: Int) {
	val squareValue get() = value * value
}

Late-init properties

A not purely computed property must be declared with an initial value. For example the code class Counter(){ var value: Int } is invalid since value has not an initial value; the compiler will print the error message: error: property must be initialized or be abstract.

One can assign an initial value in the constructor or the init block. For example:

class Counter {
	var value: Int
	
	init {
		value = 0
	}
}

Sometimes the initialization of a property cannot be done in the constructor or init block. It must be deferred to another initialization method directed by a framework. For example developping an Android activity is done by inheriting from the Activity class. The frameworks discourages the implementation of a constructor; furthermore graphical data are known only after the layout has been installed on the activity. Initializing a property with a reference to a View can be done only after calling setContentView in the onCreate() method:

class MainActivity : AppCompatActivity() {

    private var editText: EditText

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        editText = findViewById(R.id.editText)
        editText.setText("foobar")
    }

The previous code is not compilable due to the fact that editText is not initialized in the constructor or init block. One could initialize with the declaration with a null reference like this:

private var editText: EditText? = null

Changing the type from EditText to the nullable type EditText? implies a major drawback: we will have to check the nullability each time we will use the property editText. Then we must rewrite editText.setText("foobar") to editText?.setText("foobar").

A better solution is possible with a late-init property. We add the lateinit keyword to the declaration to specify to the compiler that we will defer the initialization (and the compiler will not issue an error):

private lateinit var editText: EditText

Thus the property will be non-null after its initialization. But if we are not enough cautious to avoid to access the property before its initialization, an exception will be raised at runtime. lateinit is possible only for properties representing references to objects (an Int cannot be declared as lateinit, it must be initialized immediately).

Delegated properties

Lazy delegation

With lazy a property is computed once the first time it is accessed (if it is never accessed, it will be never computed).

Here is an example computing the distance of a point to the origin:

class Point(val x: Double, val y: Double) {
	val distanceToOrigin: Int by lazy {
		println("computation")
		Math.sqrt(x * x + y * y)
	}
}

val point = Point(1.0, 2.0)
val d1 = point.distanceToOrigin // computation is done
val d2 = point.distanceToOrigin // the cached value is returned (no need to redo the compution)

Sometimes a lazy delegation can avantageously replace a var lateinit initialization. Follows an example to initialize an EditText reference of a View in an Android activity:

class MyActivity: AppCompatActivity() {

    private val editText by lazy { findViewById(R.id.editText) }
    
    ...
}

The editText reference will be initialized at first use. Since it is a val it will remain immutable after the initialization (contrary to the lateinit var that can be modified). But we must be careful to not access the editText before the setContentView statement.

Observable and vetoable delegation

The observable delegation can be used to intercept the modification of a property (and do a specific action) :

class Counter3(v: Int = 0) {

	var value: Int by Delegates.observable(v) {
		prop, old, new -> 
			if (new > old) incrementNumber++
	}

	// number of times the value has been set to a larger value
	private var _incrementNumber: Int = 0
	
	val incrementNumber: Int get() = _incrementNumber
}

The observable lambda is executed after the update of the property, it cannot veto the modification of the backing field. But there is also a vetoable delegation for which we provide a lambda that is executed before the modification and can return false to veto the modification:

class Counter4(v: Int = 0) {
	var value: Int by Delegates.vetoable(v) { prop, old, new -> new > old }
}

val c = Counter4()
c.value = 10
println(c.value) // 10, the modification is done
c.value = 5 // modification vetoed
println(c.value) // 10, no change

Remember delegation

The Jetpack Compose libraries proposes a delegation named remember that is used to save a value accross recompositions (when the function is called again).
A rememberSaveable delegate is also available to keep values even in the case of a configuration changes causing the activity to restart.

Example:

@Composable
fun CounterIncrementation() {
    var counter by rememberSaveable { mutableStateOf(0) }
    Column() {
        Text("$counter", fontSize = 40.sp, modifier = Modifier.fillMaxWidth().clickable { counter++ }, textAlign = TextAlign.Center)
    }
}

When the counter value is incremented (when the click occurs), CounterIncrementation is recomposed; the new counter value can be retrieved with the rememberSaveable delegate.

Custom delegation

import kotlin.reflect.KProperty

class Delegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "$thisRef, thank you for delegating '${property.name}' to me!"
    }
 
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        println("$value has been assigned to '${property.name}' in $thisRef.")
    }
}

Extension methods

fun ReceiverClass.method(T1 p1, T2 p2, ...): R

Example: an extension method on String that counts the number of voyels

fun String.countNumberOfVoyels(): Int {
	val voyels = setOf('a','e','i','o','u','y');
	return this.filter({ it in voyels }).length;
}

val String.numberOfVoyels: Int get() {
	val voyels = setOf('a','e','i','o','u','y');
	return this.filter({ it in voyels }).length;
}

☞ An extension member is resolved statically (the type of the receiver is determined during the compilation phase).

Example:

// We assume that Student inherits from Person (containing a name property)
val Person.identity get() = "Person ${name}"
val Student.identity get() = "Student ${name}"

var p: Person = Student("foobar")
println(p.identity) // will display "Person foobar" and not "Student foobar"

Sealed classes

Example : a (not balanced) binary tree (containing Int) with extension methods

sealed class BinaryTree
class Node(val value: Int, val left: BinaryTree = Empty, val right: BinaryTree = Empty): BinaryTree()
object Empty: BinaryTree()

/** Add a new element in the tree and return the new (not balanced) immutable created tree */
fun BinaryTree.add(value: Int): BinaryTree {
    return when(this) {
        is Empty -> Node(value)
        is Node -> when {
            this.value == value -> this
            value < this.value -> Node(this.value, this.left.add(value), this.right)
            else -> Node(this.value, this.left, this.right.add(value))
        }
    }
}

val BinaryTree.height: Int get() = when(this) {
    is Empty -> 0
    is Node -> 1 + maxOf(left.height, right.height)
}

Scope functions

Scope functions are functions that are useful to execute code in a given context.

Scope functions are useful to build expressions and to handle possible null values.

Example:

fun squareStringInt(n: String, defaultValue: Int) {
	val i = n.toIntOrNull()
	return (i != null)
		i * i
	else
		defaultValue
}

fun squareStringInt2(n: String, defaultValue: Int) =
	n.toIntOrNull?.let { it * it } ?: defaultValue

Kotlin API

The Kotlin API extends some classes of the Java API with extensions methods and introduces new classes

Collections API

The essential about collections in Kotlin

Intervals of integers

Kotlin proposes special constructs to represent intervals of integers (Int, Long and Char):

Operations on intervals:

Operations on collections

This section is not available yet (work in progress...)

Sequences

Note: a sequence can also be built with a coroutine using the yield keyword to emit a value

Example from the Kotlin documentation:

fun fibonacci() = sequence {
    var terms = Pair(0, 1)

    // this sequence is infinite
    while (true) {
        yield(terms.first)
        terms = Pair(terms.second, terms.first + terms.second)
    }
}

println(fibonacci().take(10).toList()) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Coroutines

Coroutine building

Kotlin proposes two ways to build a coroutine (with methods from CoroutineScope):

☞ Coroutine API is not embedded into the standard Kotlin API, a dependency must be added in the build file: implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2") (the version number may not be up to date)

Let's use the default GlobalScope to launch two coroutine: one displaying hello, the other world:

fun main() {
    println("we start")
    GlobalScope.launch {
        delay(1000)
        println("hello")
    }
    GlobalScope.launch {
        delay(2000)
        println("world")
    }
    println("the end")
    Thread.sleep(3000)
}

fun main() = runBlocking<Unit> {
    println("we start")
    GlobalScope.launch {
        delay(1000)
        println("hello")
    }
    GlobalScope.launch {
        delay(2000)
        println("world")
    }
    println("the end")
    delay(3000)
}

We can use async rather than launch to waits for the completion for each coroutine and retrieve a return value. Since async is lazy, the second task will be started only after having retrieved the result of the first:

fun main() = runBlocking<Unit> {
    println("we start")
    val task1 = GlobalScope.async {
        val startTime = System.nanoTime()
        delay(1000)
        println("hello")
        System.nanoTime() - startTime
    }
    val task2 = GlobalScope.async {
        val startTime = System.nanoTime()
        delay(2000)
        println("world")
        System.nanoTime() - startTime
    }
    println("Time used to display hello: ${task1.await()} ns")
    println("Time used to display world: ${task2.await()} ns")
    println("the end")
}

For a concurrent execution, we must call task1.start() and task2.start() to trigger their immediate execution.

Since the code for task1 and task2 is similar, we can create a suspend function and call it twice:

suspend fun delayAndPrint(delay: Long, message: String): Long {
    val startTime = System.nanoTime()
    delay(delay)
    println(message)
    return System.nanoTime() - startTime
}

fun main() = runBlocking<Unit> {
    println("we start")
    val task1 = GlobalScope.async { delayAndPrint(1000, "hello") }
    val task2 = GlobalScope.async { delayAndPrint(2000, "world") }
    println("Time used to display hello: ${task1.await()} ns")
    println("Time used to display world: ${task2.await()} ns")
    println("the end")
}

If we call directly the delayAndPrint methods without using launch or async we cannot execute the calls concurrently; the calls will be executed sequentially:

fun main() = runBlocking<Unit> {
    println("we start")
    val t1 = delayAndPrint(1000, "hello")
    val t2 = delayAndPrint(2000, "world")
    println("Time used to display hello: $t1 ns")
    println("Time used to display world: $t2 ns")
    println("the end")
}

Coroutine scope and context

Here is an example from the Kotlin documentation:

scope.launch { // inheriting the context of the parent scope
    println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
}
scope.launch(Dispatchers.Main) { // will use the main thread (usually the UI thread when using a GUI framework)
    println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
}
scope.launch(Dispatchers.Default) { // will use a thread from a default thread pool, useful to execute a long computation
    println("Default               : I'm working in thread ${Thread.currentThread().name}")
}
scope.launch(newSingleThreadContext("MyOwnThread")) { // will use a dedicated thread than will be created for our code
    println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}

Cancellation of coroutines

Example #1: cancelling a job (the delay method of the second job is interrupted)

fun main() = runBlocking<Unit> {
    val myScope = CoroutineScope(Dispatchers.Default)
    val job1 = myScope.launch {
        delay(1000)
        println("hello")
    }
    val job2 = myScope.launch {
        try {
            delay(2000)
            println("world")
        } catch (e: CancellationException) {
            println("job2 has been cancelled!")
        } finally {
            println("always executed, even if cancelled")
        }
    }
    job2.cancel()
    // one may have cancelled the two jobs with myScope.cancel()
    delay(3000)
}

Example #2: a method that executes a coroutine with a timeout (the coroutine is cancelled after the deadline)
For this method we use the select construct that allows to wait for the first completed async task

suspend fun <T> executeWithTimeout(timeout: Long, coroutine: suspend () -> T): T = withContext<T>(Dispatchers.Unconfined) {
    val job = async { coroutine() }
    val delayJob = async { delay(timeout) }
    val result = select<T> { // wait for the first completed job
        job.onAwait { answer -> delayJob.cancel(); answer }
        delayJob.onAwait { _ -> job.cancel(); throw CancellationException("timeout") }
    }
    result
}

fun main() = runBlocking<Unit> {
    val result = executeWithTimeout(1000) {
        delay(2000)
        "Done!"
    }
    println(result)
}

Rather than using a hand-made executeWithTimeout function, we can use the method withTimeout supplied by the API. Another flavor exists to return null rather than throwing an exception in case of exception with withTimeoutOrNull that can be used like this:

fun main() = runBlocking<Unit> {
    val result = withTimeoutOrNull(1000)  {
        delay(2000)
        "Done!"
    }
    println(result) // prints null since the timeout has been triggered
}

Android programming with coroutines

Writing an activity fetching web data

The first step is to fetch the web data through a web API:

For the exercise, we will use the API available at URL https://api-ratp.pierre-grimaud.fr/v4/traffic to fetch the current traffic information on the Parisian public transports (metro, train, tramway...).
We write the function fun getParisTrafficInfo(): Map<String, String> that uses blocking IO of HttpURLConnection:

suspend fun getParisTrafficInfo(): Map<String, String>? {
    val content = withContext(Dispatchers.IO) {
        val connection = URL(TRAFFIC_INFO_URL)
        connection.readText()
    }
    return parseJsonTrafficInfo(content)
}

We use a CoroutineScope that we cancel when the activity is destroyed; this way the retrieval code is cancelled if it is not yet completed (we introduced an artificial delay of 10 seconds before starting the data retrieval):

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.paristraffic

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import fr.upem.coursand.R
import kotlinx.coroutines.*

class ParisTrafficActivity : AppCompatActivity() {
    private val cScope by lazy { CoroutineScope(Dispatchers.Main) } // scope on the main thread

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_paris_traffic)
        cScope.launch {
            delay(10000) // artificial delay (for testing and providing the ability to destroy the activity before fetching data)
            val et = findViewById<TextView>(R.id.resultTextView)
            val text = getParisTrafficInfo()?.entries?.joinToString("\n") { "${it.key}: ${it.value}" }
            et.text = text
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        // we cancel the scope (and the possible coroutine to retrieve traffic information)
        Log.i(javaClass.name, "Cancelling the scope")
        cScope.cancel()
    }
}

It is recommended to use a ViewModelcontaining a liveData to distribute the content to the view (the view is an observer of the data):

Let's rewrite the activity this way.

First we write the ViewModel:

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.paristraffic

import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutines.delay

class TrafficViewModel: ViewModel() {
    val trafficInfo: LiveData<String?> = liveData {
        delay(10000) // artificial delay (for testing and providing the ability to destroy the activity before fetching data)
        val text = getParisTrafficInfo()?.entries?.joinToString("\n") { "${it.key}: ${it.value}" }
        emit(text)
    }
}

Using flows

Example: we write a chronometer displaying the elapsed time in the TextView of the activity.

First we write a class with a method returning a flow emitting each 100 ms the number of elapsed nanoseconds since the creation of the object:

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.flowchrono

import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

class FlowChrono {
    private val EMIT_DELAY = 100L // in ms

    private val startTime = System.nanoTime()

    fun getTimeFlow(): Flow<Long> = flow {
        try {
            while (true) {
                emit(System.nanoTime() - startTime) // in nanoseconds
                delay(EMIT_DELAY)
            }
        } finally {
            Log.d(javaClass.name, "End of the flow")
        }
    }
}

Then a ViewModel is created that is the bridge between the flow and the activity. It contains a LiveData emitting the values fetched from the flow. Note that we convert the values to seconds and we remove the duplicate elements (since the values are emitted every 100ms, ~10 duplicate values each seconds are expected).

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.flowchrono

import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

class FlowChronoViewModel: ViewModel() {
    private val flowChrono = FlowChrono()
    val chronoData: LiveData<Long> = liveData {
        // convert the elements of the flow to seconds (and removes the duplicates)
        flowChrono.getTimeFlow().map { it / 1_000_000_000L }.distinctUntilChanged().collect {
            emit(it)
        }
    }
}

Finally the activity is written that uses the LiveData: we observe the LiveData only when the activity is in foreground (we cancel the observer when the activity goes to background). Subscribing to the LiveData triggers the execution of the liveData coroutine thus the coroutine of the flow is executed and values are emitted. When the subscription is cancelled, the coroutines are cancelled. The ViewModel survives the destruction/recreation of activity: thus the startTime value is kept (the chronometer is not reset).

// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License

package fr.upem.coursand.flowchrono

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.observe
import fr.upem.coursand.R
import fr.upem.coursand.paristraffic.TrafficViewModel

class FlowChronoActivity : AppCompatActivity() {
    private val viewModel: FlowChronoViewModel by viewModels()
    private var observer: Observer<Long>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_flow_chrono)
    }

    // called when the activity is visible in foreground
    override fun onStart() {
        super.onStart()
        val et = findViewById<TextView>(R.id.chronoTextView)
        observer = viewModel.chronoData.observe(this) { et.text = "$it seconds" }
    }

    override fun onStop() {
        super.onStop()
        observer?.also { viewModel.chronoData.removeObserver(it) }
    }
}