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 访问地址
}
优先用 Expo SDK 在决定自己写原生模块之前,先查看 Expo SDK 文档(docs.expo.dev)和 React Native Directory(reactnative.directory)。90% 的常见需求都有现成的库。自己写原生模块意味着需要维护 iOS 和 Android 两套代码,升级 RN 版本时也要同步适配,成本很高。
权限说明文字要诚实 iOS 审核会严格检查权限申请的说明文字(NSCameraUsageDescription 等)。说明必须清楚告诉用户为什么需要这个权限,且与实际功能一致。模糊或欺骗性的说明会导致应用被拒绝。