From 5c9cb6acea16b2478bcbfb81c2709af5e999656f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 01:19:09 +0800 Subject: [PATCH] refactor(ProcessPostUseCase, useCursorPagination, useDifferentialPosts): enhance post merging and loading states - Introduced new methods for merging posts and comments efficiently, improving performance during data updates. - Updated loading state management in hooks to include initial loading and loading more states for better user feedback. - Refactored HomeScreen and PostDetailScreen to utilize the new loading states and merging logic, enhancing user experience during data fetching. - Improved pagination handling in useCursorPagination and useDifferentialPosts to ensure consistent data management across components. --- src/core/usecases/ProcessPostUseCase.ts | 112 ++++++++++++- src/hooks/useCursorPagination.ts | 96 ++++++++++- src/hooks/useDifferentialPosts.ts | 6 + src/infrastructure/pagination/types.ts | 4 + .../hooks/responsive/useBreakpoint.ts | 30 +++- src/screens/home/HomeScreen.tsx | 41 +++-- src/screens/home/PostDetailScreen.tsx | 155 ++++++++++++++---- src/screens/message/ChatScreen.tsx | 60 ++++++- .../components/ChatScreen/useChatScreen.ts | 51 +++--- src/stores/messageManager.ts | 8 +- src/stores/messageManagerHooks.ts | 13 +- 11 files changed, 475 insertions(+), 101 deletions(-) diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts index c87bffa..fb709f7 100644 --- a/src/core/usecases/ProcessPostUseCase.ts +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -109,6 +109,8 @@ class ProcessPostUseCase { // 当前用户ID private currentUserId: string | null = null; + private readonly mergePerfWarnThresholdMs = 12; + // 私有构造函数 private constructor() { this.paginationManager = new PaginationStateManager(); @@ -241,6 +243,83 @@ class ProcessPostUseCase { this.notifyStateChanged(key); } + private getEntityId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; + } + + private mergePostListWithFastPath(previousInput: Post[] | null | undefined, incomingInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) { + return previous; + } + if (previous.length === 0) { + return incoming; + } + + const lastPrevId = this.getEntityId(previous[previous.length - 1]); + const firstIncomingId = this.getEntityId(incoming[0]); + if (lastPrevId && firstIncomingId && lastPrevId !== firstIncomingId) { + const prevIdSet = new Set(previous.map((item) => this.getEntityId(item)).filter(Boolean) as string[]); + if (!prevIdSet.has(firstIncomingId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = this.getEntityId(merged[i]); + if (id) { + indexById.set(id, i); + } + } + + for (const post of incoming) { + const id = this.getEntityId(post); + if (!id) { + merged.push(post); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(post); + } else { + merged[existingIndex] = post; + } + } + + return merged; + } + + private mergeRefreshWindow(previousInput: Post[] | null | undefined, latestInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) { + return previous; + } + if (previous.length === 0) { + return latest; + } + + const latestIdSet = new Set( + latest.map((item) => this.getEntityId(item)).filter(Boolean) as string[] + ); + const remaining = previous.filter((item) => { + const id = this.getEntityId(item); + return !id || !latestIdSet.has(id); + }); + return [...latest, ...remaining]; + } + /** * 更新帖子详情状态 * @param postId 帖子ID @@ -280,8 +359,17 @@ class ProcessPostUseCase { const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; // 更新状态(保存请求参数以便加载更多时使用) + const mergeStart = Date.now(); + const currentState = this.getPostsState(key); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(currentState.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length }); + } + this.updatePostsState(key, { - posts: result.posts, + posts: mergedPosts, hasMore: result.hasMore, cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor,否则设为 null 表示 page 模式 currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始 @@ -293,7 +381,7 @@ class ProcessPostUseCase { // 通知订阅者 this.notifySubscribers({ type: 'posts_loaded', - payload: { key, posts: result.posts, hasMore: result.hasMore }, + payload: { key, posts: incomingPosts, hasMore: result.hasMore }, timestamp: Date.now(), }); @@ -338,8 +426,16 @@ class ProcessPostUseCase { const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; // 更新状态 + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length }); + } + this.updatePostsState(key, { - posts: result.posts, + posts: mergedPosts, hasMore: result.hasMore, cursor: isCursorMode ? result.nextCursor : null, currentPage: isCursorMode ? undefined : 1, @@ -417,7 +513,13 @@ class ProcessPostUseCase { const result = await postRepository.getPosts(queryParams); // 合并新数据 - const newPosts = [...state.posts, ...result.posts]; + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const newPosts = this.mergePostListWithFastPath(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] loadMore merge cost', { key, costMs: mergeCost, prev: state.posts.length, incoming: incomingPosts.length, merged: newPosts.length }); + } // 判断响应是否为 cursor 模式(有 next_cursor 返回) const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; @@ -433,7 +535,7 @@ class ProcessPostUseCase { }); return { - posts: result.posts, + posts: incomingPosts, hasMore: result.hasMore, nextCursor: result.nextCursor, }; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 1fa6d3c..b48f1b6 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -11,6 +11,58 @@ import { CursorPaginationResponse } from '../types/dto'; const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; +function getItemId(item: unknown): string | null { + if (!item || typeof item !== 'object') return null; + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; +} + +function mergeByIdFastPath(previousInput: T[] | null | undefined, incomingInput: T[] | null | undefined): T[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const prevLastId = getItemId(previous[previous.length - 1]); + const incomingFirstId = getItemId(incoming[0]); + if (prevLastId && incomingFirstId && prevLastId !== incomingFirstId) { + const prevSet = new Set(previous.map((item) => getItemId(item)).filter(Boolean) as string[]); + if (!prevSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = getItemId(merged[i]); + if (id) indexById.set(id, i); + } + + for (const item of incoming) { + const id = getItemId(item); + if (!id) { + merged.push(item); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(item); + } else { + merged[existingIndex] = item; + } + } + return merged; +} + +function normalizeList(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + /** * 游标分页 Hook * @@ -34,6 +86,8 @@ export function useCursorPagination( prevCursor: null, hasMore: true, // 初始设为 true,以便首次加载可以执行 isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: true, @@ -66,7 +120,13 @@ export function useCursorPagination( isLoadingRef.current = true; cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ + ...prev, + isLoading: true, + isInitialLoading: prev.list.length === 0, + isLoadingMore: prev.list.length > 0, + error: null, + })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -84,13 +144,16 @@ export function useCursorPagination( // 标记首次加载完成 isFirstLoadRef.current = false; + const incomingList = normalizeList(response.list); setState(prev => ({ ...prev, - list: [...prev.list, ...response.list], + list: mergeByIdFastPath(prev.list, incomingList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); @@ -104,6 +167,8 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); @@ -119,7 +184,7 @@ export function useCursorPagination( cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ ...prev, isLoading: true, isLoadingMore: true, error: null })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -131,14 +196,16 @@ export function useCursorPagination( if (cancelledRef.current) return; + const incomingList = normalizeList(response.list); setState(prev => ({ ...prev, // 向前加载时,新数据放在前面 - list: [...response.list, ...prev.list], + list: mergeByIdFastPath(incomingList, prev.list), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more, isLoading: false, + isLoadingMore: false, isFirstLoad: false, })); } catch (error) { @@ -147,6 +214,7 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); } @@ -168,12 +236,15 @@ export function useCursorPagination( if (cancelledRef.current) return; + const refreshedList = normalizeList(response.list); setState({ - list: response.list, + list: mergeByIdFastPath([], refreshedList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: false, @@ -201,6 +272,8 @@ export function useCursorPagination( prevCursor: null, hasMore: true, // 重置后也设为 true,以便可以重新加载 isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isRefreshing: false, error: null, isFirstLoad: true, @@ -217,10 +290,12 @@ export function useCursorPagination( ) => { setState(prev => ({ ...prev, - list, + list: mergeByIdFastPath([], normalizeList(list)), nextCursor, prevCursor, hasMore, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); }, @@ -242,7 +317,7 @@ export function useCursorPagination( isLoadingRef.current = true; cancelledRef.current = false; - setState(prev => ({ ...prev, isLoading: true, error: null })); + setState(prev => ({ ...prev, isLoading: true, isInitialLoading: prev.list.length === 0, error: null })); try { const response: CursorPaginationResponse = await fetchFunction({ @@ -259,13 +334,16 @@ export function useCursorPagination( isFirstLoadRef.current = false; + const initialList = normalizeList(response.list); setState(prev => ({ ...prev, - list: response.list, + list: mergeByIdFastPath([], initialList), nextCursor: response.next_cursor, prevCursor: response.prev_cursor, hasMore: response.has_more && response.next_cursor !== null, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, isFirstLoad: false, })); @@ -279,6 +357,8 @@ export function useCursorPagination( setState(prev => ({ ...prev, isLoading: false, + isInitialLoading: false, + isLoadingMore: false, error: error instanceof Error ? error.message : '加载失败', })); diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts index d10150b..9a37618 100644 --- a/src/hooks/useDifferentialPosts.ts +++ b/src/hooks/useDifferentialPosts.ts @@ -70,6 +70,10 @@ export interface UseDifferentialPostsResult { posts: T[]; /** 是否正在加载 */ loading: boolean; + /** 是否首屏加载 */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; /** 是否正在刷新 */ refreshing: boolean; /** 错误信息 */ @@ -514,6 +518,8 @@ export function useDifferentialPosts( return { posts, loading, + isInitialLoading: loading && posts.length === 0 && !refreshing, + isLoadingMore: loading && posts.length > 0 && !refreshing, refreshing, error, hasMore, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index f2e7798..6e54336 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -214,6 +214,10 @@ export interface CursorPaginationState { hasMore: boolean; /** 是否正在加载 */ isLoading: boolean; + /** 是否首屏加载(空列表初始化) */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; /** 是否正在刷新 */ isRefreshing: boolean; /** 错误信息 */ diff --git a/src/presentation/hooks/responsive/useBreakpoint.ts b/src/presentation/hooks/responsive/useBreakpoint.ts index 8a2bdb9..6f35b46 100644 --- a/src/presentation/hooks/responsive/useBreakpoint.ts +++ b/src/presentation/hooks/responsive/useBreakpoint.ts @@ -3,8 +3,8 @@ * 断点检测 - 检测当前断点 */ -import { useMemo } from 'react'; -import { useWindowDimensions } from './useScreenSize'; +import { useMemo, useState, useEffect } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; import type { BreakpointKey, FineBreakpointKey } from './types'; @@ -74,6 +74,22 @@ export function isBreakpointBetween( // ==================== Hooks ==================== +function useWindowDimensionsLocal(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} + /** * 断点检测 Hook * 返回当前的基础断点 @@ -85,7 +101,7 @@ export function isBreakpointBetween( * // 'mobile' | 'tablet' | 'desktop' | 'wide' */ export function useBreakpoint(): BreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getBreakpoint(width); @@ -103,7 +119,7 @@ export function useBreakpoint(): BreakpointKey { * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' */ export function useFineBreakpoint(): FineBreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getFineBreakpoint(width); @@ -120,7 +136,7 @@ export function useFineBreakpoint(): FineBreakpointKey { * const isMediumUp = useBreakpointGTE('md'); */ export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -138,7 +154,7 @@ export function useBreakpointGTE(target: FineBreakpointKey): boolean { * const isMobileOnly = useBreakpointLT('lg'); */ export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -160,7 +176,7 @@ export function useBreakpointBetween( min: FineBreakpointKey, max: FineBreakpointKey ): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index bf672c0..bbf9134 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -119,6 +119,8 @@ export const HomeScreen: React.FC = () => { const { posts, loading: isLoading, + isInitialLoading, + isLoadingMore, refreshing: isRefreshing, hasMore, error, @@ -135,17 +137,23 @@ export const HomeScreen: React.FC = () => { // 加载更多方法 const loadMore = useCallback(async () => { - if (hasMore && !isLoading && !isLoadingMoreRef.current) { + if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) { isLoadingMoreRef.current = true; + const startedAt = Date.now(); try { await processPostUseCase.loadMorePosts(listKey); + console.log('[PostPerf] home loadMore', { + listKey, + costMs: Date.now() - startedAt, + totalItems: posts.length, + }); } catch (err) { console.error('加载更多失败:', err); } finally { isLoadingMoreRef.current = false; } } - }, [listKey, hasMore, isLoading]); + }, [posts.length, listKey, hasMore, isLoadingMore]); // 网格模式滚动处理 - 检测是否滚动到底部 const handleGridScroll = useCallback((event: any) => { @@ -366,7 +374,9 @@ export const HomeScreen: React.FC = () => { }; // 渲染帖子卡片(列表模式) - const renderPostList = ({ item }: { item: Post }) => { + const keyExtractor = useCallback((item: Post) => item.id, []); + + const renderPostList = useCallback(({ item }: { item: Post }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -394,7 +404,7 @@ export const HomeScreen: React.FC = () => { /> ); - }; + }, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { @@ -558,8 +568,8 @@ export const HomeScreen: React.FC = () => { }; // 渲染空状态 - const renderEmpty = () => { - if (isLoading) return null; + const renderEmpty = useCallback(() => { + if (isInitialLoading) return null; return ( { description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'} /> ); - }; + }, [activeIndex, isInitialLoading]); // 渲染列表内容 const renderListContent = () => { @@ -581,7 +591,7 @@ export const HomeScreen: React.FC = () => { item.id} + keyExtractor={keyExtractor} contentContainerStyle={[ styles.listContent, { @@ -602,7 +612,12 @@ export const HomeScreen: React.FC = () => { onEndReached={loadMore} onEndReachedThreshold={0.3} ListEmptyComponent={renderEmpty} - ListFooterComponent={isLoading ? : null} + ListFooterComponent={isLoadingMore ? : null} + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews /> ); }; @@ -661,7 +676,9 @@ export const HomeScreen: React.FC = () => { // 移动端:使用 GestureDetector 支持手势切换 Tab - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 @@ -672,7 +689,9 @@ export const HomeScreen: React.FC = () => { ) : ( // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击 - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 2e8a639..c0ca5f5 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -67,13 +67,15 @@ export const PostDetailScreen: React.FC = () => { const [post, setPost] = useState(null); const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [isPostInitialLoading, setIsPostInitialLoading] = useState(true); + const [isPostRefreshing, setIsPostRefreshing] = useState(false); // 使用游标分页 Hook 管理评论列表 const { list: paginatedComments, isLoading: isCommentsLoading, + isInitialLoading: isCommentsInitialLoading, + isLoadingMore: isCommentsLoadingMore, isRefreshing: isCommentsRefreshing, hasMore: hasMoreComments, loadMore: loadMoreComments, @@ -88,11 +90,71 @@ export const PostDetailScreen: React.FC = () => { }, { pageSize: 20 } ); + + const getCommentId = useCallback((comment: Comment) => String(comment.id), []); + + const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const previousSet = new Set(previous.map(getCommentId)); + const incomingFirstId = getCommentId(incoming[0]); + if (!previousSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + indexById.set(getCommentId(merged[i]), i); + } + + for (const comment of incoming) { + const id = getCommentId(comment); + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(comment); + } else { + merged[existingIndex] = comment; + } + } + return merged; + }, [getCommentId]); + + const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) return previous; + if (previous.length === 0) return latest; + + const latestSet = new Set(latest.map(getCommentId)); + const tail = previous.filter((item) => !latestSet.has(getCommentId(item))); + return [...latest, ...tail]; + }, [getCommentId]); - // 同步分页评论到本地状态 + // 切换帖子时,先清空上一帖评论,避免跨帖残留。 useEffect(() => { - setComments(paginatedComments); - }, [paginatedComments]); + setComments([]); + }, [postId]); + + // 同步分页评论到本地状态(增量合并,避免全量替换) + useEffect(() => { + setComments((prev) => { + if (isCommentsRefreshing) { + return mergeCommentsRefreshWindow(prev, paginatedComments); + } + return mergeCommentsById(prev, paginatedComments); + }); + }, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]); + + useEffect(() => { + if (commentsError) { + console.warn('[PostPerf] comments request error', { postId, error: commentsError }); + } + }, [commentsError, postId]); const [commentText, setCommentText] = useState(''); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); @@ -131,7 +193,7 @@ export const PostDetailScreen: React.FC = () => { // 加载帖子详情(可选择是否记录浏览量) const loadPostDetail = useCallback(async (recordView: boolean = false) => { if (!postId) { - setLoading(false); + setIsPostInitialLoading(false); return; } @@ -188,7 +250,7 @@ export const PostDetailScreen: React.FC = () => { } catch (error) { console.error('加载帖子详情失败:', error); } finally { - setLoading(false); + setIsPostInitialLoading(false); } }, [postId]); @@ -206,13 +268,13 @@ export const PostDetailScreen: React.FC = () => { // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { - if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) { + if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } - }, [shouldScrollToComments, loading, comments?.length]); + }, [shouldScrollToComments, isPostInitialLoading, comments?.length]); // 动态设置导航头部 useEffect(() => { @@ -280,11 +342,26 @@ export const PostDetailScreen: React.FC = () => { // 下拉刷新 - 同时刷新帖子和评论 const onRefresh = useCallback(async () => { - setRefreshing(true); + const startedAt = Date.now(); + setIsPostRefreshing(true); await loadPostDetail(); - await refreshComments(); - setRefreshing(false); - }, [loadPostDetail, refreshComments]); + console.log('[PostPerf] detail refresh', { + postId, + costMs: Date.now() - startedAt, + comments: comments.length, + }); + setIsPostRefreshing(false); + }, [comments.length, loadPostDetail, postId, refreshComments]); + + const handleLoadMoreComments = useCallback(async () => { + const startedAt = Date.now(); + await loadMoreComments(); + console.log('[PostPerf] detail loadMoreComments', { + postId, + costMs: Date.now() - startedAt, + totalItems: comments.length, + }); + }, [comments.length, loadMoreComments, postId]); const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; @@ -1259,7 +1336,9 @@ export const PostDetailScreen: React.FC = () => { }; // 渲染评论 - 带楼层号 - const renderComment = ({ item, index }: { item: Comment; index: number }) => { + const commentKeyExtractor = useCallback((item: Comment) => item.id, []); + + const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; @@ -1283,10 +1362,10 @@ export const PostDetailScreen: React.FC = () => { currentUserId={currentUser?.id} /> ); - }; + }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]); // 渲染空评论 - 现代化设计 - const renderEmptyComments = () => ( + const renderEmptyComments = useCallback(() => ( { 成为第一个评论的人吧~ - ); + ), [isDesktop]); // 渲染评论输入框 const renderCommentInput = () => ( @@ -1398,7 +1477,7 @@ export const PostDetailScreen: React.FC = () => { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} @@ -1409,23 +1488,32 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } - onEndReached={loadMoreComments} + onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews ListFooterComponent={ - isCommentsLoading ? ( + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( ) : hasMoreComments ? ( 加载更多评论 @@ -1456,7 +1544,7 @@ export const PostDetailScreen: React.FC = () => { ); - if (loading) { + if (isPostInitialLoading) { return ; } @@ -1489,7 +1577,7 @@ export const PostDetailScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListHeaderComponent={renderPostHeader} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} @@ -1542,23 +1630,32 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } - onEndReached={loadMoreComments} + onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews ListFooterComponent={ - isCommentsLoading ? ( + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( ) : hasMoreComments ? ( 加载更多评论 diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 5211e47..b6223be 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -77,6 +77,7 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); + const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); const isPreloadingRef = useRef(false); const lastScrollYRef = useRef(0); const isUserDraggingRef = useRef(false); @@ -203,8 +204,15 @@ export const ChatScreen: React.FC = () => { loadMoreHistory, handleMessageListContentSizeChange, handleReachLatestEdge, + setBrowsingHistory, } = useChatScreen(); + useEffect(() => { + if (!loadingMore && showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(false); + } + }, [loadingMore, showEdgeLoadingIndicator]); + useEffect(() => { return () => { if (preloadCooldownTimerRef.current) { @@ -231,7 +239,7 @@ export const ChatScreen: React.FC = () => { return map; }, [messages]); - const renderMessage = ({ item, index }: { item: any; index: number }) => { + const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => { // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; return ( @@ -255,7 +263,26 @@ export const ChatScreen: React.FC = () => { onReply={handleReplyMessage} /> ); - }; + }, [ + messageIndexMap, + currentUserId, + currentUser, + otherUser, + isGroupChat, + groupMembers, + otherUserLastReadSeq, + selectedMessageId, + messageMap, + handleLongPressMessage, + handleAvatarPress, + handleAvatarLongPress, + formatTime, + shouldShowTime, + handleImagePress, + handleReplyMessage, + ]); + + const keyExtractor = useCallback((item: any) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); @@ -269,6 +296,11 @@ export const ChatScreen: React.FC = () => { viewportHeight: layoutMeasurement.height, }; + // 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底 + if (contentOffset.y > 120) { + setBrowsingHistory(true); + } + // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; if ( @@ -282,13 +314,19 @@ export const ChatScreen: React.FC = () => { // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); - const isScrollingTowardHistory = contentOffset.y > lastScrollYRef.current; lastScrollYRef.current = contentOffset.y; - const PRELOAD_HISTORY_THRESHOLD = 120; + // 预加载阈值:按屏幕高度动态提前(至少 420),确保边界前开始加载 + const PRELOAD_HISTORY_THRESHOLD = Math.max(420, layoutMeasurement.height * 1.2); + // 仅在真正接近边界时才显示旋转提示 + const EDGE_LOADING_INDICATOR_THRESHOLD = 60; + const shouldShowEdgeIndicator = loadingMore && distanceToHistoryEdge <= EDGE_LOADING_INDICATOR_THRESHOLD; + if (shouldShowEdgeIndicator !== showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(shouldShowEdgeIndicator); + } + if ( distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && - isScrollingTowardHistory && hasMoreHistory && !loadingMore && !loading && @@ -304,7 +342,7 @@ export const ChatScreen: React.FC = () => { }, 350); }); } - }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge]); + }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]); const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { handleMessageListContentSizeChange(contentWidth, contentHeight); @@ -338,6 +376,7 @@ export const ChatScreen: React.FC = () => { onLayout={e => { scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; }} + onTouchEnd={handleDismiss} > {loading ? ( @@ -349,10 +388,15 @@ export const ChatScreen: React.FC = () => { inverted data={displayMessages} renderItem={renderMessage} - keyExtractor={(item: any) => String(item.id)} + keyExtractor={keyExtractor} contentContainerStyle={listContentStyle as any} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" + initialNumToRender={14} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={9} + removeClippedSubviews={Platform.OS !== 'web'} onScroll={handleMessageListScroll} onScrollBeginDrag={() => { isUserDraggingRef.current = true; @@ -368,7 +412,7 @@ export const ChatScreen: React.FC = () => { onContentSizeChange={handleContentSizeChange} /> )} - {loadingMore && ( + {showEdgeLoadingIndicator && ( { const prevMessageCountRef = useRef(0); const prevLatestSeqRef = useRef(0); const prevMarkedReadSeqRef = useRef(0); + const enterMarkedKeyRef = useRef(''); const isProgrammaticScrollRef = useRef(false); const suppressAutoFollowRef = useRef(false); const isBrowsingHistoryRef = useRef(false); @@ -277,6 +278,7 @@ export const useChatScreen = () => { prevMessageCountRef.current = 0; prevLatestSeqRef.current = 0; prevMarkedReadSeqRef.current = 0; + enterMarkedKeyRef.current = ''; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; }, [conversationId]); @@ -479,6 +481,29 @@ export const useChatScreen = () => { isBrowsingHistoryRef.current = browsing; }, []); + // 进入聊天详情后立即清未读(不依赖滚动位置) + useEffect(() => { + if (!conversationId) return; + if (!conversation) return; + + const unreadCount = Number((conversation as any).unread_count || 0); + if (unreadCount <= 0) return; + + const latestFromConversation = Number((conversation as any).last_seq || 0); + const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; + const targetSeq = Math.max(latestFromConversation, latestFromMessages); + if (targetSeq <= 0) return; + + const markKey = `${conversationId}:${targetSeq}`; + if (enterMarkedKeyRef.current === markKey) return; + enterMarkedKeyRef.current = markKey; + + prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq); + markAsRead(targetSeq).catch(error => { + console.error('[ChatScreen] 进入会话清未读失败:', error); + }); + }, [conversationId, conversation, messages, markAsRead]); + // 自动标记已读(QQ/Telegram 风格): // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 // 历史加载/浏览历史不会触发 read。 @@ -521,20 +546,10 @@ export const useChatScreen = () => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setActivePanel(prev => prev === 'mention' ? 'mention' : 'none'); - if (isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'keyboard-show'); - }, 150); - } }; const keyboardWillHide = () => { setKeyboardHeight(0); - if (isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'keyboard-hide'); - }, 100); - } }; const showSubscription = Keyboard.addListener( @@ -550,7 +565,7 @@ export const useChatScreen = () => { showSubscription.remove(); hideSubscription.remove(); }; - }, [scrollToLatest, isNearBottom]); + }, []); // 格式化时间 const formatTime = useCallback((dateString: string): string => { @@ -996,28 +1011,18 @@ export const useChatScreen = () => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'emoji' ? 'none' : 'emoji'; - if (newPanel !== 'none' && isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'open-emoji-panel'); - }, 200); - } return newPanel; }); - }, [scrollToLatest, isNearBottom]); + }, []); // 切换更多功能面板 const toggleMorePanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'more' ? 'none' : 'more'; - if (newPanel !== 'none' && isNearBottom()) { - setTimeout(() => { - scrollToLatest(false, false, 'open-more-panel'); - }, 200); - } return newPanel; }); - }, [scrollToLatest, isNearBottom]); + }, []); // 关闭面板 const closePanel = useCallback(() => { diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index e0dff90..e95c4d9 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -1557,8 +1557,8 @@ class MessageManager { payload: { conversationId, messages: mergedMessages, source: 'local_history' }, timestamp: Date.now(), }); - // 异步填充 sender 信息 - this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); + // 性能优化:历史分页仅对新批次做 sender 补全,避免每次扫描全量消息 + this.enrichMessagesWithSenderInfo(conversationId, formattedMessages); return formattedMessages; } @@ -1593,8 +1593,8 @@ class MessageManager { payload: { conversationId, messages: mergedMessages, source: 'server_history' }, timestamp: Date.now(), }); - // 异步填充 sender 信息 - this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); + // 性能优化:仅对本次服务端批次做 sender 补全 + this.enrichMessagesWithSenderInfo(conversationId, serverMessages); return serverMessages; } diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index 3962210..3f65986 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -103,6 +103,7 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasMore, setHasMore] = useState(true); + const loadMoreInFlightRef = useRef(false); useEffect(() => { if (!conversationId) { @@ -156,23 +157,23 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { }, [conversationId]); const loadMore = useCallback(async () => { - if (!conversationId || isLoading || !hasMore) return; + if (!conversationId || !hasMore || loadMoreInFlightRef.current) return; const currentMessages = messageManager.getMessages(conversationId); if (currentMessages.length === 0) return; - // 获取最早的消息seq - const minSeq = Math.min(...currentMessages.map(m => m.seq)); + // 消息数组在 MessageManager 内保持 seq 升序,首项即最早消息 + const minSeq = currentMessages[0]?.seq ?? Math.min(...currentMessages.map(m => m.seq)); - setIsLoading(true); + loadMoreInFlightRef.current = true; const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); - setIsLoading(false); + loadMoreInFlightRef.current = false; // 如果没有加载到新消息,说明没有更多了 if (loadedMessages.length === 0) { setHasMore(false); } - }, [conversationId, isLoading, hasMore]); + }, [conversationId, hasMore]); const refresh = useCallback(async () => { if (!conversationId) return;