feat: 性能优化 + Bug修复 + 搜索增强

- expo-image 替换 RN Image(VideoCard/LiveCard/BigVideoCard/CommentItem,recyclingKey)
- DanmakuList Animated.Value 对象池,减少 GC 压力
- FlatList 性能参数:windowSize=7 / maxToRenderPerBatch=6 / removeClippedSubviews
- bilibili.ts 请求去重(getVideoDetail/getPlayUrl)
- SESSDATA 迁移至 expo-secure-store(utils/secureStorage.ts,启动自动迁移)
- 主题系统扩展:新增 sheetBg/modalBg/modalText/placeholder/iconDefault/danger 等 token
- 多组件深色模式适配:DownloadSheet/LivePlayer/NativeVideoPlayer/DownloadProgressBtn/CommentItem/LoginModal
- 搜索页增强:搜索建议 + 热搜榜(hooks/useSearch.ts + app/search.tsx)
- LoginModal 修复轮询竞态(cancelled flag + try-catch)
- 修复 downloads.tsx 冗余三元表达式
This commit is contained in:
Developer
2026-03-26 00:43:37 +08:00
parent 27587859a4
commit 463c0db058
21 changed files with 524 additions and 103 deletions

View File

@@ -202,12 +202,12 @@ function DownloadRow({
)}
<TouchableOpacity
style={styles.actionBtn}
onPress={isDownloading ? onDelete : onDelete}
onPress={onDelete}
>
<Ionicons
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
size={20}
color={isDownloading ? '#bbb' : '#bbb'}
color="#bbb"
/>
</TouchableOpacity>
</View>

View File

