You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Expo自定义底部TabBar响应卡顿,如何进行优化?

Expo自定义底部TabBar动画延迟问题排查与优化

我跟着YouTube教程实现了一款Expo自定义底部TabBar,但点击标签后,动画启动前有大约1秒的延迟。我猜测可能是路径插值(interpolate path)没优化,但作为Expo和动画开发新手,没法确定问题根源。附上TabBar样式图和完整代码,求帮忙排查卡顿原因并给出优化方案:

TabBar样式图

CustomBottomTab组件代码

import React, { FC, useEffect } from "react";
import { StyleSheet, View } from "react-native";
import Svg, { Path } from "react-native-svg";
import Animated, {
  runOnJS,
  useAnimatedProps,
  useSharedValue,
  withTiming,
  Easing,
} from "react-native-reanimated";
import { interpolatePath } from "react-native-redash";
import { useRouter, usePathname } from "expo-router";

import { SCREEN_WIDTH } from "@/src/constants/screen";
import usePath from "@/src/hooks/usePath";
import { getPathXCenter } from "@/src/utils/path";
import TabItem from "./tabItem";
import AnimatedCircle from "./animatedCircle";
import HeadphoneIcon from "@/src/components/icons/headphoneIcon";
import HomeIcon from "@/src/components/icons/homeIcon";
import HistoryIcon from "@/src/components/icons/historyIcon";
import ProfileIcon from "@/src/components/icons/profileIcon";
import SettingsIcon from "../icons/settingsIcon";

const AnimatedPath = Animated.createAnimatedComponent(Path);

const TABS = [
  { name: "home", keyword: "home", label: "Home", icon: HomeIcon },
  { name: "playlistsList", keyword: "playlist", label: "Playlist", icon: HeadphoneIcon },
  { name: "history", keyword: "history", label: "History", icon: HistoryIcon },
  { name: "profile", keyword: "profile", label: "Profile", icon: ProfileIcon },
  { name: "settings", keyword: "settings", label: "Settings", icon: SettingsIcon },
];

interface CustomBottomTabProps {
  state: {
    index: number;
    routes: Array<{ name: string }>;
  };
  navigation: {
    navigate: (name: string) => void;
  };
}

export const CustomBottomTab: FC<CustomBottomTabProps> = ({ state, navigation }) => {
  const { containerPath, curvedPaths, tHeight } = usePath();
  const circleXCoordinate = useSharedValue(0);
  const progress = useSharedValue(1);
  const router = useRouter();
  const pathname = usePathname();
  const currentIndex = TABS.findIndex(tab => {
    if (tab.keyword === 'home' && pathname === '/') {
      return true;
    }
    return pathname.includes(tab.keyword);
  });
  const handleMoveCircle = (currentPath: string) => {
    circleXCoordinate.value = getPathXCenter(currentPath);
  };

  const selectIcon = (routeName: string, isActive: boolean) => {
    const color = isActive ? "#000000" : "#BABABA";
    const IconComponent = TABS.find(tab => tab.name === routeName)?.icon;
    return IconComponent ? <IconComponent color={color} /> : null;
  };

  useEffect(() => {
    if (currentIndex >= 0) {
      progress.value = withTiming(currentIndex + 1, {
        duration: 500,
        easing: Easing.bezier(0.25, 0.1, 0.25, 1),
      });
    }
  }, [pathname]);

  const animatedProps = useAnimatedProps(() => {
    console.log('progress.value', progress.value);
    const currentPath = interpolatePath(
      progress.value,
      Array.from({ length: curvedPaths.length }, (_, index) => index + 1),
      curvedPaths,
    );
    runOnJS(handleMoveCircle)(currentPath);
    return {
      d: `${containerPath} ${currentPath}`,
    };
  });

  const handleTabPress = (index: number, path: string) => {
    console.log('press', index);
   if (path === "home") {
    router.push(`/(auth)/(tabs)/`);
   } else {
    router.push(`/(auth)/(tabs)/${path}`);
   }
    progress.value = withTiming(index, {
      duration: 500,
      easing: Easing.bezier(0.25, 0.1, 0.25, 1),
    });
  };
  return (
    <View style={styles.tabBarContainer}>
      <Svg width={SCREEN_WIDTH} height={tHeight} style={styles.shadowMd}>
        <AnimatedPath fill={"white"} animatedProps={animatedProps} />
      </Svg>
      <AnimatedCircle circleX={circleXCoordinate} />
      <View
        style={[
          styles.tabItemsContainer,
          {
            height: tHeight,
          },
        ]}
      >
        {TABS.map((tab, index) => (
          <TabItem
            key={index.toString()}
            label={tab.label}
            icon={selectIcon(tab.name, currentIndex === index)}
            activeIndex={currentIndex + 1}
            index={index}
            onTabPress={() => handleTabPress(index + 1, tab.name)}
          />
        ))}
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  tabBarContainer: {
    position: "absolute",
    bottom: 0,
    zIndex: 2,
  },
  tabItemsContainer: {
    position: "absolute",
    flexDirection: "row",
    width: "100%",
  },
  shadowMd: {
    elevation: 3,
    shadowColor: "#000",
    shadowOpacity: 0.2,
    shadowRadius: 3,
    shadowOffset: { width: 0, height: 3 },
  }
});

usePath Hook代码

import { useMemo } from "react";
import { curveBasis, line } from "d3-shape";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SCREEN_WIDTH } from "@/src/constants/screen";
import { parse } from "react-native-redash";

type GenerateTabShapePath = (
  position: number,
  adjustedHeight: number,
) => string;

const NUM_TABS = 5;
const SCALE = 0.6;
const TAB_BAR_HEIGHT = 50;

const generateTabShapePath: GenerateTabShapePath = (
  position,
  adjustedHeight,
) => {
  const adjustedWidth = SCREEN_WIDTH / NUM_TABS;
  const tabX = adjustedWidth * position;

  const lineGenerator = line().curve(curveBasis);
  const tab = lineGenerator([
    [tabX - 100 * SCALE, 0],
    [tabX - (65 + 35) * SCALE, 0],
    [tabX - (50 - 10) * SCALE, -4 * SCALE],
    [tabX - (50 - 10) * SCALE, (adjustedHeight - 14) * SCALE],
    [tabX + (50 - 15) * SCALE, (adjustedHeight - 14) * SCALE],
    [tabX + (50 - 10) * SCALE, -4 * SCALE],
    [tabX + (65 + 35) * SCALE, 0],
    [tabX + 100 * SCALE, 0],
  ]);

  return `${tab}`;
};

const usePath = () => {
  const insets = useSafeAreaInsets();
  const tHeight = TAB_BAR_HEIGHT + insets.bottom;
  const adjustedHeight = tHeight - insets.bottom;

  const containerPath = useMemo(() => {
    return `M0,0L${SCREEN_WIDTH},0L${SCREEN_WIDTH},0L${SCREEN_WIDTH},${tHeight}L0,${tHeight}L0,0`;
  }, [tHeight]);

  const curvedPaths = useMemo(() => {
    return Array.from({ length: NUM_TABS }, (_, index) => {
      const tabShapePath = generateTabShapePath(index + 0.5, adjustedHeight);
      return parse(`${tabShapePath}`);
    });
  }, [adjustedHeight]);

  return { containerPath, curvedPaths, tHeight };
};

export default usePath;

卡顿原因排查

  1. 路由跳转阻塞动画启动:handleTabPress中先执行router.push再触发动画,页面跳转的JS线程开销会延迟动画启动。
  2. 跨线程调用频繁:useAnimatedProps中每次都通过runOnJS调用handleMoveCircle,频繁在UI/JS线程间切换,增加性能损耗。
  3. 重复计算与查找:selectIcon每次渲染都遍历TABS数组查找图标,currentIndex计算未做缓存,导致不必要的重渲染。
  4. 日志占用资源:animatedProps中的console.log会占用JS线程资源,拖慢动画响应。

优化方案

1. 调整动画与路由顺序,优先启动动画

const handleTabPress = (index: number, path: string) => {
  // 先触发动画,再执行路由跳转
  progress.value = withTiming(index, {
    duration: 500,
    easing: Easing.bezier(0.25, 0.1, 0.25, 1),
  });
  const targetPath = path === "home" ? `/(auth)/(tabs)/` : `/(auth)/(tabs)/${path}`;
  router.push(targetPath);
};

2. 移除跨线程调用,预计算圆心坐标

// 预计算每个tab的圆心X坐标
const tabCenterXs = useMemo(() => {
  return curvedPaths.map(path => getPathXCenter(path));
}, [curvedPaths]);

// 预定义插值输入范围
const inputRange = useMemo(() => {
  return Array.from({ length: curvedPaths.length }, (_, index) => index + 1);
}, [curvedPaths.length]);

const animatedProps = useAnimatedProps(() => {
  const currentPath = interpolatePath(
    progress.value,
    inputRange,
    curvedPaths,
  );
  // 直接在UI线程插值获取圆心坐标,无需跨线程调用
  circleXCoordinate.value = interpolate(
    progress.value,
    inputRange,
    tabCenterXs
  );
  return {
    d: `${containerPath} ${currentPath}`,
  };
});

3. 优化图标查找逻辑,避免重复遍历

// 预创建图标映射对象
const tabIconMap = useMemo(() => {
  return TABS.reduce((map, tab) => {
    map[tab.name] = tab.icon;
    return map;
  }, {} as Record<string, React.ElementType>);
}, []);

// 用useCallback缓存函数,避免重渲染
const selectIcon = useCallback((routeName: string, isActive: boolean) => {
  const color = isActive ? "#000000" : "#BABABA";
  const IconComponent = tabIconMap[routeName];
  return IconComponent ? <IconComponent color={color} /> : null;
}, [tabIconMap]);

4. 优化currentIndex计算与缓存

const currentIndex = useMemo(() => {
  if (pathname === '/') return TABS.findIndex(tab => tab.keyword === 'home');
  return TABS.findIndex(tab => pathname.includes(tab.keyword));
}, [pathname]);

5. 移除不必要的日志

删除animatedProps中的console.log('progress.value', progress.value),减少JS线程负担。

内容的提问来源于stack exchange,提问作者Neolex

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 00:04:52