Debounce Android realtime refresh bursts

This commit is contained in:
kris
2026-04-10 16:54:21 +08:00
parent 7593cc9cea
commit 68da424eb8
4 changed files with 130 additions and 3 deletions

View File

@@ -2,6 +2,8 @@ package com.hyzq.boss;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@@ -20,6 +22,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id"; public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id";
public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids"; public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids";
public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label"; public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label";
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L;
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String folderKey; private String folderKey;
@@ -29,8 +32,17 @@ public class ConversationFolderActivity extends BossScreenActivity {
private ArrayList<String> targetProjectIds; private ArrayList<String> targetProjectIds;
private String targetProjectLabel; private String targetProjectLabel;
private @Nullable BossRealtimeClient realtimeClient; private @Nullable BossRealtimeClient realtimeClient;
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>(); private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
private final Set<String> trackedProjectIds = new LinkedHashSet<>(); private final Set<String> trackedProjectIds = new LinkedHashSet<>();
private boolean realtimeReloadScheduled;
private final Runnable realtimeReloadRunnable = new Runnable() {
@Override
public void run() {
realtimeReloadScheduled = false;
reload();
}
};
@Override @Override
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -67,12 +79,14 @@ public class ConversationFolderActivity extends BossScreenActivity {
@Override @Override
protected void onPause() { protected void onPause() {
cancelRealtimeReloadSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
super.onPause(); super.onPause();
} }
@Override @Override
protected void onDestroy() { protected void onDestroy() {
cancelRealtimeReloadSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
super.onDestroy(); super.onDestroy();
} }
@@ -130,7 +144,20 @@ public class ConversationFolderActivity extends BossScreenActivity {
if (isDuplicateRealtimeEvent(eventFingerprint, now)) { if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return; return;
} }
runOnUiThread(this::reload); runOnUiThread(this::scheduleRealtimeReload);
}
private void scheduleRealtimeReload() {
if (realtimeReloadScheduled) {
return;
}
realtimeReloadScheduled = true;
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeReloadSchedule() {
uiHandler.removeCallbacks(realtimeReloadRunnable);
realtimeReloadScheduled = false;
} }
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {

View File

@@ -47,6 +47,7 @@ public class MainActivity extends AppCompatActivity {
private static final String KEY_LAST_ROOT_TAB = "last_root_tab"; private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L; private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L; private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L;
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L; private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
@@ -106,6 +107,7 @@ public class MainActivity extends AppCompatActivity {
private boolean conversationAutoRefreshEnabled = false; private boolean conversationAutoRefreshEnabled = false;
private boolean rootTabRefreshInFlight = false; private boolean rootTabRefreshInFlight = false;
private boolean pendingRootTabRefresh = false; private boolean pendingRootTabRefresh = false;
private boolean realtimeRefreshScheduled = false;
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>(); private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>(); private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable RootPagerAdapter rootPagerAdapter; private @Nullable RootPagerAdapter rootPagerAdapter;
@@ -123,6 +125,16 @@ public class MainActivity extends AppCompatActivity {
armConversationAutoRefresh(); armConversationAutoRefresh();
} }
}; };
private final Runnable realtimeRefreshRunnable = new Runnable() {
@Override
public void run() {
realtimeRefreshScheduled = false;
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
return;
}
refreshCurrentTab();
}
};
@Override @Override
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -178,6 +190,7 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
executor.shutdownNow(); executor.shutdownNow();
super.onDestroy(); super.onDestroy();
@@ -195,6 +208,7 @@ public class MainActivity extends AppCompatActivity {
protected void onPause() { protected void onPause() {
conversationAutoRefreshEnabled = false; conversationAutoRefreshEnabled = false;
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
super.onPause(); super.onPause();
} }
@@ -567,7 +581,20 @@ public class MainActivity extends AppCompatActivity {
if (isDuplicateRealtimeEvent(eventFingerprint, now)) { if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return; return;
} }
runOnUiThread(this::refreshCurrentTab); runOnUiThread(this::scheduleRealtimeRefresh);
}
private void scheduleRealtimeRefresh() {
if (realtimeRefreshScheduled) {
return;
}
realtimeRefreshScheduled = true;
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeRefreshSchedule() {
uiHandler.removeCallbacks(realtimeRefreshRunnable);
realtimeRefreshScheduled = false;
} }
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {

View File

@@ -44,6 +44,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name"; public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long CONVERSATION_AUTO_REFRESH_MS = 8_000L; private static final long CONVERSATION_AUTO_REFRESH_MS = 8_000L;
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L;
private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L; private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L;
private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L; private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L;
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
@@ -92,6 +93,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final Handler uiHandler = new Handler(Looper.getMainLooper());
private boolean conversationAutoRefreshArmed; private boolean conversationAutoRefreshArmed;
private boolean conversationAutoRefreshEnabled; private boolean conversationAutoRefreshEnabled;
private boolean realtimeReloadScheduled;
private boolean reloadInFlight; private boolean reloadInFlight;
private boolean pendingReload; private boolean pendingReload;
private boolean pendingReloadForcedScrollToBottom; private boolean pendingReloadForcedScrollToBottom;
@@ -108,6 +110,13 @@ public class ProjectDetailActivity extends BossScreenActivity {
armConversationAutoRefresh(); armConversationAutoRefresh();
} }
}; };
private final Runnable realtimeReloadRunnable = new Runnable() {
@Override
public void run() {
realtimeReloadScheduled = false;
triggerRealtimeReload();
}
};
static final class ChromeBindings { static final class ChromeBindings {
final boolean multiSelecting; final boolean multiSelecting;
@@ -278,6 +287,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeReloadSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
replyWaitExecutor.shutdownNow(); replyWaitExecutor.shutdownNow();
super.onDestroy(); super.onDestroy();
@@ -295,6 +305,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
protected void onPause() { protected void onPause() {
conversationAutoRefreshEnabled = false; conversationAutoRefreshEnabled = false;
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeReloadSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
super.onPause(); super.onPause();
} }
@@ -324,7 +335,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (isDuplicateRealtimeEvent(eventFingerprint, now)) { if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return; return;
} }
runOnUiThread(this::triggerRealtimeReload); runOnUiThread(this::scheduleRealtimeReload);
} }
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
@@ -362,6 +373,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
reload(); reload();
} }
private void scheduleRealtimeReload() {
if (realtimeReloadScheduled) {
return;
}
realtimeReloadScheduled = true;
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeReloadSchedule() {
uiHandler.removeCallbacks(realtimeReloadRunnable);
realtimeReloadScheduled = false;
}
private void reload(boolean forcedScrollToBottom) { private void reload(boolean forcedScrollToBottom) {
if (projectId == null || projectId.isEmpty()) { if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId"); showMessage("缺少 projectId");

View File

@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("MainActivity debounces root realtime refresh bursts", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java");
assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/);
assert.match(source, /private boolean realtimeRefreshScheduled(?: = false)?;/);
assert.match(source, /private final Runnable realtimeRefreshRunnable = new Runnable\(\)/);
assert.match(source, /scheduleRealtimeRefresh\(\)/);
assert.doesNotMatch(
source,
/runOnUiThread\(this::refreshCurrentTab\)/,
"root page should coalesce bursts instead of refreshing immediately for each event",
);
});
test("ProjectDetailActivity debounces realtime chat reload bursts", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/);
assert.match(source, /private boolean realtimeReloadScheduled(?: = false)?;/);
assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/);
assert.match(source, /scheduleRealtimeReload\(\)/);
assert.doesNotMatch(
source,
/runOnUiThread\(this::triggerRealtimeReload\)/,
"chat page should debounce repeated realtime updates before reloading",
);
});
test("ConversationFolderActivity debounces realtime folder reload bursts", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java");
assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/);
assert.match(source, /private boolean realtimeReloadScheduled(?: = false)?;/);
assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/);
assert.match(source, /scheduleRealtimeReload\(\)/);
assert.doesNotMatch(
source,
/runOnUiThread\(this::reload\)/,
"folder page should debounce repeated realtime updates before reloading",
);
});