perf: move root surfaces to recycler-backed pages

This commit is contained in:
kris
2026-04-04 01:31:02 +08:00
parent 35bcf92d72
commit bf4b27b062
6 changed files with 280 additions and 53 deletions

View File

@@ -12,6 +12,7 @@ import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
@@ -20,6 +21,7 @@ import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.viewpager2.widget.ViewPager2;
@@ -65,6 +67,7 @@ public class MainActivity extends AppCompatActivity {
private Button tabMe;
private ViewPager2 rootPager;
private SwipeRefreshLayout screenRefresh;
private RecyclerView screenList;
private ScrollView screenScroll;
private LinearLayout screenContent;
@@ -461,7 +464,7 @@ public class MainActivity extends AppCompatActivity {
switch (activeTab) {
case "devices":
updateHeader("设备", "这里管理已接入设备与账号状态。");
updateHeader("设备", "");
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false));
renderDevicesRoot();
break;
@@ -533,17 +536,18 @@ public class MainActivity extends AppCompatActivity {
}
private void renderConversationsRoot() {
if (screenContent == null) {
if (screenList == null) {
return;
}
screenContent.removeAllViews();
screenContent.addView(buildConversationSearchInput());
List<RootListItem> items = new ArrayList<>();
items.add(() -> buildConversationSearchInput());
if (conversationSelectionMode) {
appendConversationSelectionControls();
appendConversationSelectionControls(items);
}
JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery);
if (filteredConversations == null || filteredConversations.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
items.add(() -> BossUi.buildEmptyCard(this, "当前没有会话数据。"));
showListPage(items);
return;
}
@@ -560,7 +564,7 @@ public class MainActivity extends AppCompatActivity {
}
if (pinnedItems.length() > 0) {
screenContent.addView(BossUi.buildConversationSectionHeader(
items.add(() -> BossUi.buildConversationSectionHeader(
this,
"置顶会话",
pinnedConversationsCollapsed ? "展开" : "收起",
@@ -570,13 +574,14 @@ public class MainActivity extends AppCompatActivity {
}
));
if (!pinnedConversationsCollapsed) {
appendConversationRows(pinnedItems);
appendConversationRows(items, pinnedItems);
}
}
appendConversationRows(regularItems);
appendConversationRows(items, regularItems);
showListPage(items);
}
private void appendConversationRows(JSONArray items) {
private void appendConversationRows(List<RootListItem> rootItems, JSONArray items) {
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.optJSONObject(i);
if (item == null) continue;
@@ -584,7 +589,7 @@ public class MainActivity extends AppCompatActivity {
String conversationType = item.optString("conversationType", "");
String folderKey = item.optString("folderKey", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
screenContent.addView(BossUi.buildConversationRow(
rootItems.add(() -> BossUi.buildConversationRow(
this,
row,
conversationSelectionMode,
@@ -620,8 +625,8 @@ public class MainActivity extends AppCompatActivity {
}
}
private void appendConversationSelectionControls() {
screenContent.addView(buildSelectionSummaryView());
private void appendConversationSelectionControls(List<RootListItem> items) {
items.add(() -> buildSelectionSummaryView());
Button cancelButton = BossUi.buildMiniActionButton(this, "取消", false);
cancelButton.setOnClickListener(v -> exitConversationSelectionMode());
@@ -630,7 +635,7 @@ public class MainActivity extends AppCompatActivity {
createButton.setEnabled(selectedConversationProjectIds.size() >= 2);
createButton.setOnClickListener(v -> createStandaloneGroupChatFromSelection());
screenContent.addView(BossUi.buildInlineActionRow(this, cancelButton, createButton));
items.add(() -> BossUi.buildInlineActionRow(this, cancelButton, createButton));
}
private LinearLayout buildSelectionSummaryView() {
@@ -826,12 +831,13 @@ public class MainActivity extends AppCompatActivity {
}
private void renderDevicesRoot() {
if (screenContent == null) {
if (screenList == null) {
return;
}
screenContent.removeAllViews();
List<RootListItem> items = new ArrayList<>();
if (devicesData == null || devicesData.length() == 0) {
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
items.add(() -> BossUi.buildEmptyCard(this, "当前没有接入设备。"));
showListPage(items);
return;
}
@@ -840,7 +846,7 @@ public class MainActivity extends AppCompatActivity {
if (item == null) continue;
String deviceId = item.optString("id", "");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
screenContent.addView(BossUi.buildDeviceCard(
items.add(() -> BossUi.buildDeviceCard(
this,
row,
v -> {
@@ -853,12 +859,14 @@ public class MainActivity extends AppCompatActivity {
null
));
}
showListPage(items);
}
private void renderMeRoot() {
if (screenContent == null) {
return;
}
showScrollPage();
screenContent.removeAllViews();
String displayName = sessionData == null
? apiClient.getDisplayName()
@@ -1117,6 +1125,7 @@ public class MainActivity extends AppCompatActivity {
private void syncActivePageViews(String tab) {
if (rootPagerAdapter == null) {
screenRefresh = null;
screenList = null;
screenScroll = null;
screenContent = null;
return;
@@ -1124,12 +1133,14 @@ public class MainActivity extends AppCompatActivity {
RootPageViewHolder holder = rootPagerAdapter.findHolder(tab);
if (holder == null) {
screenRefresh = null;
screenList = null;
screenScroll = null;
screenContent = null;
rootPager.post(() -> {
RootPageViewHolder pendingHolder = rootPagerAdapter.findHolder(activeTab);
if (pendingHolder != null) {
screenRefresh = pendingHolder.refresh;
screenList = pendingHolder.list;
screenScroll = pendingHolder.scroll;
screenContent = pendingHolder.content;
renderCurrentTab();
@@ -1139,10 +1150,43 @@ public class MainActivity extends AppCompatActivity {
return;
}
screenRefresh = holder.refresh;
screenList = holder.list;
screenScroll = holder.scroll;
screenContent = holder.content;
}
private void showListPage(List<RootListItem> items) {
if (screenList == null || screenScroll == null) {
return;
}
screenList.setVisibility(View.VISIBLE);
screenScroll.setVisibility(View.GONE);
RootListAdapter adapter = adapterForCurrentTab();
if (adapter != null) {
adapter.submit(items);
}
}
private void showScrollPage() {
if (screenList == null || screenScroll == null) {
return;
}
RootListAdapter adapter = adapterForCurrentTab();
if (adapter != null) {
adapter.submit(new ArrayList<>());
}
screenList.setVisibility(View.GONE);
screenScroll.setVisibility(View.VISIBLE);
}
private @Nullable RootListAdapter adapterForCurrentTab() {
if (rootPagerAdapter == null) {
return null;
}
RootPageViewHolder holder = rootPagerAdapter.findHolder(activeTab);
return holder == null ? null : holder.listAdapter;
}
private static int indexForTab(String tab) {
switch (tab) {
case "devices":
@@ -1224,8 +1268,10 @@ public class MainActivity extends AppCompatActivity {
private static final class RootPageViewHolder extends RecyclerView.ViewHolder {
final String tab;
final SwipeRefreshLayout refresh;
final RecyclerView list;
final ScrollView scroll;
final LinearLayout content;
final RootListAdapter listAdapter;
static RootPageViewHolder create(LayoutInflater inflater, ViewGroup parent, String tab) {
View view = inflater.inflate(R.layout.view_root_tab_page, parent, false);
@@ -1236,8 +1282,61 @@ public class MainActivity extends AppCompatActivity {
super(itemView);
this.tab = tab;
this.refresh = itemView.findViewById(R.id.root_page_refresh);
this.list = itemView.findViewById(R.id.root_page_list);
this.scroll = itemView.findViewById(R.id.root_page_scroll);
this.content = itemView.findViewById(R.id.root_page_content);
this.list.setLayoutManager(new LinearLayoutManager(itemView.getContext()));
this.listAdapter = new RootListAdapter();
this.list.setAdapter(listAdapter);
}
}
private interface RootListItem {
View build();
}
private static final class RootListViewHolder extends RecyclerView.ViewHolder {
final FrameLayout container;
RootListViewHolder(FrameLayout container) {
super(container);
this.container = container;
}
}
private static final class RootListAdapter extends RecyclerView.Adapter<RootListViewHolder> {
private List<RootListItem> items = new ArrayList<>();
@Override
public int getItemCount() {
return items.size();
}
@Override
public RootListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
FrameLayout container = new FrameLayout(parent.getContext());
RecyclerView.LayoutParams params = new RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
);
container.setLayoutParams(params);
return new RootListViewHolder(container);
}
@Override
public void onBindViewHolder(RootListViewHolder holder, int position) {
holder.container.removeAllViews();
View child = items.get(position).build();
ViewParent parent = child.getParent();
if (parent instanceof ViewGroup) {
((ViewGroup) parent).removeView(child);
}
holder.container.addView(child);
}
void submit(List<RootListItem> nextItems) {
items = new ArrayList<>(nextItems);
notifyDataSetChanged();
}
}
}

View File

@@ -4,21 +4,37 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/root_page_scroll"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/root_page_content"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/root_page_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:clipToPadding="false"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
android:paddingBottom="88dp"
android:scrollbars="vertical" />
<ScrollView
android:id="@+id/root_page_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:id="@+id/root_page_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -3,8 +3,11 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.FrameLayout;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -58,9 +61,19 @@ public class MainActivityConversationSearchTest {
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot");
Shadows.shadowOf(activity.getMainLooper()).idle();
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(content.getChildAt(0) instanceof EditText);
EditText input = (EditText) content.getChildAt(0);
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View firstItem = buildRecyclerItem(list, 0);
assertTrue(firstItem instanceof EditText);
EditText input = (EditText) firstItem;
assertEquals("搜索项目或线程", String.valueOf(input.getHint()));
}
private static View buildRecyclerItem(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType);
adapter.bindViewHolder(holder, position);
FrameLayout container = (FrameLayout) holder.itemView;
return container.getChildAt(0);
}
}

View File

@@ -5,9 +5,12 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
@@ -31,21 +34,19 @@ public class MainActivityConversationSelectionTest {
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode"));
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "发起群聊"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
assertTrue(recyclerContainsText(list, "发起群聊"));
assertTrue(recyclerContainsText(list, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-1"));
content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "已选 1 个线程"));
assertTrue(viewTreeContainsText(content, "至少选择 2 个线程"));
assertTrue(recyclerContainsText(list, "已选 1 个线程"));
assertTrue(recyclerContainsText(list, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2"));
content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "已选 2 个线程"));
assertFalse(viewTreeContainsText(content, "至少选择 2 个线程"));
assertTrue(recyclerContainsText(list, "已选 2 个线程"));
assertFalse(recyclerContainsText(list, "至少选择 2 个线程"));
}
@Test
@@ -56,11 +57,30 @@ public class MainActivityConversationSelectionTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
View row = content.getChildAt(3);
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 3);
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType);
adapter.bindViewHolder(holder, position);
FrameLayout container = (FrameLayout) holder.itemView;
return container.getChildAt(0);
}
private static boolean recyclerContainsText(RecyclerView recyclerView, String expectedText) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
for (int index = 0; index < adapter.getItemCount(); index += 1) {
if (viewTreeContainsText(getRecyclerChild(recyclerView, index), expectedText)) {
return true;
}
}
return false;
}
private static JSONArray buildConversations() throws Exception {
return new JSONArray()
.put(new JSONObject()

View File

@@ -0,0 +1,57 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.view.View.MeasureSpec;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityDevicesRootTest {
@Test
public void devicesTab_hidesLegacySubtitleAndUsesRecyclerList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "devicesData", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("status", "online")
.put("platform", "macOS")
.put("account", "17600003315")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
TextView subtitle = activity.findViewById(R.id.top_subtitle);
assertEquals(View.GONE, subtitle.getVisibility());
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
layoutRecyclerView(list);
assertTrue(list.getVisibility() == View.VISIBLE);
assertTrue(list.getAdapter().getItemCount() > 0);
}
private static void layoutRecyclerView(RecyclerView recyclerView) {
int widthSpec = MeasureSpec.makeMeasureSpec(1080, MeasureSpec.EXACTLY);
int heightSpec = MeasureSpec.makeMeasureSpec(2400, MeasureSpec.EXACTLY);
recyclerView.measure(widthSpec, heightSpec);
recyclerView.layout(0, 0, 1080, 2400);
}
}

View File

@@ -4,9 +4,12 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
@@ -45,18 +48,37 @@ public class MainActivityPinnedConversationsTest {
ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot");
Shadows.shadowOf(activity.getMainLooper()).idle();
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertTrue(viewTreeContainsText(content, "置顶会话"));
assertTrue(viewTreeContainsText(content, "收起"));
assertTrue(viewTreeContainsText(content, "主 Agent"));
assertTrue(viewTreeContainsText(content, "Boss 移动控制台"));
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
assertTrue(recyclerContainsText(list, "置顶会话"));
assertTrue(recyclerContainsText(list, "收起"));
assertTrue(recyclerContainsText(list, "主 Agent"));
assertTrue(recyclerContainsText(list, "Boss 移动控制台"));
View pinnedHeader = content.getChildAt(1);
View pinnedHeader = getRecyclerChild(list, 1);
pinnedHeader.performClick();
assertEquals(true, ReflectionHelpers.getField(activity, "pinnedConversationsCollapsed"));
assertTrue(viewTreeContainsText(content, "展开"));
assertTrue("收起后普通会话仍应保留", viewTreeContainsText(content, "Boss 移动控制台"));
assertTrue(recyclerContainsText(list, "展开"));
assertTrue("收起后普通会话仍应保留", recyclerContainsText(list, "Boss 移动控制台"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType);
adapter.bindViewHolder(holder, position);
FrameLayout container = (FrameLayout) holder.itemView;
return container.getChildAt(0);
}
private static boolean recyclerContainsText(RecyclerView recyclerView, String expectedText) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
for (int index = 0; index < adapter.getItemCount(); index += 1) {
if (viewTreeContainsText(getRecyclerChild(recyclerView, index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsText(View root, String expectedText) {