Chapter 06

原生模块与 Expo SDK

相机、位置、推送通知——调用设备硬件和系统能力的完整指南,以及新架构 JSI 的通信原理。

什么情况需要原生模块

React Native 的 JS 层无法直接访问设备硬件(相机、麦克风、蓝牙、NFC)和系统能力(推送通知、健康数据、支付)。这些功能需要通过原生模块暴露给 JS 层调用。

好消息是:你需要的大多数原生能力,Expo SDK 或社区库已经实现好了。自己写原生模块的场景越来越少,主要是:集成特定厂商的原生 SDK(如某银行的支付 SDK、某地图 SDK)、使用尚未有 JS 封装的系统 API、对性能要求极高的场景(需要在 Native 层处理大量数据)。

核心名词解释

Expo SDK
Expo 官方提供的一套原生模块集合,封装了几十个常用的设备能力 API,包括相机、位置、文件系统、推送通知、传感器、日历等。使用 npx expo install 安装,自动匹配兼容版本。
expo-camera
相机模块,提供预览、拍照、录像功能。新版(v14+)基于 Camera2 API(Android)和 AVFoundation(iOS),支持二维码扫描、面部检测等。
expo-location
位置服务,支持前台/后台定位、地理围栏、持续位置更新。需要在 app.json 中声明权限,iOS 还需在 Info.plist 说明使用原因。
expo-notifications
推送通知模块,支持本地通知和远程推送。获取 Expo Push Token,通过 Expo 的推送服务转发到 APNs(iOS)和 FCM(Android)。
Native Module(原生模块)
用 Objective-C/Swift(iOS)或 Java/Kotlin(Android)编写,通过 Bridge 或 JSI 暴露给 JS 调用的模块。
JSI(JavaScript Interface)
新架构的 JS-Native 通信层,JS 引擎直接持有 C++ 对象引用,调用原生方法无需 JSON 序列化,实现同步调用,延迟接近零。
Turbo Module
基于 JSI 实现的新一代原生模块。与旧的 Native Module 的区别:懒加载(按需初始化,启动更快)、类型安全(通过 Codegen 自动生成 C++ 接口)、支持同步调用。
Codegen
新架构的代码生成工具,根据 TypeScript/Flow 类型定义自动生成 C++ 胶水代码,确保 JS 和 Native 层的类型安全,消除接口不一致导致的崩溃。

原生通信架构(旧 vs 新)

旧架构:Bridge(异步,有序列化开销) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ JS Thread Bridge Native │ │ │ │─── JSON 消息 ────────▶│ │ │ {module:"Camera", │ │ │ method:"takePic"} │ │ │ │──── 反序列化 ──────▶│ │ │ │ 执行原生代码 │ │◀─── JSON 结果 ─────│ │◀─── 回调 ────────────│ │ │ │ │ 新架构:JSI + Turbo Module(同步,无序列化) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ JS Thread C++ Layer Native │ │ │ │ camera.takePic() │ │ │──── 直接 C++ 调用 ──▶│ │ │ (同步,无队列) │──── C++ 调用 ─────▶│ │ │ │ 执行原生代码 │◀──── 同步返回 ────────│◀─── 返回值 ────────│ │ │ │ 优势: • 无 JSON 序列化,性能提升 ~3-5x • 支持同步调用(旧架构只能异步) • 懒加载,启动时间减少

推送通知完整流程

推送通知是移动应用最复杂的功能之一,涉及多个系统和第三方服务的协作。以下是从申请权限到用户点击通知的完整链路:

// hooks/useNotifications.ts
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { useEffect, useRef } from 'react';

// 通知显示方式(前台时也显示)
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotifications(): Promise<string | null> {
  // 1. 检查并申请权限
  const { status: existingStatus } =
    await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('推送通知权限被拒绝');
    return null;
  }

  // 2. Android 需要创建通知渠道
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
    });
  }

  // 3. 获取 Expo Push Token(后端用此 token 发送通知)
  const { data: pushToken } = await Notifications.getExpoPushTokenAsync({
    projectId: 'your-expo-project-id', // app.json 中的 projectId
  });

  return pushToken; // 发给后端保存
}

