perf: coalesce project chat realtime reload bursts

This commit is contained in:
kris
2026-04-05 08:07:09 +08:00
parent 0bae3a78ec
commit 6083079be9
2 changed files with 164 additions and 11 deletions

View File

@@ -85,6 +85,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
private @Nullable BossRealtimeClient realtimeClient;
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
private boolean reloadInFlight;
private boolean pendingReload;
private boolean pendingReloadForcedScrollToBottom;
static final class ChromeBindings {
final boolean multiSelecting;
@@ -123,7 +126,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
private static final class ProjectSnapshot {
static final class ProjectSnapshot {
final JSONObject payload;
final @Nullable JSONArray dispatchPlans;
final @Nullable JSONObject participantsPayload;
@@ -331,28 +334,61 @@ public class ProjectDetailActivity extends BossScreenActivity {
finish();
return;
}
if (reloadInFlight) {
pendingReload = true;
pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom;
return;
}
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = forcedScrollToBottom;
reloadInFlight = true;
setRefreshing(true);
executor.execute(() -> {
try {
ProjectSnapshot snapshot = fetchProjectSnapshot();
runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload));
ProjectSnapshot snapshot = loadProjectSnapshotForRefresh();
runOnUiThread(() -> {
renderLoadedProjectSnapshot(snapshot);
finishReloadCycle();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
composerSending = false;
updateComposerSendButtonState();
if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) {
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
} else {
showMessage("项目详情刷新失败:" + error.getMessage());
}
handleProjectReloadFailure(error);
finishReloadCycle();
});
}
});
}
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
return fetchProjectSnapshot();
}
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
}
void handleProjectReloadFailure(Exception error) {
setRefreshing(false);
composerSending = false;
updateComposerSendButtonState();
if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) {
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
} else {
showMessage("项目详情刷新失败:" + error.getMessage());
}
}
private void finishReloadCycle() {
reloadInFlight = false;
if (!pendingReload) {
return;
}
boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom;
pendingReload = false;
pendingReloadForcedScrollToBottom = false;
reload(forcedScrollToBottom);
}
@Override
protected void setRefreshing(boolean refreshing) {
super.setRefreshing(refreshing);

View File

@@ -1,9 +1,13 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.os.Looper;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -13,6 +17,10 @@ import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityRealtimeTest {
@@ -164,17 +172,126 @@ public class ProjectDetailActivityRealtimeTest {
assertEquals(1, activity.reloadCount);
}
@Test
public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
activity.blockFirstReload();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.renderCount);
activity.releaseFirstLoad();
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2);
assertEquals(2, activity.loadCallCount);
assertEquals(2, activity.renderCount);
}
private static void waitFor(BooleanSupplier condition) throws Exception {
long deadlineAt = System.currentTimeMillis() + 2_000L;
while (System.currentTimeMillis() < deadlineAt) {
Shadows.shadowOf(Looper.getMainLooper()).idle();
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(20L);
}
fail("condition not met before timeout");
}
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount;
volatile int loadCallCount;
volatile int renderCount;
private CountDownLatch firstLoadStarted;
private CountDownLatch releaseFirstLoad;
@Override
boolean shouldLoadOnCreate() {
return false;
}
void blockFirstReload() {
firstLoadStarted = new CountDownLatch(1);
releaseFirstLoad = new CountDownLatch(1);
}
boolean awaitFirstLoadStarted() throws InterruptedException {
return firstLoadStarted != null && firstLoadStarted.await(2, TimeUnit.SECONDS);
}
void releaseFirstLoad() {
if (releaseFirstLoad != null) {
releaseFirstLoad.countDown();
}
}
@Override
protected void reload() {
reloadCount += 1;
super.reload();
}
@Override
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
loadCallCount += 1;
if (loadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) {
firstLoadStarted.countDown();
releaseFirstLoad.await(2, TimeUnit.SECONDS);
}
return new ProjectSnapshot(
new JSONObject().put(
"project",
new JSONObject()
.put("name", "北区试产线")
.put("messages", new JSONArray())
),
null,
null
);
}
@Override
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
renderCount += 1;
setRefreshing(false);
}
}
}