第二章:从文件系统到用户界面 - Expo Router 在医疗应用中的深度实践

139 阅读8分钟

在上一章中,我们介绍了整体架构设计。本章将深入探讨 Expo Router 在医疗应用中的路由架构设计,包括基于文件系统的路由管理、跨页面状态同步、权限控制等核心实践。

2.1 基于文件系统的路由设计

2.1.1 Expo Router 核心概念

Expo Router 采用了"文件系统即路由"的设计理念,这种设计让路由结构变得直观且易于维护。在我们的医疗应用中,路由结构直接反映了业务模块的层次关系。

// 项目路由结构
app/
├── (tabs)/                    # Tab 导航容器
│   ├── index.tsx             # 首页 - 工作台
│   └── profile.tsx           # 个人中心
├── patient/                  # 患者管理模块
│   ├── index.tsx            # 患者搜索
│   ├── create.tsx           # 新建患者
│   ├── detail/[id].tsx      # 患者详情(动态路由)
│   ├── edit/[id].tsx        # 编辑患者
│   └── follow-up-records/   # 回访记录子模块
├── appointment-module/       # 预约模块
│   ├── add-appointment/     # 新增预约
│   └── edit-appointment/    # 编辑预约
└── reservation/             # 预约管理

2.1.2 动态路由的实现机制

在医疗应用中,患者详情、编辑页面等都需要动态参数。Expo Router 通过 [id].tsx 的命名约定实现动态路由:

// app/patient/detail/[id].tsx
import { useLocalSearchParams, router } from 'expo-router';

export default function PatientDetailScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  // 使用患者ID获取详情数据
  const { data: patientDetail, isLoading } = usePatientDetailQuery(Number(id));

  const handleEdit = () => {
    // 导航到编辑页面,传递相同的ID参数
    router.push({
      pathname: '/patient/edit/[id]',
      params: { id }
    });
  };

  return (
    // 患者详情UI组件
  );
}

2.1.3 嵌套路由的层级管理

医疗应用的复杂业务逻辑要求我们设计多层级的路由结构。每个业务模块都有独立的 _layout.tsx 文件来管理其子路由:

// app/patient/_layout.tsx
import { Stack } from 'expo-router';

export default function PatientLayout() {
  return (
    <Stack
      screenOptions={{
        animation: 'slide_from_right', // iOS风格的滑动动画
        gestureDirection: 'horizontal', // 水平手势
        gestureEnabled: true, // 启用手势返回
      }}
    >
      <Stack.Screen
        name="index"
        options={{
          title: '患者搜索',
          headerShown: false,
        }}
      />
      <Stack.Screen
        name="detail/[id]"
        options={{
          title: '患者详情',
          headerShown: false,
        }}
      />
      <Stack.Screen
        name="edit/[id]"
        options={{
          title: '编辑患者',
          headerShown: false,
        }}
      />
      {/* 更多子路由配置 */}
    </Stack>
  );
}

2.2 导航状态管理与用户体验

2.2.1 多层级导航设计

在医疗应用中,用户经常需要在不同页面间跳转,比如从患者列表到患者详情,再到编辑页面。我们通过统一的动画配置确保用户体验的一致性:

// app/_layout.tsx - 根布局配置
export default function RootLayout() {
  return (
    <Stack
      screenOptions={{
        animation: 'slide_from_right', // 统一的页面切换动画
        gestureDirection: 'horizontal', // 水平手势方向
        gestureEnabled: true, // 启用手势返回
        animationTypeForReplace: 'push', // 替换动画类型
        animationDuration: 100, // 动画持续时间
      }}
    >
      <Stack.Screen name="index" options={{ headerShown: false }} />
      <Stack.Screen name="login" options={{ headerShown: false }} />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="patient" options={{ headerShown: false }} />
      <Stack.Screen name="appointment-module" options={{ headerShown: false }} />
    </Stack>
  );
}

2.2.2 患者选择流程的状态管理