export function useNotificationObserver() {
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    // 监听用户点击通知(App 在后台或关闭状态)
    responseListener.current =
      Notifications.addNotificationResponseReceivedListener(response => {
        const { postId } = response.notification.request.content.data;
        if (postId) {
          // 跳转到对应帖子页面(深度链接)
          navigation.navigate('PostDetail', { id: postId });
        }
      });

    return () => {
      if (responseListener.current) {
        Notifications.removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);
}

相机拍照与上传

import * as ImagePicker from 'expo-image-picker';
import { api } from '../lib/api';

export async function pickAndUploadImage(): Promise<string | null> {
  // 1. 申请相册权限
  const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
  if (status !== 'granted') return null;

  // 2. 打开图片选择器
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ImagePicker.MediaTypeOptions.Images,
    allowsEditing: true,
    aspect: [4, 3],
    quality: 0.8,           // 压缩到 80% 质量
  });

  if (result.canceled) return null;

  // 3. 获取预签名上传 URL(安全做法,避免前端持有 S3 密钥)
  const { uploadUrl, publicUrl } = await api.post('/upload/presign', {
    filename: result.assets[0].fileName,
    contentType: 'image/jpeg',
  });

  // 4. 直传 S3(绕过后端,减轻服务器压力)
  const imageData = await fetch(result.assets[0].uri);
  const blob = await imageData.blob();

  await fetch(uploadUrl, {
    method: 'PUT',
    body: blob,
    headers: { 'Content-Type': 'image/jpeg' },
  });

  return publicUrl; // CDN 访问地址
}

定位服务

import * as Location from 'expo-location';

export async function getCurrentLocation() {
  // 1. 申请前台定位权限
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== 'granted') {
    throw new Error('位置权限被拒绝');
  }

  // 2. 获取当前位置(精度 vs 速度 权衡)
  const location = await Location.getCurrentPositionAsync({
    accuracy: Location.Accuracy.Balanced, // High/Low/Balanced
  });

  return {
    lat: location.coords.latitude,
    lon: location.coords.longitude,
  };
}

// 持续位置更新(实时跟踪场景)
export async function startTracking(
  callback: (loc: { lat: number; lon: number }) => void
) {
  // 注意:持续定位需要后台定位权限(iOS 更严格)
  const subscription = await Location.watchPositionAsync(
    { accuracy: Location.Accuracy.High, timeInterval: 5000 },
    (loc) => callback({ lat: loc.coords.latitude, lon: loc.coords.longitude })
  );

  return subscription; // 调用 subscription.remove() 停止跟踪
}
iOS 后台定位限制 iOS 对后台定位权限审查极为严格(NSLocationAlwaysAndWhenInUseUsageDescription)。App Store 要求你明确说明"后台定位的必要性",且不能用于广告追踪。如非必要(如运动追踪、导航类 App),只申请前台定位权限(NSLocationWhenInUseUsageDescription)。
优先用 Expo SDK 在决定自己写原生模块之前,先查看 Expo SDK 文档(docs.expo.dev)和 React Native Directory(reactnative.directory)。90% 的常见需求都有现成的库。自己写原生模块意味着需要维护 iOS 和 Android 两套代码,升级 RN 版本时也要同步适配,成本很高。
权限说明文字要诚实 iOS 审核会严格检查权限申请的说明文字(NSCameraUsageDescription 等)。说明必须清楚告诉用户为什么需要这个权限,且与实际功能一致。模糊或欺骗性的说明会导致应用被拒绝。Android 权限在 app.json 的 android.permissions 数组中声明,并在 Managed Workflow 下由 Expo 自动写入 AndroidManifest.xml。
Expo SDK 常用模块速查 expo-camera(相机拍照/录像/扫码)、expo-location(GPS 定位)、expo-notifications(推送通知)、expo-image-picker(相册选图)、expo-file-system(本地文件读写)、expo-sensors(加速度计/陀螺仪)、expo-av(音频/视频播放)、expo-barcode-scanner(条码扫描)。所有模块都可用 npx expo install 模块名 安装,自动选择与当前 Expo SDK 版本兼容的版本号。
本章小结

原生模块与设备能力的核心要点:① Expo SDK 已封装大量设备 API(相机、定位、推送通知、传感器),优先使用现成库,减少维护 iOS/Android 双端代码的成本;② 相机和定位权限必须在 app.json 中声明(iOS 的 NSCameraUsageDescription 等),且运行时调用 requestPermissionsAsync() 二次申请,未授权时优雅降级而非崩溃;③ 推送通知需要先获取 Expo Push Token,通过服务器端 Expo Push API 发送;FCM/APNs 凭证分平台独立配置;④ expo-image-picker 选择图片后得到本地 URI,上传到服务器时用 FormData 包装,或通过预签名 URL 直传 S3;⑤ 自定义原生模块(Turbo Module)需要用 C++ 或平台语言实现接口,通过 JSI 桥接调用,适合性能敏感或平台专属功能;⑥ Bare Workflow 才能安装任意原生模块;Managed Workflow 只能使用 Expo 预配置的原生能力。