perf: diff root list updates by stable item keys

This commit is contained in:
kris
2026-04-05 08:18:15 +08:00
parent 6083079be9
commit 71aa1a7143
2 changed files with 242 additions and 67 deletions

View File

@@ -23,6 +23,7 @@ import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
@@ -38,6 +39,7 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab";
@@ -950,7 +952,7 @@ public class MainActivity extends AppCompatActivity {
}
if (filteredConversations == null || filteredConversations.length() == 0) {
String emptyText = conversationSelectionMode ? "当前没有可发起群聊的线程。" : "当前没有会话数据。";
items.add(() -> BossUi.buildEmptyCard(this, emptyText));
items.add(rootListItem("conversations-empty", emptyText, () -> BossUi.buildEmptyCard(this, emptyText)));
showListPage(items);
return;
}
@@ -968,14 +970,19 @@ public class MainActivity extends AppCompatActivity {
}
if (pinnedItems.length() > 0) {
items.add(() -> BossUi.buildConversationSectionHeader(
this,
"置顶会话",
pinnedConversationsCollapsed ? "展开" : "收起",
v -> {
pinnedConversationsCollapsed = !pinnedConversationsCollapsed;
renderConversationsRoot();
}
String actionLabel = pinnedConversationsCollapsed ? "展开" : "收起";
items.add(rootListItem(
"conversations-pinned-header",
"pinned:" + actionLabel,
() -> BossUi.buildConversationSectionHeader(
this,
"置顶会话",
actionLabel,
v -> {
pinnedConversationsCollapsed = !pinnedConversationsCollapsed;
renderConversationsRoot();
}
)
));
if (!pinnedConversationsCollapsed) {
appendConversationRows(items, pinnedItems);
@@ -993,53 +1000,77 @@ public class MainActivity extends AppCompatActivity {
String conversationType = item.optString("conversationType", "");
String folderKey = item.optString("folderKey", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
rootItems.add(() -> BossUi.buildConversationRow(
this,
row,
conversationSelectionMode,
selectedConversationProjectIds.contains(projectId),
v -> {
if (conversationSelectionMode) {
if (!"single_device".equals(conversationType)) {
showMessage("只能选择线程会话发起群聊");
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
toggleConversationSelection(projectId);
return;
}
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
return;
}
openConversationFolder(folderKey, row.threadTitle);
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
openProject(projectId, projectName);
}));
boolean selected = selectedConversationProjectIds.contains(projectId);
String stableKey = !"".equals(projectId)
? "conversation-project:" + projectId
: "conversation-folder:" + folderKey;
String signature = row.threadTitle + "|"
+ row.lastMessagePreview + "|"
+ row.timeLabel + "|"
+ row.unreadCount + "|"
+ row.contextStatusLabel + "|"
+ row.activityIconCount + "|"
+ selected + "|"
+ conversationSelectionMode;
rootItems.add(rootListItem(
stableKey,
signature,
() -> BossUi.buildConversationRow(
this,
row,
conversationSelectionMode,
selected,
v -> {
if (conversationSelectionMode) {
if (!"single_device".equals(conversationType)) {
showMessage("只能选择线程会话发起群聊");
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
toggleConversationSelection(projectId);
return;
}
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
return;
}
openConversationFolder(folderKey, row.threadTitle);
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
openProject(projectId, projectName);
})
));
}
}
private void appendConversationSelectionControls(List<RootListItem> items) {
items.add(() -> buildSelectionSummaryView());
items.add(() -> {
Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false);
cancelButton.setOnClickListener(v -> exitConversationSelectionMode());
items.add(rootListItem(
"conversation-selection-summary",
"selected:" + selectedConversationProjectIds.size(),
this::buildSelectionSummaryView
));
items.add(rootListItem(
"conversation-selection-actions",
"create-enabled:" + (selectedConversationProjectIds.size() >= 2),
() -> {
Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false);
cancelButton.setOnClickListener(v -> exitConversationSelectionMode());
Button createButton = BossUi.buildMiniActionButton(this, "发起群聊", true);
createButton.setEnabled(selectedConversationProjectIds.size() >= 2);
createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection());
return BossUi.buildInlineActionRow(this, cancelButton, createButton);
});
Button createButton = BossUi.buildMiniActionButton(this, "发起群聊", true);
createButton.setEnabled(selectedConversationProjectIds.size() >= 2);
createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection());
return BossUi.buildInlineActionRow(this, cancelButton, createButton);
}
));
}
private LinearLayout buildSelectionSummaryView() {
@@ -1331,7 +1362,7 @@ public class MainActivity extends AppCompatActivity {
}
List<RootListItem> items = new ArrayList<>();
if (devicesData == null || devicesData.length() == 0) {
items.add(() -> BossUi.buildEmptyCard(this, "当前没有接入设备。"));
items.add(rootListItem("devices-empty", "devices-empty", () -> BossUi.buildEmptyCard(this, "当前没有接入设备。")));
showListPage(items);
return;
}
@@ -1341,22 +1372,46 @@ public class MainActivity extends AppCompatActivity {
if (item == null) continue;
String deviceId = item.optString("id", "");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
items.add(() -> BossUi.buildDeviceCard(
this,
row,
v -> {
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
openDevice(deviceId, row.title);
},
null
String signature = row.title + "|" + row.subtitle + "|" + row.meta + "|" + row.avatarLabel + "|" + row.statusKey;
items.add(rootListItem(
"device:" + deviceId,
signature,
() -> BossUi.buildDeviceCard(
this,
row,
v -> {
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
openDevice(deviceId, row.title);
},
null
)
));
}
showListPage(items);
}
private RootListItem rootListItem(String key, String signature, Supplier<View> builder) {
return new RootListItem() {
@Override
public String stableKey() {
return key;
}
@Override
public String contentSignature() {
return signature;
}
@Override
public View build() {
return builder.get();
}
};
}
private void renderMeRoot() {
if (screenContent == null) {
return;
@@ -1803,7 +1858,15 @@ public class MainActivity extends AppCompatActivity {
}
}
private interface RootListItem {
interface RootListItem {
default String stableKey() {
return Integer.toHexString(System.identityHashCode(this));
}
default String contentSignature() {
return stableKey();
}
View build();
}
@@ -1816,7 +1879,7 @@ public class MainActivity extends AppCompatActivity {
}
}
private static final class RootListAdapter extends RecyclerView.Adapter<RootListViewHolder> {
static final class RootListAdapter extends RecyclerView.Adapter<RootListViewHolder> {
private List<RootListItem> items = new ArrayList<>();
@Override
@@ -1847,8 +1910,33 @@ public class MainActivity extends AppCompatActivity {
}
void submit(List<RootListItem> nextItems) {
items = new ArrayList<>(nextItems);
notifyDataSetChanged();
List<RootListItem> previousItems = items;
List<RootListItem> incomingItems = new ArrayList<>(nextItems);
DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffUtil.Callback() {
@Override
public int getOldListSize() {
return previousItems.size();
}
@Override
public int getNewListSize() {
return incomingItems.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
return previousItems.get(oldItemPosition).stableKey()
.equals(incomingItems.get(newItemPosition).stableKey());
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
return previousItems.get(oldItemPosition).contentSignature()
.equals(incomingItems.get(newItemPosition).contentSignature());
}
});
items = incomingItems;
diff.dispatchUpdatesTo(this);
}
}
}

View File

@@ -0,0 +1,87 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.view.View;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityRootListAdapterTest {
@Test
public void submitUsesItemDiffInsteadOfFullDatasetChange() {
MainActivity.RootListAdapter adapter = new MainActivity.RootListAdapter();
RecordingObserver observer = new RecordingObserver();
adapter.registerAdapterDataObserver(observer);
adapter.submit(List.of(
fakeItem("conversation:a", "same"),
fakeItem("conversation:b", "before")
));
observer.reset();
adapter.submit(List.of(
fakeItem("conversation:a", "same"),
fakeItem("conversation:b", "after")
));
assertEquals(0, observer.changedAllCount);
assertEquals(1, observer.itemRangeChangedCount);
assertEquals(1, observer.lastChangedPosition);
assertEquals(1, observer.lastChangedCount);
}
private static MainActivity.RootListItem fakeItem(String key, String signature) {
return new MainActivity.RootListItem() {
@Override
public String stableKey() {
return key;
}
@Override
public String contentSignature() {
return signature;
}
@Override
public View build() {
return new View(RuntimeEnvironment.getApplication());
}
};
}
private static final class RecordingObserver extends RecyclerView.AdapterDataObserver {
int changedAllCount;
int itemRangeChangedCount;
int lastChangedPosition = -1;
int lastChangedCount = -1;
void reset() {
changedAllCount = 0;
itemRangeChangedCount = 0;
lastChangedPosition = -1;
lastChangedCount = -1;
}
@Override
public void onChanged() {
changedAllCount += 1;
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
itemRangeChangedCount += 1;
lastChangedPosition = positionStart;
lastChangedCount = itemCount;
}
}
}