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

@@ -9,13 +9,13 @@ import React, {
import {
View,
Text,
Image,
TouchableOpacity,
StyleSheet,
useWindowDimensions,
Animated,
PanResponder,
} from "react-native";
import { Image } from "expo-image";
import Video, { VideoRef } from "react-native-video";
import { Ionicons } from "@expo/vector-icons";
import { buildDashMpdUri } from "../utils/dash";
@@ -103,7 +103,11 @@ export const BigVideoCard = React.memo(function BigVideoCard({
const detail = await getVideoDetail(item.bvid);
cid = detail.cid ?? detail.pages?.[0]?.cid;
}
if (!cid || cancelled) return;
if (!cid) {
console.warn('BigVideoCard: no cid available for', item.bvid);
return;
}
if (cancelled) return;
const playData = await getPlayUrl(item.bvid, cid, 16);
if (cancelled) return;
if (playData.dash) {
@@ -263,7 +267,8 @@ export const BigVideoCard = React.memo(function BigVideoCard({
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={mediaDimensions}
resizeMode="cover"
contentFit="cover"
recyclingKey={item.bvid}
/>
</Animated.View>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import type { Comment } from '../services/types';
import { formatTime } from '../utils/format';
@@ -17,10 +18,10 @@ export function CommentItem({ item }: Props) {
<Text style={styles.username}>{item.member.uname}</Text>
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
<View style={styles.footer}>
<Text style={styles.time}>{formatTime(item.ctime)}</Text>
<Text style={[styles.time, { color: theme.textSub }]}>{formatTime(item.ctime)}</Text>
<View style={styles.likeRow}>
<Ionicons name="thumbs-up-outline" size={12} color="#999" />
<Text style={styles.likeCount}>{item.like > 0 ? item.like : ''}</Text>
<Ionicons name="thumbs-up-outline" size={12} color={theme.textSub} />
<Text style={[styles.likeCount, { color: theme.textSub }]}>{item.like > 0 ? item.like : ''}</Text>
</View>
</View>
</View>

View File

@@ -36,6 +36,24 @@ const FAST_DRIP_INTERVAL = 100;
const QUEUE_FAST_THRESHOLD = 50;
const SEEK_THRESHOLD = 2;
// ─── Animated.Value 对象池,减少频繁创建/GC ──────────────────────────────────
const animPool: Animated.Value[] = [];
const POOL_MAX = 64;
function acquireAnim(): Animated.Value {
const v = animPool.pop();
if (v) { v.setValue(0); return v; }
return new Animated.Value(0);
}
function releaseAnims(items: DisplayedDanmaku[]) {
for (const item of items) {
if (animPool.length < POOL_MAX) {
animPool.push(item._fadeAnim);
}
}
}
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
1: { text: "总督", color: "#E13979" },
@@ -153,7 +171,7 @@ export default function DanmakuList({
if (queueRef.current.length === 0) return;
const item = queueRef.current.shift()!;
const fadeAnim = new Animated.Value(0);
const fadeAnim = acquireAnim();
const displayed: DisplayedDanmaku = {
...item,
_key: keyCounterRef.current++,
@@ -168,9 +186,13 @@ export default function DanmakuList({
setDisplayedItems((prev) => {
const next = [...prev, displayed];
return next.length > maxItems
? next.slice(-Math.floor(maxItems / 2))
: next;
if (next.length > maxItems) {
const trimCount = next.length - Math.floor(maxItems / 2);
const trimmed = next.slice(trimCount);
releaseAnims(next.slice(0, trimCount));
return trimmed;
}
return next;
});
if (isAtBottomRef.current) {

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useDownloadStore } from '../store/downloadStore';
import { useTheme } from '../utils/theme';
const SIZE = 32; // 环外径
const RING = 3; // 环宽
@@ -13,6 +14,7 @@ interface Props {
}
export function DownloadProgressBtn({ onPress }: Props) {
const theme = useTheme();
const tasks = useDownloadStore((s) => s.tasks);
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
const hasDownloading = downloading.length > 0;
@@ -32,7 +34,7 @@ export function DownloadProgressBtn({ onPress }: Props) {
<Ionicons
name="cloud-download-outline"
size={22}
color={hasDownloading ? BLUE : '#999'}
color={hasDownloading ? BLUE : theme.iconDefault}
/>
</TouchableOpacity>
);

View File

@@ -9,6 +9,7 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useDownload } from "../hooks/useDownload";
import { useTheme } from "../utils/theme";
interface Props {
visible: boolean;
@@ -30,6 +31,7 @@ export function DownloadSheet({
qualities,
}: Props) {
const { tasks, startDownload, taskKey } = useDownload();
const theme = useTheme();
const slideAnim = useRef(new Animated.Value(300)).current;
useEffect(() => {
@@ -55,21 +57,21 @@ export function DownloadSheet({
onPress={onClose}
/>
<Animated.View
style={[styles.sheet, { transform: [{ translateY: slideAnim }] }]}
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
>
<View style={styles.header}>
<Text style={styles.headerTitle}></Text>
<Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Ionicons name="close" size={20} color="#555" />
<Ionicons name="close" size={20} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
<View style={styles.divider} />
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
{qualities.map((q) => {
const key = taskKey(bvid, q.qn);
const task = tasks[key];
return (
<View key={q.qn} style={styles.row}>
<Text style={styles.qualityLabel}>{q.desc}</Text>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
<View style={styles.right}>
{!task && (
<TouchableOpacity

View File

@@ -10,6 +10,7 @@ import {
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../utils/theme";
interface Props {
hlsUrl: string;
@@ -87,6 +88,7 @@ function NativeLivePlayer({
onQualityChange?: (qn: number) => void;
}) {
const Video = require("react-native-video").default;
const theme = useTheme();
const [showControls, setShowControls] = useState(true);
const [paused, setPaused] = useState(false);
@@ -277,12 +279,12 @@ function NativeLivePlayer({
onPress={() => setShowQualityPanel(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={styles.qualityPanel}>
<Text style={styles.qualityPanelTitle}></Text>
<View style={[styles.qualityPanel, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityPanelTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem]}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
onQualityChange?.(q.qn);
setShowQualityPanel(false);
@@ -291,6 +293,7 @@ function NativeLivePlayer({
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
currentQn === q.qn && styles.qualityItemTextActive,
]}
>

View File

@@ -16,6 +16,7 @@ import QRCode from "react-native-qrcode-svg";
import { Ionicons } from "@expo/vector-icons";
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
import { useAuthStore } from "../store/authStore";
import { useTheme } from "../utils/theme";
interface Props {
visible: boolean;
@@ -33,6 +34,7 @@ export function LoginModal({ visible, onClose }: Props) {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const login = useAuthStore((s) => s.login);
const setProfile = useAuthStore((s) => s.setProfile);
const theme = useTheme();
// sheet 滑入动画
const slideY = useRef(new Animated.Value(300)).current;
@@ -69,27 +71,36 @@ export function LoginModal({ visible, onClose }: Props) {
useEffect(() => {
if (!qrKey || status !== "waiting") return;
let cancelled = false;
pollRef.current = setInterval(async () => {
const result = await pollQRCode(qrKey);
if (result.code === 86038) {
setStatus("error");
clearInterval(pollRef.current!);
}
if (result.code === 86090) setStatus("scanned");
if (result.code === 0 && result.cookie) {
clearInterval(pollRef.current!);
try {
await login(result.cookie, "", "");
setStatus("done");
const info = await getUserInfo();
setProfile(info.face, info.uname, String(info.mid));
} catch {
if (cancelled) return;
try {
const result = await pollQRCode(qrKey);
if (cancelled) return;
if (result.code === 86038) {
setStatus("error");
clearInterval(pollRef.current!);
}
onClose();
if (result.code === 86090) setStatus("scanned");
if (result.code === 0 && result.cookie) {
clearInterval(pollRef.current!);
try {
await login(result.cookie, "", "");
if (cancelled) return;
setStatus("done");
const info = await getUserInfo();
if (!cancelled) setProfile(info.face, info.uname, String(info.mid));
} catch {
if (!cancelled) setStatus("error");
}
onClose();
}
} catch {
// Network error during poll — ignore, will retry next interval
}
}, 2000);
return () => {
cancelled = true;
if (pollRef.current) clearInterval(pollRef.current);
};
}, [qrKey, status]);
@@ -143,8 +154,8 @@ export function LoginModal({ visible, onClose }: Props) {
<Animated.View
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
>
<View style={styles.sheet}>
<Text style={styles.title}></Text>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<Text style={[styles.title, { color: theme.modalText }]}></Text>
{status === "loading" && (
<ActivityIndicator
size="large"
@@ -172,7 +183,7 @@ export function LoginModal({ visible, onClose }: Props) {
)}
</TouchableOpacity>
</View>
<Text style={styles.hint}>
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
{status === "scanned"
? "扫描成功,请在手机确认"
: "使用 B站 APP 扫一扫"}
@@ -180,7 +191,7 @@ export function LoginModal({ visible, onClose }: Props) {
</>
)}
{status === "error" && (
<Text style={styles.hint}></Text>
<Text style={[styles.hint, { color: theme.modalTextSub }]}></Text>
)}
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeTxt}></Text>

View File

@@ -29,6 +29,7 @@ import type {
import { buildDashMpdUri } from "../utils/dash";
import { getVideoShot } from "../services/bilibili";
import DanmakuOverlay from "./DanmakuOverlay";
import { useTheme } from "../utils/theme";
const BAR_H = 3;
// 进度球尺寸
@@ -100,6 +101,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
const theme = useTheme();
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const isDash = !!playData?.dash;
@@ -569,12 +571,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
style={styles.modalOverlay}
onPress={() => setShowQuality(false)}
>
<View style={styles.qualityList}>
<Text style={styles.qualityTitle}></Text>
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={styles.qualityItem}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
@@ -584,6 +586,7 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
q.qn === currentQn && styles.qualityItemActive,
]}
>

View File

@@ -2,11 +2,11 @@ import React from "react";
import {
View,
Text,
Image,
TouchableOpacity,
StyleSheet,
Dimensions,
} from "react-native";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import type { VideoItem } from "../services/types";
import { formatCount, formatDuration } from "../utils/format";
@@ -35,7 +35,9 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={[styles.thumb, { backgroundColor: theme.card }]}
resizeMode="cover"
contentFit="cover"
transition={200}
recyclingKey={item.bvid}
/>
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />