Skip to content

gladed/watchable

Repository files navigation

Download Quality Gate Status CircleCI CodeCov detekt Kotlin API Docs

Watchable

This library uses Kotlin coroutines to provide data structures that are mutable, lock-free, thread-safe, and observable.

coroutineScope {
    val set = watchableSetOf(1, 2)
    watch(set) { println("Got $it") }.start()
    set.add(3)

    // Output:
    // Got Initial(set=[1, 2])
    // Got Add(add=[3])
}

Why Use This?

Listening for changes to objects is hard:

  • Define listeners to match your data.
  • Store and maintain the list of listeners in your object.
  • Understand and be sensitive the listener's threading model.
  • Remember to remove them when you are done listening (the Lapsed Listener Problem).

Watchable solves these problems by defining a standard interface for monitoring changes to ordinary data objects.

This can help Clean Architecture designs, in which the data model lives at the center, and everyone depends on it. When the data changes, higher level components can handle those changes without depending on each other.

Usage

Add to build.gradle:

repositories {
    maven { url 'https://www.jitpack.io' }
}

dependencies {
    implementation 'com.github.gladed:watchable:v0.6.21'
}

Features

Watchable Data Types

WatchableList, WatchableSet, and WatchableMap allow access to wrapped List, Set, and Map data. WatchableValue wraps a single object value of any type.

val list = watchableListOf(1, 2, 3)
val map = watchableMapOf(4 to "four")
val set = watchableSetOf(5.0, 6.0)
val value = watchableValueOf(URI.create("https://github.com"))

Each data type can be accessed normally, but it can also be modified, watched, bound, etc. as described below.

Reading and Writing Data

Use WatchableMap, WatchableList, and WatchableSet anywhere you would use a normal Kotlin Map, List, or Set. Use WatchableValue to wrap a single object. Contents may be changed from within a suspending function at any time.

val map = watchableMapOf(1 to "1")
println(map) // Prints {1=1}
map.put(2, "2") // Suspends if concurrent modification attempted
println(map) // Prints {1=1, 2=2}

To perform multiple operations while protecting from concurrent access, invoke the object:

val list = watchableListOf(1, 2, 3)
// Remove last element safely
println(list { removeAt(list.size - 1) }) // Removes last element, prints 3

Some Kotlin extension functions on List are unreliable if the data is modified from separate threads, since these functions assume List cannot change during execution. For example, List.last and List.getOrElse access the size and then an element in separate steps.

Watching for Changes

Watch any Watchable for changes from within any CoroutineScope using watch:

val set = watchableSetOf(1, 2)
watch(set) { println(it) }.start()
set += 3
set -= listOf(3, 2)

// Prints:
// Initial(set=[1, 2])
// Add(add=[3])
// Remove(remove=[3, 2])

Watchable objects send out an "Initial" change to reflect the current state at the very beginning of the watch operation.

start() is not required, but we use it here to force the "Initial" change to appear before any other changes.

Batching

It's possible to listen for lists of changes, collected and delivered in-order periodically. See the documentation for batch, especially the minPeriod parameter.

val list = listOf(4, 5).toWatchableList()
batch(list, 50) { println(it) }.start()
list { add(6); add(7) }

// After time passes, prints:
// [Initial(list=[4, 5]), Add(index=2, added=6), Add(index=3, added=7)]

Simple Watches

You may not really care about the details of a change, or just want to respond to simple adds and removes of values. A simplified syntax allows you to see handle incremental changes as the receiver of your lambda:

val map = watchableMapOf(1 to "2")
simple(map) {
    with(it) {
        println("at $key remove $remove add $add")
    }
}.start()
map.put(1, "3")

// Prints:
// at 1 remove null add 2
// at 1 remove 2 add 3

Binding

A bind is just a watch that connects a target watchable to an originating watchable, so that the target automatically receives all changes from the origin.

val origin = listOf(4, 5).toWatchableList()
val target = watchableListOf<Int>()
bind(target, origin).start()
println(origin == target) // true

While bound, a watchable cannot be independently modified, and attempts to do so in use will throw.

Complex binds are possible in which changes are received and may be arbitrarily mapped into changes on a bound item:

val origin = listOf(4, 5).toWatchableList()
val target = watchableValueOf(0)
bind(target, origin) {
    // Update value with current origin size
    value = origin.size
}.start()
println(target) // 2

Grouping

You can group several watchables into a WatchableGroup so that you receive changes for both:

val set = setOf("a").toWatchableSet()
val list = listOf(4).toWatchableList()
watch(group(set, list)) { println(it) }
// Prints:
// GroupChange(watchable=[a], change=Initial(set=[a]))
// GroupChange(watchable=[4], change=Initial(list=[4]))

list += 6
set += "b"
// Prints:
// GroupChange(watchable=[4, 6], change=Insert(index=1, insert=[6]))
// GroupChange(watchable=[a, b], change=Add(add=[b]))""")

Read-Only Watchables

You can use any MutableWatchable's readOnly function to return a Watchable which cannot be changed externally. The copy may still be watched normally.

Object Lifetime

All operations (watch, bind, simple, etc.) are initiated from a CoroutineScope. When that scope completes, the corresponding operations cease. No additional cleanup code is required.

In addition, all operations return a Watcher. You can use this to wait for the start of the operation, immediately cancel, or gracefully stop it to allow outstanding changes to be processed first.

val list = watchableListOf(1)
val handle = watch(list) { println(it) }
handle.start()
list.add(2)
handle.stop() // deliver prior changes, but don't watch for new changes
list.add(3)

// Prints:
// Initial(list=[1])
// Insert(index=1, insert=[2])

Sample

See the Sample Project for some ideas on how this could be integrated into a project.

Version History

See HISTORY.md

About

Listenable data structures using Kotlin Coroutines

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages