diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 44bf2a1..79b77ef 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -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 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 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 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 { + static final class RootListAdapter extends RecyclerView.Adapter { private List items = new ArrayList<>(); @Override @@ -1847,8 +1910,33 @@ public class MainActivity extends AppCompatActivity { } void submit(List nextItems) { - items = new ArrayList<>(nextItems); - notifyDataSetChanged(); + List previousItems = items; + List 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); } } } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java new file mode 100644 index 0000000..e27bd0b --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRootListAdapterTest.java @@ -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; + } + } +}