Expo 技术深度解析:从架构设计到工程化实践

0 阅读25分钟

一、引言

Expo 是围绕 React Native 构建的一套开源工具链和服务平台,旨在简化跨平台移动应用的开发流程。作为 React Native 生态系统中最受欢迎的开发框架之一,Expo 提供了从项目初始化、开发调试到构建部署的完整解决方案,使开发者能够使用统一的 JavaScript/TypeScript 代码库同时构建 iOS、Android 和 Web 应用。本文将从架构设计、技术实现细节和工程化实践三个维度,深入剖析 Expo 的核心技术原理,帮助开发者全面理解这一强大的跨平台开发工具。

Expo 的设计理念源于对开发效率的极致追求。传统的 React Native 开发要求开发者具备原生移动开发的知识,能够配置 Xcode、Android Studio 等复杂的开发环境,处理各种原生模块的集成问题。Expo 通过提供预封装的模块集合、托管的构建服务和开箱即用的开发工具链,大大降低了跨平台开发的门槛。同时,Expo 并不牺牲灵活性——开发者可以根据项目需求,在托管工作流和原生工作流之间自由切换,甚至可以渐进式地从托管模式迁移到完全原生模式。

二、Expo 架构设计

2.1 整体架构概述

Expo 的架构设计遵循模块化和分层解耦的原则,整体架构可以分为四个核心层次:应用层、运行时层、构建层和服务层。这种分层设计使得各部分职责明确,便于维护和扩展,同时保证了系统的稳定性和可测试性。

应用层是开发者直接交互的代码层,包括 React 组件、业务逻辑和数据管理。这一层使用标准的 React 和 React Native API,开发者编写的代码具有高度的可移植性,可以在不同的 Expo 项目类型之间共享。应用层通过调用 Expo SDK 提供的 JavaScript API 来访问原生功能,这些 API 在幕后处理了与原生模块通信的所有复杂性。

运行时层是 Expo 架构中最关键的部分,它负责在 JavaScript 引擎和原生平台之间建立桥梁。运行时层的核心组件包括 Expo SDK、Expo Modules 和 JSI(JavaScript Interface)。Expo SDK 提供了一套统一的 JavaScript API,封装了对原生功能的调用;Expo Modules 是原生模块的容器,提供了模块注册、生命周期管理和错误处理等功能;JSI 是 React Native 新架构的核心组件,它允许 JavaScript 代码直接调用原生方法,省去了传统的 Bridge 序列化开销。

构建层负责将应用代码编译成可执行的安装包。这一层主要由 EAS(Expo Application Services)构建服务实现,提供了云端编译、签名管理和多平台打包等功能。EAS Build 支持自定义构建流程,开发者可以通过配置文件定义构建步骤、安装依赖和处理资源文件。对于需要完全原生控制的项目,构建层还提供了 expo prebuild 命令,用于生成完整的原生项目代码。

服务层是 Expo 提供的云端服务集合,包括 EAS Update 热更新服务、EAS Submit 应用商店提交服务和 Expo 应用商店。这些服务为应用的部署和分发提供了端到端的解决方案,使开发者能够专注于业务逻辑的实现,而将运维相关的工作交给 Expo 平台处理。

2.2 托管工作流与原生工作流

Expo 支持两种主要的工作流模式:托管工作流(Managed Workflow)和原生工作流(Bare Workflow)。理解这两种工作流的区别和适用场景,对于选择合适的开发模式至关重要。

托管工作流是 Expo 最推荐的开发模式,特别适合以下场景:快速原型开发、MVP 产品验证、团队缺乏原生开发经验、需要快速迭代的初创项目。在托管工作流模式下,开发者只需要编写 JavaScript/TypeScript 代码,Expo 负责处理所有的原生编译和打包工作。开发者可以通过 Expo Go 应用直接在真机上预览和调试应用,无需配置复杂的原生开发环境。这种模式的核心优势在于极低的使用门槛——开发者只需要安装 Node.js 和一个代码编辑器,就可以开始构建跨平台应用。托管工作流还提供了自动更新功能,用户打开应用时可以自动获取最新版本,确保所有用户都能使用最新功能。

托管工作流的局限性主要体现在定制化方面。由于 Expo 控制了原生项目的生成和管理,开发者无法直接修改原生代码来添加自定义的原生功能。不过,Expo 提供了 Config Plugin 机制来扩展原生配置,一定程度上缓解了这个限制。对于 Expo SDK 未提供的功能,开发者可以通过安装社区维护的 Expo Config Plugins 来集成,或者使用 React Native 社区中兼容 Expo 的第三方库。

原生工作流,也称为裸露工作流或裸机工作流,提供了对原生代码的完全访问权限。当项目通过 expo prebuild 命令生成原生项目后,开发者可以像使用 React Native CLI 创建的项目一样,直接编辑 iOS 和 Android 的原生代码。这种模式适合以下场景:对原生功能有深度定制需求、需要集成 Expo SDK 不支持的第三方原生库、性能敏感的复杂应用、需要在特定平台上进行优化。

原生工作流的使用并不意味着放弃 Expo 提供的工具和服务。生成原生项目后,开发者仍然可以使用 Expo CLI、Expo SDK 和 EAS 服务。主要的区别在于构建过程:原生工作流使用各平台的原生工具链进行编译,而不是依赖 Expo 的托管构建服务。值得注意的是,从托管工作流迁移到原生工作流是一个渐进的过程,开发者可以在项目成熟后逐步增加原生代码的使用,而无需一开始就做出非此即彼的选择。

2.3 运行时架构与 JSI 集成