@@ -334,6 +334,9 @@ export default function HomeScreen() {
}
onScroll={onScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
@@ -402,6 +405,9 @@ export default function HomeScreen() {
}
onScroll={onLiveScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
</PagerView>

View File

@@ -24,7 +24,6 @@ type Tab = "intro" | "danmaku";
export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
console.log("LiveDetailScreen params:", { roomId });
const router = useRouter();
const theme = useTheme();
const id = parseInt(roomId ?? "0", 10);

View File

@@ -7,26 +7,50 @@ import {
TouchableOpacity,
FlatList,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard';
import { useSearch } from '../hooks/useSearch';
import { useSearch, SearchSort } from '../hooks/useSearch';
import { useTheme } from '../utils/theme';
import type { VideoItem } from '../services/types';
const SORT_OPTIONS: { key: SearchSort; label: string }[] = [
{ key: 'default', label: '综合排序' },
{ key: 'pubdate', label: '最新发布' },
{ key: 'view', label: '最多播放' },
];
export default function SearchScreen() {
const router = useRouter();
const { keyword, setKeyword, results, loading, hasMore, search, loadMore } = useSearch();
const {
keyword, setKeyword,
results, loading, hasMore,
search, loadMore,
sort, changeSort,
history, removeFromHistory, clearHistory,
suggestions,
hotSearches,
} = useSearch();
const theme = useTheme();
const inputRef = useRef<TextInput>(null);
const hasResults = results.length > 0;
const hasSearched = hasResults || (loading && results.length === 0);
const handleSearch = useCallback(() => {
if (keyword.trim()) {
search(keyword, true);
const handleSearch = useCallback((kw?: string) => {
const term = (kw ?? keyword).trim();
if (term) {
if (kw) setKeyword(kw);
search(kw ?? keyword, true);
}
}, [keyword, search]);
}, [keyword, search, setKeyword]);
const handleSuggestionPress = useCallback((value: string) => {
setKeyword(value);
search(value, true);
}, [search, setKeyword]);
const renderItem = useCallback(
({ item, index }: { item: VideoItem; index: number }) => {
@@ -61,16 +85,37 @@ export default function SearchScreen() {
[],
);
// Show pre-search panel (history + hot searches + suggestions)
const showPreSearch = !hasSearched && !loading;
const showSuggestions = suggestions.length > 0 && keyword.trim().length > 0 && !hasResults;
const ListHeaderComponent = useCallback(() => {
if (!hasResults) return null;
return (
<View style={[styles.sortBar, { backgroundColor: theme.card }]}>
{SORT_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.key}
style={[styles.sortBtn, sort === opt.key && styles.sortBtnActive]}
onPress={() => changeSort(opt.key)}
activeOpacity={0.85}
>
<Text style={[styles.sortBtnText, sort === opt.key && styles.sortBtnTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}, [hasResults, sort, changeSort, theme.card]);
const ListEmptyComponent = () => {
if (loading) return null;
if (!keyword.trim()) return null;
return (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}>
{results.length === 0 && keyword.trim()
? '没有找到相关视频'
: '输入关键词搜索'}
</Text>
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
);
};
@@ -90,7 +135,7 @@ export default function SearchScreen() {
placeholderTextColor="#999"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={handleSearch}
onSubmitEditing={() => handleSearch()}
returnKeyType="search"
autoFocus
autoCapitalize="none"
@@ -102,29 +147,109 @@ export default function SearchScreen() {
</TouchableOpacity>
)}
</View>
<TouchableOpacity style={styles.searchBtn} onPress={handleSearch}>
<TouchableOpacity style={styles.searchBtn} onPress={() => handleSearch()} activeOpacity={0.85}>
<Text style={styles.searchBtnText}></Text>
</TouchableOpacity>
</View>
{/* Results */}
<FlatList
data={results}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={<ListEmptyComponent />}
ListFooterComponent={
loading && results.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
{/* Suggestions dropdown */}
{showSuggestions && (
<View style={[styles.suggestPanel, { backgroundColor: theme.card }]}>
{suggestions.map((s, i) => (
<TouchableOpacity
key={`${s.value}-${i}`}
style={[styles.suggestItem, { borderBottomColor: theme.border }]}
onPress={() => handleSuggestionPress(s.value)}
activeOpacity={0.85}
>
<Ionicons name="search-outline" size={14} color="#bbb" style={styles.suggestIcon} />
<Text style={[styles.suggestText, { color: theme.text }]} numberOfLines={1}>{s.value}</Text>
</TouchableOpacity>
))}
</View>
)}
{/* Pre-search: history + hot searches */}
{showPreSearch && !showSuggestions ? (
<ScrollView style={styles.preSearch} keyboardShouldPersistTaps="handled">
{/* Search history */}
{history.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<TouchableOpacity onPress={clearHistory} activeOpacity={0.85}>
<Ionicons name="trash-outline" size={16} color={theme.textSub} />
</TouchableOpacity>
</View>
<View style={styles.tagWrap}>
{history.map(h => (
<TouchableOpacity
key={h}
style={[styles.tag, { backgroundColor: theme.inputBg }]}
onPress={() => handleSearch(h)}
onLongPress={() => removeFromHistory(h)}
activeOpacity={0.85}
>
<Text style={[styles.tagText, { color: theme.text }]} numberOfLines={1}>{h}</Text>
</TouchableOpacity>
))}
</View>
</View>
) : null
}
keyboardShouldPersistTaps="handled"
/>
)}
{/* Hot searches */}
{hotSearches.length > 0 && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
{hotSearches.map((item, idx) => (
<TouchableOpacity
key={item.keyword}
style={[styles.hotItem, { borderBottomColor: theme.border }]}
onPress={() => handleSearch(item.keyword)}
activeOpacity={0.85}
>
<Text style={[
styles.hotIndex,
idx < 3 && styles.hotIndexTop,
]}>
{idx + 1}
</Text>
<Text style={[styles.hotText, { color: theme.text }]} numberOfLines={1}>
{item.show_name}
</Text>
</TouchableOpacity>
))}
</View>
)}
{history.length === 0 && hotSearches.length === 0 && (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
)}
</ScrollView>
) : (
/* Results list */
<FlatList
data={results}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={<ListHeaderComponent />}
ListEmptyComponent={<ListEmptyComponent />}
ListFooterComponent={
loading && results.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
</View>
) : null
}
keyboardShouldPersistTaps="handled"
/>
)}
</SafeAreaView>
);
}
@@ -163,7 +288,88 @@ const styles = StyleSheet.create({
paddingVertical: 6,
},
searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
listContent: { paddingTop: 6, paddingBottom: 20 },
// Sort bar
sortBar: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 12,
},
sortBtn: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 14,
},
sortBtnActive: {
backgroundColor: '#00AEEC',
},
sortBtnText: {
fontSize: 12,
color: '#999',
},
sortBtnTextActive: {
color: '#fff',
fontWeight: '600',
},
// Suggestions
suggestPanel: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
suggestItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
suggestIcon: { marginRight: 8 },
suggestText: { fontSize: 14, flex: 1 },
// Pre-search
preSearch: { flex: 1, paddingHorizontal: 16, paddingTop: 12 },
section: { marginBottom: 20 },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
sectionTitle: { fontSize: 15, fontWeight: '600', color: '#212121', marginBottom: 2 },
tagWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#f0f0f0',
maxWidth: '45%',
},
tagText: { fontSize: 13, color: '#212121' },
// Hot search list
hotItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
hotIndex: {
width: 22,
fontSize: 14,
fontWeight: '600',
color: '#999',
textAlign: 'center',
marginRight: 10,
},
hotIndexTop: { color: '#00AEEC' },
hotText: { fontSize: 14, flex: 1 },
// Results
listContent: { paddingTop: 0, paddingBottom: 20 },
row: {
flexDirection: 'row',
paddingHorizontal: 1,

View File

@@ -75,7 +75,7 @@ export default function VideoDetailScreen() {
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus);
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
}, [video?.cid]);
useEffect(() => {
@@ -197,7 +197,11 @@ export default function VideoDetailScreen() {
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
<View style={styles.upRow}>
<TouchableOpacity
style={styles.upRow}
activeOpacity={0.85}
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
@@ -213,10 +217,10 @@ export default function VideoDetailScreen() {
</Text>
)}
</View>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
<View style={styles.followBtn}>
<Text style={styles.followTxt}></Text>
</View>
</TouchableOpacity>
<View
style={[
styles.titleSection,
@@ -661,6 +665,7 @@ const styles = StyleSheet.create({
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,