perf: diff root list updates by stable item keys
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user