Expo 的运行时架构经历了从传统 Bridge 到 JSI(JavaScript Interface)的重大演进。React Native 0.68 引入了新架构,JSI 是新架构的核心组件,它彻底改变了 JavaScript 和原生代码之间的通信方式。

传统的 React Native 使用 Bridge 进行 JavaScript 和原生代码之间的通信。Bridge 的工作方式是异步序列化和反序列化:JavaScript 调用原生方法时,参数会被序列化成 JSON 字符串,通过 Bridge 发送到原生端,原生端处理后再将结果序列化返回。这种方式的主要问题在于序列化和反序列化的开销,特别是对于大量数据传输或高频调用的场景,Bridge 往往成为性能瓶颈。

JSI 通过直接方法调用解决了这个问题。在 JSI 架构中,JavaScript 可以持有原生对象的引用,并直接调用这些对象的方法,无需通过 Bridge 进行通信。这种设计带来了几个显著的优势:首先是性能提升,消除了序列化和反序列化的开销,使得同步调用成为可能;其次是简化了原生模块的开发,开发者不需要编写复杂的 Bridge 代码;第三是支持多 JavaScript 引擎,除了 Hermes 之外,还可以集成其他 JavaScript 引擎。

Expo Modules 是在 JSI 基础上构建的模块系统,它为原生模块提供了统一的开发范式。在 Expo Modules 中,原生模块使用 Swift 或 Kotlin 等原生语言编写,通过 Swift/Objective-C 或 Kotlin/Java 的混合方式与 React Native 交互。模块通过 AsyncFunction 装饰器声明异步方法,使用 Promise 或回调处理异步结果。AutoCapabilities 机制自动处理模块的能力声明,如是否需要摄像头权限或位置权限。

// iOS - Swift 示例:创建 Expo 模块
import ExpoModulesCore

public class MyNativeModule: Module {
  public func definition() -> ModuleDefinition {
    return ModuleDefinition(name: "MyNativeModule") {
      // 异步函数
      AsyncFunction("getDeviceInfo") { (resolve: @escaping PromiseResolve, reject: @escaping PromiseReject) in
        let deviceInfo: [String: Any] = [
          "model": UIDevice.current.model,
          "systemVersion": UIDevice.current.systemVersion
        ]
        resolve(deviceInfo)
      }

      // 同步函数(可直接返回)
      Function("getConstant") {
        return "Hello from Native!"
      }

      // 监听原生事件
      OnViewDidAppear {
        // 事件处理逻辑
      }
    }
  }
}
// Android - Kotlin 示例:创建 Expo 模块
package com.myapp.modules

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.AsyncFunction
import expo.modules.kotlin.functions

class MyNativeModule : Module() {
  override fun definition() = ModuleDefinition("MyNativeModule") {
    // 异步函数
    AsyncFunction("getDeviceInfo") { promise ->
      val deviceInfo = mapOf(
        "model" to android.os.Build.MODEL,
        "systemVersion" to android.os.Build.VERSION.SDK_INT
      )
      promise.resolve(deviceInfo)
    }

    // 同步函数
    Function("getConstant") {
      return@Function "Hello from Native!"
    }
  }
}

三、核心技术细节

3.1 Expo SDK 架构

Expo SDK 是 Expo 平台的核心组件,它提供了一套统一的 JavaScript API,使开发者能够以平台无关的方式访问原生设备功能。SDK 的设计遵循一致性、可预测性和渐进增强的原则,每个模块都经过精心设计,确保在不同平台上提供一致的行为和 API 接口。

Expo SDK 采用 monorepo 结构管理,所有模块都放在同一个代码仓库中,通过 workspaces 机制组织成独立的包。这种结构确保了各模块之间的版本一致性,便于统一发布和版本管理。SDK 包含数十个功能模块,覆盖了移动应用开发的各个方面:基础功能如文件系统、存储、网络请求;设备功能如相机、传感器、地理位置;系统功能如通知、联系人、日历;媒体功能如图像处理、视频播放、音频录制。

每个 Expo SDK 模块都可以独立安装,开发者只需要安装项目所需的具体模块,而不是整个 SDK。例如,如果项目只需要使用相机功能,可以只安装 expo-camera 包。这种按需加载的设计减少了应用的最终包大小,同时保持了代码的模块化和可维护性。模块之间存在依赖关系管理,expo 安装某个模块时,npm/yarn 会自动安装其依赖的模块。

// 安装特定模块(推荐方式)
npx expo install expo-camera
npx expo install expo-location
npx expo install expo-notifications

// 或者使用 npm(需要手动处理版本兼容性)
npm install expo-camera expo-location expo-notifications

// 使用模块的示例代码
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';

async function setupApp() {
  // 请求相机权限
  const { status: cameraStatus } = await useCameraPermissions();

  // 请求位置权限并获取当前位置
  const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();
  if (locationStatus === 'granted') {
    const location = await Location.getCurrentPositionAsync({});
    console.log('当前位置:', location);
  }

  // 设置推送通知
  await Notifications.setNotificationChannelAsync('default', {
    name: '默认通知',
    importance: Notifications.AndroidImportance.MAX,
  });
}

3.2 预构建系统(Prebuild)

Expo prebuild 是 Expo 提供的一个强大功能,它将托管工作流中的项目配置转换为完整的原生项目代码。这个过程解决了托管工作流的灵活性限制问题,使开发者能够在保持 Expo 工具链便利性的同时,获得对原生代码的完全控制。

预构建过程的输入是项目的 app.json/app.config.js 配置文件和 package.json 中的依赖列表。输出是完整的 iOS 和 Android 原生项目目录,包括 Xcode 项目文件(.xcodeproj)、Android Studio 项目文件以及所有原生配置文件。预构建过程会考虑多个因素来生成原生代码:传递给命令的参数(如 --platform 指定目标平台)、app.config.js 中的平台特定配置、项目依赖中声明的 Expo 模块和 Config Plugins。

