zhibo/.kiro/specs/android-hot-tab/design.md

579 lines
19 KiB
Markdown
Raw Normal View History

# Design Document
## Overview
本设计文档描述了在Android直播应用中添加"热门"Tab功能的技术实现方案。该功能将在"发现"页面的分类标签栏categoryTabs中添加一个"热门"Tab位于最左侧"推荐"Tab左边用于展示后台管理员标记为热门的作品内容。
## Architecture
### 系统架构
```
┌─────────────────────────────────────────────────────────────┐
│ MainActivity │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ topTabs: 关注 | 发现 | 附近 │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ categoryTabs: 热门 | 推荐 | 直播 | 视频 | 音乐 | 游戏│ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ RecyclerView (WorksAdapter) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 作品1 │ │ 作品2 │ │ 作品3 │ │ │
│ │ │ 🔥热门 │ │ 🔥热门 │ │ 🔥热门 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
ApiService
GET /api/works/hot?page=1&pageSize=20
后端服务器
返回 is_hot=1 的作品列表
```
### 数据流
1. **用户操作** → 点击"热门"Tab
2. **MainActivity** → 调用 `loadHotWorks(page)` 方法
3. **ApiService** → 发送 GET 请求到 `/api/works/hot`
4. **后端服务器** → 查询 `is_hot=1` 的作品,按 `hot_time` 降序排序
5. **ApiService** → 接收 `PageResponse<WorksResponse>` 数据
6. **WorksAdapter** → 渲染作品列表
7. **UI** → 显示热门作品,每个作品卡片显示🔥标识
## Components and Interfaces
### 1. MainActivity 修改
#### 新增成员变量
```java
// 热门作品相关
private final List<WorksResponse> hotWorks = new ArrayList<>(); // 热门作品列表
private int currentHotWorksPage = 1; // 当前热门作品页码
private boolean isLoadingHotWorks = false; // 是否正在加载热门作品
private boolean hasMoreHotWorks = true; // 是否还有更多热门作品
```
#### 新增方法
```java
/**
* 加载热门作品列表
* @param page 页码从1开始
*/
private void loadHotWorks(int page) {
if (isLoadingHotWorks) return;
isLoadingHotWorks = true;
// 显示加载状态
if (page == 1) {
if (hotWorks.isEmpty()) {
LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6);
}
}
ApiClient.getService(getApplicationContext())
.getHotWorks(page, 20)
.enqueue(new Callback<ApiResponse<PageResponse<WorksResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<PageResponse<WorksResponse>>> call,
Response<ApiResponse<PageResponse<WorksResponse>>> response) {
isLoadingHotWorks = false;
binding.swipeRefresh.setRefreshing(false);
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
PageResponse<WorksResponse> pageData = response.body().getData();
if (pageData != null && pageData.getList() != null) {
if (page == 1) {
hotWorks.clear();
}
hotWorks.addAll(pageData.getList());
// 检查是否还有更多数据
hasMoreHotWorks = pageData.getList().size() >= 20;
// 更新UI
updateHotWorksUI();
}
} else {
showErrorState("加载失败,请重试");
}
}
@Override
public void onFailure(Call<ApiResponse<PageResponse<WorksResponse>>> call, Throwable t) {
isLoadingHotWorks = false;
binding.swipeRefresh.setRefreshing(false);
showErrorState("网络错误,请重试");
}
});
}
/**
* 更新热门作品UI
*/
private void updateHotWorksUI() {
if (hotWorks.isEmpty()) {
showEmptyState("暂无热门作品");
} else {
hideEmptyState();
hideErrorState();
// 转换为FeedItem列表
List<FeedItem> feedItems = new ArrayList<>();
for (WorksResponse work : hotWorks) {
feedItems.add(FeedItem.fromWorks(work));
}
adapter.submitList(feedItems);
}
}
/**
* 刷新热门作品
*/
private void refreshHotWorks() {
currentHotWorksPage = 1;
hasMoreHotWorks = true;
loadHotWorks(1);
}
/**
* 加载更多热门作品
*/
private void loadMoreHotWorks() {
if (!isLoadingHotWorks && hasMoreHotWorks) {
currentHotWorksPage++;
loadHotWorks(currentHotWorksPage);
}
}
```
#### 修改 categoryTabs 初始化
`activity_main.xml` 中的 `categoryTabs` 添加"热门"Tab
```xml
<com.google.android.material.tabs.TabLayout
android:id="@+id/categoryTabs"
...>
<!-- 新增热门Tab -->
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="热门" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="推荐" />
<!-- 其他Tab保持不变 -->
...
</com.google.android.material.tabs.TabLayout>
```
#### 修改 categoryTabs 监听器
```java
binding.categoryTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
if (tab == null) return;
CharSequence title = tab.getText();
currentCategory = title != null ? title.toString() : "热门";
// 保存选中的分类
CategoryFilterManager.saveLastCategory(MainActivity.this, currentCategory);
// 根据分类加载数据
if ("热门".equals(currentCategory)) {
// 加载热门作品
refreshHotWorks();
} else {
// 应用其他分类筛选
applyCategoryFilterWithAnimation(currentCategory);
}
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {}
@Override
public void onTabReselected(TabLayout.Tab tab) {
if (tab == null) return;
CharSequence title = tab.getText();
currentCategory = title != null ? title.toString() : "热门";
if ("热门".equals(currentCategory)) {
// 刷新热门作品
refreshHotWorks();
} else {
applyCategoryFilterWithAnimation(currentCategory);
}
}
});
```
#### 修改下拉刷新逻辑
```java
binding.swipeRefresh.setOnRefreshListener(() -> {
if ("热门".equals(currentCategory)) {
refreshHotWorks();
} else {
fetchDiscoverRooms();
}
});
```
#### 修改滚动加载更多逻辑
```java
binding.roomsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
RecyclerView.LayoutManager lm = recyclerView.getLayoutManager();
if (!(lm instanceof GridLayoutManager)) return;
GridLayoutManager glm = (GridLayoutManager) lm;
int total = glm.getItemCount();
int lastVisible = glm.findLastVisibleItemPosition();
if (total <= 0) return;
if (lastVisible >= total - 4) {
if ("热门".equals(currentCategory)) {
loadMoreHotWorks();
} else {
// 其他分类的加载逻辑
}
}
}
});
```
### 2. WorksAdapter 修改
#### 添加热门标识显示
`item_works.xml` 布局中添加热门标识:
```xml
<!-- 热门标识 -->
<ImageView
android:id="@+id/hotBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:src="@drawable/ic_fire_24"
android:visibility="gone"
app:tint="#FF4500" />
```
#### 修改 ViewHolder 的 bind 方法
```java
public void bind(WorksResponse works, OnWorksClickListener listener) {
if (works == null) return;
// ... 现有代码 ...
// 显示热门标识
ImageView hotBadge = itemView.findViewById(R.id.hotBadge);
if (hotBadge != null) {
Boolean isHot = works.getIsHot();
if (isHot != null && isHot) {
hotBadge.setVisibility(View.VISIBLE);
} else {
hotBadge.setVisibility(View.GONE);
}
}
// ... 现有代码 ...
}
```
### 3. WorksResponse 数据模型
确保 `WorksResponse` 类包含 `isHot` 字段:
```java
public class WorksResponse {
private Long id;
private String title;
private String coverUrl;
private String videoUrl;
private String type;
private Integer userId;
private String userName;
private String authorName;
private Integer likeCount;
private Boolean isLiked;
private Boolean isHot; // 新增:是否热门
private String hotTime; // 新增:设置热门的时间
// Getters and Setters
public Boolean getIsHot() {
return isHot;
}
public void setIsHot(Boolean isHot) {
this.isHot = isHot;
}
public String getHotTime() {
return hotTime;
}
public void setHotTime(String hotTime) {
this.hotTime = hotTime;
}
}
```
### 4. ApiService 接口
已存在的接口:
```java
/**
* 获取热门作品列表
*/
@GET("api/works/hot")
Call<ApiResponse<PageResponse<WorksResponse>>> getHotWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
```
## Data Models
### WorksResponse
```java
public class WorksResponse {
private Long id; // 作品ID
private String title; // 作品标题
private String coverUrl; // 封面图片URL
private String videoUrl; // 视频URL
private String type; // 作品类型VIDEO/IMAGE
private Integer userId; // 用户ID
private String userName; // 用户名
private String authorName; // 作者名
private Integer likeCount; // 点赞数
private Boolean isLiked; // 当前用户是否已点赞
private Boolean isHot; // 是否热门
private String hotTime; // 设置热门的时间
}
```
### PageResponse<T>
```java
public class PageResponse<T> {
private List<T> list; // 数据列表
private Integer total; // 总数
private Integer page; // 当前页码
private Integer pageSize; // 每页大小
private Integer totalPage; // 总页数
}
```
### ApiResponse<T>
```java
public class ApiResponse<T> {
private Integer code; // 响应码200表示成功
private String message; // 响应消息
private T data; // 响应数据
public boolean isOk() {
return code != null && code == 200;
}
}
```
## Data Models
### 数据库字段(后端)
```sql
-- eb_works 表
ALTER TABLE `eb_works`
ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门1-是 0-否' AFTER `status`,
ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`,
ADD INDEX `idx_is_hot` (`is_hot`);
```
### API 请求参数
```
GET /api/works/hot?page=1&pageSize=20
```
### API 响应格式
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 1,
"title": "精彩作品",
"coverUrl": "https://example.com/cover.jpg",
"videoUrl": "https://example.com/video.mp4",
"type": "VIDEO",
"userId": 10,
"userName": "用户A",
"authorName": "作者A",
"likeCount": 100,
"isLiked": false,
"isHot": true,
"hotTime": "2026-01-08 10:00:00"
}
],
"total": 50,
"page": 1,
"pageSize": 20,
"totalPage": 3
}
}
```
## Error Handling
### 错误场景处理
1. **网络错误**
- 显示错误提示:"网络错误,请重试"
- 提供重试按钮
- 保留已加载的数据
2. **API 返回错误**
- 显示服务器返回的错误消息
- 提供重试按钮
3. **空数据**
- 显示空状态视图:"暂无热门作品"
- 提供刷新按钮
4. **加载超时**
- 设置合理的超时时间30秒
- 显示超时提示
- 提供重试选项
### 错误处理代码
```java
@Override
public void onFailure(Call<ApiResponse<PageResponse<WorksResponse>>> call, Throwable t) {
isLoadingHotWorks = false;
binding.swipeRefresh.setRefreshing(false);
String errorMsg = "网络错误";
if (t != null) {
if (t.getMessage() != null && t.getMessage().contains("Unable to resolve host")) {
errorMsg = "无法连接服务器,请检查网络";
} else if (t.getMessage() != null && t.getMessage().contains("timeout")) {
errorMsg = "连接超时,请重试";
} else if (t.getMessage() != null && t.getMessage().contains("Connection refused")) {
errorMsg = "连接被拒绝,请确保服务器已启动";
}
}
showErrorState(errorMsg);
}
```
## Testing Strategy
### Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
#### Property 1: 热门Tab仅显示作品内容
*For any* 热门Tab中显示的数据项该数据项应该是WorksResponse类型不应包含Room直播间类型的数据
**Validates: Requirements 2.3**
#### Property 2: 热门作品显示热门标识
*For any* 在热门Tab中显示的作品如果该作品的isHot字段为true则作品卡片上应该显示热门图标hotBadge可见
**Validates: Requirements 3.1**
### Unit Tests
1. **MainActivity 单元测试**
- 测试"热门"Tab在categoryTabs的索引为0最左侧**Validates: Requirements 1.1, 1.2**
- 测试categoryTabs包含6个Tab顺序为热门、推荐、直播、视频、音乐、游戏 **Validates: Requirements 1.4**
- 测试进入"发现"Tab时默认选中"热门"Tab **Validates: Requirements 1.3**
- 测试点击"热门"Tab调用loadHotWorks方法 **Validates: Requirements 2.1**
- 测试loadHotWorks方法调用ApiService.getHotWorks接口 **Validates: Requirements 2.1**
- 测试接收到数据后WorksAdapter被正确更新 **Validates: Requirements 2.2**
- 测试加载中显示骨架屏或加载动画 **Validates: Requirements 2.4**
- 测试加载失败显示错误提示 **Validates: Requirements 2.5**
- 测试空数据显示"暂无热门作品" **Validates: Requirements 2.6**
- 测试下拉刷新触发refreshHotWorks方法 **Validates: Requirements 4.1**
- 测试刷新时SwipeRefreshLayout.isRefreshing为true **Validates: Requirements 4.2**
- 测试刷新完成后isRefreshing为false且列表更新 **Validates: Requirements 4.3**
- 测试刷新失败显示错误提示且保留原数据 **Validates: Requirements 4.4**
- 测试滚动到底部触发loadMoreHotWorks方法 **Validates: Requirements 5.1**
- 测试加载更多时显示底部加载指示器 **Validates: Requirements 5.2**
- 测试hasMoreHotWorks为false时显示"没有更多内容" **Validates: Requirements 5.3**
- 测试加载更多失败显示错误提示 **Validates: Requirements 5.4**
- 测试切换Tab后hotWorks列表数据保留 **Validates: Requirements 6.1**
- 测试返回热门Tab后列表数据和滚动位置恢复 **Validates: Requirements 6.2**
- 测试停留超过5分钟后返回触发自动刷新 **Validates: Requirements 6.3**
2. **WorksAdapter 单元测试**
- 测试isHot=true时hotBadge可见 **Validates: Requirements 3.1**
- 测试isHot=false时hotBadge不可见 **Validates: Requirements 3.1**
- 测试点击作品触发onWorksClick回调 **Validates: Requirements 3.3**
- 测试点击作品启动WorkDetailActivity **Validates: Requirements 3.3**
3. **API 接口测试**
- 测试getHotWorks接口返回PageResponse<WorksResponse>
- 测试page和pageSize参数正确传递
- 测试错误响应正确处理
### Integration Tests
1. **端到端测试**
- 测试从点击"热门"Tab到显示作品列表的完整流程
- 测试下拉刷新功能
- 测试上拉加载更多功能
- 测试点击作品跳转到详情页
2. **UI 测试**
- 测试"热门"Tab在最左侧显示
- 测试热门标识🔥正确显示
- 测试加载动画正确显示
- 测试空状态和错误状态正确显示
### 测试数据准备
```sql
-- 准备测试数据:设置一些作品为热门
UPDATE eb_works SET is_hot = 1, hot_time = NOW() WHERE id IN (1, 2, 3, 4, 5);
```
### 测试用例
| 测试用例 | 输入 | 预期输出 |
|---------|------|---------|
| 加载热门作品 | page=1, pageSize=20 | 返回20条热门作品 |
| 空数据 | 数据库无热门作品 | 显示"暂无热门作品" |
| 网络错误 | 网络断开 | 显示"网络错误,请重试" |
| 下拉刷新 | 下拉列表 | 重新加载第1页数据 |
| 上拉加载更多 | 滚动到底部 | 加载下一页数据 |
| 点击作品 | 点击作品卡片 | 跳转到作品详情页 |