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])
}
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.
Add to build.gradle
:
repositories {
maven { url 'https://www.jitpack.io' }
}
dependencies {
implementation 'com.github.gladed:watchable:v0.6.21'
}
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.
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.
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.
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)]
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
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
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]))""")
You can use any MutableWatchable
's readOnly
function to return a Watchable
which cannot be changed externally. The copy may still be watched normally.
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])
See the Sample Project for some ideas on how this could be integrated into a project.
See HISTORY.md