// app.config.js 配置示例
export default {
  expo: {
    name: "MyApp",
    slug: "my-app",
    version: "1.0.0",
    orientation: "portrait",
    icon: "./assets/icon.png",
    userInterfaceStyle: "automatic",

    // iOS 配置
    ios: {
      bundleIdentifier: "com.company.myapp",
      supportsTablet: true,
      infoPlist: {
        NSCameraUsageDescription: "此应用需要访问您的相机",
        NSLocationWhenInUseUsageDescription: "此应用需要获取您的位置信息",
      },
      entitlements: {
        "aps-environment": "development",
      },
    },

    // Android 配置
    android: {
      package: "com.company.myapp",
      versionCode: 1,
      permissions: [
        "CAMERA",
        "ACCESS_FINE_LOCATION",
        "READ_EXTERNAL_STORAGE",
      ],
      config: {
        googleMaps: {
          apiKey: "YOUR_GOOGLE_MAPS_API_KEY",
        },
      },
    },

    // 插件配置
    plugins: [
      [
        "expo-camera",
        {
          cameraPermission: "允许应用访问您的相机",
        },
      ],
      [
        "expo-build-properties",
        {
          ios: {
            deploymentTarget: "13.4",
          },
          android: {
            compileSdkVersion: 34,
            targetSdkVersion: 34,
            minSdkVersion: 24,
          },
        },
      ],
    ],

    // 额外资源
    assets: ["./assets/fonts"],
    extra: {
      eas: {
        projectId: "your-project-id",
      },
    },
  },
};

预构建生成的原生项目保持了与 Expo 工具链的兼容性。开发者可以直接使用 EAS Build 进行云端构建,也可以选择使用本地原生工具链进行构建。当需要升级 Expo SDK 版本时,只需更新 package.json 中的版本号,然后重新运行 prebuild 即可。这种设计使得项目的维护和升级变得简单可控。

3.3 开发客户端(Development Client)

Development Client 是 Expo 提供的一个特殊构建版本,它在保留 Expo 工具链便利性的同时,提供了对自定义原生代码的支持。相比 Expo Go,Development Client 是一个可定制的沙箱环境,适合开发阶段需要测试原生功能或 Config Plugins 的场景。

使用 Development Client 的工作流程如下:首先,开发者需要构建一个包含项目特定配置的 Development Client 安装包。这个构建过程可以通过 EAS Build 在线完成,也可以使用本地构建。构建完成后,开发者将生成的安装包安装到设备或模拟器上,然后在应用内启动开发服务器即可开始调试。

# 构建 Development Client
# 方式一:使用 EAS Build 在线构建
eas build --profile development --platform ios
eas build --profile development --platform android

# 方式二:本地构建
npx expo run:ios --configuration Debug
npx expo run:android --variant debug

# 启动开发服务器(带 --dev-client 参数)
npx expo start --dev-client

Development Client 的核心优势在于支持 Config Plugins 的测试。Config Plugins 是 Expo 提供的扩展机制,用于在预构建阶段修改原生项目配置。很多第三方库提供了 Config Plugins 来简化原生代码的集成,但在 Expo Go 环境中无法测试这些插件。Development Client 解决了这个问题,开发者可以在构建时将自定义插件打包到应用中,然后在真实环境中测试插件的效果。

3.4 EAS 构建系统

EAS(Expo Application Services)是 Expo 提供的云端构建和部署服务,它为 React Native 和 Expo 项目提供了完整的 CI/CD 解决方案。EAS 由三个主要服务组成:EAS Build 用于应用构建、EAS Submit 用于应用商店提交、EAS Update 用于热更新。

EAS Build 是整个服务的核心,它提供了托管的构建环境,支持 iOS 和 Android 双平台的构建需求。EAS Build 的构建流程由 eas.json 配置文件定义,开发者可以在配置中指定构建类型、触发条件、凭证管理和自定义构建步骤。

// eas.json 配置文件
{
  "cli": {
    "version": ">= 5.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      }
    },
    "preview": {
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      }
    },
    "production": {
      "android": {
        "buildType": "app-bundle"
      },
      "ios": {
        "simulator": false
      }
    }
  },
  "submit": {
    "production": {
      "android": {
        "serviceAccountKeyPath": "./path-to-service-account.json",
        "track": "production"
      },
      "ios": {
        "appleId": "your-apple-id@email.com"
      }
    }
  }
}

EAS Build 支持多种构建类型:development 构建生成包含 Metro bundler 的开发版本,适合使用 Development Client 进行调试;preview 构建生成内部测试版本,可以安装到设备上进行真机测试;production 构建生成发布版本,用于应用商店提交。构建过程会自动处理凭证管理,包括 iOS 证书和描述文件、Android 签名密钥等敏感信息。开发者可以通过 EAS Secret 管理敏感的环境变量和 API 密钥,确保这些信息不会泄露到代码仓库中。

构建流程的可定制性是 EAS Build 的一大特色。开发者可以通过 expo-build-properties 插件修改构建参数,也可以在构建配置中指定自定义的 gradle 或 xcodebuild 参数。对于更复杂的构建需求,EAS Build 支持本地构建流程(eas local build),开发者可以在本地机器上使用 Docker 运行完整的云端构建环境,这对于调试构建问题或需要访问本地私有依赖的场景非常有用。

3.5 EAS Update 热更新

