Skip to content

Commit 25881c1

Browse files
committed
decoupling consumer/producer in ChangeManager
1 parent 5377e4d commit 25881c1

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

AnkiDroid/src/main/java/com/ichi2/anki/observability/ChangeManager.kt

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,88 @@ import anki.collection.opChanges
4040
import anki.import_export.ImportResponse
4141
import com.ichi2.anki.common.crashreporting.CrashReportService
4242
import com.ichi2.anki.utils.ext.ifNotZero
43+
import kotlinx.coroutines.CoroutineDispatcher
44+
import kotlinx.coroutines.CoroutineScope
45+
import kotlinx.coroutines.CoroutineStart
46+
import kotlinx.coroutines.Dispatchers
47+
import kotlinx.coroutines.Job
48+
import kotlinx.coroutines.SupervisorJob
49+
import kotlinx.coroutines.cancel
50+
import kotlinx.coroutines.channels.BufferOverflow
51+
import kotlinx.coroutines.flow.MutableSharedFlow
52+
import kotlinx.coroutines.launch
4353
import org.jetbrains.annotations.Contract
4454
import timber.log.Timber
4555
import java.lang.ref.WeakReference
4656
import java.util.concurrent.CopyOnWriteArrayList
4757

4858
object ChangeManager {
59+
@get:VisibleForTesting
60+
@set:VisibleForTesting
61+
internal var dispatcher: CoroutineDispatcher = Dispatchers.Main
62+
set(value) {
63+
if (field == value) return
64+
field = value
65+
66+
resetCollector()
67+
}
68+
69+
private var scope: CoroutineScope? = null
70+
private var collectorJob: Job? = null
71+
72+
private val changeFlow =
73+
MutableSharedFlow<Pair<OpChanges, Any?>>(
74+
extraBufferCapacity = 64,
75+
onBufferOverflow = BufferOverflow.DROP_OLDEST,
76+
)
77+
78+
private fun ensureCollector() {
79+
if (collectorJob?.isActive == true) return
80+
81+
val currentScope = CoroutineScope(dispatcher + SupervisorJob())
82+
scope = currentScope
83+
collectorJob =
84+
currentScope.launch(start = CoroutineStart.UNDISPATCHED) {
85+
changeFlow.collect { (changes, handler) ->
86+
notifySubscribers(changes, handler)
87+
}
88+
}
89+
}
90+
91+
private fun resetCollector() {
92+
collectorJob?.cancel()
93+
collectorJob = null
94+
scope?.cancel()
95+
scope = null
96+
}
97+
98+
fun <T : Any> publish(
99+
changes: T,
100+
handler: Any? = null,
101+
) {
102+
ensureCollector()
103+
val opChanges =
104+
when (changes) {
105+
is OpChanges -> changes
106+
is OpChangesWithCount -> changes.changes
107+
is OpChangesWithId -> changes.changes
108+
is OpChangesAfterUndo -> changes.changes
109+
is OpChangesOnly -> changes.changes
110+
is ImportResponse -> changes.changes
111+
else -> {
112+
Timber.e("unhandled change type: ${changes::class}")
113+
return
114+
}
115+
}
116+
117+
val success = changeFlow.tryEmit(Pair(opChanges, handler))
118+
if (!success) {
119+
scope?.launch {
120+
changeFlow.emit(Pair(opChanges, handler))
121+
}
122+
}
123+
}
124+
49125
// do not make this a 'fun interface' - lambdas may immediately be GCed
50126
// due to the use of WeakReference
51127
interface Subscriber {
@@ -78,6 +154,7 @@ object ChangeManager {
78154
subscriber: Subscriber,
79155
owner: LifecycleOwner? = subscriber as? LifecycleOwner,
80156
) {
157+
ensureCollector()
81158
val weakRef = WeakReference(subscriber)
82159

83160
if (owner == null) {
@@ -156,7 +233,10 @@ object ChangeManager {
156233
is OpChangesAfterUndo -> changes.changes
157234
is OpChangesOnly -> changes.changes
158235
is ImportResponse -> changes.changes
159-
else -> TODO("unhandled change type of class '${changes::class}'")
236+
else -> {
237+
Timber.e("unhandled change type: ${changes::class}")
238+
return
239+
}
160240
}
161241
notifySubscribers(opChanges, initiator)
162242
}

AnkiDroid/src/test/java/com/ichi2/anki/observability/ChangeManagerTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,24 @@
1616
package com.ichi2.anki.observability
1717

1818
import anki.collection.OpChanges
19+
import com.ichi2.anki.RobolectricTest
1920
import com.ichi2.testutils.JvmTest
21+
import kotlinx.coroutines.Dispatchers
22+
import kotlinx.coroutines.ExperimentalCoroutinesApi
23+
import kotlinx.coroutines.test.StandardTestDispatcher
24+
import kotlinx.coroutines.test.advanceUntilIdle
25+
import kotlinx.coroutines.test.runCurrent
26+
import kotlinx.coroutines.yield
2027
import org.hamcrest.MatcherAssert.assertThat
2128
import org.hamcrest.Matchers.equalTo
2229
import org.hamcrest.Matchers.greaterThan
2330
import org.junit.After
31+
import org.junit.Before
2432
import org.junit.Test
2533
import org.junit.runner.RunWith
2634
import org.junit.runners.Parameterized
2735
import org.robolectric.RobolectricTestRunner
36+
import org.robolectric.shadows.ShadowLooper
2837
import kotlin.reflect.KProperty1
2938
import kotlin.reflect.full.memberProperties
3039
import kotlin.reflect.javaType
@@ -107,3 +116,62 @@ class ChangeManagerTest {
107116
}
108117
}
109118
}
119+
120+
@OptIn(ExperimentalCoroutinesApi::class)
121+
@RunWith(RobolectricTestRunner::class)
122+
class ChangeManagerPublishTest : RobolectricTest() {
123+
private val testDispatcher = StandardTestDispatcher()
124+
125+
@Before
126+
override fun setUp() {
127+
super.setUp()
128+
ChangeManager.dispatcher = testDispatcher
129+
setupTestDispatcher(testDispatcher)
130+
}
131+
132+
@After
133+
override fun tearDown() {
134+
super.tearDown()
135+
ChangeManager.clearSubscribers()
136+
ChangeManager.dispatcher = Dispatchers.Main
137+
}
138+
139+
@Test
140+
fun `publish notifies multiple subscribers asynchronously`() =
141+
runTest(testDispatcher) {
142+
var firstNotified = false
143+
var secondNotified = false
144+
145+
val subscriber1 =
146+
object : ChangeManager.Subscriber {
147+
override fun opExecuted(
148+
changes: OpChanges,
149+
handler: Any?,
150+
) {
151+
firstNotified = true
152+
}
153+
}
154+
155+
val subscriber2 =
156+
object : ChangeManager.Subscriber {
157+
override fun opExecuted(
158+
changes: OpChanges,
159+
handler: Any?,
160+
) {
161+
secondNotified = true
162+
}
163+
}
164+
165+
ChangeManager.subscribe(subscriber1)
166+
ChangeManager.subscribe(subscriber2)
167+
168+
runCurrent()
169+
ChangeManager.publish(ChangeManager.ALL)
170+
171+
advanceUntilIdle()
172+
ShadowLooper.idleMainLooper()
173+
174+
assertThat("First subscriber should be notified", firstNotified, equalTo(true))
175+
assertThat("Second subscriber should be notified", secondNotified, equalTo(true))
176+
}
177+
}

0 commit comments

Comments
 (0)