医疗应用中最复杂的导航场景之一是患者选择流程。用户可能从预约创建页面跳转到患者搜索,选择患者后再返回原页面。我们通过 Zustand 实现了跨页面的状态管理:

// stores/patientSelectionStore.ts
export interface SelectedPatient {
  id: string;
  name: string;
  gender: string;
  age: string | number;
  phone: string;
  recordId?: string;
  consultItem?: string;
  source?: string;
}

export type SourcePageType = 'add-appointment' | 'edit-appointment' | 'patient-detail' | null;

interface PatientSelectionState {
  // 选中的患者信息
  selectedPatient: SelectedPatient | null;

  // 来源页面标识
  sourcePageType: SourcePageType;

  // 保存的表单数据(用于新建预约页面)
  savedFormData: Record<string, any> | null;

  // 编辑预约的ID(用于编辑页面)
  editReservationId: string | null;

  // 是否正在处理患者选择(防止重复处理)
  isProcessingPatientSelection: boolean;

  // Actions
  setSelectedPatient: (patient: SelectedPatient | null) => void;
  setSourcePage: (pageType: SourcePageType) => void;
  setSavedFormData: (formData: Record<string, any> | null) => void;

  // 获取并清除患者信息(一次性使用)
  consumeSelectedPatient: () => SelectedPatient | null;

  // 设置患者信息并保存表单数据
  setPatientWithFormData: (
    patient: SelectedPatient,
    formData?: Record<string, any>,
    sourceType?: SourcePageType
  ) => void;

  // 准备患者搜索(保存当前状态)
  preparePatientSearch: (
    formData: Record<string, any>,
    sourceType: SourcePageType,
    editId?: string
  ) => void;
}

2.2.3 页面间数据传递机制

在患者选择流程中,我们需要在多个页面间传递复杂的数据:

// 在预约创建页面准备患者搜索
const handleSelectPatient = () => {
  const formData = getValues(); // 获取当前表单数据

  // 保存当前状态到全局store
  preparePatientSearch(formData, 'add-appointment');

  // 跳转到患者搜索页面
  router.push('/appointment-module/patient-search');
};

// 在患者搜索页面选择患者后返回
const handlePatientSelect = (patient: SelectedPatient) => {
  // 将选中的患者和表单数据保存到store
  setPatientWithFormData(patient, savedFormData, sourcePageType);

  // 根据来源页面类型返回
  if (sourcePageType === 'add-appointment') {
    router.back(); // 返回预约创建页面
  }
};

// 在预约创建页面恢复数据
useEffect(() => {
  const selectedPatient = consumeSelectedPatient();
  if (selectedPatient) {
    // 恢复表单数据
    if (savedFormData) {
      reset(savedFormData);
    }
    // 设置选中的患者
    setValue('patientId', selectedPatient.id);
    setValue('patientName', selectedPatient.name);
  }
}, []);

2.3 权限控制与路由守卫

2.3.1 应用启动流程

医疗应用需要严格的权限控制。我们在应用启动时实现了完整的用户状态验证机制:

// app/index.tsx - 应用入口
export default function Index() {
  const { user, restoreUserFromStorage } = useUserStore();
  const {
    setSplashLoading,
    setBooted,
    setHasTriedAutoLogin,
    setLastLoginTime,
    setLastUserId
  } = useAppStore();

  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // 启动时设置 splashLoading
    setSplashLoading(true);
    setHasTriedAutoLogin(true);

    // 添加短暂延迟,确保 Root Layout 完全挂载
    const timer = setTimeout(() => {
      setIsReady(true);
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  useEffect(() => {
    if (!isReady) return;

    const initializeApp = async () => {
      try {
        // 使用用户状态管理工具验证用户状态
        const isUserStateValid = await ensureUserState();
        const userSummary = getUserStateSummary();

        if (isUserStateValid && userSummary.hasUser) {
          // 用户状态有效,跳转到主页
          setLastLoginTime(dayjs().valueOf());
          setLastUserId(userSummary.userId!);
          setSplashLoading(false);
          setBooted(true);
          router.replace('/(tabs)');
        } else {
          // 用户状态无效,跳转到登录页
          setSplashLoading(false);
          setBooted(true);
          router.replace('/login');
        }
      } catch (error) {
        setSplashLoading(false);
        setBooted(true);
        router.replace('/login');
      }
    };

    initializeApp();
  }, [isReady]);

  return null;
}

2.3.2 用户状态验证机制

我们实现了完整的用户状态管理工具,确保应用的安全性:

// utils/auth/userStateManager.ts
export interface UserStateStatus {
  hasToken: boolean;
  hasUser: boolean;
  isValid: boolean;
  needsRestore: boolean;
}

/**
 * 检查用户状态
 */
export async function checkUserState(): Promise<UserStateStatus> {
  try {
    const [accessToken, refreshToken, storedUser] = await Promise.all([
      getAccessToken(),
      getRefreshToken(),
      getUser()
    ]);

    const currentUser = useUserStore.getState().user;

    const hasToken = !!(accessToken || refreshToken);
    const hasUser = !!(currentUser && currentUser.id);
    const hasStoredUser = !!(storedUser && storedUser.user && storedUser.user.id);

    const isValid = hasToken && hasUser;
    const needsRestore = hasToken && !hasUser && hasStoredUser;

    return {
      hasToken,
      hasUser,
      isValid,
      needsRestore
    };
  } catch (error) {
    return {
      hasToken: false,
      hasUser: false,
      isValid: false,
      needsRestore: false
    };
  }
}

/**
 * 确保用户状态有效
 * 如果无效则尝试恢复
 */
export async function ensureUserState(): Promise<boolean> {
  try {
    const status = await checkUserState();

    if (status.isValid) {
      return true;
    }

    if (status.needsRestore) {
      return await restoreUserState();
    }

    return false;
  } catch (error) {
    return false;
  }
}

2.3.3 页面访问控制

在登录页面,我们实现了完整的登录流程和权限验证:

// app/login.tsx
export default function LoginScreen() {
  const [showErrorModal, setShowErrorModal] = useState(false);

  // 表单提交处理
  const onSubmit = async (data: LoginFormData) => {
    try {
      const res = await login(data.username, data.password);

      clearError();
      setShowErrorModal(false);
      Toast.success('登录成功', 1);

      // 登录成功后跳转到主页
      router.replace('/(tabs)');
    } catch (error: any) {
      const errorMessage = error?.message || '账户或密码错误,建议企微登录!';
      Toast.fail(errorMessage);
    }
  };

  // 企业微信登录处理
  const handleWeChatLogin = async () => {
    try {
      // 企业微信登录逻辑
      const result = await handleWeWorkLoginResult();

      if (result.success) {
        Toast.success('登录成功', 1);
        router.replace('/(tabs)');
      }
    } catch (error) {
      console.error('企业微信登录失败:', error);
    }
  };

  return (
    // 登录页面UI
  );
}

2.4 性能优化与用户体验

2.4.1 页面预加载策略

在医疗应用中,关键页面的快速响应至关重要。我们实现了智能的页面预加载机制:

// 在关键页面预加载相关数据
const PatientDetailScreen = () => {
  const { id } = useLocalSearchParams<{ id: string }>();

  // 预加载患者相关的其他数据
  const { data: medicalRecords } = useMedicalRecordsQuery(Number(id), {
    enabled: !!id,
    staleTime: 5 * 60 * 1000, // 5分钟内数据保持新鲜
  });

  const { data: followUpRecords } = useFollowUpRecordsQuery(Number(id), {
    enabled: !!id,
    staleTime: 5 * 60 * 1000,
  });

  return (
    // 患者详情UI
  );
};

2.4.2 导航动画优化

我们通过原生动画API实现了流畅的页面切换效果:

// 统一的导航动画配置
const screenOptions = {
  animation: 'slide_from_right', // 新页面从右侧滑入(iOS风格)
  gestureDirection: 'horizontal', // 手势方向设为水平
  gestureEnabled: true, // 启用手势返回
  animationTypeForReplace: 'push', // 替换动画类型
  animationDuration: 100, // 动画持续时间
};

// 在需要特殊动画的页面可以覆盖默认配置
<Stack.Screen
  name="detail/[id]"
  options={{
    ...screenOptions,
    animation: 'fade', // 使用淡入淡出动画
  }}
/>

2.4.3 手势返回优化

我们为所有页面启用了手势返回功能,提升用户体验:

// 在布局文件中统一配置手势返回
<Stack
  screenOptions={{
    gestureEnabled: true, // 启用手势返回
    gestureDirection: 'horizontal', // 水平手势
    gestureResponseDistance: 50, // 手势响应距离
  }}
>
  {/* 页面配置 */}
</Stack>

2.5 实际应用场景

2.5.1 患者管理流程

在患者管理模块中,我们实现了完整的CRUD操作流程:

// 患者列表 -> 患者详情 -> 编辑患者
const PatientListScreen = () => {
  const handlePatientPress = (patientId: string) => {
    router.push({
      pathname: '/patient/detail/[id]',
      params: { id: patientId }
    });
  };

  return (
    <FlatList
      data={patients}
      renderItem={({ item }) => (
        <PatientCard
          patient={item}
          onPress={() => handlePatientPress(item.id)}
        />
      )}
    />
  );
};

// 患者详情页面
const PatientDetailScreen = () => {
  const { id } = useLocalSearchParams<{ id: string }>();

  const handleEdit = () => {
    router.push({
      pathname: '/patient/edit/[id]',
      params: { id }
    });
  };

  return (
    <View>
      {/* 患者详情内容 */}
      <Button title="编辑患者" onPress={handleEdit} />
    </View>
  );
};

2.5.2 预约管理流程

预约管理涉及多个页面的复杂交互:

// 预约创建流程
const AddAppointmentScreen = () => {
  const { preparePatientSearch } = usePatientSelectionStore();

  const handleSelectPatient = () => {
    const formData = getValues(); // 获取当前表单数据

    // 保存表单数据并跳转到患者搜索
    preparePatientSearch(formData, 'add-appointment');
    router.push('/appointment-module/patient-search');
  };

  return (
    <View>
      <Button title="选择患者" onPress={handleSelectPatient} />
      {/* 其他表单字段 */}
    </View>
  );
};

2.6 最佳实践总结

2.6.1 路由设计原则

  1. 文件系统即路由:保持路由结构与业务模块的一致性
  2. 动态路由命名:使用 [id].tsx 约定实现动态参数
  3. 嵌套布局管理:每个业务模块使用独立的 _layout.tsx
  4. 统一的动画配置:确保用户体验的一致性

2.6.2 状态管理策略

  1. 跨页面状态同步:使用 Zustand 管理复杂的页面间状态
  2. 一次性数据消费:通过 consumeSelectedPatient 避免状态污染
  3. 状态持久化:关键状态需要持久化到本地存储
  4. 状态清理机制:及时清理不再需要的状态

2.6.3 性能优化要点

  1. 页面预加载:关键数据提前加载
  2. 动画优化:使用原生动画API提升性能
  3. 手势返回:提供直观的导航体验
  4. 内存管理:及时清理页面状态和缓存

2.7 总结

通过本章的深入探讨,我们了解了 Expo Router 在医疗应用中的完整实践:

  • 文件系统路由:直观的路由结构设计
  • 状态管理:复杂的跨页面状态同步机制
  • 权限控制:完整的用户状态验证和路由守卫
  • 性能优化:流畅的导航体验和动画效果

这些实践不仅提升了用户体验,也为后续的功能扩展奠定了坚实的基础。在下一章中,我们将深入探讨状态管理架构设计,包括 Zustand 和 React Query 的最佳实践。


下一章预告:第三章将详细介绍 Zustand 状态管理实践和 React Query 数据管理策略,包括全局状态与局部状态的设计原则、缓存策略与数据同步等核心内容。