EAS Update 是 Expo 提供的热更新服务,它允许开发者在应用发布后无需重新构建和提交应用商店,即可向用户推送 JavaScript 代码和资源的更新。这种能力对于修复紧急 bug、发布新功能和 A/B 测试等场景非常有价值,能够显著缩短迭代周期并提升用户体验。

EAS Update 的工作原理基于 React Native 的代码打包机制。当应用启动时,expo-updates 库会检查是否有可用的更新。如果有更新可用,库会在后台下载新的 JavaScript bundle 和资源文件,下一次应用启动时自动加载新代码。如果没有可用更新或下载失败,应用会使用本地缓存的代码继续运行。这种设计确保了应用的可靠性——即使在网络不可用的情况下,用户仍然可以正常使用应用。

// 配置 EAS Update
import { Updates, useUpdateEffect } from 'expo-updates';

// 在应用启动时检查更新
async function checkForUpdates() {
  try {
    const update = await Updates.checkForUpdateAsync();
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();
      // 更新已下载,重启应用以应用更新
      await Updates.reloadAsync();
    }
  } catch (error) {
    console.error('检查更新失败:', error);
  }
}

// 在 React 组件中使用更新钩子
function App() {
  const { isUpdateAvailable, isLoading } = useUpdates();

  useUpdateEffect(() => {
    // 有可用更新时的处理逻辑
    Alert.alert(
      '发现新版本',
      '新版本已下载,将在下次启动时自动更新。',
      [{ text: '确定' }]
    );
  }, [isUpdateAvailable]);

  // ... 应用组件内容
}

// 自定义更新行为
const updateRequest = {
  studioCode: {
    releaseChannel: 'production',
    timeout: 5000,
  },
};

// 运行时获取更新
async function fetchUpdatesManually() {
  const update = await Updates.checkForUpdateAsync(updateRequest);
  if (update.isAvailable) {
    const { isNew } = await Updates.fetchUpdateAsync();
    if (isNew) {
      await Updates.reloadAsync();
    }
  }
}

EAS Update 引入了分支(Branch)的概念来管理更新发布。每个分支对应一个用户群体或发布阶段,开发者可以将不同的更新推送到不同的分支。例如,可以设置 preview 分支用于内部测试,production 分支用于正式用户。更新发布时使用 eas update 命令:

# 发布更新到预览分支
eas update --branch preview --message "修复登录问题"

# 发布更新到生产分支
eas update --branch production --message "新增用户资料页"

对于需要更精细控制的场景,EAS Update 支持通过代码动态选择分支或更新配置。开发者可以根据用户属性、地区或实验配置来决定加载哪个分支的更新。这种灵活性使得 EAS Update 不仅适用于常规的功能更新,还可以支持灰度发布、功能开关和动态配置等高级场景。

四、工程化实践

4.1 项目结构与代码组织

良好的项目结构是工程化开发的基础。一个典型的 Expo 项目应该遵循清晰的分层架构,将不同职责的代码组织到对应的目录中。下面是一个经过实践验证的项目结构示例:

my-expo-app/
├── src/
│   ├── app/                    # 路由和页面组件(使用 Expo Router)
│   │   ├── _layout.tsx        # 根布局
│   │   ├── index.tsx          # 首页
│   │   ├── (tabs)/           # 标签页路由组
│   │   │   ├── _layout.tsx
│   │   │   ├── home.tsx
│   │   │   └── profile.tsx
│   │   └── settings/
│   │       └── index.tsx
│   ├── components/            # 可复用组件
│   │   ├── ui/               # 基础 UI 组件
│   │   │   ├── Button.tsx
│   │   │   ├── Card.tsx
│   │   │   └── Input.tsx
│   │   ├── forms/            # 表单组件
│   │   │   ├── LoginForm.tsx
│   │   │   └── SignupForm.tsx
│   │   └── business/         # 业务组件
│   │       ├── UserAvatar.tsx
│   │       └── ProductCard.tsx
│   ├── hooks/                # 自定义 Hooks
│   │   ├── useAuth.ts
│   │   ├── useFetch.ts
│   │   └── useTheme.ts
│   ├── services/             # API 和外部服务
│   │   ├── api/
│   │   │   ├── client.ts     # API 客户端配置
│   │   │   ├── auth.ts
│   │   │   └── products.ts
│   │   └── storage.ts        # 本地存储服务
│   ├── stores/               # 状态管理(Zustand/Jotai)
│   │   ├── authStore.ts
│   │   └── cartStore.ts
│   ├── utils/                # 工具函数
│   │   ├── format.ts
│   │   ├── validation.ts
│   │   └── helpers.ts
│   ├── constants/            # 常量定义
│   │   ├── theme.ts          # 主题配置
│   │   ├── colors.ts
│   │   └── config.ts
│   ├── types/                # TypeScript 类型定义
│   │   ├── user.ts
│   │   ├── product.ts
│   │   └── api.ts
│   └── navigation/            # 导航配置(非 Expo Router 项目)
│       └── AppNavigator.tsx
├── app.json                  # Expo 配置
├── babel.config.js           # Babel 配置
├── tsconfig.json             # TypeScript 配置
├── metro.config.js           # Metro 打包器配置
├── eas.json                  # EAS 构建配置
└── package.json

对于大型项目,建议进一步采用功能模块化的组织方式。每个功能模块包含其相关的页面、组件、服务和类型定义:

src/
├── features/
│   ├── auth/                 # 认证功能模块
│   │   ├── components/
│   │   ├── screens/
│   │   ├── services/
│   │   ├── hooks/
│   │   ├── types/
│   │   └── index.ts          # 模块导出
│   ├── products/             # 产品功能模块
│   └── orders/               # 订单功能模块
├── shared/                   # 跨模块共享代码
│   ├── components/
│   ├── hooks/
│   └── utils/
└── app/                      # 应用入口和路由

4.2 TypeScript 配置与最佳实践

TypeScript 是 Expo 项目的推荐开发语言,它提供了静态类型检查能力,能够在编译阶段发现类型错误,减少运行时 bug。Expo 项目默认使用 TypeScript,无需额外安装或配置。

// tsconfig.json 配置示例
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@hooks/*": ["src/hooks/*"],
      "@services/*": ["src/services/*"],
      "@stores/*": ["src/stores/*"],
      "@utils/*": ["src/utils/*"],
      "@types/*": ["src/types/*"]
    },
    "types": ["jest", "@types/react"],
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "bundler"
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts"
  ],
  "exclude": [
    "node_modules",
    "android",
    "ios"
  ]
}

类型定义文件应该按照功能模块组织,并创建索引文件方便导入:

// src/types/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface UserProfile extends User {
  bio?: string;
  phone?: string;
  address?: Address;
}

export interface Address {
  street: string;
  city: string;
  state: string;
  country: string;
  zipCode: string;
}

export interface LoginCredentials {
  email: string;
  password: string;
  rememberMe?: boolean;
}

export interface AuthResponse {
  user: User;
  token: string;
  refreshToken: string;
  expiresIn: number;
}

// src/types/index.ts
export * from './user';
export * from './product';
export * from './api';

// src/types/api.ts
export interface ApiResponse<T> {
  data: T;
  message?: string;
  success: boolean;
}

export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}

export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
  };
}

4.3 状态管理方案

对于中大型应用,状态管理是架构设计中的重要环节。Expo 项目可以使用多种状态管理方案,常见的选择包括 Zustand、Jotai、Recoil 和 Redux Toolkit。选择时需要考虑项目的规模、团队的技术背景和特定的功能需求。

Zustand 是一个轻量级的状态管理库,它提供了简洁的 API 和优秀的性能,特别适合中小型应用:

// src/stores/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginCredentials } from '@/types';
import { authService } from '@/services/api/auth';

interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;

  // Actions
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => Promise<void>;
  refreshToken: () => Promise<void>;
  updateUser: (user: Partial<User>) => void;
  clearError: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,

      login: async (credentials) => {
        set({ isLoading: true, error: null });
        try {
          const response = await authService.login(credentials);
          set({
            user: response.user,
            token: response.token,
            isAuthenticated: true,
            isLoading: false,
          });
        } catch (error) {
          set({
            error: error instanceof Error ? error.message : '登录失败',
            isLoading: false,
          });
          throw error;
        }
      },

      logout: async () => {
        set({ isLoading: true });
        try {
          await authService.logout();
        } finally {
          set({
            user: null,
            token: null,
            isAuthenticated: false,
            isLoading: false,
          });
        }
      },

      refreshToken: async () => {
        const { token } = get();
        if (!token) return;

        try {
          const response = await authService.refreshToken(token);
          set({ token: response.token });
        } catch (error) {
          await get().logout();
        }
      },

      updateUser: (userData) => {
        const { user } = get();
        if (user) {
          set({ user: { ...user, ...userData } });
        }
      },

      clearError: () => set({ error: null }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({
        user: state.user,
        token: state.token,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

Jotai 是另一种值得考虑的选择,它基于原子(Atom)的概念,提供了更细粒度的状态管理:

// src/stores/cartStore.ts(使用 Jotai)
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, CartItem } from '@/types';

// 持久化存储适配器
const storage = createJSONStorage<CartItem[]>(() => AsyncStorage);

// 购物车状态原子
const cartItemsAtom = atomWithStorage<CartItem[]>(
  'cart-storage',
  [],
  storage
);

// 计算属性原子
export const cartCountAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((total, item) => total + item.quantity, 0);
});

export const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce(
    (total, item) => total + item.price * item.quantity,
    0
  );
});

// 操作原子
export const addToCartAtom = atom(
  null,
  (get, set, product: Product) => {
    const items = get(cartItemsAtom);
    const existingIndex = items.findIndex(
      (item) => item.id === product.id
    );

    if (existingIndex >= 0) {
      const updatedItems = [...items];
      updatedItems[existingIndex].quantity += 1;
      set(cartItemsAtom, updatedItems);
    } else {
      set(cartItemsAtom, [
        ...items,
        { ...product, quantity: 1 },
      ]);
    }
  }
);

// 在组件中使用
// import { useAtom, useSetAtom } from 'jotai';
// import { cartCountAtom, addToCartAtom } from '@/stores/cartStore';
//
// const cartCount = useAtomValue(cartCountAtom);
// const addToCart = useSetAtom(addToCartAtom);

4.4 API 层设计与数据获取

合理设计 API 层对于应用的可维护性和可测试性至关重要。推荐使用 Axios 或 Fetch API 结合自定义的 API 客户端层,封装请求配置、错误处理和响应转换逻辑:

// src/services/api/client.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { router } from 'expo-router';
import { useAuthStore } from '@/stores/authStore';

const BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com';

class ApiClient {
  private client: AxiosInstance;

  constructor() {
    this.client = axios.create({
      baseURL: BASE_URL,
      timeout: 15000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // 请求拦截器:添加认证令牌
    this.client.interceptors.request.use(
      (config: InternalAxiosRequestConfig) => {
        const token = useAuthStore.getState().token;
        if (token && config.headers) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // 响应拦截器:统一错误处理
    this.client.interceptors.response.use(
      (response) => response,
      async (error: AxiosError) => {
        if (error.response?.status === 401) {
          // Token 过期,尝试刷新
          const refreshed = await this.handleTokenRefresh();
          if (refreshed) {
            // 重试原请求
            const originalRequest = error.config;
            if (originalRequest) {
              return this.client(originalRequest);
            }
          } else {
            // 刷新失败,登出
            await useAuthStore.getState().logout();
            router.replace('/login');
          }
        }
        return Promise.reject(this.normalizeError(error));
      }
    );
  }

  private async handleTokenRefresh(): Promise<boolean> {
    try {
      await useAuthStore.getState().refreshToken();
      return true;
    } catch {
      return false;
    }
  }

  private normalizeError(error: AxiosError): Error {
    const message =
      error.response?.data &&
      typeof error.response.data === 'object' &&
      'message' in error.response.data
        ? (error.response.data as { message: string }).message
        : error.message || '网络请求失败';

    return new Error(message);
  }

  async get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
    const response = await this.client.get<T>(url, { params });
    return response.data;
  }

  async post<T>(url: string, data?: unknown): Promise<T> {
    const response = await this.client.post<T>(url, data);
    return response.data;
  }

  async put<T>(url: string, data?: unknown): Promise<T> {
    const response = await this.client.put<T>(url, data);
    return response.data;
  }

  async delete<T>(url: string): Promise<T> {
    const response = await this.client.delete<T>(url);
    return response.data;
  }

  async patch<T>(url: string, data?: unknown): Promise<T> {
    const response = await this.client.patch<T>(url, data);
    return response.data;
  }
}

export const apiClient = new ApiClient();
// src/services/api/products.ts
import { apiClient } from './client';
import { ApiResponse, PaginatedResponse, Product, ProductFilter } from '@/types';

export const productService = {
  async getProducts(
    filter?: ProductFilter,
    page = 1,
    pageSize = 20
  ): Promise<PaginatedResponse<Product>> {
    return apiClient.get<PaginatedResponse<Product>>('/products', {
      ...filter,
      page,
      pageSize,
    });
  },

  async getProductById(id: string): Promise<ApiResponse<Product>> {
    return apiClient.get<ApiResponse<Product>>(`/products/${id}`);
  },

  async createProduct(data: Partial<Product>): Promise<ApiResponse<Product>> {
    return apiClient.post<ApiResponse<Product>>('/products', data);
  },

  async updateProduct(
    id: string,
    data: Partial<Product>
  ): Promise<ApiResponse<Product>> {
    return apiClient.put<ApiResponse<Product>>(`/products/${id}`, data);
  },

  async deleteProduct(id: string): Promise<void> {
    await apiClient.delete(`/products/${id}`);
  },
};

4.5 环境配置与敏感信息管理

敏感信息(如 API 密钥、第三方服务凭证)的安全管理是应用安全的重要环节。Expo 提供了多种机制来处理不同环境的配置需求。

对于公开的配置,可以使用 app.json 中的 extra 字段或环境变量:

// app.config.js
const isDev = process.env.NODE_ENV === 'development';

export default {
  expo: {
    // ... 其他配置
    extra: {
      eas: {
        projectId: 'your-project-id',
      },
      // 公开环境变量(会被包含在构建中)
      apiUrl: process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com',
      enableAnalytics: !isDev,
    },
  },
};
// 使用公开配置
import Constants from 'expo-constants';

const config = Constants.expoConfig?.extra;
const apiUrl = config.apiUrl as string;

对于敏感信息,应该使用 EAS Secrets 进行管理:

# 添加 EAS Secret
eas secret:create --name STRIPE_SECRET_KEY --value "sk_test_xxx"
eas secret:create --name GOOGLE_MAPS_API_KEY --value "AIzaSyxxx" --scope production

# 查看 Secret 列表
eas secret:list
// 在代码中使用 Secret
// EAS Secrets 会自动注入到环境变量中
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY;

4.6 测试策略

完善的测试是保证代码质量的关键。Expo 项目可以使用 Jest 作为测试框架,配合 React Native Testing Library 进行组件测试:

// src/components/__tests__/Button.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Button } from '../Button';

describe('Button Component', () => {
  it('renders correctly with default props', () => {
    const { getByText } = render(<Button>Click Me</Button>);
    expect(getByText('Click Me')).toBeTruthy();
  });

  it('calls onPress when pressed', async () => {
    const onPressMock = jest.fn();
    const { getByText } = render(
      <Button onPress={onPressMock}>Click Me</Button>
    );

    fireEvent.press(getByText('Click Me'));

    await waitFor(() => {
      expect(onPressMock).toHaveBeenCalledTimes(1);
    });
  });

  it('is disabled when disabled prop is true', () => {
    const onPressMock = jest.fn();
    const { getByText } = render(
      <Button onPress={onPressMock} disabled>Click Me</Button>
    );

    fireEvent.press(getByText('Click Me'));

    expect(onPressMock).not.toHaveBeenCalled();
  });

  it('shows loading indicator when loading', () => {
    const { queryByText, getByTestId } = render(
      <Button loading loadingText="Loading...">Click Me</Button>
    );

    expect(queryByText('Click Me')).toBeNull();
    expect(getByTestId('loading-indicator')).toBeTruthy();
  });
});
// src/hooks/__tests__/useAuth.test.ts
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useAuth } from '../useAuth';
import { MockAuthService } from '@/services/__mocks__/auth';

jest.mock('@/services/api/auth', () => ({
  authService: new MockAuthService(),
}));

describe('useAuth Hook', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('initializes with unauthenticated state', () => {
    const { result } = renderHook(() => useAuth());

    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.user).toBeNull();
  });

  it('logs in successfully', async () => {
    const { result } = renderHook(() => useAuth());

    await act(async () => {
      await result.current.login({
        email: 'test@example.com',
        password: 'password123',
      });
    });

    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
      expect(result.current.user?.email).toBe('test@example.com');
    });
  });

  it('handles login error', async () => {
    const { result } = renderHook(() => useAuth());

    await act(async () => {
      try {
        await result.current.login({
          email: 'invalid@example.com',
          password: 'wrongpassword',
        });
      } catch (error) {
        // Expected error
      }
    });

    expect(result.current.error).toBeTruthy();
  });
});

五、实战代码示例

5.1 完整的应用模块实现

以下是一个使用 Expo Router 构建的完整功能模块示例,包含用户认证、列表展示和表单处理:

// src/app/(auth)/login.tsx
import { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
  Alert,
} from 'react-native';
import { Link, router } from 'expo-router';
import { useAuthStore } from '@/stores/authStore';
import { useTheme } from '@/hooks/useTheme';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';

export default function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const { login } = useAuthStore();
  const { colors } = useTheme();

  const handleLogin = async () => {
    if (!email || !password) {
      Alert.alert('错误', '请填写所有必填字段');
      return;
    }

    setIsLoading(true);
    try {
      await login({ email, password });
      router.replace('/(tabs)/home');
    } catch (error) {
      Alert.alert(
        '登录失败',
        error instanceof Error ? error.message : '登录时发生错误'
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <KeyboardAvoidingView
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      style={[styles.container, { backgroundColor: colors.background }]}
    >
      <View style={styles.content}>
        <Text style={[styles.title, { color: colors.text }]}>欢迎回来</Text>
        <Text style={[styles.subtitle, { color: colors.textSecondary }]}>
          请登录您的账户继续
        </Text>

        <View style={styles.form}>
          <Input
            label="邮箱"
            placeholder="请输入邮箱地址"
            value={email}
            onChangeText={setEmail}
            keyboardType="email-address"
            autoCapitalize="none"
            autoCorrect={false}
          />

          <Input
            label="密码"
            placeholder="请输入密码"
            value={password}
            onChangeText={setPassword}
            secureTextEntry
          />

          <TouchableOpacity style={styles.forgotPassword}>
            <Link href="/(auth)/forgot-password">
              <Text style={[styles.forgotPasswordText, { color: colors.primary }]}>
                忘记密码?
              </Text>
            </Link>
          </TouchableOpacity>

          <Button
            onPress={handleLogin}
            loading={isLoading}
            disabled={isLoading}
            style={styles.loginButton}
          >
            登录
          </Button>
        </View>

        <View style={styles.footer}>
          <Text style={[styles.footerText, { color: colors.textSecondary }]}>
            还没有账户?
          </Text>
          <Link href="/(auth)/signup" asChild>
            <TouchableOpacity>
              <Text style={[styles.signUpLink, { color: colors.primary }]}>
                立即注册
              </Text>
            </TouchableOpacity>
          </Link>
        </View>
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    justifyContent: 'center',
    paddingHorizontal: 24,
  },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    textAlign: 'center',
    marginBottom: 32,
  },
  form: {
    gap: 16,
  },
  forgotPassword: {
    alignSelf: 'flex-end',
    marginTop: -8,
    marginBottom: 8,
  },
  forgotPasswordText: {
    fontSize: 14,
  },
  loginButton: {
    marginTop: 8,
  },
  footer: {
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 32,
    gap: 4,
  },
  footerText: {
    fontSize: 14,
  },
  signUpLink: {
    fontSize: 14,
    fontWeight: '600',
  },
});
// src/app/(tabs)/products.tsx
import { useCallback, useState } from 'react';
import {
  View,
  FlatList,
  StyleSheet,
  RefreshControl,
  Text,
} from 'react-native';
import { useFocusEffect } from 'expo-router';
import { productService } from '@/services/api/products';
import { useProductStore } from '@/stores/productStore';
import { Product } from '@/types';
import { ProductCard } from '@/components/business/ProductCard';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';

export default function ProductsScreen() {
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [isLoadingMore, setIsLoadingMore] = useState(false);
  const { products, isLoading, fetchProducts, loadMore, hasMore } =
    useProductStore();

  useFocusEffect(
    useCallback(() => {
      fetchProducts();
    }, [])
  );

  const handleRefresh = async () => {
    setIsRefreshing(true);
    await fetchProducts();
    setIsRefreshing(false);
  };

  const handleLoadMore = async () => {
    if (!isLoadingMore && hasMore) {
      setIsLoadingMore(true);
      await loadMore();
      setIsLoadingMore(false);
    }
  };

  const renderItem = useCallback(
    ({ item }: { item: Product }) => (
      <ProductCard product={item} />
    ),
    []
  );

  const renderFooter = () => {
    if (!isLoadingMore) return null;
    return <LoadingSpinner />;
  };

  const renderEmpty = () => {
    if (isLoading) return <LoadingSpinner />;
    return (
      <EmptyState
        title="暂无产品"
        description="暂时没有可显示的产品"
        icon="package"
      />
    );
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={products}
        renderItem={renderItem}
        keyExtractor={(item) => item.id}
        numColumns={2}
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.listContent}
        refreshControl={
          <RefreshControl
            refreshing={isRefreshing}
            onRefresh={handleRefresh}
          />
        }
        onEndReached={handleLoadMore}
        onEndReachedThreshold={0.3}
        ListFooterComponent={renderFooter}
        ListEmptyComponent={renderEmpty}
        showsVerticalScrollIndicator={false}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  listContent: {
    padding: 16,
    flexGrow: 1,
  },
  row: {
    justifyContent: 'space-between',
  },
});

5.2 自定义原生模块开发

当 Expo SDK 提供的功能无法满足需求时,可以通过 Expo Modules 开发自定义原生模块:

// src/native/MyCustomModule.ts
import { NativeModules, Platform } from 'react-native';

// 定义 TypeScript 接口
interface MyCustomModuleInterface {
  getDeviceId(): Promise<string>;
  getSystemInfo(): Promise<{
    model: string;
    systemVersion: string;
    manufacturer: string;
  }>;
  setClipboard(content: string): Promise<void>;
  getClipboard(): Promise<string>;
}

// 获取原生模块
const { MyCustomModule } = NativeModules;

// 包装函数,提供更好的 TypeScript 支持和错误处理
export const MyCustomModuleAPI: MyCustomModuleInterface = {
  async getDeviceId(): Promise<string> {
    if (Platform.OS === 'web') {
      // Web 平台的降级实现
      return `web-${Date.now()}`;
    }
    return MyCustomModule.getDeviceId();
  },

  async getSystemInfo() {
    if (Platform.OS === 'web') {
      return {
        model: 'Web Browser',
        systemVersion: navigator.userAgent,
        manufacturer: 'Unknown',
      };
    }
    return MyCustomModule.getSystemInfo();
  },

  async setClipboard(content: string): Promise<void> {
    if (Platform.OS === 'web') {
      await navigator.clipboard.writeText(content);
      return;
    }
    return MyCustomModule.setClipboard(content);
  },

  async getClipboard(): Promise<string> {
    if (Platform.OS === 'web') {
      return await navigator.clipboard.readText();
    }
    return MyCustomModule.getClipboard();
  },
};
// ios/LocalPods/MyCustomModule/MyCustomModule.swift
import Foundation
import ExpoModulesCore

public class MyCustomModule: Module {
  public func definition() -> ModuleDefinition {
    return ModuleDefinition(name: "MyCustomModule") {
      // 获取设备 ID
      AsyncFunction("getDeviceId") { () -> String in
        return UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
      }

      // 获取系统信息
      AsyncFunction("getSystemInfo") { () -> [String: String] in
        return [
          "model": self.getDeviceModel(),
          "systemVersion": UIDevice.current.systemVersion,
          "manufacturer": "Apple"
        ]
      }

      // 设置剪贴板
      AsyncFunction("setClipboard") { (content: String) in
        UIPasteboard.general.string = content
      }

      // 获取剪贴板
      AsyncFunction("getClipboard") { () -> String? in
        return UIPasteboard.general.string
      }
    }
  }

  private func getDeviceModel() -> String {
    var systemInfo = utsname()
    uname(&systemInfo)
    let machineMirror = Mirror(reflecting: systemInfo.machine)
    let identifier = machineMirror.children.reduce("") { identifier, element in
      guard let value = element.value as? Int8, value != 0 else { return identifier }
      return identifier + String(UnicodeScalar(UInt8(value)))
    }
    return mapToDevice(identifier: identifier)
  }

  private func mapToDevice(identifier: String) -> String {
    switch identifier {
    case "iPhone14,2": return "iPhone 13 Pro"
    case "iPhone14,3": return "iPhone 13 Pro Max"
    case "iPhone14,4": return "iPhone 13 mini"
    case "iPhone14,5": return "iPhone 13"
    default: return identifier
    }
  }
}
// android/app/src/main/java/com/myapp/modules/MyCustomModule.kt
package com.myapp.modules

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.AsyncFunction

class MyCustomModule : Module() {
  override fun definition() = ModuleDefinition("MyCustomModule") {
    // 获取设备 ID
    AsyncFunction("getDeviceId") {
      return@AsyncFunction android.provider.Settings.Secure.getString(
        appContext.contentResolver,
        android.provider.Settings.Secure.ANDROID_ID
      ) ?: "unknown"
    }

    // 获取系统信息
    AsyncFunction("getSystemInfo") {
      return@AsyncFunction mapOf(
        "model" to "${Build.MANUFACTURER} ${Build.MODEL}",
        "systemVersion" to Build.VERSION.RELEASE,
        "manufacturer" to Build.MANUFACTURER
      )
    }

    // 设置剪贴板
    AsyncFunction("setClipboard") { content: String ->
      val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
      val clip = ClipData.newPlainText("label", content)
      clipboard.setPrimaryClip(clip)
    }

    // 获取剪贴板
    AsyncFunction("getClipboard") {
      val clipboard = appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
      return@AsyncFunction clipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
    }
  }
}

六、总结与展望

Expo 作为 React Native 生态中最成熟的开发平台,通过其精心设计的架构和丰富的功能集,为跨平台移动应用开发提供了高效、可靠的解决方案。从托管工作流的零配置开发体验,到原生工作流的完全自定义能力,Expo 为不同阶段的团队提供了灵活的选择。

在架构层面,Expo 采用的分层设计和 JSI 集成确保了与现代 React Native 新架构的兼容性,同时保证了良好的性能表现。EAS 服务的引入使得构建和部署流程工业化,大大提升了团队的开发效率。热更新能力则赋予了应用快速迭代的能力,让开发者能够在不依赖应用商店审核的情况下即时修复问题。

对于工程化实践,Expo 项目应该遵循良好的代码组织规范,建立完善的状态管理和 API 层设计,实施严格的测试策略,并妥善管理敏感信息。这些实践不仅提升了代码质量,也为项目的长期维护奠定了基础。

展望未来,Expo 正在向更广阔的平台扩展,除了 iOS、Android 和 Web 之外,还在探索 Vision Pro、TV 和车载系统等新平台的支持。随着 AI 技术的快速发展,Expo 也在积极集成智能化开发工具,如基于大语言模型的代码生成、错误诊断和构建优化等功能。可以预见,Expo 将继续引领跨平台开发工具的发展,为开发者创造更大的价值。